Vellum/internal/media/handlers.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

396 lines
11 KiB
Go

package media
import (
"database/sql"
"errors"
"log/slog"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/mithral/vellum/internal/auth"
"github.com/mithral/vellum/internal/catalog"
"github.com/mithral/vellum/internal/home"
"github.com/mithral/vellum/internal/httpx"
"github.com/mithral/vellum/internal/progress"
"github.com/mithral/vellum/internal/stream"
)
// Handlers serves the generic per-item endpoints.
type Handlers struct {
db *sql.DB
log *slog.Logger
auth *auth.Authenticator
cacheDir string
}
// NewHandlers builds the media HTTP layer. cacheDir is where covers and
// extracted pages are written.
func NewHandlers(db *sql.DB, log *slog.Logger, a *auth.Authenticator, cacheDir string) *Handlers {
return &Handlers{db: db, log: log, auth: a, cacheDir: cacheDir}
}
// Register mounts the routes.
//
// The home screen is not registered here: it is an aggregate over items, not an
// item resource, and it lives in internal/home.
func (h *Handlers) Register(mux *http.ServeMux) {
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}/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("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}/files/{fileID}", h.auth.Require(h.handleFileByID))
mux.Handle("GET /api/v1/items/{id}/cover", h.auth.Require(h.handleCover))
mux.Handle("GET /api/v1/items/{id}/progress", h.auth.Require(h.handleGetProgress))
mux.Handle("PUT /api/v1/items/{id}/progress", h.auth.Require(h.handlePutProgress))
mux.Handle("GET /api/v1/progress/recent", h.auth.Require(h.handleRecent))
}
func (h *Handlers) handleItem(w http.ResponseWriter, r *http.Request, u auth.User) {
it, ok := h.authorized(w, r, u)
if !ok {
return
}
files, err := h.files(r, it.ID)
if err != nil {
h.fail(w, "list files", err)
return
}
prog, has, err := progress.Get(r.Context(), h.db, u.ID, it.ID)
if err != nil {
h.fail(w, "get progress", err)
return
}
authors, err := h.authors(r, it.ID)
if err != nil {
h.fail(w, "authors", err)
return
}
out := map[string]any{
"item": map[string]any{
"id": it.ID,
"library_id": it.LibraryID,
"kind": it.Kind,
"path": it.Path,
"title": it.Title,
"year": it.Year,
"missing": it.Missing,
},
"files": files,
"has_cover": h.hasCover(r, it),
"authors": authors,
}
if has {
out["progress"] = prog
}
httpx.WriteJSON(w, http.StatusOK, out)
}
// authors returns the credited people for an item.
func (h *Handlers) authors(r *http.Request, itemID int64) ([]string, error) {
rows, err := h.db.QueryContext(r.Context(), `
SELECT p.name FROM item_people ip
JOIN people p ON p.id = ip.person_id
WHERE ip.item_id = ? ORDER BY ip.ord`, itemID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []string{}
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, err
}
out = append(out, name)
}
return out, rows.Err()
}
func (h *Handlers) handleFile(w http.ResponseWriter, r *http.Request, u auth.User) {
it, ok := h.authorized(w, r, u)
if !ok {
return
}
// Toolbar download vs inline. Inline is the default for readers.
disposition := "inline"
if r.URL.Query().Get("download") == "1" {
disposition = "attachment"
allowed, err := auth.CanAccessLibrary(r.Context(), h.db, u, it.LibraryID)
if err != nil || !allowed {
httpx.WriteError(w, http.StatusForbidden, "download not permitted")
return
}
}
w.Header().Set("Content-Disposition", disposition+`; filename="`+sanitizeFilename(it.Path)+`"`)
if err := stream.ServeFile(w, r, it.AbsPath()); err != nil {
if errors.Is(err, os.ErrNotExist) {
httpx.WriteError(w, http.StatusNotFound, "file is not available")
return
}
if errors.Is(err, stream.ErrNotAFile) {
httpx.WriteError(w, http.StatusBadRequest, "item is not a file")
return
}
h.fail(w, "serve file", err)
return
}
}
// handleFileByID streams one specific file of an item, for multi-file items
// such as audiobook chapters or album tracks.
func (h *Handlers) handleFileByID(w http.ResponseWriter, r *http.Request, u auth.User) {
it, ok := h.authorized(w, r, u)
if !ok {
return
}
fileID, err := strconv.ParseInt(r.PathValue("fileID"), 10, 64)
if err != nil || fileID <= 0 {
httpx.WriteError(w, http.StatusBadRequest, "invalid file id")
return
}
// Confirm the file belongs to this item, so an item id cannot be paired
// with another item's file id.
f, err := catalog.FileByID(r.Context(), h.db, it.ID, fileID)
if errors.Is(err, catalog.ErrNotFound) {
httpx.WriteError(w, http.StatusNotFound, "file not found")
return
}
if err != nil {
h.fail(w, "load file", err)
return
}
abs := filepath.Join(it.RootPath, filepath.FromSlash(f.Path))
w.Header().Set("Content-Disposition", "inline")
if err := stream.ServeFile(w, r, abs); err != nil {
if errors.Is(err, os.ErrNotExist) {
httpx.WriteError(w, http.StatusNotFound, "file is not available")
return
}
h.fail(w, "serve file", err)
return
}
}
func (h *Handlers) handleCover(w http.ResponseWriter, r *http.Request, u auth.User) {
it, ok := h.authorized(w, r, u)
if !ok {
return
}
path, found, err := CoverPath(r.Context(), h.db, it, h.cacheDir)
if err != nil {
h.fail(w, "cover", err)
return
}
if !found {
httpx.WriteError(w, http.StatusNotFound, "no cover available")
return
}
w.Header().Set("Cache-Control", "public, max-age=86400")
if err := stream.ServeFile(w, r, path); err != nil {
h.fail(w, "serve cover", err)
return
}
}
func (h *Handlers) handleGetProgress(w http.ResponseWriter, r *http.Request, u auth.User) {
it, ok := h.authorized(w, r, u)
if !ok {
return
}
prog, found, err := progress.Get(r.Context(), h.db, u.ID, it.ID)
if err != nil {
h.fail(w, "get progress", err)
return
}
if !found {
httpx.WriteJSON(w, http.StatusOK, map[string]any{"progress": nil})
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"progress": prog})
}
func (h *Handlers) handlePutProgress(w http.ResponseWriter, r *http.Request, u auth.User) {
it, ok := h.authorized(w, r, u)
if !ok {
return
}
var req struct {
FileID int64 `json:"file_id"`
Position *int64 `json:"position"`
Duration int64 `json:"duration"`
Percent float64 `json:"percent"`
Finished bool `json:"finished"`
}
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
p := progress.Progress{
ItemID: it.ID,
FileID: req.FileID,
Kind: it.Kind,
Duration: req.Duration,
Percent: req.Percent,
Finished: req.Finished,
}
if req.Position != nil {
p.Position = *req.Position
}
saved, err := progress.Put(r.Context(), h.db, u.ID, p)
if errors.Is(err, progress.ErrNoFile) {
httpx.WriteError(w, http.StatusConflict, "item has no file to track progress against")
return
}
if err != nil {
h.fail(w, "save progress", err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"progress": saved})
}
func (h *Handlers) handleRecent(w http.ResponseWriter, r *http.Request, u auth.User) {
items, err := progress.RecentlyUpdated(r.Context(), h.db, u.ID, 20)
if err != nil {
h.fail(w, "recent progress", err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"progress": items})
}
// handleRecordView increments the user's view count for an item.
//
// The counting SQL lives in internal/home, which owns the shelves that read
// these numbers back; only the item-scoped route stays here.
func (h *Handlers) handleRecordView(w http.ResponseWriter, r *http.Request, u auth.User) {
it, ok := h.authorized(w, r, u)
if !ok {
return
}
views, err := home.RecordView(r.Context(), h.db, u.ID, it.ID)
if err != nil {
h.fail(w, "record view", err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"item_id": it.ID, "views": views})
}
// handleAuthors returns the people credited on an item.
func (h *Handlers) handleAuthors(w http.ResponseWriter, r *http.Request, u auth.User) {
it, ok := h.authorized(w, r, u)
if !ok {
return
}
authors, err := h.authors(r, it.ID)
if err != nil {
h.fail(w, "authors", err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"authors": authors})
}
// ── helpers ────────────────────────────────────────────────────────────
// inClause builds "?,?,?" plus the matching args for an IN list.
//
// Duplicated from internal/home on purpose: a browse list is an item resource,
// and importing the home-screen package to borrow a query-string helper would
// invert the dependency for three lines.
func inClause(ids []int64) (string, []any) {
placeholders := make([]string, len(ids))
args := make([]any, len(ids))
for i, id := range ids {
placeholders[i] = "?"
args[i] = id
}
return strings.Join(placeholders, ","), args
}
// authorized loads an item and confirms the caller may read its library.
// On failure it has already written the response.
//
// 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.
func (h *Handlers) authorized(w http.ResponseWriter, r *http.Request, u auth.User) (Item, bool) {
id, ok := itemID(w, r)
if !ok {
return Item{}, false
}
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 Item{}, false
}
if err != nil {
h.fail(w, "load item", err)
return Item{}, false
}
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.
//
// The shape is catalog.File, whose JSON tags are frozen by MediaFile in
// web/src/lib/api.ts. The server reports what is IN the file and never
// transcodes; each client decides whether it can direct-play it.
func (h *Handlers) files(r *http.Request, itemID int64) ([]catalog.File, error) {
return catalog.Files(r.Context(), h.db, itemID)
}
func (h *Handlers) hasCover(r *http.Request, it Item) bool {
var n int
_ = h.db.QueryRowContext(r.Context(),
`SELECT count(*) FROM assets WHERE item_id = ? AND role = 'cover'`, it.ID).Scan(&n)
return n > 0
}
func (h *Handlers) fail(w http.ResponseWriter, what string, err error) {
h.log.Error(what, "err", err)
httpx.WriteError(w, http.StatusInternalServerError, "internal server error")
}
// sanitizeFilename strips characters that break a Content-Disposition header.
func sanitizeFilename(name string) string {
out := make([]rune, 0, len(name))
for _, r := range name {
switch {
case r == '"' || r == '\\' || r == '\n' || r == '\r':
out = append(out, '_')
default:
out = append(out, r)
}
}
return string(out)
}