Compare commits

..

No commits in common. "2c424ae67cc1147e93b74c1259e17c846074efa2" and "6545240430c46bdba04842232cae687011cbf584" have entirely different histories.

14 changed files with 80 additions and 1396 deletions

View file

@ -30,12 +30,6 @@
// 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
@ -69,14 +63,6 @@ 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
@ -125,55 +111,14 @@ 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 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)
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), '')
FROM media_items i
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
}
JOIN libraries l ON l.id = i.library_id
WHERE i.id = ?`
// SQL is the SQLite implementation of Store, and the only thing in this package
// that knows the query text.
@ -189,16 +134,22 @@ 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) {
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.
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) {
return Item{}, ErrNotFound
case err != nil:
}
if 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
}
@ -304,101 +255,6 @@ 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.

View file

@ -4,12 +4,9 @@ import (
"context"
"database/sql"
"errors"
"fmt"
"io"
"log/slog"
"path/filepath"
"slices"
"strings"
"testing"
"github.com/mithral/vellum/internal/auth"
@ -91,116 +88,6 @@ 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")
@ -332,155 +219,3 @@ 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)
}
}

View file

@ -25,13 +25,6 @@ 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
@ -41,11 +34,10 @@ 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{},
children: map[int64][]Item{},
items: map[int64]Item{},
files: map[int64][]File{},
granted: map[int64][]int64{},
admins: map[int64]bool{},
}
}
@ -117,44 +109,6 @@ 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.
@ -182,15 +136,6 @@ 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
}
@ -244,42 +189,6 @@ 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) {

View file

@ -14,8 +14,6 @@
package dto
import (
"slices"
"github.com/mithral/vellum/internal/auth"
"github.com/mithral/vellum/internal/catalog"
"github.com/mithral/vellum/internal/library"
@ -69,32 +67,24 @@ type UserItemDataDto struct {
}
// BaseItemDto is the browse shape. Type is the Jellyfin item type
// ("Movie"/"Audio"/"Book"/"Photo"/"Series"/"Season"/"Episode"/"Folder"), mapped
// from Vellum's media kind.
// ("Movie"/"Audio"/"Book"/"Photo"/"CollectionFolder"/"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"`
// 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"`
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"`
}
// MediaSourceInfo is one playable source. Vellum never transcodes, so
@ -204,22 +194,12 @@ type ItemView struct {
Favorite bool
}
// 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"
// 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 {
case "video":
if it.EpisodeNumber > 0 {
return "Episode"
}
return "Movie"
case "audiobooks", "music":
return "Audio"
@ -233,11 +213,10 @@ func ItemType(it catalog.Item) string {
}
// MediaType maps a Vellum media kind to the Jellyfin MediaType. It is the
// 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.
// coarser classification ("Video"/"Audio"/"Book"/"Photo").
func MediaType(kind string) string {
switch kind {
case "video", "series", "season":
case "video":
return "Video"
case "audiobooks", "music":
return "Audio"
@ -251,12 +230,9 @@ func MediaType(kind string) string {
}
// CollectionType maps a Vellum kind to the Jellyfin collection type, which
// 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.
// tells a client how to render the folder.
func CollectionType(kind string) string {
switch kind {
case "series":
return "tvshows"
case "video":
return "movies"
case "audiobooks", "music":
@ -271,83 +247,32 @@ func CollectionType(kind string) string {
}
// KindsForTypes maps requested Jellyfin IncludeItemTypes back to Vellum kinds,
// so a client asking for Series does not receive films. An unknown type maps
// so a client asking for Movies does not receive ebooks. 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", "Episode":
add("video")
case "Series":
add("series")
case "Season":
add("season")
case "Movie":
out = append(out, "video")
case "Audio":
add("audiobooks")
add("music")
out = append(out, "audiobooks", "music")
case "Book":
add("ebooks")
add("comics")
out = append(out, "ebooks", "comics")
case "Photo":
add("photos")
out = append(out, "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),
Type: ItemType(v.Item.Kind),
MediaType: MediaType(v.Item.Kind),
Container: v.File.Container,
RunTimeTicks: MsToTicks(v.File.DurationMS),
@ -363,74 +288,17 @@ func playableDto(v ItemView, includeMediaSources bool, apiKey string) BaseItemDt
// 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 = 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{
dto.UserData = &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

View file

@ -272,34 +272,20 @@ 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 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: ""},
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", "", ""},
}
for _, c := range cases {
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 := ItemType(c.kind); got != c.item {
t.Errorf("ItemType(%q) = %q, want %q", c.kind, got, c.item)
}
if got := MediaType(c.kind); got != c.media {
t.Errorf("MediaType(%q) = %q, want %q", c.kind, got, c.media)
@ -317,8 +303,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"}); !slices.Equal(got, []string{"video"}) {
t.Errorf("Episode = %v, want [video]", got)
if got := KindsForTypes([]string{"Episode", "Nonsense"}); got != nil {
t.Errorf("unknown types = %v, want nil", got)
}
if got, want := KindsForTypes([]string{"Movie"}), []string{"video"}; !slices.Equal(got, want) {
t.Errorf("Movie = %v, want %v", got, want)
@ -326,115 +312,6 @@ 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

View file

@ -42,7 +42,6 @@ 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
@ -68,8 +67,7 @@ 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(types),
episodesOnly: dto.EpisodesOnly(types),
kinds: dto.KindsForTypes(splitCSV(q.Get("IncludeItemTypes"))),
includeSources: containsFold(splitCSV(q.Get("Fields")), "MediaSources"),
}

View file

@ -33,9 +33,6 @@ 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
@ -76,23 +73,6 @@ 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")
@ -121,7 +101,6 @@ 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),
}
}
@ -290,121 +269,6 @@ 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)

View file

@ -44,12 +44,7 @@ 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.
@ -58,7 +53,6 @@ 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
@ -197,7 +191,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.SeasonNumber, &v.Item.EpisodeNumber,
&v.Item.Year, &v.Item.Missing,
&v.SortTitle, &parentID,
&v.PositionMS, &v.Percent, &finished,
&v.PlayCount, &favUser, &hasCover,
@ -234,12 +228,6 @@ 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+"%")

