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

@@ -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)
}