Vellum/internal/library/handlers.go
mithral 67f145a9b1 Waves 7-8: playback engine selection, and missing files you can see
Wave 7 - engines, chosen per file by the client's own capabilities
- internal/engine: a registry. html5 is built in and declares a CONSERVATIVE
  browser baseline (h264/vp8/vp9/av1, deliberately no hevc and no mpeg4, because
  that is what was measured). mpv, vlc and mdk are registered as native engines
  declaring the broad support those libraries really have.
- The server never declares a file playable. It reports what is in the file and
  matches it against the engines the CLIENT declares. Proven:
      engines=["html5"]       -> playable=false engine=null reason=vcodec:hevc
      engines=["html5","mpv"] -> playable=true  engine=mpv
  Same file, different answer, because the client offered a different engine.
  That is what makes adding a native client a registration rather than a rewrite.
- internal/playback: GET /playback/engines and POST /playback/resolve, behind
  auth and the same grant chokepoint. An unreadable item is 404, not 403.
- web/src/lib/capabilities.ts: uses navigator.mediaCapabilities.decodingInfo(),
  which returns {supported, smooth, powerEfficient}. That is real client-hardware
  detection and it distinguishes "cannot decode" from "can decode but it will
  stutter and drain the battery". Feature-detected; degrades to the server
  baseline rather than throwing.
- VideoPlayer consults the server as an ADVISORY only. The element remains the
  source of truth: the server's note appears first, then the element's own
  confirmation replaces it once videoWidth proves the answer. Verified on HEVC —
  "Playback may not work here" at 0-906ms, "Video cannot play here" from ~1208ms.

Wave 8 - missing files: visibility and an explicit purge
- migrations/0011 adds missing_since to media_items and media_files.
- It is written only on the TRANSITION into missing, so the age says when the
  file first vanished rather than the last rescan. Proven byte-identical across a
  rescan, and cleared when a file is restored.
- GET /api/v1/missing (grant-scoped) and POST /api/v1/libraries/{id}/purge
  (admin only). The purge deletes only items that are missing AND have no
  present file, and its cascade into progress/item_people/item_views/game_saves
  is enumerated in a comment — that cascade is exactly why deletion is never
  automatic. Proven: 19 items -> 18, only the missing one, the non-missing item
  untouched and its files intact, second purge returns 0.
- MissingFiles.svelte in the Manage nav: title, kind, library, path, human age,
  and a per-library Purge behind a confirmation that names the count and says
  progress cannot be regenerated.

Coordinator wired playback.NewHandlers into main.go (the one line agents were
told to leave alone).

Verified end to end: 17 packages pass, gofmt/vet clean, svelte-check 0/0, and
every claim above checked against the running server.
2026-10-11 14:36:55 -04:00

339 lines
10 KiB
Go

