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

293 lines
9.5 KiB
Go

package library
import (
"context"
"database/sql"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
var (
// ErrNotFound means no library matched.
ErrNotFound = errors.New("library: not found")
// ErrNameExists means the library name is taken.
ErrNameExists = errors.New("library: name already in use")
// ErrInvalidRoot means the root path is missing, not a directory, or not
// readable.
ErrInvalidRoot = errors.New("library: invalid root path")
)
// Library is a media root of a single kind.
type Library struct {
ID int64 `json:"id"`
Name string `json:"name"`
Kind string `json:"kind"`
RootPath string `json:"root_path"`
CreatedAt time.Time `json:"created_at"`
}
const librarySelect = `SELECT id, name, kind, root_path, created_at FROM libraries`
// Create validates and inserts a library.
//
// The root path is resolved to an absolute, symlink-free path and must exist.
// Storing the resolved form matters: the scanner compares filesystem paths
// against this value, and "/media/../media" would silently match nothing.
func Create(ctx context.Context, db *sql.DB, name, kind, rootPath string) (Library, error) {
name = strings.TrimSpace(name)
if name == "" {
return Library{}, errors.New("library: name must not be empty")
}
if err := ValidateKind(kind); err != nil {
return Library{}, err
}
resolved, err := resolveRoot(rootPath)
if err != nil {
return Library{}, err
}
var lib Library
var created string
err = db.QueryRowContext(ctx, `
INSERT INTO libraries (name, kind, root_path)
VALUES (?, ?, ?)
RETURNING id, created_at`,
name, strings.ToLower(kind), resolved,
).Scan(&lib.ID, &created)
if err != nil {
if isUnique(err) {
return Library{}, ErrNameExists
}
return Library{}, fmt.Errorf("library: create: %w", err)
}
lib.Name, lib.Kind, lib.RootPath = name, strings.ToLower(kind), resolved
lib.CreatedAt, _ = time.Parse(time.RFC3339Nano, created)
return lib, nil
}
// Get loads one library by id.
func Get(ctx context.Context, db *sql.DB, id int64) (Library, error) {
return scanLibrary(db.QueryRowContext(ctx, librarySelect+` WHERE id = ?`, id))
}
// List returns all libraries.
func List(ctx context.Context, db *sql.DB) ([]Library, error) {
return queryLibraries(ctx, db, librarySelect+` ORDER BY name`)
}
// ListVisible returns the libraries in the given id set. An empty set yields an
// empty slice, never "all" — that distinction is the whole point of the
// access-control model.
func ListVisible(ctx context.Context, db *sql.DB, ids []int64) ([]Library, error) {
if len(ids) == 0 {
return []Library{}, nil
}
placeholders := make([]string, len(ids))
args := make([]any, len(ids))
for i, id := range ids {
placeholders[i] = "?"
args[i] = id
}
q := librarySelect + ` WHERE id IN (` + strings.Join(placeholders, ",") + `) ORDER BY name`
return queryLibraries(ctx, db, q, args...)
}
// Delete removes a library. Items, files, and grants cascade.
func Delete(ctx context.Context, db *sql.DB, id int64) error {
res, err := db.ExecContext(ctx, `DELETE FROM libraries WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("library: delete: %w", err)
}
n, _ := res.RowsAffected()
if n == 0 {
return ErrNotFound
}
return nil
}
// CountItems returns how many items a library holds (excluding missing).
func CountItems(ctx context.Context, db *sql.DB, id int64) (int, error) {
var n int
err := db.QueryRowContext(ctx,
`SELECT count(*) FROM media_items WHERE library_id = ? AND missing = 0`, id).Scan(&n)
return n, err
}
// MissingEntry is one missing file, surfaced so an admin can see what the
// scanner has flagged and decide whether to purge it.
//
// It carries both the file (file_id) and its owning item (item_id) because the
// file is what actually vanished, while the item is what a purge deletes. A
// multi-file item (an audiobook of tracks) produces one entry per missing file.
type MissingEntry struct {
ItemID int64 `json:"item_id"`
FileID int64 `json:"file_id"`
LibraryID int64 `json:"library_id"`
Kind string `json:"kind"`
Title string `json:"title"`
Path string `json:"path"`
MissingSince string `json:"missing_since"`
}
// ListMissing returns the missing files in the given libraries, grouped by
// library and then by title.
//
// It is keyed on media_files because a missing file is the thing the scanner
// saw disappear; an item whose whole group is gone has each of its files
// flagged, so every missing item still appears. ids is the caller's visible
// set, resolved by the auth layer — an empty set yields nothing, never "all",
// which is the same rule ListVisible follows.
func ListMissing(ctx context.Context, db *sql.DB, ids []int64) ([]MissingEntry, error) {
if len(ids) == 0 {
return []MissingEntry{}, nil
}
placeholders := make([]string, len(ids))
args := make([]any, len(ids))
for i, id := range ids {
placeholders[i] = "?"
args[i] = id
}
rows, err := db.QueryContext(ctx, `
SELECT i.id, f.id, i.library_id, i.kind, i.title, f.path,
CASE WHEN f.missing_since <> '' THEN f.missing_since ELSE i.missing_since END
FROM media_files f
JOIN media_items i ON i.id = f.item_id
WHERE f.missing = 1 AND i.library_id IN (`+strings.Join(placeholders, ",")+`)
ORDER BY i.library_id, i.sort_title, f.path`, args...)
if err != nil {
return nil, fmt.Errorf("library: list missing: %w", err)
}
defer rows.Close()
out := []MissingEntry{}
for rows.Next() {
var e MissingEntry
if err := rows.Scan(&e.ItemID, &e.FileID, &e.LibraryID, &e.Kind, &e.Title, &e.Path, &e.MissingSince); err != nil {
return nil, err
}
out = append(out, e)
}
return out, rows.Err()
}
// PurgeMissing deletes the items in a library that are genuinely missing:
// flagged missing and with no file left present. It returns how many items were
// removed.
//
// This is the ONLY place media rows are deleted on purpose, and it is an
// explicit admin action rather than part of a scan because the delete is a
// one-way door. Deleting a media_items row cascades (foreign_keys is on) into:
//
// - media_files.item_id — the files themselves
// - progress.item_id — watch/read positions, which cannot be
// regenerated; this is why a scan never deletes
// - progress.file_id — via the cascaded media_files rows
// - item_people.item_id — cast and author credits
// - provider_ids.item_id — external metadata matches
// - metadata_fetch.item_id — provider attempt history
// - assets.item_id — covers, thumbnails, extracted pages
// - collection_items.item_id — collection membership
// - favorites.item_id
// - item_views.item_id — per-user view counts
// - game_saves.item_id — emulator save states
// - child media_items — the parent_id self-reference
//
// The WHERE clause is deliberately narrow: an item is purged only when it is
// flagged missing AND has no present file, so an item missing one track while
// its other tracks remain on disk is never touched.
func PurgeMissing(ctx context.Context, db *sql.DB, libraryID int64) (int64, error) {
res, err := db.ExecContext(ctx, `
DELETE FROM media_items
WHERE library_id = ?
AND missing = 1
AND NOT EXISTS (
SELECT 1 FROM media_files f
WHERE f.item_id = media_items.id AND f.missing = 0
)`, libraryID)
if err != nil {
return 0, fmt.Errorf("library: purge missing: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return 0, fmt.Errorf("library: purge missing: %w", err)
}
return n, nil
}
// ── helpers ────────────────────────────────────────────────────────────
func resolveRoot(rootPath string) (string, error) {
if strings.TrimSpace(rootPath) == "" {
return "", fmt.Errorf("%w: path is empty", ErrInvalidRoot)
}
abs, err := filepath.Abs(rootPath)
if err != nil {
return "", fmt.Errorf("%w: %v", ErrInvalidRoot, err)
}
if resolved, err := filepath.EvalSymlinks(abs); err == nil {
abs = resolved
} else {
return "", fmt.Errorf("%w: %v", ErrInvalidRoot, err)
}
info, err := os.Stat(abs)
if err != nil {
return "", fmt.Errorf("%w: %v", ErrInvalidRoot, err)
}
if !info.IsDir() {
return "", fmt.Errorf("%w: %s is not a directory", ErrInvalidRoot, abs)
}
// Confirm readability; a directory can exist and still deny access.
f, err := os.Open(abs)
if err != nil {
return "", fmt.Errorf("%w: %v", ErrInvalidRoot, err)
}
_ = f.Close()
return abs, nil
}
func queryLibraries(ctx context.Context, db *sql.DB, q string, args ...any) ([]Library, error) {
rows, err := db.QueryContext(ctx, q, args...)
if err != nil {
return nil, fmt.Errorf("library: query: %w", err)
}
defer rows.Close()
out := []Library{}
for rows.Next() {
lib, err := scanLibrary(rows)
if err != nil {
return nil, err
}
out = append(out, lib)
}
return out, rows.Err()
}
type scanner interface{ Scan(dest ...any) error }
func scanLibrary(row scanner) (Library, error) {
var lib Library
var created string
if err := row.Scan(&lib.ID, &lib.Name, &lib.Kind, &lib.RootPath, &created); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return Library{}, ErrNotFound
}
return Library{}, err
}
lib.CreatedAt, _ = time.Parse(time.RFC3339Nano, created)
return lib, nil
}
func isUnique(err error) bool {
if err == nil {
return false
}
msg := strings.ToUpper(err.Error())
return strings.Contains(msg, "UNIQUE CONSTRAINT FAILED") || strings.Contains(msg, "CONSTRAINT FAILED: UNIQUE")
}