diff --git a/internal/scanner/scan.go b/internal/scanner/scan.go index 3dfaa29..a5a7e62 100644 --- a/internal/scanner/scan.go +++ b/internal/scanner/scan.go @@ -18,7 +18,9 @@ import ( "log/slog" "path" "path/filepath" + "regexp" "sort" + "strconv" "strings" "syscall" "time" @@ -85,10 +87,18 @@ type knownFile struct { } // 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 - files []candidate + 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. @@ -134,6 +144,12 @@ func Scan(ctx context.Context, db *sql.DB, lib library.Library, log *slog.Logger } 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) { @@ -156,7 +172,7 @@ func Scan(ctx context.Context, db *sql.DB, lib library.Library, log *slog.Logger for _, key := range batch { 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 { res.addError(fmt.Sprintf("%s: %v", g.key, err)) 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, // 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 { + if lib.Kind == "video" { + return buildTVGroups(lib, found) + } + mode := library.GroupModeFor(lib.Kind) 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.key, g.title = key, title + g.key, g.title, g.kind = key, title, lib.Kind g.files = append(g.files, c) groups[key] = g } @@ -325,6 +346,189 @@ func buildGroups(lib library.Library, found map[string]candidate) map[string]gro 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 // path relative to root. 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() } -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 err := tx.QueryRowContext(ctx, ` - INSERT INTO media_items (library_id, kind, path, title, sort_title, missing, missing_since) - VALUES (?, ?, ?, ?, ?, 0, '') + 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, lib.Kind, key, title, strings.ToLower(title), + 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) @@ -454,6 +711,15 @@ func upsertItem(ctx context.Context, tx *sql.Tx, lib library.Library, key, title 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 diff --git a/internal/scanner/scan_test.go b/internal/scanner/scan_test.go index a71afe9..bdbeeae 100644 --- a/internal/scanner/scan_test.go +++ b/internal/scanner/scan_test.go @@ -428,3 +428,344 @@ func TestPurgeMissingOnlyDeletesMissingItems(t *testing.T) { 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) + } + } +}