Vellum/internal/probe/probe.go
mithral 785d3ed5ee Waves 4-6: audio depth, ffprobe facts, and direct-play video
Direct play only. The server is a headless GPU-less Xeon: nothing is ever
transcoded or remuxed. A file either plays in the client or it does not.

Measured, not assumed (ffmpeg-generated files with known codecs, played through
Vellum's own stream endpoint in Chrome 155 / Linux):
  mp4  h264+aac   plays        mkv h264+aac  PLAYS   <- container is fine
  mov  h264+aac   plays        webm vp9/opus plays
  mp4  av1+opus   plays        webm vp8/vorbis plays
  mp4  hevc+aac   AUDIO ONLY, videoWidth stays 0, no error
  mkv  hevc+flac  AUDIO ONLY
  avi  mpeg4+mp3  fails outright
All seven audio formats play. Two conclusions drive the design: codec matters
and container barely does (mkv+h264 plays, mp4+HEVC does not), and canPlayType
is unreliable in both directions — it says "no" for mov+h264 (which plays) and
"maybe" for mkv+hevc (whose video never decodes).

Therefore playability is a property of the CLIENT, not the file. The server
never declares a file playable; it reports codec facts and each client decides.
That is what makes adding mpv/libvlc/MDK later a matter of registering an engine
rather than re-architecting.

Wave 4 - audio depth
- New MusicPlayer.svelte: real queue, shuffle that walks a fixed permutation
  (so "previous" means something and nothing repeats early), repeat off/all/one,
  per-track rows, album art, next-track preload.
- AudiobookPlayer: MediaSession (OS media keys + lock screen), playback speed
  that SURVIVES a chapter change and persists, sleep timer with countdown that a
  manual pause cancels, keyboard shortcuts, next-chapter preload.
- Shared mediaSession.ts written by the coordinator before dispatch so both
  players consume one implementation instead of inventing two.

Wave 5 - ffprobe facts
- internal/probe (ffprobe wrapper, optional dependency), migration 0010 adding
  vcodec/acodec/width/height/probed_at to media_files, scanner hook, API fields.
- Probing runs only for NEW or CHANGED files and BEFORE the write transaction
  opens, so it can never hold SQLite's write lock and a rescan never becomes a
  full-library ffprobe sweep. Proven: 797ms probing scan, then 1ms rescan with
  probed_at byte-identical.
- ffprobe skips attached_pic so an mp3 with cover art reports no video codec.
- Fixed a real hole: .avi is in the video registry but had no MIME entry, so it
  was served as application/octet-stream and could never play.

Wave 6 - the video player
- VideoPlayer.svelte with resume, progress + keepalive final flush, MediaSession,
  keyboard shortcuts, multi-file picker, and codec facts shown.
- The honest path is the feature: an HEVC file makes Chrome play audio over a
  blank frame with no error, which is the worst possible outcome. The player
  detects it and says so, naming the codec and pointing at clients that can.

Coordinator fix to Wave 6: the agent's detection hung off `loadedmetadata`, but
an explicit `type` the browser cannot satisfy makes it reject the source during
selection, so NEITHER loadedmetadata NOR error fires — and the player showed a
working-looking 0:00 with a Play button, exactly the silently-broken state the
component exists to prevent. Added a load watchdog: if the element says nothing
within 2.5s, the source was rejected and the honest notice is shown. Verified
per file afterwards (h264/mkv play at 640 wide; hevc and mpeg4 show their
notices).

Verified end to end, not just compiled: 15 packages pass (including the new
internal/probe), gofmt/vet clean, svelte-check 0/0, and every claim above checked
against a running server. Note the demo's own mp3s are 100 KB of random bytes and
cannot decode; the audio players were proven against generated real audio instead.
2026-10-11 14:14:32 -04:00

133 lines
4.1 KiB
Go

// Package probe reads the container and codec facts out of a media file with
// ffprobe, so a client can decide for itself whether it can direct-play the
// file.
//
// Vellum never transcodes. The server has no spare CPU, and measured against
// real files, playability turned out to be a property of the CLIENT, not the
// file: Chrome on Linux plays an MKV carrying H.264 perfectly well, yet cannot
// decode an MP4 carrying HEVC at all — it plays the audio track and reports
// videoWidth 0. So the server's only job is to expose what is actually inside
// the file; each client decides what it can handle.
//
// ffprobe is OPTIONAL. Vellum runs without it, just with less information:
// Available reports whether it is on PATH, and callers skip probing when it is
// absent rather than treating that as an error.
package probe
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os/exec"
"strconv"
"strings"
"time"
)
// timeout bounds a single ffprobe invocation. A file on a stalled network mount
// must never hang a scan, so ffprobe is killed after this and the error is
// recorded like any other probe failure.
const timeout = 15 * time.Second
// Info is the subset of ffprobe facts Vellum stores per file.
//
// Zero values are meaningful: an empty VCodec means there is no video track
// (audio, or a container whose only "video" stream is embedded cover art), and
// an empty ACodec means there is no audio track. DurationMS and the dimensions
// stay zero when ffprobe could not determine them.
type Info struct {
DurationMS int64
VCodec string
ACodec string
Width int
Height int
}
// Available reports whether ffprobe is on PATH.
//
// Callers use it to skip probing entirely, so Vellum still works on a host with
// no ffmpeg installed — the library just carries no codec facts, and every
// client is left to try the file and find out.
func Available() bool {
_, err := exec.LookPath("ffprobe")
return err == nil
}
// File probes one file and returns its codec facts. The returned context
// deadline is capped at timeout regardless of the caller's deadline.
func File(ctx context.Context, path string) (Info, error) {
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
cmd := exec.CommandContext(ctx, "ffprobe",
"-v", "error",
"-print_format", "json",
"-show_format",
"-show_streams",
path,
)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
if ctx.Err() != nil {
return Info{}, fmt.Errorf("probe %s: %w", path, ctx.Err())
}
msg := strings.TrimSpace(stderr.String())
if msg == "" {
msg = err.Error()
}
return Info{}, fmt.Errorf("probe %s: %s", path, msg)
}
var out ffprobeOutput
if err := json.Unmarshal(stdout.Bytes(), &out); err != nil {
return Info{}, fmt.Errorf("probe %s: parse ffprobe output: %w", path, err)
}
info := Info{}
for _, s := range out.Streams {
switch s.CodecType {
case "video":
// Embedded cover art (an mp3's album image) is reported as a video
// stream. It is not a playable video track, so ignore it and leave
// VCodec empty for audio files.
if s.Disposition.AttachedPic != 0 || info.VCodec != "" {
continue
}
info.VCodec = s.CodecName
info.Width = s.Width
info.Height = s.Height
case "audio":
if info.ACodec == "" {
info.ACodec = s.CodecName
}
}
}
// ffprobe reports the container duration in seconds as a decimal string.
// It is absent for some live/streamed inputs; leave DurationMS zero then.
if d, err := strconv.ParseFloat(out.Format.Duration, 64); err == nil && d > 0 {
info.DurationMS = int64(d * 1000)
}
return info, nil
}
// ffprobeOutput mirrors only the JSON fields Vellum reads from
// `ffprobe -print_format json -show_format -show_streams`.
type ffprobeOutput struct {
Format struct {
Duration string `json:"duration"`
} `json:"format"`
Streams []struct {
CodecType string `json:"codec_type"`
CodecName string `json:"codec_name"`
Width int `json:"width"`
Height int `json:"height"`
Disposition struct {
AttachedPic int `json:"attached_pic"`
} `json:"disposition"`
} `json:"streams"`
}