Vellum/internal/scanner/scan_test.go
mithral 1fcc7344cb Separate video libraries by kind: movies, TV, adult, hentai
One "video" kind meant every video library behaved the same, so a film shelf
tried to parse episode numbers and a TV library could not be told apart from
it. The kinds are now a declaration rather than a special case.

Five video kinds, all owning the same containers and differing only in how
their files become items:

  movies  flat                    tv      series/season/episode
  adult   flat                    hentai  series/season/episode
  video   series/season/episode   (kept: existing libraries already use it)

The scanner dispatches on library.GroupModeFor(lib.Kind) instead of comparing
the kind to "video", so a new episodic kind is a registry entry and nothing in
the scanner changes.

The decision worth recording, because it could have gone the other way:
media_items.kind stays STRUCTURAL. Every video library stores 'video' on its
rows, not its own kind. media_items.kind answers "is this a playable video",
which is what the playback, catalog and Jellyfin paths actually ask, and
migration 0013 already committed to that rule when it decided an episode would
stay kind='video'.

Storing the library kind instead would have meant teaching every query that
says kind='video' about five new values - catalog's episode and next-episode
reads, Jellyfin's episodes-only filter, the item-type mapping, the web
player's routing - and every kind added later would have had to do it again.

So the two jobs are split: the ROW kind says what a thing is, the LIBRARY kind
says how it is classified, and the library kind is what routes metadata. The
enricher now reads the library to choose providers, because a movies library
and an adult library hold identical rows and must not query the same
catalogue.

The adult kinds have no metadata provider. There is no mainstream catalogue
for that content - the ecosystem uses StashDB or local NFO files - so they
scan and browse and stay unenriched until someone registers one. That is
stated in the registry rather than left as a surprise.

Verified end-to-end: the same tree under a movies library stays flat, under a
tv library builds series/season/episodes, and every row in both is stored as
kind='video'. The UI needs no change - it reads /api/v1/media-types, so the
new kinds appeared in the picker by themselves.

Red-green: dispatching on the literal kind fails the tv and hentai cases;
storing the library kind on rows fails the structural-kind assertion.
2026-10-12 11:20:51 -04:00

858 lines
28 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)
}
}
}
// The same tree must behave differently depending on the library's declared
// 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)
}
})
}
}