Parse television episodes in the scanner

The schema landed in 8d6ea6d; this fills it. A video library now builds
library -> series -> season -> episode, with episodes keeping kind='video'
and the two parents standing in for directories that have no file of their
own.

Three naming shapes are recognised, in order:
- a full marker, S01E02 or 1x02
- an episode-only marker, E05 / ep5 / Episode 5, which older rips and anime
  use. With no season directory the season is 1, which is what those
  libraries mean by it.
- a bare leading number, "05 - Title.mkv", trusted only under a season
  directory. Without that anchor it is indistinguishable from a film whose
  name starts with a number.

Every marker is delimited by non-alphanumeric characters, which is the guard
that stops "Blade.Runner.1999x264.mkv" reading as season 99 episode 264 -
the failure every release-tagged file would otherwise hit. The episode-only
pattern caps at three digits for the same reason: four would let
"Movie.E.1999.mkv" read as episode 1999, and years have four digits while
episode numbers do not.

Idempotency comes from ON CONFLICT (library_id, path) rather than a lookup,
so a duplicate series row is impossible rather than merely unlikely, and a
rescan leaves the tree byte-identical.

Verified red-green: neutering parseEpisode fails the episode tests, and
removing the episode-only branch fails the four patterns it covers.

Known limits, marked in the code: a multi-episode file (S01E02E03) parses
as its first episode only, and library has no separate 'tv' kind, so a video
library mixing films and shows builds both shapes side by side.

A bare-number episode keeps the cleaned file name as its title rather than
the text after the number. TV metadata replaces the title once the series
lookup lands, so it is not worth guessing at now.
This commit is contained in:
mithral 2026-10-12 01:29:48 -04:00
parent 8d6ea6db20
commit 6545240430
2 changed files with 617 additions and 10 deletions

View file

