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

@@ -106,7 +106,7 @@ func TestDataFlowSmoke(t *testing.T) {
Username: "admin",
Password: "secret",
}
srv := web.NewServer(srvCfg, db, "http://localhost:8080")
srv := web.NewServer(srvCfg, db, "http://localhost:8080", 0)
// Unauthenticated -> 401.
rec := httptest.NewRecorder()

View File

@@ -14,7 +14,9 @@ import (
"naviwatcher/internal/database"
"naviwatcher/internal/musicbrainz"
"naviwatcher/internal/navidrome"
"naviwatcher/internal/notifier"
"naviwatcher/internal/scanner"
"naviwatcher/internal/web"
)
// App holds all application dependencies for clean shutdown and testability.
@@ -23,6 +25,8 @@ type App struct {
db *database.DB
mbClient *musicbrainz.MusicBrainzClient
ndClient *navidrome.NavidromeClient
web *web.Server
sender notifier.Sender
// syncFn, when non-nil, replaces the real syncAndScan call in
// startPeriodicSync so tests can observe the loop without live clients.
@@ -94,11 +98,23 @@ func NewApp(ctx context.Context, cfg *config.Config, dbPath string) (*App, error
return nil, fmt.Errorf("failed to initialize navidrome client: %w", err)
}
// Build the Web UI dashboard server (not started until run).
webServer := web.NewServerWithConfig(cfg, db)
// Build the notifier sender. A nil sender is fine when Telegram is disabled;
// the scheduler is no-op-safe and the web UI needs no sender.
var sender notifier.Sender
if cfg.Telegram.Enabled {
sender = notifier.NewTelegramSender(cfg.Telegram)
}
return &App{
cfg: cfg,
db: db,
mbClient: mbClient,
ndClient: ndClient,
web: webServer,
sender: sender,
}, nil
}
@@ -107,10 +123,6 @@ func (a *App) Close() {
if a.mbClient != nil {
a.mbClient.Close()
}
if a.ndClient != nil {
// NavidromeClient holds a stateless subsonic client; nothing to close
// beyond releasing idle connections tracked by the MusicBrainz client.
}
if a.db != nil {
if err := a.db.Close(); err != nil {
log.Printf("Error closing database: %v", err)
@@ -120,9 +132,7 @@ func (a *App) Close() {
func (a *App) run(ctx context.Context) error {
// Run an immediate sync+scan so the service produces results without
// waiting a full interval, then kick off the periodic loop goroutine.
// Business logic added in later tasks (notifier, web server) will be
// wired as additional goroutines below.
// waiting a full interval.
if err := a.doSync(ctx); err != nil {
if ctx.Err() != nil {
return nil
@@ -130,6 +140,24 @@ func (a *App) run(ctx context.Context) error {
log.Printf("Initial sync+scan failed: %v", err)
}
// Start the Web UI dashboard in its own goroutine; it serves until ctx is
// cancelled, then shuts down gracefully.
if a.web != nil {
go func() {
if err := a.web.Start(ctx); err != nil {
if ctx.Err() != nil {
return
}
log.Printf("Web UI server stopped with error: %v", err)
}
}()
}
// Start the Telegram notifier scheduler. It is no-op-safe when Telegram is
// disabled (sender nil / enabled false), so always calling it is safe.
a.startNotifier(ctx)
// Kick off the periodic sync+scan loop goroutine.
a.startPeriodicSync(ctx)
<-ctx.Done()
@@ -173,28 +201,62 @@ func (a *App) syncAndScan(ctx context.Context) error {
return nil
}
// startNotifier wires the Telegram digest scheduler. The scheduler is
// no-op-safe (returns without starting when disabled or sender is nil), so it
// is always safe to call. The base URL for dashboard links comes from the
// server's configured public_url.
func (a *App) startNotifier(ctx context.Context) {
if !a.cfg.Telegram.Enabled {
return
}
schedule, err := notifier.NewCronSchedule(a.cfg.Telegram.CronSchedule)
if err != nil {
log.Printf("Notifier schedule invalid (%q): %v; notifier disabled", a.cfg.Telegram.CronSchedule, err)
return
}
uiBaseURL := a.cfg.Server.PublicURL
notifier.StartScheduler(ctx, true, schedule, func(ctx context.Context) error {
_, err := notifier.NotifyOnce(ctx, a.db, a.sender, a.cfg.Telegram, uiBaseURL)
return err
}, nil)
}
// startPeriodicSync runs syncAndScan on a ticker at cfg.Sync.Interval. It
// blocks until ctx is cancelled, then returns cleanly. Each tick runs in its
// own goroutine so a slow sync does not block the ticker; a fresh interval is
// still scheduled regardless.
// blocks until ctx is cancelled, then returns cleanly. Each tick spawns a
// goroutine so a slow sync does not block the ticker, but a new sync is
// skipped while the previous one is still running (guarded by a done channel)
// so syncs never overlap and contend for the shared DB and rate-limited
// MusicBrainz client.
func (a *App) startPeriodicSync(ctx context.Context) {
ticker := time.NewTicker(a.cfg.Sync.Interval)
defer ticker.Stop()
// free is a sentinel channel: nil means a sync is currently in flight.
var free = make(chan struct{}, 1)
free <- struct{}{}
for {
select {
case <-ctx.Done():
log.Println("Periodic sync stopped.")
return
case <-ticker.C:
go func() {
if err := a.doSync(ctx); err != nil {
if ctx.Err() != nil {
return
select {
case <-free:
// Slot was free; start a sync and release the slot when done.
go func() {
defer func() { free <- struct{}{} }()
if err := a.doSync(ctx); err != nil {
if ctx.Err() != nil {
return
}
log.Printf("Periodic sync+scan failed: %v", err)
}
log.Printf("Periodic sync+scan failed: %v", err)
}
}()
}()
default:
// Previous sync still running; skip this tick.
log.Println("Skipping periodic sync: previous sync still in progress.")
}
}
}
}