The schema landed in 8d6ea6d; this fills it. A video library now builds library -> series -> season -> episode, with episodes keeping kind='video' and the two parents standing in for directories that have no file of their own. Three naming shapes are recognised, in order: - a full marker, S01E02 or 1x02 - an episode-only marker, E05 / ep5 / Episode 5, which older rips and anime use. With no season directory the season is 1, which is what those libraries mean by it. - a bare leading number, "05 - Title.mkv", trusted only under a season directory. Without that anchor it is indistinguishable from a film whose name starts with a number. Every marker is delimited by non-alphanumeric characters, which is the guard that stops "Blade.Runner.1999x264.mkv" reading as season 99 episode 264 - the failure every release-tagged file would otherwise hit. The episode-only pattern caps at three digits for the same reason: four would let "Movie.E.1999.mkv" read as episode 1999, and years have four digits while episode numbers do not. Idempotency comes from ON CONFLICT (library_id, path) rather than a lookup, so a duplicate series row is impossible rather than merely unlikely, and a rescan leaves the tree byte-identical. Verified red-green: neutering parseEpisode fails the episode tests, and removing the episode-only branch fails the four patterns it covers. Known limits, marked in the code: a multi-episode file (S01E02E03) parses as its first episode only, and library has no separate 'tv' kind, so a video library mixing films and shows builds both shapes side by side. A bare-number episode keeps the cleaned file name as its title rather than the text after the number. TV metadata replaces the title once the series lookup lands, so it is not worth guessing at now.
771 lines
24 KiB
Go
771 lines
24 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},
|
|
},
|
|
},
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|