Fourth and final step of ADR-008's server half. When nothing the client declared can direct-play a file and this host can re-encode, resolve now answers playable with engine "transcode". The client sees a string it already understands, so the frozen JSON contract did not change. The ordering is the whole point and is asserted directly: transcoding sits after the direct-play loop, so a file that plays directly never pays for it. Re-encoding something a client could already decode would burn the GPU and lose quality for nothing, which is the failure mode ADR-008 rejects by choosing direct play first. Verified red-green - hoisting the check above the loop fails with "direct play must always win". The capability is resolved once at boot and passed in, not read inside a request: detection spawns ffmpeg to prove the encoder works, and that must never happen on the request path. It is also passed rather than read from the transcode package so the tests can drive both answers without hardware. TranscodeID is deliberately not a registry entry. Every other engine is something a client declares; this one is the server's own capability, so it is not advertised in /playback/engines and a client must not name it. On a host with no usable encoder canTranscode is false and every answer is byte-identical to what it was before any of this existed - which is this deployment, and is why ADR-002 still holds for it.
113 lines
4.4 KiB
Go
113 lines
4.4 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
|
|
// canTranscode is whether this host can re-encode (ADR-008).
|
|
canTranscode bool
|
|
}
|
|
|
|
// NewHandlers builds the playback HTTP layer.
|
|
//
|
|
// canTranscode is the server's own capability (ADR-008), resolved once at boot
|
|
// rather than per request: detection spawns ffmpeg to prove the encoder works,
|
|
// which must not happen inside a request. It is passed in rather than read from
|
|
// the transcode package so the resolve tests can drive both answers.
|
|
func NewHandlers(items catalog.Store, log *slog.Logger, a *auth.Authenticator, canTranscode bool) *Handlers {
|
|
return &Handlers{items: items, log: log, auth: a, canTranscode: canTranscode}
|
|
}
|
|
|
|
// 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, h.canTranscode)
|
|
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")
|
|
}
|