Vellum/internal/media/fixtures_test.go
mithral 5ea7ffe6c6 Serve transcoded streams over HLS
Third step of ADR-008. Two routes inside the item family: the playlist starts
the session lazily and serves what ffmpeg has written so far, so playback can
begin while the rest still encodes; the segment route serves one .ts.

Segment names are validated against the exact shape ffmpeg writes before being
used as a path. Verified red-green: removing the check serves a file from
outside the session directory with a 200.

Two corrections made in review.

1. The manager was being built inside NewHandlers, which meant nothing could
   ever shut it down. Its reaper would run for the process lifetime and a
   running ffmpeg would outlive the server that started it. It is now created
   in main.go beside the job queue and deferred, and passed in - the same
   ownership the queue already has.

2. A playlist request could 404 before ffmpeg wrote the file, which a native
   HLS player treats as fatal rather than retrying. The playlist handler now
   waits up to four seconds for the first write. Segments are never waited for.

Verified end-to-end against a running server with real hardware: a 30-second
file produced a valid EVENT playlist in about a second (the wait doing its
job - no 404), a 658 KB segment with Content-Type video/mp2t, a 404 for a
traversal attempt, and a 401 unauthenticated.

Also confirmed at boot: all four metadata providers report their disabled
state and name their env var, including the new adult one.
2026-10-12 12:00:20 -04:00

196 lines
6.4 KiB
Go

