Sync from /srv/compose/unified-media-manager
This commit is contained in:
182
internal/worker/library_scanner.go
Normal file
182
internal/worker/library_scanner.go
Normal file
@@ -0,0 +1,182 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user