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

156 lines
5.5 KiB
Go

package engine
import (
"fmt"
"strings"
)
// Resolution is the answer to "can this client play this file, and with what?".
//
// The JSON shape is frozen by PlaybackResolution in web/src/lib/api.ts:
// playable, engine (null when nothing can), reason, message. Reason is
// machine-readable ("vcodec:hevc"); message is the human text the UI shows.
type Resolution struct {
Playable bool `json:"playable"`
// Engine is the ID of the engine that should play the file, or nil when
// nothing the client declared can. It is a pointer so it serialises to JSON
// null, matching the frozen contract.
Engine *string `json:"engine"`
// Reason is empty when playable; otherwise a machine-readable cause such as
// "vcodec:hevc", "container:avi" or "acodec:dts".
Reason string `json:"reason"`
// Message is human text for the UI. Empty when playable.
Message string `json:"message"`
}
// Resolve decides whether a client can direct-play a file.
//
// fileFacts is the positional slice produced by Facts: container, video codec,
// audio codec. clientEngineIDs is the list of engines the CLIENT declared, in
// the client's own preference order.
//
// Semantics:
// - Only engines the client declared are considered. If the client declared
// none (an older client, or one that has not implemented capability
// detection), we fall back to the html5 baseline rather than refusing to
// answer.
// - An engine that can play the file always beats one that cannot; among
// capable engines the client's ordering wins, so a native client can say
// "try mpv first".
// - A file with no video codec (audio-only) is playable as soon as its audio
// codec is supported.
func Resolve(fileFacts, clientEngineIDs []string) Resolution {
container, vcodec, acodec := splitFacts(fileFacts)
ids := clientEngineIDs
if len(ids) == 0 {
// No declared engines: fall back to the conservative html5 baseline so
// the caller still gets a useful verdict. The client is always free to
// refine it (see web/src/lib/capabilities.ts).
ids = []string{HTML5ID}
}
// First declared capable engine wins.
for _, id := range ids {
e, ok := Get(id)
if !ok {
continue
}
if e.canPlay(container, vcodec, acodec) {
engineID := e.ID
return Resolution{Playable: true, Engine: &engineID}
}
}
// Nothing the client declared can play it. Explain the blocker using the
// client's first *known* engine — its stated preference — falling back to
// html5 when the client named only engines we do not know.
ref, ok := Get(firstKnown(ids))
if !ok {
ref, _ = Get(HTML5ID)
}
reason := blocker(ref, container, vcodec, acodec)
return Resolution{
Playable: false,
Reason: reason,
Message: message(reason, container, vcodec, acodec),
}
}
// splitFacts unpacks the positional fileFacts slice, tolerating a short slice.
func splitFacts(fileFacts []string) (container, vcodec, acodec string) {
get := func(i int) string {
if i < len(fileFacts) {
return fileFacts[i]
}
return ""
}
return get(0), get(1), get(2)
}
// firstKnown returns the first ID the registry knows, or "" if none.
func firstKnown(ids []string) string {
for _, id := range ids {
if _, ok := Get(id); ok {
return id
}
}
return ""
}
// blocker names the first fact the reference engine cannot handle, in the order
// container, video codec, audio codec. The reason is machine-readable; the
// caller turns it into a human message.
func blocker(e Engine, container, vcodec, acodec string) string {
switch {
case container != "" && !contains(e.Containers, container):
return "container:" + strings.ToLower(strings.TrimPrefix(container, "."))
case vcodec != "" && !contains(e.VCodecs, vcodec):
return "vcodec:" + strings.ToLower(vcodec)
case acodec != "" && !contains(e.ACodecs, acodec):
return "acodec:" + strings.ToLower(acodec)
default:
// canPlay was false, so one of the checks above should have matched.
return "unsupported"
}
}
// message renders the human explanation for a non-playable file. It names the
// offending codec, never blames the user, and — when a registered native engine
// could play the file — points at it, because the browser is not the only way
// in and a native client may well be on hand.
func message(reason, container, vcodec, acodec string) string {
var lead string
switch {
case strings.HasPrefix(reason, "container:"):
lead = fmt.Sprintf("This file is a %s, which this browser cannot open directly.",
strings.ToUpper(strings.TrimPrefix(container, ".")))
case strings.HasPrefix(reason, "vcodec:"):
lead = fmt.Sprintf("This file uses %s video, which this browser cannot decode.",
strings.ToUpper(vcodec))
case strings.HasPrefix(reason, "acodec:"):
lead = fmt.Sprintf("This file uses %s audio, which this browser cannot decode.",
strings.ToUpper(acodec))
default:
lead = "This browser cannot play this file directly."
}
if native := nativeEngineFor(container, vcodec, acodec); native != "" {
lead += fmt.Sprintf(" A native player such as %s can play this.", native)
}
lead += " Vellum does not transcode."
return lead
}
// nativeEngineFor returns the ID of a registered native engine that can play
// the file, or "" if none can. It is used only to make the message honest about
// the ways the file could still be played. mpv is named first because it is the
// engine Vellum's native client reaches for; the others follow.
func nativeEngineFor(container, vcodec, acodec string) string {
for _, id := range []string{"mpv", "vlc", "mdk"} {
if e, ok := Get(id); ok && e.Kind == KindNative && e.canPlay(container, vcodec, acodec) {
return e.ID
}
}
return ""
}