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 +}