One "video" kind meant every video library behaved the same, so a film shelf tried to parse episode numbers and a TV library could not be told apart from it. The kinds are now a declaration rather than a special case. Five video kinds, all owning the same containers and differing only in how their files become items: movies flat tv series/season/episode adult flat hentai series/season/episode video series/season/episode (kept: existing libraries already use it) The scanner dispatches on library.GroupModeFor(lib.Kind) instead of comparing the kind to "video", so a new episodic kind is a registry entry and nothing in the scanner changes. The decision worth recording, because it could have gone the other way: media_items.kind stays STRUCTURAL. Every video library stores 'video' on its rows, not its own kind. media_items.kind answers "is this a playable video", which is what the playback, catalog and Jellyfin paths actually ask, and migration 0013 already committed to that rule when it decided an episode would stay kind='video'. Storing the library kind instead would have meant teaching every query that says kind='video' about five new values - catalog's episode and next-episode reads, Jellyfin's episodes-only filter, the item-type mapping, the web player's routing - and every kind added later would have had to do it again. So the two jobs are split: the ROW kind says what a thing is, the LIBRARY kind says how it is classified, and the library kind is what routes metadata. The enricher now reads the library to choose providers, because a movies library and an adult library hold identical rows and must not query the same catalogue. The adult kinds have no metadata provider. There is no mainstream catalogue for that content - the ecosystem uses StashDB or local NFO files - so they scan and browse and stay unenriched until someone registers one. That is stated in the registry rather than left as a surprise. Verified end-to-end: the same tree under a movies library stays flat, under a tv library builds series/season/episodes, and every row in both is stored as kind='video'. The UI needs no change - it reads /api/v1/media-types, so the new kinds appeared in the picker by themselves. Red-green: dispatching on the literal kind fails the tv and hentai cases; storing the library kind on rows fails the structural-kind assertion.
885 lines
28 KiB
Go
885 lines
28 KiB
Go
// Package scanner walks a library's root and reconciles it with the database:
|
|
// files that appeared become items, files that changed are re-upserted with
|
|
// fresh codec facts, and files that vanished are flagged missing rather than
|
|
// deleted.
|
|
//
|
|
// It is separate from internal/library because the two have different callers.
|
|
// A scan runs in a background job with no user and no HTTP request, tolerates a
|
|
// partially readable tree, and must never delete; library CRUD is an admin
|
|
// surface where a bad request is a 400 and a bad path is a 404. Scanner imports
|
|
// library (for Library and the media-kind registry), never the other way round.
|
|
package scanner
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"io/fs"
|
|
"log/slog"
|
|
"path"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/mithral/vellum/internal/core"
|
|
"github.com/mithral/vellum/internal/jobs"
|
|
"github.com/mithral/vellum/internal/library"
|
|
"github.com/mithral/vellum/internal/natsort"
|
|
"github.com/mithral/vellum/internal/probe"
|
|
)
|
|
|
|
const (
|
|
// scanBatchSize is the number of media-item groups reconciled per
|
|
// transaction. Chunking keeps the SQLite write lock held for seconds at a
|
|
// time rather than minutes on a large library, so other writers (progress
|
|
// saves, session touches) are not starved.
|
|
scanBatchSize = 500
|
|
// maxScanErrors bounds the errors retained in a ScanResult. A pathological
|
|
// tree can produce an error per file; we keep the first few and count the
|
|
// rest rather than balloon the result and the job row.
|
|
maxScanErrors = 100
|
|
)
|
|
|
|
// ScanResult summarises one scan pass.
|
|
type ScanResult struct {
|
|
LibraryID int64 `json:"library_id"`
|
|
Scanned int `json:"scanned"`
|
|
Added int `json:"added"`
|
|
Updated int `json:"updated"`
|
|
Restored int `json:"restored"`
|
|
Missing int `json:"missing"`
|
|
Unchanged int `json:"unchanged"`
|
|
Duration int64 `json:"duration_ms"`
|
|
Errors []string `json:"errors,omitempty"`
|
|
ErrorsSuppressed int `json:"errors_suppressed,omitempty"`
|
|
}
|
|
|
|
// addError records a scan error while keeping res.Errors bounded; errors past
|
|
// maxScanErrors are counted instead of stored.
|
|
func (r *ScanResult) addError(msg string) {
|
|
if len(r.Errors) < maxScanErrors {
|
|
r.Errors = append(r.Errors, msg)
|
|
return
|
|
}
|
|
r.ErrorsSuppressed++
|
|
}
|
|
|
|
// candidate is a supported file found on disk.
|
|
type candidate struct {
|
|
relPath string
|
|
size int64
|
|
mtime int64
|
|
inode int64
|
|
ext string
|
|
}
|
|
|
|
// knownFile is an existing media_files row for this library.
|
|
type knownFile struct {
|
|
itemID int64
|
|
fileID int64
|
|
size int64
|
|
mtime int64
|
|
missing bool
|
|
}
|
|
|
|
// group is one media item: either a single file or a directory of tracks.
|
|
// The kind/parent/season/episode fields are the television shape — a series and
|
|
// its seasons are fileless groups that exist only to stand above their episodes,
|
|
// so a missing parent is detected and a series/season row is upserted (not
|
|
// duplicated) on every scan.
|
|
type group struct {
|
|
key string // media_items.path (unique within the library)
|
|
title string
|
|
kind string // media_items.kind: lib.Kind, "series" or "season"
|
|
parentKey string // path key of the parent group, "" when top-level
|
|
season int // season_number, 0 means NULL
|
|
episode int // episode_number, 0 means NULL
|
|
files []candidate
|
|
}
|
|
|
|
// Scan walks a library's root and reconciles it with the database.
|
|
//
|
|
// Files that changed are re-upserted; files that vanished are marked missing
|
|
// rather than deleted, so their progress survives a temporarily unavailable
|
|
// mount. A scan never fails because of a single unreadable file — it records
|
|
// the error and continues, because a partial library is better than none.
|
|
func Scan(ctx context.Context, db *sql.DB, lib library.Library, log *slog.Logger) (ScanResult, error) {
|
|
start := time.Now()
|
|
res := ScanResult{LibraryID: lib.ID}
|
|
|
|
exts := library.ExtensionsFor(lib.Kind)
|
|
if len(exts) == 0 {
|
|
return res, fmt.Errorf("library %d: no extensions registered for kind %q", lib.ID, lib.Kind)
|
|
}
|
|
|
|
knownFiles, err := loadKnownFiles(ctx, db, lib.ID)
|
|
if err != nil {
|
|
return res, err
|
|
}
|
|
knownItems, err := loadKnownItems(ctx, db, lib.ID)
|
|
if err != nil {
|
|
return res, err
|
|
}
|
|
|
|
found, errs := walkRoot(lib.RootPath, exts)
|
|
for _, e := range errs {
|
|
res.addError(e)
|
|
}
|
|
|
|
groups := buildGroups(lib, found)
|
|
|
|
now := core.Now()
|
|
seenFiles := make(map[string]bool, len(found))
|
|
|
|
// report writes the running result back to the job (if any) so the UI can
|
|
// poll for progress. Best-effort: a failed report must not fail the scan.
|
|
report := func() {
|
|
if id, ok := jobs.JobID(ctx); ok {
|
|
_ = jobs.SetResult(ctx, db, id, res)
|
|
}
|
|
}
|
|
|
|
groupKeys := sortedGroupKeys(groups)
|
|
|
|
// Upserted item IDs by group key. A series and its seasons carry no file,
|
|
// so they are only ever reached as the parent of an episode; memoising the
|
|
// ID here keeps a season with fifty episodes to one upsert, and lets a
|
|
// parent that landed in an earlier batch be resolved without a second walk.
|
|
itemIDs := make(map[string]int64, len(groups))
|
|
for start := 0; start < len(groupKeys); start += scanBatchSize {
|
|
end := start + scanBatchSize
|
|
if end > len(groupKeys) {
|
|
end = len(groupKeys)
|
|
}
|
|
batch := groupKeys[start:end]
|
|
|
|
// Probe the changed media files in this batch BEFORE opening the write
|
|
// transaction, so a slow ffprobe (a stalled mount, a huge file) can
|
|
// never hold SQLite's write lock.
|
|
//
|
|
// The probe map is deliberately built from the same changed-only test
|
|
// the transaction uses below. Probing on the unchanged path would turn
|
|
// every rescan into a full ffprobe sweep of the entire library; the
|
|
// size+mtime skip is what keeps rescans cheap, and the probe must live
|
|
// on the changed side of it.
|
|
probed := probeBatch(ctx, lib, groups, batch, knownFiles, &res)
|
|
|
|
err := commitBatch(ctx, db, func(tx *sql.Tx) error {
|
|
for _, key := range batch {
|
|
g := groups[key]
|
|
|
|
itemID, err := upsertGroup(ctx, tx, lib, g, groups, itemIDs)
|
|
if err != nil {
|
|
res.addError(fmt.Sprintf("%s: %v", g.key, err))
|
|
continue
|
|
}
|
|
|
|
for _, c := range g.files {
|
|
res.Scanned++
|
|
seenFiles[c.relPath] = true
|
|
|
|
prev, exists := knownFiles[c.relPath]
|
|
if exists && !prev.missing && prev.size == c.size && prev.mtime == c.mtime {
|
|
res.Unchanged++
|
|
continue
|
|
}
|
|
|
|
info, wasProbed := probed[c.relPath]
|
|
if err := upsertFile(ctx, tx, itemID, c, info, wasProbed, now); err != nil {
|
|
res.addError(fmt.Sprintf("%s: %v", c.relPath, err))
|
|
continue
|
|
}
|
|
switch {
|
|
case !exists:
|
|
res.Added++
|
|
case prev.missing:
|
|
res.Restored++
|
|
default:
|
|
res.Updated++
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return res, err
|
|
}
|
|
report()
|
|
}
|
|
|
|
// Items whose group is gone entirely.
|
|
missingItemKeys := make([]string, 0)
|
|
for key := range knownItems {
|
|
if _, ok := groups[key]; ok {
|
|
continue
|
|
}
|
|
missingItemKeys = append(missingItemKeys, key)
|
|
}
|
|
sort.Strings(missingItemKeys)
|
|
|
|
for start := 0; start < len(missingItemKeys); start += scanBatchSize {
|
|
end := start + scanBatchSize
|
|
if end > len(missingItemKeys) {
|
|
end = len(missingItemKeys)
|
|
}
|
|
batch := missingItemKeys[start:end]
|
|
|
|
err := commitBatch(ctx, db, func(tx *sql.Tx) error {
|
|
for _, key := range batch {
|
|
if err := markItemMissing(ctx, tx, lib.ID, key, now); err != nil {
|
|
res.addError(fmt.Sprintf("%s: %v", key, err))
|
|
continue
|
|
}
|
|
res.Missing++
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return res, err
|
|
}
|
|
report()
|
|
}
|
|
|
|
// Individual files that vanished but whose item remains.
|
|
missingFilePaths := make([]string, 0)
|
|
for rel := range knownFiles {
|
|
if seenFiles[rel] {
|
|
continue
|
|
}
|
|
missingFilePaths = append(missingFilePaths, rel)
|
|
}
|
|
sort.Strings(missingFilePaths)
|
|
|
|
for start := 0; start < len(missingFilePaths); start += scanBatchSize {
|
|
end := start + scanBatchSize
|
|
if end > len(missingFilePaths) {
|
|
end = len(missingFilePaths)
|
|
}
|
|
batch := missingFilePaths[start:end]
|
|
|
|
err := commitBatch(ctx, db, func(tx *sql.Tx) error {
|
|
for _, rel := range batch {
|
|
if err := markFileMissing(ctx, tx, rel, now); err != nil {
|
|
res.addError(fmt.Sprintf("%s: %v", rel, err))
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return res, err
|
|
}
|
|
report()
|
|
}
|
|
|
|
res.Duration = time.Since(start).Milliseconds()
|
|
log.Info("scan complete",
|
|
"library", lib.Name, "kind", lib.Kind, "grouping", library.GroupModeFor(lib.Kind),
|
|
"scanned", res.Scanned, "added", res.Added, "updated", res.Updated,
|
|
"restored", res.Restored, "missing", res.Missing, "unchanged", res.Unchanged,
|
|
"duration_ms", res.Duration, "errors", len(res.Errors),
|
|
"errors_suppressed", res.ErrorsSuppressed)
|
|
return res, nil
|
|
}
|
|
|
|
// commitBatch runs fn in a single write transaction and commits it. Per-row
|
|
// errors are handled inside fn; a returned error (begin or commit failure)
|
|
// aborts the scan.
|
|
func commitBatch(ctx context.Context, db *sql.DB, fn func(tx *sql.Tx) error) error {
|
|
tx, err := db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := fn(tx); err != nil {
|
|
tx.Rollback() //nolint:errcheck
|
|
return err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return fmt.Errorf("library: commit scan: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// buildGroups turns found files into items. Directory-grouped kinds (audiobooks,
|
|
// music) collapse a folder of tracks into one item; everything else is one item
|
|
// per file — except an episode-grouped kind, where a file that parses as an
|
|
// episode grows a series and a season above it.
|
|
//
|
|
// The dispatch is on the kind's declared grouping rather than on its name, so a
|
|
// new episodic kind is a registry entry and nothing here changes. Comparing the
|
|
// kind to "video" was what made a movies library indistinguishable from a TV
|
|
// one.
|
|
func buildGroups(lib library.Library, found map[string]candidate) map[string]group {
|
|
mode := library.GroupModeFor(lib.Kind)
|
|
if mode == library.GroupByEpisode {
|
|
return buildTVGroups(lib, found)
|
|
}
|
|
|
|
// The row kind is not always the library kind: every video kind stores
|
|
// "video", so that "is this a playable video" stays one answer however the
|
|
// admin classified the library. See library.MediaType.ItemKind.
|
|
itemKind := library.ItemKindFor(lib.Kind)
|
|
groups := make(map[string]group)
|
|
|
|
for _, rel := range sortedCandidateKeys(found) {
|
|
c := found[rel]
|
|
|
|
var key, title string
|
|
if mode == library.GroupByDirectory {
|
|
dir := path.Dir(rel)
|
|
if dir == "." {
|
|
key, title = ".", lib.Name
|
|
} else {
|
|
key, title = dir, library.CleanTitle(path.Base(dir))
|
|
}
|
|
} else {
|
|
key, title = rel, library.CleanTitle(rel)
|
|
}
|
|
|
|
g := groups[key]
|
|
g.key, g.title, g.kind = key, title, itemKind
|
|
g.files = append(g.files, c)
|
|
groups[key] = g
|
|
}
|
|
|
|
// Natural order so track 2 precedes track 10.
|
|
for k, g := range groups {
|
|
sort.Slice(g.files, func(i, j int) bool {
|
|
return natsort.Less(g.files[i].relPath, g.files[j].relPath)
|
|
})
|
|
groups[k] = g
|
|
}
|
|
return groups
|
|
}
|
|
|
|
// Episode patterns. Both require the marker to be delimited by non-alphanumeric
|
|
// characters or the edges of the name: without that guard "Movie.1999x264.mkv"
|
|
// reads as season 99, episode 264, and every release-tagged file does. A
|
|
// multi-episode file ("S01E02E03") parses as its first episode; shortcut: the
|
|
// second number is dropped, split it if a library ever needs both rows.
|
|
var (
|
|
episodeSExE = regexp.MustCompile(`(?i)(?:^|[^a-z0-9])s(\d{1,3})[\s._-]*e(\d{1,4})(?:[\s._-]*e\d{1,4})*(?:[^0-9a-z]|$)`)
|
|
episodeXxY = regexp.MustCompile(`(?i)(?:^|[^a-z0-9])(\d{1,2})x(\d{1,3})(?:x\d{1,3})*(?:[^0-9a-z]|$)`)
|
|
seasonDirRE = regexp.MustCompile(`(?i)^season[\s._-]*0*(\d{1,3})$`)
|
|
|
|
// An episode marker that carries no season: "E05", "ep5", "Episode 5". The
|
|
// digits are capped at three on purpose. Four would let "Movie.E.1999.mkv"
|
|
// read as episode 1999, and no episode number needs four digits while years
|
|
// routinely have them.
|
|
episodeOnly = regexp.MustCompile(`(?i)(?:^|[^a-z0-9])(?:episode|ep|e)[\s._-]*(\d{1,3})(?:[^0-9a-z]|$)`)
|
|
|
|
// A bare leading number, "05 - Title.mkv". Only consulted when the directory
|
|
// is itself a season, because without that anchor it is indistinguishable
|
|
// from a film whose name starts with a number.
|
|
leadingEpisode = regexp.MustCompile(`^0*(\d{1,3})(?:[\s._-]|$)`)
|
|
)
|
|
|
|
// seasonDirNumber returns the season a directory name stands for, or 0 when it
|
|
// is not a season directory ("Season 1", "Season 01", "Season.2").
|
|
func seasonDirNumber(name string) int {
|
|
m := seasonDirRE.FindStringSubmatch(strings.TrimSpace(name))
|
|
if m == nil {
|
|
return 0
|
|
}
|
|
n, _ := strconv.Atoi(m[1])
|
|
return n
|
|
}
|
|
|
|
// parseEpisode reads a season and episode number out of a file name. dirName is
|
|
// the name of the directory the file sits in, which wins over the filename when
|
|
// it is itself a season directory: the directory is how the owner filed the
|
|
// show, and it is the only signal a multi-episode file that omits its season
|
|
// still gets one from.
|
|
//
|
|
// Three shapes are recognised, in order: a full "S01E02" or "1x02" marker, an
|
|
// episode-only "E05" / "ep5" / "Episode 5", and a bare leading number under a
|
|
// season directory. The last two are the naming older rips and anime actually
|
|
// use, and each is deliberately narrow — see the patterns for why.
|
|
func parseEpisode(name, dirName string) (season, episode int, ok bool) {
|
|
m := episodeSExE.FindStringSubmatch(name)
|
|
if m == nil {
|
|
m = episodeXxY.FindStringSubmatch(name)
|
|
}
|
|
if m != nil {
|
|
season, _ = strconv.Atoi(m[1])
|
|
if s := seasonDirNumber(dirName); s > 0 {
|
|
season = s
|
|
}
|
|
episode, _ = strconv.Atoi(m[2])
|
|
if season <= 0 || episode <= 0 {
|
|
return 0, 0, false
|
|
}
|
|
return season, episode, true
|
|
}
|
|
|
|
// An episode-only marker. Common in anime, where the season is either in the
|
|
// directory or simply implied; with no season directory the season is 1,
|
|
// which is what those libraries mean by it.
|
|
if m := episodeOnly.FindStringSubmatch(name); m != nil {
|
|
if episode, _ = strconv.Atoi(m[1]); episode > 0 {
|
|
season = 1
|
|
if s := seasonDirNumber(dirName); s > 0 {
|
|
season = s
|
|
}
|
|
return season, episode, true
|
|
}
|
|
}
|
|
|
|
// A bare leading number, trusted only under a season directory.
|
|
if s := seasonDirNumber(dirName); s > 0 {
|
|
if m := leadingEpisode.FindStringSubmatch(name); m != nil {
|
|
if e, _ := strconv.Atoi(m[1]); e > 0 {
|
|
return s, e, true
|
|
}
|
|
}
|
|
}
|
|
return 0, 0, false
|
|
}
|
|
|
|
// episodeTitle is the part of the file name before the episode marker, so
|
|
// "The.Wire.1x02.mkv" reads as "The Wire" and leaves the numbers to their own
|
|
// columns. A file with no leading name at all falls back to the marker itself.
|
|
//
|
|
// CleanTitle runs on the whole file name first because it strips the extension
|
|
// by path rule: handed a bare "The.Wire" it reads ".Wire" as the extension and
|
|
// returns "The". Cleaning first also means the marker is matched against the
|
|
// cleaned text, which the surrounding separators can no longer disturb.
|
|
func episodeTitle(name string) string {
|
|
base := library.CleanTitle(name)
|
|
m := episodeSExE.FindStringIndex(base)
|
|
if m == nil {
|
|
m = episodeXxY.FindStringIndex(base)
|
|
}
|
|
if m == nil {
|
|
return base
|
|
}
|
|
if t := strings.TrimRight(base[:m[0]], " -_."); t != "" {
|
|
return t
|
|
}
|
|
return base
|
|
}
|
|
|
|
// buildTVGroups builds the television tree for a video library: library ->
|
|
// series -> season -> episode, with the episode keeping kind='video' and the
|
|
// two parents standing in for a directory that has no file of its own.
|
|
//
|
|
// A file that is not an episode is a film and is grouped exactly as it was
|
|
// before any of this existed, so adding it cannot disturb a movie library.
|
|
func buildTVGroups(lib library.Library, found map[string]candidate) map[string]group {
|
|
// Rows carry the structural kind, not the library's own: a tv library and a
|
|
// movies library both store 'video', and only the series and season rows —
|
|
// which are the same everywhere — name themselves. See ItemKind.
|
|
itemKind := library.ItemKindFor(lib.Kind)
|
|
groups := make(map[string]group)
|
|
|
|
add := func(g group) {
|
|
cur := groups[g.key]
|
|
cur.key, cur.title, cur.kind, cur.parentKey = g.key, g.title, g.kind, g.parentKey
|
|
if g.season > 0 {
|
|
cur.season = g.season
|
|
}
|
|
if g.episode > 0 {
|
|
cur.episode = g.episode
|
|
}
|
|
groups[g.key] = cur
|
|
}
|
|
|
|
for _, rel := range sortedCandidateKeys(found) {
|
|
c := found[rel]
|
|
dir := path.Dir(rel)
|
|
|
|
// The base name, not the relative path: parseEpisode is a function of a
|
|
// file name and its directory, and the leading-number pattern is anchored
|
|
// at the start. Handed "Show/Season 1/05 - Title.mkv" it would never
|
|
// match, which is how the bare-number case was silently missed.
|
|
season, episode, isEpisode := parseEpisode(path.Base(rel), path.Base(dir))
|
|
if !isEpisode {
|
|
g := groups[rel]
|
|
g.key, g.title, g.kind = rel, library.CleanTitle(rel), itemKind
|
|
g.files = append(g.files, c)
|
|
groups[rel] = g
|
|
continue
|
|
}
|
|
|
|
// A loose episode at the library root has no directory to hang a
|
|
// series on, so it stays top-level rather than growing a row named
|
|
// after the library.
|
|
if dir == "." {
|
|
g := groups[rel]
|
|
g.key, g.title, g.kind = rel, episodeTitle(path.Base(rel)), itemKind
|
|
g.season, g.episode = season, episode
|
|
g.files = append(g.files, c)
|
|
groups[rel] = g
|
|
continue
|
|
}
|
|
|
|
// A "Season N" directory is the season; anything else is the series,
|
|
// and its season gets a path of its own so two seasons of the same
|
|
// show do not collide on UNIQUE (library_id, path).
|
|
seriesKey, seasonKey := dir, path.Join(dir, "Season "+strconv.Itoa(season))
|
|
if seasonDirNumber(path.Base(dir)) > 0 {
|
|
seriesKey = path.Dir(dir)
|
|
seasonKey = dir
|
|
}
|
|
if seriesKey == "." {
|
|
seriesKey = "" // "Season 1/S01E01.mkv": a season with no show above it
|
|
}
|
|
|
|
if seriesKey != "" {
|
|
add(group{key: seriesKey, title: library.CleanTitle(path.Base(seriesKey)), kind: "series"})
|
|
}
|
|
add(group{
|
|
key: seasonKey, title: "Season " + strconv.Itoa(season), kind: "season",
|
|
parentKey: seriesKey, season: season,
|
|
})
|
|
|
|
g := groups[rel]
|
|
g.key, g.title, g.kind = rel, episodeTitle(path.Base(rel)), itemKind
|
|
g.parentKey, g.season, g.episode = seasonKey, season, episode
|
|
g.files = append(g.files, c)
|
|
groups[rel] = g
|
|
}
|
|
|
|
return groups
|
|
}
|
|
|
|
// walkRoot collects supported files under root, keyed by slash-separated
|
|
// path relative to root.
|
|
func walkRoot(root string, exts map[string]bool) (map[string]candidate, []string) {
|
|
out := make(map[string]candidate)
|
|
var errs []string
|
|
|
|
err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
errs = append(errs, fmt.Sprintf("%s: %v", p, err))
|
|
if d != nil && d.IsDir() {
|
|
return fs.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
|
|
name := d.Name()
|
|
if d.IsDir() {
|
|
if p != root && strings.HasPrefix(name, ".") {
|
|
return fs.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
if strings.HasPrefix(name, ".") {
|
|
return nil
|
|
}
|
|
|
|
ext := strings.ToLower(filepath.Ext(name))
|
|
if !exts[ext] {
|
|
return nil
|
|
}
|
|
|
|
info, err := d.Info()
|
|
if err != nil {
|
|
errs = append(errs, fmt.Sprintf("%s: %v", p, err))
|
|
return nil
|
|
}
|
|
if !info.Mode().IsRegular() {
|
|
return nil
|
|
}
|
|
|
|
rel, err := filepath.Rel(root, p)
|
|
if err != nil {
|
|
errs = append(errs, fmt.Sprintf("%s: %v", p, err))
|
|
return nil
|
|
}
|
|
|
|
c := candidate{
|
|
relPath: filepath.ToSlash(rel),
|
|
size: info.Size(),
|
|
mtime: info.ModTime().UnixNano(),
|
|
ext: strings.TrimPrefix(ext, "."),
|
|
}
|
|
if st, ok := info.Sys().(*syscall.Stat_t); ok {
|
|
c.inode = int64(st.Ino)
|
|
}
|
|
out[c.relPath] = c
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
errs = append(errs, fmt.Sprintf("%s: %v", root, err))
|
|
}
|
|
return out, errs
|
|
}
|
|
|
|
func loadKnownFiles(ctx context.Context, db *sql.DB, libraryID int64) (map[string]knownFile, error) {
|
|
rows, err := db.QueryContext(ctx, `
|
|
SELECT f.path, f.item_id, f.id, f.size, f.mtime, f.missing
|
|
FROM media_files f
|
|
JOIN media_items i ON i.id = f.item_id
|
|
WHERE i.library_id = ?`, libraryID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("library: load known files: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := make(map[string]knownFile)
|
|
for rows.Next() {
|
|
var (
|
|
absPath string
|
|
kf knownFile
|
|
mtime string
|
|
missing int
|
|
)
|
|
if err := rows.Scan(&absPath, &kf.itemID, &kf.fileID, &kf.size, &mtime, &missing); err != nil {
|
|
return nil, err
|
|
}
|
|
kf.mtime = parseUnixNano(mtime)
|
|
kf.missing = missing == 1
|
|
out[absPath] = kf
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func loadKnownItems(ctx context.Context, db *sql.DB, libraryID int64) (map[string]int64, error) {
|
|
rows, err := db.QueryContext(ctx,
|
|
`SELECT path, id FROM media_items WHERE library_id = ?`, libraryID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("library: load known items: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := make(map[string]int64)
|
|
for rows.Next() {
|
|
var p string
|
|
var id int64
|
|
if err := rows.Scan(&p, &id); err != nil {
|
|
return nil, err
|
|
}
|
|
out[p] = id
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// upsertGroup inserts or refreshes one group, resolving its parent first.
|
|
//
|
|
// A parent is upserted before its child so the parent_id chain is complete the
|
|
// moment the child exists, whatever order the batch happens to arrive in. Every
|
|
// upsert is ON CONFLICT (library_id, path), so the same series or season is
|
|
// refreshed rather than duplicated on a rescan — the unique index is what makes
|
|
// a second row impossible, not a lookup.
|
|
func upsertGroup(ctx context.Context, tx *sql.Tx, lib library.Library, g group, groups map[string]group, ids map[string]int64) (int64, error) {
|
|
if id, ok := ids[g.key]; ok {
|
|
return id, nil
|
|
}
|
|
|
|
var parentID int64
|
|
if g.parentKey != "" {
|
|
parent, ok := groups[g.parentKey]
|
|
if !ok {
|
|
return 0, fmt.Errorf("parent group %q not built", g.parentKey)
|
|
}
|
|
id, err := upsertGroup(ctx, tx, lib, parent, groups, ids)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
parentID = id
|
|
}
|
|
|
|
id, err := upsertItem(ctx, tx, lib, g, parentID)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
ids[g.key] = id
|
|
return id, nil
|
|
}
|
|
|
|
// upsertItem writes one media_items row and returns its id.
|
|
//
|
|
// season_number and episode_number are nullable by design (migration 0013), so
|
|
// a film keeps NULL for both and every existing query and view is unaffected —
|
|
// only an episode writes them. The kind is the group kind: 'video' for the
|
|
// episode itself, 'series' or 'season' for its parents.
|
|
func upsertItem(ctx context.Context, tx *sql.Tx, lib library.Library, g group, parentID int64) (int64, error) {
|
|
kind := g.kind
|
|
if kind == "" {
|
|
kind = lib.Kind
|
|
}
|
|
var season, episode any
|
|
if g.season > 0 {
|
|
season = g.season
|
|
}
|
|
if g.episode > 0 {
|
|
episode = g.episode
|
|
}
|
|
|
|
var id int64
|
|
err := tx.QueryRowContext(ctx, `
|
|
INSERT INTO media_items (library_id, kind, path, parent_id, title, sort_title,
|
|
season_number, episode_number, missing, missing_since)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, '')
|
|
ON CONFLICT (library_id, path)
|
|
DO UPDATE SET missing = 0, missing_since = ''
|
|
RETURNING id`,
|
|
lib.ID, kind, g.key, nullInt(parentID), g.title, strings.ToLower(g.title),
|
|
season, episode,
|
|
).Scan(&id)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("upsert item: %w", err)
|
|
}
|
|
return id, nil
|
|
}
|
|
|
|
// nullInt maps a zero id to SQL NULL, so a top-level item has no parent_id
|
|
// instead of pointing at row 0.
|
|
func nullInt(id int64) any {
|
|
if id == 0 {
|
|
return nil
|
|
}
|
|
return id
|
|
}
|
|
|
|
func upsertFile(ctx context.Context, tx *sql.Tx, itemID int64, c candidate, info probe.Info, probed bool, now string) error {
|
|
// A non-probed file (an ebook, a photo, or any file on a host without
|
|
// ffprobe) must keep whatever codec facts a previous scan recorded, so its
|
|
// upsert never mentions those columns at all.
|
|
if !probed {
|
|
_, err := tx.ExecContext(ctx, `
|
|
INSERT INTO media_files (item_id, path, size, mtime, inode, container, missing, missing_since)
|
|
VALUES (?, ?, ?, ?, ?, ?, 0, '')
|
|
ON CONFLICT (path)
|
|
DO UPDATE SET item_id = excluded.item_id, size = excluded.size,
|
|
mtime = excluded.mtime, inode = excluded.inode,
|
|
container = excluded.container, missing = 0,
|
|
missing_since = ''`,
|
|
itemID, c.relPath, c.size, fmt.Sprint(c.mtime), c.inode, c.ext)
|
|
if err != nil {
|
|
return fmt.Errorf("upsert file: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
_, err := tx.ExecContext(ctx, `
|
|
INSERT INTO media_files (item_id, path, size, mtime, inode, container, missing, missing_since,
|
|
duration_ms, vcodec, acodec, width, height, probed_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, 0, '', ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT (path)
|
|
DO UPDATE SET item_id = excluded.item_id, size = excluded.size,
|
|
mtime = excluded.mtime, inode = excluded.inode,
|
|
container = excluded.container, missing = 0,
|
|
missing_since = '',
|
|
duration_ms = excluded.duration_ms, vcodec = excluded.vcodec,
|
|
acodec = excluded.acodec, width = excluded.width,
|
|
height = excluded.height, probed_at = excluded.probed_at`,
|
|
itemID, c.relPath, c.size, fmt.Sprint(c.mtime), c.inode, c.ext,
|
|
info.DurationMS, info.VCodec, info.ACodec, info.Width, info.Height, now)
|
|
if err != nil {
|
|
return fmt.Errorf("upsert file: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// probeBatch probes every new or changed video/audio file in one batch and
|
|
// returns the facts keyed by relative path.
|
|
//
|
|
// Only files the transaction will treat as changed (a size+mtime mismatch, or a
|
|
// row that is absent or flagged missing) are probed; unchanged files are absent
|
|
// from the result, so no ffprobe runs and no codec column is rewritten for
|
|
// them. Non-media kinds (ebooks, comics, photos, games) are never probed: they
|
|
// hold no media streams, and running ffprobe over a library of them would be
|
|
// pure waste.
|
|
//
|
|
// It runs before commitBatch opens the write transaction, so the write lock is
|
|
// never held across an ffprobe. A probe failure is recorded through the bounded
|
|
// error mechanism and the file is written with empty codec fields rather than
|
|
// failing the scan.
|
|
func probeBatch(ctx context.Context, lib library.Library, groups map[string]group, batch []string, known map[string]knownFile, res *ScanResult) map[string]probe.Info {
|
|
if !probeable(lib.Kind) || !probe.Available() {
|
|
return nil
|
|
}
|
|
|
|
out := make(map[string]probe.Info)
|
|
for _, key := range batch {
|
|
for _, c := range groups[key].files {
|
|
if prev, ok := known[c.relPath]; ok && !prev.missing && prev.size == c.size && prev.mtime == c.mtime {
|
|
continue // unchanged: never re-probe
|
|
}
|
|
|
|
abs := filepath.Join(lib.RootPath, filepath.FromSlash(c.relPath))
|
|
info, err := probe.File(ctx, abs)
|
|
if err != nil {
|
|
res.addError(fmt.Sprintf("%s: %v", c.relPath, err))
|
|
// Record the attempt with empty fields; the codecs stay empty
|
|
// until the file changes again.
|
|
out[c.relPath] = probe.Info{}
|
|
continue
|
|
}
|
|
out[c.relPath] = info
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// probeable reports whether a library kind holds files worth probing for codec
|
|
// facts. Only video and the audio kinds have media streams; ebooks, comics,
|
|
// photos, and games are never probed.
|
|
func probeable(kind string) bool {
|
|
switch kind {
|
|
case "video", "audiobooks", "music":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// markItemMissing flags an item whose whole group has vanished. missing_since
|
|
// records when it first disappeared, and is written only on that transition: a
|
|
// rescan of an already-missing item must not refresh the age, or a file gone
|
|
// for months would look "just now missing" on every pass. An empty timestamp
|
|
// (a row that went missing before this column existed) is filled once, so the
|
|
// age is still real rather than permanently blank.
|
|
func markItemMissing(ctx context.Context, tx *sql.Tx, libraryID int64, key, now string) error {
|
|
_, err := tx.ExecContext(ctx, `
|
|
UPDATE media_items SET missing = 1, missing_since = ?
|
|
WHERE library_id = ? AND path = ? AND (missing = 0 OR missing_since = '')`,
|
|
now, libraryID, key)
|
|
return err
|
|
}
|
|
|
|
// markFileMissing flags a file that vanished while its item survived. It obeys
|
|
// the same transition rule as markItemMissing: the timestamp is set once and
|
|
// never rewritten by a later scan.
|
|
func markFileMissing(ctx context.Context, tx *sql.Tx, rel, now string) error {
|
|
_, err := tx.ExecContext(ctx, `
|
|
UPDATE media_files SET missing = 1, missing_since = ?
|
|
WHERE path = ? AND (missing = 0 OR missing_since = '')`,
|
|
now, rel)
|
|
return err
|
|
}
|
|
|
|
func sortedGroupKeys(groups map[string]group) []string {
|
|
keys := make([]string, 0, len(groups))
|
|
for k := range groups {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
return keys
|
|
}
|
|
|
|
func sortedCandidateKeys(found map[string]candidate) []string {
|
|
keys := make([]string, 0, len(found))
|
|
for k := range found {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
return keys
|
|
}
|
|
|
|
func parseUnixNano(s string) int64 {
|
|
var n int64
|
|
for i := 0; i < len(s); i++ {
|
|
if s[i] < '0' || s[i] > '9' {
|
|
return 0
|
|
}
|
|
n = n*10 + int64(s[i]-'0')
|
|
}
|
|
return n
|
|
}
|