Vellum/internal/jellyfin/items.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

265 lines
7.6 KiB
Go

package jellyfin
import (
"context"
"database/sql"
"errors"
"net/http"
"strings"
"github.com/mithral/vellum/internal/auth"
"github.com/mithral/vellum/internal/catalog"
"github.com/mithral/vellum/internal/httpx"
"github.com/mithral/vellum/internal/jellyfin/dto"
"github.com/mithral/vellum/internal/library"
"github.com/mithral/vellum/internal/stream"
)
// handleItems is the browse workhorse for GET /Items. The /Users/{userId}/Items
// form delegates here; the token's user is authoritative for scoping.
func (h *Handlers) handleItems(w http.ResponseWriter, r *http.Request, u auth.User) {
ctx := r.Context()
q := r.URL.Query()
libs, err := auth.VisibleLibraryIDs(ctx, h.db, u)
if err != nil {
h.fail(w, "visible libraries", err)
return
}
if libs == nil {
libs = []int64{}
}
startIndex := atoiDefault(q.Get("StartIndex"), 0)
if startIndex < 0 {
startIndex = 0
}
limit := atoiDefault(q.Get("Limit"), defaultLimit)
if limit <= 0 || limit > maxLimit {
limit = maxLimit
}
parentRaw := strings.TrimSpace(q.Get("ParentId"))
recursive := parseBool(q.Get("Recursive"))
search := strings.TrimSpace(q.Get("SearchTerm"))
types := splitCSV(q.Get("IncludeItemTypes"))
// Top level: no parent, not recursive, no search. Real clients expect the
// user's views here, so the visible libraries are returned as
// CollectionFolder items to drill into.
if parentRaw == "" && !recursive && search == "" {
h.writeViews(w, ctx, libs, startIndex, limit)
return
}
// A user with no grants gets an empty page, never an error and never
// content from another library.
if len(libs) == 0 {
writeItems(w, startIndex, 0, nil)
return
}
query := listQuery{
userID: u.ID,
libraryIDs: libs,
sortColumn: sortColumn(q.Get("SortBy")),
descending: isDescending(q.Get("SortOrder")),
limit: limit,
offset: startIndex,
searchTerm: search,
filters: splitCSV(q.Get("Filters")),
kinds: dto.KindsForTypes(types),
episodesOnly: dto.EpisodesOnly(types),
includeSources: containsFold(splitCSV(q.Get("Fields")), "MediaSources"),
}
if parentRaw != "" {
pid, isLib, ok := dto.DecodeID(parentRaw)
if !ok {
// An unknown ParentId is an empty page, not an error: clients probe
// ids and expect to be told there is nothing there.
writeItems(w, startIndex, 0, nil)
return
}
if isLib {
if !containsInt64(libs, pid) {
writeItems(w, startIndex, 0, nil)
return
}
query.libraryIDs = []int64{pid}
} else {
// Children of an item, and only if that item is visible.
visible, err := h.itemVisible(ctx, u, pid)
if err != nil {
h.fail(w, "check parent", err)
return
}
if !visible {
writeItems(w, startIndex, 0, nil)
return
}
query.parentItemID = pid
}
}
items, total, err := h.queryItems(ctx, query)
if err != nil {
h.fail(w, "list items", err)
return
}
apiKey := tokenFromRequest(r)
dtos := make([]dto.BaseItemDto, 0, len(items))
for _, it := range items {
dtos = append(dtos, dto.ToBaseItemDto(it, query.includeSources, apiKey))
}
writeItems(w, startIndex, total, dtos)
}
// handleUserItems serves GET /Users/{userId}/Items. The token's user is
// authoritative; a mismatched path id is a 404 (cross-user reads are not
// implemented — see the package notes).
func (h *Handlers) handleUserItems(w http.ResponseWriter, r *http.Request, u auth.User) {
id, _, ok := dto.DecodeID(r.PathValue("userId"))
if !ok || id != u.ID {
httpx.WriteError(w, http.StatusNotFound, "user not found")
return
}
h.handleItems(w, r, u)
}
// handleItem serves one BaseItemDto. A library id is answered as a folder so a
// client that treats views as items still works.
func (h *Handlers) handleItem(w http.ResponseWriter, r *http.Request, u auth.User) {
id, isLib, ok := dto.DecodeID(r.PathValue("itemId"))
if !ok {
httpx.WriteError(w, http.StatusNotFound, "item not found")
return
}
if isLib {
lib, err := library.Get(r.Context(), h.db, id)
if errors.Is(err, library.ErrNotFound) {
httpx.WriteError(w, http.StatusNotFound, "item not found")
return
}
if err != nil {
h.fail(w, "load library", err)
return
}
allowed, err := auth.CanAccessLibrary(r.Context(), h.db, u, id)
if err != nil {
h.fail(w, "check access", err)
return
}
if !allowed {
httpx.WriteError(w, http.StatusNotFound, "item not found")
return
}
httpx.WriteJSON(w, http.StatusOK, dto.LibraryDto(lib))
return
}
it, ok := h.authorizedItem(w, r, u, id)
if !ok {
return
}
v := dto.ItemView{Item: it}
if err := h.attachFile(r.Context(), &v); err != nil {
h.fail(w, "load file", err)
return
}
if err := h.loadOverlay(r.Context(), u.ID, it.ID, &v); err != nil {
if errors.Is(err, sql.ErrNoRows) {
httpx.WriteError(w, http.StatusNotFound, "item not found")
return
}
h.fail(w, "load item state", err)
return
}
httpx.WriteJSON(w, http.StatusOK, dto.ToBaseItemDto(v, true, tokenFromRequest(r)))
}
// handleImagePrimary streams the cover bytes the scanner/web already extracted.
//
// It does not trigger extraction: NewHandlers has no cache directory, and the
// extraction path belongs to the web layer. An item whose cover has not been
// extracted yet simply 404s, which clients treat as "no artwork".
func (h *Handlers) handleImagePrimary(w http.ResponseWriter, r *http.Request, u auth.User) {
id, isLib, ok := dto.DecodeID(r.PathValue("itemId"))
if !ok || isLib {
httpx.WriteError(w, http.StatusNotFound, "no cover available")
return
}
if _, ok := h.authorizedItem(w, r, u, id); !ok {
return
}
var path string
err := h.db.QueryRowContext(r.Context(),
`SELECT path FROM assets WHERE item_id = ? AND role = 'cover' LIMIT 1`, id).Scan(&path)
if errors.Is(err, sql.ErrNoRows) {
httpx.WriteError(w, http.StatusNotFound, "no cover available")
return
}
if err != nil {
h.fail(w, "load cover", err)
return
}
w.Header().Set("Cache-Control", "public, max-age=86400")
if err := stream.ServeFile(w, r, path); err != nil {
httpx.WriteError(w, http.StatusNotFound, "no cover available")
return
}
}
// authorizedItem loads an item and confirms access, writing a 404 (never a 403,
// so existence is not disclosed) on failure. Both the load and the grant check
// live in catalog.LoadFor, which returns the same ErrNotFound for a missing
// item and an unreadable one.
func (h *Handlers) authorizedItem(w http.ResponseWriter, r *http.Request, u auth.User, id int64) (catalog.Item, bool) {
it, err := catalog.LoadFor(r.Context(), h.db, u, id)
if errors.Is(err, catalog.ErrNotFound) {
httpx.WriteError(w, http.StatusNotFound, "item not found")
return catalog.Item{}, false
}
if err != nil {
h.fail(w, "load item", err)
return catalog.Item{}, false
}
return it, true
}
// writeViews returns the caller's visible libraries as CollectionFolder items,
// paginated like any other browse result.
func (h *Handlers) writeViews(w http.ResponseWriter, ctx context.Context, libs []int64, startIndex, limit int) {
list, err := library.ListVisible(ctx, h.db, libs)
if err != nil {
h.fail(w, "list libraries", err)
return
}
total := len(list)
if startIndex > total {
startIndex = total
}
end := startIndex + limit
if end > total {
end = total
}
items := make([]dto.BaseItemDto, 0, end-startIndex)
for _, lib := range list[startIndex:end] {
items = append(items, dto.LibraryDto(lib))
}
writeItems(w, startIndex, total, items)
}
func writeItems(w http.ResponseWriter, startIndex, total int, items []dto.BaseItemDto) {
if items == nil {
items = []dto.BaseItemDto{}
}
httpx.WriteJSON(w, http.StatusOK, dto.ItemQueryResult{
Items: items,
TotalRecordCount: total,
StartIndex: startIndex,
})
}