Vellum/internal/media/handlers.go
mithral 5ea7ffe6c6 Serve transcoded streams over HLS
Third step of ADR-008. Two routes inside the item family: the playlist starts
the session lazily and serves what ffmpeg has written so far, so playback can
begin while the rest still encodes; the segment route serves one .ts.

Segment names are validated against the exact shape ffmpeg writes before being
used as a path. Verified red-green: removing the check serves a file from
outside the session directory with a 200.

Two corrections made in review.

1. The manager was being built inside NewHandlers, which meant nothing could
   ever shut it down. Its reaper would run for the process lifetime and a
   running ffmpeg would outlive the server that started it. It is now created
   in main.go beside the job queue and deferred, and passed in - the same
   ownership the queue already has.

2. A playlist request could 404 before ffmpeg wrote the file, which a native
   HLS player treats as fatal rather than retrying. The playlist handler now
   waits up to four seconds for the first write. Segments are never waited for.

Verified end-to-end against a running server with real hardware: a 30-second
file produced a valid EVENT playlist in about a second (the wait doing its
job - no 404), a 658 KB segment with Content-Type video/mp2t, a 404 for a
traversal attempt, and a 401 unauthenticated.

Also confirmed at boot: all four metadata providers report their disabled
state and name their env var, including the new adult one.
2026-10-12 12:00:20 -04:00

411 lines
12 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
sessions Sessions
}
// NewHandlers builds the media HTTP layer. cacheDir is where covers, extracted
// pages and transcode sessions are written.
//
// sessions is the transcode manager, owned by the caller so it can be shut
// down. It is a parameter rather than being built here for exactly that reason:
// a manager built in this constructor has an idle reaper running for the
// process lifetime with nobody able to stop it, and a running ffmpeg would
// outlive the server that started it. A nil value falls back to a fresh
// manager, which is what a test wants.
func NewHandlers(db *sql.DB, log *slog.Logger, a *auth.Authenticator, cacheDir string, sessions Sessions) *Handlers {
if sessions == nil {
sessions = newSessions()
}
return &Handlers{db: db, log: log, auth: a, cacheDir: cacheDir, sessions: sessions}
}
// 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}/subtitles", h.auth.Require(h.handleSubtitles))
mux.Handle("GET /api/v1/items/{id}/subtitles/{trackID}", h.auth.Require(h.handleSubtitleTrack))
mux.Handle("GET /api/v1/items/{id}/transcode/playlist.m3u8", h.auth.Require(h.handleTranscodePlaylist))
mux.Handle("GET /api/v1/items/{id}/transcode/{segment}", h.auth.Require(h.handleTranscodeSegment))
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)
}