* fix(scanner): stop classifying "other" content folders as extras Regression from #322 (trailers and extras for movies and series), which introduced the extrasDirKinds map. The extras directory classifier mapped the generic labels "other" and "others" to ExtraKindOther. These are not part of the Jellyfin/Plex extras folder convention the map claims to mirror, and they collide with real content-scope folder names. A library organized as "movies/other/<Title (year) {ids}>/<file>" tripped the depth-2 ancestor lookup in classifyExtraPath: every title two levels under the scope folder "other" was classified as an "other"-kind extra. Such files are partitioned out of primary root/group inference and matching, then deferred in processExtraFiles because their parent cannot resolve (they are the primary titles, not children of one). The result on one deployment was ~10k movies under a folder named "other" funneled through the slow extras path every scan (parent-unresolved deferrals at ~9.5/s), stalling the scan and freezing that scope for new/changed primary content. Remove "other"/"others" from extrasDirKinds. The ExtraKindOther kind stays reachable through genuine convention labels (extra/extras/interviews/ scenes/shorts). Add regression coverage asserting titles under a scope folder named other/others stay primary. * perf(scanner): rewrite identity-only changes without re-probing A pure identity/grouping change on an already-probed file — a root_assignment_changed or group_assignment_changed reason with nothing else — used to fall into the full update branch, which unconditionally ran ffprobe (probeFile) and then upserted every column, including probe columns, from the freshly built row. When a group-key or root scheme changes across the library (see #319), this reprobed nearly every file on the next scan: an incremental scan that normally takes ~1h ran 7h+ as a full-library ffprobe storm, even though the media bytes were untouched. Add a metadata-only update path in processFile: when identityOnlyUpdateReasons reports every reason is a root/group reassignment, rewrite just the derived identity columns via the new FileRepository.UpdateIdentity and skip ffprobe, OSHash, and marker fetch entirely. UpdateIdentity issues a targeted UPDATE of the root/group/identity and edition/presentation columns only, mirroring Upsert's column handling, and leaves probe data, file bytes/mtime/hash, subtitles, chapters, markers, and content/episode/extra linkage intact. The stored group key converges to the recomputed value on the next scan, so the file takes the unchanged fast-path thereafter — without a probe storm. The shared identity-column population is extracted into populateScanIdentity so the full path and the metadata-only path stay in lockstep. Verification: unit test for the identityOnlyUpdateReasons classifier; a DB-backed test (skipped without SILO_TEST_DATABASE_URL) asserting UpdateIdentity rewrites grouping while preserving probe/linkage columns; the UPDATE statement was also exercised against the live schema inside a rolled-back transaction. * fix(scanner): harden identity fast path and extras scope classification Review follow-ups for the two scan-regression fixes on this branch, addressing both Codex review comments on PR #341 plus adversarial-review findings. Identity fast path (processFile/UpdateIdentity): - Gate the metadata-only path on existing.ExtraID == "": a row still linked as an extra reaching processFile is being reclassified as primary, and only the full upsert clears extra linkage; UpdateIdentity would have frozen it out of matching forever (match backlog filters extra_id IS NULL). - Gate on existing.FileHash != "": the full path backfills the OSHash and fetches hash-keyed S3 intro/credits markers, which no later scan reason would repair; hash-less legacy rows now take the full path once instead of silently losing that repair channel. file_hash is added to the scan-state row shape to support the gate. - Clear match_suppressed_at like every other scan write, so files with fresh identity re-enter the match backlog (suppression is documented as lasting "until retried or seen by a new scan"). - Write media_folder_id, mirroring Upsert's ON CONFLICT reassignment. - Return ErrFileNotFound when the row vanished mid-scan (concurrent delete) and fall through to the full upsert path instead of surfacing a per-file scan error. - Return only the row id instead of RETURNING all ~75 columns: the fast path fires once per file during library-wide grouping migrations, and dragging the track/chapter JSONB payloads along for a million rows dominated the cost of the path built to be cheap. - Extract identityColumnDefaults shared by Upsert and UpdateIdentity so the defaulting rules cannot drift, and drop the no-op editionConfidence indirection copied between them. - Use populateScanIdentity in the new-file insert path too; it still carried a verbatim copy of the extracted block (with a provably dead existingByPath lookup). Extras classification: - Restore "other" to extrasDirKinds: it is part of both the documented Jellyfin and Plex extras-folder conventions (the removed-label fix overshot and broke "movies/<Title>/Other/<file>" libraries, ingesting their extras as bogus primary titles). "others" stays removed - it is in neither convention. - Replace label removal with the structural guard the PR had deferred: classifyExtraPath now rejects a supplemental-named directory sitting at library-scope depth (the dir, any supplemental ancestor, or the first non-supplemental ancestor is a configured library root). This fixes the original "movies/other/<Title>" defer-storm generically, covering every convention label (shorts, scenes, extras, ...) used as a content-scope folder. - Scope extras parent binding by folder.Paths instead of the walk roots, so a subtree scan targeting a single movie folder still binds that movie's own extras instead of deferring them. Tests: eligibility-gate unit tests, scope-guard classifier cases (convention Other/ inside a title binds; scope-level other/shorts stay primary), and the DB-backed UpdateIdentity test now also covers folder moves, suppression clearing, and ErrFileNotFound. Full scanner suite ran green against a migrated scratch PostgreSQL 17 container. * refactor(scanner): simplify extras scope guard to title-folder rule Replace the ancestor-walking supplementalDirAtScopeDepth loop with the plain rule it was approximating: a convention-named directory counts as an extras dir only when it sits inside a title folder — it must not be a configured library root or directly under one. Same outcome for the layouts that matter (movies/other/<Title> stays primary, <Title>/Other classifies), less machinery. * test(scanner): assert all rewritten identity columns in UpdateIdentity test * fix(scanner): make extras scope classification structure-aware The title-folder rule from 53632022 anchored on library roots, so it missed both directions: chained convention dirs at the root ("movies/extras/behind the scenes/clip.mkv") classified as extras with an unresolvable parent (deferred forever), and category folders nested below the root ("movies/4K/other/<Title>/") still misclassified their titles. Replace the root-distance heuristic with the structural property that actually distinguishes the two cases: a convention-named directory only counts as an extras dir when its owner (first non-supplemental ancestor) is a title folder — a directory that holds media of its own. The new extrasClassifier derives that from the scan's walked path list (no extra I/O): movie folders must hold a file directly beside the extras dir; series folders may hold episodes one level down in season folders (media hiding inside a folder's own extras dirs doesn't count). Library roots never qualify. Watch-event scans, which have no walked list, probe ownership with bounded os.ReadDir instead. This handles title folders at any depth below the root and keeps scope/category folders primary at any depth, with two known edges: a title folder holding only extras (its media file missing) stays primary until the file appears, and a mixed dir holding both loose media and a category folder degrades to deferral, never wrong linkage. resolveExtraParent's inline supplemental-chain walk is extracted into the shared firstNonSupplementalAncestor.
2975 lines
93 KiB
Go
2975 lines
93 KiB
Go
package scanner
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/catalog"
|
|
"github.com/Silo-Server/silo-server/internal/librarykind"
|
|
"github.com/Silo-Server/silo-server/internal/models"
|
|
"github.com/Silo-Server/silo-server/internal/naming"
|
|
"github.com/Silo-Server/silo-server/internal/s3client"
|
|
)
|
|
|
|
// videoExtensions is the set of file extensions recognized as media files.
|
|
var videoExtensions = map[string]bool{
|
|
".mkv": true,
|
|
".mp4": true,
|
|
".avi": true,
|
|
".m4v": true,
|
|
".ts": true,
|
|
".wmv": true,
|
|
}
|
|
|
|
// SupportsVideoFile reports whether the given path uses a recognized media extension.
|
|
func SupportsVideoFile(filePath string) bool {
|
|
return videoExtensions[strings.ToLower(filepath.Ext(filePath))]
|
|
}
|
|
|
|
// ignoredDirNames is the set of directory names skipped during scanning.
|
|
var ignoredDirNames = map[string]bool{
|
|
".recyclebin": true,
|
|
"@recycle": true,
|
|
"@eadir": true,
|
|
".trash": true,
|
|
"#recycle": true,
|
|
"$recycle.bin": true,
|
|
".deleted": true,
|
|
".inbound": true,
|
|
".downloads": true,
|
|
}
|
|
|
|
// ignoredMovieSupplementalDirNames holds movie-library directory names whose
|
|
// contents are never playable content (noise). Extras-shaped directories
|
|
// (Trailers/, Featurettes/, ...) are NOT in this set: they are walked and
|
|
// classified via extrasDirKinds instead of discarded.
|
|
var ignoredMovieSupplementalDirNames = map[string]bool{
|
|
"sample": true,
|
|
"samples": true,
|
|
"subs": true,
|
|
"subtitles": true,
|
|
}
|
|
|
|
// extrasDirKinds classifies supplemental directory names (normalized via
|
|
// normalizeScannerDirLabel) into the shared extra-kind vocabulary. The set
|
|
// mirrors the Jellyfin/Plex extras folder convention ("other" included: both
|
|
// conventions document it).
|
|
//
|
|
// Deliberately absent: the plural "others", which is in neither convention.
|
|
// Convention labels can also appear as content-scope folder names ("movies/
|
|
// other/<Movie>/<file>", "movies/shorts/..."); those never classify as extras
|
|
// because extrasClassifier only honors a convention-named dir owned by a
|
|
// title folder — one that holds media of its own, which library roots and
|
|
// organizational folders do not.
|
|
var extrasDirKinds = map[string]models.ExtraKind{
|
|
"extra": models.ExtraKindOther,
|
|
"extras": models.ExtraKindOther,
|
|
"other": models.ExtraKindOther,
|
|
"featurette": models.ExtraKindFeaturette,
|
|
"featurettes": models.ExtraKindFeaturette,
|
|
"behind the scenes": models.ExtraKindBehindTheScenes,
|
|
"deleted scene": models.ExtraKindDeletedScene,
|
|
"deleted scenes": models.ExtraKindDeletedScene,
|
|
"trailer": models.ExtraKindTrailer,
|
|
"trailers": models.ExtraKindTrailer,
|
|
"teaser": models.ExtraKindTeaser,
|
|
"teasers": models.ExtraKindTeaser,
|
|
"clip": models.ExtraKindClip,
|
|
"clips": models.ExtraKindClip,
|
|
"bloopers": models.ExtraKindBloopers,
|
|
"interviews": models.ExtraKindOther,
|
|
"scenes": models.ExtraKindOther,
|
|
"shorts": models.ExtraKindOther,
|
|
}
|
|
|
|
func normalizeScannerDirLabel(name string) string {
|
|
surface := strings.ToLower(strings.TrimSpace(name))
|
|
surface = strings.NewReplacer(".", " ", "_", " ", "-", " ").Replace(surface)
|
|
return strings.Join(strings.Fields(surface), " ")
|
|
}
|
|
|
|
func shouldSkipMovieSupplementalDir(path string) bool {
|
|
label := normalizeScannerDirLabel(filepath.Base(path))
|
|
if label == "" {
|
|
return false
|
|
}
|
|
return ignoredMovieSupplementalDirNames[label]
|
|
}
|
|
|
|
func shouldSkipMovieSupplementalFile(path string) bool {
|
|
baseNoExt := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
|
|
surface := normalizeScannerDirLabel(baseNoExt)
|
|
if surface == "" {
|
|
return false
|
|
}
|
|
if surface != "sample" && !strings.HasPrefix(surface, "sample ") && !strings.HasSuffix(surface, " sample") {
|
|
return false
|
|
}
|
|
|
|
parentTitle, parentYear, trusted := naming.ParseInferFolderTitleYear(filepath.Base(filepath.Dir(path)))
|
|
if !trusted {
|
|
return true
|
|
}
|
|
stem := naming.ParseInferMovieStem(baseNoExt, parentTitle, parentYear)
|
|
return stem.Title == "" || !naming.InferTitlesCoherent(parentTitle, stem.Title)
|
|
}
|
|
|
|
// Scanner discovers and indexes media files in media folders.
|
|
// scannerImageCacher is the slice of imagecache.Cacher the book scanners use.
|
|
// Wired via SetImageCacher; nil-safe when absent.
|
|
type scannerImageCacher interface {
|
|
audiobookCoverCacher
|
|
ebookCoverCacher
|
|
}
|
|
|
|
type Scanner struct {
|
|
fileRepo *FileRepository
|
|
rootSnapshotRepo *ScannedRootRepository
|
|
groupSnapshotRepo *ScannedGroupRepository
|
|
rootOverrideRepo *MediaRootOverrideRepository
|
|
groupOverrideRepo *MediaGroupOverrideRepository
|
|
identityOverrideRepo *MediaIdentityOverrideRepository
|
|
locationRepo *ObservedLocationRepository
|
|
groupLocationRepo *GroupLocationRepository
|
|
folderRepo *catalog.FolderRepository
|
|
libraryRepo *catalog.LibraryItemRepository
|
|
episodeLibraryRepo *catalog.EpisodeLibraryRepository
|
|
itemRepo *catalog.ItemRepository
|
|
personRepo *catalog.PersonRepository
|
|
episodeRepo *catalog.EpisodeRepository
|
|
extraRepo *catalog.ExtraRepository
|
|
ffprobePath string
|
|
s3Client *s3client.Client // public assets bucket (may be nil)
|
|
imageCacher scannerImageCacher
|
|
// workers is atomic so admin settings changes can resize the per-scan
|
|
// worker pool while a scan is running (applies to the next scan).
|
|
workers atomic.Int32
|
|
emptyTrashAfterScan bool
|
|
// fileRemovalGrace is how long a file must have been marked missing before
|
|
// emptying trash hard-deletes its row. Missing files are hidden from
|
|
// clients immediately; the grace only delays losing per-file state so a
|
|
// file that reappears (flapping mount, reverted upgrade) restores cheaply.
|
|
fileRemovalGrace time.Duration
|
|
markerFetcher func(context.Context, string) *IntroCreditsMarkers
|
|
metadataQueue MetadataQueueProducer
|
|
movieQueueSyncer MovieQueueSyncer
|
|
seriesQueueSyncer SeriesQueueSyncer
|
|
literaryWorkLinker LiteraryWorkLinker
|
|
}
|
|
|
|
// SetImageCacher installs the imagecache.Cacher used by book scanners to push
|
|
// embedded cover art into the public assets bucket. Optional; if unset, local
|
|
// covers are not extracted.
|
|
func (s *Scanner) SetImageCacher(cacher scannerImageCacher) {
|
|
if s == nil {
|
|
return
|
|
}
|
|
s.imageCacher = cacher
|
|
}
|
|
|
|
// SetWorkers updates the scan worker pool size. Safe for concurrent use; the
|
|
// next scan run picks the new value up. Values below 1 are ignored.
|
|
func (s *Scanner) SetWorkers(workers int) {
|
|
if s == nil || workers < 1 {
|
|
return
|
|
}
|
|
s.workers.Store(int32(workers))
|
|
}
|
|
|
|
func (s *Scanner) workerCount() int { return int(s.workers.Load()) }
|
|
|
|
const scanProgressLogInterval = 10 * time.Second
|
|
|
|
// MetadataQueueProducer enqueues durable initial metadata work rows after a
|
|
// successful scan upsert.
|
|
type MetadataQueueProducer interface {
|
|
EnqueueMovieFile(ctx context.Context, fileID int) error
|
|
EnqueueSeriesRoot(ctx context.Context, folderID int, observedRootPath string) error
|
|
}
|
|
|
|
type LiteraryWorkLinker interface {
|
|
AutoLinkContent(ctx context.Context, contentID string) (workID string, linked bool, err error)
|
|
}
|
|
|
|
func (s *Scanner) SetLiteraryWorkLinker(linker LiteraryWorkLinker) {
|
|
if s == nil {
|
|
return
|
|
}
|
|
s.literaryWorkLinker = linker
|
|
}
|
|
|
|
// MovieQueueSyncer synchronizes pending movie-file match queue state from the
|
|
// scanner's persisted file rows.
|
|
type MovieQueueSyncer interface {
|
|
SyncForFolder(ctx context.Context, folderID int) error
|
|
SyncInScope(ctx context.Context, folderID int, scopePath string) error
|
|
}
|
|
|
|
// SeriesQueueSyncer synchronizes pending series-root match queue state from
|
|
// the scanner's persisted snapshot tables.
|
|
type SeriesQueueSyncer interface {
|
|
SyncForFolder(ctx context.Context, folderID int) error
|
|
SyncInScope(ctx context.Context, folderID int, scopePath string) error
|
|
}
|
|
|
|
// NewScanner creates a new Scanner with the given dependencies.
|
|
func NewScanner(fileRepo *FileRepository, ffprobePath string, s3Client *s3client.Client, workers int, emptyTrashAfterScan bool, fileRemovalGrace time.Duration) *Scanner {
|
|
if workers < 1 {
|
|
workers = 8
|
|
}
|
|
if fileRemovalGrace < 0 {
|
|
fileRemovalGrace = 0
|
|
}
|
|
s := &Scanner{
|
|
fileRepo: fileRepo,
|
|
rootSnapshotRepo: NewScannedRootRepository(fileRepo.Pool()),
|
|
groupSnapshotRepo: NewScannedGroupRepository(fileRepo.Pool()),
|
|
rootOverrideRepo: NewMediaRootOverrideRepository(fileRepo.Pool()),
|
|
groupOverrideRepo: NewMediaGroupOverrideRepository(fileRepo.Pool()),
|
|
identityOverrideRepo: NewMediaIdentityOverrideRepository(fileRepo.Pool()),
|
|
locationRepo: NewObservedLocationRepository(fileRepo.Pool()),
|
|
groupLocationRepo: NewGroupLocationRepository(fileRepo.Pool()),
|
|
folderRepo: catalog.NewFolderRepository(fileRepo.Pool()),
|
|
libraryRepo: catalog.NewLibraryItemRepository(fileRepo.Pool()),
|
|
episodeLibraryRepo: catalog.NewEpisodeLibraryRepository(fileRepo.Pool()),
|
|
itemRepo: catalog.NewItemRepository(fileRepo.Pool()),
|
|
personRepo: catalog.NewPersonRepository(fileRepo.Pool()),
|
|
episodeRepo: catalog.NewEpisodeRepository(fileRepo.Pool()),
|
|
extraRepo: catalog.NewExtraRepository(fileRepo.Pool()),
|
|
ffprobePath: ffprobePath,
|
|
s3Client: s3Client,
|
|
emptyTrashAfterScan: emptyTrashAfterScan,
|
|
fileRemovalGrace: fileRemovalGrace,
|
|
markerFetcher: nil,
|
|
}
|
|
s.SetWorkers(workers)
|
|
return s
|
|
}
|
|
|
|
func (s *Scanner) SetSearchIndexProvider(provider string) {
|
|
if s == nil || s.itemRepo == nil {
|
|
return
|
|
}
|
|
s.itemRepo.WithActiveSearchProvider(provider)
|
|
}
|
|
|
|
// SetSeriesQueueSyncer installs the optional pending-series root queue synchronizer.
|
|
func (s *Scanner) SetSeriesQueueSyncer(syncer SeriesQueueSyncer) {
|
|
if s == nil {
|
|
return
|
|
}
|
|
s.seriesQueueSyncer = syncer
|
|
}
|
|
|
|
// SetMovieQueueSyncer installs the optional pending movie-file match queue synchronizer.
|
|
func (s *Scanner) SetMovieQueueSyncer(syncer MovieQueueSyncer) {
|
|
if s == nil {
|
|
return
|
|
}
|
|
s.movieQueueSyncer = syncer
|
|
}
|
|
|
|
// SetMetadataQueueProducer installs the optional inline metadata queue producer.
|
|
func (s *Scanner) SetMetadataQueueProducer(producer MetadataQueueProducer) {
|
|
if s == nil {
|
|
return
|
|
}
|
|
s.metadataQueue = producer
|
|
}
|
|
|
|
// ScanFolder walks a media folder's directory tree, discovers media files,
|
|
// probes them for technical data, and upserts them into the database.
|
|
// Files previously in the DB that no longer exist on disk are marked as missing.
|
|
//
|
|
// Audiobook libraries are handled by ScanAudiobookFolder and podcast
|
|
// libraries by ScanPodcastFolder; both bypass the per-file movie/TV
|
|
// pipeline entirely.
|
|
func (s *Scanner) ScanFolder(ctx context.Context, folder *models.MediaFolder) (*ScanResult, error) {
|
|
watchCtx, stopWatch := s.watchFolderContext(ctx, folder.ID)
|
|
defer stopWatch()
|
|
|
|
if librarykind.IsAudiobook(folder.Type) {
|
|
if err := s.ScanAudiobookFolder(watchCtx, folder, true); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.syncFolderScopedAudioLibraryState(watchCtx, folder.ID); err != nil {
|
|
return nil, err
|
|
}
|
|
return &ScanResult{}, nil
|
|
}
|
|
|
|
if librarykind.IsPodcast(folder.Type) {
|
|
if err := s.ScanPodcastFolder(watchCtx, folder); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.syncFolderScopedAudioLibraryState(watchCtx, folder.ID); err != nil {
|
|
return nil, err
|
|
}
|
|
return &ScanResult{}, nil
|
|
}
|
|
|
|
if librarykind.IsManga(folder.Type) {
|
|
if err := s.ScanMangaFolder(watchCtx, folder); err != nil {
|
|
return nil, err
|
|
}
|
|
return &ScanResult{}, nil
|
|
}
|
|
|
|
if librarykind.IsEbook(folder.Type) {
|
|
if err := s.ScanEbookFolder(watchCtx, folder); err != nil {
|
|
return nil, err
|
|
}
|
|
return &ScanResult{}, nil
|
|
}
|
|
|
|
return s.scanPaths(watchCtx, folder, folder.Paths, folder.Paths, true)
|
|
}
|
|
|
|
// ScanSubtree walks a single subtree within a media folder and reconciles only
|
|
// files that live beneath that subtree.
|
|
func (s *Scanner) ScanSubtree(ctx context.Context, folder *models.MediaFolder, subtreePath string) (*ScanResult, error) {
|
|
cleanSubtree := filepath.Clean(subtreePath)
|
|
watchCtx, stopWatch := s.watchFolderContext(ctx, folder.ID)
|
|
defer stopWatch()
|
|
if librarykind.IsAudiobook(folder.Type) {
|
|
scanRoot, err := cleanScopedAudiobookScanRoot(subtreePath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.ScanAudiobookFolder(watchCtx, scopedFolderPaths(folder, []string{scanRoot}), false); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.syncFolderScopedAudioLibraryState(watchCtx, folder.ID); err != nil {
|
|
return nil, err
|
|
}
|
|
return &ScanResult{}, nil
|
|
}
|
|
if librarykind.IsManga(folder.Type) {
|
|
if err := s.scanMangaPaths(watchCtx, folder, []string{cleanSubtree}, false); err != nil {
|
|
return nil, err
|
|
}
|
|
return &ScanResult{}, nil
|
|
}
|
|
if librarykind.IsEbook(folder.Type) {
|
|
if err := s.scanEbookPaths(watchCtx, folder, []string{cleanSubtree}, false); err != nil {
|
|
return nil, err
|
|
}
|
|
return &ScanResult{}, nil
|
|
}
|
|
return s.scanPaths(watchCtx, folder, []string{cleanSubtree}, []string{cleanSubtree}, false)
|
|
}
|
|
|
|
func cleanScopedAudiobookScanRoot(path string) (string, error) {
|
|
clean := filepath.Clean(path)
|
|
if clean == "" || clean == "." || clean == ".." || clean == string(filepath.Separator) ||
|
|
strings.HasPrefix(clean, ".."+string(filepath.Separator)) || !filepath.IsAbs(clean) {
|
|
return "", fmt.Errorf("invalid audiobook scan root: %s", path)
|
|
}
|
|
return clean, nil
|
|
}
|
|
|
|
func scopedFolderPaths(folder *models.MediaFolder, paths []string) *models.MediaFolder {
|
|
if folder == nil {
|
|
return nil
|
|
}
|
|
clone := *folder
|
|
clone.Paths = paths
|
|
return &clone
|
|
}
|
|
|
|
// walkMode tells walkLogicalTree which file extensions to surface and
|
|
// which library-specific filename heuristics (sample/extra skipping)
|
|
// to apply.
|
|
type walkMode int
|
|
|
|
const (
|
|
walkModeVideo walkMode = iota // bare video walk: video extensions, no movie skipping
|
|
walkModeMovie // movie library: video extensions + sample/extra skipping
|
|
walkModeAudiobook // audiobook library: audio extensions, no skipping
|
|
walkModePodcast // podcast library: audio extensions, no skipping
|
|
walkModeEbook // ebook library: ebook extensions, no skipping
|
|
)
|
|
|
|
// walkModeFor derives a walkMode from a media_folders.type string.
|
|
// Unknown types default to walkModeVideo so existing call sites that
|
|
// pass arbitrary types preserve their prior behavior.
|
|
func walkModeFor(folderType string) walkMode {
|
|
switch {
|
|
case librarykind.IsMovie(folderType):
|
|
return walkModeMovie
|
|
case librarykind.IsAudiobook(folderType):
|
|
return walkModeAudiobook
|
|
case librarykind.IsPodcast(folderType):
|
|
return walkModePodcast
|
|
case librarykind.IsEbook(folderType):
|
|
return walkModeEbook
|
|
case librarykind.IsManga(folderType):
|
|
// Manga chapters are .cbz/.cbr archives, surfaced by the ebook walk.
|
|
return walkModeEbook
|
|
default:
|
|
return walkModeVideo
|
|
}
|
|
}
|
|
|
|
// acceptsExt reports whether the given lowercased extension belongs to
|
|
// the file types this walk mode is looking for.
|
|
func (m walkMode) acceptsExt(ext string) bool {
|
|
switch m {
|
|
case walkModeAudiobook, walkModePodcast:
|
|
return audioExtensions[ext]
|
|
case walkModeEbook:
|
|
return ebookExtensions[ext]
|
|
default:
|
|
return videoExtensions[ext]
|
|
}
|
|
}
|
|
|
|
func canonicalWalkPath(path string) (string, error) {
|
|
resolved, err := filepath.EvalSymlinks(path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
abs, err := filepath.Abs(resolved)
|
|
if err == nil {
|
|
resolved = abs
|
|
}
|
|
return filepath.Clean(resolved), nil
|
|
}
|
|
|
|
func isIgnoredDirectoryPath(path string) bool {
|
|
return ignoredDirNames[strings.ToLower(filepath.Base(path))]
|
|
}
|
|
|
|
// recordWalkFailure counts an entry the walk could not read or resolve.
|
|
// Callers that pass a non-nil counter (the book scanners) use it to exclude
|
|
// incompletely walked roots from missing-file reconciliation; the video walk
|
|
// passes nil and keeps its historical log-and-continue behavior.
|
|
func recordWalkFailure(failures *int) {
|
|
if failures != nil {
|
|
*failures++
|
|
}
|
|
}
|
|
|
|
func walkLogicalTree(
|
|
ctx context.Context,
|
|
logicalPath string,
|
|
physicalPath string,
|
|
mode walkMode,
|
|
visitedPhysicalDirs map[string]struct{},
|
|
filePaths *[]string,
|
|
walkFailures *int,
|
|
) error {
|
|
if ctx != nil {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
info, err := os.Lstat(physicalPath)
|
|
if err != nil {
|
|
slog.Warn("scanner: walk lstat failed", "path", logicalPath, "physical_path", physicalPath, "error", err)
|
|
recordWalkFailure(walkFailures)
|
|
return nil
|
|
}
|
|
|
|
if info.Mode()&os.ModeSymlink != 0 {
|
|
resolved, err := filepath.EvalSymlinks(physicalPath)
|
|
if err != nil {
|
|
slog.Warn("scanner: symlink resolve failed", "path", logicalPath, "physical_path", physicalPath, "error", err)
|
|
recordWalkFailure(walkFailures)
|
|
return nil
|
|
}
|
|
targetInfo, err := os.Stat(resolved)
|
|
if err != nil {
|
|
slog.Warn("scanner: symlink stat failed", "path", logicalPath, "resolved_path", resolved, "error", err)
|
|
recordWalkFailure(walkFailures)
|
|
return nil
|
|
}
|
|
if targetInfo.IsDir() {
|
|
return walkLogicalTree(ctx, logicalPath, resolved, mode, visitedPhysicalDirs, filePaths, walkFailures)
|
|
}
|
|
if mode == walkModeMovie && shouldSkipMovieSupplementalFile(logicalPath) {
|
|
return nil
|
|
}
|
|
if mode.acceptsExt(strings.ToLower(filepath.Ext(logicalPath))) {
|
|
*filePaths = append(*filePaths, logicalPath)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
if !info.IsDir() {
|
|
if mode == walkModeMovie && shouldSkipMovieSupplementalFile(logicalPath) {
|
|
return nil
|
|
}
|
|
if mode.acceptsExt(strings.ToLower(filepath.Ext(logicalPath))) {
|
|
*filePaths = append(*filePaths, logicalPath)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
canonicalDir, err := canonicalWalkPath(physicalPath)
|
|
if err != nil {
|
|
slog.Warn("scanner: canonical path resolution failed", "path", logicalPath, "physical_path", physicalPath, "error", err)
|
|
recordWalkFailure(walkFailures)
|
|
return nil
|
|
}
|
|
if _, seen := visitedPhysicalDirs[canonicalDir]; seen {
|
|
return nil
|
|
}
|
|
visitedPhysicalDirs[canonicalDir] = struct{}{}
|
|
|
|
if isIgnoredDirectoryPath(logicalPath) {
|
|
return nil
|
|
}
|
|
if mode == walkModeMovie && shouldSkipMovieSupplementalDir(logicalPath) {
|
|
return nil
|
|
}
|
|
|
|
entries, err := os.ReadDir(physicalPath)
|
|
if err != nil {
|
|
slog.Warn("scanner: directory read failed", "path", logicalPath, "physical_path", physicalPath, "error", err)
|
|
recordWalkFailure(walkFailures)
|
|
return nil
|
|
}
|
|
for _, entry := range entries {
|
|
if ctx != nil {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
logicalChild := filepath.Join(logicalPath, entry.Name())
|
|
physicalChild := filepath.Join(physicalPath, entry.Name())
|
|
|
|
if entry.Type()&os.ModeSymlink != 0 {
|
|
resolved, err := filepath.EvalSymlinks(physicalChild)
|
|
if err != nil {
|
|
slog.Warn("scanner: symlink resolve failed", "path", logicalChild, "physical_path", physicalChild, "error", err)
|
|
recordWalkFailure(walkFailures)
|
|
continue
|
|
}
|
|
targetInfo, err := os.Stat(resolved)
|
|
if err != nil {
|
|
slog.Warn("scanner: symlink stat failed", "path", logicalChild, "resolved_path", resolved, "error", err)
|
|
recordWalkFailure(walkFailures)
|
|
continue
|
|
}
|
|
if targetInfo.IsDir() {
|
|
if err := walkLogicalTree(ctx, logicalChild, resolved, mode, visitedPhysicalDirs, filePaths, walkFailures); err != nil {
|
|
return err
|
|
}
|
|
continue
|
|
}
|
|
if mode == walkModeMovie && shouldSkipMovieSupplementalFile(logicalChild) {
|
|
continue
|
|
}
|
|
if mode.acceptsExt(strings.ToLower(filepath.Ext(entry.Name()))) {
|
|
*filePaths = append(*filePaths, logicalChild)
|
|
}
|
|
continue
|
|
}
|
|
|
|
if entry.IsDir() {
|
|
if err := walkLogicalTree(ctx, logicalChild, physicalChild, mode, visitedPhysicalDirs, filePaths, walkFailures); err != nil {
|
|
return err
|
|
}
|
|
continue
|
|
}
|
|
|
|
if mode == walkModeMovie && shouldSkipMovieSupplementalFile(logicalChild) {
|
|
continue
|
|
}
|
|
if mode.acceptsExt(strings.ToLower(filepath.Ext(entry.Name()))) {
|
|
*filePaths = append(*filePaths, logicalChild)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func collectLogicalFilePaths(ctx context.Context, walkRoots []string, libraryType string) ([]string, error) {
|
|
filePaths := make([]string, 0)
|
|
visitedPhysicalDirs := make(map[string]struct{})
|
|
mode := walkModeFor(libraryType)
|
|
|
|
for _, rootPath := range walkRoots {
|
|
if ctx != nil {
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
cleanRoot := filepath.Clean(rootPath)
|
|
if cleanRoot == "" || cleanRoot == "." {
|
|
continue
|
|
}
|
|
if err := walkLogicalTree(ctx, cleanRoot, cleanRoot, mode, visitedPhysicalDirs, &filePaths, nil); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
return filePaths, nil
|
|
}
|
|
|
|
func (s *Scanner) scanPaths(
|
|
ctx context.Context,
|
|
folder *models.MediaFolder,
|
|
walkRoots []string,
|
|
reconcileRoots []string,
|
|
allowEmptyRootGuard bool,
|
|
) (*ScanResult, error) {
|
|
if err := s.ensureFolderEnabled(ctx, folder.ID); err != nil {
|
|
return nil, err
|
|
}
|
|
if allowEmptyRootGuard {
|
|
return s.scanFolderByRoots(ctx, folder, walkRoots, reconcileRoots)
|
|
}
|
|
|
|
result := &ScanResult{}
|
|
|
|
// Get existing files in this scan scope from the DB.
|
|
var (
|
|
existingFiles []*scanStateFile
|
|
err error
|
|
)
|
|
if allowEmptyRootGuard {
|
|
existingFiles, err = s.fileRepo.GetScanStateByFolder(ctx, folder.ID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("getting existing files for folder %d: %w", folder.ID, err)
|
|
}
|
|
} else {
|
|
existingFiles, err = s.fileRepo.GetScanStateByFolderAndPathPrefix(ctx, folder.ID, reconcileRoots[0])
|
|
if err != nil {
|
|
return nil, fmt.Errorf("getting existing files for folder %d path %q: %w", folder.ID, reconcileRoots[0], err)
|
|
}
|
|
}
|
|
|
|
// Build a set of existing file paths for quick lookup.
|
|
existingByPath := make(map[string]*scanStateFile, len(existingFiles))
|
|
for _, f := range existingFiles {
|
|
existingByPath[f.FilePath] = f
|
|
}
|
|
existingContentStatuses, err := s.itemRepo.GetStatusByIDs(ctx, collectScanStateContentIDs(existingFiles))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("loading item statuses for folder %d: %w", folder.ID, err)
|
|
}
|
|
|
|
// Phase 1: Collect all media file paths.
|
|
reportProgress(ctx, ProgressUpdate{
|
|
Phase: "walking",
|
|
Message: "Discovering media files",
|
|
CurrentScope: firstScope(reconcileRoots),
|
|
})
|
|
filePaths, walkErr := collectLogicalFilePaths(ctx, walkRoots, folder.Type)
|
|
if walkErr != nil {
|
|
return nil, fmt.Errorf("walking media roots: %w", walkErr)
|
|
}
|
|
slog.Info("scanner: discovered files",
|
|
"folder_id", folder.ID,
|
|
"scope", firstScope(reconcileRoots),
|
|
"files", len(filePaths),
|
|
)
|
|
reportProgress(ctx, ProgressUpdate{
|
|
Phase: "processing",
|
|
Message: "Processing discovered files",
|
|
CurrentScope: firstScope(reconcileRoots),
|
|
TotalFiles: len(filePaths),
|
|
FilesDiscovered: len(filePaths),
|
|
})
|
|
|
|
// Track which paths we see on disk so we can detect missing files.
|
|
// Extras count as seen (they own media_files rows) but are partitioned
|
|
// out of identity inference and match processing below.
|
|
seenPaths := make(map[string]bool, len(filePaths))
|
|
for _, p := range filePaths {
|
|
seenPaths[p] = true
|
|
}
|
|
primaryPaths, extraCandidates := partitionExtraPaths(filePaths, folder.Type, folder.Paths)
|
|
rootOverrides, err := s.loadRootOverrides(ctx, folder.ID, reconcileRoots)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("loading root overrides: %w", err)
|
|
}
|
|
rootInference := inferRootAssignments(primaryPaths, folder.Type, folder.ID, rootOverrides)
|
|
identityOverrides, err := s.loadIdentityOverrides(ctx, folder.ID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("loading identity overrides: %w", err)
|
|
}
|
|
groupInference := inferGroupAssignments(primaryPaths, folder.Type, folder.ID, rootInference.Assignments, identityOverrides)
|
|
groupOverrides, err := s.loadGroupOverrides(ctx, folder.ID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("loading group overrides: %w", err)
|
|
}
|
|
applyGroupOverrides(&groupInference, groupOverrides)
|
|
result.RootObservations = rootInference.Observations
|
|
s.logRootInferenceDisagreements(rootInference.Assignments)
|
|
if err := s.reconcileScannedRoots(
|
|
ctx,
|
|
folder.ID,
|
|
reconcileRoots,
|
|
rootInference.Snapshots,
|
|
); err != nil {
|
|
return nil, fmt.Errorf("reconciling scanned roots: %w", err)
|
|
}
|
|
if _, err := s.clearLegacyLinksForUnmatchableRoots(ctx, folder.ID, result.RootObservations); err != nil {
|
|
return nil, fmt.Errorf("clearing legacy links for unmatchable roots: %w", err)
|
|
}
|
|
|
|
allowEmptyCleanup := false
|
|
if allowEmptyRootGuard && len(filePaths) == 0 && len(existingFiles) > 0 {
|
|
var err error
|
|
allowEmptyCleanup, err = s.folderRepo.ConsumeEmptyCleanupAllowance(ctx, folder.ID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("checking empty cleanup confirmation for folder %d: %w", folder.ID, err)
|
|
}
|
|
if !allowEmptyCleanup {
|
|
result.EmptyRootGuarded = true
|
|
if err := s.folderRepo.SetScanWarning(ctx, folder.ID,
|
|
"empty_root",
|
|
"Scan found 0 media files; cleanup was skipped until deletion is confirmed.",
|
|
time.Now().UTC(),
|
|
); err != nil {
|
|
return nil, fmt.Errorf("recording empty-root warning for folder %d: %w", folder.ID, err)
|
|
}
|
|
return result, nil
|
|
}
|
|
}
|
|
|
|
// Phase 2: Process files concurrently with a worker pool.
|
|
var wg sync.WaitGroup
|
|
pathCh := make(chan string, s.workerCount())
|
|
var newCount, updatedCount, unchangedCount, errorCount, processedCount atomic.Int64
|
|
subtitleCache := newExternalSubtitleDirCache()
|
|
|
|
for range s.workerCount() {
|
|
wg.Go(func() {
|
|
for path := range pathCh {
|
|
if ctx.Err() != nil {
|
|
continue // drain channel
|
|
}
|
|
action, updateReasons, processErr := s.processFile(ctx, path, folder, existingByPath, existingContentStatuses, rootInference.Assignments[path], groupInference.Assignments[path], subtitleCache)
|
|
if processErr != nil {
|
|
slog.Error("scanner: file processing failed", "path", path, "error", processErr)
|
|
errorCount.Add(1)
|
|
continue
|
|
}
|
|
switch action {
|
|
case actionNew:
|
|
newCount.Add(1)
|
|
slog.Debug("scanner: new file added", "path", path)
|
|
case actionUpdated:
|
|
updatedCount.Add(1)
|
|
slog.Debug("scanner: file updated", "path", path, "reasons", updateReasons)
|
|
case actionUnchanged:
|
|
unchangedCount.Add(1)
|
|
}
|
|
processedCount.Add(1)
|
|
}
|
|
})
|
|
}
|
|
|
|
stopProgress := make(chan struct{})
|
|
progressDone := make(chan struct{})
|
|
go func() {
|
|
ticker := time.NewTicker(scanProgressLogInterval)
|
|
defer ticker.Stop()
|
|
defer close(progressDone)
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-stopProgress:
|
|
return
|
|
case <-ticker.C:
|
|
processed := int(processedCount.Load())
|
|
total := len(filePaths)
|
|
slog.Info("scanner: processing progress",
|
|
"folder_id", folder.ID,
|
|
"scope", firstScope(reconcileRoots),
|
|
"processed", processed,
|
|
"total", total,
|
|
"new", newCount.Load(),
|
|
"updated", updatedCount.Load(),
|
|
"unchanged", unchangedCount.Load(),
|
|
"errors", errorCount.Load(),
|
|
)
|
|
reportProgress(ctx, ProgressUpdate{
|
|
Phase: "processing",
|
|
Message: "Processing discovered files",
|
|
CurrentScope: firstScope(reconcileRoots),
|
|
TotalFiles: total,
|
|
FilesDiscovered: total,
|
|
FilesProcessed: processed,
|
|
New: int(newCount.Load()),
|
|
Updated: int(updatedCount.Load()),
|
|
Unchanged: int(unchangedCount.Load()),
|
|
Errors: int(errorCount.Load()),
|
|
})
|
|
}
|
|
}
|
|
}()
|
|
|
|
for _, p := range primaryPaths {
|
|
pathCh <- p
|
|
}
|
|
close(pathCh)
|
|
wg.Wait()
|
|
close(stopProgress)
|
|
<-progressDone
|
|
|
|
result.New = int(newCount.Load())
|
|
result.Updated = int(updatedCount.Load())
|
|
result.Unchanged = int(unchangedCount.Load())
|
|
result.Errors = int(errorCount.Load())
|
|
reportProgress(ctx, ProgressUpdate{
|
|
Phase: "reconciling",
|
|
Message: "Reconciling scan state",
|
|
CurrentScope: firstScope(reconcileRoots),
|
|
TotalFiles: len(filePaths),
|
|
FilesDiscovered: len(filePaths),
|
|
FilesProcessed: int(processedCount.Load()),
|
|
New: result.New,
|
|
Updated: result.Updated,
|
|
Unchanged: result.Unchanged,
|
|
Errors: result.Errors,
|
|
})
|
|
if walkErr != nil {
|
|
return nil, walkErr
|
|
}
|
|
|
|
// If the scan was cancelled, return partial results without marking
|
|
// files as missing or deleting records — that would corrupt state.
|
|
if ctx.Err() != nil {
|
|
return result, ctx.Err()
|
|
}
|
|
|
|
extraStats := s.processExtraFiles(ctx, folder, extraCandidates, existingByPath)
|
|
result.New += extraStats.New
|
|
result.Updated += extraStats.Updated
|
|
result.Unchanged += extraStats.Unchanged
|
|
result.Errors += extraStats.Errors
|
|
|
|
if err := s.syncPresentLibraryState(ctx, folder.ID); err != nil {
|
|
return nil, fmt.Errorf("syncing present library state for folder %d: %w", folder.ID, err)
|
|
}
|
|
|
|
if err := s.reconcileScannedGroups(ctx, folder.ID, allowEmptyRootGuard, reconcileRoots, !allowEmptyRootGuard, groupInference); err != nil {
|
|
return nil, fmt.Errorf("reconciling scanned groups: %w", err)
|
|
}
|
|
|
|
// Mark files that were in the DB but not found on disk as missing.
|
|
now := time.Now().UTC()
|
|
for _, existing := range existingFiles {
|
|
if seenPaths[existing.FilePath] {
|
|
continue
|
|
}
|
|
// Only mark as missing if not already marked.
|
|
if existing.MissingSince == nil {
|
|
if err := s.fileRepo.MarkMissing(ctx, existing.ID, now); err != nil {
|
|
slog.Error("scanner: failed to mark file missing",
|
|
"path", existing.FilePath,
|
|
"error", err,
|
|
)
|
|
result.Errors++
|
|
continue
|
|
}
|
|
}
|
|
result.Missing++
|
|
}
|
|
|
|
// Empty trash: delete files marked as missing for longer than the removal
|
|
// grace for this folder. Safe because the empty-root guard (above) returns
|
|
// early when 0 files are found on disk, so we only reach here when the
|
|
// root is populated.
|
|
if s.emptyTrashAfterScan {
|
|
trashed, err := s.fileRepo.DeleteMissingByFolder(ctx, folder.ID, s.fileRemovalGrace)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("emptying trash for folder %d: %w", folder.ID, err)
|
|
}
|
|
if trashed > 0 {
|
|
slog.Info("scanner: emptied trash", "folder_id", folder.ID, "deleted", trashed)
|
|
}
|
|
result.FilesDeleted += trashed
|
|
}
|
|
|
|
staleFileIDs := collectStaleRemovedPathFileIDs(existingFiles, seenPaths, reconcileRoots)
|
|
if len(filePaths) == 0 && allowEmptyCleanup {
|
|
staleFileIDs = make([]int, 0, len(existingFiles))
|
|
for _, existing := range existingFiles {
|
|
staleFileIDs = append(staleFileIDs, existing.ID)
|
|
}
|
|
}
|
|
deletedFiles, err := s.fileRepo.DeleteByIDs(ctx, staleFileIDs)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("deleting stale files for folder %d: %w", folder.ID, err)
|
|
}
|
|
result.FilesDeleted += deletedFiles
|
|
|
|
removedMemberships, deletedItems, orphanedImageDirs, err := s.reconcileLibraryMemberships(ctx, folder.ID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reconciling library membership for folder %d: %w", folder.ID, err)
|
|
}
|
|
result.MembershipsRemoved = removedMemberships
|
|
result.ItemsDeleted = deletedItems
|
|
|
|
if s.seriesQueueSyncer != nil {
|
|
if allowEmptyRootGuard {
|
|
if err := s.seriesQueueSyncer.SyncForFolder(ctx, folder.ID); err != nil {
|
|
return nil, fmt.Errorf("syncing series match queue for folder %d: %w", folder.ID, err)
|
|
}
|
|
slog.Info("metadata: series root queue sync",
|
|
"folder_id", folder.ID,
|
|
"scope", "folder",
|
|
)
|
|
} else if len(reconcileRoots) > 0 {
|
|
for _, scopePath := range reconcileRoots {
|
|
if err := s.seriesQueueSyncer.SyncInScope(ctx, folder.ID, scopePath); err != nil {
|
|
return nil, fmt.Errorf("syncing series match queue for scope %q: %w", scopePath, err)
|
|
}
|
|
slog.Info("metadata: series root queue sync",
|
|
"folder_id", folder.ID,
|
|
"scope", scopePath,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
if s.movieQueueSyncer != nil {
|
|
if allowEmptyRootGuard {
|
|
if err := s.movieQueueSyncer.SyncForFolder(ctx, folder.ID); err != nil {
|
|
return nil, fmt.Errorf("syncing movie match queue for folder %d: %w", folder.ID, err)
|
|
}
|
|
slog.Info("metadata: movie file queue sync",
|
|
"folder_id", folder.ID,
|
|
"scope", "folder",
|
|
)
|
|
} else if len(reconcileRoots) > 0 {
|
|
for _, scopePath := range reconcileRoots {
|
|
if err := s.movieQueueSyncer.SyncInScope(ctx, folder.ID, scopePath); err != nil {
|
|
return nil, fmt.Errorf("syncing movie match queue for scope %q: %w", scopePath, err)
|
|
}
|
|
slog.Info("metadata: movie file queue sync",
|
|
"folder_id", folder.ID,
|
|
"scope", scopePath,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Best-effort S3 image cleanup for orphaned items.
|
|
if s.s3Client != nil && len(orphanedImageDirs) > 0 {
|
|
bucket := s.s3Client.Bucket()
|
|
for _, dir := range orphanedImageDirs {
|
|
_, _ = s.s3Client.DeletePrefix(ctx, bucket, dir)
|
|
}
|
|
}
|
|
|
|
if allowEmptyRootGuard && (len(filePaths) > 0 || allowEmptyCleanup) {
|
|
if err := s.folderRepo.ClearScanWarning(ctx, folder.ID); err != nil {
|
|
return nil, fmt.Errorf("clearing scan warning for folder %d: %w", folder.ID, err)
|
|
}
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
type scopedScan struct {
|
|
walkRoots []string
|
|
reconcileRoots []string
|
|
existingFiles []*scanStateFile
|
|
filePaths []string
|
|
seenPaths map[string]bool
|
|
rootInference rootInferenceResult
|
|
groupInference groupInferenceResult
|
|
result *ScanResult
|
|
}
|
|
|
|
func (s *Scanner) scanFolderByRoots(
|
|
ctx context.Context,
|
|
folder *models.MediaFolder,
|
|
walkRoots []string,
|
|
reconcileRoots []string,
|
|
) (*ScanResult, error) {
|
|
result := &ScanResult{}
|
|
roots := compactScanRoots(reconcileRoots)
|
|
if len(roots) == 0 {
|
|
roots = compactScanRoots(walkRoots)
|
|
}
|
|
|
|
pendingEmptyScopes := make([]*scopedScan, 0)
|
|
totalExisting := 0
|
|
seenAnyFiles := false
|
|
allowEmptyCleanup := false
|
|
|
|
for _, root := range roots {
|
|
reportProgress(ctx, ProgressUpdate{
|
|
Phase: "walking",
|
|
Message: "Scanning library root",
|
|
CurrentScope: root,
|
|
})
|
|
scope, err := s.scanScope(ctx, folder, []string{root}, []string{root})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
totalExisting += len(scope.existingFiles)
|
|
mergeScanResult(result, scope.result)
|
|
|
|
if ctx.Err() != nil {
|
|
return result, ctx.Err()
|
|
}
|
|
|
|
if len(scope.filePaths) > 0 {
|
|
seenAnyFiles = true
|
|
for _, pending := range pendingEmptyScopes {
|
|
beforeErrors := pending.result.Errors
|
|
if err := s.applyScopedScan(ctx, folder, pending, false); err != nil {
|
|
return nil, err
|
|
}
|
|
mergeCleanupResult(result, pending.result, beforeErrors)
|
|
}
|
|
pendingEmptyScopes = pendingEmptyScopes[:0]
|
|
|
|
beforeErrors := scope.result.Errors
|
|
if err := s.applyScopedScan(ctx, folder, scope, false); err != nil {
|
|
return nil, err
|
|
}
|
|
mergeCleanupResult(result, scope.result, beforeErrors)
|
|
continue
|
|
}
|
|
|
|
if seenAnyFiles {
|
|
beforeErrors := scope.result.Errors
|
|
if err := s.applyScopedScan(ctx, folder, scope, false); err != nil {
|
|
return nil, err
|
|
}
|
|
mergeCleanupResult(result, scope.result, beforeErrors)
|
|
continue
|
|
}
|
|
|
|
pendingEmptyScopes = append(pendingEmptyScopes, scope)
|
|
}
|
|
|
|
if !seenAnyFiles && totalExisting > 0 {
|
|
var err error
|
|
allowEmptyCleanup, err = s.folderRepo.ConsumeEmptyCleanupAllowance(ctx, folder.ID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("checking empty cleanup confirmation for folder %d: %w", folder.ID, err)
|
|
}
|
|
if !allowEmptyCleanup {
|
|
result.EmptyRootGuarded = true
|
|
if err := s.folderRepo.SetScanWarning(ctx, folder.ID,
|
|
"empty_root",
|
|
"Scan found 0 media files; cleanup was skipped until deletion is confirmed.",
|
|
time.Now().UTC(),
|
|
); err != nil {
|
|
return nil, fmt.Errorf("recording empty-root warning for folder %d: %w", folder.ID, err)
|
|
}
|
|
return result, nil
|
|
}
|
|
}
|
|
|
|
for _, pending := range pendingEmptyScopes {
|
|
beforeErrors := pending.result.Errors
|
|
if err := s.applyScopedScan(ctx, folder, pending, allowEmptyCleanup); err != nil {
|
|
return nil, err
|
|
}
|
|
mergeCleanupResult(result, pending.result, beforeErrors)
|
|
}
|
|
|
|
if ctx.Err() != nil {
|
|
return result, ctx.Err()
|
|
}
|
|
|
|
reportProgress(ctx, ProgressUpdate{
|
|
Phase: "reconciling",
|
|
Message: "Reconciling library state",
|
|
CurrentScope: "folder",
|
|
})
|
|
if err := s.syncPresentLibraryState(ctx, folder.ID); err != nil {
|
|
return nil, fmt.Errorf("syncing present library state for folder %d: %w", folder.ID, err)
|
|
}
|
|
|
|
if s.emptyTrashAfterScan {
|
|
trashed, err := s.fileRepo.DeleteMissingByFolder(ctx, folder.ID, s.fileRemovalGrace)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("emptying trash for folder %d: %w", folder.ID, err)
|
|
}
|
|
if trashed > 0 {
|
|
slog.Info("scanner: emptied trash", "folder_id", folder.ID, "deleted", trashed)
|
|
}
|
|
result.FilesDeleted += trashed
|
|
}
|
|
|
|
staleOutsideRoots, err := s.fileRepo.ListIDsOutsideRoots(ctx, folder.ID, roots)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("listing stale files outside configured roots for folder %d: %w", folder.ID, err)
|
|
}
|
|
deletedOutsideRoots, err := s.fileRepo.DeleteByIDs(ctx, staleOutsideRoots)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("deleting stale files outside configured roots for folder %d: %w", folder.ID, err)
|
|
}
|
|
result.FilesDeleted += deletedOutsideRoots
|
|
|
|
removedMemberships, deletedItems, orphanedImageDirs, err := s.reconcileLibraryMemberships(ctx, folder.ID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reconciling library membership for folder %d: %w", folder.ID, err)
|
|
}
|
|
result.MembershipsRemoved = removedMemberships
|
|
result.ItemsDeleted = deletedItems
|
|
|
|
if s.seriesQueueSyncer != nil {
|
|
reportProgress(ctx, ProgressUpdate{
|
|
Phase: "queue_sync",
|
|
Message: "Syncing series match queue",
|
|
CurrentScope: "folder",
|
|
})
|
|
if err := s.seriesQueueSyncer.SyncForFolder(ctx, folder.ID); err != nil {
|
|
return nil, fmt.Errorf("syncing series match queue for folder %d: %w", folder.ID, err)
|
|
}
|
|
slog.Info("metadata: series root queue sync",
|
|
"folder_id", folder.ID,
|
|
"scope", "folder",
|
|
)
|
|
}
|
|
if s.movieQueueSyncer != nil {
|
|
reportProgress(ctx, ProgressUpdate{
|
|
Phase: "queue_sync",
|
|
Message: "Syncing movie match queue",
|
|
CurrentScope: "folder",
|
|
})
|
|
if err := s.movieQueueSyncer.SyncForFolder(ctx, folder.ID); err != nil {
|
|
return nil, fmt.Errorf("syncing movie match queue for folder %d: %w", folder.ID, err)
|
|
}
|
|
slog.Info("metadata: movie file queue sync",
|
|
"folder_id", folder.ID,
|
|
"scope", "folder",
|
|
)
|
|
}
|
|
|
|
if s.s3Client != nil && len(orphanedImageDirs) > 0 {
|
|
bucket := s.s3Client.Bucket()
|
|
for _, dir := range orphanedImageDirs {
|
|
_, _ = s.s3Client.DeletePrefix(ctx, bucket, dir)
|
|
}
|
|
}
|
|
|
|
if seenAnyFiles || allowEmptyCleanup {
|
|
if err := s.folderRepo.ClearScanWarning(ctx, folder.ID); err != nil {
|
|
return nil, fmt.Errorf("clearing scan warning for folder %d: %w", folder.ID, err)
|
|
}
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func (s *Scanner) scanScope(
|
|
ctx context.Context,
|
|
folder *models.MediaFolder,
|
|
walkRoots []string,
|
|
reconcileRoots []string,
|
|
) (*scopedScan, error) {
|
|
result := &ScanResult{}
|
|
existingFiles, err := s.fileRepo.GetScanStateByFolderAndPathPrefix(ctx, folder.ID, reconcileRoots[0])
|
|
if err != nil {
|
|
return nil, fmt.Errorf("getting existing files for folder %d path %q: %w", folder.ID, reconcileRoots[0], err)
|
|
}
|
|
|
|
existingByPath := make(map[string]*scanStateFile, len(existingFiles))
|
|
for _, f := range existingFiles {
|
|
existingByPath[f.FilePath] = f
|
|
}
|
|
existingContentStatuses, err := s.itemRepo.GetStatusByIDs(ctx, collectScanStateContentIDs(existingFiles))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("loading item statuses for folder %d path %q: %w", folder.ID, reconcileRoots[0], err)
|
|
}
|
|
|
|
filePaths, walkErr := collectLogicalFilePaths(ctx, walkRoots, folder.Type)
|
|
if walkErr != nil {
|
|
return nil, fmt.Errorf("walking media roots for %q: %w", reconcileRoots[0], walkErr)
|
|
}
|
|
slog.Info("scanner: discovered files",
|
|
"folder_id", folder.ID,
|
|
"scope", firstScope(reconcileRoots),
|
|
"files", len(filePaths),
|
|
)
|
|
reportProgress(ctx, ProgressUpdate{
|
|
Phase: "processing",
|
|
Message: "Processing discovered files",
|
|
CurrentScope: firstScope(reconcileRoots),
|
|
TotalFiles: len(filePaths),
|
|
FilesDiscovered: len(filePaths),
|
|
})
|
|
|
|
// Extras count as seen (so reconciliation keeps their rows) but are
|
|
// partitioned out of identity inference and match processing.
|
|
seenPaths := make(map[string]bool, len(filePaths))
|
|
for _, p := range filePaths {
|
|
seenPaths[p] = true
|
|
}
|
|
primaryPaths, extraCandidates := partitionExtraPaths(filePaths, folder.Type, folder.Paths)
|
|
rootOverrides, err := s.loadRootOverrides(ctx, folder.ID, reconcileRoots)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("loading root overrides: %w", err)
|
|
}
|
|
rootInference := inferRootAssignments(primaryPaths, folder.Type, folder.ID, rootOverrides)
|
|
identityOverrides, err := s.loadIdentityOverrides(ctx, folder.ID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("loading identity overrides: %w", err)
|
|
}
|
|
groupInference := inferGroupAssignments(primaryPaths, folder.Type, folder.ID, rootInference.Assignments, identityOverrides)
|
|
groupOverrides, err := s.loadGroupOverrides(ctx, folder.ID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("loading group overrides: %w", err)
|
|
}
|
|
applyGroupOverrides(&groupInference, groupOverrides)
|
|
result.RootObservations = append(result.RootObservations, rootInference.Observations...)
|
|
s.logRootInferenceDisagreements(rootInference.Assignments)
|
|
if _, err := s.clearLegacyLinksForUnmatchableRoots(ctx, folder.ID, result.RootObservations); err != nil {
|
|
return nil, fmt.Errorf("clearing legacy links for unmatchable roots: %w", err)
|
|
}
|
|
|
|
var wg sync.WaitGroup
|
|
pathCh := make(chan string, s.workerCount())
|
|
var newCount, updatedCount, unchangedCount, errorCount, processedCount atomic.Int64
|
|
subtitleCache := newExternalSubtitleDirCache()
|
|
|
|
for range s.workerCount() {
|
|
wg.Go(func() {
|
|
for path := range pathCh {
|
|
if ctx.Err() != nil {
|
|
continue
|
|
}
|
|
action, updateReasons, processErr := s.processFile(ctx, path, folder, existingByPath, existingContentStatuses, rootInference.Assignments[path], groupInference.Assignments[path], subtitleCache)
|
|
if processErr != nil {
|
|
slog.Error("scanner: file processing failed", "path", path, "error", processErr)
|
|
errorCount.Add(1)
|
|
continue
|
|
}
|
|
switch action {
|
|
case actionNew:
|
|
newCount.Add(1)
|
|
slog.Debug("scanner: new file added", "path", path)
|
|
case actionUpdated:
|
|
updatedCount.Add(1)
|
|
slog.Debug("scanner: file updated", "path", path, "reasons", updateReasons)
|
|
case actionUnchanged:
|
|
unchangedCount.Add(1)
|
|
}
|
|
processedCount.Add(1)
|
|
}
|
|
})
|
|
}
|
|
|
|
stopProgress := make(chan struct{})
|
|
progressDone := make(chan struct{})
|
|
go func() {
|
|
ticker := time.NewTicker(scanProgressLogInterval)
|
|
defer ticker.Stop()
|
|
defer close(progressDone)
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-stopProgress:
|
|
return
|
|
case <-ticker.C:
|
|
processed := int(processedCount.Load())
|
|
total := len(filePaths)
|
|
slog.Info("scanner: processing progress",
|
|
"folder_id", folder.ID,
|
|
"scope", firstScope(reconcileRoots),
|
|
"processed", processed,
|
|
"total", total,
|
|
"new", newCount.Load(),
|
|
"updated", updatedCount.Load(),
|
|
"unchanged", unchangedCount.Load(),
|
|
"errors", errorCount.Load(),
|
|
)
|
|
reportProgress(ctx, ProgressUpdate{
|
|
Phase: "processing",
|
|
Message: "Processing discovered files",
|
|
CurrentScope: firstScope(reconcileRoots),
|
|
TotalFiles: total,
|
|
FilesDiscovered: total,
|
|
FilesProcessed: processed,
|
|
New: int(newCount.Load()),
|
|
Updated: int(updatedCount.Load()),
|
|
Unchanged: int(unchangedCount.Load()),
|
|
Errors: int(errorCount.Load()),
|
|
})
|
|
}
|
|
}
|
|
}()
|
|
|
|
for _, p := range primaryPaths {
|
|
pathCh <- p
|
|
}
|
|
close(pathCh)
|
|
wg.Wait()
|
|
close(stopProgress)
|
|
<-progressDone
|
|
|
|
result.New = int(newCount.Load())
|
|
result.Updated = int(updatedCount.Load())
|
|
result.Unchanged = int(unchangedCount.Load())
|
|
result.Errors = int(errorCount.Load())
|
|
reportProgress(ctx, ProgressUpdate{
|
|
Phase: "reconciling",
|
|
Message: "Reconciling scan state",
|
|
CurrentScope: firstScope(reconcileRoots),
|
|
TotalFiles: len(filePaths),
|
|
FilesDiscovered: len(filePaths),
|
|
FilesProcessed: int(processedCount.Load()),
|
|
New: result.New,
|
|
Updated: result.Updated,
|
|
Unchanged: result.Unchanged,
|
|
Errors: result.Errors,
|
|
})
|
|
|
|
if ctx.Err() == nil {
|
|
extraStats := s.processExtraFiles(ctx, folder, extraCandidates, existingByPath)
|
|
result.New += extraStats.New
|
|
result.Updated += extraStats.Updated
|
|
result.Unchanged += extraStats.Unchanged
|
|
result.Errors += extraStats.Errors
|
|
}
|
|
|
|
return &scopedScan{
|
|
walkRoots: append([]string(nil), walkRoots...),
|
|
reconcileRoots: append([]string(nil), reconcileRoots...),
|
|
existingFiles: existingFiles,
|
|
filePaths: filePaths,
|
|
seenPaths: seenPaths,
|
|
rootInference: rootInference,
|
|
groupInference: groupInference,
|
|
result: result,
|
|
}, nil
|
|
}
|
|
|
|
func (s *Scanner) applyScopedScan(
|
|
ctx context.Context,
|
|
folder *models.MediaFolder,
|
|
scope *scopedScan,
|
|
forceDeleteAll bool,
|
|
) error {
|
|
if scope == nil || scope.result == nil {
|
|
return nil
|
|
}
|
|
|
|
if err := s.reconcileScannedRoots(
|
|
ctx,
|
|
folder.ID,
|
|
scope.reconcileRoots,
|
|
scope.rootInference.Snapshots,
|
|
); err != nil {
|
|
return fmt.Errorf("reconciling scanned roots for scope %q: %w", scope.reconcileRoots[0], err)
|
|
}
|
|
if err := s.reconcileScannedGroups(ctx, folder.ID, false, scope.reconcileRoots, true, scope.groupInference); err != nil {
|
|
return fmt.Errorf("reconciling scanned groups for scope %q: %w", scope.reconcileRoots[0], err)
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
for _, existing := range scope.existingFiles {
|
|
if scope.seenPaths[existing.FilePath] {
|
|
continue
|
|
}
|
|
if existing.MissingSince == nil {
|
|
if err := s.fileRepo.MarkMissing(ctx, existing.ID, now); err != nil {
|
|
slog.Error("scanner: failed to mark file missing",
|
|
"path", existing.FilePath,
|
|
"error", err,
|
|
)
|
|
scope.result.Errors++
|
|
continue
|
|
}
|
|
}
|
|
scope.result.Missing++
|
|
}
|
|
|
|
staleFileIDs := collectStaleRemovedPathFileIDs(scope.existingFiles, scope.seenPaths, scope.reconcileRoots)
|
|
if forceDeleteAll && len(scope.filePaths) == 0 {
|
|
staleFileIDs = make([]int, 0, len(scope.existingFiles))
|
|
for _, existing := range scope.existingFiles {
|
|
staleFileIDs = append(staleFileIDs, existing.ID)
|
|
}
|
|
}
|
|
deletedFiles, err := s.fileRepo.DeleteByIDs(ctx, staleFileIDs)
|
|
if err != nil {
|
|
return fmt.Errorf("deleting stale files for scope %q: %w", scope.reconcileRoots[0], err)
|
|
}
|
|
scope.result.FilesDeleted += deletedFiles
|
|
|
|
return nil
|
|
}
|
|
|
|
func mergeScanResult(dst *ScanResult, src *ScanResult) {
|
|
if dst == nil || src == nil {
|
|
return
|
|
}
|
|
dst.New += src.New
|
|
dst.Updated += src.Updated
|
|
dst.Unchanged += src.Unchanged
|
|
dst.Errors += src.Errors
|
|
dst.RootObservations = append(dst.RootObservations, src.RootObservations...)
|
|
dst.EmptyRootGuarded = dst.EmptyRootGuarded || src.EmptyRootGuarded
|
|
}
|
|
|
|
func firstScope(scopes []string) string {
|
|
if len(scopes) == 0 {
|
|
return ""
|
|
}
|
|
return scopes[0]
|
|
}
|
|
|
|
func mergeCleanupResult(dst *ScanResult, src *ScanResult, priorErrors int) {
|
|
if dst == nil || src == nil {
|
|
return
|
|
}
|
|
dst.Missing += src.Missing
|
|
dst.FilesDeleted += src.FilesDeleted
|
|
if src.Errors > priorErrors {
|
|
dst.Errors += src.Errors - priorErrors
|
|
}
|
|
}
|
|
|
|
func compactScanRoots(paths []string) []string {
|
|
out := make([]string, 0, len(paths))
|
|
for _, rawPath := range paths {
|
|
if strings.TrimSpace(rawPath) == "" {
|
|
continue
|
|
}
|
|
path := filepath.Clean(rawPath)
|
|
if path == "" || path == "." {
|
|
continue
|
|
}
|
|
if pathWithinAnyRoot(path, out) {
|
|
continue
|
|
}
|
|
filtered := out[:0]
|
|
for _, existing := range out {
|
|
if pathWithinAnyRoot(existing, []string{path}) {
|
|
continue
|
|
}
|
|
filtered = append(filtered, existing)
|
|
}
|
|
out = append(filtered, path)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (s *Scanner) syncPresentLibraryState(ctx context.Context, folderID int) error {
|
|
if _, err := s.fileRepo.Pool().Exec(ctx, `
|
|
UPDATE media_files mf
|
|
SET content_id = NULL,
|
|
updated_at = NOW()
|
|
WHERE mf.media_folder_id = $1
|
|
AND mf.missing_since IS NULL
|
|
AND mf.content_id IS NOT NULL
|
|
AND NOT EXISTS (
|
|
SELECT 1
|
|
FROM media_items mi
|
|
WHERE mi.content_id = mf.content_id
|
|
)
|
|
`, folderID); err != nil {
|
|
return fmt.Errorf("clearing dangling content links: %w", err)
|
|
}
|
|
|
|
if _, err := s.fileRepo.Pool().Exec(ctx, `
|
|
UPDATE media_files mf
|
|
SET episode_id = NULL,
|
|
updated_at = NOW()
|
|
WHERE mf.media_folder_id = $1
|
|
AND mf.missing_since IS NULL
|
|
AND mf.episode_id IS NOT NULL
|
|
AND NOT EXISTS (
|
|
SELECT 1
|
|
FROM episodes e
|
|
WHERE e.content_id = mf.episode_id
|
|
)
|
|
`, folderID); err != nil {
|
|
return fmt.Errorf("clearing dangling episode links: %w", err)
|
|
}
|
|
|
|
if _, err := s.fileRepo.Pool().Exec(ctx, `
|
|
INSERT INTO media_item_libraries (content_id, media_folder_id, first_seen_at)
|
|
SELECT DISTINCT mf.content_id, mf.media_folder_id, NOW()
|
|
FROM media_files mf
|
|
JOIN media_items mi ON mi.content_id = mf.content_id
|
|
WHERE mf.media_folder_id = $1
|
|
AND mf.missing_since IS NULL
|
|
AND mf.content_id IS NOT NULL
|
|
ON CONFLICT (content_id, media_folder_id) DO NOTHING
|
|
`, folderID); err != nil {
|
|
return fmt.Errorf("restoring folder memberships: %w", err)
|
|
}
|
|
|
|
if _, err := s.fileRepo.Pool().Exec(ctx, `
|
|
WITH inserted AS (
|
|
INSERT INTO episode_libraries (episode_id, media_folder_id, first_seen_at)
|
|
SELECT mf.episode_id, mf.media_folder_id, MIN(mf.created_at)
|
|
FROM media_files mf
|
|
JOIN episodes e ON e.content_id = mf.episode_id
|
|
WHERE mf.media_folder_id = $1
|
|
AND mf.missing_since IS NULL
|
|
AND mf.episode_id IS NOT NULL
|
|
GROUP BY mf.episode_id, mf.media_folder_id
|
|
ON CONFLICT (episode_id, media_folder_id) DO NOTHING
|
|
RETURNING episode_id, first_seen_at
|
|
)
|
|
-- Bump each parent series' latest-episode-added denorm for the
|
|
-- genuinely new links ("Latest Episodes" sort, issue #202).
|
|
UPDATE media_items mi
|
|
SET latest_episode_added_at = GREATEST(COALESCE(mi.latest_episode_added_at, sub.latest_added), sub.latest_added)
|
|
FROM (
|
|
SELECT e.series_id, MAX(i.first_seen_at) AS latest_added
|
|
FROM inserted i
|
|
JOIN episodes e ON e.content_id = i.episode_id
|
|
GROUP BY e.series_id
|
|
) sub
|
|
WHERE mi.content_id = sub.series_id
|
|
AND mi.type = 'series'
|
|
`, folderID); err != nil {
|
|
return fmt.Errorf("restoring episode folder memberships: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *Scanner) syncFolderScopedAudioLibraryState(ctx context.Context, folderID int) error {
|
|
if err := s.syncPresentLibraryState(ctx, folderID); err != nil {
|
|
return err
|
|
}
|
|
|
|
if _, err := s.fileRepo.Pool().Exec(ctx, `
|
|
INSERT INTO media_item_roots (media_folder_id, canonical_root_path, content_id)
|
|
SELECT DISTINCT ON (mf.media_folder_id, mf.canonical_root_path)
|
|
mf.media_folder_id, mf.canonical_root_path, mf.content_id
|
|
FROM media_files mf
|
|
JOIN media_items mi ON mi.content_id = mf.content_id
|
|
WHERE mf.media_folder_id = $1
|
|
AND mf.missing_since IS NULL
|
|
AND mf.content_id IS NOT NULL
|
|
AND COALESCE(mf.canonical_root_path, '') <> ''
|
|
AND mi.type IN ('audiobook', 'podcast')
|
|
ON CONFLICT (media_folder_id, canonical_root_path)
|
|
DO UPDATE SET content_id = EXCLUDED.content_id,
|
|
last_seen_at = NOW()
|
|
`, folderID); err != nil {
|
|
return fmt.Errorf("restoring folder-scoped audio roots: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *Scanner) reconcileLibraryMemberships(ctx context.Context, folderID int) (int, int, []string, error) {
|
|
if s.episodeLibraryRepo != nil {
|
|
if _, err := s.episodeLibraryRepo.ReconcileFolderMembership(ctx, folderID); err != nil {
|
|
return 0, 0, nil, err
|
|
}
|
|
}
|
|
return s.libraryRepo.ReconcileFolderMembership(ctx, folderID)
|
|
}
|
|
|
|
func collectStaleRemovedPathFileIDs(existingFiles []*scanStateFile, seenPaths map[string]bool, roots []string) []int {
|
|
ids := make([]int, 0)
|
|
for _, existing := range existingFiles {
|
|
if seenPaths[existing.FilePath] {
|
|
continue
|
|
}
|
|
if pathWithinAnyRoot(existing.FilePath, roots) {
|
|
continue
|
|
}
|
|
ids = append(ids, existing.ID)
|
|
}
|
|
return ids
|
|
}
|
|
|
|
func collectScanStateContentIDs(files []*scanStateFile) []string {
|
|
contentIDs := make([]string, 0, len(files))
|
|
seen := make(map[string]struct{}, len(files))
|
|
for _, file := range files {
|
|
if file == nil || strings.TrimSpace(file.ContentID) == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[file.ContentID]; ok {
|
|
continue
|
|
}
|
|
seen[file.ContentID] = struct{}{}
|
|
contentIDs = append(contentIDs, file.ContentID)
|
|
}
|
|
return contentIDs
|
|
}
|
|
|
|
func pathWithinAnyRoot(path string, roots []string) bool {
|
|
cleanPath := filepath.Clean(path)
|
|
for _, root := range roots {
|
|
cleanRoot := filepath.Clean(root)
|
|
rel, err := filepath.Rel(cleanRoot, cleanPath)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if rel == "." || rel == "" {
|
|
return true
|
|
}
|
|
if rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ScanFile scans a single file and upserts it into the database.
|
|
func (s *Scanner) ScanFile(ctx context.Context, filePath string, folder *models.MediaFolder) error {
|
|
var stopWatch context.CancelFunc
|
|
ctx, stopWatch = s.watchFolderContext(ctx, folder.ID)
|
|
defer stopWatch()
|
|
if err := s.ensureFolderEnabled(ctx, folder.ID); err != nil {
|
|
return err
|
|
}
|
|
|
|
cleanFile := filepath.Clean(filePath)
|
|
if librarykind.IsAudiobook(folder.Type) {
|
|
if !SupportsAudioFile(cleanFile) {
|
|
return fmt.Errorf("unrecognized audio extension: %s", strings.ToLower(filepath.Ext(cleanFile)))
|
|
}
|
|
scanRoot, err := cleanScopedAudiobookScanRoot(filepath.Dir(cleanFile))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := s.ScanAudiobookFolder(ctx, scopedFolderPaths(folder, []string{scanRoot}), false); err != nil {
|
|
return err
|
|
}
|
|
return s.syncFolderScopedAudioLibraryState(ctx, folder.ID)
|
|
}
|
|
|
|
// Verify the file extension is recognized.
|
|
ext := strings.ToLower(filepath.Ext(cleanFile))
|
|
if !videoExtensions[ext] {
|
|
return fmt.Errorf("unrecognized video extension: %s", ext)
|
|
}
|
|
|
|
// Look up only this specific file instead of loading the entire folder.
|
|
existingByPath := make(map[string]*scanStateFile, 1)
|
|
existing, err := s.fileRepo.GetByPath(ctx, filePath)
|
|
if err == nil {
|
|
existingByPath[filePath] = scanStateFromMediaFile(existing)
|
|
}
|
|
|
|
// A local extra (Trailers/ dir, -trailer suffix, ...) bypasses identity
|
|
// inference and matching entirely.
|
|
if candidate, isExtra := newWatchExtrasClassifier(folder.Type, folder.Paths).classify(cleanFile); isExtra {
|
|
stats := s.processExtraFiles(ctx, folder, []extraCandidate{candidate}, existingByPath)
|
|
if stats.Errors > 0 {
|
|
return fmt.Errorf("processing extra file %s failed", cleanFile)
|
|
}
|
|
// Converting a previously-primary row into an extra clears its
|
|
// content linkage; run the same membership cleanup a full scan would
|
|
// so stale library membership doesn't linger until the next scan.
|
|
if err := s.syncPresentLibraryState(ctx, folder.ID); err != nil {
|
|
return fmt.Errorf("syncing present library state for extra file: %w", err)
|
|
}
|
|
if _, _, _, err := s.reconcileLibraryMemberships(ctx, folder.ID); err != nil {
|
|
return fmt.Errorf("reconciling library membership after extra file scan: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
existingContentStatuses, err := s.itemRepo.GetStatusByIDs(ctx, collectScanStateContentIDs([]*scanStateFile{existingByPath[filePath]}))
|
|
if err != nil {
|
|
return fmt.Errorf("loading item statuses for file: %w", err)
|
|
}
|
|
observation, ok := ObserveRoot(filePath, folder.Type)
|
|
if ok {
|
|
cleared, clearErr := s.clearLegacyLinksForUnmatchableRoots(ctx, folder.ID, []RootObservation{observation})
|
|
if clearErr != nil {
|
|
return clearErr
|
|
}
|
|
if cleared > 0 {
|
|
if _, _, _, reconcileErr := s.reconcileLibraryMemberships(ctx, folder.ID); reconcileErr != nil {
|
|
return fmt.Errorf("reconciling folder membership after clearing legacy links: %w", reconcileErr)
|
|
}
|
|
}
|
|
}
|
|
|
|
rootOverrides, err := s.loadRootOverrides(ctx, folder.ID, []string{filepath.Dir(filePath)})
|
|
if err != nil {
|
|
return fmt.Errorf("loading root overrides for file: %w", err)
|
|
}
|
|
rootInference := inferRootAssignments([]string{filePath}, folder.Type, folder.ID, rootOverrides)
|
|
s.logRootInferenceDisagreements(rootInference.Assignments)
|
|
|
|
identityOverrides, err := s.loadIdentityOverrides(ctx, folder.ID)
|
|
if err != nil {
|
|
return fmt.Errorf("loading identity overrides for file: %w", err)
|
|
}
|
|
groupInference := inferGroupAssignments([]string{filePath}, folder.Type, folder.ID, rootInference.Assignments, identityOverrides)
|
|
groupOverrides, err := s.loadGroupOverrides(ctx, folder.ID)
|
|
if err != nil {
|
|
return fmt.Errorf("loading group overrides for file: %w", err)
|
|
}
|
|
applyGroupOverrides(&groupInference, groupOverrides)
|
|
_, _, err = s.processFile(ctx, filePath, folder, existingByPath, existingContentStatuses, rootInference.Assignments[filepath.Clean(filePath)], groupInference.Assignments[filepath.Clean(filePath)], newExternalSubtitleDirCache())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := s.reconcileScannedRoots(
|
|
ctx,
|
|
folder.ID,
|
|
nil,
|
|
rootInference.Snapshots,
|
|
); err != nil {
|
|
return fmt.Errorf("reconciling scanned root for file: %w", err)
|
|
}
|
|
scopePath := filepath.Dir(filePath)
|
|
if err := s.reconcileScannedGroups(ctx, folder.ID, false, []string{scopePath}, false, groupInference); err != nil {
|
|
return fmt.Errorf("reconciling scanned groups for file: %w", err)
|
|
}
|
|
if err := s.syncPresentLibraryState(ctx, folder.ID); err != nil {
|
|
return fmt.Errorf("syncing present library state for file: %w", err)
|
|
}
|
|
if s.seriesQueueSyncer != nil {
|
|
if err := s.seriesQueueSyncer.SyncInScope(ctx, folder.ID, scopePath); err != nil {
|
|
return fmt.Errorf("syncing series match queue for file: %w", err)
|
|
}
|
|
slog.Info("metadata: series root queue sync",
|
|
"folder_id", folder.ID,
|
|
"scope", scopePath,
|
|
)
|
|
}
|
|
if s.movieQueueSyncer != nil {
|
|
if err := s.movieQueueSyncer.SyncInScope(ctx, folder.ID, filepath.Clean(filePath)); err != nil {
|
|
return fmt.Errorf("syncing movie match queue for file: %w", err)
|
|
}
|
|
slog.Info("metadata: movie file queue sync",
|
|
"folder_id", folder.ID,
|
|
"scope", filepath.Clean(filePath),
|
|
)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Scanner) watchFolderContext(ctx context.Context, folderID int) (context.Context, context.CancelFunc) {
|
|
cancel := func() {}
|
|
if ctx == nil || folderID <= 0 || s == nil || s.folderRepo == nil {
|
|
return ctx, cancel
|
|
}
|
|
|
|
watchCtx, cancel := context.WithCancel(ctx)
|
|
enabled, err := s.folderEnabledState(watchCtx, folderID)
|
|
if err != nil {
|
|
slog.Warn("scanner: failed to load folder state", "folder_id", folderID, "error", err)
|
|
cancel()
|
|
return watchCtx, cancel
|
|
}
|
|
if !enabled {
|
|
cancel()
|
|
return watchCtx, cancel
|
|
}
|
|
|
|
go func() {
|
|
ticker := time.NewTicker(time.Second)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-watchCtx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
enabled, err := s.folderEnabledState(watchCtx, folderID)
|
|
if err != nil {
|
|
slog.Warn("scanner: failed to refresh folder state", "folder_id", folderID, "error", err)
|
|
continue
|
|
}
|
|
if !enabled {
|
|
cancel()
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
|
|
return watchCtx, cancel
|
|
}
|
|
|
|
func (s *Scanner) ensureFolderEnabled(ctx context.Context, folderID int) error {
|
|
if ctx != nil {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if s == nil || s.folderRepo == nil || folderID <= 0 {
|
|
return nil
|
|
}
|
|
|
|
enabled, err := s.folderEnabledState(ctx, folderID)
|
|
switch {
|
|
case errors.Is(err, catalog.ErrFolderNotFound):
|
|
return context.Canceled
|
|
case err != nil:
|
|
return fmt.Errorf("loading folder state: %w", err)
|
|
case !enabled:
|
|
return context.Canceled
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func (s *Scanner) folderEnabledState(ctx context.Context, folderID int) (bool, error) {
|
|
if s == nil || s.folderRepo == nil || folderID <= 0 {
|
|
return true, nil
|
|
}
|
|
folder, err := s.folderRepo.GetByID(ctx, folderID)
|
|
switch {
|
|
case errors.Is(err, catalog.ErrFolderNotFound):
|
|
return false, err
|
|
case err != nil:
|
|
return false, err
|
|
case folder == nil:
|
|
return false, catalog.ErrFolderNotFound
|
|
default:
|
|
return folder.Enabled, nil
|
|
}
|
|
}
|
|
|
|
// fileAction represents what happened when processing a file.
|
|
type fileAction int
|
|
|
|
const (
|
|
actionNew fileAction = iota
|
|
actionUpdated
|
|
actionUnchanged
|
|
)
|
|
|
|
// processFile handles a single file: checks if it changed, gathers hints,
|
|
// probes it, and upserts it.
|
|
func (s *Scanner) processFile(
|
|
ctx context.Context,
|
|
filePath string,
|
|
folder *models.MediaFolder,
|
|
existingByPath map[string]*scanStateFile,
|
|
existingContentStatuses map[string]string,
|
|
assignment fileRootAssignment,
|
|
groupAssignment fileGroupAssignment,
|
|
subtitleCache *externalSubtitleDirCache,
|
|
) (fileAction, []string, error) {
|
|
// Stat the file.
|
|
info, err := os.Stat(filePath)
|
|
if err != nil {
|
|
return 0, nil, fmt.Errorf("stat %s: %w", filePath, err)
|
|
}
|
|
|
|
fileSize := info.Size()
|
|
fileModifiedAt := normalizeFileModifiedAt(info.ModTime())
|
|
var externalSubs []ExternalSubtitleInfo
|
|
externalSubsLoaded := false
|
|
externalSubsChecked := false
|
|
loadExternalSubs := func() []ExternalSubtitleInfo {
|
|
if externalSubsLoaded {
|
|
return externalSubs
|
|
}
|
|
externalSubsLoaded = true
|
|
var subErr error
|
|
externalSubs, subErr = subtitleCache.Detect(filePath)
|
|
if subErr != nil {
|
|
slog.Warn("scanner: subtitle detection failed", "path", filePath, "error", subErr)
|
|
externalSubs = nil
|
|
externalSubsChecked = false
|
|
return externalSubs
|
|
}
|
|
externalSubsChecked = true
|
|
return externalSubs
|
|
}
|
|
|
|
// Check if unchanged (same size) only when playback-critical probe data
|
|
// is already present or this scanner cannot repair it on this node. This
|
|
// lets rescans repair legacy rows when local ffprobe metadata is available
|
|
// without forcing endless rewrites on nodes that cannot probe.
|
|
if existing, ok := existingByPath[filePath]; ok {
|
|
currentExternalSubtitlePaths := externalSubtitleInfoPaths(loadExternalSubs())
|
|
updateReasons := scanStateUpdateReasons(existing, fileSize, fileModifiedAt, currentExternalSubtitlePaths, externalSubsChecked, assignment, groupAssignment, folder.Type, s.ffprobePath != "")
|
|
if shouldSkipStableConfirmedScanState(existing, existingContentStatuses[existing.ContentID], fileSize, fileModifiedAt, updateReasons, s.ffprobePath != "") {
|
|
return actionUnchanged, nil, nil
|
|
}
|
|
if len(updateReasons) == 0 {
|
|
return actionUnchanged, nil, nil
|
|
}
|
|
|
|
// Metadata-only fast path: when the only thing that changed is the
|
|
// derived identity/grouping (root or content-group-key reassignment),
|
|
// the media bytes are untouched and existing probe data is still valid.
|
|
// Rewrite just the identity columns in place — no ffprobe, no OSHash,
|
|
// no probe-column churn. This decouples identity/grouping-scheme changes
|
|
// from probing: a library-wide group-key scheme bump (see #319) converges
|
|
// the stored keys on the next scan without a full-library ffprobe storm.
|
|
if identityOnlyFastPathEligible(existing, updateReasons) {
|
|
mf := models.MediaFile{
|
|
MediaFolderID: folder.ID,
|
|
FilePath: filePath,
|
|
}
|
|
populateScanIdentity(&mf, filePath, folder.Type, assignment, groupAssignment, existing)
|
|
switch id, updErr := s.fileRepo.UpdateIdentity(ctx, mf); {
|
|
case updErr == nil:
|
|
mf.ID = id
|
|
if err := s.enqueueMetadataWork(ctx, folder, &mf); err != nil {
|
|
return 0, nil, fmt.Errorf("enqueueing metadata work for file %s: %w", filePath, err)
|
|
}
|
|
return actionUpdated, updateReasons, nil
|
|
case errors.Is(updErr, ErrFileNotFound):
|
|
// The row vanished between the scan-state snapshot and this
|
|
// write (concurrent delete). Fall through to the full path,
|
|
// whose upsert re-ingests the file in this scan — the old
|
|
// behavior before the fast path existed.
|
|
default:
|
|
return 0, nil, fmt.Errorf("updating identity for file %s: %w", filePath, updErr)
|
|
}
|
|
}
|
|
|
|
action := actionUpdated
|
|
// Gather hints (OSHash only).
|
|
hints := s.gatherHints(filePath)
|
|
fileHash := hints.FileHash
|
|
|
|
// Try to get probe data.
|
|
probe, probeSource := s.probeFile(ctx, filePath)
|
|
|
|
// Detect external subtitles.
|
|
externalSubs = loadExternalSubs()
|
|
|
|
// Check for intro/credits markers from S3.
|
|
markerFetcher := s.markerFetcher
|
|
if markerFetcher == nil {
|
|
markerFetcher = s.fetchMarkers
|
|
}
|
|
markers := markerFetcher(ctx, fileHash)
|
|
|
|
// Build the media file for upsert.
|
|
mf := models.MediaFile{
|
|
MediaFolderID: folder.ID,
|
|
FilePath: filePath,
|
|
FileSize: fileSize,
|
|
FileModifiedAt: &fileModifiedAt,
|
|
FileHash: fileHash,
|
|
}
|
|
populateScanIdentity(&mf, filePath, folder.Type, assignment, groupAssignment, existing)
|
|
|
|
// Apply probe data if available.
|
|
if probe != nil {
|
|
applyProbeData(&mf, probe, probeSource)
|
|
}
|
|
|
|
if mf.SubtitleTracks == nil {
|
|
mf.SubtitleTracks = []models.SubtitleTrack{}
|
|
}
|
|
|
|
modelExternalSubs := make([]models.ExternalSubtitle, len(externalSubs))
|
|
for i, es := range externalSubs {
|
|
modelExternalSubs[i] = models.ExternalSubtitle{
|
|
Path: es.Path,
|
|
Language: es.Language,
|
|
Format: es.Format,
|
|
Title: es.Title,
|
|
Forced: es.Forced,
|
|
}
|
|
}
|
|
mf.ExternalSubtitles = modelExternalSubs
|
|
if mf.ExternalSubtitles == nil {
|
|
mf.ExternalSubtitles = []models.ExternalSubtitle{}
|
|
}
|
|
|
|
upserted, upsertErr := s.fileRepo.Upsert(ctx, mf)
|
|
if upsertErr != nil {
|
|
return 0, nil, fmt.Errorf("upserting file %s: %w", filePath, upsertErr)
|
|
}
|
|
if err := s.enqueueMetadataWork(ctx, folder, upserted); err != nil {
|
|
return 0, nil, fmt.Errorf("enqueueing metadata work for file %s: %w", filePath, err)
|
|
}
|
|
if markers != nil {
|
|
applied, markerErr := s.fileRepo.UpsertMarkers(ctx, upserted.ID, MarkerUpdate{
|
|
IntroStart: markers.IntroStart,
|
|
IntroEnd: markers.IntroEnd,
|
|
CreditsStart: markers.CreditsStart,
|
|
CreditsEnd: markers.CreditsEnd,
|
|
MarkersSource: models.MarkerSourceS3,
|
|
})
|
|
if markerErr != nil {
|
|
return 0, nil, fmt.Errorf("upserting markers for file %s: %w", filePath, markerErr)
|
|
}
|
|
if !applied {
|
|
slog.Debug("scanner: skipped lower-priority s3 markers", "path", filePath, "hash", fileHash)
|
|
}
|
|
}
|
|
|
|
return action, updateReasons, nil
|
|
}
|
|
|
|
action := actionNew
|
|
|
|
// Gather hints (OSHash only).
|
|
hints := s.gatherHints(filePath)
|
|
fileHash := hints.FileHash
|
|
|
|
// Try to get probe data.
|
|
probe, probeSource := s.probeFile(ctx, filePath)
|
|
|
|
// Detect external subtitles.
|
|
externalSubs = loadExternalSubs()
|
|
|
|
// Check for intro/credits markers from S3.
|
|
markerFetcher := s.markerFetcher
|
|
if markerFetcher == nil {
|
|
markerFetcher = s.fetchMarkers
|
|
}
|
|
markers := markerFetcher(ctx, fileHash)
|
|
|
|
// Build the media file for upsert.
|
|
mf := models.MediaFile{
|
|
MediaFolderID: folder.ID,
|
|
FilePath: filePath,
|
|
FileSize: fileSize,
|
|
FileModifiedAt: &fileModifiedAt,
|
|
FileHash: fileHash,
|
|
}
|
|
// This branch only runs when the path is absent from existingByPath, so
|
|
// there is no prior row to preserve import editions from.
|
|
populateScanIdentity(&mf, filePath, folder.Type, assignment, groupAssignment, nil)
|
|
|
|
// Apply probe data if available.
|
|
if probe != nil {
|
|
applyProbeData(&mf, probe, probeSource)
|
|
}
|
|
|
|
if mf.SubtitleTracks == nil {
|
|
mf.SubtitleTracks = []models.SubtitleTrack{}
|
|
}
|
|
|
|
// Convert external subtitles to model type.
|
|
modelExternalSubs := make([]models.ExternalSubtitle, len(externalSubs))
|
|
for i, es := range externalSubs {
|
|
modelExternalSubs[i] = models.ExternalSubtitle{
|
|
Path: es.Path,
|
|
Language: es.Language,
|
|
Format: es.Format,
|
|
Title: es.Title,
|
|
Forced: es.Forced,
|
|
}
|
|
}
|
|
mf.ExternalSubtitles = modelExternalSubs
|
|
|
|
if mf.ExternalSubtitles == nil {
|
|
mf.ExternalSubtitles = []models.ExternalSubtitle{}
|
|
}
|
|
|
|
// Upsert into DB.
|
|
upserted, upsertErr := s.fileRepo.Upsert(ctx, mf)
|
|
if upsertErr != nil {
|
|
return 0, nil, fmt.Errorf("upserting file %s: %w", filePath, upsertErr)
|
|
}
|
|
if err := s.enqueueMetadataWork(ctx, folder, upserted); err != nil {
|
|
return 0, nil, fmt.Errorf("enqueueing metadata work for file %s: %w", filePath, err)
|
|
}
|
|
if markers != nil {
|
|
applied, markerErr := s.fileRepo.UpsertMarkers(ctx, upserted.ID, MarkerUpdate{
|
|
IntroStart: markers.IntroStart,
|
|
IntroEnd: markers.IntroEnd,
|
|
CreditsStart: markers.CreditsStart,
|
|
CreditsEnd: markers.CreditsEnd,
|
|
MarkersSource: models.MarkerSourceS3,
|
|
})
|
|
if markerErr != nil {
|
|
return 0, nil, fmt.Errorf("upserting markers for file %s: %w", filePath, markerErr)
|
|
}
|
|
if !applied {
|
|
slog.Debug("scanner: skipped lower-priority s3 markers", "path", filePath, "hash", fileHash)
|
|
}
|
|
}
|
|
|
|
return action, nil, nil
|
|
}
|
|
|
|
// populateScanIdentity fills mf's derived root/group/identity and
|
|
// edition/presentation columns from freshly inferred scan assignments. Every
|
|
// field it sets is derived from the file's path and sibling layout — never from
|
|
// ffprobe — so the full update path and the metadata-only update path share it.
|
|
func populateScanIdentity(
|
|
mf *models.MediaFile,
|
|
filePath string,
|
|
folderType string,
|
|
assignment fileRootAssignment,
|
|
groupAssignment fileGroupAssignment,
|
|
existing *scanStateFile,
|
|
) {
|
|
if assignment.RootPath != "" {
|
|
mf.CanonicalRootPath = filepath.Clean(assignment.RootPath)
|
|
} else if root, ok := naming.DetectCanonicalRoot(filePath, folderType); ok {
|
|
mf.CanonicalRootPath = filepath.Clean(root.RootPath)
|
|
}
|
|
mf.ObservedRootPath = filepath.Clean(groupAssignment.ObservedRootPath)
|
|
mf.ContentGroupKey = groupAssignment.ContentGroupKey
|
|
mf.GroupKeyVersion = groupAssignment.GroupKeyVersion
|
|
mf.BaseTitle = groupAssignment.BaseTitle
|
|
mf.BaseYear = groupAssignment.BaseYear
|
|
mf.BaseType = groupAssignment.BaseType
|
|
mf.IdentityConfidence = groupAssignment.Confidence
|
|
mf.IdentityJSON = append([]byte(nil), groupAssignment.EvidenceJSON...)
|
|
if filenameHints := naming.ParseFilename(filePath, folderType); filenameHints != nil &&
|
|
filenameHints.Type == "series" && filenameHints.EpisodeNum > 0 {
|
|
mf.SeasonNumber = filenameHints.SeasonNum
|
|
mf.EpisodeNumber = filenameHints.EpisodeNum
|
|
}
|
|
variantHints := naming.ParseVariantHints(filePath, folderType)
|
|
if existing != nil && existing.EditionSource == "import" && existing.EditionKey != "" {
|
|
variantHints = &naming.VariantHints{
|
|
EditionRaw: existing.EditionRaw,
|
|
EditionKey: existing.EditionKey,
|
|
EditionSource: existing.EditionSource,
|
|
EditionConfidence: existing.EditionConfidence,
|
|
PresentationKind: existing.PresentationKind,
|
|
PresentationGroupKey: existing.PresentationGroupKey,
|
|
PresentationPartIndex: existing.PresentationPartIndex,
|
|
MultiEpisodeStart: existing.MultiEpisodeStart,
|
|
MultiEpisodeEnd: existing.MultiEpisodeEnd,
|
|
}
|
|
}
|
|
if variantHints != nil {
|
|
mf.EditionRaw = variantHints.EditionRaw
|
|
mf.EditionKey = variantHints.EditionKey
|
|
mf.EditionConfidence = variantHints.EditionConfidence
|
|
mf.EditionSource = variantHints.EditionSource
|
|
mf.PresentationKind = variantHints.PresentationKind
|
|
mf.PresentationGroupKey = variantHints.PresentationGroupKey
|
|
mf.PresentationPartIndex = variantHints.PresentationPartIndex
|
|
mf.MultiEpisodeStart = variantHints.MultiEpisodeStart
|
|
mf.MultiEpisodeEnd = variantHints.MultiEpisodeEnd
|
|
}
|
|
}
|
|
|
|
// identityOnlyUpdateReasons reports whether every update reason is a pure
|
|
// identity/grouping reclassification (root or content-group-key reassignment)
|
|
// that can be persisted without re-probing the media bytes. Any other reason —
|
|
// size/mtime change, a reappeared file, missing-probe repair, or a subtitle
|
|
// sidecar change — needs the full update path that re-reads the file. Returns
|
|
// false for an empty slice (nothing to update).
|
|
func identityOnlyUpdateReasons(reasons []string) bool {
|
|
if len(reasons) == 0 {
|
|
return false
|
|
}
|
|
for _, reason := range reasons {
|
|
switch reason {
|
|
case "group_assignment_changed", "root_assignment_changed":
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// identityOnlyFastPathEligible reports whether an existing row may take the
|
|
// metadata-only update path (UpdateIdentity, no probe) for the given reasons.
|
|
// Beyond the reasons being pure identity/grouping reassignments, the row
|
|
// itself must not need the full path's side effects:
|
|
//
|
|
// - A row still linked as an extra is being reclassified as primary content
|
|
// (extras never reach processFile); only the full upsert clears the extra
|
|
// linkage so the file can re-enter matching.
|
|
// - A row without an OSHash needs the full path once — it backfills the hash
|
|
// and fetches the hash-keyed S3 intro/credits markers, which no later scan
|
|
// reason would ever repair.
|
|
func identityOnlyFastPathEligible(existing *scanStateFile, reasons []string) bool {
|
|
return identityOnlyUpdateReasons(reasons) &&
|
|
existing.ExtraID == "" &&
|
|
existing.FileHash != ""
|
|
}
|
|
|
|
func scanStateUpdateReasons(
|
|
existing *scanStateFile,
|
|
fileSize int64,
|
|
fileModifiedAt time.Time,
|
|
currentExternalSubtitlePaths []string,
|
|
externalSubtitlesChecked bool,
|
|
assignment fileRootAssignment,
|
|
groupAssignment fileGroupAssignment,
|
|
libraryType string,
|
|
canRepairProbe bool,
|
|
) []string {
|
|
if existing == nil {
|
|
return nil
|
|
}
|
|
|
|
reasons := make([]string, 0, 7)
|
|
if existing.FileSize != fileSize {
|
|
reasons = append(reasons, "size_changed")
|
|
}
|
|
if !sameFileModifiedAt(existing.FileModifiedAt, fileModifiedAt) {
|
|
reasons = append(reasons, "mtime_changed")
|
|
}
|
|
if existing.MissingSince != nil {
|
|
reasons = append(reasons, "was_missing")
|
|
}
|
|
if canRepairProbe && needsCriticalProbeRepairScanState(existing) {
|
|
reasons = append(reasons, "probe_repair")
|
|
}
|
|
if externalSubtitlesChecked {
|
|
if !sameStringSet(existing.ExternalSubtitlePaths, currentExternalSubtitlePaths) {
|
|
reasons = append(reasons, "external_subtitle_changed")
|
|
}
|
|
} else if hasMissingExternalSubtitlePath(existing.ExternalSubtitlePaths) {
|
|
reasons = append(reasons, "external_subtitle_missing")
|
|
}
|
|
if scanStateRootAssignmentChanged(existing, assignment, libraryType) {
|
|
reasons = append(reasons, "root_assignment_changed")
|
|
}
|
|
if scanStateGroupAssignmentChanged(existing, groupAssignment) {
|
|
reasons = append(reasons, "group_assignment_changed")
|
|
}
|
|
return reasons
|
|
}
|
|
|
|
func externalSubtitleInfoPaths(subtitles []ExternalSubtitleInfo) []string {
|
|
paths := make([]string, 0, len(subtitles))
|
|
for _, subtitle := range subtitles {
|
|
path := strings.TrimSpace(subtitle.Path)
|
|
if path == "" {
|
|
continue
|
|
}
|
|
paths = append(paths, filepath.Clean(path))
|
|
}
|
|
return paths
|
|
}
|
|
|
|
func sameStringSet(left, right []string) bool {
|
|
seen := make(map[string]int, len(left))
|
|
for _, value := range left {
|
|
value = filepath.Clean(strings.TrimSpace(value))
|
|
if value == "." || value == "" {
|
|
continue
|
|
}
|
|
seen[value]++
|
|
}
|
|
for _, value := range right {
|
|
value = filepath.Clean(strings.TrimSpace(value))
|
|
if value == "." || value == "" {
|
|
continue
|
|
}
|
|
if seen[value] == 0 {
|
|
return false
|
|
}
|
|
seen[value]--
|
|
}
|
|
for _, count := range seen {
|
|
if count != 0 {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func hasMissingExternalSubtitlePath(paths []string) bool {
|
|
for _, path := range paths {
|
|
if strings.TrimSpace(path) == "" {
|
|
continue
|
|
}
|
|
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func shouldSkipStableConfirmedScanState(
|
|
existing *scanStateFile,
|
|
itemStatus string,
|
|
fileSize int64,
|
|
fileModifiedAt time.Time,
|
|
updateReasons []string,
|
|
canRepairProbe bool,
|
|
) bool {
|
|
if existing == nil || existing.ContentID == "" {
|
|
return false
|
|
}
|
|
if !strings.EqualFold(strings.TrimSpace(itemStatus), "matched") {
|
|
return false
|
|
}
|
|
if existing.FileSize != fileSize {
|
|
return false
|
|
}
|
|
if !sameFileModifiedAt(existing.FileModifiedAt, fileModifiedAt) {
|
|
return false
|
|
}
|
|
if existing.MissingSince != nil {
|
|
return false
|
|
}
|
|
if canRepairProbe && needsCriticalProbeRepairScanState(existing) {
|
|
return false
|
|
}
|
|
if len(updateReasons) > 0 {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func scannerUpdateReasons(
|
|
existing *models.MediaFile,
|
|
fileSize int64,
|
|
fileModifiedAt time.Time,
|
|
assignment fileRootAssignment,
|
|
groupAssignment fileGroupAssignment,
|
|
libraryType string,
|
|
canRepairProbe bool,
|
|
) []string {
|
|
if existing == nil {
|
|
return nil
|
|
}
|
|
|
|
reasons := make([]string, 0, 6)
|
|
if existing.FileSize != fileSize {
|
|
reasons = append(reasons, "size_changed")
|
|
}
|
|
if !sameFileModifiedAt(existing.FileModifiedAt, fileModifiedAt) {
|
|
reasons = append(reasons, "mtime_changed")
|
|
}
|
|
if existing.MissingSince != nil {
|
|
reasons = append(reasons, "was_missing")
|
|
}
|
|
if canRepairProbe && NeedsCriticalProbeRepair(existing) {
|
|
reasons = append(reasons, "probe_repair")
|
|
}
|
|
if rootAssignmentChanged(existing, assignment, libraryType) {
|
|
reasons = append(reasons, "root_assignment_changed")
|
|
}
|
|
if groupAssignmentChanged(existing, groupAssignment) {
|
|
reasons = append(reasons, "group_assignment_changed")
|
|
}
|
|
return reasons
|
|
}
|
|
|
|
func shouldSkipStableConfirmedFile(
|
|
existing *models.MediaFile,
|
|
itemStatus string,
|
|
fileSize int64,
|
|
fileModifiedAt time.Time,
|
|
updateReasons []string,
|
|
canRepairProbe bool,
|
|
) bool {
|
|
if existing == nil || existing.ContentID == "" {
|
|
return false
|
|
}
|
|
if !strings.EqualFold(strings.TrimSpace(itemStatus), "matched") {
|
|
return false
|
|
}
|
|
if existing.FileSize != fileSize {
|
|
return false
|
|
}
|
|
if !sameFileModifiedAt(existing.FileModifiedAt, fileModifiedAt) {
|
|
return false
|
|
}
|
|
if existing.MissingSince != nil {
|
|
return false
|
|
}
|
|
if canRepairProbe && NeedsCriticalProbeRepair(existing) {
|
|
return false
|
|
}
|
|
if len(updateReasons) > 0 {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func sameFileModifiedAt(existing *time.Time, current time.Time) bool {
|
|
if existing == nil {
|
|
return false
|
|
}
|
|
return normalizeFileModifiedAt(*existing).Equal(normalizeFileModifiedAt(current))
|
|
}
|
|
|
|
func normalizeFileModifiedAt(ts time.Time) time.Time {
|
|
return ts.UTC().Truncate(time.Microsecond)
|
|
}
|
|
|
|
func needsCriticalProbeRepairScanState(file *scanStateFile) bool {
|
|
if file == nil {
|
|
return true
|
|
}
|
|
if strings.TrimSpace(file.ProbeSource) == "" || file.ProbeUpdatedAt == nil {
|
|
return true
|
|
}
|
|
if file.Duration <= 0 {
|
|
return true
|
|
}
|
|
if strings.TrimSpace(file.Container) == "" {
|
|
return true
|
|
}
|
|
if strings.TrimSpace(file.CodecVideo) == "" {
|
|
return true
|
|
}
|
|
if strings.TrimSpace(file.CodecAudio) == "" {
|
|
return true
|
|
}
|
|
if strings.TrimSpace(file.Resolution) == "" {
|
|
return true
|
|
}
|
|
if !file.HasVideoTracks {
|
|
return true
|
|
}
|
|
if !file.HasAudioTracks {
|
|
return true
|
|
}
|
|
if !file.HasChapters {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (s *Scanner) enqueueMetadataWork(ctx context.Context, folder *models.MediaFolder, file *models.MediaFile) error {
|
|
if s == nil || s.metadataQueue == nil || folder == nil || file == nil {
|
|
return nil
|
|
}
|
|
|
|
switch strings.ToLower(strings.TrimSpace(folder.Type)) {
|
|
case "movie", "movies":
|
|
if err := s.metadataQueue.EnqueueMovieFile(ctx, file.ID); err != nil {
|
|
return err
|
|
}
|
|
slog.Debug("metadata queue: movie file enqueued",
|
|
"folder_id", folder.ID,
|
|
"file_id", file.ID,
|
|
"path", file.FilePath,
|
|
)
|
|
case "series", "tv", "show", "tvshows":
|
|
if strings.TrimSpace(file.ObservedRootPath) == "" {
|
|
return nil
|
|
}
|
|
if err := s.metadataQueue.EnqueueSeriesRoot(ctx, folder.ID, file.ObservedRootPath); err != nil {
|
|
return err
|
|
}
|
|
slog.Debug("metadata queue: series root touched",
|
|
"folder_id", folder.ID,
|
|
"observed_root_path", file.ObservedRootPath,
|
|
"file_id", file.ID,
|
|
)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *Scanner) reconcileScannedGroups(
|
|
ctx context.Context,
|
|
folderID int,
|
|
fullFolderScan bool,
|
|
scopeRoots []string,
|
|
deleteMissingInScope bool,
|
|
groups groupInferenceResult,
|
|
) error {
|
|
if s == nil {
|
|
return nil
|
|
}
|
|
if s.groupSnapshotRepo != nil {
|
|
if fullFolderScan {
|
|
if err := s.groupSnapshotRepo.ReplaceForFolder(ctx, folderID, groups.ScannedGroups); err != nil {
|
|
return err
|
|
}
|
|
} else if deleteMissingInScope {
|
|
for _, scope := range scopeRoots {
|
|
if err := s.groupSnapshotRepo.ReplaceInScope(ctx, folderID, scope, filterScannedGroupsByScope(groups.ScannedGroups, scope)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
} else {
|
|
if err := s.groupSnapshotRepo.UpsertMany(ctx, groups.ScannedGroups); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
if s.locationRepo != nil {
|
|
if fullFolderScan {
|
|
if err := s.locationRepo.ReplaceForFolder(ctx, folderID, groups.Locations); err != nil {
|
|
return err
|
|
}
|
|
} else if deleteMissingInScope {
|
|
for _, scope := range scopeRoots {
|
|
if err := s.locationRepo.ReplaceInScope(ctx, folderID, scope, filterObservedLocationsByScope(groups.Locations, scope)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
} else {
|
|
if err := s.locationRepo.UpsertMany(ctx, groups.Locations); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
if s.groupLocationRepo != nil {
|
|
if fullFolderScan {
|
|
if err := s.groupLocationRepo.ReplaceForFolder(ctx, folderID, groups.GroupLocations); err != nil {
|
|
return err
|
|
}
|
|
} else if deleteMissingInScope {
|
|
for _, scope := range scopeRoots {
|
|
if err := s.groupLocationRepo.ReplaceInScope(ctx, folderID, scope, filterGroupLocationsByScope(groups.GroupLocations, scope)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
} else {
|
|
if err := s.groupLocationRepo.UpsertMany(ctx, groups.GroupLocations); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func filterScannedGroupsByScope(groups []models.ScannedMediaGroup, scopePath string) []models.ScannedMediaGroup {
|
|
filtered := make([]models.ScannedMediaGroup, 0, len(groups))
|
|
for _, group := range groups {
|
|
if pathWithinAnyRoot(group.SampleObservedRootPath, []string{scopePath}) {
|
|
filtered = append(filtered, group)
|
|
}
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
func filterObservedLocationsByScope(locations []models.ObservedMediaLocation, scopePath string) []models.ObservedMediaLocation {
|
|
filtered := make([]models.ObservedMediaLocation, 0, len(locations))
|
|
for _, location := range locations {
|
|
if pathWithinAnyRoot(location.ObservedRootPath, []string{scopePath}) {
|
|
filtered = append(filtered, location)
|
|
}
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
func filterGroupLocationsByScope(locations []models.MediaGroupLocation, scopePath string) []models.MediaGroupLocation {
|
|
filtered := make([]models.MediaGroupLocation, 0, len(locations))
|
|
for _, location := range locations {
|
|
if pathWithinAnyRoot(location.ObservedRootPath, []string{scopePath}) {
|
|
filtered = append(filtered, location)
|
|
}
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
func (s *Scanner) reconcileScannedRoots(
|
|
ctx context.Context,
|
|
folderID int,
|
|
scopeRoots []string,
|
|
roots []models.ScannedMediaRoot,
|
|
) error {
|
|
if s == nil || s.rootSnapshotRepo == nil {
|
|
return nil
|
|
}
|
|
|
|
seenByScope := make(map[string][]string, len(scopeRoots))
|
|
for _, scope := range scopeRoots {
|
|
seenByScope[scope] = []string{}
|
|
}
|
|
|
|
for _, root := range roots {
|
|
for scope := range seenByScope {
|
|
if pathWithinAnyRoot(root.RootPath, []string{scope}) {
|
|
seenByScope[scope] = append(seenByScope[scope], root.RootPath)
|
|
}
|
|
}
|
|
}
|
|
if err := s.rootSnapshotRepo.UpsertMany(ctx, roots); err != nil {
|
|
return err
|
|
}
|
|
|
|
for scope, seenRoots := range seenByScope {
|
|
if err := s.rootSnapshotRepo.DeleteMissingInScope(ctx, folderID, scope, seenRoots); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *Scanner) loadRootOverrides(
|
|
ctx context.Context,
|
|
folderID int,
|
|
scopeRoots []string,
|
|
) (map[string]models.MediaRootOverride, error) {
|
|
overridesByRoot := map[string]models.MediaRootOverride{}
|
|
if s == nil || s.rootOverrideRepo == nil || folderID <= 0 {
|
|
return overridesByRoot, nil
|
|
}
|
|
|
|
overrides, err := s.rootOverrideRepo.ListByFolder(ctx, folderID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, override := range overrides {
|
|
if len(scopeRoots) > 0 && !pathWithinAnyRoot(override.RootPath, scopeRoots) {
|
|
continue
|
|
}
|
|
overridesByRoot[filepath.Clean(override.RootPath)] = override
|
|
}
|
|
return overridesByRoot, nil
|
|
}
|
|
|
|
func (s *Scanner) loadIdentityOverrides(
|
|
ctx context.Context,
|
|
folderID int,
|
|
) (*identityOverrideSet, error) {
|
|
if s == nil || s.identityOverrideRepo == nil || folderID <= 0 {
|
|
return nil, nil
|
|
}
|
|
overrides, err := s.identityOverrideRepo.ListByFolder(ctx, folderID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return newIdentityOverrideSet(overrides), nil
|
|
}
|
|
|
|
func (s *Scanner) loadGroupOverrides(
|
|
ctx context.Context,
|
|
folderID int,
|
|
) (map[string]models.MediaGroupOverride, error) {
|
|
overridesByKey := map[string]models.MediaGroupOverride{}
|
|
if s == nil || s.groupOverrideRepo == nil || folderID <= 0 {
|
|
return overridesByKey, nil
|
|
}
|
|
|
|
overrides, err := s.groupOverrideRepo.ListByFolder(ctx, folderID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, override := range overrides {
|
|
overridesByKey[groupOverrideKey(override.GroupKeyVersion, override.ContentGroupKey)] = override
|
|
}
|
|
return overridesByKey, nil
|
|
}
|
|
|
|
func (s *Scanner) logRootInferenceDisagreements(assignments map[string]fileRootAssignment) {
|
|
for _, assignment := range assignments {
|
|
if assignment.LegacyRootPath == "" {
|
|
continue
|
|
}
|
|
if assignment.LegacyRootPath == assignment.RootPath && assignment.LegacyType == assignment.InferredType {
|
|
continue
|
|
}
|
|
slog.Info("scanner: root inference disagreement",
|
|
"file_path", assignment.FilePath,
|
|
"legacy_root_path", assignment.LegacyRootPath,
|
|
"inferred_root_path", assignment.RootPath,
|
|
"legacy_type", assignment.LegacyType,
|
|
"inferred_type", assignment.InferredType,
|
|
"wrapper_collapsed", assignment.WrapperCollapsed,
|
|
"promoted_ancestor", assignment.PromotedAncestor,
|
|
)
|
|
}
|
|
}
|
|
|
|
func scanStateRootAssignmentChanged(existing *scanStateFile, assignment fileRootAssignment, libraryType string) bool {
|
|
if existing == nil {
|
|
return true
|
|
}
|
|
expectedRoot := assignment.RootPath
|
|
if expectedRoot == "" {
|
|
if root, ok := naming.DetectCanonicalRoot(existing.FilePath, libraryType); ok {
|
|
expectedRoot = filepath.Clean(root.RootPath)
|
|
}
|
|
}
|
|
if filepath.Clean(existing.CanonicalRootPath) != filepath.Clean(expectedRoot) {
|
|
return true
|
|
}
|
|
|
|
hints := naming.ParseVariantHints(existing.FilePath, libraryType)
|
|
if existing.EditionSource == "import" && existing.EditionKey != "" {
|
|
hints = &naming.VariantHints{
|
|
EditionRaw: existing.EditionRaw,
|
|
EditionKey: existing.EditionKey,
|
|
EditionSource: existing.EditionSource,
|
|
EditionConfidence: existing.EditionConfidence,
|
|
PresentationKind: existing.PresentationKind,
|
|
PresentationGroupKey: existing.PresentationGroupKey,
|
|
PresentationPartIndex: existing.PresentationPartIndex,
|
|
MultiEpisodeStart: existing.MultiEpisodeStart,
|
|
MultiEpisodeEnd: existing.MultiEpisodeEnd,
|
|
}
|
|
}
|
|
if hints == nil {
|
|
hints = &naming.VariantHints{}
|
|
}
|
|
if existing.EditionRaw != hints.EditionRaw ||
|
|
existing.EditionKey != hints.EditionKey ||
|
|
existing.EditionSource != hints.EditionSource ||
|
|
existing.PresentationKind != hints.PresentationKind ||
|
|
existing.PresentationGroupKey != hints.PresentationGroupKey ||
|
|
existing.PresentationPartIndex != hints.PresentationPartIndex ||
|
|
existing.MultiEpisodeStart != hints.MultiEpisodeStart ||
|
|
existing.MultiEpisodeEnd != hints.MultiEpisodeEnd {
|
|
return true
|
|
}
|
|
switch {
|
|
case existing.EditionConfidence == nil && hints.EditionConfidence == nil:
|
|
return false
|
|
case existing.EditionConfidence == nil || hints.EditionConfidence == nil:
|
|
return true
|
|
default:
|
|
return *existing.EditionConfidence != *hints.EditionConfidence
|
|
}
|
|
}
|
|
|
|
func scanStateGroupAssignmentChanged(existing *scanStateFile, assignment fileGroupAssignment) bool {
|
|
if existing == nil {
|
|
return true
|
|
}
|
|
if filepath.Clean(existing.ObservedRootPath) != filepath.Clean(assignment.ObservedRootPath) {
|
|
return true
|
|
}
|
|
if existing.ContentGroupKey != assignment.ContentGroupKey ||
|
|
existing.GroupKeyVersion != assignment.GroupKeyVersion ||
|
|
existing.BaseTitle != assignment.BaseTitle ||
|
|
existing.BaseYear != assignment.BaseYear ||
|
|
existing.BaseType != assignment.BaseType ||
|
|
existing.IdentityConfidence != assignment.Confidence {
|
|
return true
|
|
}
|
|
return !identityEvidenceEqual(existing.IdentityJSON, assignment.EvidenceJSON)
|
|
}
|
|
|
|
func rootAssignmentChanged(existing *models.MediaFile, assignment fileRootAssignment, libraryType string) bool {
|
|
if existing == nil {
|
|
return true
|
|
}
|
|
expectedRoot := assignment.RootPath
|
|
if expectedRoot == "" {
|
|
if root, ok := naming.DetectCanonicalRoot(existing.FilePath, libraryType); ok {
|
|
expectedRoot = filepath.Clean(root.RootPath)
|
|
}
|
|
}
|
|
if filepath.Clean(existing.CanonicalRootPath) != filepath.Clean(expectedRoot) {
|
|
return true
|
|
}
|
|
|
|
hints := naming.ParseVariantHints(existing.FilePath, libraryType)
|
|
if existing.EditionSource == "import" && existing.EditionKey != "" {
|
|
hints = &naming.VariantHints{
|
|
EditionRaw: existing.EditionRaw,
|
|
EditionKey: existing.EditionKey,
|
|
EditionSource: existing.EditionSource,
|
|
EditionConfidence: existing.EditionConfidence,
|
|
PresentationKind: existing.PresentationKind,
|
|
PresentationGroupKey: existing.PresentationGroupKey,
|
|
PresentationPartIndex: existing.PresentationPartIndex,
|
|
MultiEpisodeStart: existing.MultiEpisodeStart,
|
|
MultiEpisodeEnd: existing.MultiEpisodeEnd,
|
|
}
|
|
}
|
|
if hints == nil {
|
|
hints = &naming.VariantHints{}
|
|
}
|
|
if existing.EditionRaw != hints.EditionRaw ||
|
|
existing.EditionKey != hints.EditionKey ||
|
|
existing.EditionSource != hints.EditionSource ||
|
|
existing.PresentationKind != hints.PresentationKind ||
|
|
existing.PresentationGroupKey != hints.PresentationGroupKey ||
|
|
existing.PresentationPartIndex != hints.PresentationPartIndex ||
|
|
existing.MultiEpisodeStart != hints.MultiEpisodeStart ||
|
|
existing.MultiEpisodeEnd != hints.MultiEpisodeEnd {
|
|
return true
|
|
}
|
|
switch {
|
|
case existing.EditionConfidence == nil && hints.EditionConfidence == nil:
|
|
return false
|
|
case existing.EditionConfidence == nil || hints.EditionConfidence == nil:
|
|
return true
|
|
default:
|
|
return *existing.EditionConfidence != *hints.EditionConfidence
|
|
}
|
|
}
|
|
|
|
func groupAssignmentChanged(existing *models.MediaFile, assignment fileGroupAssignment) bool {
|
|
if existing == nil {
|
|
return true
|
|
}
|
|
if filepath.Clean(existing.ObservedRootPath) != filepath.Clean(assignment.ObservedRootPath) {
|
|
return true
|
|
}
|
|
if existing.ContentGroupKey != assignment.ContentGroupKey ||
|
|
existing.GroupKeyVersion != assignment.GroupKeyVersion ||
|
|
existing.BaseTitle != assignment.BaseTitle ||
|
|
existing.BaseYear != assignment.BaseYear ||
|
|
existing.BaseType != assignment.BaseType ||
|
|
existing.IdentityConfidence != assignment.Confidence {
|
|
return true
|
|
}
|
|
return !identityEvidenceEqual(existing.IdentityJSON, assignment.EvidenceJSON)
|
|
}
|
|
|
|
func identityEvidenceEqual(existing, expected []byte) bool {
|
|
if bytes.Equal(existing, expected) {
|
|
return true
|
|
}
|
|
if len(bytes.TrimSpace(existing)) == 0 || len(bytes.TrimSpace(expected)) == 0 {
|
|
return len(bytes.TrimSpace(existing)) == 0 && len(bytes.TrimSpace(expected)) == 0
|
|
}
|
|
|
|
var existingValue any
|
|
if err := json.Unmarshal(existing, &existingValue); err != nil {
|
|
return false
|
|
}
|
|
var expectedValue any
|
|
if err := json.Unmarshal(expected, &expectedValue); err != nil {
|
|
return false
|
|
}
|
|
|
|
return reflect.DeepEqual(existingValue, expectedValue)
|
|
}
|
|
|
|
func applyProbeData(mf *models.MediaFile, probe *ProbeData, probeSource string) {
|
|
mf.CodecVideo = probe.CodecVideo
|
|
mf.CodecAudio = probe.CodecAudio
|
|
mf.Resolution = probe.Resolution
|
|
mf.AudioChannels = probe.AudioChannels
|
|
mf.HDR = probe.HDR
|
|
mf.Container = probe.Container
|
|
mf.Duration = probe.Duration
|
|
mf.Bitrate = probe.Bitrate
|
|
mf.ProbeSource = probeSource
|
|
|
|
now := time.Now().UTC()
|
|
mf.ProbeUpdatedAt = &now
|
|
|
|
videoTracks := make([]models.VideoTrack, len(probe.VideoTracks))
|
|
for i, vt := range probe.VideoTracks {
|
|
videoTracks[i] = models.VideoTrack{
|
|
Title: vt.Title,
|
|
Codec: vt.Codec,
|
|
DolbyVision: vt.DolbyVision,
|
|
DVProfile: vt.DVProfile,
|
|
DVBLCompatID: vt.DVBLCompatID,
|
|
DVELPresent: vt.DVELPresent,
|
|
HDR10Plus: vt.HDR10Plus,
|
|
Profile: vt.Profile,
|
|
Level: vt.Level,
|
|
Width: vt.Width,
|
|
Height: vt.Height,
|
|
AspectRatio: vt.AspectRatio,
|
|
Interlaced: vt.Interlaced,
|
|
FrameRate: vt.FrameRate,
|
|
Bitrate: vt.Bitrate,
|
|
VideoRange: vt.VideoRange,
|
|
VideoRangeType: vt.VideoRangeType,
|
|
ColorPrimaries: vt.ColorPrimaries,
|
|
ColorSpace: vt.ColorSpace,
|
|
ColorTransfer: vt.ColorTransfer,
|
|
BitDepth: vt.BitDepth,
|
|
PixelFormat: vt.PixelFormat,
|
|
ReferenceFrames: vt.ReferenceFrames,
|
|
}
|
|
}
|
|
mf.VideoTracks = videoTracks
|
|
|
|
audioTracks := make([]models.AudioTrack, len(probe.AudioTracks))
|
|
for i, at := range probe.AudioTracks {
|
|
audioTracks[i] = models.AudioTrack{
|
|
Title: at.Title,
|
|
EmbeddedTitle: at.EmbeddedTitle,
|
|
Language: at.Language,
|
|
Codec: at.Codec,
|
|
Layout: at.Layout,
|
|
Channels: at.Channels,
|
|
Bitrate: at.Bitrate,
|
|
SampleRate: at.SampleRate,
|
|
BitDepth: at.BitDepth,
|
|
Default: at.Default,
|
|
}
|
|
}
|
|
mf.AudioTracks = audioTracks
|
|
|
|
subtitleTracks := make([]models.SubtitleTrack, len(probe.SubtitleTracks))
|
|
for i, st := range probe.SubtitleTracks {
|
|
subtitleTracks[i] = models.SubtitleTrack{
|
|
Index: st.Index,
|
|
Language: st.Language,
|
|
Codec: st.Codec,
|
|
Title: st.Title,
|
|
EmbeddedTitle: st.EmbeddedTitle,
|
|
Resolution: st.Resolution,
|
|
Forced: st.Forced,
|
|
Default: st.Default,
|
|
HearingImpaired: st.HearingImpaired,
|
|
}
|
|
}
|
|
mf.SubtitleTracks = subtitleTracks
|
|
|
|
chapters := make([]models.MediaChapter, len(probe.Chapters))
|
|
for i, chapter := range probe.Chapters {
|
|
chapters[i] = models.MediaChapter{
|
|
Index: chapter.Index,
|
|
Title: chapter.Title,
|
|
StartSeconds: chapter.StartSeconds,
|
|
EndSeconds: chapter.EndSeconds,
|
|
Source: chapter.Source,
|
|
}
|
|
}
|
|
mf.Chapters = chapters
|
|
}
|
|
|
|
// clearLegacyLinksForUnmatchableRoots was previously used to clear content
|
|
// links for files under roots without embedded folder IDs. With the library
|
|
// matching redesign, roots without folder IDs are now valid and create
|
|
// pending items, so this function is a no-op.
|
|
// TODO: remove callers and delete this function
|
|
func (s *Scanner) clearLegacyLinksForUnmatchableRoots(_ context.Context, _ int, _ []RootObservation) (int, error) {
|
|
return 0, nil
|
|
}
|
|
|
|
// gatherHints computes the OSHash for a media file.
|
|
func (s *Scanner) gatherHints(filePath string) FileHints {
|
|
hints := FileHints{}
|
|
hash, err := ComputeOSHash(filePath)
|
|
if err != nil {
|
|
slog.Warn("scanner: OSHash computation failed", "path", filePath, "error", err)
|
|
} else {
|
|
hints.FileHash = hash
|
|
}
|
|
return hints
|
|
}
|
|
|
|
// probeFile attempts to get probe data by running local ffprobe.
|
|
func (s *Scanner) probeFile(ctx context.Context, filePath string) (*ProbeData, string) {
|
|
if s.ffprobePath != "" {
|
|
probe, err := ProbeFile(ctx, s.ffprobePath, filePath)
|
|
if err != nil {
|
|
slog.Warn("scanner: ffprobe failed", "path", filePath, "error", err)
|
|
return nil, "local"
|
|
}
|
|
return probe, "local"
|
|
}
|
|
|
|
return nil, "local"
|
|
}
|
|
|
|
// fetchMarkers checks S3 for intro/credits markers for the given file hash.
|
|
func (s *Scanner) fetchMarkers(ctx context.Context, fileHash string) *IntroCreditsMarkers {
|
|
if fileHash == "" || s.s3Client == nil {
|
|
return nil
|
|
}
|
|
|
|
key := fmt.Sprintf("markers/%s.json", fileHash)
|
|
data, err := s.s3Client.GetObject(ctx, s.s3Client.Bucket(), key)
|
|
if err != nil {
|
|
// Not found is expected; don't log it.
|
|
return nil
|
|
}
|
|
|
|
var markers IntroCreditsMarkers
|
|
if err := json.Unmarshal(data, &markers); err != nil {
|
|
slog.Warn("scanner: markers JSON parse failed",
|
|
"hash", fileHash,
|
|
"error", err,
|
|
)
|
|
return nil
|
|
}
|
|
|
|
return &markers
|
|
}
|