diff --git a/cmd/vellum/main.go b/cmd/vellum/main.go index 8a8933f..b6f0464 100644 --- a/cmd/vellum/main.go +++ b/cmd/vellum/main.go @@ -126,7 +126,12 @@ func run(cfg core.Config, log *slog.Logger) error { // Playback engine registry + selection. Vellum never transcodes, so the // server reports what is inside a file and matches it against the engines the // CLIENT declares — a browser offers "html5", a native client can offer mpv. - playback.NewHandlers(catalog.New(db), log, authenticator).Register(api.Mux()) + // ADR-008: transcode capability is resolved ONCE, at boot. Detection spawns + // ffmpeg to prove the encoder actually encodes, so it must never run inside + // a request. On a host with no usable encoder this is false and every + // resolve answer is what it was before transcoding existed. + canTranscode := transcode.Detect().Available() + playback.NewHandlers(catalog.New(db), log, authenticator, canTranscode).Register(api.Mux()) // Client compatibility layers. Neither speaks the session cookie; both // authenticate with an api_tokens credential and then go through the same diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 204b982..c741f49 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -32,6 +32,14 @@ const ( // HTML5ID is the built-in browser engine every client can be assumed to have. const HTML5ID = "html5" +// TranscodeID is the engine the SERVER offers when it can re-encode (ADR-008). +// +// It is deliberately not a registry entry. Every other engine is something a +// client declares and the server matches against; this one is the server's own +// capability, so it is never advertised in All() and a client must not name it. +// Resolve is told whether it is available and returns it only as a last resort. +const TranscodeID = "transcode" + // Engine declares one playback backend and the media it can open. // // The JSON tags are the frozen EngineSupport contract in web/src/lib/api.ts and diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go index 8fa91dc..96d846e 100644 --- a/internal/engine/engine_test.go +++ b/internal/engine/engine_test.go @@ -74,6 +74,8 @@ func TestResolve(t *testing.T) { engine string // expected engine id, or "" for nil reason string msgSubstr string // must appear in Message when not playable + // canTranscode is whether the server itself can re-encode (ADR-008). + canTranscode bool }{ { name: "mp4 h264 aac plays in html5", @@ -190,7 +192,7 @@ func TestResolve(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := Resolve(tt.facts, tt.engines) + got := Resolve(tt.facts, tt.engines, tt.canTranscode) if got.Playable != tt.playable { t.Fatalf("playable = %v, want %v (reason %q)", got.Playable, tt.playable, got.Reason) } @@ -222,7 +224,7 @@ func TestResolve(t *testing.T) { // TestResolutionJSONEngineNull pins that a non-playable answer serialises // engine as JSON null, exactly as PlaybackResolution declares. func TestResolutionJSONEngineNull(t *testing.T) { - got := Resolve(Facts("mp4", "hevc", "aac"), []string{"html5"}) + got := Resolve(Facts("mp4", "hevc", "aac"), []string{"html5"}, false) b, err := json.Marshal(got) if err != nil { t.Fatal(err) @@ -243,3 +245,33 @@ func TestResolutionJSONEngineNull(t *testing.T) { t.Errorf("unexpected resolution JSON: %s", b) } } + +// A file nothing can direct-play is still playable when the SERVER can re-encode +// (ADR-008). The ordering matters more than the feature: transcoding is the last +// resort, so a file that plays directly must never pay for it. +func TestResolveTranscodeIsTheLastResort(t *testing.T) { + hevc := Facts("mp4", "hevc", "aac") + + // Nothing the client declared can decode it, so the server re-encodes. + got := Resolve(hevc, []string{"html5"}, true) + if !got.Playable || got.Engine == nil || *got.Engine != TranscodeID { + t.Errorf("Resolve(hevc, html5, canTranscode) = %+v, want playable via %q", got, TranscodeID) + } + + // A file the client CAN decode gets direct play, never a transcode. This is + // the assertion that keeps a working file from being re-encoded for nothing. + got = Resolve(Facts("mp4", "h264", "aac"), []string{"html5"}, true) + if !got.Playable || got.Engine == nil || *got.Engine == TranscodeID { + t.Errorf("a direct-playable file chose %v; direct play must always win", got.Engine) + } + + // With no capability the answer is exactly what it was before ADR-008: the + // blocker, with a machine-readable reason for the UI. + got = Resolve(hevc, []string{"html5"}, false) + if got.Playable { + t.Errorf("Resolve(hevc, html5, no transcode) = playable; want the blocker") + } + if got.Reason == "" || got.Message == "" { + t.Errorf("want a reason and a message when nothing can play it, got %+v", got) + } +} diff --git a/internal/engine/resolve.go b/internal/engine/resolve.go index 2a5f1f6..9883f7c 100644 --- a/internal/engine/resolve.go +++ b/internal/engine/resolve.go @@ -27,7 +27,9 @@ type Resolution struct { // // fileFacts is the positional slice produced by Facts: container, video codec, // audio codec. clientEngineIDs is the list of engines the CLIENT declared, in -// the client's own preference order. +// the client's own preference order. canTranscode is whether this server can +// re-encode at all (ADR-008) — on a host with no usable encoder it is false and +// the answer is unchanged from before transcoding existed. // // Semantics: // - Only engines the client declared are considered. If the client declared @@ -39,7 +41,9 @@ type Resolution struct { // "try mpv first". // - A file with no video codec (audio-only) is playable as soon as its audio // codec is supported. -func Resolve(fileFacts, clientEngineIDs []string) Resolution { +// - Transcoding is the LAST resort, offered only when nothing the client +// declared can direct-play. A file that plays directly never pays for it. +func Resolve(fileFacts, clientEngineIDs []string, canTranscode bool) Resolution { container, vcodec, acodec := splitFacts(fileFacts) ids := clientEngineIDs @@ -62,9 +66,18 @@ func Resolve(fileFacts, clientEngineIDs []string) Resolution { } } - // Nothing the client declared can play it. Explain the blocker using the - // client's first *known* engine — its stated preference — falling back to - // html5 when the client named only engines we do not know. + // Nothing the client declared can play it. If this server can re-encode, that + // is the last resort and it beats explaining the blocker: a file the client + // cannot decode is exactly what the transcode engine exists for. This sits + // after the loop, so direct play always wins when it is possible. + if canTranscode { + id := TranscodeID + return Resolution{Playable: true, Engine: &id} + } + + // Otherwise explain the blocker using the client's first *known* engine — its + // stated preference — falling back to html5 when the client named only + // engines we do not know. ref, ok := Get(firstKnown(ids)) if !ok { ref, _ = Get(HTML5ID) diff --git a/internal/playback/handlers.go b/internal/playback/handlers.go index b5aff32..5e0587a 100644 --- a/internal/playback/handlers.go +++ b/internal/playback/handlers.go @@ -27,11 +27,18 @@ type Handlers struct { 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. -func NewHandlers(items catalog.Store, log *slog.Logger, a *auth.Authenticator) *Handlers { - return &Handlers{items: items, log: log, auth: a} +// +// 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 @@ -96,7 +103,7 @@ func (h *Handlers) handleResolve(w http.ResponseWriter, r *http.Request, u auth. return } - res := engine.Resolve(engine.Facts(f.Container, f.VCodec, f.ACodec), req.Engines) + res := engine.Resolve(engine.Facts(f.Container, f.VCodec, f.ACodec), req.Engines, h.canTranscode) httpx.WriteJSON(w, http.StatusOK, res) } diff --git a/internal/playback/handlers_test.go b/internal/playback/handlers_test.go index 040db7a..da9f3ef 100644 --- a/internal/playback/handlers_test.go +++ b/internal/playback/handlers_test.go @@ -54,7 +54,9 @@ func newHandlers(t *testing.T, db *sql.DB) *Handlers { t.Helper() log := slog.New(slog.NewTextHandler(io.Discard, nil)) a := auth.NewAuthenticator(db, "vellum_session", 0, false, log) - return NewHandlers(catalog.New(db), log, a) + // No transcode: these tests are about grants and direct play, and a host + // with an encoder would otherwise change their answers. + return NewHandlers(catalog.New(db), log, a, false) } // fixture builds two libraries with one item each and a user granted only the