Compare commits

...

2 commits

Author SHA1 Message Date
mithral
2c424ae67c 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.
2026-10-12 01:41:05 -04:00
mithral
8b41c43937 Fix the bare-number episode pattern, which never matched in production
parseEpisode was handed the file's whole relative path, but the bare-number
pattern is anchored at the start of a name. Handed "Show/Season 3/06 -
Title.mkv" it could never match, so every file named that way was filed as a
standalone film at the library root instead of an episode.

Found by running a real scan against a real tree, not by reading the code:
the unit test on parseEpisode passed a bare file name, which is exactly the
input the call site was not providing. The unit test was right and the
caller was wrong, which is a class of bug a helper test cannot see.

parseEpisode now gets path.Base(rel), matching episodeTitle two lines below
it, so the function really is a function of a file name and its directory as
its signature and doc already claimed.

Both scan-level cases are added to the tree test, so the call site is now
covered and not just the helper. Verified red-green: reverting the call site
fails "bare leading number under a Season directory".
2026-10-12 01:41:05 -04:00
14 changed files with 1396 additions and 80 deletions

View file

@ -30,6 +30,12 @@
// writing the whole application's query layer twice. The seam buys a swappable // writing the whole application's query layer twice. The seam buys a swappable
// per-item load; it is not a repository. // 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 // This package holds no HTTP knowledge. It returns typed errors and lets the
// caller choose a status code. // caller choose a status code.
package catalog package catalog
@ -63,6 +69,14 @@ type Item struct {
Year int Year int
Missing bool 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 is the absolute library root, so AbsPath can be computed.
RootPath string RootPath string
@ -111,14 +125,55 @@ type Store interface {
FileByID(ctx context.Context, itemID, fileID int64) (File, error) FileByID(ctx context.Context, itemID, fileID int64) (File, error)
FirstFile(ctx context.Context, itemID int64) (File, error) FirstFile(ctx context.Context, itemID int64) (File, error)
FileCount(ctx context.Context, itemID int64) (int, 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 = ` const itemColumns = `
SELECT i.id, i.library_id, i.kind, i.path, i.title, COALESCE(i.year, 0), i.missing, l.root_path, SELECT i.id, i.library_id, i.kind, i.path, i.title, COALESCE(i.year, 0), i.missing,
COALESCE((SELECT f.mtime FROM media_files f WHERE f.item_id = i.id ORDER BY f.id LIMIT 1), '') 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 FROM media_items i
JOIN libraries l ON l.id = i.library_id JOIN libraries l ON l.id = i.library_id`
WHERE i.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 // SQL is the SQLite implementation of Store, and the only thing in this package
// that knows the query text. // 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 // serves a request: this exists for the scanner, jobs and tests, which have
// already established the right to touch the row. // already established the right to touch the row.
func (s *SQL) Item(ctx context.Context, id int64) (Item, error) { func (s *SQL) Item(ctx context.Context, id int64) (Item, error) {
var ( it, err := scanItem(s.db.QueryRowContext(ctx, itemSelect, id))
it Item switch {
mtime string case errors.Is(err, ErrNotFound):
) // Returned unwrapped on purpose: this message is part of the "absent
err := s.db.QueryRowContext(ctx, itemSelect, id).Scan( // and forbidden are indistinguishable" contract and must stay the same
&it.ID, &it.LibraryID, &it.Kind, &it.Path, &it.Title, // one every caller gets.
&it.Year, &it.Missing, &it.RootPath, &mtime)
if errors.Is(err, sql.ErrNoRows) {
return Item{}, ErrNotFound return Item{}, ErrNotFound
} case err != nil:
if err != nil {
return Item{}, fmt.Errorf("catalog: load item: %w", err) 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 return it, nil
} }
@ -255,6 +304,101 @@ func (s *SQL) FileCount(ctx context.Context, itemID int64) (int, error) {
return n, nil 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 // 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 // callers compile and behave exactly as before. Each one is a one-line
// delegation to the SQL implementation above. // delegation to the SQL implementation above.

View file

@ -4,9 +4,12 @@ import (
"context" "context"
"database/sql" "database/sql"
"errors" "errors"
"fmt"
"io" "io"
"log/slog" "log/slog"
"path/filepath" "path/filepath"
"slices"
"strings"
"testing" "testing"
"github.com/mithral/vellum/internal/auth" "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) { func TestLoadReturnsCodecFacts(t *testing.T) {
db := testDB(t) db := testDB(t)
lib := newLibrary(t, db, "A") lib := newLibrary(t, db, "A")
@ -219,3 +332,155 @@ func TestFileCount(t *testing.T) {
t.Errorf("FileCount = %d, want 1", n) 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)
}
}

View file

@ -25,6 +25,13 @@ type fakeStore struct {
granted map[int64][]int64 // user id -> library ids granted map[int64][]int64 // user id -> library ids
admins map[int64]bool 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 // calls records which methods ran, so the test can prove the caller went
// through the interface rather than reaching for a database. // through the interface rather than reaching for a database.
calls []string calls []string
@ -34,10 +41,11 @@ var _ Store = (*fakeStore)(nil)
func newFakeStore() *fakeStore { func newFakeStore() *fakeStore {
return &fakeStore{ return &fakeStore{
items: map[int64]Item{}, items: map[int64]Item{},
files: map[int64][]File{}, files: map[int64][]File{},
granted: map[int64][]int64{}, granted: map[int64][]int64{},
admins: map[int64]bool{}, 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 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) // 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 // 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. // 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"} granted := auth.User{ID: 1, Username: "granted"}
outsider := auth.User{ID: 2, Username: "outsider"} outsider := auth.User{ID: 2, Username: "outsider"}
s.granted[granted.ID] = []int64{libID} 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 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 // 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. // in-memory Store would be laxer than the SQL one it stands in for.
func TestStoreFileByIDIsScopedToItsItem(t *testing.T) { func TestStoreFileByIDIsScopedToItsItem(t *testing.T) {

View file

@ -14,6 +14,8 @@
package dto package dto
import ( import (
"slices"
"github.com/mithral/vellum/internal/auth" "github.com/mithral/vellum/internal/auth"
"github.com/mithral/vellum/internal/catalog" "github.com/mithral/vellum/internal/catalog"
"github.com/mithral/vellum/internal/library" "github.com/mithral/vellum/internal/library"
@ -67,24 +69,32 @@ type UserItemDataDto struct {
} }
// BaseItemDto is the browse shape. Type is the Jellyfin item type // BaseItemDto is the browse shape. Type is the Jellyfin item type
// ("Movie"/"Audio"/"Book"/"Photo"/"CollectionFolder"/"Folder"), mapped from // ("Movie"/"Audio"/"Book"/"Photo"/"Series"/"Season"/"Episode"/"Folder"), mapped
// Vellum's media kind. // from Vellum's media kind.
type BaseItemDto struct { type BaseItemDto struct {
Id string `json:"Id"` Id string `json:"Id"`
Name string `json:"Name"` Name string `json:"Name"`
Type string `json:"Type"` Type string `json:"Type"`
MediaType string `json:"MediaType,omitempty"` MediaType string `json:"MediaType,omitempty"`
CollectionType string `json:"CollectionType,omitempty"` CollectionType string `json:"CollectionType,omitempty"`
Container string `json:"Container,omitempty"` Container string `json:"Container,omitempty"`
RunTimeTicks int64 `json:"RunTimeTicks,omitempty"` RunTimeTicks int64 `json:"RunTimeTicks,omitempty"`
ProductionYear int `json:"ProductionYear,omitempty"` ProductionYear int `json:"ProductionYear,omitempty"`
ParentId string `json:"ParentId,omitempty"` ParentId string `json:"ParentId,omitempty"`
IsFolder bool `json:"IsFolder"` IsFolder bool `json:"IsFolder"`
SortName string `json:"SortName,omitempty"` SortName string `json:"SortName,omitempty"`
Path string `json:"Path,omitempty"` Path string `json:"Path,omitempty"`
ImageTags map[string]string `json:"ImageTags,omitempty"`
UserData *UserItemDataDto `json:"UserData,omitempty"` // IndexNumber is the number within the parent — an episode number for an
MediaSources []MediaSourceInfo `json:"MediaSources,omitempty"` // 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 // MediaSourceInfo is one playable source. Vellum never transcodes, so
@ -194,12 +204,22 @@ type ItemView struct {
Favorite bool Favorite bool
} }
// ItemType maps a Vellum media kind to the Jellyfin item type. The four types // ItemType maps a Vellum media item to the Jellyfin item type.
// the task requires are Movie, Audio, Book and Photo; anything Vellum adds //
// later (games) degrades to Folder so a client can still show it. // It takes the item, not the kind, because television is not a kind: an EPISODE
func ItemType(kind string) string { // stays kind='video' (migration 0013) and is told from a film by its episode
switch kind { // 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": case "video":
if it.EpisodeNumber > 0 {
return "Episode"
}
return "Movie" return "Movie"
case "audiobooks", "music": case "audiobooks", "music":
return "Audio" return "Audio"
@ -213,10 +233,11 @@ func ItemType(kind string) string {
} }
// MediaType maps a Vellum media kind to the Jellyfin MediaType. It is the // 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 { func MediaType(kind string) string {
switch kind { switch kind {
case "video": case "video", "series", "season":
return "Video" return "Video"
case "audiobooks", "music": case "audiobooks", "music":
return "Audio" return "Audio"
@ -230,9 +251,12 @@ func MediaType(kind string) string {
} }
// CollectionType maps a Vellum kind to the Jellyfin collection type, which // 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 { func CollectionType(kind string) string {
switch kind { switch kind {
case "series":
return "tvshows"
case "video": case "video":
return "movies" return "movies"
case "audiobooks", "music": case "audiobooks", "music":
@ -247,32 +271,83 @@ func CollectionType(kind string) string {
} }
// KindsForTypes maps requested Jellyfin IncludeItemTypes back to Vellum kinds, // 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. // 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 { func KindsForTypes(types []string) []string {
var out []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 { for _, t := range types {
switch t { switch t {
case "Movie": case "Movie", "Episode":
out = append(out, "video") add("video")
case "Series":
add("series")
case "Season":
add("season")
case "Audio": case "Audio":
out = append(out, "audiobooks", "music") add("audiobooks")
add("music")
case "Book": case "Book":
out = append(out, "ebooks", "comics") add("ebooks")
add("comics")
case "Photo": case "Photo":
out = append(out, "photos") add("photos")
} }
} }
return out 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 // ToBaseItemDto renders one ItemView. includeMediaSources is set from the
// Fields query parameter; lists normally omit sources to keep responses small. // 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 { 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{ dto := BaseItemDto{
Id: ItemID(v.Item.ID), Id: ItemID(v.Item.ID),
Name: v.Item.Title, Name: v.Item.Title,
Type: ItemType(v.Item.Kind), Type: ItemType(v.Item),
MediaType: MediaType(v.Item.Kind), MediaType: MediaType(v.Item.Kind),
Container: v.File.Container, Container: v.File.Container,
RunTimeTicks: MsToTicks(v.File.DurationMS), 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. // 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.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), PlaybackPositionTicks: MsToTicks(v.PositionMS),
PlayedPercentage: v.Percent * 100, PlayedPercentage: v.Percent * 100,
PlayCount: v.PlayCount, PlayCount: v.PlayCount,
IsFavorite: v.Favorite, IsFavorite: v.Favorite,
Played: v.Finished, 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 // BuildMediaSource renders the single direct-play source for an item. It is

View file

@ -272,20 +272,34 @@ func TestMediaSourceGoldenJSON(t *testing.T) {
// TestKindMappings pins the kind-to-type table. A mis-mapped kind does not // TestKindMappings pins the kind-to-type table. A mis-mapped kind does not
// crash: the client just renders an empty library view. // 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) { func TestKindMappings(t *testing.T) {
cases := []struct{ kind, item, media, collection string }{ cases := []struct {
{"video", "Movie", "Video", "movies"}, kind string
{"audiobooks", "Audio", "Audio", "music"}, episode int
{"music", "Audio", "Audio", "music"}, item string
{"photos", "Photo", "Photo", "homevideos"}, media string
{"ebooks", "Book", "Book", "books"}, collection string
{"comics", "Book", "Book", "books"}, }{
{"games", "Folder", "", ""}, {kind: "video", item: "Movie", media: "Video", collection: "movies"},
{"", "Folder", "", ""}, {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 { for _, c := range cases {
if got := ItemType(c.kind); got != c.item { it := catalog.Item{Kind: c.kind, EpisodeNumber: c.episode}
t.Errorf("ItemType(%q) = %q, want %q", c.kind, got, c.item) 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 { if got := MediaType(c.kind); got != c.media {
t.Errorf("MediaType(%q) = %q, want %q", 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 { if got := KindsForTypes(nil); got != nil {
t.Errorf("KindsForTypes(nil) = %v, want nil", got) t.Errorf("KindsForTypes(nil) = %v, want nil", got)
} }
if got := KindsForTypes([]string{"Episode", "Nonsense"}); got != nil { if got := KindsForTypes([]string{"Episode", "Nonsense"}); !slices.Equal(got, []string{"video"}) {
t.Errorf("unknown types = %v, want nil", got) t.Errorf("Episode = %v, want [video]", got)
} }
if got, want := KindsForTypes([]string{"Movie"}), []string{"video"}; !slices.Equal(got, want) { if got, want := KindsForTypes([]string{"Movie"}), []string{"video"}; !slices.Equal(got, want) {
t.Errorf("Movie = %v, want %v", 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) { if got, want := KindsForTypes([]string{"Audio"}), []string{"audiobooks", "music"}; !slices.Equal(got, want) {
t.Errorf("Audio = %v, want %v", 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 // TestLibraryDto: a library is a folder, and its CollectionType is what tells a

View file

@ -42,6 +42,7 @@ func (h *Handlers) handleItems(w http.ResponseWriter, r *http.Request, u auth.Us
parentRaw := strings.TrimSpace(q.Get("ParentId")) parentRaw := strings.TrimSpace(q.Get("ParentId"))
recursive := parseBool(q.Get("Recursive")) recursive := parseBool(q.Get("Recursive"))
search := strings.TrimSpace(q.Get("SearchTerm")) search := strings.TrimSpace(q.Get("SearchTerm"))
types := splitCSV(q.Get("IncludeItemTypes"))
// Top level: no parent, not recursive, no search. Real clients expect the // Top level: no parent, not recursive, no search. Real clients expect the
// user's views here, so the visible libraries are returned as // 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, offset: startIndex,
searchTerm: search, searchTerm: search,
filters: splitCSV(q.Get("Filters")), 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"), includeSources: containsFold(splitCSV(q.Get("Fields")), "MediaSources"),
} }

View file

@ -33,6 +33,9 @@ type fixture struct {
itemID int64 itemID int64
fileID int64 fileID int64
libID int64 libID int64
series int64
season int64
episode int64
admin string // token admin string // token
granted string // token, non-admin, granted the library granted string // token, non-admin, granted the library
none string // token, non-admin, no grants none string // token, non-admin, no grants
@ -73,6 +76,23 @@ func newFixture(t *testing.T) *fixture {
vcodec, acodec, width, height) vcodec, acodec, width, height)
VALUES (?, 'movie.mp4', 20, '1700000000000000000', 'mp4', 5000, 'h264', 'aac', 640, 360)`, itemID) 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 // Three users: an admin, a non-admin granted the library, and a non-admin
// with no grants at all. // with no grants at all.
hash, err := auth.HashPassword("pw") hash, err := auth.HashPassword("pw")
@ -101,6 +121,7 @@ func newFixture(t *testing.T) *fixture {
return &fixture{ return &fixture{
srv: srv, db: db, itemID: itemID, fileID: fileID, libID: libID, 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), 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 ─────────────────────────────────────────────────────────── // ── streaming ───────────────────────────────────────────────────────────
// The stream must work with ONLY ?api_key= (media elements cannot set headers) // The stream must work with ONLY ?api_key= (media elements cannot set headers)

View file

@ -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 // 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 // never disagree about what a browse row contains. File facts are attached
// separately via catalog.FirstFile. // 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, ` + 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 overlayColumns
// listQuery is everything the browse query needs. // listQuery is everything the browse query needs.
@ -53,6 +58,7 @@ type listQuery struct {
libraryIDs []int64 libraryIDs []int64
parentItemID int64 // 0 means "not scoped to an item's children" parentItemID int64 // 0 means "not scoped to an item's children"
kinds []string kinds []string
episodesOnly bool // IncludeItemTypes asked for episodes and not films
sortColumn string sortColumn string
descending bool descending bool
limit int limit int
@ -191,7 +197,7 @@ func scanItem(row interface{ Scan(...any) error }) (dto.ItemView, error) {
) )
err := row.Scan( err := row.Scan(
&v.Item.ID, &v.Item.LibraryID, &v.Item.Kind, &v.Item.Path, &v.Item.Title, &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.SortTitle, &parentID,
&v.PositionMS, &v.Percent, &finished, &v.PositionMS, &v.Percent, &finished,
&v.PlayCount, &favUser, &hasCover, &v.PlayCount, &favUser, &hasCover,
@ -228,6 +234,12 @@ func buildWhere(q listQuery) (string, []any) {
clauses = append(clauses, "i.kind IN ("+in+")") clauses = append(clauses, "i.kind IN ("+in+")")
args = append(args, inArgs...) 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 != "" { if q.searchTerm != "" {
clauses = append(clauses, "i.title LIKE ?") clauses = append(clauses, "i.title LIKE ?")
args = append(args, "%"+q.searchTerm+"%") args = append(args, "%"+q.searchTerm+"%")

View file

@ -39,6 +39,9 @@ func NewHandlers(db *sql.DB, log *slog.Logger, a *auth.Authenticator, cacheDir s
func (h *Handlers) Register(mux *http.ServeMux) { func (h *Handlers) Register(mux *http.ServeMux) {
mux.Handle("GET /api/v1/items", h.auth.Require(h.handleListItems)) 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}", 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("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}/authors", h.auth.Require(h.handleAuthors))
mux.Handle("GET /api/v1/items/{id}/file", h.auth.Require(h.handleFile)) 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 // 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. // 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) { func (h *Handlers) authorized(w http.ResponseWriter, r *http.Request, u auth.User) (Item, bool) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) id, ok := itemID(w, r)
if err != nil || id <= 0 { if !ok {
httpx.WriteError(w, http.StatusBadRequest, "invalid item id")
return Item{}, false return Item{}, false
} }
@ -347,6 +349,17 @@ func (h *Handlers) authorized(w http.ResponseWriter, r *http.Request, u auth.Use
return it, true 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. // 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 // The shape is catalog.File, whose JSON tags are frozen by MediaFile in

View file

@ -16,6 +16,12 @@ type itemRow struct {
Path string `json:"path"` Path string `json:"path"`
Title string `json:"title"` Title string `json:"title"`
Year int `json:"year"` 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, // 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 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 FROM media_items
WHERE ` + where + ` ORDER BY sort_title LIMIT ? OFFSET ?` WHERE ` + where + ` ORDER BY sort_title LIMIT ? OFFSET ?`
pageArgs := append(append([]any{}, args...), 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{} items := []itemRow{}
for rows.Next() { for rows.Next() {
var it itemRow 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) h.fail(w, "scan item", err)
return return
} }

105
internal/media/series.go Normal file
View file

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

View file

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

View file

@ -478,7 +478,11 @@ func buildTVGroups(lib library.Library, found map[string]candidate) map[string]g
c := found[rel] c := found[rel]
dir := path.Dir(rel) dir := path.Dir(rel)
season, episode, isEpisode := parseEpisode(c.relPath, path.Base(dir)) // The base name, not the relative path: parseEpisode is a function of a
// file name and its directory, and the leading-number pattern is anchored
// at the start. Handed "Show/Season 1/05 - Title.mkv" it would never
// match, which is how the bare-number case was silently missed.
season, episode, isEpisode := parseEpisode(path.Base(rel), path.Base(dir))
if !isEpisode { if !isEpisode {
g := groups[rel] g := groups[rel]
g.key, g.title, g.kind = rel, library.CleanTitle(rel), lib.Kind g.key, g.title, g.kind = rel, library.CleanTitle(rel), lib.Kind

View file

@ -519,6 +519,30 @@ func TestScanParsesEpisodes(t *testing.T) {
{kind: "video", path: "Fargo/Season 4/Fargo.S01E01.mkv", title: "Fargo", parent: "Fargo/Season 4", season: 4, episode: 1}, {kind: "video", path: "Fargo/Season 4/Fargo.S01E01.mkv", title: "Fargo", parent: "Fargo/Season 4", season: 4, episode: 1},
}, },
}, },
{
// A bare leading number, which older rips and anime use. This case
// exists because the pattern is anchored at the start of the name:
// handed the whole relative path it silently never matched, and only
// a scan-level test catches that - a unit test on parseEpisode passes
// a bare file name and cannot see it.
name: "bare leading number under a Season directory",
file: "The Wire/Season 3/06 - Title.mkv",
want: []item{
{kind: "series", path: "The Wire", title: "The Wire"},
{kind: "season", path: "The Wire/Season 3", title: "Season 3", parent: "The Wire", season: 3},
{kind: "video", path: "The Wire/Season 3/06 - Title.mkv", title: "06 - Title", parent: "The Wire/Season 3", season: 3, episode: 6},
},
},
{
// The anime shape: an episode with no season in the name at all.
name: "episode-only marker under a Season directory",
file: "Show/Season 2/E05.mkv",
want: []item{
{kind: "series", path: "Show", title: "Show"},
{kind: "season", path: "Show/Season 2", title: "Season 2", parent: "Show", season: 2},
{kind: "video", path: "Show/Season 2/E05.mkv", title: "E05", parent: "Show/Season 2", season: 2, episode: 5},
},
},
} }
for _, tc := range tests { for _, tc := range tests {