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:
2026-07-20 06:18:21 +03:00
parent f5b0034b4d
commit a8aa445d94
13 changed files with 402 additions and 31 deletions

View File

@@ -89,7 +89,7 @@ func nullIfEmpty(s string) 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 FROM artist_settings",
"SELECT id, name, mbid, ignore_singles, ignore_compilations, monitored, last_synced FROM artist_settings",
)
if err != nil {
return nil, fmt.Errorf("query all artist settings: %w", err)
@@ -100,10 +100,14 @@ func GetAllArtistSettings(db *DB) ([]ArtistSettings, error) {
for rows.Next() {
var s ArtistSettings
var mbid sql.NullString
if err := rows.Scan(&s.ID, &s.Name, &mbid, &s.IgnoreSingles, &s.IgnoreCompilations, &s.Monitored); err != nil {
var lastSynced sql.NullTime
if err := rows.Scan(&s.ID, &s.Name, &mbid, &s.IgnoreSingles, &s.IgnoreCompilations, &s.Monitored, &lastSynced); err != nil {
return nil, fmt.Errorf("scan artist settings: %w", err)
}
s.MBID = mbid.String
if lastSynced.Valid {
s.LastSynced = lastSynced.Time
}
results = append(results, s)
}
if err := rows.Err(); err != nil {

View File

@@ -4,6 +4,7 @@ import (
"database/sql"
"errors"
"testing"
"time"
)
// insertTestArtist inserts a minimal artist_settings row for use in tests that need FK satisfaction.
@@ -378,3 +379,108 @@ func TestSetReleaseIgnored_NotFound(t *testing.T) {
t.Error("expected error for nonexistent RGID, got nil")
}
}
// TestSecondaryTypesRoundTrip verifies that the comma-joined secondary_types
// column round-trips through save + read with the same slice, so the cache-hit
// type filtering (ignore_singles / ignore_compilations) sees the same data as
// the cache-miss path.
func TestSecondaryTypesRoundTrip(t *testing.T) {
db, err := New(":memory:")
if err != nil {
t.Fatalf("New() error: %v", err)
}
defer db.Close()
if err := insertTestArtist(db, "artist-1"); err != nil {
t.Fatalf("insertTestArtist: %v", err)
}
cases := []struct {
name string
in []string
want []string
}{
{"empty", nil, nil},
{"single", []string{"Compilation"}, []string{"Compilation"}},
{"multiple", []string{"Compilation", "Live", "EP"}, []string{"Compilation", "Live", "EP"}},
}
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",
SecondaryTypes: c.in,
}
if err := SaveExternalRelease(db, r); err != nil {
t.Fatalf("SaveExternalRelease() error: %v", err)
}
got, err := GetExternalRelease(db, r.RGID)
if err != nil {
t.Fatalf("GetExternalRelease() error: %v", err)
}
if len(got.SecondaryTypes) != len(c.want) {
t.Fatalf("secondary types = %v, want %v", got.SecondaryTypes, c.want)
}
for i := range c.want {
if got.SecondaryTypes[i] != c.want[i] {
t.Errorf("secondary types[%d] = %q, want %q", i, got.SecondaryTypes[i], c.want[i])
}
}
})
}
}
// TestArtistCacheFresh covers the two-signal freshness logic: a fresh
// external_releases row, a fresh last_synced marker (empty discography), a stale
// state, and the ttl<=0 guard.
func TestArtistCacheFresh(t *testing.T) {
db, err := New(":memory:")
if err != nil {
t.Fatalf("New() error: %v", err)
}
defer db.Close()
if err := insertTestArtist(db, "artist-1"); err != nil {
t.Fatalf("insertTestArtist: %v", err)
}
const ttl = 24 * time.Hour
// No rows at all => not fresh.
if fresh, err := ArtistCacheFresh(db, "artist-1", ttl); err != nil || fresh {
t.Fatalf("empty state: fresh=%v err=%v, want (false, nil)", fresh, err)
}
// ttl <= 0 => never fresh.
if fresh, err := ArtistCacheFresh(db, "artist-1", 0); err != nil || fresh {
t.Fatalf("ttl=0: fresh=%v err=%v, want (false, nil)", fresh, err)
}
// Fresh release row => fresh, even with an empty last_synced.
if _, err := db.Conn().Exec(
"INSERT INTO external_releases (rgid, artist_id, title, cached_at) VALUES (?, ?, ?, ?)",
"rgid-1", "artist-1", "Album", FormatCachedAt(time.Now().UTC()),
); err != nil {
t.Fatalf("insert release: %v", err)
}
if fresh, err := ArtistCacheFresh(db, "artist-1", ttl); err != nil || !fresh {
t.Fatalf("fresh release: fresh=%v err=%v, want (true, nil)", fresh, err)
}
// Remove the release row, set a fresh last_synced (empty discography still cached).
if _, err := db.Conn().Exec("DELETE FROM external_releases WHERE artist_id = ?", "artist-1"); err != nil {
t.Fatalf("delete releases: %v", err)
}
if _, err := db.Conn().Exec("UPDATE artist_settings SET last_synced = ? WHERE id = ?", FormatCachedAt(time.Now().UTC()), "artist-1"); err != nil {
t.Fatalf("touch synced: %v", err)
}
if fresh, err := ArtistCacheFresh(db, "artist-1", ttl); err != nil || !fresh {
t.Fatalf("fresh last_synced: fresh=%v err=%v, want (true, nil)", fresh, err)
}
// Stale both signals => not fresh.
db.Conn().Exec("UPDATE artist_settings SET last_synced = ? WHERE id = ?", FormatCachedAt(time.Now().UTC().Add(-2*ttl)), "artist-1")
if fresh, err := ArtistCacheFresh(db, "artist-1", ttl); err != nil || fresh {
t.Fatalf("stale both: fresh=%v err=%v, want (false, nil)", fresh, err)
}
}