Vellum/internal/library/library_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

302 lines
9 KiB
Go

package library
import (
"context"
"database/sql"
"errors"
"io"
"log/slog"
"os"
"path/filepath"
"testing"
"github.com/mithral/vellum/internal/core"
"github.com/mithral/vellum/migrations"
)
func testDB(t *testing.T) *sql.DB {
t.Helper()
db, err := core.OpenDB(filepath.Join(t.TempDir(), "lib.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 testLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
func writeFile(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func TestRegistryBasics(t *testing.T) {
if err := ValidateKind("ebooks"); err != nil {
t.Errorf("ebooks should be valid: %v", err)
}
if err := ValidateKind("nonsense"); !errors.Is(err, ErrUnknownKind) {
t.Errorf("nonsense kind error = %v, want ErrUnknownKind", err)
}
if k, ok := KindForExtension(".epub"); !ok || k != "ebooks" {
t.Errorf("KindForExtension(.epub) = %q,%v", k, ok)
}
if k, ok := KindForExtension("CBZ"); !ok || k != "comics" {
t.Errorf("KindForExtension(CBZ) = %q,%v (should be case-insensitive)", k, ok)
}
if len(SupportedTypes()) == 0 {
t.Error("no media types registered")
}
}
func TestCleanTitle(t *testing.T) {
cases := map[string]string{
"Project Hail Mary.epub": "Project Hail Mary",
"the_hobbit.mobi": "the hobbit",
"Book.Title.2020.epub": "Book Title 2020",
"/deep/path/Saga 001.cbz": "Saga 001",
}
for in, want := range cases {
if got := CleanTitle(in); got != want {
t.Errorf("CleanTitle(%q) = %q, want %q", in, got, want)
}
}
}
func TestCreateLibraryValidation(t *testing.T) {
db := testDB(t)
ctx := context.Background()
root := t.TempDir()
if _, err := Create(ctx, db, "Good", "ebooks", root); err != nil {
t.Fatalf("valid create failed: %v", err)
}
if _, err := Create(ctx, db, "Good", "ebooks", root); !errors.Is(err, ErrNameExists) {
t.Errorf("duplicate name error = %v, want ErrNameExists", err)
}
if _, err := Create(ctx, db, "Bad", "nonsense", root); !errors.Is(err, ErrUnknownKind) {
t.Errorf("bad kind error = %v, want ErrUnknownKind", err)
}
if _, err := Create(ctx, db, "Bad", "ebooks", "/nope/not/here"); !errors.Is(err, ErrInvalidRoot) {
t.Errorf("bad root error = %v, want ErrInvalidRoot", err)
}
}
func TestListVisibleNeverMeansAll(t *testing.T) {
db := testDB(t)
ctx := context.Background()
root := t.TempDir()
if _, err := Create(ctx, db, "Ebooks", "ebooks", root); err != nil {
t.Fatal(err)
}
libs, err := ListVisible(ctx, db, nil)
if err != nil {
t.Fatal(err)
}
if len(libs) != 0 {
t.Errorf("ListVisible(nil) returned %d libraries; empty set must mean none", len(libs))
}
}
func TestMissingSinceLifecycle(t *testing.T) {
db := testDB(t)
ctx := context.Background()
root := t.TempDir()
writeFile(t, filepath.Join(root, "a.epub"), "aaaaaaaaaa")
writeFile(t, filepath.Join(root, "b.epub"), "bbbbbbbbbbbbbbbbbbbb")
lib, err := Create(ctx, db, "Ebooks", "ebooks", root)
if err != nil {
t.Fatalf("Create: %v", err)
}
if _, err := Scan(ctx, db, lib, testLogger()); err != nil {
t.Fatalf("first Scan: %v", err)
}
// A present file has no missing timestamp.
var since string
if err := db.QueryRowContext(ctx,
`SELECT missing_since FROM media_files WHERE path = ?`, "b.epub").Scan(&since); err != nil {
t.Fatal(err)
}
if since != "" {
t.Errorf("present file missing_since = %q, want empty", since)
}
// Delete b.epub: it goes missing and the timestamp is recorded.
if err := os.Remove(filepath.Join(root, "b.epub")); err != nil {
t.Fatal(err)
}
if _, err := Scan(ctx, db, lib, testLogger()); err != nil {
t.Fatalf("missing Scan: %v", err)
}
if err := db.QueryRowContext(ctx,
`SELECT missing_since FROM media_files WHERE path = ?`, "b.epub").Scan(&since); err != nil {
t.Fatal(err)
}
if since == "" {
t.Fatal("missing_since was not set on the transition into missing")
}
first := since
// A rescan must not refresh the age.
if _, err := Scan(ctx, db, lib, testLogger()); err != nil {
t.Fatalf("rescan: %v", err)
}
if err := db.QueryRowContext(ctx,
`SELECT missing_since FROM media_files WHERE path = ?`, "b.epub").Scan(&since); err != nil {
t.Fatal(err)
}
if since != first {
t.Errorf("missing_since reset on rescan: %q -> %q", first, since)
}
// ListMissing surfaces the file with its age.
entries, err := ListMissing(ctx, db, []int64{lib.ID})
if err != nil {
t.Fatalf("ListMissing: %v", err)
}
if len(entries) != 1 {
t.Fatalf("ListMissing returned %d entries, want 1", len(entries))
}
e := entries[0]
if e.Title != "b" || e.Path != "b.epub" || e.Kind != "ebooks" || e.LibraryID != lib.ID {
t.Errorf("unexpected missing entry: %+v", e)
}
if e.MissingSince != first {
t.Errorf("entry missing_since = %q, want %q", e.MissingSince, first)
}
// Restore the file: missing_since must be cleared, not left behind.
writeFile(t, filepath.Join(root, "b.epub"), "bbbbbbbbbbbbbbbbbbbb")
if _, err := Scan(ctx, db, lib, testLogger()); err != nil {
t.Fatalf("restore Scan: %v", err)
}
if err := db.QueryRowContext(ctx,
`SELECT missing_since FROM media_files WHERE path = ?`, "b.epub").Scan(&since); err != nil {
t.Fatal(err)
}
if since != "" {
t.Errorf("missing_since after restore = %q, want empty", since)
}
if got, _ := ListMissing(ctx, db, []int64{lib.ID}); len(got) != 0 {
t.Errorf("ListMissing after restore returned %d entries, want 0", len(got))
}
}
func TestPurgeMissingOnlyDeletesMissingItems(t *testing.T) {
db := testDB(t)
ctx := context.Background()
root := t.TempDir()
// Two audiobooks, each a folder of tracks.
writeFile(t, filepath.Join(root, "Book A", "01.mp3"), "a1")
writeFile(t, filepath.Join(root, "Book A", "02.mp3"), "a2")
writeFile(t, filepath.Join(root, "Book B", "01.mp3"), "b1")
writeFile(t, filepath.Join(root, "Book B", "02.mp3"), "b2")
lib, err := Create(ctx, db, "Audiobooks", "audiobooks", root)
if err != nil {
t.Fatalf("Create: %v", err)
}
if _, err := Scan(ctx, db, lib, testLogger()); err != nil {
t.Fatalf("Scan: %v", err)
}
// A second library with its own missing item, to prove purge is scoped.
otherRoot := t.TempDir()
writeFile(t, filepath.Join(otherRoot, "gone.epub"), "x")
other, err := Create(ctx, db, "Other", "ebooks", otherRoot)
if err != nil {
t.Fatalf("Create other: %v", err)
}
if _, err := Scan(ctx, db, other, testLogger()); err != nil {
t.Fatalf("Scan other: %v", err)
}
if err := os.Remove(filepath.Join(otherRoot, "gone.epub")); err != nil {
t.Fatal(err)
}
if _, err := Scan(ctx, db, other, testLogger()); err != nil {
t.Fatalf("Scan other (missing): %v", err)
}
// Remove one track from Book A: the item is not missing, only that file.
// Remove Book B entirely: the item and both its files are missing.
if err := os.Remove(filepath.Join(root, "Book A", "02.mp3")); err != nil {
t.Fatal(err)
}
if err := os.RemoveAll(filepath.Join(root, "Book B")); err != nil {
t.Fatal(err)
}
if _, err := Scan(ctx, db, lib, testLogger()); err != nil {
t.Fatalf("second Scan: %v", err)
}
entries, err := ListMissing(ctx, db, []int64{lib.ID})
if err != nil {
t.Fatalf("ListMissing: %v", err)
}
if len(entries) != 3 {
t.Fatalf("ListMissing returned %d entries, want 3 (Book A/02 + Book B/01 + Book B/02)", len(entries))
}
// Purge must remove only Book B.
n, err := PurgeMissing(ctx, db, lib.ID)
if err != nil {
t.Fatalf("PurgeMissing: %v", err)
}
if n != 1 {
t.Errorf("purged = %d, want 1 (only the fully missing item)", n)
}
// Book A survives, with its present and missing files intact.
var items int
if err := db.QueryRowContext(ctx,
`SELECT count(*) FROM media_items WHERE library_id = ?`, lib.ID).Scan(&items); err != nil {
t.Fatal(err)
}
if items != 1 {
t.Errorf("media_items after purge = %d, want 1", items)
}
var bookATitle string
if err := db.QueryRowContext(ctx,
`SELECT title FROM media_items WHERE library_id = ? AND path = ?`, lib.ID, "Book A").Scan(&bookATitle); err != nil {
t.Fatalf("a non-missing item was purged: %v", err)
}
var files int
if err := db.QueryRowContext(ctx, `
SELECT count(*) FROM media_files f JOIN media_items i ON i.id = f.item_id
WHERE i.library_id = ? AND i.path = ?`, lib.ID, "Book A").Scan(&files); err != nil {
t.Fatal(err)
}
if files != 2 {
t.Errorf("Book A files after purge = %d, want 2", files)
}
// The other library's missing item is untouched.
var otherItems int
if err := db.QueryRowContext(ctx,
`SELECT count(*) FROM media_items WHERE library_id = ?`, other.ID).Scan(&otherItems); err != nil {
t.Fatal(err)
}
if otherItems != 1 {
t.Errorf("other library items after purge = %d, want 1 (purge must be scoped)", otherItems)
}
// A second purge has nothing left to remove.
if n, err := PurgeMissing(ctx, db, lib.ID); err != nil || n != 0 {
t.Errorf("second purge = %d, %v; want 0, nil", n, err)
}
}