Vellum/internal/media/series.go
mithral 2c424ae67c Expose the television tree: catalog reads, API routes, Jellyfin shapes
The scanner builds library -> series -> season -> episode; this makes it
reachable. Three reads, all grant-checked through catalog.Store (ADR-005):
a series' seasons, a season's episodes, and the next unwatched episode.

Each loads the parent through ItemFor first, so forbidden and absent are the
same ErrNotFound, and the child query is scoped to the parent's own
library_id so a corrupt parent_id cannot cross a library boundary. Verified
red-green: swapping ItemFor for Item returns two rows to an ungranted
caller.

Episodes come back in episode order, not insertion order, and the ordering
is verified red-green too - dropping the clause yields [3 1 10 2].

Routes stay inside the existing family: /api/v1/items/{id}/seasons,
/episodes and /next. No parallel route surface.

Jellyfin gains SeriesDto, SeasonDto and EpisodeDto. Jellyfin's wire type is
BaseItemDto, so these carry the folder/file split plus IndexNumber and
ParentIndexNumber. ItemType now takes the item rather than the kind, because
an episode IS kind video - that is the one signature change, and the dto
tests were updated with it.

Verified end-to-end against a running server: a library holding S01E02,
1x02, a bare-number file and a film produced two series, three seasons and
six episodes, with the film left alone; the routes returned the seasons in
order, the episodes in episode order, and the next unwatched episode.

Note for later: Subsonic and the web UI are untouched, so a series browses
there as it did before. The scanner also has no separate 'tv' kind, so a
video library mixing films and shows builds both shapes side by side.
2026-10-12 01:41:05 -04:00

105 lines
3.7 KiB
Go

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
}