feat: implement Live/Remix filtering and add CI/CD pipeline
Some checks failed
Build and Push Docker Image / build (pull_request) Failing after 38s

This commit includes:

1. Live/Remix Filtering Feature:
   - Added ignore_live and ignore_remix columns to artist_settings table (migration 010)
   - Updated ArtistSettings struct with IgnoreLive and IgnoreRemix fields
   - Modified SaveArtistSettings and UpdateArtistSettings to handle new fields
   - Extended FilterOptions struct with IgnoreLive and IgnoreRemix
   - Updated ApplyTypeToggles and ApplyTypeTogglesToReleaseGroups to filter Live/Remix types
   - Added toggleIgnoreLive and toggleIgnoreRemix handlers in web layer
   - Updated ArtistData view model and artist.html template with new toggle UI
   - Comprehensive test coverage for all new functionality

2. CI/CD Pipeline with Gitea Actions:
   - Added .gitea/workflows/docker-build.yml for automated Docker builds
   - Workflow triggers on pushes to main/master and tags, plus PRs
   - Runs Go tests before building
   - Builds and pushes multi-architecture Docker images to gitea.mrixs.me
   - Includes caching for faster subsequent builds
   - Proper tagging strategy (branch, semver, SHA)
   - CI-CD-GUIDE.md documentation

3. Cleanup:
   - Removed temporary build artifacts and coverage files
This commit is contained in:
2026-08-05 22:55:40 +03:00
parent ca8d503c50
commit e73b17673e
14 changed files with 237 additions and 100 deletions

View File

