diff options
| -rw-r--r-- | cmd/mediaplayer/main.go | 167 | ||||
| -rw-r--r-- | internal/api/server.go | 186 | ||||
| -rw-r--r-- | internal/config.go | 81 | ||||
| -rw-r--r-- | internal/repository/migrate.go | 39 | ||||
| -rw-r--r-- | internal/scanner/scanner.go | 301 | ||||
| -rw-r--r-- | internal/service/browse.go | 110 |
6 files changed, 560 insertions, 324 deletions
diff --git a/cmd/mediaplayer/main.go b/cmd/mediaplayer/main.go index 93eed12..d0143e2 100644 --- a/cmd/mediaplayer/main.go +++ b/cmd/mediaplayer/main.go @@ -33,31 +33,36 @@ func run(args []string) error { return runWithSignal(args, nil) } -func runWithSignal(args []string, sigCh <-chan os.Signal) error { +// appDeps bundles all wired service-layer dependencies. +type appDeps struct { + store repository.Store + hasher auth.Hasher + sm *auth.SessionManager + cfg *internal.Config + clk clock.Clock + mediaSvc service.MediaService + adminSvc service.AdminService + progressSvc service.ProgressService + authSvc service.AuthService + scanner scanner.Scanner + gcWorker *service.GCWorker + logger *slog.Logger +} + +// parseVersionFlag parses CLI flags and returns whether --version was requested. +func parseVersionFlag(args []string) (bool, error) { fs := flag.NewFlagSet("mediaplayer", flag.ContinueOnError) versionFlag := fs.Bool("version", false, "print version and exit") if err := fs.Parse(args); err != nil { - return err - } - - if *versionFlag { - fmt.Println(internal.Version) - return nil - } - - cfg, err := internal.LoadConfig() - if err != nil { - return fmt.Errorf("failed to load config: %w", err) - } - - store, err := repository.Open(cfg.DBPath) - if err != nil { - return fmt.Errorf("failed to open database: %w", err) + return false, err } + return *versionFlag, nil +} - // Build logger aligned with the configured log level. +// buildLogger creates a slog.Logger aligned with the named log level. +func buildLogger(logLevel string) *slog.Logger { var level slog.Level - switch cfg.LogLevel { + switch logLevel { case "debug": level = slog.LevelDebug case "info": @@ -69,20 +74,15 @@ func runWithSignal(args []string, sigCh <-chan os.Signal) error { default: level = slog.LevelInfo } - logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: level})) - defer func() { - if err := store.Close(); err != nil { - logger.Error("failed to close database", "err", err) - } - }() + return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: level})) +} +// wireDeps constructs the core service layer dependencies. +func wireDeps(cfg *internal.Config, store repository.Store, logger *slog.Logger, appCtx context.Context) *appDeps { clk := clock.RealClock{} hasher := auth.NewBCryptHasher(12) sm := auth.NewSessionManager(store, clk, time.Duration(cfg.SessionTimeoutHours)*time.Hour) - appCtx, appCancel := context.WithCancel(context.Background()) - defer appCancel() - prober := probe.NewFFProber() thumbGen := thumb.NewFFmpegGenerator() mediaSvc := service.NewMediaService(store, clk, cfg.MediaRoot, thumbGen, prober) @@ -93,24 +93,51 @@ func runWithSignal(args []string, sigCh <-chan os.Signal) error { progressSvc := service.NewProgressService(store, clk) authSvc := service.NewAuthService(store, clk, hasher, sm) - // Start the background GC worker that hard-deletes soft-deleted media. gcWorker := service.NewGCWorker(store, clk, cfg.MediaRoot, time.Duration(cfg.GCIntervalMinutes)*time.Minute, logger) gcWorker.Start() - defer gcWorker.Stop() - staticFS := http.Dir("web") - remuxer := probe.NewFFRemuxer() - server := api.NewServerWithLogger(store, hasher, sm, cfg, - mediaSvc, // MediaBrowseService - mediaSvc, // MediaWriteService - mediaSvc, // MediaShareService - mediaSvc, // MediaTagService - mediaSvc, // MediaFavoriteService - mediaSvc, // MediaNoteService - adminSvc, progressSvc, authSvc, staticFS, remuxer, logger, - ) + return &appDeps{ + store: store, + hasher: hasher, + sm: sm, + cfg: cfg, + clk: clk, + mediaSvc: mediaSvc, + adminSvc: adminSvc, + progressSvc: progressSvc, + authSvc: authSvc, + scanner: fsScanner, + gcWorker: gcWorker, + logger: logger, + } +} - gs := api.NewGracefulServer(server, cfg) +// ensureSignalChannel returns the provided channel or creates a new one wired +// to OS interrupt signals. +func ensureSignalChannel(sigCh <-chan os.Signal) <-chan os.Signal { + if sigCh != nil { + return sigCh + } + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + return quit +} + +// shutdownGracefully performs a timed graceful shutdown of the server. +func shutdownGracefully(gs *api.GracefulServer, logger *slog.Logger) error { + logger.Info("shutting down server") + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := gs.Server.Shutdown(shutdownCtx); err != nil { + return fmt.Errorf("failed to shutdown server: %w", err) + } + logger.Info("server stopped") + return nil +} + +// runServer starts the HTTP server and blocks until shutdown. +func runServer(handler http.Handler, cfg *internal.Config, logger *slog.Logger, sigCh <-chan os.Signal) error { + gs := api.NewGracefulServer(handler, cfg) logger.Info("player starting", "version", internal.Version, "addr", gs.Server.Addr) @@ -121,11 +148,7 @@ func runWithSignal(args []string, sigCh <-chan os.Signal) error { } }() - if sigCh == nil { - quit := make(chan os.Signal, 1) - signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) - sigCh = quit - } + sigCh = ensureSignalChannel(sigCh) select { case <-sigCh: @@ -135,12 +158,48 @@ func runWithSignal(args []string, sigCh <-chan os.Signal) error { } } - logger.Info("shutting down server") - shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - if err := gs.Server.Shutdown(shutdownCtx); err != nil { - return fmt.Errorf("failed to shutdown server: %w", err) + return shutdownGracefully(gs, logger) +} + +func runWithSignal(args []string, sigCh <-chan os.Signal) error { + showVersion, err := parseVersionFlag(args) + if err != nil { + return err } - logger.Info("server stopped") - return nil + if showVersion { + fmt.Println(internal.Version) + return nil + } + + cfg, err := internal.LoadConfig() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + logger := buildLogger(cfg.LogLevel) + + store, err := repository.Open(cfg.DBPath) + if err != nil { + return fmt.Errorf("failed to open database: %w", err) + } + defer func() { + if err := store.Close(); err != nil { + logger.Error("failed to close database", "err", err) + } + }() + + appCtx, appCancel := context.WithCancel(context.Background()) + defer appCancel() + + deps := wireDeps(cfg, store, logger, appCtx) + defer deps.gcWorker.Stop() + + staticFS := http.Dir("web") + remuxer := probe.NewFFRemuxer() + server := api.NewServerWithLogger(store, deps.hasher, deps.sm, cfg, + deps.mediaSvc, deps.mediaSvc, deps.mediaSvc, deps.mediaSvc, deps.mediaSvc, deps.mediaSvc, + deps.adminSvc, deps.progressSvc, deps.authSvc, staticFS, remuxer, logger, + ) + + return runServer(server, cfg, logger, sigCh) } diff --git a/internal/api/server.go b/internal/api/server.go index bcc8330..23cb5eb 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -107,102 +107,134 @@ func NewServerWithLogger( return s } -func (s *Server) routes() { - // Public routes — use plain path so wrong method returns 405 instead of falling through to / - s.mux.HandleFunc("/api/bootstrap", func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - s.handleBootstrap(w, r) - }) - s.mux.HandleFunc("/api/login", func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - s.handleLogin(w, r) - }) - s.mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - s.handleHealthz(w, r) - }) - s.mux.HandleFunc("/readyz", func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { +// requireSession wraps a handler with the session requirement middleware. +func (s *Server) requireSession(h http.HandlerFunc) http.HandlerFunc { + return s.mw.RequireSession(h).(http.HandlerFunc) +} + +// requireAdmin wraps a handler with both session and admin middleware. +func (s *Server) requireAdmin(h http.HandlerFunc) http.HandlerFunc { + return s.mw.RequireSession(s.mw.RequireAdmin(h)).(http.HandlerFunc) +} + +// publicMethod wraps a handler so only the given HTTP method is allowed; +// other methods yield 405 instead of falling through. +func publicMethod(method string, handler http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != method { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } - s.handleReadyz(w, r) - }) + handler(w, r) + } +} + +// routesPublic wires the fully-public API endpoints (bootstrap, login, probes). +func (s *Server) routesPublic() { + s.mux.HandleFunc("/api/bootstrap", publicMethod(http.MethodPost, s.handleBootstrap)) + s.mux.HandleFunc("/api/login", publicMethod(http.MethodPost, s.handleLogin)) + s.mux.HandleFunc("/healthz", publicMethod(http.MethodGet, s.handleHealthz)) + s.mux.HandleFunc("/readyz", publicMethod(http.MethodGet, s.handleReadyz)) +} - // Public share routes +// routesSharePublic wires public share routes (no session required). +func (s *Server) routesSharePublic() { s.mux.HandleFunc("GET /s/{token}", s.handleSharePage) s.mux.HandleFunc("GET /s/{token}/stream", s.handleShareStream) s.mux.HandleFunc("GET /s/{token}/thumbnail", s.handleShareThumbnail) s.mux.HandleFunc("GET /s/{token}/download", s.handleShareDownload) +} - // Static assets (public) +// routesStatic wires static CSS/JS asset serving. +func (s *Server) routesStatic() { staticHandler := http.FileServer(s.staticFS) s.mux.Handle("/css/", staticHandler) s.mux.Handle("/js/", staticHandler) +} - // HTML pages +// routesHTML wires the SPA HTML page routes. +func (s *Server) routesHTML() { s.mux.Handle("/login.html", http.HandlerFunc(s.serveLogin)) s.mux.Handle("/bootstrap.html", http.HandlerFunc(s.serveBootstrap)) s.mux.Handle("/", s.mw.RequireSession(http.HandlerFunc(s.serveIndex))) s.mux.Handle("GET /index.html", s.mw.RequireSession(http.HandlerFunc(s.serveIndex))) s.mux.Handle("GET /detach.html", s.mw.RequireSession(http.HandlerFunc(s.serveDetach))) +} + +// routesAuth wires the logout route. +func (s *Server) routesAuth() { + s.mux.Handle("POST /api/logout", s.requireSession(s.handleLogout)) +} + +// routesSets wires the set-related API routes. +func (s *Server) routesSets() { + s.mux.Handle("GET /api/sets", s.requireSession(s.handleListSets)) + s.mux.Handle("GET /api/sets/{id}/browse", s.requireSession(s.handleBrowseSet)) + s.mux.Handle("GET /api/sets/{id}/cover", s.requireSession(s.handleGetSetCover)) + s.mux.Handle("POST /api/sets/{id}/cover", s.requireSession(s.handlePostSetCover)) + s.mux.Handle("POST /api/sets/{id}/upload", s.requireSession(s.handleUpload)) +} + +// routesMedia wires the media-related API routes. +func (s *Server) routesMedia() { + s.mux.Handle("GET /api/media", s.requireSession(s.handleListMedia)) + s.mux.Handle("GET /api/media/{id}", s.requireSession(s.handleGetMedia)) + s.mux.Handle("GET /api/media/{id}/stream", s.requireSession(s.handleStream)) + s.mux.Handle("GET /api/media/{id}/download", s.requireSession(s.handleDownload)) + s.mux.Handle("GET /api/media/{id}/thumbnail", s.requireSession(s.handleThumbnail)) + s.mux.Handle("POST /api/media/{id}/thumbnail", s.requireSession(s.handleRegenThumbnail)) + s.mux.Handle("POST /api/media/{id}/favorite", s.requireSession(s.handleFavorite)) + s.mux.Handle("POST /api/media/{id}/tags", s.requireSession(s.handleAddTag)) + s.mux.Handle("DELETE /api/media/{id}/tags/{tag}", s.requireSession(s.handleRemoveTag)) + s.mux.Handle("DELETE /api/media/{id}", s.requireSession(s.handleSoftDelete)) + s.mux.Handle("POST /api/media/{id}/restore", s.requireSession(s.handleRestore)) + s.mux.Handle("POST /api/media/{id}/shares", s.requireSession(s.handleCreateShare)) + s.mux.Handle("GET /api/media/{id}/shares", s.requireSession(s.handleListShares)) +} - // Session-required routes - s.mux.Handle("POST /api/logout", s.mw.RequireSession(http.HandlerFunc(s.handleLogout))) - - // Sets - s.mux.Handle("GET /api/sets", s.mw.RequireSession(http.HandlerFunc(s.handleListSets))) - s.mux.Handle("GET /api/sets/{id}/browse", s.mw.RequireSession(http.HandlerFunc(s.handleBrowseSet))) - s.mux.Handle("GET /api/sets/{id}/cover", s.mw.RequireSession(http.HandlerFunc(s.handleGetSetCover))) - s.mux.Handle("POST /api/sets/{id}/cover", s.mw.RequireSession(http.HandlerFunc(s.handlePostSetCover))) - s.mux.Handle("POST /api/sets/{id}/upload", s.mw.RequireSession(http.HandlerFunc(s.handleUpload))) - - // Media - s.mux.Handle("GET /api/media", s.mw.RequireSession(http.HandlerFunc(s.handleListMedia))) - s.mux.Handle("GET /api/media/{id}", s.mw.RequireSession(http.HandlerFunc(s.handleGetMedia))) - s.mux.Handle("GET /api/media/{id}/stream", s.mw.RequireSession(http.HandlerFunc(s.handleStream))) - s.mux.Handle("GET /api/media/{id}/download", s.mw.RequireSession(http.HandlerFunc(s.handleDownload))) - s.mux.Handle("GET /api/media/{id}/thumbnail", s.mw.RequireSession(http.HandlerFunc(s.handleThumbnail))) - s.mux.Handle("POST /api/media/{id}/thumbnail", s.mw.RequireSession(http.HandlerFunc(s.handleRegenThumbnail))) - s.mux.Handle("POST /api/media/{id}/favorite", s.mw.RequireSession(http.HandlerFunc(s.handleFavorite))) - s.mux.Handle("POST /api/media/{id}/tags", s.mw.RequireSession(http.HandlerFunc(s.handleAddTag))) - s.mux.Handle("DELETE /api/media/{id}/tags/{tag}", s.mw.RequireSession(http.HandlerFunc(s.handleRemoveTag))) - s.mux.Handle("DELETE /api/media/{id}", s.mw.RequireSession(http.HandlerFunc(s.handleSoftDelete))) - s.mux.Handle("POST /api/media/{id}/restore", s.mw.RequireSession(http.HandlerFunc(s.handleRestore))) - s.mux.Handle("POST /api/media/{id}/shares", s.mw.RequireSession(http.HandlerFunc(s.handleCreateShare))) - s.mux.Handle("GET /api/media/{id}/shares", s.mw.RequireSession(http.HandlerFunc(s.handleListShares))) - - // Notes - s.mux.Handle("GET /api/media/{id}/notes", s.mw.RequireSession(http.HandlerFunc(s.handleGetNote))) - s.mux.Handle("POST /api/media/{id}/notes", s.mw.RequireSession(http.HandlerFunc(s.handleUpsertNote))) - s.mux.Handle("DELETE /api/media/{id}/notes", s.mw.RequireSession(http.HandlerFunc(s.handleDeleteNote))) - - // Progress - s.mux.Handle("POST /api/progress", s.mw.RequireSession(http.HandlerFunc(s.handleProgress))) - - // Shares - s.mux.Handle("DELETE /api/shares/{token}", s.mw.RequireSession(http.HandlerFunc(s.handleRevokeShare))) - s.mux.Handle("GET /api/shares", s.mw.RequireSession(http.HandlerFunc(s.handleMyShares))) - - // Admin routes - s.mux.Handle("GET /api/admin/trash", s.mw.RequireSession(s.mw.RequireAdmin(http.HandlerFunc(s.handleListTrash)))) - s.mux.Handle("POST /api/admin/rescan", s.mw.RequireSession(s.mw.RequireAdmin(http.HandlerFunc(s.handleRescan)))) - s.mux.Handle("GET /api/admin/scan-progress", s.mw.RequireSession(s.mw.RequireAdmin(http.HandlerFunc(s.handleScanProgress)))) - s.mux.Handle("GET /api/admin/users", s.mw.RequireSession(s.mw.RequireAdmin(http.HandlerFunc(s.handleListUsers)))) - s.mux.Handle("POST /api/admin/users", s.mw.RequireSession(s.mw.RequireAdmin(http.HandlerFunc(s.handleCreateUser)))) - s.mux.Handle("DELETE /api/admin/users/{id}", s.mw.RequireSession(s.mw.RequireAdmin(http.HandlerFunc(s.handleDeleteUser)))) - s.mux.Handle("GET /api/admin/permissions", s.mw.RequireSession(s.mw.RequireAdmin(http.HandlerFunc(s.handleListPermissions)))) - s.mux.Handle("POST /api/admin/permissions", s.mw.RequireSession(s.mw.RequireAdmin(http.HandlerFunc(s.handleGrantPermission)))) - s.mux.Handle("DELETE /api/admin/permissions", s.mw.RequireSession(s.mw.RequireAdmin(http.HandlerFunc(s.handleRevokePermission)))) +// routesNotes wires the notes API routes. +func (s *Server) routesNotes() { + s.mux.Handle("GET /api/media/{id}/notes", s.requireSession(s.handleGetNote)) + s.mux.Handle("POST /api/media/{id}/notes", s.requireSession(s.handleUpsertNote)) + s.mux.Handle("DELETE /api/media/{id}/notes", s.requireSession(s.handleDeleteNote)) +} + +// routesProgress wires the progress API routes. +func (s *Server) routesProgress() { + s.mux.Handle("POST /api/progress", s.requireSession(s.handleProgress)) +} + +// routesShares wires the share-management API routes. +func (s *Server) routesShares() { + s.mux.Handle("DELETE /api/shares/{token}", s.requireSession(s.handleRevokeShare)) + s.mux.Handle("GET /api/shares", s.requireSession(s.handleMyShares)) +} + +// routesAdmin wires the admin-only API routes. +func (s *Server) routesAdmin() { + s.mux.Handle("GET /api/admin/trash", s.requireAdmin(s.handleListTrash)) + s.mux.Handle("POST /api/admin/rescan", s.requireAdmin(s.handleRescan)) + s.mux.Handle("GET /api/admin/scan-progress", s.requireAdmin(s.handleScanProgress)) + s.mux.Handle("GET /api/admin/users", s.requireAdmin(s.handleListUsers)) + s.mux.Handle("POST /api/admin/users", s.requireAdmin(s.handleCreateUser)) + s.mux.Handle("DELETE /api/admin/users/{id}", s.requireAdmin(s.handleDeleteUser)) + s.mux.Handle("GET /api/admin/permissions", s.requireAdmin(s.handleListPermissions)) + s.mux.Handle("POST /api/admin/permissions", s.requireAdmin(s.handleGrantPermission)) + s.mux.Handle("DELETE /api/admin/permissions", s.requireAdmin(s.handleRevokePermission)) +} + +func (s *Server) routes() { + s.routesPublic() + s.routesSharePublic() + s.routesStatic() + s.routesHTML() + s.routesAuth() + s.routesSets() + s.routesMedia() + s.routesNotes() + s.routesProgress() + s.routesShares() + s.routesAdmin() } func (s *Server) pingStore(ctx context.Context) error { diff --git a/internal/config.go b/internal/config.go index ed9f738..e3e0647 100644 --- a/internal/config.go +++ b/internal/config.go @@ -58,10 +58,17 @@ func envString(name string, set func(string)) { } } -// LoadConfig reads configuration from environment variables and returns -// a populated Config. Unset variables use the package defaults. -func LoadConfig() (*Config, error) { - cfg := &Config{ +// validLogLevels contains the acceptable values for LOG_LEVEL. +var validLogLevels = map[string]struct{}{ + "debug": {}, + "info": {}, + "warn": {}, + "error": {}, +} + +// defaultConfig returns a Config populated with package-level defaults. +func defaultConfig() *Config { + return &Config{ Port: DefaultPort, MediaRoot: DefaultMediaRoot, DBPath: DefaultDBPath, @@ -72,14 +79,11 @@ func LoadConfig() (*Config, error) { LogLevel: DefaultLogLevel, SecureCookies: DefaultSecureCookies, } +} - validLevels := map[string]struct{}{ - "debug": {}, - "info": {}, - "warn": {}, - "error": {}, - } - +// loadNumericSettings reads PORT, MAX_UPLOAD_SIZE_MB, SESSION_TIMEOUT_HOURS, +// GC_INTERVAL_MINUTES and SHARE_DEFAULT_EXPIRY_DAYS from the environment. +func loadNumericSettings(cfg *Config) error { if err := envInt("PORT", func(n int) error { // Allow 0 so tests can bind to an ephemeral port. if n < 0 || n > 65535 { @@ -87,19 +91,16 @@ func LoadConfig() (*Config, error) { } return nil }, func(n int) { cfg.Port = n }); err != nil { - return nil, err + return err } - envString("MEDIA_ROOT", func(s string) { cfg.MediaRoot = s }) - envString("DB_PATH", func(s string) { cfg.DBPath = s }) - if err := envInt("MAX_UPLOAD_SIZE_MB", func(n int) error { if n < 1 { return fmt.Errorf("must be >= 1, got %d", n) } return nil }, func(n int) { cfg.MaxUploadSizeMB = n }); err != nil { - return nil, err + return err } if err := envInt("SESSION_TIMEOUT_HOURS", func(n int) error { @@ -108,7 +109,7 @@ func LoadConfig() (*Config, error) { } return nil }, func(n int) { cfg.SessionTimeoutHours = n }); err != nil { - return nil, err + return err } if err := envInt("GC_INTERVAL_MINUTES", func(n int) error { @@ -117,7 +118,7 @@ func LoadConfig() (*Config, error) { } return nil }, func(n int) { cfg.GCIntervalMinutes = n }); err != nil { - return nil, err + return err } if err := envInt("SHARE_DEFAULT_EXPIRY_DAYS", func(n int) error { @@ -126,24 +127,60 @@ func LoadConfig() (*Config, error) { } return nil }, func(n int) { cfg.ShareDefaultExpiryDays = n }); err != nil { - return nil, err + return err } + return nil +} + +// loadStringSettings reads MEDIA_ROOT and DB_PATH from the environment. +func loadStringSettings(cfg *Config) { + envString("MEDIA_ROOT", func(s string) { cfg.MediaRoot = s }) + envString("DB_PATH", func(s string) { cfg.DBPath = s }) +} + +// loadLogLevel reads LOG_LEVEL from the environment and validates it. +func loadLogLevel(cfg *Config) error { if v := os.Getenv("LOG_LEVEL"); v != "" { level := strings.ToLower(strings.TrimSpace(v)) - if _, ok := validLevels[level]; !ok { - return nil, fmt.Errorf("invalid LOG_LEVEL: must be one of debug, info, warn, error, got %q", level) + if _, ok := validLogLevels[level]; !ok { + return fmt.Errorf("invalid LOG_LEVEL: must be one of debug, info, warn, error, got %q", level) } cfg.LogLevel = level } + return nil +} +// loadSecureCookies reads SECURE_COOKIES from the environment. +func loadSecureCookies(cfg *Config) error { if v := os.Getenv("SECURE_COOKIES"); v != "" { b, err := strconv.ParseBool(strings.TrimSpace(v)) if err != nil { - return nil, fmt.Errorf("invalid SECURE_COOKIES: %w", err) + return fmt.Errorf("invalid SECURE_COOKIES: %w", err) } cfg.SecureCookies = b } + return nil +} + +// LoadConfig reads configuration from environment variables and returns +// a populated Config. Unset variables use the package defaults. +func LoadConfig() (*Config, error) { + cfg := defaultConfig() + + loadStringSettings(cfg) + + if err := loadNumericSettings(cfg); err != nil { + return nil, err + } + + if err := loadLogLevel(cfg); err != nil { + return nil, err + } + + if err := loadSecureCookies(cfg); err != nil { + return nil, err + } return cfg, nil } diff --git a/internal/repository/migrate.go b/internal/repository/migrate.go index 3c1e8bb..daf94c6 100644 --- a/internal/repository/migrate.go +++ b/internal/repository/migrate.go @@ -5,13 +5,8 @@ import ( "fmt" ) -// Migrate creates the database schema if it does not exist. -func Migrate(db *sql.DB) error { - if _, err := db.Exec(`PRAGMA foreign_keys = ON;`); err != nil { - return fmt.Errorf("enable foreign keys: %w", err) - } - - schema := ` +// tablesSchema defines all CREATE TABLE statements. +const tablesSchema = ` CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, @@ -126,7 +121,10 @@ CREATE TABLE IF NOT EXISTS media_notes ( updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE(media_id, user_id) ); +` +// indexesSchema defines all CREATE INDEX statements. +const indexesSchema = ` CREATE INDEX IF NOT EXISTS idx_media_set_id ON media(set_id); CREATE INDEX IF NOT EXISTS idx_media_rel_path ON media(set_id, rel_path); CREATE INDEX IF NOT EXISTS idx_media_deleted_at ON media(deleted_at); @@ -136,8 +134,33 @@ CREATE INDEX IF NOT EXISTS idx_permissions_user ON set_permissions(user_id); CREATE INDEX IF NOT EXISTS idx_permissions_set ON set_permissions(set_id); CREATE INDEX IF NOT EXISTS idx_shares_expires ON shares(expires_at); ` + +// execSchema executes a raw SQL schema block against the given database. +func execSchema(db *sql.DB, name, schema string) error { if _, err := db.Exec(schema); err != nil { - return fmt.Errorf("execute schema: %w", err) + return fmt.Errorf("execute %s schema: %w", name, err) + } + return nil +} + +// enableForeignKeys turns on SQLite foreign key enforcement. +func enableForeignKeys(db *sql.DB) error { + if _, err := db.Exec(`PRAGMA foreign_keys = ON;`); err != nil { + return fmt.Errorf("enable foreign keys: %w", err) + } + return nil +} + +// Migrate creates the database schema if it does not exist. +func Migrate(db *sql.DB) error { + if err := enableForeignKeys(db); err != nil { + return err + } + if err := execSchema(db, "tables", tablesSchema); err != nil { + return err + } + if err := execSchema(db, "indexes", indexesSchema); err != nil { + return err } return nil } diff --git a/internal/scanner/scanner.go b/internal/scanner/scanner.go index 765bec1..30b20d1 100644 --- a/internal/scanner/scanner.go +++ b/internal/scanner/scanner.go @@ -96,12 +96,9 @@ func (s *FSScanner) Scan(ctx context.Context, root string, progress *model.ScanP return nil } -func (s *FSScanner) scanSet(ctx context.Context, root, setPath string, progress *model.ScanProgress) error { +// ensureSet returns the set ID for the given root/relative paths, creating the set if necessary. +func (s *FSScanner) ensureSet(ctx context.Context, root, setPath string) (int64, string, error) { setName := filepath.Base(setPath) - s.log().Info("scanner set started", "name", setName, "path", setPath) - if progress != nil { - progress.SetCurrentSet(setName) - } relRoot, err := filepath.Rel(root, setPath) if err != nil { relRoot = setName @@ -109,44 +106,42 @@ func (s *FSScanner) scanSet(ctx context.Context, root, setPath string, progress sets, err := s.store.ListSets(ctx) if err != nil { - return fmt.Errorf("list sets for %q: %w", setName, err) + return 0, "", fmt.Errorf("list sets for %q: %w", setName, err) } - var setID int64 - var set *model.Set for i := range sets { if sets[i].RootPath == relRoot { - set = &sets[i] - break + return sets[i].ID, setName, nil } } - if set == nil { - newSet := &model.Set{ - Name: setName, - RootPath: relRoot, - CreatedAt: s.clock.Now(), - } - id, err := s.store.CreateSet(ctx, newSet) - if err != nil { - return fmt.Errorf("create set %q: %w", setName, err) - } - setID = id - } else { - setID = set.ID + + newSet := &model.Set{ + Name: setName, + RootPath: relRoot, + CreatedAt: s.clock.Now(), } + id, err := s.store.CreateSet(ctx, newSet) + if err != nil { + return 0, "", fmt.Errorf("create set %q: %w", setName, err) + } + return id, setName, nil +} - // Build map of existing media for quick lookup by relPath. +// loadExistingMedia builds a lookup map of existing media keyed by relPath. +func (s *FSScanner) loadExistingMedia(ctx context.Context, setID int64, setName string) (map[string]model.Media, error) { existing := make(map[string]model.Media) mediaList, err := s.store.ListMedia(ctx, repository.MediaFilter{SetID: &setID}) if err != nil { - return fmt.Errorf("list media for set %q: %w", setName, err) + return nil, fmt.Errorf("list media for set %q: %w", setName, err) } for _, m := range mediaList { existing[m.RelPath] = m } - newFiles := 0 + return existing, nil +} - // First pass: gather images per directory. +// gatherCoverImages walks the set and records the first cover image per directory. +func (s *FSScanner) gatherCoverImages(setPath string) map[string]string { coverImages := make(map[string]string) _ = s.fs.WalkDir(setPath, func(path string, d fs.DirEntry, err error) error { if err != nil || d.IsDir() || !mediatype.IsCoverImageExt(path) { @@ -162,8 +157,154 @@ func (s *FSScanner) scanSet(ctx context.Context, root, setPath string, progress } return nil }) + return coverImages +} + +// thumbnailForVideo generates a thumbnail for a video file inside the set's .thumbnails directory. +func (s *FSScanner) thumbnailForVideo(ctx context.Context, path, setPath string, duration float64) (string, error) { + thumbDir := filepath.Join(setPath, ".thumbnails") + if err := s.fs.MkdirAll(thumbDir, 0o755); err != nil { + return "", fmt.Errorf("mkdir thumbnails %q: %w", thumbDir, err) + } + thumbName := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) + ".jpg" + thumbnailPath := filepath.Join(thumbDir, thumbName) + if err := s.thumbGen.Generate(ctx, path, thumbnailPath, duration); err != nil { + s.log().Warn("scanner skipping thumbnail", "path", path, "err", err) + return "", nil + } + return thumbnailPath, nil +} + +// thumbnailForImage generates a thumbnail for an image file inside the set's .thumbnails directory. +func (s *FSScanner) thumbnailForImage(ctx context.Context, path, setPath string) (string, error) { + thumbDir := filepath.Join(setPath, ".thumbnails") + if err := s.fs.MkdirAll(thumbDir, 0o755); err != nil { + return "", fmt.Errorf("mkdir thumbnails %q: %w", thumbDir, err) + } + thumbName := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) + ".jpg" + thumbnailPath := filepath.Join(thumbDir, thumbName) + if err := s.thumbGen.Generate(ctx, path, thumbnailPath, 0); err != nil { + s.log().Warn("scanner skipping thumbnail", "path", path, "err", err) + return "", nil + } + return thumbnailPath, nil +} + +// buildThumbnailPath resolves the thumbnail path for a new media file. +func (s *FSScanner) buildThumbnailPath(ctx context.Context, path, setPath string, mediaType model.MediaType, coverImages map[string]string, meta *model.Metadata) (string, error) { + switch mediaType { + case model.MediaTypeVideo: + return s.thumbnailForVideo(ctx, path, setPath, meta.Duration) + case model.MediaTypeAudio: + return findCoverImage(path, coverImages, setPath), nil + case model.MediaTypeImage: + ext := strings.ToLower(filepath.Ext(path)) + if ext == ".svg" { + return path, nil + } + return s.thumbnailForImage(ctx, path, setPath) + } + return "", nil +} + +// processNewFile probes, thumbnails, and persists a single new media file. +func (s *FSScanner) processNewFile(ctx context.Context, path, setPath string, setID int64, setName string, existing map[string]model.Media, coverImages map[string]string, progress *model.ScanProgress) error { + relPath, err := filepath.Rel(setPath, path) + if err != nil { + return fmt.Errorf("rel path for %q: %w", path, err) + } + relPath = filepath.ToSlash(relPath) + _, alreadyExists := existing[relPath] + s.log().Debug("scanner file checked", "set", setName, "path", relPath, "existing", alreadyExists) + if progress != nil { + progress.IncrementFile() + } + if alreadyExists { + return nil + } + + info, err := s.fs.Stat(path) + if err != nil { + return fmt.Errorf("stat %q: %w", path, err) + } + + meta, err := s.prober.Probe(ctx, path) + if err != nil { + s.log().Warn("scanner skipping unprobeable file", "path", path, "err", err) + return nil + } + meta.FileSizeBytes = info.Size() + + mediaType := mediatype.TypeForExt(path) + thumbnailPath, err := s.buildThumbnailPath(ctx, path, setPath, mediaType, coverImages, meta) + if err != nil { + return err + } + + media := &model.Media{ + SetID: setID, + RelPath: relPath, + FileName: filepath.Base(path), + AbsPath: path, + Type: mediaType, + Duration: meta.Duration, + Codec: meta.Codec, + Resolution: meta.Resolution, + Bitrate: meta.Bitrate, + FileSizeBytes: meta.FileSizeBytes, + Width: meta.Width, + Height: meta.Height, + EXIFCamera: meta.EXIFCamera, + EXIFLens: meta.EXIFLens, + EXIFDate: meta.EXIFDate, + EXIFISO: meta.EXIFISO, + EXIFFNumber: meta.EXIFFNumber, + EXIFExposure: meta.EXIFExposure, + EXIFFocalLength: meta.EXIFFocalLength, + ThumbnailPath: thumbnailPath, + CreatedAt: s.clock.Now(), + } + + if _, err := s.store.CreateMedia(ctx, media); err != nil { + return fmt.Errorf("create media %q: %w", path, err) + } + return nil +} + +// updateAudioThumbnails patches existing audio tracks when a new cover image appears. +func (s *FSScanner) updateAudioThumbnails(ctx context.Context, mediaList []model.Media, coverImages map[string]string, setPath string) { + for _, m := range mediaList { + if m.Type != model.MediaTypeAudio || m.ThumbnailPath != "" { + continue + } + candidate := findCoverImage(m.AbsPath, coverImages, setPath) + if candidate != "" && candidate != m.ThumbnailPath { + if err := s.store.UpdateMediaThumbnail(ctx, m.ID, candidate); err != nil { + s.log().Warn("scanner failed to update thumbnail", "file", m.FileName, "err", err) + } + } + } +} + +func (s *FSScanner) scanSet(ctx context.Context, root, setPath string, progress *model.ScanProgress) error { + setID, setName, err := s.ensureSet(ctx, root, setPath) + if err != nil { + return err + } + + s.log().Info("scanner set started", "name", setName, "path", setPath) + if progress != nil { + progress.SetCurrentSet(setName) + } + + existing, err := s.loadExistingMedia(ctx, setID, setName) + if err != nil { + return err + } - // Second pass: walk for NEW media files. + coverImages := s.gatherCoverImages(setPath) + + newFiles := 0 walkErr := s.fs.WalkDir(setPath, func(path string, d fs.DirEntry, err error) error { if err != nil { return fmt.Errorf("walk %q: %w", path, err) @@ -177,95 +318,13 @@ func (s *FSScanner) scanSet(ctx context.Context, root, setPath string, progress if !mediatype.IsSupportedExt(path) { return nil } - relPath, err := filepath.Rel(setPath, path) - if err != nil { - return fmt.Errorf("rel path for %q: %w", path, err) - } - relPath = filepath.ToSlash(relPath) - _, alreadyExists := existing[relPath] - s.log().Debug("scanner file checked", "set", setName, "path", relPath, "existing", alreadyExists) - if progress != nil { - progress.IncrementFile() - } - if alreadyExists { - return nil - } - - info, err := s.fs.Stat(path) - if err != nil { - return fmt.Errorf("stat %q: %w", path, err) - } - - meta, err := s.prober.Probe(ctx, path) - if err != nil { - s.log().Warn("scanner skipping unprobeable file", "path", path, "err", err) - return nil - } - meta.FileSizeBytes = info.Size() - - mediaType := mediatype.TypeForExt(path) - var thumbnailPath string - if mediaType == model.MediaTypeVideo { - thumbDir := filepath.Join(setPath, ".thumbnails") - if err := s.fs.MkdirAll(thumbDir, 0o755); err != nil { - return fmt.Errorf("mkdir thumbnails %q: %w", thumbDir, err) - } - thumbName := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) + ".jpg" - thumbnailPath = filepath.Join(thumbDir, thumbName) - if err := s.thumbGen.Generate(ctx, path, thumbnailPath, meta.Duration); err != nil { - s.log().Warn("scanner skipping thumbnail", "path", path, "err", err) - thumbnailPath = "" - } - } else if mediaType == model.MediaTypeAudio { - thumbnailPath = findCoverImage(path, coverImages, setPath) - } else if mediaType == model.MediaTypeImage { - ext := strings.ToLower(filepath.Ext(path)) - if ext == ".svg" { - thumbnailPath = path - } else { - thumbDir := filepath.Join(setPath, ".thumbnails") - if err := s.fs.MkdirAll(thumbDir, 0o755); err != nil { - return fmt.Errorf("mkdir thumbnails %q: %w", thumbDir, err) - } - thumbName := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) + ".jpg" - thumbnailPath = filepath.Join(thumbDir, thumbName) - if err := s.thumbGen.Generate(ctx, path, thumbnailPath, 0); err != nil { - s.log().Warn("scanner skipping thumbnail", "path", path, "err", err) - thumbnailPath = "" - } - } - } - - media := &model.Media{ - SetID: setID, - RelPath: relPath, - FileName: filepath.Base(path), - AbsPath: path, - Type: mediaType, - Duration: meta.Duration, - Codec: meta.Codec, - Resolution: meta.Resolution, - Bitrate: meta.Bitrate, - FileSizeBytes: meta.FileSizeBytes, - Width: meta.Width, - Height: meta.Height, - EXIFCamera: meta.EXIFCamera, - EXIFLens: meta.EXIFLens, - EXIFDate: meta.EXIFDate, - EXIFISO: meta.EXIFISO, - EXIFFNumber: meta.EXIFFNumber, - EXIFExposure: meta.EXIFExposure, - EXIFFocalLength: meta.EXIFFocalLength, - ThumbnailPath: thumbnailPath, - CreatedAt: s.clock.Now(), - } - - if _, err := s.store.CreateMedia(ctx, media); err != nil { - return fmt.Errorf("create media %q: %w", path, err) + if err := s.processNewFile(ctx, path, setPath, setID, setName, existing, coverImages, progress); err != nil { + return err } newFiles++ if newFi |
