fix: address code review findings

- Fix duplicate Telegram notifications: SyncArtistDiscography no longer wipes
  notifications_sent for the whole artist on every cache-miss re-sync; only
  markers for releases that disappear are pruned (FK-safe via INSERT OR REPLACE
  + rgid NOT IN (...)).
- Cache empty MusicBrainz discographies via a new artist_settings.last_synced
  column (migration 009) so zero-release artists honor the TTL instead of being
  re-fetched every cycle.
- Wire the Web UI server and Telegram notifier scheduler into main.run/NewApp.
- Guard startPeriodicSync against overlapping syncs with a done-channel slot.
- Add server.public_url config; NewServerWithConfig derives reachable links
  and no longer advertises the 0.0.0.0 bind address.
- Web handlers: use scanner.ScanArtist per artist, drop always-false
  releaseIgnored lookup and dead endsWith, thread configured threshold.
- Limit :memory: DB pool to one connection so migrations and queries share the
  same in-memory store.
This commit is contained in:
2026-07-19 23:38:33 +03:00
parent e493a4d228
commit 389d177d85
15 changed files with 386 additions and 126 deletions

View File

@@ -370,11 +370,16 @@ func TestSyncArtistDiscography_IdempotentResync(t *testing.T) {
t.Fatalf("expected 1 server call after first sync, got %d", callCount)
}
// Force cache expiry by setting cached_at to the past.
// Force cache expiry by setting cached_at (on external_releases) and
// last_synced (on artist_settings) to the past.
_, err = db.Conn().Exec("UPDATE external_releases SET cached_at = ? WHERE artist_id = ?",
time.Now().Add(-48*time.Hour).Format("2006-01-02 15:04:05"), artistID)
if err != nil {
t.Fatalf("expire cache: %v", err)
t.Fatalf("expire cache (releases): %v", err)
}
if _, err = db.Conn().Exec("UPDATE artist_settings SET last_synced = ? WHERE id = ?",
time.Now().Add(-48*time.Hour).Format("2006-01-02 15:04:05"), artistID); err != nil {
t.Fatalf("expire cache (settings): %v", err)
}
// Second sync should re-fetch from API (cache expired).
@@ -438,6 +443,28 @@ func TestSyncArtistDiscography_EmptyResponse(t *testing.T) {
if len(stored) != 0 {
t.Errorf("expected 0 stored releases, got %d", len(stored))
}
// A second sync within the TTL must be a cache hit: an empty discography is
// now cached via artist_settings.last_synced, so the MusicBrainz API must
// not be re-queried (and still returns 0 releases).
serverHits := 0
server2 := newTestMBServer(func(w http.ResponseWriter, r *http.Request) {
serverHits++
w.Header().Set("Content-Type", "application/xml")
w.Write([]byte(mbReleaseGroupListResponse("", 0)))
})
defer server2.Close()
releases2, err := SyncArtistDiscography(ctx, newTestClient(server2.URL), db, artistID, artistMBID, ttl)
if err != nil {
t.Fatalf("second SyncArtistDiscography() error: %v", err)
}
if len(releases2) != 0 {
t.Errorf("expected 0 releases on cached empty sync, got %d", len(releases2))
}
if serverHits != 0 {
t.Errorf("expected empty discography to be cached (0 API calls), got %d", serverHits)
}
}
// -----------------------------------------------------------------------
@@ -714,11 +741,16 @@ func TestSyncArtistDiscography_CleansStaleReleases(t *testing.T) {
t.Fatalf("expected 3 releases after first sync, got %d", len(releases1))
}
// Force cache expiry by setting cached_at to the past.
// Force cache expiry by setting cached_at (on external_releases) and
// last_synced (on artist_settings) to the past.
_, err = db.Conn().Exec("UPDATE external_releases SET cached_at = ? WHERE artist_id = ?",
time.Now().Add(-48*time.Hour).Format("2006-01-02 15:04:05"), artistID)
if err != nil {
t.Fatalf("expire cache: %v", err)
t.Fatalf("expire cache (releases): %v", err)
}
if _, err = db.Conn().Exec("UPDATE artist_settings SET last_synced = ? WHERE id = ?",
time.Now().Add(-48*time.Hour).Format("2006-01-02 15:04:05"), artistID); err != nil {
t.Fatalf("expire cache (settings): %v", err)
}
// Second sync should re-fetch from API (cache expired).
@@ -876,10 +908,14 @@ func TestSyncArtistDiscography_ResyncWithNotifications(t *testing.T) {
artistID := "nav-fk-test"
artistName := "FK Artist"
// First response includes two release groups; the second sync drops one
// ("rg-2") so we can verify its notification is pruned while the surviving
// release's notification ("rg-1") is preserved.
server := newTestMBServer(func(w http.ResponseWriter, r *http.Request) {
resp := mbReleaseGroupListResponse(
mbReleaseGroupXML("rg-1", "Album", "Album", "Official", artistMBID, artistName, "2024-01-01"),
1,
mbReleaseGroupXML("rg-1", "Album", "Album", "Official", artistMBID, artistName, "2024-01-01")+
mbReleaseGroupXML("rg-2", "Album", "Album", "Official", artistMBID, artistName, "2023-01-01"),
2,
)
w.Write([]byte(resp))
})
@@ -893,27 +929,37 @@ func TestSyncArtistDiscography_ResyncWithNotifications(t *testing.T) {
ctx := context.Background()
// First sync.
_, err := SyncArtistDiscography(ctx, client, db, artistID, artistMBID, 24*time.Hour)
if err != nil {
if _, err := SyncArtistDiscography(ctx, client, db, artistID, artistMBID, 24*time.Hour); err != nil {
t.Fatalf("first SyncArtistDiscography() error: %v", err)
}
// Insert a notifications_sent row referencing the release.
_, err = db.Conn().Exec(
"INSERT INTO notifications_sent (rgid) VALUES (?)", "rg-1",
)
if err != nil {
t.Fatalf("insert notification: %v", err)
// Mark both releases as already notified.
for _, rgid := range []string{"rg-1", "rg-2"} {
if _, err := db.Conn().Exec("INSERT INTO notifications_sent (rgid) VALUES (?)", rgid); err != nil {
t.Fatalf("insert notification: %v", err)
}
}
// Force cache expiry.
_, err = db.Conn().Exec("UPDATE external_releases SET cached_at = ? WHERE artist_id = ?",
time.Now().Add(-48*time.Hour).Format("2006-01-02 15:04:05"), artistID)
if err != nil {
t.Fatalf("expire cache: %v", err)
// Force cache expiry on the first sync so the second sync re-fetches.
if _, err := db.Conn().Exec("UPDATE external_releases SET cached_at = ? WHERE artist_id = ?",
time.Now().Add(-48*time.Hour).Format("2006-01-02 15:04:05"), artistID); err != nil {
t.Fatalf("expire cache (releases): %v", err)
}
if _, err := db.Conn().Exec("UPDATE artist_settings SET last_synced = ? WHERE id = ?",
time.Now().Add(-48*time.Hour).Format("2006-01-02 15:04:05"), artistID); err != nil {
t.Fatalf("expire cache (settings): %v", err)
}
// Second sync should succeed without FK violation.
// Second sync returns only rg-1 (drop rg-2 from the server response) and
// must succeed without an FK violation.
server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
resp := mbReleaseGroupListResponse(
mbReleaseGroupXML("rg-1", "Album", "Album", "Official", artistMBID, artistName, "2024-01-01"),
1,
)
w.Write([]byte(resp))
})
releases, err := SyncArtistDiscography(ctx, client, db, artistID, artistMBID, 24*time.Hour)
if err != nil {
t.Fatalf("second SyncArtistDiscography() error (FK violation?): %v", err)
@@ -922,13 +968,20 @@ func TestSyncArtistDiscography_ResyncWithNotifications(t *testing.T) {
t.Fatalf("expected 1 release after resync, got %d", len(releases))
}
// Notification should have been cleaned up.
var count int
err = db.Conn().QueryRow("SELECT COUNT(*) FROM notifications_sent WHERE rgid = ?", "rg-1").Scan(&count)
if err != nil {
t.Fatalf("count notifications: %v", err)
// The surviving release's notification must be preserved (no duplicate
// digest on the next notify run). The dropped release's notification must
// be pruned.
var count1, count2 int
if err := db.Conn().QueryRow("SELECT COUNT(*) FROM notifications_sent WHERE rgid = ?", "rg-1").Scan(&count1); err != nil {
t.Fatalf("count rg-1 notifications: %v", err)
}
if count != 0 {
t.Errorf("expected 0 notifications after resync, got %d", count)
if err := db.Conn().QueryRow("SELECT COUNT(*) FROM notifications_sent WHERE rgid = ?", "rg-2").Scan(&count2); err != nil {
t.Fatalf("count rg-2 notifications: %v", err)
}
if count1 != 1 {
t.Errorf("expected surviving release rg-1 notification preserved (1), got %d", count1)
}
if count2 != 0 {
t.Errorf("expected dropped release rg-2 notification pruned (0), got %d", count2)
}
}