* 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.
390 lines
13 KiB
Go
390 lines
13 KiB
Go
package scanner
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/contentid"
|
|
"github.com/Silo-Server/silo-server/internal/librarykind"
|
|
"github.com/Silo-Server/silo-server/internal/models"
|
|
"github.com/Silo-Server/silo-server/internal/naming"
|
|
)
|
|
|
|
// extraCandidate is a walked file classified as a local extra rather than
|
|
// primary content. Extras bypass root/group inference and matching entirely:
|
|
// they bind to their parent item purely by directory structure.
|
|
type extraCandidate struct {
|
|
Path string
|
|
Kind models.ExtraKind
|
|
// SupplementalDir is the classified ancestor directory (Trailers/,
|
|
// Featurettes/, ...); empty when the file was classified by its filename
|
|
// suffix (-trailer, -behindthescenes, ...).
|
|
SupplementalDir string
|
|
}
|
|
|
|
// extrasDirAncestorDepth bounds how far above a file the walk looks for a
|
|
// supplemental directory name. Two levels covers "Movie/Extras/file.mkv" and
|
|
// "Movie/Extras/Subdir/file.mkv" without letting a library that happens to
|
|
// live inside a directory named "Extras" classify everything beneath it.
|
|
const extrasDirAncestorDepth = 2
|
|
|
|
// extrasClassifier classifies walked paths as local extras using the
|
|
// library's structure. A convention-named directory ("Other", "Trailers",
|
|
// "Extras", ...) only counts as an extras dir when it is owned by a title
|
|
// folder — a directory that holds media of its own (the movie file beside the
|
|
// extras dir, or episodes in season folders beside it). Convention names used
|
|
// as content-scope folders at any depth ("movies/other/<Movie>/...",
|
|
// "movies/4K/shorts/<Movie>/...") own no media directly and never classify,
|
|
// so the titles beneath them stay primary.
|
|
type extrasClassifier struct {
|
|
folderType string
|
|
rootSet map[string]bool
|
|
// dirFiles marks directories that directly contain a walked media file.
|
|
dirFiles map[string]bool
|
|
// dirFilesBelow marks directories with a walked media file exactly two
|
|
// levels down through a non-convention child (a show folder above its
|
|
// season folders — but not a folder whose only media hides inside its
|
|
// own extras dirs).
|
|
dirFilesBelow map[string]bool
|
|
// probeFS switches ownership checks to bounded os.ReadDir probes for
|
|
// single-file (watch event) scans, which have no walked path list.
|
|
probeFS bool
|
|
}
|
|
|
|
// newExtrasClassifier builds a classifier from a scan's walked paths.
|
|
func newExtrasClassifier(folderType string, libraryRoots []string, walkedPaths []string) *extrasClassifier {
|
|
c := &extrasClassifier{
|
|
folderType: folderType,
|
|
rootSet: walkRootSet(libraryRoots),
|
|
dirFiles: make(map[string]bool, len(walkedPaths)),
|
|
dirFilesBelow: make(map[string]bool, len(walkedPaths)),
|
|
}
|
|
for _, p := range walkedPaths {
|
|
dir := filepath.Dir(p)
|
|
c.dirFiles[dir] = true
|
|
if extrasDirKinds[normalizeScannerDirLabel(filepath.Base(dir))] == "" {
|
|
c.dirFilesBelow[filepath.Dir(dir)] = true
|
|
}
|
|
}
|
|
return c
|
|
}
|
|
|
|
// newWatchExtrasClassifier builds a classifier for single-file scans; title
|
|
// ownership is probed from the filesystem instead of a walked path list.
|
|
func newWatchExtrasClassifier(folderType string, libraryRoots []string) *extrasClassifier {
|
|
return &extrasClassifier{
|
|
folderType: folderType,
|
|
rootSet: walkRootSet(libraryRoots),
|
|
probeFS: true,
|
|
}
|
|
}
|
|
|
|
// classify reports whether the walked path is a local extra.
|
|
//
|
|
// Directory names win over filename suffixes. For non-movie libraries a file
|
|
// carrying a parseable SxxExx episode token is never an extra: series
|
|
// "Extras/SxxExx" files keep their documented season-0 mapping.
|
|
func (c *extrasClassifier) classify(path string) (extraCandidate, bool) {
|
|
candidate := extraCandidate{Path: path}
|
|
|
|
dir := filepath.Dir(path)
|
|
for depth := 0; depth < extrasDirAncestorDepth; depth++ {
|
|
label := normalizeScannerDirLabel(filepath.Base(dir))
|
|
if kind, ok := extrasDirKinds[label]; ok {
|
|
if c.titleDirOwns(dir) {
|
|
candidate.Kind = kind
|
|
candidate.SupplementalDir = dir
|
|
}
|
|
break
|
|
}
|
|
parent := filepath.Dir(dir)
|
|
if parent == dir {
|
|
break
|
|
}
|
|
dir = parent
|
|
}
|
|
|
|
if candidate.SupplementalDir == "" {
|
|
kind, ok := naming.ParseExtraSuffix(path)
|
|
if !ok {
|
|
return extraCandidate{}, false
|
|
}
|
|
candidate.Kind = models.NormalizeExtraKind(kind)
|
|
}
|
|
|
|
// Preserve the documented series behavior: an episode-tokened file under
|
|
// Extras/ is a season-0 special, not an extra.
|
|
if !librarykind.IsMovie(c.folderType) {
|
|
if hints := naming.ParseFilename(path, c.folderType); hints != nil &&
|
|
hints.Type == "series" && hints.EpisodeNum > 0 {
|
|
return extraCandidate{}, false
|
|
}
|
|
}
|
|
|
|
return candidate, true
|
|
}
|
|
|
|
// titleDirOwns reports whether the matched supplemental directory is owned by
|
|
// a title folder: the first non-supplemental ancestor must not be a library
|
|
// root and must hold media of its own — directly for movie folders, or one
|
|
// level down for series folders whose episodes live in season subfolders.
|
|
func (c *extrasClassifier) titleDirOwns(supplementalDir string) bool {
|
|
owner := firstNonSupplementalAncestor(supplementalDir)
|
|
if c.rootSet[owner] {
|
|
return false
|
|
}
|
|
if c.probeFS {
|
|
depth := 1
|
|
if !librarykind.IsMovie(c.folderType) {
|
|
depth = 2
|
|
}
|
|
return c.dirHoldsMedia(owner, depth)
|
|
}
|
|
if c.dirFiles[owner] {
|
|
return true
|
|
}
|
|
return !librarykind.IsMovie(c.folderType) && c.dirFilesBelow[owner]
|
|
}
|
|
|
|
// dirHoldsMedia is the probeFS counterpart of dirFiles/dirFilesBelow: it
|
|
// reports whether dir holds a media file within depth levels, without
|
|
// descending into convention-named subdirectories.
|
|
func (c *extrasClassifier) dirHoldsMedia(dir string, depth int) bool {
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
mode := walkModeFor(c.folderType)
|
|
for _, entry := range entries {
|
|
if entry.IsDir() {
|
|
if depth > 1 && extrasDirKinds[normalizeScannerDirLabel(entry.Name())] == "" &&
|
|
c.dirHoldsMedia(filepath.Join(dir, entry.Name()), depth-1) {
|
|
return true
|
|
}
|
|
continue
|
|
}
|
|
if mode.acceptsExt(strings.ToLower(filepath.Ext(entry.Name()))) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// firstNonSupplementalAncestor walks up from a supplemental directory past any
|
|
// chained convention names ("Extras/Behind The Scenes/") and returns the
|
|
// cleaned directory that owns the supplemental chain.
|
|
func firstNonSupplementalAncestor(supplementalDir string) string {
|
|
dir := filepath.Dir(filepath.Clean(supplementalDir))
|
|
for extrasDirKinds[normalizeScannerDirLabel(filepath.Base(dir))] != "" {
|
|
next := filepath.Dir(dir)
|
|
if next == dir {
|
|
break
|
|
}
|
|
dir = next
|
|
}
|
|
return dir
|
|
}
|
|
|
|
// walkRootSet builds the cleaned-path set used for scope checks against the
|
|
// library's configured roots.
|
|
func walkRootSet(roots []string) map[string]bool {
|
|
set := make(map[string]bool, len(roots))
|
|
for _, root := range roots {
|
|
set[filepath.Clean(root)] = true
|
|
}
|
|
return set
|
|
}
|
|
|
|
// partitionExtraPaths splits walked paths into primary content and extras.
|
|
// Primary paths feed the existing root/group inference and matching pipeline
|
|
// untouched; extras are processed separately and never influence identity.
|
|
func partitionExtraPaths(paths []string, folderType string, libraryRoots []string) ([]string, []extraCandidate) {
|
|
classifier := newExtrasClassifier(folderType, libraryRoots, paths)
|
|
primary := paths[:0:0]
|
|
var extras []extraCandidate
|
|
for _, p := range paths {
|
|
if candidate, ok := classifier.classify(p); ok {
|
|
extras = append(extras, candidate)
|
|
continue
|
|
}
|
|
primary = append(primary, p)
|
|
}
|
|
return primary, extras
|
|
}
|
|
|
|
// extrasScanStats aggregates processExtraFiles outcomes for the scan result.
|
|
type extrasScanStats struct {
|
|
New int
|
|
Updated int
|
|
Unchanged int
|
|
Skipped int
|
|
Errors int
|
|
}
|
|
|
|
// processExtraFiles ingests classified extras: bind to a parent item, upsert
|
|
// the media_extras entity, and upsert the backing media_files row (probe data
|
|
// included) with extra_id set and content/episode ids cleared. Files whose
|
|
// parent cannot be resolved yet (parent unmatched or ambiguous) are skipped;
|
|
// the next scan retries once the parent has a content id.
|
|
func (s *Scanner) processExtraFiles(
|
|
ctx context.Context,
|
|
folder *models.MediaFolder,
|
|
extras []extraCandidate,
|
|
existingByPath map[string]*scanStateFile,
|
|
) extrasScanStats {
|
|
var stats extrasScanStats
|
|
if len(extras) == 0 || s.extraRepo == nil {
|
|
return stats
|
|
}
|
|
|
|
// Parent binding is scoped by the library's configured roots, not the
|
|
// (possibly narrower) walk roots of a scoped scan: a movie folder targeted
|
|
// directly by a subtree scan must still bind its own extras.
|
|
rootSet := walkRootSet(folder.Paths)
|
|
|
|
for _, candidate := range extras {
|
|
if ctx.Err() != nil {
|
|
return stats
|
|
}
|
|
|
|
info, err := os.Stat(candidate.Path)
|
|
if err != nil {
|
|
slog.Warn("scanner: extra stat failed", "path", candidate.Path, "error", err)
|
|
stats.Errors++
|
|
continue
|
|
}
|
|
|
|
extraID := contentid.ForLocal(candidate.Path)
|
|
parentID, err := s.resolveExtraParent(ctx, folder.ID, candidate, rootSet)
|
|
if err != nil {
|
|
slog.Warn("scanner: extra parent lookup failed", "path", candidate.Path, "error", err)
|
|
stats.Errors++
|
|
continue
|
|
}
|
|
if parentID == "" {
|
|
slog.Debug("scanner: extra parent unresolved, deferring",
|
|
"path", candidate.Path, "kind", candidate.Kind)
|
|
stats.Skipped++
|
|
continue
|
|
}
|
|
|
|
// Upsert the entity before the unchanged check so parent/kind/title
|
|
// converge on every scan (a rematched parent or reclassified kind
|
|
// must not be masked by an unchanged file).
|
|
if err := s.extraRepo.Upsert(ctx, models.MediaExtra{
|
|
ContentID: extraID,
|
|
ParentID: parentID,
|
|
Kind: candidate.Kind,
|
|
Title: naming.ExtraTitleFromFile(candidate.Path),
|
|
}); err != nil {
|
|
slog.Warn("scanner: extra upsert failed", "path", candidate.Path, "error", err)
|
|
stats.Errors++
|
|
continue
|
|
}
|
|
|
|
fileModifiedAt := normalizeFileModifiedAt(info.ModTime())
|
|
existing := existingByPath[candidate.Path]
|
|
if existing != nil && existing.ExtraID == extraID &&
|
|
existing.FileSize == info.Size() &&
|
|
existing.FileModifiedAt != nil && existing.FileModifiedAt.Equal(fileModifiedAt) &&
|
|
existing.ProbeUpdatedAt != nil && existing.MissingSince == nil {
|
|
stats.Unchanged++
|
|
continue
|
|
}
|
|
|
|
hints := s.gatherHints(candidate.Path)
|
|
probe, probeSource := s.probeFile(ctx, candidate.Path)
|
|
|
|
mf := models.MediaFile{
|
|
MediaFolderID: folder.ID,
|
|
FilePath: candidate.Path,
|
|
FileSize: info.Size(),
|
|
FileModifiedAt: &fileModifiedAt,
|
|
FileHash: hints.FileHash,
|
|
ExtraID: extraID,
|
|
}
|
|
if probe != nil {
|
|
applyProbeData(&mf, probe, probeSource)
|
|
}
|
|
if mf.SubtitleTracks == nil {
|
|
mf.SubtitleTracks = []models.SubtitleTrack{}
|
|
}
|
|
if mf.ExternalSubtitles == nil {
|
|
mf.ExternalSubtitles = []models.ExternalSubtitle{}
|
|
}
|
|
|
|
// The upsert clears content/episode linkage atomically when extra_id
|
|
// is set, so a pre-existing primary row (e.g. a "-trailer" file
|
|
// previously scanned as a movie version) converts in one statement.
|
|
if _, err := s.fileRepo.Upsert(ctx, mf); err != nil {
|
|
slog.Warn("scanner: extra file upsert failed", "path", candidate.Path, "error", err)
|
|
stats.Errors++
|
|
continue
|
|
}
|
|
|
|
if existing == nil {
|
|
stats.New++
|
|
} else {
|
|
stats.Updated++
|
|
}
|
|
}
|
|
|
|
if stats.New+stats.Updated+stats.Skipped+stats.Errors > 0 {
|
|
slog.Info("scanner: processed extras",
|
|
"folder_id", folder.ID,
|
|
"new", stats.New,
|
|
"updated", stats.Updated,
|
|
"unchanged", stats.Unchanged,
|
|
"deferred", stats.Skipped,
|
|
"errors", stats.Errors,
|
|
)
|
|
}
|
|
return stats
|
|
}
|
|
|
|
// resolveExtraParent finds the content id of the item owning an extra.
|
|
//
|
|
// Directory-classified extras bind to the directory containing the
|
|
// supplemental folder ("Movie (2020)/" for "Movie (2020)/Extras/x.mkv"),
|
|
// requiring that directory to hold exactly one item. Suffix-classified files
|
|
// first try the sibling primary file sharing their stem ("Movie A.mkv" for
|
|
// "Movie A-trailer.mkv"), so flat multi-movie folders bind correctly, then
|
|
// fall back to the unambiguous-directory rule. Library roots never bind.
|
|
func (s *Scanner) resolveExtraParent(
|
|
ctx context.Context,
|
|
folderID int,
|
|
candidate extraCandidate,
|
|
rootSet map[string]bool,
|
|
) (string, error) {
|
|
if candidate.SupplementalDir == "" {
|
|
dir := filepath.Dir(candidate.Path)
|
|
stem := strings.TrimSuffix(filepath.Base(candidate.Path), filepath.Ext(candidate.Path))
|
|
if idx := strings.LastIndexAny(stem, "-."); idx > 0 {
|
|
stem = strings.TrimSpace(stem[:idx])
|
|
}
|
|
if stem != "" {
|
|
parentID, err := s.fileRepo.FindParentContentIDForStem(ctx, folderID, dir, stem)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if parentID != "" {
|
|
return parentID, nil
|
|
}
|
|
}
|
|
if rootSet[filepath.Clean(dir)] {
|
|
return "", nil
|
|
}
|
|
return s.fileRepo.FindUnambiguousParentContentIDForDir(ctx, folderID, dir)
|
|
}
|
|
|
|
parentDir := firstNonSupplementalAncestor(candidate.SupplementalDir)
|
|
if rootSet[filepath.Clean(parentDir)] {
|
|
// Supplemental dir sits at the library root — no single owner.
|
|
return "", nil
|
|
}
|
|
return s.fileRepo.FindUnambiguousParentContentIDForDir(ctx, folderID, parentDir)
|
|
}
|