diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-04 08:40:38 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-04 08:40:38 +0300 |
| commit | f1319f3d422f4df0844fd48442a665639e749c39 (patch) | |
| tree | 283b0d907a79b251f8f806f7553c2014cd688772 /internal | |
| parent | 81737024f1d39c022d7901de380a5d87df70d4b4 (diff) | |
task 7: refactor oversized functions into helpers
Refactored the following functions per CLAUDE.md (50+ line limit):
- internal/scanner/scanner.go: scanSet (192 → 56 lines)
Extracted: ensureSet, loadExistingMedia, gatherCoverImages,
thumbnailForVideo, thumbnailForImage, buildThumbnailPath,
processNewFile, updateAudioThumbnails
- internal/repository/migrate.go: Migrate (134 → 23 lines)
Extracted: enableForeignKeys, execSchema; split schema into
tablesSchema and indexesSchema constants
- internal/config.go: LoadConfig (85 → 28 lines)
Extracted: defaultConfig, loadNumericSettings, loadStringSettings,
loadLogLevel, loadSecureCookies; added validLogLevels variable
- cmd/mediaplayer/main.go: run / runWithSignal (114 → 26 / 26 lines)
Extracted: parseVersionFlag, buildLogger, wireDeps, runServer,
ensureSignalChannel, shutdownGracefully; introduced appDeps struct
- internal/api/server.go: routes (96 → 8 lines)
Extracted per route group: routesPublic, routesSharePublic,
routesStatic, routesHTML, routesAuth, routesSets, routesMedia,
routesNotes, routesProgress, routesShares, routesAdmin
Added helpers: requireSession, requireAdmin, publicMethod
- internal/service/browse.go: BrowseSet (85 → 26 lines)
Extracted: prefixForParent, classifyMediaPath, buildFolderMap,
folderHasCover, buildFolders
All public interfaces remain unchanged. Tests pass: go test ./... -race -cover
Diffstat (limited to 'internal')
| -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 |
5 files changed, 447 insertions, 270 deletions
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 newFiles == 1 || newFiles%25 == 0 { - s.log().Info("scanner set progress", "name", setName, "new_media", newFiles, "latest", relPath) + relPath, _ := filepath.Rel(setPath, path) + s.log().Info("scanner set progress", "name", setName, "new_media", newFiles, "latest", filepath.ToSlash(relPath)) } return nil }) @@ -273,18 +332,8 @@ func (s *FSScanner) scanSet(ctx context.Context, root, setPath string, progress return fmt.Errorf("scan set %q: %w", setName, walkErr) } - // Third pass: update existing audio files that gained a cover image. - 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) - } - } - } + mediaList, _ := s.store.ListMedia(ctx, repository.MediaFilter{SetID: &setID}) + s.updateAudioThumbnails(ctx, mediaList, coverImages, setPath) s.log().Info("scanner set completed", "name", setName, "existing_media", len(existing), "new_media", newFiles) return nil diff --git a/internal/service/browse.go b/internal/service/browse.go index 8fa72ba..00b5c84 100644 --- a/internal/service/browse.go +++ b/internal/service/browse.go @@ -290,29 +290,43 @@ func (s *browseService) RegenerateSetCover(ctx context.Context, setID int64, fol return nil } -func (s *browseService) BrowseSet(ctx context.Context, setID, userID int64, parent string) (*BrowseResult, error) { - if err := s.helper.checkSetPermission(ctx, setID, userID, ""); err != nil { - return nil, err +// prefixForParent builds the slash-terminated prefix used for matching paths under parent. +func prefixForParent(parent string) string { + if parent == "" { + return "" } + return parent + "/" +} - parent = filepath.ToSlash(strings.Trim(parent, "/")) - media, err := s.store.ListMedia(ctx, repository.MediaFilter{SetID: &setID}) - if err != nil { - return nil, fmt.Errorf("list media: %w", err) +// classifyMediaPath splits a media relPath under a parent prefix into the first path component and remainder. +// It returns name (first component), rest (remaining path), and a bool indicating whether the media is directly inside the parent. +func classifyMediaPath(rel, prefix string) (name string, rest string, isDirect bool) { + if !strings.HasPrefix(rel, prefix) { + return "", "", false } - - set, err := s.store.GetSetByID(ctx, setID) - if err != nil { - return nil, fmt.Errorf("get set: %w", err) + if prefix != "" { + rel = strings.TrimPrefix(rel, prefix) } - if set == nil { - return nil, ErrNotFound + if rel == "" { + return "", "", false } - - type folderContent struct { - files []model.Media - subfolders map[string]struct{} + parts := strings.SplitN(rel, "/", 2) + name = parts[0] + if len(parts) == 1 { + return name, "", true } + return name, parts[1], false +} + +// folderContent collects files and subfolders discovered under a single folder name. +type folderContent struct { + files []model.Media + subfolders map[string]struct{} +} + +// buildFolderMap walks media and groups entries by the first folder component under parent. +func buildFolderMap(media []model.Media, parent string) (map[string]*folderContent, []model.Media) { + prefix := prefixForParent(parent) folderMap := make(map[string]*folderContent) var items []model.Media @@ -321,20 +335,11 @@ func (s *browseService) BrowseSet(ctx context.Context, setID, userID int64, pare continue } rel := filepath.ToSlash(m.RelPath) - prefix := "" - if parent != "" { - prefix = parent + "/" - } - if !strings.HasPrefix(rel, prefix) { - continue - } - suffix := strings.TrimPrefix(rel, prefix) - if suffix == "" { + name, rest, isDirect := classifyMediaPath(rel, prefix) + if name == "" { continue } - parts := strings.SplitN(suffix, "/", 2) - name := parts[0] - if len(parts) == 1 { + if isDirect { items = append(items, m) continue } @@ -343,7 +348,6 @@ func (s *browseService) BrowseSet(ctx context.Context, setID, userID int64, pare fc = &folderContent{subfolders: make(map[string]struct{})} folderMap[name] = fc } - rest := parts[1] subparts := strings.SplitN(rest, "/", 2) if len(subparts) == 1 { fc.files = append(fc.files, m) @@ -351,24 +355,56 @@ func (s *browseService) BrowseSet(ctx context.Context, setID, userID int64, pare fc.subfolders[subparts[0]] = struct{}{} } } + return folderMap, items +} + +// folderHasCover determines whether a folder has a cover image on disk or among thumbnails. +func folderHasCover(mediaRoot, setRootPath, parent, name string, media []model.Media) bool { + subPath := filepath.Join(parent, name) + folderDir := filepath.Clean(filepath.Join(mediaRoot, setRootPath, filepath.FromSlash(subPath))) + coverPath := filepath.Join(folderDir, ".cover.jpg") + _, err := os.Stat(coverPath) + _, hasDirectCover := folderCoverFile(folderDir) + return err == nil || hasDirectCover || randomFolderThumbnail(media, filepath.ToSlash(subPath)) != "" +} +// buildFolders converts the folder map into sorted BrowseFolder results, flattening single-file folders. +func buildFolders(folderMap map[string]*folderContent, media []model.Media, items []model.Media, mediaRoot, setRootPath, parent string) ([]BrowseFolder, []model.Media) { var folders []BrowseFolder for name, fc := range folderMap { - // Flatten: show the lone file at the current level. total := len(fc.files) + len(fc.subfolders) if total == 1 && len(fc.files) == 1 { items = append(items, fc.files[0]) } else { - subPath := filepath.Join(parent, name) - folderDir := filepath.Clean(filepath.Join(s.mediaRoot, set.RootPath, filepath.FromSlash(subPath))) |
