Vellum/internal/playback/handlers_test.go
mithral 67f145a9b1 Waves 7-8: playback engine selection, and missing files you can see
Wave 7 - engines, chosen per file by the client's own capabilities
- internal/engine: a registry. html5 is built in and declares a CONSERVATIVE
  browser baseline (h264/vp8/vp9/av1, deliberately no hevc and no mpeg4, because
  that is what was measured). mpv, vlc and mdk are registered as native engines
  declaring the broad support those libraries really have.
- The server never declares a file playable. It reports what is in the file and
  matches it against the engines the CLIENT declares. Proven:
      engines=["html5"]       -> playable=false engine=null reason=vcodec:hevc
      engines=["html5","mpv"] -> playable=true  engine=mpv
  Same file, different answer, because the client offered a different engine.
  That is what makes adding a native client a registration rather than a rewrite.
- internal/playback: GET /playback/engines and POST /playback/resolve, behind
  auth and the same grant chokepoint. An unreadable item is 404, not 403.
- web/src/lib/capabilities.ts: uses navigator.mediaCapabilities.decodingInfo(),
  which returns {supported, smooth, powerEfficient}. That is real client-hardware
  detection and it distinguishes "cannot decode" from "can decode but it will
  stutter and drain the battery". Feature-detected; degrades to the server
  baseline rather than throwing.
- VideoPlayer consults the server as an ADVISORY only. The element remains the
  source of truth: the server's note appears first, then the element's own
  confirmation replaces it once videoWidth proves the answer. Verified on HEVC —
  "Playback may not work here" at 0-906ms, "Video cannot play here" from ~1208ms.

Wave 8 - missing files: visibility and an explicit purge
- migrations/0011 adds missing_since to media_items and media_files.
- It is written only on the TRANSITION into missing, so the age says when the
  file first vanished rather than the last rescan. Proven byte-identical across a
  rescan, and cleared when a file is restored.
- GET /api/v1/missing (grant-scoped) and POST /api/v1/libraries/{id}/purge
  (admin only). The purge deletes only items that are missing AND have no
  present file, and its cascade into progress/item_people/item_views/game_saves
  is enumerated in a comment — that cascade is exactly why deletion is never
  automatic. Proven: 19 items -> 18, only the missing one, the non-missing item
  untouched and its files intact, second purge returns 0.
- MissingFiles.svelte in the Manage nav: title, kind, library, path, human age,
  and a per-library Purge behind a confirmation that names the count and says
  progress cannot be regenerated.

Coordinator wired playback.NewHandlers into main.go (the one line agents were
told to leave alone).

Verified end to end: 17 packages pass, gofmt/vet clean, svelte-check 0/0, and
every claim above checked against the running server.
2026-10-11 14:36:55 -04:00

230 lines
7.9 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/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)
return NewHandlers(db, log, a)
}
// 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) }