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