Files
vibesonic/internal/app/app.go
T

174 lines
4.5 KiB
Go

// 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)
}