summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--cmd/mediaplayer/main.go167
-rw-r--r--internal/api/server.go186
-rw-r--r--internal/config.go81
-rw-r--r--internal/repository/migrate.go39
-rw-r--r--internal/scanner/scanner.go301
-rw-r--r--internal/service/browse.go110
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