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 }