feat: implement Live/Remix filtering and add CI/CD pipeline
Some checks failed
Build and Push Docker Image / build (pull_request) Failing after 38s
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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user