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) } } } // subtitle is one subtitles row as the tests want to see it. type subtitle struct { source string path string format string lang string label string stream int } // scanSubtitles returns every subtitle track in a library, ordered by path. func scanSubtitles(t *testing.T, db *sql.DB, libraryID int64) []subtitle { t.Helper() rows, err := db.Query(` SELECT s.source, s.path, s.format, s.language, s.label, s.stream_index FROM subtitles s JOIN media_items i ON i.id = s.item_id WHERE i.library_id = ? ORDER BY s.path`, libraryID) if err != nil { t.Fatalf("query subtitles: %v", err) } defer rows.Close() var out []subtitle for rows.Next() { var s subtitle if err := rows.Scan(&s.source, &s.path, &s.format, &s.lang, &s.label, &s.stream); err != nil { t.Fatalf("scan subtitle: %v", err) } out = append(out, s) } if err := rows.Err(); err != nil { t.Fatalf("rows: %v", err) } return out } // A .srt beside a video is discovered as a sidecar track, with the fields the // table defines: the path relative to the root, the format from the extension, // no language, and stream_index -1. func TestScanSidecarSubtitle(t *testing.T) { db := testDB(t) ctx := context.Background() root := t.TempDir() writeFile(t, filepath.Join(root, "Movie.mkv"), "video") writeFile(t, filepath.Join(root, "Movie.srt"), "subs") lib, err := library.Create(ctx, db, "Movies", "movies", root) if err != nil { t.Fatalf("Create: %v", err) } if _, err := Scan(ctx, db, lib, testLogger()); err != nil { t.Fatalf("Scan: %v", err) } got := scanSubtitles(t, db, lib.ID) if len(got) != 1 { t.Fatalf("got %d subtitle rows, want 1: %+v", len(got), got) } want := subtitle{source: "sidecar", path: "Movie.srt", format: "srt", lang: "", label: "Movie.srt", stream: -1} if got[0] != want { t.Errorf("subtitle = %+v, want %+v", got[0], want) } } // The middle segment of "Movie.en.srt" / "Movie.pt-BR.srt" is the language, and // a release name full of dots must not be mistaken for one. func TestScanSidecarSubtitleLanguage(t *testing.T) { db := testDB(t) ctx := context.Background() root := t.TempDir() writeFile(t, filepath.Join(root, "Movie.mkv"), "video") writeFile(t, filepath.Join(root, "Movie.en.srt"), "en") writeFile(t, filepath.Join(root, "Movie.pt-BR.srt"), "pt") writeFile(t, filepath.Join(root, "The.Matrix.1999.1080p.mkv"), "video2") writeFile(t, filepath.Join(root, "The.Matrix.1999.1080p.fr.ass"), "fr") lib, err := library.Create(ctx, db, "Movies", "movies", root) if err != nil { t.Fatalf("Create: %v", err) } if _, err := Scan(ctx, db, lib, testLogger()); err != nil { t.Fatalf("Scan: %v", err) } want := []subtitle{ {source: "sidecar", path: "Movie.en.srt", format: "srt", lang: "en", label: "Movie.en.srt", stream: -1}, {source: "sidecar", path: "Movie.pt-BR.srt", format: "srt", lang: "pt-BR", label: "Movie.pt-BR.srt", stream: -1}, {source: "sidecar", path: "The.Matrix.1999.1080p.fr.ass", format: "ass", lang: "fr", label: "The.Matrix.1999.1080p.fr.ass", stream: -1}, } got := scanSubtitles(t, db, lib.ID) if len(got) != len(want) { t.Fatalf("got %d subtitle rows, want %d: %+v", len(got), len(want), got) } for i := range want { if got[i] != want[i] { t.Errorf("subtitle %d = %+v, want %+v", i, got[i], want[i]) } } } // A rescan must refresh the sidecar row, not add a second one for the same // file: the table's UNIQUE constraint is the dedupe, not a lookup. func TestScanSidecarRescanDoesNotDuplicate(t *testing.T) { db := testDB(t) ctx := context.Background() root := t.TempDir() writeFile(t, filepath.Join(root, "Movie.mkv"), "video") writeFile(t, filepath.Join(root, "Movie.en.srt"), "subs") lib, err := library.Create(ctx, db, "Movies", "movies", root) if err != nil { t.Fatalf("Create: %v", err) } for i := 0; i < 3; i++ { if _, err := Scan(ctx, db, lib, testLogger()); err != nil { t.Fatalf("Scan %d: %v", i, err) } } if got := scanSubtitles(t, db, lib.ID); len(got) != 1 { t.Fatalf("got %d subtitle rows after rescans, want 1: %+v", len(got), got) } } // Subtitles are a cache of what is on disk: a sidecar that disappears has its // row deleted, while the video beside it is untouched and still not missing. func TestScanSidecarRemovalDeletesRow(t *testing.T) { db := testDB(t) ctx := context.Background() root := t.TempDir() writeFile(t, filepath.Join(root, "Movie.mkv"), "video") writeFile(t, filepath.Join(root, "Movie.srt"), "subs") lib, err := library.Create(ctx, db, "Movies", "movies", root) if err != nil { t.Fatalf("Create: %v", err) } if _, err := Scan(ctx, db, lib, testLogger()); err != nil { t.Fatalf("first Scan: %v", err) } if got := scanSubtitles(t, db, lib.ID); len(got) != 1 { t.Fatalf("first scan produced %d subtitle rows, want 1", len(got)) } if err := os.Remove(filepath.Join(root, "Movie.srt")); err != nil { t.Fatal(err) } if _, err := Scan(ctx, db, lib, testLogger()); err != nil { t.Fatalf("second Scan: %v", err) } if got := scanSubtitles(t, db, lib.ID); len(got) != 0 { t.Errorf("vanished sidecar left %d rows, want 0: %+v", len(got), got) } // The video is still there and was never flagged missing. var missing int if err := db.QueryRowContext(ctx, `SELECT missing FROM media_items WHERE library_id = ? AND path = ?`, lib.ID, "Movie.mkv").Scan(&missing); err != nil { t.Fatalf("video item is gone: %v", err) } if missing != 0 { t.Errorf("video missing = %d, want 0", missing) } } // A sidecar never becomes an item of its own, and an unsupported file beside a // video is ignored entirely. func TestScanSidecarIsNotAnItem(t *testing.T) { db := testDB(t) ctx := context.Background() root := t.TempDir() writeFile(t, filepath.Join(root, "Movie.mkv"), "video") writeFile(t, filepath.Join(root, "Movie.srt"), "subs") lib, err := library.Create(ctx, db, "Movies", "movies", root) if err != nil { t.Fatalf("Create: %v", err) } if _, err := Scan(ctx, db, lib, testLogger()); err != nil { t.Fatalf("Scan: %v", err) } if n, _ := library.CountItems(ctx, db, lib.ID); n != 1 { t.Errorf("item count = %d, want 1 (the video only)", n) } } // grouping. That is the whole point of separating the video kinds: a film shelf // must not try to parse episode numbers, and a TV library must not scatter its // episodes across the root. // // "video" is in the table because it is the kind existing libraries already // have, and it must not regress. func TestVideoKindsDifferByGrouping(t *testing.T) { for _, tc := range []struct { kind string wantTree bool }{ {"tv", true}, {"video", true}, {"hentai", true}, {"movies", false}, {"adult", false}, } { t.Run(tc.kind, func(t *testing.T) { db := testDB(t) ctx := context.Background() root := t.TempDir() writeFile(t, filepath.Join(root, "Show", "Season 1", "Show S01E01.mkv"), "a") writeFile(t, filepath.Join(root, "Show", "Season 1", "Show S01E02.mkv"), "b") lib, err := library.Create(ctx, db, tc.kind, tc.kind, 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) var series, seasons, files int for _, it := range got { switch it.kind { case "series": series++ case "season": seasons++ default: files++ } // Whatever the grouping, a video library stores 'video'. That is // what the playback, catalog and Jellyfin paths ask about, and // it is why the library kind is not what goes on the row. if it.kind != "series" && it.kind != "season" && it.kind != "video" { t.Errorf("%q has kind %q; a video library must store 'video'", it.path, it.kind) } } if tc.wantTree { if series != 1 || seasons != 1 || files != 2 { t.Errorf("got %d series, %d seasons, %d files; want 1/1/2", series, seasons, files) } } else if series != 0 || seasons != 0 || files != 2 { t.Errorf("got %d series, %d seasons, %d files; want 0/0/2 flat items", series, seasons, files) } }) } }