Vellum/internal/scanner/scan_test.go
mithral 8b41c43937 Fix the bare-number episode pattern, which never matched in production
parseEpisode was handed the file's whole relative path, but the bare-number
pattern is anchored at the start of a name. Handed "Show/Season 3/06 -
Title.mkv" it could never match, so every file named that way was filed as a
standalone film at the library root instead of an episode.

Found by running a real scan against a real tree, not by reading the code:
the unit test on parseEpisode passed a bare file name, which is exactly the
input the call site was not providing. The unit test was right and the
caller was wrong, which is a class of bug a helper test cannot see.

parseEpisode now gets path.Base(rel), matching episodeTitle two lines below
it, so the function really is a function of a file name and its directory as
its signature and doc already claimed.

Both scan-level cases are added to the tree test, so the call site is now
covered and not just the helper. Verified red-green: reverting the call site
fails "bare leading number under a Season directory".
2026-10-12 01:41:05 -04:00

795 lines
26 KiB
Go

package scanner
import (
"context"
"database/sql"
"io"
"log/slog"
"os"
"path/filepath"
"testing"
"github.com/mithral/vellum/internal/core"
"github.com/mithral/vellum/internal/library"
"github.com/mithral/vellum/migrations"
)
func testDB(t *testing.T) *sql.DB {
t.Helper()
db, err := core.OpenDB(filepath.Join(t.TempDir(), "scan.db"))
if err != nil {
t.Fatalf("OpenDB: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
log := slog.New(slog.NewTextHandler(io.Discard, nil))
if err := core.Migrate(context.Background(), db, migrations.FS, log); err != nil {
t.Fatalf("Migrate: %v", err)
}
return db
}
func testLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
func writeFile(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func TestScanLifecycle(t *testing.T) {
db := testDB(t)
ctx := context.Background()
root := t.TempDir()
writeFile(t, filepath.Join(root, "a.epub"), "aaaaaaaaaa")
writeFile(t, filepath.Join(root, "b.epub"), "bbbbbbbbbbbbbbbbbbbb")
writeFile(t, filepath.Join(root, "sub", "c.epub"), "cccccccccccccccccccccccccccccc")
writeFile(t, filepath.Join(root, "notes.txt"), "not media")
writeFile(t, filepath.Join(root, ".hidden.epub"), "hidden")
writeFile(t, filepath.Join(root, ".git", "d.epub"), "in a hidden dir")
lib, err := library.Create(ctx, db, "Ebooks", "ebooks", root)
if err != nil {
t.Fatalf("Create: %v", err)
}
// First scan: three supported files, nothing else.
res, err := Scan(ctx, db, lib, testLogger())
if err != nil {
t.Fatalf("Scan: %v", err)
}
if res.Added != 3 || res.Scanned != 3 {
t.Fatalf("first scan added=%d scanned=%d, want 3/3 (%+v)", res.Added, res.Scanned, res)
}
if n, _ := library.CountItems(ctx, db, lib.ID); n != 3 {
t.Errorf("item count = %d, want 3", n)
}
// Second scan: nothing changed.
res, err = Scan(ctx, db, lib, testLogger())
if err != nil {
t.Fatal(err)
}
if res.Unchanged != 3 || res.Added != 0 || res.Updated != 0 {
t.Errorf("second scan unchanged=%d added=%d updated=%d, want 3/0/0", res.Unchanged, res.Added, res.Updated)
}
// Modify a.epub (different size).
writeFile(t, filepath.Join(root, "a.epub"), "aaaaaaaaaaaaaaaaaaaaaaaaaaaa")
res, err = Scan(ctx, db, lib, testLogger())
if err != nil {
t.Fatal(err)
}
if res.Updated != 1 || res.Unchanged != 2 {
t.Errorf("after modify updated=%d unchanged=%d, want 1/2", res.Updated, res.Unchanged)
}
// Delete b.epub: it goes missing, it is not deleted.
if err := os.Remove(filepath.Join(root, "b.epub")); err != nil {
t.Fatal(err)
}
res, err = Scan(ctx, db, lib, testLogger())
if err != nil {
t.Fatal(err)
}
if res.Missing != 1 {
t.Errorf("after delete missing=%d, want 1", res.Missing)
}
if n, _ := library.CountItems(ctx, db, lib.ID); n != 2 {
t.Errorf("item count (excluding missing) = %d, want 2", n)
}
// The row still exists, flagged missing.
var missing int
if err := db.QueryRowContext(ctx,
`SELECT missing FROM media_items WHERE library_id = ? AND path = ?`, lib.ID, "b.epub").Scan(&missing); err != nil {
t.Fatalf("missing item row was deleted: %v", err)
}
if missing != 1 {
t.Errorf("missing flag = %d, want 1", missing)
}
// Restore b.epub.
writeFile(t, filepath.Join(root, "b.epub"), "bbbbbbbbbbbbbbbbbbbb")
res, err = Scan(ctx, db, lib, testLogger())
if err != nil {
t.Fatal(err)
}
if res.Restored != 1 {
t.Errorf("after restore restored=%d, want 1 (%+v)", res.Restored, res)
}
if n, _ := library.CountItems(ctx, db, lib.ID); n != 3 {
t.Errorf("item count after restore = %d, want 3", n)
}
}
func TestScanGroupsDirectoryKinds(t *testing.T) {
db := testDB(t)
ctx := context.Background()
root := t.TempDir()
// An audiobook is a folder of chapters.
writeFile(t, filepath.Join(root, "Author", "Book", "01 - Chapter One.mp3"), "a")
writeFile(t, filepath.Join(root, "Author", "Book", "02 - Chapter Two.mp3"), "b")
writeFile(t, filepath.Join(root, "Author", "Book", "10 - Chapter Ten.mp3"), "c")
// A loose file at the root groups under the library name.
writeFile(t, filepath.Join(root, "stray.mp3"), "d")
lib, err := library.Create(ctx, db, "Audiobooks", "audiobooks", root)
if err != nil {
t.Fatalf("Create: %v", err)
}
res, err := Scan(ctx, db, lib, testLogger())
if err != nil {
t.Fatalf("Scan: %v", err)
}
if res.Added != 4 {
t.Errorf("added = %d, want 4 (four tracks)", res.Added)
}
// Two items: the directory, and the root group.
var items int
if err := db.QueryRowContext(ctx,
`SELECT count(*) FROM media_items WHERE library_id = ?`, lib.ID).Scan(&items); err != nil {
t.Fatal(err)
}
if items != 2 {
t.Fatalf("items = %d, want 2", items)
}
var title string
if err := db.QueryRowContext(ctx,
`SELECT title FROM media_items WHERE library_id = ? AND path = ?`, lib.ID, "Author/Book").Scan(&title); err != nil {
t.Fatalf("grouped item not found: %v", err)
}
if title != "Book" {
t.Errorf("grouped title = %q, want Book", title)
}
// Tracks must come back in natural order, not lexical.
rows, err := db.QueryContext(ctx, `
SELECT f.path FROM media_files f JOIN media_items i ON i.id = f.item_id
WHERE i.path = ? ORDER BY f.id`, "Author/Book")
if err != nil {
t.Fatal(err)
}
defer rows.Close()
var paths []string
for rows.Next() {
var p string
if err := rows.Scan(&p); err != nil {
t.Fatal(err)
}
paths = append(paths, p)
}
want := []string{
"Author/Book/01 - Chapter One.mp3",
"Author/Book/02 - Chapter Two.mp3",
"Author/Book/10 - Chapter Ten.mp3",
}
if len(paths) != len(want) {
t.Fatalf("track count = %d, want %d: %v", len(paths), len(want), paths)
}
for i := range want {
if paths[i] != want[i] {
t.Errorf("track %d = %q, want %q", i, paths[i], want[i])
}
}
}
func TestScanUnreadableRoot(t *testing.T) {
db := testDB(t)
ctx := context.Background()
root := t.TempDir()
writeFile(t, filepath.Join(root, "a.epub"), "x")
lib, err := library.Create(ctx, db, "Ebooks", "ebooks", root)
if err != nil {
t.Fatal(err)
}
// Index it once so there is a known file to go missing.
if _, err := Scan(ctx, db, lib, testLogger()); err != nil {
t.Fatal(err)
}
// Remove the root out from under the library.
if err := os.RemoveAll(root); err != nil {
t.Fatal(err)
}
res, err := Scan(ctx, db, lib, testLogger())
if err != nil {
t.Fatalf("scan should not fail on a missing root: %v", err)
}
if res.Added != 0 {
t.Errorf("added=%d, want 0", res.Added)
}
if res.Missing != 1 {
t.Errorf("missing=%d, want 1 (existing item now vanished)", res.Missing)
}
if len(res.Errors) == 0 {
t.Error("expected the walk error to be recorded")
}
}
func TestMissingSinceLifecycle(t *testing.T) {
db := testDB(t)
ctx := context.Background()
root := t.TempDir()
writeFile(t, filepath.Join(root, "a.epub"), "aaaaaaaaaa")
writeFile(t, filepath.Join(root, "b.epub"), "bbbbbbbbbbbbbbbbbbbb")
lib, err := library.Create(ctx, db, "Ebooks", "ebooks", root)
if err != nil {
t.Fatalf("Create: %v", err)
}
if _, err := Scan(ctx, db, lib, testLogger()); err != nil {
t.Fatalf("first Scan: %v", err)
}
// A present file has no missing timestamp.
var since string
if err := db.QueryRowContext(ctx,
`SELECT missing_since FROM media_files WHERE path = ?`, "b.epub").Scan(&since); err != nil {
t.Fatal(err)
}
if since != "" {
t.Errorf("present file missing_since = %q, want empty", since)
}
// Delete b.epub: it goes missing and the timestamp is recorded.
if err := os.Remove(filepath.Join(root, "b.epub")); err != nil {
t.Fatal(err)
}
if _, err := Scan(ctx, db, lib, testLogger()); err != nil {
t.Fatalf("missing Scan: %v", err)
}
if err := db.QueryRowContext(ctx,
`SELECT missing_since FROM media_files WHERE path = ?`, "b.epub").Scan(&since); err != nil {
t.Fatal(err)
}
if since == "" {
t.Fatal("missing_since was not set on the transition into missing")
}
first := since
// A rescan must not refresh the age.
if _, err := Scan(ctx, db, lib, testLogger()); err != nil {
t.Fatalf("rescan: %v", err)
}
if err := db.QueryRowContext(ctx,
`SELECT missing_since FROM media_files WHERE path = ?`, "b.epub").Scan(&since); err != nil {
t.Fatal(err)
}
if since != first {
t.Errorf("missing_since reset on rescan: %q -> %q", first, since)
}
// ListMissing surfaces the file with its age.
entries, err := library.ListMissing(ctx, db, []int64{lib.ID})
if err != nil {
t.Fatalf("ListMissing: %v", err)
}
if len(entries) != 1 {
t.Fatalf("ListMissing returned %d entries, want 1", len(entries))
}
e := entries[0]
if e.Title != "b" || e.Path != "b.epub" || e.Kind != "ebooks" || e.LibraryID != lib.ID {
t.Errorf("unexpected missing entry: %+v", e)
}
if e.MissingSince != first {
t.Errorf("entry missing_since = %q, want %q", e.MissingSince, first)
}
// Restore the file: missing_since must be cleared, not left behind.
writeFile(t, filepath.Join(root, "b.epub"), "bbbbbbbbbbbbbbbbbbbb")
if _, err := Scan(ctx, db, lib, testLogger()); err != nil {
t.Fatalf("restore Scan: %v", err)
}
if err := db.QueryRowContext(ctx,
`SELECT missing_since FROM media_files WHERE path = ?`, "b.epub").Scan(&since); err != nil {
t.Fatal(err)
}
if since != "" {
t.Errorf("missing_since after restore = %q, want empty", since)
}
if got, _ := library.ListMissing(ctx, db, []int64{lib.ID}); len(got) != 0 {
t.Errorf("ListMissing after restore returned %d entries, want 0", len(got))
}
}
func TestPurgeMissingOnlyDeletesMissingItems(t *testing.T) {
db := testDB(t)
ctx := context.Background()
root := t.TempDir()
// Two audiobooks, each a folder of tracks.
writeFile(t, filepath.Join(root, "Book A", "01.mp3"), "a1")
writeFile(t, filepath.Join(root, "Book A", "02.mp3"), "a2")
writeFile(t, filepath.Join(root, "Book B", "01.mp3"), "b1")
writeFile(t, filepath.Join(root, "Book B", "02.mp3"), "b2")
lib, err := library.Create(ctx, db, "Audiobooks", "audiobooks", root)
if err != nil {
t.Fatalf("Create: %v", err)
}
if _, err := Scan(ctx, db, lib, testLogger()); err != nil {
t.Fatalf("Scan: %v", err)
}
// A second library with its own missing item, to prove purge is scoped.
otherRoot := t.TempDir()
writeFile(t, filepath.Join(otherRoot, "gone.epub"), "x")
other, err := library.Create(ctx, db, "Other", "ebooks", otherRoot)
if err != nil {
t.Fatalf("Create other: %v", err)
}
if _, err := Scan(ctx, db, other, testLogger()); err != nil {
t.Fatalf("Scan other: %v", err)
}
if err := os.Remove(filepath.Join(otherRoot, "gone.epub")); err != nil {
t.Fatal(err)
}
if _, err := Scan(ctx, db, other, testLogger()); err != nil {
t.Fatalf("Scan other (missing): %v", err)
}
// Remove one track from Book A: the item is not missing, only that file.
// Remove Book B entirely: the item and both its files are missing.
if err := os.Remove(filepath.Join(root, "Book A", "02.mp3")); err != nil {
t.Fatal(err)
}
if err := os.RemoveAll(filepath.Join(root, "Book B")); err != nil {
t.Fatal(err)
}
if _, err := Scan(ctx, db, lib, testLogger()); err != nil {
t.Fatalf("second Scan: %v", err)
}
entries, err := library.ListMissing(ctx, db, []int64{lib.ID})
if err != nil {
t.Fatalf("ListMissing: %v", err)
}
if len(entries) != 3 {
t.Fatalf("ListMissing returned %d entries, want 3 (Book A/02 + Book B/01 + Book B/02)", len(entries))
}
// Purge must remove only Book B.
n, err := library.PurgeMissing(ctx, db, lib.ID)
if err != nil {
t.Fatalf("PurgeMissing: %v", err)
}
if n != 1 {
t.Errorf("purged = %d, want 1 (only the fully missing item)", n)
}
// Book A survives, with its present and missing files intact.
var items int
if err := db.QueryRowContext(ctx,
`SELECT count(*) FROM media_items WHERE library_id = ?`, lib.ID).Scan(&items); err != nil {
t.Fatal(err)
}
if items != 1 {
t.Errorf("media_items after purge = %d, want 1", items)
}
var bookATitle string
if err := db.QueryRowContext(ctx,
`SELECT title FROM media_items WHERE library_id = ? AND path = ?`, lib.ID, "Book A").Scan(&bookATitle); err != nil {
t.Fatalf("a non-missing item was purged: %v", err)
}
var files int
if err := db.QueryRowContext(ctx, `
SELECT count(*) FROM media_files f JOIN media_items i ON i.id = f.item_id
WHERE i.library_id = ? AND i.path = ?`, lib.ID, "Book A").Scan(&files); err != nil {
t.Fatal(err)
}
if files != 2 {
t.Errorf("Book A files after purge = %d, want 2", files)
}
// The other library's missing item is untouched.
var otherItems int
if err := db.QueryRowContext(ctx,
`SELECT count(*) FROM media_items WHERE library_id = ?`, other.ID).Scan(&otherItems); err != nil {
t.Fatal(err)
}
if otherItems != 1 {
t.Errorf("other library items after purge = %d, want 1 (purge must be scoped)", otherItems)
}
// A second purge has nothing left to remove.
if n, err := library.PurgeMissing(ctx, db, lib.ID); err != nil || n != 0 {
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},
},
},
{
// A bare leading number, which older rips and anime use. This case
// exists because the pattern is anchored at the start of the name:
// handed the whole relative path it silently never matched, and only
// a scan-level test catches that - a unit test on parseEpisode passes
// a bare file name and cannot see it.
name: "bare leading number under a Season directory",
file: "The Wire/Season 3/06 - Title.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/06 - Title.mkv", title: "06 - Title", parent: "The Wire/Season 3", season: 3, episode: 6},
},
},
{
// The anime shape: an episode with no season in the name at all.
name: "episode-only marker under a Season directory",
file: "Show/Season 2/E05.mkv",
want: []item{
{kind: "series", path: "Show", title: "Show"},
{kind: "season", path: "Show/Season 2", title: "Season 2", parent: "Show", season: 2},
{kind: "video", path: "Show/Season 2/E05.mkv", title: "E05", parent: "Show/Season 2", season: 2, episode: 5},
},
},
}
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)
}
}
}