Two halves of the same feature: the scanner finds tracks, the media layer serves them as WebVTT. Discovery walks each directory for .srt/.ass/.ssa/.vtt/.sub beside a video and matches them by base name, including the language form "Movie.en.srt" and "Movie.pt-BR.srt". A sidecar is never itself an item. Matching is a dot-guarded prefix on the real video base name, so a dotted release name like "The.Matrix.1999.1080p.en.srt" does not read the year as a language. Serving adds two routes inside the existing item family, both grant-checked through catalog.Store (ADR-005) so forbidden and absent are the same 404. Browsers play WebVTT and nothing else, so .srt is converted (header plus the comma-to-dot in timestamps), .vtt passes through, and embedded streams and .ass/.ssa go through ffmpeg. Conversions are cached under ConfigDir, because converting per request would spawn an ffmpeg on every seek. A track that fails to convert fails alone - the video and the other tracks are unaffected. Subtitle rows are a cache of what is on disk, so a vanished sidecar has its row deleted. That is the opposite of a vanished video, which is marked instead (ADR-003) because progress hangs off it and cannot be regenerated. Verified red-green: disabling the sidecar call fails all four scanner tests, and removing the VTT header fails the conversion test. Known limits: a "Movie.forced.en.srt" records "forced.en" rather than "en", and the conversion cache is keyed by track id, so a sidecar replaced under an unchanged row serves the stale conversion until a rescan recreates the row.
1050 lines
34 KiB
Go
1050 lines
34 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
})
|
|
}
|
|
}
|