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.
230 lines
7.9 KiB
Go
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) }
|