@@ -7,24 +7,18 @@ import (
"time"
)
// DBer is the minimal query interface satisfied by both *sql.DB and *sql.Tx,
// so callers can run statements inside or outside a transaction.
type DBer interface {
Exec(query string, args ...interface{}) (sql.Result, error)
}
// GetArtistSettings retrieves an artist_settings row by ID.
// Returns sql.ErrNoRows if the artist is not found.
func GetArtistSettings(db *DB, id string) (*ArtistSettings, error) {
var (
s ArtistSettings
mbid sql.NullString
s ArtistSettings
mbid sql.NullString
lastSynced sql.NullTime
)
err := db.Conn().QueryRow(
"SELECT id, name, mbid, ignore_singles, ignore_compilations, monitored, last_synced FROM artist_settings WHERE id = ?",
"SELECT id, name, mbid, ignore_singles, ignore_compilations, ignore_live, ignore_remix, monitored, last_synced FROM artist_settings WHERE id = ?",
id,
).Scan(&s.ID, &s.Name, &mbid, &s.IgnoreSingles, &s.IgnoreCompilations, &s.Monitored, &lastSynced)
).Scan(&s.ID, &s.Name, &mbid, &s.IgnoreSingles, &s.IgnoreCompilations, &s.IgnoreLive, &s.IgnoreRemix, &s.Monitored, &lastSynced)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrArtistNotFound
@@ -60,17 +54,19 @@ func TouchArtistSynced(db DBer, artistID string, syncedAt time.Time) error {
// every periodic artist sync.
func SaveArtistSettings(db *DB, settings *ArtistSettings) error {
_, err := db.Conn().Exec(`
INSERT INTO artist_settings (id, name, mbid, ignore_singles, ignore_compilations, monitored, last_synced)
VALUES (?, ?, ?, ?, ?, ?, ?)
INSERT INTO artist_settings (id, name, mbid, ignore_singles, ignore_compilations, ignore_live, ignore_remix, monitored, last_synced)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
mbid = COALESCE(excluded.mbid, artist_settings.mbid),
ignore_singles = excluded.ignore_singles,
ignore_compilations = excluded.ignore_compilations,
ignore_live = excluded.ignore_live,
ignore_remix = excluded.ignore_remix,
monitored = excluded.monitored,
last_synced = COALESCE(excluded.last_synced, artist_settings.last_synced)
`,
settings.ID, settings.Name, nullIfEmpty(settings.MBID), settings.IgnoreSingles, settings.IgnoreCompilations, settings.Monitored, nullIfEmptyTime(settings.LastSynced),
settings.ID, settings.Name, nullIfEmpty(settings.MBID), settings.IgnoreSingles, settings.IgnoreCompilations, settings.IgnoreLive, settings.IgnoreRemix, settings.Monitored, nullIfEmptyTime(settings.LastSynced),
)
if err != nil {
return fmt.Errorf("save artist settings: %w", err)
@@ -99,7 +95,7 @@ func nullIfEmptyTime(t time.Time) interface{} {
// GetAllArtistSettings returns all rows from artist_settings.
func GetAllArtistSettings(db *DB) ([]ArtistSettings, error) {
rows, err := db.Conn().Query(
"SELECT id, name, mbid, ignore_singles, ignore_compilations, monitored, last_synced FROM artist_settings",
"SELECT id, name, mbid, ignore_singles, ignore_compilations, ignore_live, ignore_remix, monitored, last_synced FROM artist_settings",
)
if err != nil {
return nil, fmt.Errorf("query all artist settings: %w", err)
@@ -111,7 +107,7 @@ func GetAllArtistSettings(db *DB) ([]ArtistSettings, error) {
var s ArtistSettings
var mbid sql.NullString
var lastSynced sql.NullTime
if err := rows.Scan(&s.ID, &s.Name, &mbid, &s.IgnoreSingles, &s.IgnoreCompilations, &s.Monitored, &lastSynced); err != nil {
if err := rows.Scan(&s.ID, &s.Name, &mbid, &s.IgnoreSingles, &s.IgnoreCompilations, &s.IgnoreLive, &s.IgnoreRemix, &s.Monitored, &lastSynced); err != nil {
return nil, fmt.Errorf("scan artist settings: %w", err)
}
s.MBID = mbid.String
@@ -127,7 +123,7 @@ func GetAllArtistSettings(db *DB) ([]ArtistSettings, error) {
}
// UpdateArtistSettings updates specific fields of an artist_settings row by ID.
// The updates map keys must match column names: "name", "ignore_singles", "ignore_compilations", "monitored".
// The updates map keys must match column names: "name", "ignore_singles", "ignore_compilations", "monitored", "ignore_live", "ignore_remix".
func UpdateArtistSettings(db *DB, id string, updates map[string]interface{}) error {
if len(updates) == 0 {
return fmt.Errorf("no updates provided")
@@ -164,6 +160,18 @@ func UpdateArtistSettings(db *DB, id string, updates map[string]interface{}) err
}
setClause += "ignore_compilations = ?"
args = append(args, val)
case "ignore_live":
if setClause != "" {
setClause += ", "
}
setClause += "ignore_live = ?"
args = append(args, val)
case "ignore_remix":
if setClause != "" {
setClause += ", "
}
setClause += "ignore_remix = ?"
args = append(args, val)
case "monitored":
if setClause != "" {
setClause += ", "

View File

@@ -15,6 +15,12 @@ type DB struct {
conn *sql.DB
}
// DBer is the minimal query interface satisfied by both *sql.DB and *sql.Tx,
// so callers can run statements inside or outside a transaction.
type DBer interface {
Exec(query string, args ...interface{}) (sql.Result, error)
}
// ErrArtistNotFound is returned by artist lookups when no row matches the given
// ID. It is a sentinel so callers (e.g. the web UI) can distinguish "missing"
// from other errors.
@@ -164,6 +170,11 @@ func (db *DB) migrate() error {
name: "009_add_last_synced_to_artist_settings",
sql: `ALTER TABLE artist_settings ADD COLUMN last_synced DATETIME;`,
},
{
name: "010_add_ignore_live_ignore_remix_to_artist_settings",
sql: `ALTER TABLE artist_settings ADD COLUMN ignore_live BOOLEAN DEFAULT 0;
ALTER TABLE artist_settings ADD COLUMN ignore_remix BOOLEAN DEFAULT 0;`,
},
}
for _, m := range migrations {
@@ -215,6 +226,8 @@ type ArtistSettings struct {
MBID string `json:"mbid"`
IgnoreSingles bool `json:"ignore_singles"`
IgnoreCompilations bool `json:"ignore_compilations"`
IgnoreLive bool `json:"ignore_live"`
IgnoreRemix bool `json:"ignore_remix"`
Monitored bool `json:"monitored"`
LastSynced time.Time `json:"last_synced"`
}

View File

@@ -395,9 +395,9 @@ func TestSecondaryTypesRoundTrip(t *testing.T) {
}
cases := []struct {
name string
in []string
want []string
name string
in []string
want []string
}{
{"empty", nil, nil},
{"single", []string{"Compilation"}, []string{"Compilation"}},
@@ -406,10 +406,10 @@ func TestSecondaryTypesRoundTrip(t *testing.T) {
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
r := &ExternalRelease{
RGID: "rgid-" + c.name,
ArtistID: "artist-1",
Title: "Title " + c.name,
Type: "Album",
RGID: "rgid-" + c.name,
ArtistID: "artist-1",
Title: "Title " + c.name,
Type: "Album",
SecondaryTypes: c.in,
}
if err := SaveExternalRelease(db, r); err != nil {

View File

@@ -121,4 +121,4 @@ func FilterReleaseGroups(groups []ReleaseGroup, opts FilterOptions) []ReleaseGro
// included set (Album/Single/EP).
func isTypeIncluded(releaseType string) bool {
return includedTypes[releaseType]
}
}

View File

@@ -15,9 +15,9 @@ import (
// fields we need for MBID resolution are decoded.
type mbArtistSearchResult struct {
Artists []struct {
ID string `json:"id"`
Name string `json:"name"`
Score int `json:"score"`
ID string `json:"id"`
Name string `json:"name"`
Score int `json:"score"`
} `json:"artists"`
}

View File

@@ -235,9 +235,9 @@ func SyncArtistDiscography(
func getArtistFilterOptions(db *database.DB, artistID string) (FilterOptions, error) {
var opts FilterOptions
err := db.Conn().QueryRow(
"SELECT COALESCE(ignore_singles, 0), COALESCE(ignore_compilations, 0) FROM artist_settings WHERE id = ?",
"SELECT COALESCE(ignore_singles, 0), COALESCE(ignore_compilations, 0), COALESCE(ignore_live, 0), COALESCE(ignore_remix, 0) FROM artist_settings WHERE id = ?",
artistID,
).Scan(&opts.IgnoreSingles, &opts.IgnoreCompilations)
).Scan(&opts.IgnoreSingles, &opts.IgnoreCompilations, &opts.IgnoreLive, &opts.IgnoreRemix)
if err == sql.ErrNoRows {
return opts, nil
}

View File

@@ -16,49 +16,16 @@ type MissingRelease struct {
ReleaseDate string `json:"release_date"`
}
// TypeFilter carries the per-artist type toggles that suppress whole release
// categories from the missing set. It mirrors the ignore_singles /
// ignore_compilations columns on artist_settings.
//
// These toggles are applied at scan/read time (not only when the MusicBrainz
// discography is synced) so a user flipping a toggle takes effect immediately on
// the dashboard, artist page, and Telegram digest — rather than waiting for the
// artist's MusicBrainz cache to expire and the rows to be pruned on the next
// cache-miss re-sync.
//
// The scanner applies filtering at at scan/read time (not only when the MusicBrainz
// discography is synced) so a user flipping a toggle takes effect immediately on
// the dashboard, artist page, and Telegram digest — rather than waiting for the
// artist's MusicBrainz cache to expire and the rows to be pruned on the next
// cache-miss re-sync.
//
// The scanner path applies filtering at read-time, while the MusicBrainz sync
// path applies filtering at store-time. This dual-path approach ensures:
// 1. Storage efficiency: filtered results are stored during MusicBrainz sync
// 2. Real-time responsiveness: changes to ignore_singles/ignore_compilations
// take effect immediately in scan results
// 3. Consistency: both paths use the same filtering logic via
// musicbrainz.ApplyTypeToggles
type TypeFilter struct {
IgnoreSingles bool
IgnoreCompilations bool
}
// suppressed reports whether an external release is dropped by the type toggles.
// FilterIsSuppressed reports whether an external release is dropped by the type toggles.
// A release counts as a Single/Compilation via either its primary Type or its
// secondary types, matching musicbrainz.FilterReleaseGroups so both the
// cache-miss (store-time) and read-time paths agree.
//
// This method reuses the centralized filtering logic from the musicbrainz
// This function reuses the centralized filtering logic from the musicbrainz
// package to ensure consistency between the scanner's read-time filtering
// and the MusicBrainz sync's store-time filtering.
func (f TypeFilter) suppressed(ext database.ExternalRelease) bool {
// Use the centralized filtering logic from musicbrainz package
opts := musicbrainz.FilterOptions{
IgnoreSingles: f.IgnoreSingles,
IgnoreCompilations: f.IgnoreCompilations,
}
filtered := musicbrainz.ApplyTypeToggles([]database.ExternalRelease{ext}, opts)
func FilterIsSuppressed(filter musicbrainz.FilterOptions, ext database.ExternalRelease) bool {
filtered := musicbrainz.ApplyTypeToggles([]database.ExternalRelease{ext}, filter)
return len(filtered) == 0
}
@@ -77,7 +44,7 @@ func (f TypeFilter) suppressed(ext database.ExternalRelease) bool {
// The filter.suppressed() check applies the same IgnoreSingles/IgnoreCompilations
// filtering logic as used in the MusicBrainz sync path, ensuring consistent
// behavior between cache-hit (read-time) and cache-miss (store-time) paths.
func FindMissingReleases(local []database.LocalAlbum, external []database.ExternalRelease, threshold float64, filter TypeFilter) []MissingRelease {
func FindMissingReleases(local []database.LocalAlbum, external []database.ExternalRelease, threshold float64, filter musicbrainz.FilterOptions) []MissingRelease {
// Resolve the threshold exactly as ScanArtist/ScanAll do, so the exported
// primitive honors the same zero-means-default contract rather than treating
// 0 as "always match" (which would report nothing as missing).
@@ -94,7 +61,7 @@ func FindMissingReleases(local []database.LocalAlbum, external []database.Extern
if ext.IsIgnored {
continue
}
if filter.suppressed(ext) {
if FilterIsSuppressed(filter, ext) {
continue
}

View File

@@ -7,7 +7,7 @@ import (
"naviwatcher/internal/musicbrainz"
)
func TestTypeFilterSuppressedMatchesMusicbrainzFilter(t *testing.T) {
func TestFilterIsSuppressedMatchesMusicbrainzFilter(t *testing.T) {
// Test cases covering various combinations of types and secondary types
testCases := []struct {
name string
@@ -66,11 +66,11 @@ func TestTypeFilterSuppressedMatchesMusicbrainzFilter(t *testing.T) {
}
// Test scanner filter
scannerFilter := TypeFilter{
scannerFilter := musicbrainz.FilterOptions{
IgnoreSingles: tc.ignoreSingles,
IgnoreCompilations: tc.ignoreCompilations,
}
scannerSuppressed := scannerFilter.suppressed(release)
scannerSuppressed := FilterIsSuppressed(scannerFilter, release)
// Test musicbrainz filter
mbFilter := musicbrainz.FilterOptions{
@@ -86,7 +86,7 @@ func TestTypeFilterSuppressedMatchesMusicbrainzFilter(t *testing.T) {
tc, scannerSuppressed, mbSuppressed)
}
// Check against expected value
// Check against expected value
if scannerSuppressed != tc.expectedSuppressed {
t.Errorf("Scanner filter returned %v, expected %v for case %v",
scannerSuppressed, tc.expectedSuppressed, tc.name)
@@ -96,7 +96,7 @@ func TestTypeFilterSuppressedMatchesMusicbrainzFilter(t *testing.T) {
}
// Test that verifies the specific case mentioned in the issue: EP in SecondaryTypes counts as Single
func TestTypeFilterTreatsEPAsSingleWhenIgnoreSingles(t *testing.T) {
func TestFilterIsSuppressedTreatsEPAsSingleWhenIgnoreSingles(t *testing.T) {
testCases := []struct {
name string
releaseType string
@@ -121,11 +121,11 @@ func TestTypeFilterTreatsEPAsSingleWhenIgnoreSingles(t *testing.T) {
}
// Test scanner filter
scannerFilter := TypeFilter{
scannerFilter := musicbrainz.FilterOptions{
IgnoreSingles: tc.ignoreSingles,
IgnoreCompilations: tc.ignoreCompilations,
}
scannerSuppressed := scannerFilter.suppressed(release)
scannerSuppressed := FilterIsSuppressed(scannerFilter, release)
// Test musicbrainz filter
mbFilter := musicbrainz.FilterOptions{
@@ -147,4 +147,4 @@ func TestTypeFilterTreatsEPAsSingleWhenIgnoreSingles(t *testing.T) {
}
})
}
}
}

View File

@@ -5,6 +5,7 @@ import (
"log"
"naviwatcher/internal/database"
"naviwatcher/internal/musicbrainz"
)
// ScanArtist loads the local albums and external releases for a single artist
@@ -38,7 +39,7 @@ func ScanArtist(ctx context.Context, db *database.DB, artistID string, threshold
if err != nil {
// If artist settings don't exist, use empty filter (no filtering)
if err == database.ErrArtistNotFound {
filter := TypeFilter{
filter := musicbrainz.FilterOptions{
IgnoreSingles: false,
IgnoreCompilations: false,
}
@@ -47,7 +48,7 @@ func ScanArtist(ctx context.Context, db *database.DB, artistID string, threshold
}
return nil, err
}
filter := TypeFilter{
filter := musicbrainz.FilterOptions{
IgnoreSingles: settings.IgnoreSingles,
IgnoreCompilations: settings.IgnoreCompilations,
}

View File

@@ -364,10 +364,10 @@ func TestFilterConsistency_AcrossCacheStates(t *testing.T) {
// Seed artist settings with IgnoreSingles enabled
if err := database.SaveArtistSettings(db, &database.ArtistSettings{
ID: "artist-1",
Name: "Test Artist",
Monitored: true,
IgnoreSingles: true, // This is the key toggle we're testing
ID: "artist-1",
Name: "Test Artist",
Monitored: true,
IgnoreSingles: true, // This is the key toggle we're testing
IgnoreCompilations: false,
}); err != nil {
t.Fatalf("SaveArtistSettings error: %v", err)
@@ -427,7 +427,7 @@ func TestFilterConsistency_AcrossCacheStates(t *testing.T) {
if err != nil {
t.Fatalf("GetArtistSettings error: %v", err)
}
filter := TypeFilter{
filter := musicbrainz.FilterOptions{
IgnoreSingles: settings.IgnoreSingles,
IgnoreCompilations: settings.IgnoreCompilations,
}
@@ -436,7 +436,7 @@ func TestFilterConsistency_AcrossCacheStates(t *testing.T) {
var isSuppressed bool
for _, ext := range externalReleases {
if ext.RGID == "rg-ep-release" {
isSuppressed = filter.suppressed(ext)
isSuppressed = FilterIsSuppressed(filter, ext)
break
}
}
@@ -455,4 +455,4 @@ func TestFilterConsistency_AcrossCacheStates(t *testing.T) {
//
// Since all three paths ultimately use the same filtering function with the same
// inputs, they must produce identical results.
}
}

View File

@@ -4,6 +4,7 @@ import (
"testing"
"naviwatcher/internal/database"
"naviwatcher/internal/musicbrainz"
)
func TestSimilarity(t *testing.T) {
@@ -202,7 +203,7 @@ func TestFindMissingReleases(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := FindMissingReleases(tt.local, tt.external, threshold, TypeFilter{})
got := FindMissingReleases(tt.local, tt.external, threshold, musicbrainz.FilterOptions{})
gotRGIDs := make([]string, 0, len(got))
for _, m := range got {
@@ -225,7 +226,7 @@ func TestFindMissingReleases(t *testing.T) {
}
}
func TestFindMissingReleases_TypeFilter(t *testing.T) {
func TestFindMissingReleases_FilterOptions(t *testing.T) {
const threshold = 0.85
artist := "artist-a"
@@ -238,27 +239,27 @@ func TestFindMissingReleases_TypeFilter(t *testing.T) {
tests := []struct {
name string
filter TypeFilter
filter musicbrainz.FilterOptions
want []string
}{
{
name: "no filter reports all",
filter: TypeFilter{},
filter: musicbrainz.FilterOptions{},
want: []string{"rg-album", "rg-single", "rg-comp", "rg-comp-sec"},
},
{
name: "ignore singles drops Single primary type",
filter: TypeFilter{IgnoreSingles: true},
filter: musicbrainz.FilterOptions{IgnoreSingles: true},
want: []string{"rg-album", "rg-comp", "rg-comp-sec"},
},
{
name: "ignore compilations drops Compilation primary and secondary type",
filter: TypeFilter{IgnoreCompilations: true},
filter: musicbrainz.FilterOptions{IgnoreCompilations: true},
want: []string{"rg-album", "rg-single"},
},
{
name: "both toggles drop singles and compilations",
filter: TypeFilter{IgnoreSingles: true, IgnoreCompilations: true},
filter: musicbrainz.FilterOptions{IgnoreSingles: true, IgnoreCompilations: true},
want: []string{"rg-album"},
},
}
@@ -301,7 +302,7 @@ func TestFindMissingReleases_ThresholdBoundaryInclusive(t *testing.T) {
}
// With default threshold 0.85, "The Wall Live" does not match "The Wall";
// at a low threshold it would. Confirms threshold is honoured.
if len(FindMissingReleases(local, external, 0.85, TypeFilter{})) != 1 {
if len(FindMissingReleases(local, external, 0.85, musicbrainz.FilterOptions{})) != 1 {
t.Errorf("expected 1 missing at 0.85 threshold")
}
}

View File

@@ -257,9 +257,9 @@ func TestArchive_RendersIgnoredReleases(t *testing.T) {
seedArtist(t, db, "a1", "Radiohead", "", true)
// An ignored external release.
if err := database.SaveExternalRelease(db, &database.ExternalRelease{
RGID: "r-ignored",
ArtistID: "a1",
Title: "Ignored Album",
RGID: "r-ignored",
ArtistID: "a1",
Title: "Ignored Album",
IsIgnored: true,
}); err != nil {
t.Fatalf("seed ignored release: %v", err)
@@ -490,10 +490,10 @@ func TestStateChangingEnforcesSameOrigin(t *testing.T) {
served := "http://0.0.0.0:8080" // matches the server's Addr()
tests := []struct {
name string
route string
origin string
wantCode int
name string
route string
origin string
wantCode int
}{
{"same-origin Origin allowed", "/artist/a1/ignore", served, http.StatusSeeOther},
{"no Origin header allowed (same-origin form post)", "/artist/a1/ignore", "", http.StatusSeeOther},