Vellum/internal/playback/handlers_test.go
mithral fd377a7446 Offer transcoding as a resolve engine, as the last resort
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.
2026-10-12 12:03:41 -04:00

233 lines
8.1 KiB
Go

package playback
import (
"context"
"database/sql"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"path/filepath"
"strconv"
"strings"
"testing"
"github.com/mithral/vellum/internal/auth"
"github.com/mithral/vellum/internal/catalog"
"github.com/mithral/vellum/internal/core"
"github.com/mithral/vellum/internal/engine"
"github.com/mithral/vellum/migrations"
)
func testDB(t *testing.T) *sql.DB {
t.Helper()
db, err := core.OpenDB(filepath.Join(t.TempDir(), "playback.db"))
if err != nil {
t.Fatalf("OpenDB: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
log := slog.New(slog.NewTextHandler(io.Discard, nil))
if err := core.Migrate(context.Background(), db, migrations.FS, log); err != nil {
t.Fatalf("Migrate: %v", err)
}
return db
}
func mustExec(t *testing.T, db *sql.DB, q string, args ...any) {
t.Helper()
if _, err := db.ExecContext(context.Background(), q, args...); err != nil {
t.Fatalf("exec %q: %v", q, err)
}
}
func mustID(t *testing.T, db *sql.DB, q string, args ...any) int64 {
t.Helper()
var id int64
if err := db.QueryRowContext(context.Background(), q, args...).Scan(&id); err != nil {
t.Fatalf("query %q: %v", q, err)
}
return id
}
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)
// 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
// first. The item in the second library must be invisible.
type fixture struct {
db *sql.DB
libA int64
libB int64
userA int64
itemA int64 // mp4 h264 aac, readable
itemB int64 // mp4 hevc aac, NOT readable
}
func setup(t *testing.T) fixture {
t.Helper()
db := testDB(t)
f := fixture{db: db}
f.libA = mustID(t, db, `INSERT INTO libraries (name, kind, root_path) VALUES ('A','video','/a') RETURNING id`)
f.libB = mustID(t, db, `INSERT INTO libraries (name, kind, root_path) VALUES ('B','video','/b') RETURNING id`)
f.userA = mustID(t, db, `INSERT INTO users (username, password_hash) VALUES ('alice','x') RETURNING id`)
mustExec(t, db, `INSERT INTO library_grants (user_id, library_id, can_read, can_download) VALUES (?, ?, 1, 1)`, f.userA, f.libA)
f.itemA = mustID(t, db, `INSERT INTO media_items (library_id, kind, path, title) VALUES (?, 'video', 'a.mp4', 'A') RETURNING id`, f.libA)
mustExec(t, db, `INSERT INTO media_files (item_id, path, size, container, vcodec, acodec) VALUES (?, 'a.mp4', 1, 'mp4', 'h264', 'aac')`, f.itemA)
f.itemB = mustID(t, db, `INSERT INTO media_items (library_id, kind, path, title) VALUES (?, 'video', 'b.mp4', 'B') RETURNING id`, f.libB)
mustExec(t, db, `INSERT INTO media_files (item_id, path, size, container, vcodec, acodec) VALUES (?, 'b.mp4', 1, 'mp4', 'hevc', 'aac')`, f.itemB)
return f
}
func resolve(t *testing.T, h *Handlers, u auth.User, body string) (int, engine.Resolution) {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/api/v1/playback/resolve", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.handleResolve(rec, req, u)
var res engine.Resolution
if rec.Code == http.StatusOK {
if err := json.Unmarshal(rec.Body.Bytes(), &res); err != nil {
t.Fatalf("decode resolution: %v (body %s)", err, rec.Body.String())
}
}
return rec.Code, res
}
func TestEnginesEndpoint(t *testing.T) {
f := setup(t)
h := newHandlers(t, f.db)
req := httptest.NewRequest(http.MethodGet, "/api/v1/playback/engines", nil)
rec := httptest.NewRecorder()
h.handleEngines(rec, req, auth.User{ID: f.userA})
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body %s", rec.Code, rec.Body.String())
}
var body struct {
Engines []engine.Engine `json:"engines"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode engines: %v", err)
}
got := map[string]bool{}
for _, e := range body.Engines {
got[e.ID] = true
}
for _, id := range []string{"html5", "mpv", "vlc", "mdk"} {
if !got[id] {
t.Errorf("engines response is missing %q: %s", id, rec.Body.String())
}
}
}
func TestResolvePlayableAndNot(t *testing.T) {
f := setup(t)
h := newHandlers(t, f.db)
u := auth.User{ID: f.userA}
code, res := resolve(t, h, u, `{"item_id":`+itoa(f.itemA)+`,"engines":["html5"]}`)
if code != http.StatusOK || !res.Playable || res.Engine == nil || *res.Engine != "html5" {
t.Fatalf("h264 resolve = %d %+v, want playable html5", code, res)
}
// The readable item's facts are h264, so to test HEVC we point at itemB but
// grant it first — scoping is tested separately.
mustExec(t, f.db, `INSERT INTO library_grants (user_id, library_id, can_read, can_download) VALUES (?, ?, 1, 1)`, f.userA, f.libB)
code, res = resolve(t, h, u, `{"item_id":`+itoa(f.itemB)+`,"engines":["html5"]}`)
if code != http.StatusOK || res.Playable {
t.Fatalf("hevc resolve = %d %+v, want not playable", code, res)
}
if res.Reason != "vcodec:hevc" {
t.Errorf("reason = %q, want vcodec:hevc", res.Reason)
}
if !strings.Contains(res.Message, "HEVC") {
t.Errorf("message = %q, want it to name HEVC", res.Message)
}
// A native engine changes the answer.
code, res = resolve(t, h, u, `{"item_id":`+itoa(f.itemB)+`,"engines":["html5","mpv"]}`)
if code != http.StatusOK || !res.Playable || res.Engine == nil || *res.Engine != "mpv" {
t.Fatalf("hevc+mpv resolve = %d %+v, want playable mpv", code, res)
}
}
// TestResolveGrantScoping pins the chokepoint: an item in a library the caller
// cannot read must be a 404, indistinguishable from a missing item.
func TestResolveGrantScoping(t *testing.T) {
f := setup(t)
h := newHandlers(t, f.db)
// userA may read libA only; itemB lives in libB.
code, _ := resolve(t, h, auth.User{ID: f.userA}, `{"item_id":`+itoa(f.itemB)+`,"engines":["html5"]}`)
if code != http.StatusNotFound {
t.Errorf("inaccessible item status = %d, want 404", code)
}
// A user with no grants gets the same 404 for a readable-by-someone item.
none := mustID(t, f.db, `INSERT INTO users (username, password_hash) VALUES ('carol','x') RETURNING id`)
code, _ = resolve(t, h, auth.User{ID: none}, `{"item_id":`+itoa(f.itemA)+`,"engines":["html5"]}`)
if code != http.StatusNotFound {
t.Errorf("no-grant item status = %d, want 404", code)
}
// A nonexistent item is also a 404, not a leak.
code, _ = resolve(t, h, auth.User{ID: f.userA}, `{"item_id":99999,"engines":["html5"]}`)
if code != http.StatusNotFound {
t.Errorf("missing item status = %d, want 404", code)
}
}
func TestResolveValidation(t *testing.T) {
f := setup(t)
h := newHandlers(t, f.db)
u := auth.User{ID: f.userA}
for _, body := range []string{
`{"item_id":0,"engines":["html5"]}`,
`{"item_id":-3,"engines":["html5"]}`,
} {
if code, _ := resolve(t, h, u, body); code != http.StatusBadRequest {
t.Errorf("body %s status = %d, want 400", body, code)
}
}
// Missing engines is allowed: the handler falls back to the html5 baseline.
if code, res := resolve(t, h, u, `{"item_id":`+itoa(f.itemA)+`}`); code != http.StatusOK || !res.Playable {
t.Errorf("missing engines = %d %+v, want playable fallback", code, res)
}
}
// TestRegisterRequiresAuth proves the routes are mounted behind the auth
// middleware: an unauthenticated request is rejected with 401.
func TestRegisterRequiresAuth(t *testing.T) {
f := setup(t)
h := newHandlers(t, f.db)
mux := http.NewServeMux()
h.Register(mux)
for _, tc := range []struct{ method, path, body string }{
{http.MethodGet, "/api/v1/playback/engines", ""},
{http.MethodPost, "/api/v1/playback/resolve", `{"item_id":1,"engines":["html5"]}`},
} {
req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body))
if tc.body != "" {
req.Header.Set("Content-Type", "application/json")
}
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("%s %s status = %d, want 401", tc.method, tc.path, rec.Code)
}
}
}
func itoa(n int64) string { return strconv.FormatInt(n, 10) }