The scanner builds library -> series -> season -> episode; this makes it
reachable. Three reads, all grant-checked through catalog.Store (ADR-005):
a series' seasons, a season's episodes, and the next unwatched episode.
Each loads the parent through ItemFor first, so forbidden and absent are the
same ErrNotFound, and the child query is scoped to the parent's own
library_id so a corrupt parent_id cannot cross a library boundary. Verified
red-green: swapping ItemFor for Item returns two rows to an ungranted
caller.
Episodes come back in episode order, not insertion order, and the ordering
is verified red-green too - dropping the clause yields [3 1 10 2].
Routes stay inside the existing family: /api/v1/items/{id}/seasons,
/episodes and /next. No parallel route surface.
Jellyfin gains SeriesDto, SeasonDto and EpisodeDto. Jellyfin's wire type is
BaseItemDto, so these carry the folder/file split plus IndexNumber and
ParentIndexNumber. ItemType now takes the item rather than the kind, because
an episode IS kind video - that is the one signature change, and the dto
tests were updated with it.
Verified end-to-end against a running server: a library holding S01E02,
1x02, a bare-number file and a film produced two series, three seasons and
six episodes, with the film left alone; the routes returned the seasons in
order, the episodes in episode order, and the next unwatched episode.
Note for later: Subsonic and the web UI are untouched, so a series browses
there as it did before. The scanner also has no separate 'tv' kind, so a
video library mixing films and shows builds both shapes side by side.
257 lines
8.1 KiB
Go
257 lines
8.1 KiB
Go
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
|
|
}
|