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:
@@ -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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user