- Honor ignore_singles/ignore_compilations at scanner read time so toggles take effect immediately on the dashboard, artist page, and digest instead of waiting for the MusicBrainz cache to expire and prune rows. - Run notifier notify synchronously in the scheduler loop to avoid overlapping read-send-mark runs double-sending the digest. - Show artist name (with ID fallback) on the archive page instead of raw IDs. - Select last_synced in GetAllArtistSettings for contract consistency. - Fix stale startPeriodicSync comment and remove redundant error var. - Remove dead ignored-branch from the artist template (never rendered). - Add tests: CSRF sameOrigin, ArtistCacheFresh, secondary_types round-trip, and scanner type-toggle filtering. - Update Specification.md schema/config to reflect mbid, last_synced, secondary_types, sync.interval, and server.public_url.
265 lines
7.9 KiB
Go
265 lines
7.9 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"flag"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"os/signal"
|
|
"syscall"
|
|
"time"
|
|
|
|
"naviwatcher/internal/config"
|
|
"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.
|
|
type App struct {
|
|
cfg *config.Config
|
|
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.
|
|
syncFn func(ctx context.Context) error
|
|
}
|
|
|
|
// navidromeClientFactory constructs the Navidrome client. It is a package-level
|
|
// variable (not a direct call to navidrome.NewClient) so tests can inject a stub
|
|
// without requiring a live Navidrome server for authentication.
|
|
var navidromeClientFactory = navidrome.NewClient
|
|
|
|
const defaultConfigPath = "config.yaml"
|
|
|
|
func main() {
|
|
configPath := flag.String("config", defaultConfigPath, "Path to config file")
|
|
flag.Parse()
|
|
|
|
log.Println("NaviWatcher starting...")
|
|
|
|
cfg, err := config.LoadConfig(*configPath)
|
|
if err != nil {
|
|
log.Fatalf("Failed to load config: %v", err)
|
|
}
|
|
|
|
log.Printf("Config loaded from %s (server: %s:%d)", *configPath, cfg.Server.Host, cfg.Server.Port)
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
sigCh := make(chan os.Signal, 1)
|
|
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
|
|
|
go func() {
|
|
sig := <-sigCh
|
|
log.Printf("Received signal %v, shutting down...", sig)
|
|
cancel()
|
|
}()
|
|
|
|
app, err := NewApp(ctx, cfg, "naviwatcher.db")
|
|
if err != nil {
|
|
log.Fatalf("Failed to initialize application: %v", err)
|
|
}
|
|
defer app.Close()
|
|
|
|
if err := app.run(ctx); err != nil {
|
|
log.Fatalf("Application error: %v", err)
|
|
}
|
|
|
|
log.Println("NaviWatcher stopped.")
|
|
}
|
|
|
|
// NewApp initializes all application components: config, database, and MusicBrainz client.
|
|
// dbPath is the SQLite database path (use ":memory:" for tests).
|
|
func NewApp(ctx context.Context, cfg *config.Config, dbPath string) (*App, error) {
|
|
// Initialize database.
|
|
db, err := database.New(dbPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to initialize database: %w", err)
|
|
}
|
|
|
|
// Initialize MusicBrainz client with rate limiting.
|
|
mbClient := musicbrainz.NewClient(cfg.MusicBrainz)
|
|
|
|
log.Printf("MusicBrainz client initialized (user-agent: %s)", cfg.MusicBrainz.UserAgent)
|
|
|
|
// Initialize Navidrome client (authenticates immediately; error if auth fails).
|
|
ndClient, err := navidromeClientFactory(cfg.Navidrome)
|
|
if err != nil {
|
|
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
|
|
}
|
|
|
|
// Close cleans up all application resources in reverse order of initialization.
|
|
func (a *App) Close() {
|
|
if a.mbClient != nil {
|
|
a.mbClient.Close()
|
|
}
|
|
if a.db != nil {
|
|
if err := a.db.Close(); err != nil {
|
|
log.Printf("Error closing database: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (a *App) run(ctx context.Context) error {
|
|
// Run an immediate sync+scan so the service produces results without
|
|
// waiting a full interval.
|
|
if err := a.doSync(ctx); err != nil {
|
|
if ctx.Err() != nil {
|
|
return nil
|
|
}
|
|
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()
|
|
return nil
|
|
}
|
|
|
|
// doSync runs the sync pipeline, using the injected syncFn when present (tests)
|
|
// or the real syncAndScan otherwise.
|
|
func (a *App) doSync(ctx context.Context) error {
|
|
if a.syncFn != nil {
|
|
return a.syncFn(ctx)
|
|
}
|
|
return a.syncAndScan(ctx)
|
|
}
|
|
|
|
// syncAndScan runs the full data pipeline once: Navidrome artist sync, the
|
|
// MusicBrainz discography pipeline (SyncAll), then the scanner over the
|
|
// now-populated DB. It logs results and observes ctx cancellation.
|
|
func (a *App) syncAndScan(ctx context.Context) error {
|
|
if err := navidrome.SyncArtists(ctx, a.ndClient, a.db); err != nil {
|
|
return fmt.Errorf("sync artists: %w", err)
|
|
}
|
|
|
|
discography := musicbrainz.NewDiscographySyncer(a.mbClient)
|
|
albums := musicbrainz.NewAlbumSyncer(func(ctx context.Context, db *database.DB) error {
|
|
return navidrome.SyncAlbums(ctx, a.ndClient, db)
|
|
})
|
|
if err := musicbrainz.SyncAll(ctx, a.db, a.mbClient, discography, albums, a.cfg.MusicBrainz.CacheTTL); err != nil {
|
|
return fmt.Errorf("sync all: %w", err)
|
|
}
|
|
|
|
missing, err := scanner.ScanAll(ctx, a.db, a.cfg.Scanner.FuzzyThreshold)
|
|
if err != nil {
|
|
return fmt.Errorf("scan all: %w", err)
|
|
}
|
|
|
|
log.Printf("Scan complete: %d missing release(s) across monitored artists", len(missing))
|
|
for _, m := range missing {
|
|
log.Printf(" missing: artist=%s rgid=%s title=%q", m.ArtistID, m.RGID, m.Title)
|
|
}
|
|
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 := web.ResolveUIBaseURL(&a.cfg.Server)
|
|
notifier.StartScheduler(ctx, true, schedule, func(ctx context.Context) error {
|
|
_, err := notifier.NotifyOnce(ctx, a.db, a.sender, a.cfg.Telegram, uiBaseURL, a.cfg.Scanner.FuzzyThreshold)
|
|
return err
|
|
}, nil)
|
|
}
|
|
|
|
// startPeriodicSync runs syncAndScan on a ticker at cfg.Sync.Interval. It
|
|
// 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 buffered token (capacity 1). A sync is in flight while the token
|
|
// is drained; the in-flight goroutine returns it when done so the next tick
|
|
// can start a new sync. While the token is held, ticks are skipped.
|
|
var free = make(chan struct{}, 1)
|
|
free <- struct{}{}
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
log.Println("Periodic sync stopped.")
|
|
return
|
|
case <-ticker.C:
|
|
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)
|
|
}
|
|
}()
|
|
default:
|
|
// Previous sync still running; skip this tick.
|
|
log.Println("Skipping periodic sync: previous sync still in progress.")
|
|
}
|
|
}
|
|
}
|
|
}
|