fix: address code review findings
- Honor ignore_singles/ignore_compilations at scanner read time so toggles take effect immediately on the dashboard, artist page, and digest instead of waiting for the MusicBrainz cache to expire and prune rows. - Run notifier notify synchronously in the scheduler loop to avoid overlapping read-send-mark runs double-sending the digest. - Show artist name (with ID fallback) on the archive page instead of raw IDs. - Select last_synced in GetAllArtistSettings for contract consistency. - Fix stale startPeriodicSync comment and remove redundant error var. - Remove dead ignored-branch from the artist template (never rendered). - Add tests: CSRF sameOrigin, ArtistCacheFresh, secondary_types round-trip, and scanner type-toggle filtering. - Update Specification.md schema/config to reflect mbid, last_synced, secondary_types, sync.interval, and server.public_url.
This commit is contained in:
@@ -15,16 +15,56 @@ 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.
|
||||
type TypeFilter struct {
|
||||
IgnoreSingles bool
|
||||
IgnoreCompilations bool
|
||||
}
|
||||
|
||||
// suppressed 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.
|
||||
func (f TypeFilter) suppressed(ext database.ExternalRelease) bool {
|
||||
if f.IgnoreSingles && (ext.Type == "Single" || hasType(ext.SecondaryTypes, "Single")) {
|
||||
return true
|
||||
}
|
||||
if f.IgnoreCompilations && (ext.Type == "Compilation" || hasType(ext.SecondaryTypes, "Compilation")) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// hasType reports whether types contains want.
|
||||
func hasType(types []string, want string) bool {
|
||||
for _, t := range types {
|
||||
if t == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// FindMissingReleases compares an artist's external discography against the
|
||||
// user's local albums and returns the releases that are present externally but
|
||||
// have no sufficiently similar local album.
|
||||
//
|
||||
// Rules:
|
||||
// - External releases flagged IsIgnored are never reported.
|
||||
// - External releases suppressed by the per-artist type toggles (filter) are
|
||||
// never reported.
|
||||
// - A local album only matches an external release for the same ArtistID.
|
||||
// - An external release is "missing" when none of the local albums (same
|
||||
// ArtistID) IsMatch at the given threshold.
|
||||
func FindMissingReleases(local []database.LocalAlbum, external []database.ExternalRelease, threshold float64) []MissingRelease {
|
||||
func FindMissingReleases(local []database.LocalAlbum, external []database.ExternalRelease, threshold float64, filter TypeFilter) []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).
|
||||
@@ -41,6 +81,9 @@ func FindMissingReleases(local []database.LocalAlbum, external []database.Extern
|
||||
if ext.IsIgnored {
|
||||
continue
|
||||
}
|
||||
if filter.suppressed(ext) {
|
||||
continue
|
||||
}
|
||||
|
||||
albums := localByArtist[ext.ArtistID]
|
||||
matched := false
|
||||
|
||||
@@ -28,7 +28,19 @@ func ScanArtist(ctx context.Context, db *database.DB, artistID string, threshold
|
||||
return nil, err
|
||||
}
|
||||
|
||||
missing := FindMissingReleases(local, external, threshold)
|
||||
// Apply the artist's type toggles at read time so ignore_singles /
|
||||
// ignore_compilations changes take effect immediately, without waiting for
|
||||
// the MusicBrainz cache to expire and prune rows on the next re-sync.
|
||||
settings, err := database.GetArtistSettings(db, artistID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
filter := TypeFilter{
|
||||
IgnoreSingles: settings.IgnoreSingles,
|
||||
IgnoreCompilations: settings.IgnoreCompilations,
|
||||
}
|
||||
|
||||
missing := FindMissingReleases(local, external, threshold, filter)
|
||||
return missing, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -246,3 +246,61 @@ func seedArtistUnmonitored(t *testing.T, db *database.DB, id, name string) {
|
||||
t.Fatalf("seedArtistUnmonitored(%s) error: %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestScanArtist_TypeToggle verifies that ScanArtist honors the artist's
|
||||
// ignore_singles / ignore_compilations toggles at read time, so a toggled
|
||||
// artist stops reporting those categories as missing immediately (without
|
||||
// waiting for the MusicBrainz cache to expire).
|
||||
func TestScanArtist_TypeToggle(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
seedArtist(t, db, "a1", "Artist")
|
||||
seedExternalRelease(t, db, "rg-album", "a1", "Album", false)
|
||||
seedExternalRelease(t, db, "rg-single", "a1", "Single", false)
|
||||
// seedExternalRelease leaves Type empty; set the primary type that the
|
||||
// toggle filtering keys on.
|
||||
if _, err := db.Conn().Exec("UPDATE external_releases SET type = ? WHERE rgid = ?", "Album", "rg-album"); err != nil {
|
||||
t.Fatalf("set album type: %v", err)
|
||||
}
|
||||
if _, err := db.Conn().Exec("UPDATE external_releases SET type = ? WHERE rgid = ?", "Single", "rg-single"); err != nil {
|
||||
t.Fatalf("set single type: %v", err)
|
||||
}
|
||||
|
||||
// No toggle: both reported missing (no local albums).
|
||||
got, err := ScanArtist(context.Background(), db, "a1", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("ScanArtist error: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2 missing before toggle, got %d", len(got))
|
||||
}
|
||||
|
||||
// Toggle ignore_singles on.
|
||||
if err := database.UpdateArtistSettings(db, "a1", map[string]interface{}{"ignore_singles": true}); err != nil {
|
||||
t.Fatalf("toggle ignore_singles: %v", err)
|
||||
}
|
||||
got, err = ScanArtist(context.Background(), db, "a1", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("ScanArtist error: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].RGID != "rg-album" {
|
||||
ids := make([]string, len(got))
|
||||
for i, m := range got {
|
||||
ids[i] = m.RGID
|
||||
}
|
||||
t.Fatalf("expected only rg-album after toggle, got %v", ids)
|
||||
}
|
||||
|
||||
// Toggle back off: single reappears.
|
||||
if err := database.UpdateArtistSettings(db, "a1", map[string]interface{}{"ignore_singles": false}); err != nil {
|
||||
t.Fatalf("toggle ignore_singles off: %v", err)
|
||||
}
|
||||
got, err = ScanArtist(context.Background(), db, "a1", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("ScanArtist error: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2 missing after toggle off, got %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,7 +202,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)
|
||||
got := FindMissingReleases(tt.local, tt.external, threshold, TypeFilter{})
|
||||
|
||||
gotRGIDs := make([]string, 0, len(got))
|
||||
for _, m := range got {
|
||||
@@ -225,6 +225,67 @@ func TestFindMissingReleases(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindMissingReleases_TypeFilter(t *testing.T) {
|
||||
const threshold = 0.85
|
||||
artist := "artist-a"
|
||||
|
||||
external := []database.ExternalRelease{
|
||||
{RGID: "rg-album", ArtistID: artist, Title: "The Wall", Type: "Album"},
|
||||
{RGID: "rg-single", ArtistID: artist, Title: "B-side", Type: "Single"},
|
||||
{RGID: "rg-comp", ArtistID: artist, Title: "Hits", Type: "Compilation"},
|
||||
{RGID: "rg-comp-sec", ArtistID: artist, Title: "Live at X", Type: "Album", SecondaryTypes: []string{"Compilation"}},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
filter TypeFilter
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "no filter reports all",
|
||||
filter: TypeFilter{},
|
||||
want: []string{"rg-album", "rg-single", "rg-comp", "rg-comp-sec"},
|
||||
},
|
||||
{
|
||||
name: "ignore singles drops Single primary type",
|
||||
filter: TypeFilter{IgnoreSingles: true},
|
||||
want: []string{"rg-album", "rg-comp", "rg-comp-sec"},
|
||||
},
|
||||
{
|
||||
name: "ignore compilations drops Compilation primary and secondary type",
|
||||
filter: TypeFilter{IgnoreCompilations: true},
|
||||
want: []string{"rg-album", "rg-single"},
|
||||
},
|
||||
{
|
||||
name: "both toggles drop singles and compilations",
|
||||
filter: TypeFilter{IgnoreSingles: true, IgnoreCompilations: true},
|
||||
want: []string{"rg-album"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := FindMissingReleases(nil, external, threshold, tt.filter)
|
||||
gotRGIDs := make([]string, 0, len(got))
|
||||
for _, m := range got {
|
||||
gotRGIDs = append(gotRGIDs, m.RGID)
|
||||
}
|
||||
wantSet := make(map[string]struct{}, len(tt.want))
|
||||
for _, r := range tt.want {
|
||||
wantSet[r] = struct{}{}
|
||||
}
|
||||
if len(gotRGIDs) != len(tt.want) {
|
||||
t.Fatalf("got %v, want %v", gotRGIDs, tt.want)
|
||||
}
|
||||
for _, r := range gotRGIDs {
|
||||
if _, ok := wantSet[r]; !ok {
|
||||
t.Errorf("unexpected RGID %q", r)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindMissingReleases_ThresholdBoundaryInclusive(t *testing.T) {
|
||||
// A title at exactly the threshold must NOT be reported as missing
|
||||
// (IsMatch uses >= threshold).
|
||||
@@ -240,7 +301,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)) != 1 {
|
||||
if len(FindMissingReleases(local, external, 0.85, TypeFilter{})) != 1 {
|
||||
t.Errorf("expected 1 missing at 0.85 threshold")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user