Compare commits
No commits in common. "2c424ae67cc1147e93b74c1259e17c846074efa2" and "6545240430c46bdba04842232cae687011cbf584" have entirely different histories.
2c424ae67c
...
6545240430
14 changed files with 80 additions and 1396 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -45,7 +38,6 @@ func newFakeStore() *fakeStore {
|
|||
files: map[int64][]File{},
|
||||
granted: map[int64][]int64{},
|
||||
admins: map[int64]bool{},
|
||||
children: map[int64][]Item{},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,8 +67,8 @@ 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"`
|
||||
|
|
@ -84,14 +82,6 @@ type BaseItemDto struct {
|
|||
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"`
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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+"%")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue