Two halves of the same feature: the scanner finds tracks, the media layer serves them as WebVTT. Discovery walks each directory for .srt/.ass/.ssa/.vtt/.sub beside a video and matches them by base name, including the language form "Movie.en.srt" and "Movie.pt-BR.srt". A sidecar is never itself an item. Matching is a dot-guarded prefix on the real video base name, so a dotted release name like "The.Matrix.1999.1080p.en.srt" does not read the year as a language. Serving adds two routes inside the existing item family, both grant-checked through catalog.Store (ADR-005) so forbidden and absent are the same 404. Browsers play WebVTT and nothing else, so .srt is converted (header plus the comma-to-dot in timestamps), .vtt passes through, and embedded streams and .ass/.ssa go through ffmpeg. Conversions are cached under ConfigDir, because converting per request would spawn an ffmpeg on every seek. A track that fails to convert fails alone - the video and the other tracks are unaffected. Subtitle rows are a cache of what is on disk, so a vanished sidecar has its row deleted. That is the opposite of a vanished video, which is marked instead (ADR-003) because progress hangs off it and cannot be regenerated. Verified red-green: disabling the sidecar call fails all four scanner tests, and removing the VTT header fails the conversion test. Known limits: a "Movie.forced.en.srt" records "forced.en" rather than "en", and the conversion cache is keyed by track id, so a sidecar replaced under an unchanged row serves the stale conversion until a rescan recreates the row.
1036 lines
34 KiB
Go
1036 lines
34 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, sidecars, errs := walkRoot(lib.RootPath, exts)
|
|
for _, e := range errs {
|
|
res.addError(e)
|
|
}
|
|
|
|
groups := buildGroups(lib, found)
|
|
|
|
// Sidecar subtitle files, bucketed by the directory they sit in so a video
|
|
// can find its own without scanning every subtitle. seenSidecars records
|
|
// which ones a video actually claimed; anything else is deleted below,
|
|
// because the subtitles table is a cache of what is on disk.
|
|
sidecarsByDir := make(map[string][]candidate)
|
|
for _, s := range sidecars {
|
|
dir := path.Dir(s.relPath)
|
|
sidecarsByDir[dir] = append(sidecarsByDir[dir], s)
|
|
}
|
|
seenSidecars := make(map[string]bool)
|
|
|
|
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
|
|
|
|
// Sidecars are keyed to the video beside them, not to the
|
|
// media_files row, so this runs even for an unchanged video:
|
|
// a new subtitle beside a settled file must still be picked
|
|
// up, and its row must not be pruned as vanished.
|
|
upsertSidecars(ctx, tx, itemID, c, sidecarsByDir[path.Dir(c.relPath)], seenSidecars, &res)
|
|
|
|
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()
|
|
}
|
|
|
|
// Sidecar subtitle rows are a cache of what is on disk: a row whose file is
|
|
// gone is deleted, not flagged missing. This is scoped to the library
|
|
// through item_id, and runs every scan so a library that lost all its
|
|
// subtitles still ends up with none.
|
|
if err := reconcileSidecars(ctx, db, lib.ID, seenSidecars, &res); err != nil {
|
|
return res, err
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// sidecarExts are the subtitle file types a video may carry beside it. They are
|
|
// not library extensions: a subtitle never becomes an item, it is only ever a
|
|
// track hanging off the video it sits next to.
|
|
var sidecarExts = map[string]bool{
|
|
".srt": true, ".ass": true, ".ssa": true, ".vtt": true, ".sub": true,
|
|
}
|
|
|
|
// walkRoot collects supported files under root, keyed by slash-separated path
|
|
// relative to root. Subtitle sidecars are returned separately: they are
|
|
// discovered here, with everything else, but are not items.
|
|
func walkRoot(root string, exts map[string]bool) (map[string]candidate, map[string]candidate, []string) {
|
|
out := make(map[string]candidate)
|
|
subs := 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))
|
|
isMedia := exts[ext]
|
|
if !isMedia && !sidecarExts[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)
|
|
}
|
|
// A media extension wins: if a kind ever owns one of these, it is a
|
|
// media file first and a subtitle only by coincidence.
|
|
if isMedia {
|
|
out[c.relPath] = c
|
|
} else {
|
|
subs[c.relPath] = c
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
errs = append(errs, fmt.Sprintf("%s: %v", root, err))
|
|
}
|
|
return out, subs, 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
|
|
}
|
|
|
|
// upsertSidecars matches the subtitle files in one directory to the media file
|
|
// they sit beside, and writes a row for each. A sidecar belongs to a video when
|
|
// its name is the video's base name with an optional language segment between:
|
|
// "Movie.srt" and "Movie.en.srt" both belong to "Movie.mkv", the latter with
|
|
// language "en". The language is the whole middle segment, so "pt-BR" survives
|
|
// intact.
|
|
//
|
|
// The match is a prefix guarded by the dot, not a split, because a release name
|
|
// may itself contain dots: for "The.Matrix.1999.1080p.mkv" the sidecar is
|
|
// "The.Matrix.1999.1080p.en.srt" and only the trailing segment is a language.
|
|
func upsertSidecars(ctx context.Context, tx *sql.Tx, itemID int64, media candidate, sidecars []candidate, seen map[string]bool, res *ScanResult) {
|
|
if len(sidecars) == 0 {
|
|
return
|
|
}
|
|
base := strings.TrimSuffix(path.Base(media.relPath), path.Ext(media.relPath))
|
|
|
|
for _, s := range sidecars {
|
|
stem := strings.TrimSuffix(path.Base(s.relPath), path.Ext(s.relPath))
|
|
var lang string
|
|
switch {
|
|
case stem == base:
|
|
lang = ""
|
|
case strings.HasPrefix(stem, base+"."):
|
|
lang = stem[len(base)+1:]
|
|
default:
|
|
continue
|
|
}
|
|
seen[s.relPath] = true
|
|
if err := upsertSidecar(ctx, tx, itemID, s, lang); err != nil {
|
|
res.addError(fmt.Sprintf("%s: %v", s.relPath, err))
|
|
}
|
|
}
|
|
}
|
|
|
|
// upsertSidecar writes one sidecar track. The conflict target is the table's
|
|
// UNIQUE (item_id, source, path, stream_index), so a rescan refreshes the row
|
|
// instead of adding a second one. stream_index is -1, the file-backed value.
|
|
func upsertSidecar(ctx context.Context, tx *sql.Tx, itemID int64, s candidate, lang string) error {
|
|
_, err := tx.ExecContext(ctx, `
|
|
INSERT INTO subtitles (item_id, source, path, stream_index, format, language, label)
|
|
VALUES (?, 'sidecar', ?, -1, ?, ?, ?)
|
|
ON CONFLICT (item_id, source, path, stream_index)
|
|
DO UPDATE SET format = excluded.format, language = excluded.language,
|
|
label = excluded.label`,
|
|
itemID, s.relPath, s.ext, lang, path.Base(s.relPath))
|
|
if err != nil {
|
|
return fmt.Errorf("upsert sidecar: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// reconcileSidecars deletes sidecar rows whose file is no longer on disk. It
|
|
// reads the library's existing rows and removes only the ones no video claimed
|
|
// this pass, so the subtitles table stays exactly a cache of what is beside the
|
|
// media. It never touches embedded or downloaded tracks.
|
|
func reconcileSidecars(ctx context.Context, db *sql.DB, libraryID int64, seen map[string]bool, res *ScanResult) error {
|
|
rows, err := db.QueryContext(ctx, `
|
|
SELECT s.path FROM subtitles s
|
|
JOIN media_items i ON i.id = s.item_id
|
|
WHERE i.library_id = ? AND s.source = 'sidecar'`, libraryID)
|
|
if err != nil {
|
|
return fmt.Errorf("load sidecars: %w", err)
|
|
}
|
|
var stale []string
|
|
for rows.Next() {
|
|
var p string
|
|
if err := rows.Scan(&p); err != nil {
|
|
rows.Close()
|
|
return err
|
|
}
|
|
if !seen[p] {
|
|
stale = append(stale, p)
|
|
}
|
|
}
|
|
if err := rows.Close(); err != nil {
|
|
return err
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return err
|
|
}
|
|
if len(stale) == 0 {
|
|
return nil
|
|
}
|
|
sort.Strings(stale)
|
|
|
|
for start := 0; start < len(stale); start += scanBatchSize {
|
|
end := start + scanBatchSize
|
|
if end > len(stale) {
|
|
end = len(stale)
|
|
}
|
|
batch := stale[start:end]
|
|
err := commitBatch(ctx, db, func(tx *sql.Tx) error {
|
|
for _, p := range batch {
|
|
if _, err := tx.ExecContext(ctx, `
|
|
DELETE FROM subtitles
|
|
WHERE source = 'sidecar' AND path = ?
|
|
AND item_id IN (SELECT id FROM media_items WHERE library_id = ?)`,
|
|
p, libraryID); err != nil {
|
|
res.addError(fmt.Sprintf("%s: %v", p, err))
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return 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
|
|
}
|