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,19 +4,27 @@ import (
"database/sql"
"errors"
"fmt"
"time"
)
// DBer is the minimal query interface satisfied by both *sql.DB and *sql.Tx,
// so callers can run statements inside or outside a transaction.
type DBer interface {
Exec(query string, args ...interface{}) (sql.Result, error)
}
// GetArtistSettings retrieves an artist_settings row by ID.
// Returns sql.ErrNoRows if the artist is not found.
func GetArtistSettings(db *DB, id string) (*ArtistSettings, error) {
var (
s ArtistSettings
mbid sql.NullString
s ArtistSettings
mbid sql.NullString
lastSynced sql.NullTime
)
err := db.Conn().QueryRow(
"SELECT id, name, mbid, ignore_singles, ignore_compilations, monitored FROM artist_settings WHERE id = ?",
"SELECT id, name, mbid, ignore_singles, ignore_compilations, monitored, last_synced FROM artist_settings WHERE id = ?",
id,
).Scan(&s.ID, &s.Name, &mbid, &s.IgnoreSingles, &s.IgnoreCompilations, &s.Monitored)
).Scan(&s.ID, &s.Name, &mbid, &s.IgnoreSingles, &s.IgnoreCompilations, &s.Monitored, &lastSynced)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrArtistNotFound
@@ -24,9 +32,26 @@ func GetArtistSettings(db *DB, id string) (*ArtistSettings, error) {
return nil, err
}
s.MBID = mbid.String
if lastSynced.Valid {
s.LastSynced = lastSynced.Time
}
return &s, nil
}
// TouchArtistSynced records that the artist was synced at the given time. It
// is used by the MusicBrainz pipeline to mark a successful sync (even one that
// found zero release groups) so the cache TTL is honoured.
func TouchArtistSynced(db DBer, artistID string, syncedAt time.Time) error {
_, err := db.Exec(
"UPDATE artist_settings SET last_synced = ? WHERE id = ?",
FormatCachedAt(syncedAt), artistID,
)
if err != nil {
return fmt.Errorf("touch artist synced: %w", err)
}
return nil
}
// SaveArtistSettings inserts or replaces an artist_settings row.
func SaveArtistSettings(db *DB, settings *ArtistSettings) error {
_, err := db.Conn().Exec(

View File

@@ -25,10 +25,21 @@ func New(dbPath string) (*DB, error) {
// EVERY connection in the pool. A one-off "PRAGMA foreign_keys=ON" executed
// on the pooled *sql.DB only applies to the first connection and is lost on
// connections opened later by the pool, silently disabling the safety net.
// A plain ":memory:" database is private to the connection that opened it,
// so a pool of N connections would give N separate empty databases and
// migrations would appear missing on some. Limiting the pool to a single
// connection keeps one in-memory database per New() call, which is correct
// for both tests (isolated) and the single-process production service.
conn, err := sql.Open("sqlite3", dbPath+"?_foreign_keys=on")
if err != nil {
return nil, fmt.Errorf("open database: %w", err)
}
if dbPath == ":memory:" {
conn.SetMaxOpenConns(1)
}
if err != nil {
return nil, fmt.Errorf("open database: %w", err)
}
// Enable WAL mode for better concurrent read performance.
if _, err := conn.Exec("PRAGMA journal_mode=WAL"); err != nil {
@@ -135,6 +146,10 @@ func (db *DB) migrate() error {
name: "008_add_mbid_to_artist_settings",
sql: `ALTER TABLE artist_settings ADD COLUMN mbid TEXT;`,
},
{
name: "009_add_last_synced_to_artist_settings",
sql: `ALTER TABLE artist_settings ADD COLUMN last_synced DATETIME;`,
},
}
for _, m := range migrations {
@@ -181,12 +196,13 @@ func (db *DB) isMigrationApplied(name string) (bool, error) {
// ArtistSettings represents a row in the artist_settings table.
type ArtistSettings struct {
ID string `json:"id"`
Name string `json:"name"`
MBID string `json:"mbid"`
IgnoreSingles bool `json:"ignore_singles"`
IgnoreCompilations bool `json:"ignore_compilations"`
Monitored bool `json:"monitored"`
ID string `json:"id"`
Name string `json:"name"`
MBID string `json:"mbid"`
IgnoreSingles bool `json:"ignore_singles"`
IgnoreCompilations bool `json:"ignore_compilations"`
Monitored bool `json:"monitored"`
LastSynced time.Time `json:"last_synced"`
}
// LocalAlbum represents a row in the local_albums table.

View File

@@ -205,11 +205,11 @@ func TestMigrationTracking(t *testing.T) {
t.Fatalf("query migrations count: %v", err)
}
// We have 8 recorded migrations: artist_settings, external_releases,
// We have 9 recorded migrations: artist_settings, external_releases,
// local_albums, notifications_sent, cached_at column, secondary_types
// column, the external_releases.artist_id index, and the artist_settings
// mbid column.
if count != 8 {
t.Errorf("expected 8 applied migrations, got %d", count)
// column, the external_releases.artist_id index, the artist_settings mbid
// column, and the artist_settings last_synced column.
if count != 9 {
t.Errorf("expected 9 applied migrations, got %d", count)
}
}

View File

@@ -166,6 +166,42 @@ func SetReleaseIgnored(db *DB, rgid string, ignored bool) error {
return nil
}
// ArtistCacheFresh reports whether the artist was synced within the TTL. A hit
// requires a fresh external_releases row (covers pre-seeded/non-empty caches)
// OR a fresh artist_settings.last_synced (covers empty discographies, which
// store no external_releases rows but are still marked as synced). Either
// signal means we should not re-fetch from MusicBrainz.
func ArtistCacheFresh(db *DB, artistID string, ttl time.Duration) (bool, error) {
if ttl <= 0 {
return false, nil
}
cutoff := time.Now().UTC().Add(-ttl).Format(utcLayout)
var dummy int
err := db.Conn().QueryRow(
"SELECT 1 FROM external_releases WHERE artist_id = ? AND cached_at >= ? LIMIT 1",
artistID, cutoff,
).Scan(&dummy)
if err == nil {
return true, nil
}
if err != sql.ErrNoRows {
return false, fmt.Errorf("check artist cache freshness (releases): %w", err)
}
// Fall back to the per-artist last_synced marker (set even on empty syncs).
err = db.Conn().QueryRow(
"SELECT 1 FROM artist_settings WHERE id = ? AND last_synced >= ? LIMIT 1",
artistID, cutoff,
).Scan(&dummy)
if err == nil {
return true, nil
}
if err == sql.ErrNoRows {
return false, nil
}
return false, fmt.Errorf("check artist cache freshness (settings): %w", err)
}
// GetExternalReleasesByArtistWithCache returns cached external_release rows for a given artist_id
// that are within the specified TTL.
func GetExternalReleasesByArtistWithCache(db *DB, artistID string, ttl time.Duration) ([]ExternalRelease, error) {