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.
156 lines
5.5 KiB
Go
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 ""
|
|
}
|