Vellum/internal/playback/handlers.go
mithral 8bd6b8a417 Phase 3: a storage seam behind catalog, and one consumer threaded onto it
EXPAND ONLY, per the migration order (expand, migrate, verify, contract). No
Postgres implementation, no behaviour change.

internal/catalog now exposes:

  type Store interface {
      Item / ItemFor / Files / FilesFor / FileByID / FirstFile / FileCount
  }
  type SQL struct{ db *sql.DB }; func New(db *sql.DB) *SQL

The seven bodies are the old functions moved verbatim. ItemFor still returns the
same ErrNotFound for absent and forbidden, so the security property is
unchanged. A compile-time `var _ Store = (*SQL)(nil)` keeps the implementation
honest.

Deliberately NOT moved: browse lists, home shelves and subsonic album queries.
Those are each package's own presentation concern, and pulling all of them into
the seam is exactly the 186-statement rewrite the seam exists to avoid. The
package doc says so.

Proven substitutable, not just declared: store_test.go implements a fake Store
in memory with no database at all and drives a caller through the interface. A
seam nobody can substitute proves nothing.

The agent's own risk note was right and I acted on it: the seam only pays off
once something actually threads it, and initially nothing did. So internal/
playback — the smallest consumer — now takes a catalog.Store instead of *sql.DB
and is wired with catalog.New(db) at the composition point. It has no database
reference left outside a comment. The remaining consumers (media, jellyfin,
subsonic, metadata) still call the package-level wrappers; threading them is
mechanical, and I would rather do it deliberately than in one sweep.

Verified: gofmt clean, build exit 0, vet exit 0, 25 packages pass, arch rules
pass. End to end against a running server: /playback/resolve still returns
vcodec:hevc for html5 and engine mpv when mpv is offered, an unknown item 404s,
/home and Jellyfin /Items still answer.

Known gap, unchanged by this commit: internal/arch does not enforce "new code
must not take *sql.DB", so the seam can erode by drift. That rule would be the
natural next step if this is to hold.
2026-10-11 16:16:07 -04:00

106 lines
3.9 KiB
Go

// Package playback exposes the server side of direct-play selection: the list
// of playback engines and the decision of whether a particular item can be
// direct-played by the engines a client declares.
//
// Vellum does not transcode. The server reports what is in each file (codec
// facts written by the scanner) and matches it against the client's declared
// engines; the client is the only party that knows its own hardware. The web
// app declares "html5"; a native client declares "mpv"/"vlc"/"mdk" as well.
package playback
import (
"errors"
"log/slog"
"net/http"
"github.com/mithral/vellum/internal/auth"
"github.com/mithral/vellum/internal/catalog"
"github.com/mithral/vellum/internal/engine"
"github.com/mithral/vellum/internal/httpx"
)
// Handlers serves the playback selection endpoints.
type Handlers struct {
// items is the catalog seam. This package talks to a Store rather than to
// *sql.DB, so the storage engine is swappable without touching the handler —
// and a test can substitute a fake with no database at all.
items catalog.Store
log *slog.Logger
auth *auth.Authenticator
}
// NewHandlers builds the playback HTTP layer.
func NewHandlers(items catalog.Store, log *slog.Logger, a *auth.Authenticator) *Handlers {
return &Handlers{items: items, log: log, auth: a}
}
// Register mounts the routes. Both require an authenticated user; the resolve
// endpoint additionally scopes to items the caller may read.
func (h *Handlers) Register(mux *http.ServeMux) {
mux.Handle("GET /api/v1/playback/engines", h.auth.Require(h.handleEngines))
mux.Handle("POST /api/v1/playback/resolve", h.auth.Require(h.handleResolve))
}
// handleEngines lists every engine the server knows about. A client uses this to
// show what it could offer and to name engines in a resolve request.
func (h *Handlers) handleEngines(w http.ResponseWriter, r *http.Request, u auth.User) {
httpx.WriteJSON(w, http.StatusOK, map[string]any{"engines": engine.All()})
}
// handleResolve answers whether the caller's client can direct-play an item.
//
// The body is {"item_id":N,"engines":["html5",...]}. The item's codec facts
// (container, vcodec, acodec) are read from the database and handed to
// engine.Resolve together with the client's declared engines.
func (h *Handlers) handleResolve(w http.ResponseWriter, r *http.Request, u auth.User) {
var req struct {
ItemID int64 `json:"item_id"`
Engines []string `json:"engines"`
}
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
if req.ItemID <= 0 {
httpx.WriteError(w, http.StatusBadRequest, "invalid item_id")
return
}
// catalog.LoadFor is the shared access-control chokepoint: it reads the
// item and confirms the caller may read its library, returning the same
// ErrNotFound for a missing item and an unreadable one. That keeps an
// inaccessible item a 404, never a 403, so its existence is not disclosed.
if _, err := h.items.ItemFor(r.Context(), u, req.ItemID); err != nil {
if errors.Is(err, catalog.ErrNotFound) {
httpx.WriteError(w, http.StatusNotFound, "item not found")
return
}
h.fail(w, "load item", err)
return
}
// The first file carries the codec facts the player will try. An item with
// no file cannot be played, but is still a 200 with a resolution so the UI
// has one shape to render.
f, err := h.items.FirstFile(r.Context(), req.ItemID)
if errors.Is(err, catalog.ErrNotFound) {
httpx.WriteJSON(w, http.StatusOK, engine.Resolution{
Playable: false,
Reason: "nofile",
Message: "This item has no file to play.",
})
return
}
if err != nil {
h.fail(w, "load file facts", err)
return
}
res := engine.Resolve(engine.Facts(f.Container, f.VCodec, f.ACodec), req.Engines)
httpx.WriteJSON(w, http.StatusOK, res)
}
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")
}