Replace compute-only run() with an immediate sync+scan followed by a ticker-driven periodic loop, add the sync.interval config field (default 6h) with defaults and validation, and add tests for the scheduling logic.
201 lines
5.7 KiB
Go
201 lines
5.7 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/scanner"
|
|
)
|
|
|
|
// 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
|
|
|
|
// 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)
|
|
}
|
|
|
|
return &App{
|
|
cfg: cfg,
|
|
db: db,
|
|
mbClient: mbClient,
|
|
ndClient: ndClient,
|
|
}, 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.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)
|
|
}
|
|
}
|
|
}
|
|
|
|
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.
|
|
if err := a.doSync(ctx); err != nil {
|
|
if ctx.Err() != nil {
|
|
return nil
|
|
}
|
|
log.Printf("Initial sync+scan failed: %v", err)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// 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.
|
|
func (a *App) startPeriodicSync(ctx context.Context) {
|
|
ticker := time.NewTicker(a.cfg.Sync.Interval)
|
|
defer ticker.Stop()
|
|
|
|
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
|
|
}
|
|
log.Printf("Periodic sync+scan failed: %v", err)
|
|
}
|
|
}()
|
|
}
|
|
}
|
|
}
|