Web UI: setup wizard, auth pages, library browsing, player, admin, streaming, cover art
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
// 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/web"
|
||||
)
|
||||
|
||||
type App struct {
|
||||
Cfg config.Config
|
||||
DB *sql.DB
|
||||
Log *slog.Logger
|
||||
Scanner *scanner.Scanner
|
||||
Web *web.Web
|
||||
}
|
||||
|
||||
// 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}
|
||||
|
||||
webApp := &web.Web{
|
||||
DB: db,
|
||||
Config: cfg,
|
||||
Sessions: sessions,
|
||||
CSRF: csrf,
|
||||
Middleware: mw,
|
||||
Scanner: sc,
|
||||
CoverArt: covers,
|
||||
Log: log,
|
||||
}
|
||||
|
||||
return &App{Cfg: cfg, DB: db, Log: log, Scanner: sc, Web: webApp}, 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: a.Cfg.ListenAddr,
|
||||
Handler: a.Web.NewRouter(),
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user