// Package app wires together every subsystem (database, auth, scanner, // cover art, web UI) into a single running VibeSonic process. package app import ( "context" "database/sql" "fmt" "log/slog" "net/http" "os" "path/filepath" "time" "git.noroutes.net/NoRoutes/vibetunes/internal/auth" "git.noroutes.net/NoRoutes/vibetunes/internal/config" "git.noroutes.net/NoRoutes/vibetunes/internal/coverart" "git.noroutes.net/NoRoutes/vibetunes/internal/database" "git.noroutes.net/NoRoutes/vibetunes/internal/metadata" "git.noroutes.net/NoRoutes/vibetunes/internal/scanner" "git.noroutes.net/NoRoutes/vibetunes/internal/subsonic" "git.noroutes.net/NoRoutes/vibetunes/internal/web" ) type App struct { Cfg config.Config DB *sql.DB Log *slog.Logger Scanner *scanner.Scanner Web *web.Web Subsonic *subsonic.Server } // Open initializes the database and every subsystem, and imports // VIBESONIC_MUSIC_DIRS as music folders on first run. It does not start // any listeners or background loops. func Open(cfg config.Config, log *slog.Logger) (*App, error) { if err := os.MkdirAll(cfg.DataDir, 0o700); err != nil { return nil, fmt.Errorf("create data dir: %w", err) } db, err := database.Open(cfg.DBPath) if err != nil { return nil, fmt.Errorf("open database: %w", err) } if err := importConfiguredMusicDirs(db, cfg.MusicDirs); err != nil { db.Close() return nil, fmt.Errorf("importing VIBESONIC_MUSIC_DIRS: %w", err) } if cfg.SessionSecret == "" { log.Warn("VIBESONIC_SESSION_SECRET is not set; sessions are still database-backed and safe, but set this in production for defense in depth") } prober := &metadata.Prober{FFprobePath: cfg.FFprobePath} sc := scanner.New(db, prober, log) coverCache := filepath.Join(cfg.DataDir, "cache", "covers") covers := coverart.New(coverCache, cfg.FFmpegPath) sessions := &auth.Sessions{DB: db, CookieSecure: cfg.CookieSecure} extauth := &auth.ExtAuth{DB: db, Config: cfg.ExtAuth} mw := &auth.Middleware{Sessions: sessions, ExtAuth: extauth} csrf := &auth.CSRF{CookieSecure: cfg.CookieSecure} encryptionKey, err := auth.EncryptionKey(db, cfg.SessionSecret) if err != nil { db.Close() return nil, fmt.Errorf("deriving auth encryption key: %w", err) } webApp := &web.Web{ DB: db, Config: cfg, Sessions: sessions, CSRF: csrf, Middleware: mw, Scanner: sc, CoverArt: covers, EncryptionKey: encryptionKey, Log: log, } subsonicSrv := &subsonic.Server{ DB: db, Config: cfg, EncryptionKey: encryptionKey, Scanner: sc, CoverArt: covers, Log: log, } return &App{Cfg: cfg, DB: db, Log: log, Scanner: sc, Web: webApp, Subsonic: subsonicSrv}, nil } func (a *App) Close() error { return a.DB.Close() } // importConfiguredMusicDirs adds any VIBESONIC_MUSIC_DIRS entries not // already present as music folders, keyed by path so restarts don't // create duplicates. func importConfiguredMusicDirs(db *sql.DB, dirs []string) error { if len(dirs) == 0 { return nil } existing, err := database.ListMusicFolders(db) if err != nil { return err } byPath := make(map[string]bool, len(existing)) for _, f := range existing { byPath[f.Path] = true } for _, dir := range dirs { if byPath[dir] { continue } name := filepath.Base(dir) if _, err := database.CreateMusicFolder(db, name, dir, "music"); err != nil { return err } } return nil } // Run starts the HTTP server and the scheduled scanner loop, blocking // until ctx is cancelled, then shuts down gracefully. func (a *App) Run(ctx context.Context) error { go a.Scanner.RunScheduled(ctx, a.Cfg.ScanInterval) go func() { ticker := time.NewTicker(time.Hour) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: if err := database.DeleteExpiredSessions(a.DB); err != nil { a.Log.Warn("session cleanup failed", "err", err) } } } }() mux := http.NewServeMux() mux.Handle("/rest/", a.Subsonic.Router()) mux.Handle("/", a.Web.NewRouter()) srv := &http.Server{ Addr: a.Cfg.ListenAddr, Handler: mux, } errCh := make(chan error, 1) go func() { a.Log.Info("listening", "addr", a.Cfg.ListenAddr) if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { errCh <- err } }() select { case <-ctx.Done(): case err := <-errCh: return err } shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() return srv.Shutdown(shutdownCtx) }