View file

@ -39,9 +39,6 @@ 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))
@ -332,8 +329,9 @@ 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, ok := itemID(w, r)
if !ok {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || id <= 0 {
httpx.WriteError(w, http.StatusBadRequest, "invalid item id")
return Item{}, false
}
@ -349,17 +347,6 @@ 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

View file

@ -16,12 +16,6 @@ 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,
@ -87,8 +81,7 @@ func (h *Handlers) handleListItems(w http.ResponseWriter, r *http.Request, u aut
return
}
query := `SELECT id, library_id, kind, path, title, COALESCE(year, 0),
COALESCE(season_number, 0), COALESCE(episode_number, 0)
query := `SELECT id, library_id, kind, path, title, COALESCE(year, 0)
FROM media_items
WHERE ` + where + ` ORDER BY sort_title LIMIT ? OFFSET ?`
pageArgs := append(append([]any{}, args...), limit, offset)
@ -103,8 +96,7 @@ 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,
&it.SeasonNumber, &it.EpisodeNumber); err != nil {
if err := rows.Scan(&it.ID, &it.LibraryID, &it.Kind, &it.Path, &it.Title, &it.Year); err != nil {
h.fail(w, "scan item", err)
return
}

View file

@ -1,105 +0,0 @@
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

@ -1,257 +0,0 @@
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,11 +478,7 @@ func buildTVGroups(lib library.Library, found map[string]candidate) map[string]g
c := found[rel]
dir := path.Dir(rel)
// 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))
season, episode, isEpisode := parseEpisode(c.relPath, path.Base(dir))
if !isEpisode {
g := groups[rel]
g.key, g.title, g.kind = rel, library.CleanTitle(rel), lib.Kind

View file

@ -519,30 +519,6 @@ func TestScanParsesEpisodes(t *testing.T) {
{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 {