package library
import (
"database/sql"
"encoding/json"
"errors"
"log/slog"
"net/http"
"strconv"
"github.com/mithral/vellum/internal/auth"
"github.com/mithral/vellum/internal/httpx"
"github.com/mithral/vellum/internal/jobs"
)
// ScanJobKind is the queue job that scans a library.
const ScanJobKind = "scan_library"
// ScanPayload is the job payload for a scan.
type ScanPayload struct {
LibraryID int64 `json:"library_id"`
}
// Handlers serves library management and scanning endpoints.
type Handlers struct {
db *sql.DB
log *slog.Logger
auth *auth.Authenticator
queue *jobs.Queue
}
// NewHandlers builds the library HTTP layer.
func NewHandlers(db *sql.DB, log *slog.Logger, a *auth.Authenticator, q *jobs.Queue) *Handlers {
return &Handlers{db: db, log: log, auth: a, queue: q}
}
// Register mounts the routes.
func (h *Handlers) Register(mux *http.ServeMux) {
mux.HandleFunc("GET /api/v1/media-types", h.handleMediaTypes)
mux.Handle("GET /api/v1/libraries", h.auth.Require(h.handleList))
mux.Handle("GET /api/v1/libraries/{id}", h.auth.Require(h.handleGet))
mux.Handle("GET /api/v1/libraries/{id}/items", h.auth.Require(h.handleListItems))
mux.Handle("GET /api/v1/libraries/{id}/scan/status", h.auth.RequireAuth(http.HandlerFunc(h.handleScanStatus)))
mux.Handle("GET /api/v1/missing", h.auth.Require(h.handleMissing))
mux.Handle("POST /api/v1/libraries", h.auth.RequireAdmin(http.HandlerFunc(h.handleCreate)))
mux.Handle("DELETE /api/v1/libraries/{id}", h.auth.RequireAdmin(http.HandlerFunc(h.handleDelete)))
mux.Handle("POST /api/v1/libraries/{id}/scan", h.auth.RequireAdmin(http.HandlerFunc(h.handleScan)))
mux.Handle("POST /api/v1/libraries/{id}/purge", h.auth.RequireAdmin(http.HandlerFunc(h.handlePurge)))
}
// handleMediaTypes is public: the UI needs the list before login to render.
func (h *Handlers) handleMediaTypes(w http.ResponseWriter, r *http.Request) {
httpx.WriteJSON(w, http.StatusOK, map[string]any{"media_types": SupportedTypes()})
}
func (h *Handlers) handleList(w http.ResponseWriter, r *http.Request, u auth.User) {
ids, err := auth.VisibleLibraryIDs(r.Context(), h.db, u)
if err != nil {
h.fail(w, "visible libraries", err)
return
}
libs, err := ListVisible(r.Context(), h.db, ids)
if err != nil {
h.fail(w, "list libraries", err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"libraries": libs})
}
func (h *Handlers) handleGet(w http.ResponseWriter, r *http.Request, u auth.User) {
id, ok := pathInt(w, r, "id")
if !ok {
return
}
allowed, err := auth.CanAccessLibrary(r.Context(), h.db, u, id)
if err != nil {
h.fail(w, "check access", err)
return
}
if !allowed {
// 404 rather than 403: an unauthorised user should not learn that a
// library exists.
httpx.WriteError(w, http.StatusNotFound, "library not found")
return
}
lib, err := Get(r.Context(), h.db, id)
if errors.Is(err, ErrNotFound) {
httpx.WriteError(w, http.StatusNotFound, "library not found")
return
}
if err != nil {
h.fail(w, "get library", err)
return
}
count, _ := CountItems(r.Context(), h.db, id)
httpx.WriteJSON(w, http.StatusOK, map[string]any{
"library": lib,
"item_count": count,
"reachable": RootExists(lib),
})
}
func (h *Handlers) handleCreate(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
Kind string `json:"kind"`
RootPath string `json:"root_path"`
}
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
lib, err := Create(r.Context(), h.db, req.Name, req.Kind, req.RootPath)
switch {
case errors.Is(err, ErrNameExists):
httpx.WriteError(w, http.StatusConflict, "a library with that name already exists")
return
case errors.Is(err, ErrInvalidRoot), errors.Is(err, ErrUnknownKind):
httpx.WriteError(w, http.StatusBadRequest, err.Error())
return
case err != nil:
h.fail(w, "create library", err)
return
}
h.log.Info("library created", "name", lib.Name, "kind", lib.Kind, "path", lib.RootPath)
httpx.WriteJSON(w, http.StatusCreated, map[string]any{"library": lib})
}
func (h *Handlers) handleDelete(w http.ResponseWriter, r *http.Request) {
id, ok := pathInt(w, r, "id")
if !ok {
return
}
if err := Delete(r.Context(), h.db, id); errors.Is(err, ErrNotFound) {
httpx.WriteError(w, http.StatusNotFound, "library not found")
return
} else if err != nil {
h.fail(w, "delete library", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handlers) handleScan(w http.ResponseWriter, r *http.Request) {
id, ok := pathInt(w, r, "id")
if !ok {
return
}
lib, err := Get(r.Context(), h.db, id)
if errors.Is(err, ErrNotFound) {
httpx.WriteError(w, http.StatusNotFound, "library not found")
return
}
if err != nil {
h.fail(w, "get library", err)
return
}
// Queued rather than run inline: a real library takes longer than any
// sensible request timeout, and a hanging request is indistinguishable
// from a broken server.
jobID, err := h.queue.EnqueueRef(r.Context(), ScanJobKind, lib.ID, ScanPayload{LibraryID: lib.ID})
if err != nil {
h.fail(w, "enqueue scan", err)
return
}
h.log.Info("scan queued", "library", lib.Name, "job", jobID)
httpx.WriteJSON(w, http.StatusAccepted, map[string]any{"queued": 1, "job_id": jobID})
}
// handleScanStatus reports the most recent scan for a library, so the UI can
// show progress instead of hanging on the request.
func (h *Handlers) handleScanStatus(w http.ResponseWriter, r *http.Request) {
id, ok := pathInt(w, r, "id")
if !ok {
return
}
var state, result, errMsg string
err := h.db.QueryRowContext(r.Context(), `
SELECT state, result, error FROM jobs
WHERE kind = ? AND ref_id = ?
ORDER BY id DESC LIMIT 1`, ScanJobKind, id).Scan(&state, &result, &errMsg)
if errors.Is(err, sql.ErrNoRows) {
httpx.WriteJSON(w, http.StatusOK, map[string]any{"state": "idle"})
return
}
if err != nil {
h.fail(w, "scan status", err)
return
}
out := map[string]any{"state": state}
if result != "" {
out["result"] = json.RawMessage(result)
}
if errMsg != "" {
out["error"] = errMsg
}
httpx.WriteJSON(w, http.StatusOK, out)
}
// handleListItems returns items in a library the caller may read.
func (h *Handlers) handleListItems(w http.ResponseWriter, r *http.Request, u auth.User) {
id, ok := pathInt(w, r, "id")
if !ok {
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, "library not found")
return
}
limit := 100
if v := r.URL.Query().Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 500 {
limit = n
}
}
rows, err := h.db.QueryContext(r.Context(), `
SELECT id, library_id, kind, path, title, COALESCE(year, 0), added_at
FROM media_items
WHERE library_id = ? AND missing = 0
ORDER BY sort_title
LIMIT ?`, id, limit)
if err != nil {
h.fail(w, "list items", err)
return
}
defer rows.Close()
type itemDTO struct {
ID int64 `json:"id"`
LibraryID int64 `json:"library_id"`
Kind string `json:"kind"`
Path string `json:"path"`
Title string `json:"title"`
Year int `json:"year"`
AddedAt string `json:"added_at"`
}
items := []itemDTO{}
for rows.Next() {
var it itemDTO
if err := rows.Scan(&it.ID, &it.LibraryID, &it.Kind, &it.Path, &it.Title, &it.Year, &it.AddedAt); err != nil {
h.fail(w, "scan items", err)
return
}
items = append(items, it)
}
if err := rows.Err(); err != nil {
h.fail(w, "iterate items", err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"items": items, "limit": limit})
}
// handleMissing lists the files the scanner has flagged missing in the
// libraries the caller may read. An optional ?library_id= narrows the result
// to a single library, and an id outside the caller's visible set simply
// yields nothing rather than leaking that the library exists.
func (h *Handlers) handleMissing(w http.ResponseWriter, r *http.Request, u auth.User) {
ids, err := auth.VisibleLibraryIDs(r.Context(), h.db, u)
if err != nil {
h.fail(w, "visible libraries", err)
return
}
if v := r.URL.Query().Get("library_id"); v != "" {
id, err := strconv.ParseInt(v, 10, 64)
if err != nil || id <= 0 {
httpx.WriteError(w, http.StatusBadRequest, "invalid library_id")
return
}
ids = narrowToLibrary(ids, id)
}
entries, err := ListMissing(r.Context(), h.db, ids)
if err != nil {
h.fail(w, "list missing", err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"items": entries, "total": len(entries)})
}
// handlePurge deletes a library's genuinely missing items on explicit admin
// request. It is admin-only and never runs automatically: the delete cascades
// into progress, which cannot be regenerated. See PurgeMissing for the full
// cascade and the exact "actually missing" condition.
func (h *Handlers) handlePurge(w http.ResponseWriter, r *http.Request) {
id, ok := pathInt(w, r, "id")
if !ok {
return
}
n, err := PurgeMissing(r.Context(), h.db, id)
if err != nil {
h.fail(w, "purge missing", err)
return
}
h.log.Info("purged missing items", "library", id, "count", n)
httpx.WriteJSON(w, http.StatusOK, map[string]any{"purged": n})
}
// ── helpers ────────────────────────────────────────────────────────────
// narrowToLibrary keeps only id when the caller asks for one library. An id
// outside the visible set yields an empty set, so a non-admin cannot probe a
// library they cannot read.
func narrowToLibrary(ids []int64, id int64) []int64 {
for _, v := range ids {
if v == id {
return []int64{id}
}
}
return []int64{}
}
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")
}
func pathInt(w http.ResponseWriter, r *http.Request, name string) (int64, bool) {
n, err := strconv.ParseInt(r.PathValue(name), 10, 64)
if err != nil || n <= 0 {
httpx.WriteError(w, http.StatusBadRequest, "invalid "+name)
return 0, false
}
return n, true
}