// 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 ( "database/sql" "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 { db *sql.DB log *slog.Logger auth *auth.Authenticator } // NewHandlers builds the playback HTTP layer. func NewHandlers(db *sql.DB, log *slog.Logger, a *auth.Authenticator) *Handlers { return &Handlers{db: db, 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 := catalog.LoadFor(r.Context(), h.db, 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 := catalog.FirstFile(r.Context(), h.db, 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") }