package worker import ( "context" "encoding/json" "fmt" "io/fs" "log/slog" "os" "path/filepath" "strings" "time" "github.com/TopherMayor/unified-media-manager/internal/config" "github.com/TopherMayor/unified-media-manager/internal/db" "github.com/TopherMayor/unified-media-manager/internal/service" ) var scannerMediaExts = map[string]bool{ ".mkv": true, ".mp4": true, ".avi": true, ".wmv": true, ".flv": true, ".webm": true, ".mp3": true, ".flac": true, ".m4a": true, ".m4b": true, ".ogg": true, ".opus": true, ".epub": true, ".pdf": true, ".mobi": true, ".azw3": true, } type LibraryScanner struct { database *db.DB matcherSvc *service.MatcherService mediaSvc *service.MediaService cfg *config.Config } func NewLibraryScanner(database *db.DB, matcherSvc *service.MatcherService, mediaSvc *service.MediaService, cfg *config.Config) *LibraryScanner { return &LibraryScanner{ database: database, matcherSvc: matcherSvc, mediaSvc: mediaSvc, cfg: cfg, } } func (w *LibraryScanner) Name() string { return "library_scanner" } func (w *LibraryScanner) CronExpr() string { return w.cfg.WorkerLibraryScanInterval } func (w *LibraryScanner) Run(ctx context.Context) error { ctx, cancel := context.WithTimeout(ctx, 10*time.Minute) defer cancel() scanned := 0 tracked := 0 matched := 0 unmatched := 0 rows, err := w.database.Pool.Query(ctx, "SELECT id, path, media_type FROM root_folders") if err != nil { return fmt.Errorf("query root folders: %w", err) } defer rows.Close() type rootFolder struct { id int64 path string mediaType string } var roots []rootFolder for rows.Next() { var r rootFolder if err := rows.Scan(&r.id, &r.path, &r.mediaType); err != nil { slog.Error("failed to scan root folder", "error", err) continue } roots = append(roots, r) } for _, root := range roots { cleanRoot := filepath.Clean(root.path) err := filepath.WalkDir(root.path, func(path string, d fs.DirEntry, walkErr error) error { if walkErr != nil { return nil } if d.IsDir() { return nil } if !strings.HasPrefix(filepath.Clean(path), cleanRoot) { return nil } ext := filepath.Ext(path) if !scannerMediaExts[ext] { return nil } scanned++ var exists bool checkErr := w.database.Pool.QueryRow(ctx, "SELECT EXISTS(SELECT 1 FROM media_files WHERE path = $1 AND deleted_at IS NULL)", path).Scan(&exists) if checkErr != nil { slog.Error("failed to check tracked file", "path", path, "error", checkErr) return nil } if exists { tracked++ return nil } dirName := filepath.Base(filepath.Dir(path)) fileName := filepath.Base(path) searchName := dirName if searchName == filepath.Base(root.path) { searchName = fileName } matchResult, matchErr := w.matcherSvc.Match(ctx, searchName, root.mediaType) if matchErr != nil { slog.Error("failed to match file", "path", path, "error", matchErr) unmatched++ return nil } if matchResult.Confidence == "none" { unmatched++ return nil } fileInfo, statErr := os.Stat(path) if statErr != nil { slog.Error("failed to stat file", "path", path, "error", statErr) unmatched++ return nil } _, insertErr := w.database.Pool.Exec(ctx, `INSERT INTO media_files (media_id, media_type, path, file_name, file_size, quality, is_hardlinked) VALUES ($1, $2, $3, $4, $5, $6, false)`, matchResult.MediaID, matchResult.MediaType, path, filepath.Base(path), fileInfo.Size(), json.RawMessage("{}")) if insertErr != nil { slog.Error("failed to insert media file", "path", path, "error", insertErr) unmatched++ return nil } _, updateErr := w.database.Pool.Exec(ctx, "UPDATE media SET status = 'available' WHERE id = $1 AND status = 'unavailable'", matchResult.MediaID) if updateErr != nil { slog.Error("failed to update media status", "media_id", matchResult.MediaID, "error", updateErr) } matched++ return nil }) if err != nil { slog.Error("library scan walk error", "root", root.path, "error", err) } } slog.Info("library scan completed", "scanned", scanned, "tracked", tracked, "matched", matched, "unmatched", unmatched) return nil }