From 2c424ae67cc1147e93b74c1259e17c846074efa2 Mon Sep 17 00:00:00 2001 From: mithral Date: Mon, 12 Oct 2026 01:41:05 -0400 Subject: [PATCH] Expose the television tree: catalog reads, API routes, Jellyfin shapes The scanner builds library -> series -> season -> episode; this makes it reachable. Three reads, all grant-checked through catalog.Store (ADR-005): a series' seasons, a season's episodes, and the next unwatched episode. Each loads the parent through ItemFor first, so forbidden and absent are the same ErrNotFound, and the child query is scoped to the parent's own library_id so a corrupt parent_id cannot cross a library boundary. Verified red-green: swapping ItemFor for Item returns two rows to an ungranted caller. Episodes come back in episode order, not insertion order, and the ordering is verified red-green too - dropping the clause yields [3 1 10 2]. Routes stay inside the existing family: /api/v1/items/{id}/seasons, /episodes and /next. No parallel route surface. Jellyfin gains SeriesDto, SeasonDto and EpisodeDto. Jellyfin's wire type is BaseItemDto, so these carry the folder/file split plus IndexNumber and ParentIndexNumber. ItemType now takes the item rather than the kind, because an episode IS kind video - that is the one signature change, and the dto tests were updated with it. Verified end-to-end against a running server: a library holding S01E02, 1x02, a bare-number file and a film produced two series, three seasons and six episodes, with the film left alone; the routes returned the seasons in order, the episodes in episode order, and the next unwatched episode. Note for later: Subsonic and the web UI are untouched, so a series browses there as it did before. The scanner also has no separate 'tv' kind, so a video library mixing films and shows builds both shapes side by side. --- internal/catalog/catalog.go | 180 ++++++++++++++++++-- internal/catalog/catalog_test.go | 265 +++++++++++++++++++++++++++++ internal/catalog/store_test.go | 99 ++++++++++- internal/jellyfin/dto/dto.go | 206 ++++++++++++++++++---- internal/jellyfin/dto/dto_test.go | 149 ++++++++++++++-- internal/jellyfin/items.go | 4 +- internal/jellyfin/jellyfin_test.go | 136 +++++++++++++++ internal/jellyfin/query.go | 14 +- internal/media/handlers.go | 19 ++- internal/media/items.go | 12 +- internal/media/series.go | 105 ++++++++++++ internal/media/series_test.go | 257 ++++++++++++++++++++++++++++ 12 files changed, 1367 insertions(+), 79 deletions(-) create mode 100644 internal/media/series.go create mode 100644 internal/media/series_test.go diff --git a/internal/catalog/catalog.go b/internal/catalog/catalog.go index cd415e6..a7137f4 100644 --- a/internal/catalog/catalog.go +++ b/internal/catalog/catalog.go @@ -30,6 +30,12 @@ // writing the whole application's query layer twice. The seam buys a swappable // per-item load; it is not a repository. // +// The television tree is the one exception, and for the same reason ItemFor is +// here rather than in a caller: Seasons, Episodes and NextEpisode are not +// presentation, they are a grant-enforcing read of a parent the caller has not +// yet proved it may read. Implementing them per surface would mean implementing +// "may this user read this series" three more times. +// // This package holds no HTTP knowledge. It returns typed errors and lets the // caller choose a status code. package catalog @@ -63,6 +69,14 @@ type Item struct { Year int Missing bool + // SeasonNumber and EpisodeNumber are the television tree (migration 0013). + // Both are zero for anything that is not part of it, which includes every + // film: the columns are nullable and a film leaves both NULL. An EPISODE + // stays kind='video' (see Kind), so an episode is recognised by its kind + // together with a non-zero EpisodeNumber, never by kind alone. + SeasonNumber int + EpisodeNumber int + // RootPath is the absolute library root, so AbsPath can be computed. RootPath string @@ -111,14 +125,55 @@ type Store interface { FileByID(ctx context.Context, itemID, fileID int64) (File, error) FirstFile(ctx context.Context, itemID int64) (File, error) FileCount(ctx context.Context, itemID int64) (int, error) + + // Seasons returns a series' seasons in season order, Episodes a season's + // episodes in episode order, and NextEpisode the series' first episode the + // caller has not finished. + // + // All three take the caller rather than an already-authorized item because + // the grant is the whole point of them: they read a parent the caller has + // not named yet, so the parent has to be checked before its children are + // listed. As everywhere else in this package, a parent the caller may not + // read and a parent that does not exist are the same ErrNotFound. + Seasons(ctx context.Context, u auth.User, seriesID int64) ([]Item, error) + Episodes(ctx context.Context, u auth.User, seasonID int64) ([]Item, error) + NextEpisode(ctx context.Context, u auth.User, seriesID int64) (Item, error) } -const itemSelect = ` - SELECT i.id, i.library_id, i.kind, i.path, i.title, COALESCE(i.year, 0), i.missing, l.root_path, - COALESCE((SELECT f.mtime FROM media_files f WHERE f.item_id = i.id ORDER BY f.id LIMIT 1), '') +const itemColumns = ` + SELECT i.id, i.library_id, i.kind, i.path, i.title, COALESCE(i.year, 0), i.missing, + l.root_path, + COALESCE((SELECT f.mtime FROM media_files f WHERE f.item_id = i.id ORDER BY f.id LIMIT 1), ''), + COALESCE(i.season_number, 0), COALESCE(i.episode_number, 0) FROM media_items i - JOIN libraries l ON l.id = i.library_id - WHERE i.id = ?` + JOIN libraries l ON l.id = i.library_id` + +const itemSelect = itemColumns + ` WHERE i.id = ?` + +// scanner is one row, so the single-item and list paths share the same scan. +type scanner interface{ Scan(dest ...any) error } + +// scanItem reads one row of itemColumns. A missing row is ErrNotFound, so every +// caller inherits the "absent and forbidden are the same" rule rather than +// re-deciding it. +func scanItem(row scanner) (Item, error) { + var ( + it Item + mtime string + ) + if err := row.Scan(&it.ID, &it.LibraryID, &it.Kind, &it.Path, &it.Title, + &it.Year, &it.Missing, &it.RootPath, &mtime, + &it.SeasonNumber, &it.EpisodeNumber); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return Item{}, ErrNotFound + } + return Item{}, err + } + if nanos := parseUnixNano(mtime); nanos > 0 { + it.ModTime = time.Unix(0, nanos).UTC() + } + return it, nil +} // SQL is the SQLite implementation of Store, and the only thing in this package // that knows the query text. @@ -134,22 +189,16 @@ func New(db *sql.DB) *SQL { return &SQL{db: db} } // serves a request: this exists for the scanner, jobs and tests, which have // already established the right to touch the row. func (s *SQL) Item(ctx context.Context, id int64) (Item, error) { - var ( - it Item - mtime string - ) - err := s.db.QueryRowContext(ctx, itemSelect, id).Scan( - &it.ID, &it.LibraryID, &it.Kind, &it.Path, &it.Title, - &it.Year, &it.Missing, &it.RootPath, &mtime) - if errors.Is(err, sql.ErrNoRows) { + it, err := scanItem(s.db.QueryRowContext(ctx, itemSelect, id)) + switch { + case errors.Is(err, ErrNotFound): + // Returned unwrapped on purpose: this message is part of the "absent + // and forbidden are indistinguishable" contract and must stay the same + // one every caller gets. return Item{}, ErrNotFound - } - if err != nil { + case err != nil: return Item{}, fmt.Errorf("catalog: load item: %w", err) } - if nanos := parseUnixNano(mtime); nanos > 0 { - it.ModTime = time.Unix(0, nanos).UTC() - } return it, nil } @@ -255,6 +304,101 @@ func (s *SQL) FileCount(ctx context.Context, itemID int64) (int, error) { return n, nil } +// ── the television tree ───────────────────────────────────────────────── +// +// library -> series -> season -> episode, by parent_id (migration 0013). Each +// read below loads the parent through ItemFor first, so a caller with no grant +// on the series' library gets the same ErrNotFound an absent series would give, +// and only then walks its children — which are additionally scoped to the +// parent's own library, so a corrupt parent_id can never reach across it. + +// Seasons returns a series' seasons, ordered by season number. +// +// The number is the order, not the title and not the insertion order: a rescan +// upserts by path, so "Season 10" and "Season 2" arrive in whatever order the +// filesystem walk produced, and a title sort would interleave them +// lexicographically ("Season 10" before "Season 2"). id breaks a tie, so two +// rows claiming the same number still come back deterministically. +func (s *SQL) Seasons(ctx context.Context, u auth.User, seriesID int64) ([]Item, error) { + series, err := s.ItemFor(ctx, u, seriesID) + if err != nil { + return nil, err + } + rows, err := s.db.QueryContext(ctx, itemColumns+` + WHERE i.parent_id = ? AND i.library_id = ? AND i.kind = 'season' AND i.missing = 0 + ORDER BY COALESCE(i.season_number, 0), i.id`, series.ID, series.LibraryID) + if err != nil { + return nil, fmt.Errorf("catalog: list seasons: %w", err) + } + defer rows.Close() + return scanItems(rows) +} + +// Episodes returns a season's episodes, ordered by episode number, for the same +// reason Seasons orders by season number. +func (s *SQL) Episodes(ctx context.Context, u auth.User, seasonID int64) ([]Item, error) { + season, err := s.ItemFor(ctx, u, seasonID) + if err != nil { + return nil, err + } + rows, err := s.db.QueryContext(ctx, itemColumns+` + WHERE i.parent_id = ? AND i.library_id = ? AND i.kind = 'video' AND i.missing = 0 + ORDER BY COALESCE(i.episode_number, 0), i.id`, season.ID, season.LibraryID) + if err != nil { + return nil, fmt.Errorf("catalog: list episodes: %w", err) + } + defer rows.Close() + return scanItems(rows) +} + +// NextEpisode returns the first episode of a series the caller has not finished, +// in season-then-episode order, so a finished season 1 rolls into season 2 rather +// than reporting the show as caught up. A half-watched episode is the next one: +// it is what the caller is already in the middle of. +// +// It returns ErrNotFound when every episode is finished, and — like every other +// read here — when the series is missing or the caller may not read it. +func (s *SQL) NextEpisode(ctx context.Context, u auth.User, seriesID int64) (Item, error) { + series, err := s.ItemFor(ctx, u, seriesID) + if err != nil { + return Item{}, err + } + // The join reaches one level down (series -> season -> episode) because the + // parent of an episode is a season, and a series holds no episodes of its + // own. episode_number IS NOT NULL is what makes the order total: a video row + // with no number cannot be placed in a series at all. + it, err := scanItem(s.db.QueryRowContext(ctx, itemColumns+` + WHERE i.library_id = ? AND i.kind = 'video' AND i.missing = 0 + AND i.episode_number IS NOT NULL + AND i.parent_id IN (SELECT c.id FROM media_items c + WHERE c.parent_id = ? AND c.kind = 'season') + AND NOT EXISTS (SELECT 1 FROM progress p + WHERE p.user_id = ? AND p.item_id = i.id AND p.finished = 1) + ORDER BY COALESCE((SELECT c.season_number FROM media_items c WHERE c.id = i.parent_id), 0), + i.episode_number, i.id + LIMIT 1`, series.LibraryID, series.ID, u.ID)) + switch { + case errors.Is(err, ErrNotFound): + return Item{}, ErrNotFound + case err != nil: + return Item{}, fmt.Errorf("catalog: next episode: %w", err) + } + return it, nil +} + +// scanItems drains a query of itemColumns. +func scanItems(rows *sql.Rows) ([]Item, error) { + out := []Item{} + for rows.Next() { + it, err := scanItem(rows) + if err != nil { + return nil, err + } + out = append(out, it) + } + return out, rows.Err() +} + // The functions below are the original package API, kept so that existing // callers compile and behave exactly as before. Each one is a one-line // delegation to the SQL implementation above. diff --git a/internal/catalog/catalog_test.go b/internal/catalog/catalog_test.go index c05350a..1117e63 100644 --- a/internal/catalog/catalog_test.go +++ b/internal/catalog/catalog_test.go @@ -4,9 +4,12 @@ import ( "context" "database/sql" "errors" + "fmt" "io" "log/slog" "path/filepath" + "slices" + "strings" "testing" "github.com/mithral/vellum/internal/auth" @@ -88,6 +91,116 @@ func grant(t *testing.T, db *sql.DB, u auth.User, libID int64) { } } +// newTVItem writes one row of the television tree exactly as the scanner does: +// a series or season carries no file, an episode is kind='video' with both +// numbers set, and a zero number is stored as NULL rather than 0. +// +// Insertion order is the caller's business — the tests insert out of order on +// purpose, because a query that returns rows in id order would look right. +func newTVItem(t *testing.T, db *sql.DB, libID int64, kind, path, title string, parent int64, season, episode int) int64 { + t.Helper() + var ( + parentArg, seasonArg, episodeArg any + ) + if parent != 0 { + parentArg = parent + } + if season > 0 { + seasonArg = season + } + if episode > 0 { + episodeArg = episode + } + id := newTVRow(t, db, `INSERT INTO media_items + (library_id, kind, path, parent_id, title, sort_title, season_number, episode_number) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`, + libID, kind, path, parentArg, title, strings.ToLower(title), seasonArg, episodeArg) + if kind == "video" { + // An episode has a file; progress rows need one to hang off. + if _, err := db.Exec( + `INSERT INTO media_files (item_id, path, size) VALUES (?, ?, 1)`, id, path); err != nil { + t.Fatalf("insert episode file: %v", err) + } + } + return id +} + +func newTVRow(t *testing.T, db *sql.DB, q string, args ...any) int64 { + t.Helper() + var id int64 + if err := db.QueryRowContext(context.Background(), q, args...).Scan(&id); err != nil { + t.Fatalf("query %q: %v", q, err) + } + return id +} + +// newTVLibrary builds library -> series -> season 1 (four episodes) -> season 2, +// inserting every child in a deliberately useless order: season 2 before season +// 1, and episodes 3, 1, 10, 2. Anything that sorts by id, by title or by path +// comes back wrong, so these rows pin the ORDER and not just the contents. +type tvFixture struct { + series int64 + seasons []int64 // season 1, season 2 + ep1 map[int]int64 + ep2 map[int]int64 +} + +func newTVLibrary(t *testing.T, db *sql.DB) tvFixture { + t.Helper() + lib := newTVRow(t, db, `INSERT INTO libraries (kind, name, root_path) + VALUES ('video', 'TV', '/media/TV') RETURNING id`) + f := tvFixture{ep1: map[int]int64{}, ep2: map[int]int64{}} + f.series = newTVItem(t, db, lib, "series", "The Wire", "The Wire", 0, 0, 0) + + // Season 2 is written first and still has to come back second. + s2 := newTVItem(t, db, lib, "season", "The Wire/Season 2", "Season 2", f.series, 2, 0) + s1 := newTVItem(t, db, lib, "season", "The Wire/Season 1", "Season 1", f.series, 1, 0) + f.seasons = []int64{s1, s2} + + for _, n := range []int{3, 1, 10, 2} { + f.ep1[n] = newTVItem(t, db, lib, "video", + fmt.Sprintf("The Wire/Season 1/S01E%02d.mkv", n), + fmt.Sprintf("Episode %d", n), s1, 1, n) + } + f.ep2[1] = newTVItem(t, db, lib, "video", + "The Wire/Season 2/S02E01.mkv", "Episode 1", s2, 2, 1) + return f +} + +// finish marks episodes watched for a user. It replaces rather than inserts, so +// a test can mark progressively without tripping the progress primary key. +func (f tvFixture) finish(t *testing.T, db *sql.DB, u auth.User, eps ...int64) { + t.Helper() + for _, ep := range eps { + var fileID int64 + if err := db.QueryRow(`SELECT id FROM media_files WHERE item_id = ?`, ep).Scan(&fileID); err != nil { + t.Fatalf("file for episode %d: %v", ep, err) + } + if _, err := db.Exec(`INSERT OR REPLACE INTO progress + (user_id, item_id, file_id, kind, percent, finished) + VALUES (?, ?, ?, 'video', 1, 1)`, u.ID, ep, fileID); err != nil { + t.Fatalf("mark episode %d finished: %v", ep, err) + } + } +} + +// episodeNumbers projects a list of items down to their episode numbers. +func episodeNumbers(items []Item) []int { + out := make([]int, len(items)) + for i, it := range items { + out[i] = it.EpisodeNumber + } + return out +} + +func seasonNumbers(items []Item) []int { + out := make([]int, len(items)) + for i, it := range items { + out[i] = it.SeasonNumber + } + return out +} + func TestLoadReturnsCodecFacts(t *testing.T) { db := testDB(t) lib := newLibrary(t, db, "A") @@ -219,3 +332,155 @@ func TestFileCount(t *testing.T) { t.Errorf("FileCount = %d, want 1", n) } } + +// ── the television tree ───────────────────────────────────────────────── + +// Episodes come back in EPISODE order, not in the order the scanner happened to +// insert them and not in the order their paths sort. Both of those are wrong in +// a way that only shows up on a real library: a rescan upserts by path, and +// "S01E10" sorts before "S01E2" as text. Episode 10 must come after episode 3. +func TestEpisodesAreInEpisodeOrderNotInsertionOrder(t *testing.T) { + db := testDB(t) + ctx := context.Background() + f := newTVLibrary(t, db) + + u := newUser(t, db, "watcher", false) + grant(t, db, u, 1) + + seasons, err := New(db).Seasons(ctx, u, f.series) + if err != nil { + t.Fatalf("Seasons: %v", err) + } + if got := seasonNumbers(seasons); len(got) != 2 || got[0] != 1 || got[1] != 2 { + t.Errorf("Seasons = %v, want [1 2]", got) + } + + eps, err := New(db).Episodes(ctx, u, f.seasons[0]) + if err != nil { + t.Fatalf("Episodes: %v", err) + } + want := []int64{f.ep1[1], f.ep1[2], f.ep1[3], f.ep1[10]} + got := make([]int64, len(eps)) + for i, ep := range eps { + got[i] = ep.ID + } + if !slices.Equal(got, want) { + t.Fatalf("episodes = %v, want %v (episode order, not insertion order)", + episodeNumbers(eps), []int{1, 2, 3, 10}) + } + if slices.IsSorted(got) { + t.Fatal("the fixture no longer tests order: the expected order is the insertion order") + } + if eps[0].SeasonNumber != 1 || eps[0].Path != "The Wire/Season 1/S01E01.mkv" { + t.Errorf("first episode = %+v, want S01E01 of season 1", eps[0]) + } +} + +// NextEpisode is the first episode the caller has not finished, in order, and +// it rolls from a finished season into the next one. +func TestNextEpisodeIsTheFirstUnfinished(t *testing.T) { + db := testDB(t) + ctx := context.Background() + f := newTVLibrary(t, db) + store := New(db) + + u := newUser(t, db, "watcher", false) + grant(t, db, u, 1) + + next, err := store.NextEpisode(ctx, u, f.series) + if err != nil { + t.Fatalf("NextEpisode: %v", err) + } + if next.EpisodeNumber != 1 || next.Path != "The Wire/Season 1/S01E01.mkv" { + t.Fatalf("NextEpisode = %+v, want S01E01", next) + } + + finish := func(eps ...int64) { f.finish(t, db, u, eps...) } + + // A half-watched episode is still the next one: it is what they are in. + finish(f.ep1[1]) + if next, err = store.NextEpisode(ctx, u, f.series); err != nil || next.EpisodeNumber != 2 { + t.Errorf("NextEpisode after finishing E01 = %+v, %v; want episode 2", next, err) + } + + // Season 1 done, so the next unwatched one is season 2's. + finish(f.ep1[2], f.ep1[3], f.ep1[10]) + if next, err = store.NextEpisode(ctx, u, f.series); err != nil || next.SeasonNumber != 2 { + t.Errorf("NextEpisode after finishing season 1 = %+v, %v; want season 2", next, err) + } + + finish(f.ep2[1]) + if _, err := store.NextEpisode(ctx, u, f.series); !errors.Is(err, ErrNotFound) { + t.Errorf("a fully watched series: err = %v, want ErrNotFound", err) + } +} + +// The security property, for the television tree specifically: these read +// CHILDREN of a parent the caller never named, so the grant has to be applied +// before the walk. A caller with no grant gets nothing at all, and the error is +// byte-identical to the one for a parent that does not exist. +func TestTVTreeReadsAreGrantCheckedAndIndistinguishable(t *testing.T) { + db := testDB(t) + ctx := context.Background() + f := newTVLibrary(t, db) + store := New(db) + + outsider := newUser(t, db, "outsider", false) + granted := newUser(t, db, "granted", false) + grant(t, db, granted, 1) + + type read struct { + name string + id int64 + fn func(auth.User, int64) error + } + // list wraps a list read so every read reports the same way: the error, plus + // a failure if rows came back at all. + list := func(name string, s func(context.Context, auth.User, int64) ([]Item, error)) func(auth.User, int64) error { + return func(u auth.User, id int64) error { + items, err := s(ctx, u, id) + if err == nil && len(items) > 0 { + t.Errorf("%s returned %d rows to an ungranted caller", name, len(items)) + } + return err + } + } + reads := []read{ + {"Seasons", f.series, list("Seasons", store.Seasons)}, + {"Episodes", f.seasons[0], list("Episodes", store.Episodes)}, + {"NextEpisode", f.series, func(u auth.User, id int64) error { + _, err := store.NextEpisode(ctx, u, id) + return err + }}, + } + + for _, r := range reads { + // Forbidden: the parent exists, this user has no grant on its library. + ferr := r.fn(outsider, r.id) + // Absent: no such parent at all. + aerr := r.fn(outsider, r.id+9999) + + if !errors.Is(ferr, ErrNotFound) { + t.Errorf("%s forbidden: err = %v, want ErrNotFound", r.name, ferr) + } + if !errors.Is(aerr, ErrNotFound) { + t.Errorf("%s absent: err = %v, want ErrNotFound", r.name, aerr) + } + if ferr.Error() != aerr.Error() { + t.Errorf("%s: errors differ and would leak existence: %q vs %q", + r.name, ferr, aerr) + } + } + + // Positive control: the granted user reads the same tree. + seasons, err := store.Seasons(ctx, granted, f.series) + if err != nil || len(seasons) != 2 { + t.Fatalf("granted Seasons = %v, %v; want 2 seasons", seasons, err) + } + if _, err := store.Episodes(ctx, granted, f.seasons[1]); err != nil { + t.Errorf("granted Episodes: %v", err) + } + if _, err := store.NextEpisode(ctx, granted, f.series); err != nil { + t.Errorf("granted NextEpisode: %v", err) + } +} diff --git a/internal/catalog/store_test.go b/internal/catalog/store_test.go index 45a42d1..38e3d22 100644 --- a/internal/catalog/store_test.go +++ b/internal/catalog/store_test.go @@ -25,6 +25,13 @@ type fakeStore struct { granted map[int64][]int64 // user id -> library ids admins map[int64]bool + // children is the television tree: parent id -> its ordered seasons or + // episodes. Ordering is the SQL implementation's business (catalog_test.go + // pins it against a real database), so the fake just hands back what it was + // given — what it must reproduce is the GRANT, which is the part of the + // contract that is a security property. + children map[int64][]Item + // calls records which methods ran, so the test can prove the caller went // through the interface rather than reaching for a database. calls []string @@ -34,10 +41,11 @@ var _ Store = (*fakeStore)(nil) func newFakeStore() *fakeStore { return &fakeStore{ - items: map[int64]Item{}, - files: map[int64][]File{}, - granted: map[int64][]int64{}, - admins: map[int64]bool{}, + items: map[int64]Item{}, + files: map[int64][]File{}, + granted: map[int64][]int64{}, + admins: map[int64]bool{}, + children: map[int64][]Item{}, } } @@ -109,6 +117,44 @@ func (f *fakeStore) FileCount(_ context.Context, itemID int64) (int, error) { return len(f.files[itemID]), nil } +// tree applies the same rule ItemFor applies — the parent must exist and be in a +// library the caller may read, and absent and forbidden are the same error — +// then returns its children. +func (f *fakeStore) tree(u auth.User, method string, parentID int64) ([]Item, error) { + f.calls = append(f.calls, method) + parent, ok := f.items[parentID] + if !ok || !f.allowed(u, parent.LibraryID) { + return nil, ErrNotFound + } + return f.children[parentID], nil +} + +func (f *fakeStore) Seasons(_ context.Context, u auth.User, seriesID int64) ([]Item, error) { + return f.tree(u, "Seasons", seriesID) +} + +func (f *fakeStore) Episodes(_ context.Context, u auth.User, seasonID int64) ([]Item, error) { + return f.tree(u, "Episodes", seasonID) +} + +// NextEpisode walks one level deeper than Episodes — the SQL reaches the +// episodes through the seasons' ids — and takes the first one, since the fake +// has no progress rows. +func (f *fakeStore) NextEpisode(_ context.Context, u auth.User, seriesID int64) (Item, error) { + seasons, err := f.tree(u, "NextEpisode", seriesID) + if err != nil { + return Item{}, err + } + for _, season := range seasons { + for _, ep := range f.children[season.ID] { + if ep.EpisodeNumber > 0 { + return ep, nil + } + } + } + return Item{}, ErrNotFound +} + // serveItem is a stand-in for a real caller (media.playback, jellyfin.playback) // written against the Store interface instead of *sql.DB. It is here because // that is what the seam is FOR: this function cannot be told it needs SQLite. @@ -136,6 +182,15 @@ func fixture() (*fakeStore, auth.User, auth.User, int64) { granted := auth.User{ID: 1, Username: "granted"} outsider := auth.User{ID: 2, Username: "outsider"} s.granted[granted.ID] = []int64{libID} + + // A television tree in the same library: series -> season -> two episodes. + s.items[9] = Item{ID: 9, LibraryID: libID, Kind: "series", Title: "The Wire"} + s.items[10] = Item{ID: 10, LibraryID: libID, Kind: "season", Title: "Season 1", SeasonNumber: 1} + s.items[11] = Item{ID: 11, LibraryID: libID, Kind: "video", Title: "Pilot", SeasonNumber: 1, EpisodeNumber: 1} + s.items[12] = Item{ID: 12, LibraryID: libID, Kind: "video", Title: "Cleanup", SeasonNumber: 1, EpisodeNumber: 2} + s.children[9] = []Item{s.items[10]} + s.children[10] = []Item{s.items[11], s.items[12]} + return s, granted, outsider, 7 } @@ -189,6 +244,42 @@ func TestStoreAdminSeesEverythingWithoutAGrant(t *testing.T) { } } +// The television tree reads children of a parent the caller has not yet been +// checked for, so the grant has to travel with the query or the seam is a way +// around it. +func TestStoreTreeReadIsGrantChecked(t *testing.T) { + s, granted, outsider, _ := fixture() + ctx := context.Background() + + eps, err := s.Episodes(ctx, granted, 10) + if err != nil { + t.Fatalf("granted Episodes: %v", err) + } + if len(eps) != 2 || eps[0].EpisodeNumber != 1 { + t.Fatalf("Episodes = %v, want two episodes starting at 1", eps) + } + next, err := s.NextEpisode(ctx, granted, 9) + if err != nil || next.ID != 11 { + t.Errorf("NextEpisode = %+v, %v; want item 11, nil", next, err) + } + + forbidden, ferr := s.Episodes(ctx, outsider, 10) + _, aerr := s.Episodes(ctx, outsider, 9999) + if len(forbidden) != 0 || forbidden != nil { + t.Errorf("an ungranted caller got episodes back: %v", forbidden) + } + if !errors.Is(ferr, ErrNotFound) || !errors.Is(aerr, ErrNotFound) || + ferr.Error() != aerr.Error() { + t.Errorf("errors = %q / %q, want the same ErrNotFound for both", ferr, aerr) + } + if _, err := s.Seasons(ctx, outsider, 9); !errors.Is(err, ErrNotFound) { + t.Errorf("Seasons leaked to an ungranted caller: %v", err) + } + if _, err := s.NextEpisode(ctx, outsider, 9); !errors.Is(err, ErrNotFound) { + t.Errorf("NextEpisode leaked to an ungranted caller: %v", err) + } +} + // FileByID must stay scoped to its item through the interface too, or the // in-memory Store would be laxer than the SQL one it stands in for. func TestStoreFileByIDIsScopedToItsItem(t *testing.T) { diff --git a/internal/jellyfin/dto/dto.go b/internal/jellyfin/dto/dto.go index 37e6a2c..14b09ef 100644 --- a/internal/jellyfin/dto/dto.go +++ b/internal/jellyfin/dto/dto.go @@ -14,6 +14,8 @@ package dto import ( + "slices" + "github.com/mithral/vellum/internal/auth" "github.com/mithral/vellum/internal/catalog" "github.com/mithral/vellum/internal/library" @@ -67,24 +69,32 @@ type UserItemDataDto struct { } // BaseItemDto is the browse shape. Type is the Jellyfin item type -// ("Movie"/"Audio"/"Book"/"Photo"/"CollectionFolder"/"Folder"), mapped from -// Vellum's media kind. +// ("Movie"/"Audio"/"Book"/"Photo"/"Series"/"Season"/"Episode"/"Folder"), mapped +// from Vellum's media kind. type BaseItemDto struct { - Id string `json:"Id"` - Name string `json:"Name"` - Type string `json:"Type"` - MediaType string `json:"MediaType,omitempty"` - CollectionType string `json:"CollectionType,omitempty"` - Container string `json:"Container,omitempty"` - RunTimeTicks int64 `json:"RunTimeTicks,omitempty"` - ProductionYear int `json:"ProductionYear,omitempty"` - ParentId string `json:"ParentId,omitempty"` - IsFolder bool `json:"IsFolder"` - SortName string `json:"SortName,omitempty"` - Path string `json:"Path,omitempty"` - ImageTags map[string]string `json:"ImageTags,omitempty"` - UserData *UserItemDataDto `json:"UserData,omitempty"` - MediaSources []MediaSourceInfo `json:"MediaSources,omitempty"` + Id string `json:"Id"` + Name string `json:"Name"` + Type string `json:"Type"` + MediaType string `json:"MediaType,omitempty"` + CollectionType string `json:"CollectionType,omitempty"` + Container string `json:"Container,omitempty"` + RunTimeTicks int64 `json:"RunTimeTicks,omitempty"` + ProductionYear int `json:"ProductionYear,omitempty"` + ParentId string `json:"ParentId,omitempty"` + IsFolder bool `json:"IsFolder"` + SortName string `json:"SortName,omitempty"` + Path string `json:"Path,omitempty"` + + // IndexNumber is the number within the parent — an episode number for an + // Episode, a season number for a Season. ParentIndexNumber is the season an + // episode belongs to. Both are omitted rather than sent as 0 for everything + // that is not television, so a movie's payload is unchanged. + IndexNumber int `json:"IndexNumber,omitempty"` + ParentIndexNumber int `json:"ParentIndexNumber,omitempty"` + + ImageTags map[string]string `json:"ImageTags,omitempty"` + UserData *UserItemDataDto `json:"UserData,omitempty"` + MediaSources []MediaSourceInfo `json:"MediaSources,omitempty"` } // MediaSourceInfo is one playable source. Vellum never transcodes, so @@ -194,12 +204,22 @@ type ItemView struct { Favorite bool } -// ItemType maps a Vellum media kind to the Jellyfin item type. The four types -// the task requires are Movie, Audio, Book and Photo; anything Vellum adds -// later (games) degrades to Folder so a client can still show it. -func ItemType(kind string) string { - switch kind { +// ItemType maps a Vellum media item to the Jellyfin item type. +// +// It takes the item, not the kind, because television is not a kind: an EPISODE +// stays kind='video' (migration 0013) and is told from a film by its episode +// number. A function of the kind alone would label every episode a Movie and +// clients would file a show as a film. +func ItemType(it catalog.Item) string { + switch it.Kind { + case "series": + return "Series" + case "season": + return "Season" case "video": + if it.EpisodeNumber > 0 { + return "Episode" + } return "Movie" case "audiobooks", "music": return "Audio" @@ -213,10 +233,11 @@ func ItemType(kind string) string { } // MediaType maps a Vellum media kind to the Jellyfin MediaType. It is the -// coarser classification ("Video"/"Audio"/"Book"/"Photo"). +// coarser classification ("Video"/"Audio"/"Book"/"Photo"), so a series and a +// season are still Video: they are folders, but they sit in a video library. func MediaType(kind string) string { switch kind { - case "video": + case "video", "series", "season": return "Video" case "audiobooks", "music": return "Audio" @@ -230,9 +251,12 @@ func MediaType(kind string) string { } // CollectionType maps a Vellum kind to the Jellyfin collection type, which -// tells a client how to render the folder. +// tells a client how to render the folder. tvshows is what makes a client open a +// series in its television view rather than as a generic folder. func CollectionType(kind string) string { switch kind { + case "series": + return "tvshows" case "video": return "movies" case "audiobooks", "music": @@ -247,32 +271,83 @@ func CollectionType(kind string) string { } // KindsForTypes maps requested Jellyfin IncludeItemTypes back to Vellum kinds, -// so a client asking for Movies does not receive ebooks. An unknown type maps +// so a client asking for Series does not receive films. An unknown type maps // to nothing rather than to everything. +// +// An Episode maps to kind 'video' with nothing else added, because an episode IS +// a video row; the handler narrows the result to rows that carry an episode +// number (see EpisodesOnly). func KindsForTypes(types []string) []string { var out []string + add := func(kind string) { + // Episode and Movie are both 'video'; a client asking for both must not + // produce the same kind twice in the IN list. + if !slices.Contains(out, kind) { + out = append(out, kind) + } + } for _, t := range types { switch t { - case "Movie": - out = append(out, "video") + case "Movie", "Episode": + add("video") + case "Series": + add("series") + case "Season": + add("season") case "Audio": - out = append(out, "audiobooks", "music") + add("audiobooks") + add("music") case "Book": - out = append(out, "ebooks", "comics") + add("ebooks") + add("comics") case "Photo": - out = append(out, "photos") + add("photos") } } return out } +// EpisodesOnly reports whether a requested set of Jellyfin types means episodes +// and not films. Both are kind 'video', so the episode number is what separates +// them. A Movie anywhere in the list wins, however it is ordered: a client +// browsing a mixed library asks for both and means both. +func EpisodesOnly(types []string) bool { + episode := false + for _, t := range types { + switch t { + case "Episode": + episode = true + case "Movie": + return false + } + } + return episode +} + // ToBaseItemDto renders one ItemView. includeMediaSources is set from the // Fields query parameter; lists normally omit sources to keep responses small. +// +// Television is dispatched to SeriesDto / SeasonDto / EpisodeDto, which are the +// three shapes below; everything else renders here. func ToBaseItemDto(v ItemView, includeMediaSources bool, apiKey string) BaseItemDto { + switch { + case v.Item.Kind == "series": + return SeriesDto(v) + case v.Item.Kind == "season": + return SeasonDto(v) + case v.Item.Kind == "video" && v.Item.EpisodeNumber > 0: + return EpisodeDto(v, includeMediaSources, apiKey) + } + return playableDto(v, includeMediaSources, apiKey) +} + +// playableDto is the shape of everything a client can actually play: the file's +// facts, the ParentId it is browsed under, and the per-user overlay. +func playableDto(v ItemView, includeMediaSources bool, apiKey string) BaseItemDto { dto := BaseItemDto{ Id: ItemID(v.Item.ID), Name: v.Item.Title, - Type: ItemType(v.Item.Kind), + Type: ItemType(v.Item), MediaType: MediaType(v.Item.Kind), Container: v.File.Container, RunTimeTicks: MsToTicks(v.File.DurationMS), @@ -288,17 +363,74 @@ func ToBaseItemDto(v ItemView, includeMediaSources bool, apiKey string) BaseItem // The tag is the item id: stable, and it changes when the item changes. dto.ImageTags = map[string]string{"Primary": ItemID(v.Item.ID)} } - dto.UserData = &UserItemDataDto{ + dto.UserData = userData(v) + if includeMediaSources { + dto.MediaSources = []MediaSourceInfo{BuildMediaSource(v.Item, v.File, apiKey)} + } + return dto +} + +// ── the television tree ───────────────────────────────────────────────── +// +// Jellyfin has no separate series DTO: /Items answers every type with +// BaseItemDto, and a client tells them apart by Type. These three renderers +// exist because the television shapes differ in more than the Type string — a +// series and a season are folders with no file facts, and an episode carries +// the two numbers and a ParentId a client drills back up through. + +// SeriesDto renders a series: a folder with no playable file, a tvshows +// collection so a client opens its television view, and no RunTimeTicks. +func SeriesDto(v ItemView) BaseItemDto { + dto := BaseItemDto{ + Id: ItemID(v.Item.ID), + Name: v.Item.Title, + Type: "Series", + MediaType: MediaType(v.Item.Kind), + CollectionType: CollectionType(v.Item.Kind), + IsFolder: true, + SortName: v.SortTitle, + } + if v.ParentID > 0 { + dto.ParentId = ItemID(v.ParentID) + } + if v.HasCover { + dto.ImageTags = map[string]string{"Primary": ItemID(v.Item.ID)} + } + return dto +} + +// SeasonDto renders a season: a folder, numbered, whose IndexNumber is the +// season number the client sorts by. +func SeasonDto(v ItemView) BaseItemDto { + dto := SeriesDto(v) + dto.Type = "Season" + dto.CollectionType = "" // only a series is a collection + dto.IndexNumber = v.Item.SeasonNumber + return dto +} + +// EpisodeDto renders one episode: a playable item that is this, its episode +// number as IndexNumber, its season as ParentIndexNumber, and its season as +// ParentId so a client can climb back out of the episode list. +func EpisodeDto(v ItemView, includeMediaSources bool, apiKey string) BaseItemDto { + dto := playableDto(v, includeMediaSources, apiKey) + dto.Type = "Episode" + dto.IndexNumber = v.Item.EpisodeNumber + dto.ParentIndexNumber = v.Item.SeasonNumber + return dto +} + +// userData is the per-user overlay, zeroed when the user has never touched the +// item. UserData is always present on a playable item: clients read it +// unconditionally and a missing key is a null reference. +func userData(v ItemView) *UserItemDataDto { + return &UserItemDataDto{ PlaybackPositionTicks: MsToTicks(v.PositionMS), PlayedPercentage: v.Percent * 100, PlayCount: v.PlayCount, IsFavorite: v.Favorite, Played: v.Finished, } - if includeMediaSources { - dto.MediaSources = []MediaSourceInfo{BuildMediaSource(v.Item, v.File, apiKey)} - } - return dto } // BuildMediaSource renders the single direct-play source for an item. It is diff --git a/internal/jellyfin/dto/dto_test.go b/internal/jellyfin/dto/dto_test.go index 6167f89..71a542e 100644 --- a/internal/jellyfin/dto/dto_test.go +++ b/internal/jellyfin/dto/dto_test.go @@ -272,20 +272,34 @@ func TestMediaSourceGoldenJSON(t *testing.T) { // TestKindMappings pins the kind-to-type table. A mis-mapped kind does not // crash: the client just renders an empty library view. +// +// ItemType takes the whole item, not the kind: an episode IS kind 'video', so +// the television cases carry an episode number and the plain 'video' case must +// keep rendering as a Movie. func TestKindMappings(t *testing.T) { - cases := []struct{ kind, item, media, collection string }{ - {"video", "Movie", "Video", "movies"}, - {"audiobooks", "Audio", "Audio", "music"}, - {"music", "Audio", "Audio", "music"}, - {"photos", "Photo", "Photo", "homevideos"}, - {"ebooks", "Book", "Book", "books"}, - {"comics", "Book", "Book", "books"}, - {"games", "Folder", "", ""}, - {"", "Folder", "", ""}, + cases := []struct { + kind string + episode int + item string + media string + collection string + }{ + {kind: "video", item: "Movie", media: "Video", collection: "movies"}, + {kind: "video", episode: 3, item: "Episode", media: "Video", collection: "movies"}, + {kind: "series", item: "Series", media: "Video", collection: "tvshows"}, + {kind: "season", item: "Season", media: "Video", collection: ""}, + {kind: "audiobooks", item: "Audio", media: "Audio", collection: "music"}, + {kind: "music", item: "Audio", media: "Audio", collection: "music"}, + {kind: "photos", item: "Photo", media: "Photo", collection: "homevideos"}, + {kind: "ebooks", item: "Book", media: "Book", collection: "books"}, + {kind: "comics", item: "Book", media: "Book", collection: "books"}, + {kind: "games", item: "Folder", media: "", collection: ""}, + {kind: "", item: "Folder", media: "", collection: ""}, } for _, c := range cases { - if got := ItemType(c.kind); got != c.item { - t.Errorf("ItemType(%q) = %q, want %q", c.kind, got, c.item) + it := catalog.Item{Kind: c.kind, EpisodeNumber: c.episode} + if got := ItemType(it); got != c.item { + t.Errorf("ItemType(%q, episode %d) = %q, want %q", c.kind, c.episode, got, c.item) } if got := MediaType(c.kind); got != c.media { t.Errorf("MediaType(%q) = %q, want %q", c.kind, got, c.media) @@ -303,8 +317,8 @@ func TestKindsForTypes(t *testing.T) { if got := KindsForTypes(nil); got != nil { t.Errorf("KindsForTypes(nil) = %v, want nil", got) } - if got := KindsForTypes([]string{"Episode", "Nonsense"}); got != nil { - t.Errorf("unknown types = %v, want nil", got) + if got := KindsForTypes([]string{"Episode", "Nonsense"}); !slices.Equal(got, []string{"video"}) { + t.Errorf("Episode = %v, want [video]", got) } if got, want := KindsForTypes([]string{"Movie"}), []string{"video"}; !slices.Equal(got, want) { t.Errorf("Movie = %v, want %v", got, want) @@ -312,6 +326,115 @@ func TestKindsForTypes(t *testing.T) { if got, want := KindsForTypes([]string{"Audio"}), []string{"audiobooks", "music"}; !slices.Equal(got, want) { t.Errorf("Audio = %v, want %v", got, want) } + // The standard television query a client sends: series, seasons, episodes + // and films together. Movie and Episode are the same kind, so it appears once. + if got, want := KindsForTypes([]string{"Series", "Season", "Episode", "Movie"}), + []string{"series", "season", "video"}; !slices.Equal(got, want) { + t.Errorf("TV query = %v, want %v", got, want) + } +} + +// TestEpisodesOnly pins the one thing the kind cannot express: an episode and a +// film are both kind 'video', so "Episodes but not Movies" has to be decided +// from the requested types before the query is built. +func TestEpisodesOnly(t *testing.T) { + cases := []struct { + types []string + want bool + }{ + {[]string{"Episode"}, true}, + {[]string{"Series", "Season", "Episode"}, true}, + // A client browsing a mixed library asks for both and wants both. + {[]string{"Episode", "Movie"}, false}, + {[]string{"Movie"}, false}, + {[]string{"Series"}, false}, + {nil, false}, + } + for _, c := range cases { + if got := EpisodesOnly(c.types); got != c.want { + t.Errorf("EpisodesOnly(%v) = %v, want %v", c.types, got, c.want) + } + } +} + +// TestTVShapes pins the three television renderings. A client tells them apart +// by Type alone, so these fields are the contract: a series must be a folder +// with no file facts, and an episode must carry the numbers and the ParentId it +// is browsed under. +func TestTVShapes(t *testing.T) { + series := SeriesDto(ItemView{ + Item: catalog.Item{ID: 1, Kind: "series", Title: "The Wire"}, + SortTitle: "the wire", HasCover: true, + }) + if series.Type != "Series" || !series.IsFolder || series.CollectionType != "tvshows" { + t.Errorf("series dto = %+v", series) + } + if series.Id != ItemID(1) || series.ImageTags["Primary"] != ItemID(1) { + t.Errorf("series id/cover = %+v", series) + } + // A folder carries no playable facts, so a client never asks to stream one. + for _, k := range []string{"RunTimeTicks", "MediaSources", "Container", "UserData"} { + if slices.Contains(jsonKeys(t, series), k) { + t.Errorf("series DTO must not carry %q", k) + } + } + + season := SeasonDto(ItemView{ + Item: catalog.Item{ID: 2, Kind: "season", Title: "Season 1", SeasonNumber: 1}, + SortTitle: "season 1", ParentID: 1, + }) + if season.Type != "Season" || !season.IsFolder || season.IndexNumber != 1 { + t.Errorf("season dto = %+v", season) + } + if season.ParentId != ItemID(1) { + t.Errorf("season ParentId = %q, want the series", season.ParentId) + } + if season.CollectionType != "" { + t.Errorf("season CollectionType = %q, want empty (only a series is a collection)", season.CollectionType) + } + + ep := EpisodeDto(ItemView{ + Item: catalog.Item{ID: 3, Kind: "video", Title: "Pilot", + SeasonNumber: 1, EpisodeNumber: 2}, + File: catalog.File{Path: "The Wire/Season 1/S01E02.mkv", Container: "mkv", DurationMS: 5000}, + ParentID: 2, Percent: 0.5, + }, false, "") + if ep.Type != "Episode" || ep.IsFolder { + t.Errorf("episode dto = %+v", ep) + } + if ep.IndexNumber != 2 || ep.ParentIndexNumber != 1 { + t.Errorf("episode numbers = %d/%d, want 2/1", ep.IndexNumber, ep.ParentIndexNumber) + } + if ep.ParentId != ItemID(2) { + t.Errorf("episode ParentId = %q, want the season", ep.ParentId) + } + if ep.RunTimeTicks != 50_000_000 || ep.Container != "mkv" { + t.Errorf("episode file facts lost: %+v", ep) + } + if ep.UserData == nil || ep.UserData.PlayedPercentage != 50 { + t.Errorf("episode UserData = %+v", ep.UserData) + } +} + +// ToBaseItemDto dispatches on the television kinds, so a list response and a +// single-item response cannot render a series differently. +func TestToBaseItemDtoDispatchesTV(t *testing.T) { + for _, c := range []struct { + kind string + episode int + season int + want string + }{ + {"series", 0, 0, "Series"}, + {"season", 0, 1, "Season"}, + {"video", 4, 1, "Episode"}, + {"video", 0, 0, "Movie"}, + } { + v := ItemView{Item: catalog.Item{ID: 9, Kind: c.kind, EpisodeNumber: c.episode, SeasonNumber: c.season}} + if got := ToBaseItemDto(v, false, ""); got.Type != c.want { + t.Errorf("ToBaseItemDto(%q, episode %d).Type = %q, want %q", c.kind, c.episode, got.Type, c.want) + } + } } // TestLibraryDto: a library is a folder, and its CollectionType is what tells a diff --git a/internal/jellyfin/items.go b/internal/jellyfin/items.go index 4d77d5a..dd348e3 100644 --- a/internal/jellyfin/items.go +++ b/internal/jellyfin/items.go @@ -42,6 +42,7 @@ func (h *Handlers) handleItems(w http.ResponseWriter, r *http.Request, u auth.Us parentRaw := strings.TrimSpace(q.Get("ParentId")) recursive := parseBool(q.Get("Recursive")) search := strings.TrimSpace(q.Get("SearchTerm")) + types := splitCSV(q.Get("IncludeItemTypes")) // Top level: no parent, not recursive, no search. Real clients expect the // user's views here, so the visible libraries are returned as @@ -67,7 +68,8 @@ func (h *Handlers) handleItems(w http.ResponseWriter, r *http.Request, u auth.Us offset: startIndex, searchTerm: search, filters: splitCSV(q.Get("Filters")), - kinds: dto.KindsForTypes(splitCSV(q.Get("IncludeItemTypes"))), + kinds: dto.KindsForTypes(types), + episodesOnly: dto.EpisodesOnly(types), includeSources: containsFold(splitCSV(q.Get("Fields")), "MediaSources"), } diff --git a/internal/jellyfin/jellyfin_test.go b/internal/jellyfin/jellyfin_test.go index 7da296d..a1ad009 100644 --- a/internal/jellyfin/jellyfin_test.go +++ b/internal/jellyfin/jellyfin_test.go @@ -33,6 +33,9 @@ type fixture struct { itemID int64 fileID int64 libID int64 + series int64 + season int64 + episode int64 admin string // token granted string // token, non-admin, granted the library none string // token, non-admin, no grants @@ -73,6 +76,23 @@ func newFixture(t *testing.T) *fixture { vcodec, acodec, width, height) VALUES (?, 'movie.mp4', 20, '1700000000000000000', 'mp4', 5000, 'h264', 'aac', 640, 360)`, itemID) + // A television tree in the same library, written the way the scanner writes + // it: the series and the season are fileless, and the episode is kind + // 'video' with both numbers set. + seriesID := mustExec(t, db, ` + INSERT INTO media_items (library_id, kind, path, title, sort_title) + VALUES (?, 'series', 'The Wire', 'The Wire', 'the wire')`, libID) + seasonID := mustExec(t, db, ` + INSERT INTO media_items (library_id, kind, path, parent_id, title, sort_title, season_number) + VALUES (?, 'season', 'The Wire/Season 1', ?, 'Season 1', 'season 1', 1)`, libID, seriesID) + episodeID := mustExec(t, db, ` + INSERT INTO media_items (library_id, kind, path, parent_id, title, sort_title, + season_number, episode_number) + VALUES (?, 'video', 'The Wire/Season 1/S01E02.mkv', ?, 'Pilot', 'pilot', 1, 2)`, libID, seasonID) + mustExec(t, db, ` + INSERT INTO media_files (item_id, path, size, container, duration_ms) + VALUES (?, 'The Wire/Season 1/S01E02.mkv', 20, 'mkv', 300000)`, episodeID) + // Three users: an admin, a non-admin granted the library, and a non-admin // with no grants at all. hash, err := auth.HashPassword("pw") @@ -101,6 +121,7 @@ func newFixture(t *testing.T) *fixture { return &fixture{ srv: srv, db: db, itemID: itemID, fileID: fileID, libID: libID, + series: seriesID, season: seasonID, episode: episodeID, admin: token(adminID), granted: token(grantedID), none: token(noneID), } } @@ -269,6 +290,121 @@ func TestTranscodeDemandIsRefused(t *testing.T) { } } +// ── the television tree ───────────────────────────────────────────────── + +// items queries /Items and returns the decoded envelope, failing the test on a +// non-200 the way a client would. +func (f *fixture) items(t *testing.T, token, query string) dto.ItemQueryResult { + t.Helper() + resp, body := f.do(t, "GET", "/Items?"+query, "X-Emby-Token", token) + if resp.StatusCode != http.StatusOK { + t.Fatalf("/Items?%s: status = %d (body %s)", query, resp.StatusCode, body) + } + var out dto.ItemQueryResult + if err := json.Unmarshal([]byte(body), &out); err != nil { + t.Fatalf("decode /Items?%s: %v (body %s)", query, err, body) + } + return out +} + +// one returns the only item in a result, failing otherwise: a client rendering a +// show's poster page expects exactly one series back. +func one(t *testing.T, what string, res dto.ItemQueryResult) dto.BaseItemDto { + t.Helper() + if len(res.Items) != 1 { + t.Fatalf("%s returned %d items, want 1", what, len(res.Items)) + } + return res.Items[0] +} + +// A real Jellyfin client browsing television asks for Series, Season and Episode +// and drills ParentId down the tree. Before this, /Items could only answer with +// films and albums: an episode is kind 'video', so a show came back as a pile of +// movies with no numbers and nothing to drill through. +func TestItemsReturnsTheSeriesShape(t *testing.T) { + f := newFixture(t) + + // The television query, exactly as a client spells it. + res := f.items(t, f.granted, "Recursive=true&IncludeItemTypes=Series,Season,Episode&SortBy=SortName") + if res.TotalRecordCount != 3 { + t.Fatalf("TV query returned %d items, want 3 (series, season, episode): %+v", + res.TotalRecordCount, res.Items) + } + + byType := map[string]dto.BaseItemDto{} + for _, it := range res.Items { + byType[it.Type] = it + if it.Type == "Movie" { + t.Error("a film was returned as a television query asked for none") + } + } + + series, ok := byType["Series"] + if !ok { + t.Fatalf("no Series in the response: %+v", res.Items) + } + if series.Id != dto.ItemID(f.series) || series.Name != "The Wire" { + t.Errorf("series id/name = %q/%q", series.Id, series.Name) + } + // A series is a folder in the tvshows collection: that pair is what makes a + // client open its television view instead of an empty generic folder. + if !series.IsFolder || series.CollectionType != "tvshows" || series.MediaType != "Video" { + t.Errorf("series = %+v, want a Video folder with CollectionType tvshows", series) + } + // It has no file of its own, so it must not claim a runtime or a source. + if series.RunTimeTicks != 0 || len(series.MediaSources) != 0 || series.Path != "" { + t.Errorf("series carries file facts it does not have: %+v", series) + } + + season, ok := byType["Season"] + if !ok { + t.Fatalf("no Season in the response: %+v", res.Items) + } + if season.IndexNumber != 1 || season.ParentId != dto.ItemID(f.series) || !season.IsFolder { + t.Errorf("season = %+v, want IndexNumber 1 under the series", season) + } + + episode, ok := byType["Episode"] + if !ok { + t.Fatalf("no Episode in the response: %+v", res.Items) + } + if episode.IndexNumber != 2 || episode.ParentIndexNumber != 1 { + t.Errorf("episode numbers = %d/%d, want 2/1", episode.IndexNumber, episode.ParentIndexNumber) + } + if episode.ParentId != dto.ItemID(f.season) { + t.Errorf("episode ParentId = %q, want the season", episode.ParentId) + } + if episode.IsFolder || episode.RunTimeTicks != 300000*10000 { + t.Errorf("episode = %+v, want a playable item with the file's runtime", episode) + } + + // Drilling works both ways, which is how a client navigates the tree. + seasons := f.items(t, f.granted, "ParentId="+dto.ItemID(f.series)) + if s := one(t, "series' seasons", seasons); s.Type != "Season" { + t.Errorf("series' child = %q, want Season", s.Type) + } + eps := f.items(t, f.granted, "ParentId="+dto.ItemID(f.season)) + if e := one(t, "season's episodes", eps); e.Type != "Episode" || e.IndexNumber != 2 { + t.Errorf("season's child = %+v, want Episode 2", e) + } +} + +// The grant applies to the television tree exactly as it does to a film: a token +// with no grants sees an empty page, never an item it was not given. +func TestItemsSeriesAreGrantScoped(t *testing.T) { + f := newFixture(t) + + for _, q := range []string{ + "Recursive=true&IncludeItemTypes=Series,Season,Episode", + "ParentId=" + dto.ItemID(f.series), + } { + res := f.items(t, f.none, q) + if res.TotalRecordCount != 0 || len(res.Items) != 0 { + t.Errorf("/Items?%s returned %d items to a no-grant token", q, res.TotalRecordCount) + } + } +} + // ── streaming ─────────────────────────────────────────────────────────── // The stream must work with ONLY ?api_key= (media elements cannot set headers) diff --git a/internal/jellyfin/query.go b/internal/jellyfin/query.go index 7bb36f6..52e794e 100644 --- a/internal/jellyfin/query.go +++ b/internal/jellyfin/query.go @@ -44,7 +44,12 @@ const overlayFrom = `FROM media_items i // the per-user overlay. The list and single-item paths share it so the two can // never disagree about what a browse row contains. File facts are attached // separately via catalog.FirstFile. +// +// season_number and episode_number are the television tree (migration 0013) and +// they are load-bearing here: an episode is kind 'video' exactly like a film, so +// the episode number is the only thing that tells a client which is which. const itemColumns = `i.id, i.library_id, i.kind, i.path, i.title, COALESCE(i.year,0), i.missing, ` + + `COALESCE(i.season_number,0), COALESCE(i.episode_number,0), ` + overlayColumns // listQuery is everything the browse query needs. @@ -53,6 +58,7 @@ type listQuery struct { libraryIDs []int64 parentItemID int64 // 0 means "not scoped to an item's children" kinds []string + episodesOnly bool // IncludeItemTypes asked for episodes and not films sortColumn string descending bool limit int @@ -191,7 +197,7 @@ func scanItem(row interface{ Scan(...any) error }) (dto.ItemView, error) { ) err := row.Scan( &v.Item.ID, &v.Item.LibraryID, &v.Item.Kind, &v.Item.Path, &v.Item.Title, - &v.Item.Year, &v.Item.Missing, + &v.Item.Year, &v.Item.Missing, &v.Item.SeasonNumber, &v.Item.EpisodeNumber, &v.SortTitle, &parentID, &v.PositionMS, &v.Percent, &finished, &v.PlayCount, &favUser, &hasCover, @@ -228,6 +234,12 @@ func buildWhere(q listQuery) (string, []any) { clauses = append(clauses, "i.kind IN ("+in+")") args = append(args, inArgs...) } + // An episode and a film are the same kind, so "Episodes, not Movies" is a + // filter on the number rather than the kind — applied only to the video + // rows, or it would throw away the series and seasons in the same query. + if q.episodesOnly { + clauses = append(clauses, "(i.kind <> 'video' OR i.episode_number IS NOT NULL)") + } if q.searchTerm != "" { clauses = append(clauses, "i.title LIKE ?") args = append(args, "%"+q.searchTerm+"%") diff --git a/internal/media/handlers.go b/internal/media/handlers.go index 4eb199e..98c5494 100644 --- a/internal/media/handlers.go +++ b/internal/media/handlers.go @@ -39,6 +39,9 @@ func NewHandlers(db *sql.DB, log *slog.Logger, a *auth.Authenticator, cacheDir s func (h *Handlers) Register(mux *http.ServeMux) { mux.Handle("GET /api/v1/items", h.auth.Require(h.handleListItems)) mux.Handle("GET /api/v1/items/{id}", h.auth.Require(h.handleItem)) + mux.Handle("GET /api/v1/items/{id}/seasons", h.auth.Require(h.handleSeasons)) + mux.Handle("GET /api/v1/items/{id}/episodes", h.auth.Require(h.handleEpisodes)) + mux.Handle("GET /api/v1/items/{id}/next", h.auth.Require(h.handleNextEpisode)) mux.Handle("POST /api/v1/items/{id}/view", h.auth.Require(h.handleRecordView)) mux.Handle("GET /api/v1/items/{id}/authors", h.auth.Require(h.handleAuthors)) mux.Handle("GET /api/v1/items/{id}/file", h.auth.Require(h.handleFile)) @@ -329,9 +332,8 @@ func inClause(ids []int64) (string, []any) { // The load and the access check both live in catalog.LoadFor, so this package // no longer carries its own copy of the "may this user read this item" rule. func (h *Handlers) authorized(w http.ResponseWriter, r *http.Request, u auth.User) (Item, bool) { - id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) - if err != nil || id <= 0 { - httpx.WriteError(w, http.StatusBadRequest, "invalid item id") + id, ok := itemID(w, r) + if !ok { return Item{}, false } @@ -347,6 +349,17 @@ func (h *Handlers) authorized(w http.ResponseWriter, r *http.Request, u auth.Use return it, true } +// itemID parses the {id} path value, writing the 400 itself when it is not a +// positive integer. +func itemID(w http.ResponseWriter, r *http.Request) (int64, bool) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil || id <= 0 { + httpx.WriteError(w, http.StatusBadRequest, "invalid item id") + return 0, false + } + return id, true +} + // files returns an item's physical files with their ffprobe codec facts. // // The shape is catalog.File, whose JSON tags are frozen by MediaFile in diff --git a/internal/media/items.go b/internal/media/items.go index 2c412e9..1247bac 100644 --- a/internal/media/items.go +++ b/internal/media/items.go @@ -16,6 +16,12 @@ type itemRow struct { Path string `json:"path"` Title string `json:"title"` Year int `json:"year"` + + // SeasonNumber and EpisodeNumber are the television tree (migration 0013). + // They are omitted rather than sent as 0 for everything else, so a payload + // for a library with no television in it is byte-for-byte what it was. + SeasonNumber int `json:"season_number,omitempty"` + EpisodeNumber int `json:"episode_number,omitempty"` } // handleListItems lists items across every library the caller may read, @@ -81,7 +87,8 @@ func (h *Handlers) handleListItems(w http.ResponseWriter, r *http.Request, u aut return } - query := `SELECT id, library_id, kind, path, title, COALESCE(year, 0) + query := `SELECT id, library_id, kind, path, title, COALESCE(year, 0), + COALESCE(season_number, 0), COALESCE(episode_number, 0) FROM media_items WHERE ` + where + ` ORDER BY sort_title LIMIT ? OFFSET ?` pageArgs := append(append([]any{}, args...), limit, offset) @@ -96,7 +103,8 @@ func (h *Handlers) handleListItems(w http.ResponseWriter, r *http.Request, u aut items := []itemRow{} for rows.Next() { var it itemRow - if err := rows.Scan(&it.ID, &it.LibraryID, &it.Kind, &it.Path, &it.Title, &it.Year); err != nil { + if err := rows.Scan(&it.ID, &it.LibraryID, &it.Kind, &it.Path, &it.Title, &it.Year, + &it.SeasonNumber, &it.EpisodeNumber); err != nil { h.fail(w, "scan item", err) return } diff --git a/internal/media/series.go b/internal/media/series.go new file mode 100644 index 0000000..8169229 --- /dev/null +++ b/internal/media/series.go @@ -0,0 +1,105 @@ +package media + +// The television tree on the canonical API: a series' seasons, a season's +// episodes, and the next unwatched episode of a series. +// +// They are sub-resources of /api/v1/items/{id}, exactly like /authors and +// /progress, and they speak the same itemRow shape as /api/v1/items. There is +// no television route family to learn and no second shape to handle: a series +// IS an item, a season IS an item, and an episode is an item of kind 'video'. +// +// This file holds HTTP and nothing else. The grant is applied twice over — once +// by h.authorized below, once inside the catalog call — and that is deliberate +// rather than redundant: it is what lets an unreadable series answer exactly +// like a missing one (a 404) while a fully watched series answers differently +// (a 200 with a null episode). Both are the same ErrNotFound by design +// (ADR-005), so the handler cannot tell them apart from the catalog call alone. + +import ( + "errors" + "net/http" + + "github.com/mithral/vellum/internal/auth" + "github.com/mithral/vellum/internal/catalog" + "github.com/mithral/vellum/internal/httpx" +) + +// handleSeasons lists a series' seasons, in season order. +func (h *Handlers) handleSeasons(w http.ResponseWriter, r *http.Request, u auth.User) { + it, ok := h.authorized(w, r, u) + if !ok { + return + } + + seasons, err := catalog.New(h.db).Seasons(r.Context(), u, it.ID) + if err != nil { + h.writeCatalogError(w, "list seasons", err) + return + } + httpx.WriteJSON(w, http.StatusOK, map[string]any{"seasons": rows(seasons)}) +} + +// handleEpisodes lists a season's episodes, in episode order. +func (h *Handlers) handleEpisodes(w http.ResponseWriter, r *http.Request, u auth.User) { + it, ok := h.authorized(w, r, u) + if !ok { + return + } + + eps, err := catalog.New(h.db).Episodes(r.Context(), u, it.ID) + if err != nil { + h.writeCatalogError(w, "list episodes", err) + return + } + httpx.WriteJSON(w, http.StatusOK, map[string]any{"episodes": rows(eps)}) +} + +// handleNextEpisode returns the series' next unwatched episode, or a null one +// when the caller has finished them all. +func (h *Handlers) handleNextEpisode(w http.ResponseWriter, r *http.Request, u auth.User) { + it, ok := h.authorized(w, r, u) + if !ok { + return + } + + ep, err := catalog.New(h.db).NextEpisode(r.Context(), u, it.ID) + if errors.Is(err, catalog.ErrNotFound) { + // Only "caught up" reaches here: an unreadable or absent series was + // already answered with a 404 above. + httpx.WriteJSON(w, http.StatusOK, map[string]any{"episode": nil}) + return + } + if err != nil { + h.fail(w, "next episode", err) + return + } + out := rows([]catalog.Item{ep}) + httpx.WriteJSON(w, http.StatusOK, map[string]any{"episode": out[0]}) +} + +// writeCatalogError turns a catalog error into a response. ErrNotFound is a +// plain 404 — never a 403, because a caller who could tell "not yours" from +// "not there" would learn that another user's library contains this series. It +// is reachable only if the row or the grant vanished between authorized and the +// query below it. +func (h *Handlers) writeCatalogError(w http.ResponseWriter, what string, err error) { + if errors.Is(err, catalog.ErrNotFound) { + httpx.WriteError(w, http.StatusNotFound, "item not found") + return + } + h.fail(w, what, err) +} + +// rows renders catalog items in the browse-list shape, so a listing that came +// through catalog looks identical to one that came through a list query. +func rows(items []catalog.Item) []itemRow { + out := make([]itemRow, 0, len(items)) + for _, it := range items { + out = append(out, itemRow{ + ID: it.ID, LibraryID: it.LibraryID, Kind: it.Kind, Path: it.Path, + Title: it.Title, Year: it.Year, + SeasonNumber: it.SeasonNumber, EpisodeNumber: it.EpisodeNumber, + }) + } + return out +} diff --git a/internal/media/series_test.go b/internal/media/series_test.go new file mode 100644 index 0000000..a1a269f --- /dev/null +++ b/internal/media/series_test.go @@ -0,0 +1,257 @@ +package media + +import ( + "database/sql" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/mithral/vellum/internal/auth" +) + +// These cover the television tree on the canonical API: /api/v1/items/{id}/ +// seasons, /episodes and /next. The grant half matters most — these are the only +// routes that read rows the caller never named. + +type tvTree struct { + h *Handlers + db *sql.DB + user int64 + outsider int64 + series int64 + seasons []int64 // season 1, season 2 + ep1 map[int]int64 + ep2 int64 +} + +// newTVTree writes library -> series -> season 1 (episodes) -> season 2, with the +// children inserted in a useless order, so an ordering bug cannot hide behind a +// lucky insertion sequence. +func newTVTree(t *testing.T) tvTree { + t.Helper() + db := testDB(t) + f := tvTree{db: db, ep1: map[int]int64{}} + lib := newLibrary(t, db, "TV", "video", "/tv") + + f.user = newUser(t, db, "tv-watcher") + f.outsider = newUser(t, db, "tv-outsider") + grantRead(t, db, f.user, lib) + + // row writes one node of the tree the way the scanner writes it: a series or + // a season carries no file, an episode is kind 'video' with both numbers, + // and a zero number is stored as NULL. + row := func(kind, path, title string, parent int64, season, episode int) int64 { + var parentArg, seasonArg, episodeArg any + if parent != 0 { + parentArg = parent + } + if season > 0 { + seasonArg = season + } + if episode > 0 { + episodeArg = episode + } + id := mustQueryID(t, db, ` + INSERT INTO media_items (library_id, kind, path, parent_id, title, sort_title, + season_number, episode_number) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`, + lib, kind, path, parentArg, title, title, seasonArg, episodeArg) + if kind == "video" { + mustExec(t, db, `INSERT INTO media_files (item_id, path, size) VALUES (?, ?, 1)`, id, path) + } + return id + } + + f.series = row("series", "The Wire", "The Wire", 0, 0, 0) + // Season 2 is written first and still has to come back second. + s2 := row("season", "The Wire/Season 2", "Season 2", f.series, 2, 0) + s1 := row("season", "The Wire/Season 1", "Season 1", f.series, 1, 0) + f.seasons = []int64{s1, s2} + + for _, n := range []int{3, 1, 10, 2} { + path := fmt.Sprintf("The Wire/Season 1/S01E%02d.mkv", n) + f.ep1[n] = row("video", path, fmt.Sprintf("Episode %d", n), s1, 1, n) + } + f.ep2 = row("video", "The Wire/Season 2/S02E01.mkv", "Episode 1", s2, 2, 1) + + f.h = mediaHandlers(t, db) + return f +} + +// get drives one of the three television routes. PathValue is normally filled by +// the ServeMux; the test sets it explicitly, as getItem does. +func (f tvTree) get(t *testing.T, u auth.User, id int64, route string) *httptest.ResponseRecorder { + t.Helper() + path := "/api/v1/items/" + itoa(id) + "/" + route + req := httptest.NewRequest(http.MethodGet, path, nil) + req.SetPathValue("id", itoa(id)) + rec := httptest.NewRecorder() + switch route { + case "seasons": + f.h.handleSeasons(rec, req, u) + case "episodes": + f.h.handleEpisodes(rec, req, u) + case "next": + f.h.handleNextEpisode(rec, req, u) + default: + t.Fatalf("unknown route %q", route) + } + return rec +} + +// finish marks an episode watched for a user. +func (f tvTree) finish(t *testing.T, itemID int64) { + t.Helper() + mustExec(t, f.db, `INSERT OR REPLACE INTO progress + (user_id, item_id, file_id, kind, percent, finished) + VALUES (?, ?, (SELECT id FROM media_files WHERE item_id = ?), 'video', 1, 1)`, + f.user, itemID, itemID) +} + +func decode(t *testing.T, rec *httptest.ResponseRecorder, key string, into *[]itemRow) { + t.Helper() + if rec.Code != http.StatusOK { + t.Fatalf("status = %d (body %s)", rec.Code, rec.Body.String()) + } + envelope := map[string][]itemRow{key: nil} + if err := json.Unmarshal(rec.Body.Bytes(), &envelope); err != nil { + t.Fatalf("decode %s: %v (body %s)", key, err, rec.Body.String()) + } + *into = envelope[key] +} + +// A season's episodes come back in EPISODE order. They were inserted 3, 1, 10, 2, +// so returning rows by id puts episode 3 first and returning them by path puts +// episode 10 before episode 2. Only the episode NUMBER is right. +func TestEpisodesRouteIsInEpisodeOrder(t *testing.T) { + f := newTVTree(t) + u := auth.User{ID: f.user} + + var seasons []itemRow + decode(t, f.get(t, u, f.series, "seasons"), "seasons", &seasons) + if len(seasons) != 2 { + t.Fatalf("seasons = %d, want 2", len(seasons)) + } + if seasons[0].ID != f.seasons[0] || seasons[1].ID != f.seasons[1] { + t.Fatalf("seasons = %v, want season 1 (%d) then season 2 (%d)", + idsOf(seasons), f.seasons[0], f.seasons[1]) + } + if seasons[0].SeasonNumber != 1 || seasons[0].Kind != "season" { + t.Errorf("season row = %+v, want kind season numbered 1", seasons[0]) + } + + var eps []itemRow + decode(t, f.get(t, u, f.seasons[0], "episodes"), "episodes", &eps) + want := []int64{f.ep1[1], f.ep1[2], f.ep1[3], f.ep1[10]} + if got := idsOf(eps); len(got) != len(want) { + t.Fatalf("episodes = %v, want %v", got, want) + } else { + for i := range want { + if got[i] != want[i] { + t.Fatalf("episodes = %v, want %v (episode order, not insertion order)", got, want) + } + } + } + for _, ep := range eps { + if ep.Kind != "video" || ep.SeasonNumber != 1 || ep.EpisodeNumber == 0 { + t.Errorf("episode row = %+v, want a season-1 video numbered by episode", ep) + } + } +} + +// The next unwatched episode is the first one, moves on as episodes finish, and +// rolls into season 2 when season 1 is done — then reports nothing rather than +// inventing a row. +func TestNextEpisodeRoute(t *testing.T) { + f := newTVTree(t) + u := auth.User{ID: f.user} + + next := func() *itemRow { + t.Helper() + rec := f.get(t, u, f.series, "next") + if rec.Code != http.StatusOK { + t.Fatalf("next status = %d (body %s)", rec.Code, rec.Body.String()) + } + var out struct { + Episode *itemRow `json:"episode"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatalf("decode next: %v (body %s)", err, rec.Body.String()) + } + return out.Episode + } + + if ep := next(); ep == nil || ep.ID != f.ep1[1] { + t.Fatalf("first next = %+v, want episode 1", ep) + } + + f.finish(t, f.ep1[1]) + if ep := next(); ep == nil || ep.ID != f.ep1[2] { + t.Errorf("next after finishing episode 1 = %+v, want episode 2", ep) + } + + for _, n := range []int{2, 3, 10} { + f.finish(t, f.ep1[n]) + } + if ep := next(); ep == nil || ep.ID != f.ep2 || ep.SeasonNumber != 2 { + t.Errorf("next with season 1 finished = %+v, want season 2's episode", ep) + } + + f.finish(t, f.ep2) + if ep := next(); ep != nil { + t.Errorf("a fully watched series returned %+v, want a null episode", ep) + } +} + +// The security property on this surface: a caller with no grant gets NOTHING, and +// the response is byte-identical to the one for an item that does not exist — a +// 404 that says "item not found", never a 403 that would confirm somebody else's +// series is there. +func TestTVRoutesAreGrantScoped(t *testing.T) { + f := newTVTree(t) + outsider := auth.User{ID: f.outsider} + + for _, tc := range []struct { + name string + id int64 + }{ + {"series", f.series}, + {"season", f.seasons[0]}, + } { + for _, route := range []string{"seasons", "episodes", "next"} { + forbidden := f.get(t, outsider, tc.id, route) + absent := f.get(t, outsider, tc.id+9999, route) + + if forbidden.Code != http.StatusNotFound { + t.Errorf("%s/%s for an ungranted caller: status = %d, want 404", + tc.name, route, forbidden.Code) + } + if forbidden.Body.String() != absent.Body.String() { + t.Errorf("%s/%s: responses differ and leak existence:\n forbidden=%s\n absent=%s", + tc.name, route, forbidden.Body.String(), absent.Body.String()) + } + } + } + + // Positive control: the granted user reads the same tree. + granted := auth.User{ID: f.user} + if rec := f.get(t, granted, f.series, "seasons"); rec.Code != http.StatusOK { + t.Errorf("granted seasons status = %d, want 200", rec.Code) + } + if rec := f.get(t, granted, f.seasons[1], "episodes"); rec.Code != http.StatusOK { + t.Errorf("granted episodes status = %d, want 200", rec.Code) + } + if rec := f.get(t, granted, f.series, "next"); rec.Code != http.StatusOK { + t.Errorf("granted next status = %d, want 200", rec.Code) + } +} + +func idsOf(rows []itemRow) []int64 { + out := make([]int64, len(rows)) + for i, r := range rows { + out[i] = r.ID + } + return out +}