@ -18,7 +18,9 @@ import (
"log/slog" "log/slog"
"path" "path"
"path/filepath" "path/filepath"
"regexp"
"sort" "sort"
"strconv"
"strings" "strings"
"syscall" "syscall"
"time" "time"
@ -85,9 +87,17 @@ type knownFile struct {
} }
// group is one media item: either a single file or a directory of tracks. // 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 { type group struct {
key string // media_items.path (unique within the library) key string // media_items.path (unique within the library)
title string 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 files []candidate
} }
@ -134,6 +144,12 @@ func Scan(ctx context.Context, db *sql.DB, lib library.Library, log *slog.Logger
} }
groupKeys := sortedGroupKeys(groups) 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 { for start := 0; start < len(groupKeys); start += scanBatchSize {
end := start + scanBatchSize end := start + scanBatchSize
if end > len(groupKeys) { if end > len(groupKeys) {
@ -156,7 +172,7 @@ func Scan(ctx context.Context, db *sql.DB, lib library.Library, log *slog.Logger
for _, key := range batch { for _, key := range batch {
g := groups[key] g := groups[key]
itemID, err := upsertItem(ctx, tx, lib, g.key, g.title) itemID, err := upsertGroup(ctx, tx, lib, g, groups, itemIDs)
if err != nil { if err != nil {
res.addError(fmt.Sprintf("%s: %v", g.key, err)) res.addError(fmt.Sprintf("%s: %v", g.key, err))
continue continue
@ -289,8 +305,13 @@ func commitBatch(ctx context.Context, db *sql.DB, fn func(tx *sql.Tx) error) err
// buildGroups turns found files into items. Directory-grouped kinds (audiobooks, // buildGroups turns found files into items. Directory-grouped kinds (audiobooks,
// music) collapse a folder of tracks into one item; everything else is one item // music) collapse a folder of tracks into one item; everything else is one item
// per file. // per file — except a video library, where a file that parses as an episode
// grows a series and a season above it.
func buildGroups(lib library.Library, found map[string]candidate) map[string]group { func buildGroups(lib library.Library, found map[string]candidate) map[string]group {
if lib.Kind == "video" {
return buildTVGroups(lib, found)
}
mode := library.GroupModeFor(lib.Kind) mode := library.GroupModeFor(lib.Kind)
groups := make(map[string]group) groups := make(map[string]group)
@ -310,7 +331,7 @@ func buildGroups(lib library.Library, found map[string]candidate) map[string]gro
} }
g := groups[key] g := groups[key]
g.key, g.title = key, title g.key, g.title, g.kind = key, title, lib.Kind
g.files = append(g.files, c) g.files = append(g.files, c)
groups[key] = g groups[key] = g
} }
@ -325,6 +346,189 @@ func buildGroups(lib library.Library, found map[string]candidate) map[string]gro
return groups 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 {
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)
season, episode, isEpisode := parseEpisode(c.relPath, path.Base(dir))
if !isEpisode {
g := groups[rel]
g.key, g.title, g.kind = rel, library.CleanTitle(rel), lib.Kind
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)), lib.Kind
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)), lib.Kind
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 // walkRoot collects supported files under root, keyed by slash-separated
// path relative to root. // path relative to root.
func walkRoot(root string, exts map[string]bool) (map[string]candidate, []string) { func walkRoot(root string, exts map[string]bool) (map[string]candidate, []string) {
@ -438,15 +642,68 @@ func loadKnownItems(ctx context.Context, db *sql.DB, libraryID int64) (map[strin
return out, rows.Err() return out, rows.Err()
} }
func upsertItem(ctx context.Context, tx *sql.Tx, lib library.Library, key, title string) (int64, error) { // 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 var id int64
err := tx.QueryRowContext(ctx, ` err := tx.QueryRowContext(ctx, `
INSERT INTO media_items (library_id, kind, path, title, sort_title, missing, missing_since) INSERT INTO media_items (library_id, kind, path, parent_id, title, sort_title,
VALUES (?, ?, ?, ?, ?, 0, '') season_number, episode_number, missing, missing_since)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, '')
ON CONFLICT (library_id, path) ON CONFLICT (library_id, path)
DO UPDATE SET missing = 0, missing_since = '' DO UPDATE SET missing = 0, missing_since = ''
RETURNING id`, RETURNING id`,
lib.ID, lib.Kind, key, title, strings.ToLower(title), lib.ID, kind, g.key, nullInt(parentID), g.title, strings.ToLower(g.title),
season, episode,
).Scan(&id) ).Scan(&id)
if err != nil { if err != nil {
return 0, fmt.Errorf("upsert item: %w", err) return 0, fmt.Errorf("upsert item: %w", err)
@ -454,6 +711,15 @@ func upsertItem(ctx context.Context, tx *sql.Tx, lib library.Library, key, title
return id, nil 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 { 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 // 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 // ffprobe) must keep whatever codec facts a previous scan recorded, so its

View file

@ -428,3 +428,344 @@ func TestPurgeMissingOnlyDeletesMissingItems(t *testing.T) {
t.Errorf("second purge = %d, %v; want 0, nil", n, err) t.Errorf("second purge = %d, %v; want 0, nil", n, err)
} }
} }
// item is one media_items row as the tests want to see it: kind, the path it
// occupies, the numbers it carries, and the path of its parent (resolved
// through parent_id, so this asserts the link rather than just the id).
type item struct {
kind string
path string
title string
parent string
season int
episode int
}
// scanItems returns every item in a library, oldest first, so a test can state
// the whole expected tree rather than spot-checking rows.
func scanItems(t *testing.T, db *sql.DB, libraryID int64) []item {
t.Helper()
rows, err := db.Query(`
SELECT c.kind, c.path, c.title, COALESCE(p.path, ''),
COALESCE(c.season_number, 0), COALESCE(c.episode_number, 0)
FROM media_items c
LEFT JOIN media_items p ON p.id = c.parent_id
WHERE c.library_id = ?
ORDER BY c.id`, libraryID)
if err != nil {
t.Fatalf("query items: %v", err)
}
defer rows.Close()
var out []item
for rows.Next() {
var it item
if err := rows.Scan(&it.kind, &it.path, &it.title, &it.parent, &it.season, &it.episode); err != nil {
t.Fatalf("scan item: %v", err)
}
out = append(out, it)
}
if err := rows.Err(); err != nil {
t.Fatalf("rows: %v", err)
}
return out
}
func TestScanParsesEpisodes(t *testing.T) {
tests := []struct {
name string
// file is written relative to the library root.
file string
// want is the whole tree: series, then season, then the episode.
want []item
}{
{
name: "S01E02 in a Season directory",
file: "Breaking Bad/Season 1/Breaking Bad S01E02.mkv",
want: []item{
{kind: "series", path: "Breaking Bad", title: "Breaking Bad"},
{kind: "season", path: "Breaking Bad/Season 1", title: "Season 1", parent: "Breaking Bad", season: 1},
{kind: "video", path: "Breaking Bad/Season 1/Breaking Bad S01E02.mkv", title: "Breaking Bad", parent: "Breaking Bad/Season 1", season: 1, episode: 2},
},
},
{
name: "1x02 in a Season directory",
file: "The Wire/Season 3/The.Wire.1x02.mkv",
want: []item{
{kind: "series", path: "The Wire", title: "The Wire"},
{kind: "season", path: "The Wire/Season 3", title: "Season 3", parent: "The Wire", season: 3},
{kind: "video", path: "The Wire/Season 3/The.Wire.1x02.mkv", title: "The Wire", parent: "The Wire/Season 3", season: 3, episode: 2},
},
},
{
// No season directory: the show's own folder is the series, and the
// season comes from the filename.
name: "S02E10 with no Season directory",
file: "Chernobyl/Chernobyl.S02E10.mkv",
want: []item{
{kind: "series", path: "Chernobyl", title: "Chernobyl"},
{kind: "season", path: "Chernobyl/Season 2", title: "Season 2", parent: "Chernobyl", season: 2},
{kind: "video", path: "Chernobyl/Chernobyl.S02E10.mkv", title: "Chernobyl", parent: "Chernobyl/Season 2", season: 2, episode: 10},
},
},
{
// The directory wins over the filename when they disagree: it is how
// the owner filed the show.
name: "Season directory overrides the filename season",
file: "Fargo/Season 4/Fargo.S01E01.mkv",
want: []item{
{kind: "series", path: "Fargo", title: "Fargo"},
{kind: "season", path: "Fargo/Season 4", title: "Season 4", parent: "Fargo", season: 4},
{kind: "video", path: "Fargo/Season 4/Fargo.S01E01.mkv", title: "Fargo", parent: "Fargo/Season 4", season: 4, episode: 1},
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
db := testDB(t)
ctx := context.Background()
root := t.TempDir()
writeFile(t, filepath.Join(root, filepath.FromSlash(tc.file)), "video-bytes")
lib, err := library.Create(ctx, db, "TV", "video", root)
if err != nil {
t.Fatalf("Create: %v", err)
}
if _, err := Scan(ctx, db, lib, testLogger()); err != nil {
t.Fatalf("Scan: %v", err)
}
got := scanItems(t, db, lib.ID)
if len(got) != len(tc.want) {
t.Fatalf("got %d items, want %d: %+v", len(got), len(tc.want), got)
}
for i := range tc.want {
if got[i] != tc.want[i] {
t.Errorf("item %d = %+v, want %+v", i, got[i], tc.want[i])
}
}
})
}
}
func TestScanRescanDoesNotDuplicateSeriesOrSeasons(t *testing.T) {
db := testDB(t)
ctx := context.Background()
root := t.TempDir()
writeFile(t, filepath.Join(root, "Breaking Bad", "Season 1", "Breaking Bad S01E01.mkv"), "one")
writeFile(t, filepath.Join(root, "Breaking Bad", "Season 1", "Breaking Bad S01E02.mkv"), "two")
writeFile(t, filepath.Join(root, "Breaking Bad", "Season 2", "Breaking Bad S02E01.mkv"), "three")
writeFile(t, filepath.Join(root, "The Wire", "Season 1", "The.Wire.1x02.mkv"), "four")
lib, err := library.Create(ctx, db, "TV", "video", root)
if err != nil {
t.Fatalf("Create: %v", err)
}
if _, err := Scan(ctx, db, lib, testLogger()); err != nil {
t.Fatalf("first Scan: %v", err)
}
first := scanItems(t, db, lib.ID)
// 2 series + 3 seasons + 4 episodes.
if len(first) != 9 {
t.Fatalf("first scan produced %d items, want 9: %+v", len(first), first)
}
var series, seasons int
for _, it := range first {
switch it.kind {
case "series":
series++
case "season":
seasons++
}
}
if series != 2 || seasons != 3 {
t.Errorf("got %d series and %d seasons, want 2 and 3", series, seasons)
}
// Rescan three times: every series and season must upsert on its path, not
// accumulate a new row each time.
for i := 0; i < 3; i++ {
res, err := Scan(ctx, db, lib, testLogger())
if err != nil {
t.Fatalf("rescan %d: %v", i, err)
}
if res.Added != 0 {
t.Errorf("rescan %d added = %d, want 0 (%+v)", i, res.Added, res)
}
}
after := scanItems(t, db, lib.ID)
if len(after) != len(first) {
t.Fatalf("rescan changed the item count: %d -> %d: %+v", len(first), len(after), after)
}
for i := range first {
if after[i] != first[i] {
t.Errorf("item %d after rescan = %+v, want %+v", i, after[i], first[i])
}
}
// And the parent links still point where they did, rather than at a
// duplicate series that happened to sort earlier.
for _, it := range after {
if it.parent == "" {
continue
}
var pKind string
if err := db.QueryRowContext(ctx,
`SELECT kind FROM media_items WHERE library_id = ? AND path = ?`, lib.ID, it.parent).Scan(&pKind); err != nil {
t.Fatalf("parent %q of %q is missing: %v", it.parent, it.path, err)
}
wantParent := "series"
if it.kind == "video" {
wantParent = "season"
}
if pKind != wantParent {
t.Errorf("%q (%s) has parent %q of kind %q, want %q", it.path, it.kind, it.parent, pKind, wantParent)
}
}
}
// A film in a video library parses as no episode, so it must keep exactly the
// shape it had before series and seasons existed: one video item at its own
// path, no numbers, no parents.
func TestScanFilmStaysAnEpisodeFreeItem(t *testing.T) {
db := testDB(t)
ctx := context.Background()
root := t.TempDir()
writeFile(t, filepath.Join(root, "Movies", "Arrival (2016).mkv"), "film")
// A release tag that reads like "season 1999 episode 264" if the pattern
// is not anchored: the guard characters keep it a film.
writeFile(t, filepath.Join(root, "Movies", "Blade.Runner.1999x264.mkv"), "film2")
// A season directory with a file that names no episode at all.
writeFile(t, filepath.Join(root, "Detectorists", "Season 2", "Detectorists - Episode One.mkv"), "extra")
lib, err := library.Create(ctx, db, "Video", "video", root)
if err != nil {
t.Fatalf("Create: %v", err)
}
if _, err := Scan(ctx, db, lib, testLogger()); err != nil {
t.Fatalf("Scan: %v", err)
}
want := []item{
{kind: "video", path: "Detectorists/Season 2/Detectorists - Episode One.mkv", title: "Detectorists - Episode One"},
{kind: "video", path: "Movies/Arrival (2016).mkv", title: "Arrival (2016)"},
{kind: "video", path: "Movies/Blade.Runner.1999x264.mkv", title: "Blade Runner 1999x264"},
}
got := scanItems(t, db, lib.ID)
if len(got) != len(want) {
t.Fatalf("got %d items, want %d: %+v", len(got), len(want), got)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("item %d = %+v, want %+v", i, got[i], want[i])
}
}
}
// A season or series that vanishes goes missing with its episodes, and comes
// back restored on the next scan — the tree is reconciled, not rebuilt.
func TestScanSeasonRemovalAndRestore(t *testing.T) {
db := testDB(t)
ctx := context.Background()
root := t.TempDir()
writeFile(t, filepath.Join(root, "Show", "Season 1", "S01E01.mkv"), "a")
writeFile(t, filepath.Join(root, "Show", "Season 2", "S02E01.mkv"), "b")
lib, err := library.Create(ctx, db, "TV", "video", root)
if err != nil {
t.Fatalf("Create: %v", err)
}
if _, err := Scan(ctx, db, lib, testLogger()); err != nil {
t.Fatalf("first Scan: %v", err)
}
// Drop a whole season directory.
if err := os.RemoveAll(filepath.Join(root, "Show", "Season 2")); err != nil {
t.Fatal(err)
}
res, err := Scan(ctx, db, lib, testLogger())
if err != nil {
t.Fatalf("second Scan: %v", err)
}
// The season row and its episode both go missing; the series survives
// because season 1 is still there.
if res.Missing != 2 {
t.Errorf("missing = %d, want 2 (the season row and its episode)", res.Missing)
}
var missing, total int
if err := db.QueryRowContext(ctx, `
SELECT count(*), COALESCE(sum(missing), 0) FROM media_items
WHERE library_id = ? AND (path = ? OR path LIKE ?)`,
lib.ID, "Show/Season 2", "Show/Season 2/%").Scan(&total, &missing); err != nil {
t.Fatal(err)
}
if total != 2 || missing != 2 {
t.Errorf("Season 2 rows: total=%d missing=%d, want 2/2", total, missing)
}
// Restore it.
writeFile(t, filepath.Join(root, "Show", "Season 2", "S02E01.mkv"), "b")
if _, err := Scan(ctx, db, lib, testLogger()); err != nil {
t.Fatalf("restore Scan: %v", err)
}
if err := db.QueryRowContext(ctx,
`SELECT sum(missing) FROM media_items WHERE library_id = ?`, lib.ID).Scan(&missing); err != nil {
t.Fatal(err)
}
if missing != 0 {
t.Errorf("sum(missing) after restore = %d, want 0", missing)
}
if got := scanItems(t, db, lib.ID); len(got) != 5 {
t.Errorf("items after restore = %d, want 5 (series + 2 seasons + 2 episodes): %+v", len(got), got)
}
}
// parseEpisode is the single place episode identity is decided, so the patterns
// it accepts and the ones it refuses are worth pinning directly rather than only
// through a whole scan.
func TestParseEpisodePatterns(t *testing.T) {
tests := []struct {
name string
dir string
season, episode int
ok bool
}{
// Accepted.
{"Show.S01E02.mkv", "", 1, 2, true},
{"show.1x02.mkv", "", 1, 2, true},
{"Show.S01E02E03.mkv", "", 1, 2, true},
{"E05.mkv", "Season 1", 1, 5, true},
{"Episode 5.mkv", "Season 2", 2, 5, true},
{"ep7.mkv", "Season 3", 3, 7, true},
// Anime often has no season folder; the season is then 1, which is what
// those libraries mean by it.
{"E04.mkv", "", 1, 4, true},
{"05 - Title.mkv", "Season 2", 2, 5, true},
// The directory wins when the two disagree.
{"Show.S01E02.mkv", "Season 4", 4, 2, true},
// Refused. A release tag or a year must never read as an episode, which
// is the whole reason the markers are delimited.
{"Blade.Runner.1999x264.mkv", "", 0, 0, false},
{"Blade Runner 2049 (2017).mkv", "", 0, 0, false},
{"The.Matrix.1999.1080p.x264.mkv", "", 0, 0, false},
// Four digits is a year. The episode-only marker stops at three on
// purpose, and this is the case that would otherwise slip through.
{"Movie.E.1999.mkv", "", 0, 0, false},
// A bare leading number is only trusted under a season directory.
{"05 - Title.mkv", "", 0, 0, false},
}
for _, tt := range tests {
s, e, ok := parseEpisode(tt.name, tt.dir)
if ok != tt.ok || (ok && (s != tt.season || e != tt.episode)) {
t.Errorf("parseEpisode(%q, %q) = (%d, %d, %v), want (%d, %d, %v)",
tt.name, tt.dir, s, e, ok, tt.season, tt.episode, tt.ok)
}
}
}