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:
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"naviwatcher/internal/database"
|
||||
@@ -38,18 +39,25 @@ func SyncArtistDiscography(
|
||||
return nil, fmt.Errorf("sync artist discography: %w", err)
|
||||
}
|
||||
|
||||
// Step 1: Check cache.
|
||||
// Step 1: Check cache freshness. A genuine hit means the artist was synced
|
||||
// within the TTL — even when it has zero release groups. We must not gate on
|
||||
// row count, or artists with an empty MusicBrainz discography would be
|
||||
// re-fetched on every sync (defeating the TTL and wasting the 1 req/s budget).
|
||||
cachedReleases, err := database.GetExternalReleasesByArtistWithCache(db, artistID, ttl)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sync artist discography: cache check failed: %w", err)
|
||||
}
|
||||
fresh, err := database.ArtistCacheFresh(db, artistID, ttl)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sync artist discography: cache freshness check failed: %w", err)
|
||||
}
|
||||
|
||||
// Step 2: If we have cached data, return it. Re-apply the per-artist type
|
||||
// toggles even on a cache hit so user changes to ignore_singles /
|
||||
// ignore_compilations take effect without waiting for cache expiry.
|
||||
// (Status/type inclusion was already applied when the rows were first
|
||||
// Step 2: If we have a fresh cache, return the cached data. Re-apply the
|
||||
// per-artist type toggles even on a cache hit so user changes to
|
||||
// ignore_singles / ignore_compilations take effect without waiting for cache
|
||||
// expiry. (Status/type inclusion was already applied when the rows were first
|
||||
// synced and stored, so only the toggles can change.)
|
||||
if len(cachedReleases) > 0 {
|
||||
if fresh {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, fmt.Errorf("sync artist discography: %w", err)
|
||||
}
|
||||
@@ -108,17 +116,44 @@ func SyncArtistDiscography(
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
// Delete old entries for this artist to avoid stale records.
|
||||
// Must delete notifications_sent first to avoid FK violation since
|
||||
// notifications_sent.rgid references external_releases.rgid.
|
||||
if _, err := tx.Exec(
|
||||
"DELETE FROM notifications_sent WHERE rgid IN (SELECT rgid FROM external_releases WHERE artist_id = ?)",
|
||||
artistID,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("sync artist discography: delete old notifications: %w", err)
|
||||
// Build the set of RGIDs present in this sync so we can drop only the rows
|
||||
// that disappeared, leaving the rest (and their notification markers) intact.
|
||||
synced := make([]any, 0, len(filtered))
|
||||
for _, rg := range filtered {
|
||||
synced = append(synced, rg.ID)
|
||||
}
|
||||
if _, err := tx.Exec("DELETE FROM external_releases WHERE artist_id = ?", artistID); err != nil {
|
||||
return nil, fmt.Errorf("sync artist discography: delete old releases: %w", err)
|
||||
|
||||
// Drop notification markers for releases that are gone. This runs before the
|
||||
// external_releases delete so the FK on notifications_sent.rgid stays valid
|
||||
// (we only ever delete from notifications_sent here).
|
||||
if len(synced) > 0 {
|
||||
placeholders := strings.Repeat("?,", len(synced))
|
||||
placeholders = placeholders[:len(placeholders)-1]
|
||||
query := fmt.Sprintf(
|
||||
"DELETE FROM notifications_sent WHERE rgid IN (SELECT rgid FROM external_releases WHERE artist_id = ? AND rgid NOT IN (%s))",
|
||||
placeholders,
|
||||
)
|
||||
args := append([]any{artistID}, synced...)
|
||||
if _, err := tx.Exec(query, args...); err != nil {
|
||||
return nil, fmt.Errorf("sync artist discography: prune stale notifications: %w", err)
|
||||
}
|
||||
// Remove external_release rows that are no longer part of the discography.
|
||||
delQuery := fmt.Sprintf(
|
||||
"DELETE FROM external_releases WHERE artist_id = ? AND rgid NOT IN (%s)",
|
||||
placeholders,
|
||||
)
|
||||
if _, err := tx.Exec(delQuery, args...); err != nil {
|
||||
return nil, fmt.Errorf("sync artist discography: delete stale releases: %w", err)
|
||||
}
|
||||
} else {
|
||||
// No releases this sync: the artist may have an empty discography. Drop
|
||||
// everything we previously cached for them.
|
||||
if _, err := tx.Exec("DELETE FROM notifications_sent WHERE rgid IN (SELECT rgid FROM external_releases WHERE artist_id = ?)", artistID); err != nil {
|
||||
return nil, fmt.Errorf("sync artist discography: delete notifications: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec("DELETE FROM external_releases WHERE artist_id = ?", artistID); err != nil {
|
||||
return nil, fmt.Errorf("sync artist discography: delete old releases: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
var releases []database.ExternalRelease
|
||||
@@ -136,7 +171,7 @@ func SyncArtistDiscography(
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(
|
||||
"INSERT INTO external_releases (rgid, artist_id, title, type, release_date, is_ignored, cached_at, secondary_types) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
"INSERT OR REPLACE INTO external_releases (rgid, artist_id, title, type, release_date, is_ignored, cached_at, secondary_types) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
ext.RGID, ext.ArtistID, ext.Title, ext.Type, ext.ReleaseDate, ext.IsIgnored, database.FormatCachedAt(ext.CachedAt), database.JoinSecondaryTypes(ext.SecondaryTypes),
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("sync artist discography: insert release %s: %w", rg.ID, err)
|
||||
@@ -145,6 +180,13 @@ func SyncArtistDiscography(
|
||||
releases = append(releases, *ext)
|
||||
}
|
||||
|
||||
// Mark the artist as synced (even when it has zero release groups) so the
|
||||
// cache TTL honours empty discographies and they are not re-fetched every
|
||||
// cycle.
|
||||
if err := database.TouchArtistSynced(tx, artistID, now); err != nil {
|
||||
return nil, fmt.Errorf("sync artist discography: touch last_synced: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("sync artist discography: commit transaction: %w", err)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user