package media
import (
"context"
"database/sql"
"io"
"log/slog"
"path/filepath"
"strconv"
"testing"
"github.com/mithral/vellum/internal/core"
"github.com/mithral/vellum/migrations"
)
// These fixtures are duplicated from internal/home. Go test helpers cannot be
// shared across packages without being exported from non-test code, and the home
// screen's version is a different shape: it seeds progress and views so the
// shelves have something to aggregate.
// ── shared helpers ─────────────────────────────────────────────────────
func mediaHandlers(t *testing.T, db *sql.DB) *Handlers {
t.Helper()
log := slog.New(slog.NewTextHandler(io.Discard, nil))
// nil sessions: the constructor falls back to a fresh manager, which is what
// a test wants. Tests that exercise the transcode routes inject their own
// fake by replacing the field.
return NewHandlers(db, log, nil, t.TempDir(), nil)
}
func testDB(t *testing.T) *sql.DB {
t.Helper()
db, err := core.OpenDB(filepath.Join(t.TempDir(), "media.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 mustQueryID(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 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 newLibrary(t *testing.T, db *sql.DB, name, kind, root string) int64 {
t.Helper()
return mustQueryID(t, db,
`INSERT INTO libraries (name, kind, root_path) VALUES (?, ?, ?) RETURNING id`, name, kind, root)
}
func newUser(t *testing.T, db *sql.DB, name string) int64 {
t.Helper()
return mustQueryID(t, db,
`INSERT INTO users (username, password_hash) VALUES (?, 'x') RETURNING id`, name)
}
func grantRead(t *testing.T, db *sql.DB, userID, libID int64) {
t.Helper()
mustExec(t, db,
`INSERT INTO library_grants (user_id, library_id, can_read, can_download) VALUES (?, ?, 1, 1)`,
userID, libID)
}
// newItem inserts a media item plus a backing file, because progress rows carry
// a NOT NULL file_id foreign key.
func newItem(t *testing.T, db *sql.DB, libID int64, kind, path, title, sortTitle string, missing bool, addedAt string) int64 {
t.Helper()
m := 0
if missing {
m = 1
}
id := mustQueryID(t, db, `
INSERT INTO media_items (library_id, kind, path, title, sort_title, missing, added_at)
VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id`,
libID, kind, path, title, sortTitle, m, addedAt)
mustExec(t, db,
`INSERT INTO media_files (item_id, path, size) VALUES (?, ?, 1)`,
id, "file-"+strconv.FormatInt(id, 10))
return id
}
func fileIDFor(t *testing.T, db *sql.DB, itemID int64) int64 {
t.Helper()
var id int64
if err := db.QueryRowContext(context.Background(),
`SELECT id FROM media_files WHERE item_id = ? ORDER BY id LIMIT 1`, itemID).Scan(&id); err != nil {
t.Fatalf("file for item %d: %v", itemID, err)
}
return id
}
func newProgress(t *testing.T, db *sql.DB, userID, itemID int64, percent float64, finished bool, updatedAt string) {
t.Helper()
f := 0
if finished {
f = 1
}
mustExec(t, db, `
INSERT INTO progress (user_id, item_id, file_id, kind, position, percent, finished, updated_at)
VALUES (?, ?, ?, 'ebooks', 1, ?, ?, ?)`,
userID, itemID, fileIDFor(t, db, itemID), percent, f, updatedAt)
}
func newView(t *testing.T, db *sql.DB, userID, itemID int64, views int, lastViewedAt string) {
t.Helper()
mustExec(t, db,
`INSERT INTO item_views (user_id, item_id, views, last_viewed_at) VALUES (?, ?, ?, ?)`,
userID, itemID, views, lastViewedAt)
}
// setupHome builds the two-library, three-user fixture the item-detail tests
// use: one item the caller may read, one in a library they may not, and one user
// with no grants at all.
func setupHome(t *testing.T) homeFixture {
t.Helper()
db := testDB(t)
f := homeFixture{db: db}
f.libA = newLibrary(t, db, "Ebooks", "ebooks", "/media/ebooks")
f.libB = newLibrary(t, db, "Audio", "audiobooks", "/media/audio")
f.userA = newUser(t, db, "alice")
f.userB = newUser(t, db, "bob")
f.userNone = newUser(t, db, "carol")
grantRead(t, db, f.userA, f.libA)
grantRead(t, db, f.userB, f.libB)
f.a1 = newItem(t, db, f.libA, "ebooks", "a1.epub", "A One", "a one", false, "2026-01-01T00:00:01.000Z")
f.a2 = newItem(t, db, f.libA, "ebooks", "a2.epub", "A Two", "a two", false, "2026-01-01T00:00:02.000Z")
f.a3 = newItem(t, db, f.libA, "comics", "a3.cbz", "A Three", "a three", false, "2026-01-01T00:00:03.000Z")
f.a4 = newItem(t, db, f.libA, "ebooks", "a4.epub", "A Four", "a four", true, "2026-01-01T00:00:04.000Z")
f.a5 = newItem(t, db, f.libA, "ebooks", "a5.epub", "A Five", "a five", false, "2026-01-01T00:00:05.000Z")
f.b1 = newItem(t, db, f.libB, "audiobooks", "b1.mp3", "B One", "b one", false, "2026-01-01T00:00:06.000Z")
newProgress(t, db, f.userA, f.a1, 0.5, false, "2026-02-01T00:00:03.000Z")
newProgress(t, db, f.userA, f.a2, 1.0, true, "2026-02-01T00:00:02.000Z")
newProgress(t, db, f.userA, f.a4, 0.3, false, "2026-02-01T00:00:04.000Z")
newProgress(t, db, f.userB, f.b1, 0.2, false, "2026-02-02T00:00:01.000Z")
newView(t, db, f.userA, f.a1, 3, "2026-02-03T00:00:01.000Z")
newView(t, db, f.userA, f.a3, 1, "2026-02-03T00:00:02.000Z")
newView(t, db, f.userB, f.b1, 9, "2026-02-03T00:00:03.000Z")
ada := mustQueryID(t, db, `INSERT INTO people (name) VALUES ('Ada Author') RETURNING id`)
mustExec(t, db, `INSERT INTO item_people (item_id, person_id, role, ord) VALUES (?, ?, 'author', 0)`, f.a1, ada)
mustExec(t, db, `INSERT INTO item_people (item_id, person_id, role, ord) VALUES (?, ?, 'author', 0)`, f.a3, ada)
return f
}
func itoa(n int64) string {
if n == 0 {
return "0"
}
var b [20]byte
i := len(b)
for n > 0 {
i--
b[i] = byte('0' + n%10)
n /= 10
}
return string(b[i:])
}
type homeFixture struct {
db *sql.DB
libA int64
libB int64
userA int64
userB int64
userNone int64
a1 int64
a2 int64
a3 int64
a4 int64
a5 int64
b1 int64
}