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.
96 lines
2.8 KiB
Go
96 lines
2.8 KiB
Go
package probe
|
|
|
|
import (
|
|
"context"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"testing"
|
|
)
|
|
|
|
// TestAvailable never skips: the answer is a plain bool and must not panic on a
|
|
// host without ffprobe.
|
|
func TestAvailable(t *testing.T) {
|
|
_ = Available()
|
|
}
|
|
|
|
// TestFile probes a fixture generated on the fly. It skips cleanly when ffprobe
|
|
// or ffmpeg is missing, so the suite passes on a machine with neither.
|
|
func TestFile(t *testing.T) {
|
|
if !Available() {
|
|
t.Skip("ffprobe not on PATH; skipping probe test")
|
|
}
|
|
ffmpeg, err := exec.LookPath("ffmpeg")
|
|
if err != nil {
|
|
t.Skip("ffmpeg not on PATH; cannot generate a fixture")
|
|
}
|
|
|
|
path := filepath.Join(t.TempDir(), "clip.mp4")
|
|
// One second of H.264 video and AAC audio in an MP4.
|
|
gen := exec.Command(ffmpeg, "-v", "error", "-y",
|
|
"-f", "lavfi", "-i", "testsrc=size=64x48:rate=10:duration=1",
|
|
"-f", "lavfi", "-i", "sine=frequency=440:duration=1",
|
|
"-c:v", "libx264", "-c:a", "aac", "-shortest", path,
|
|
)
|
|
if out, err := gen.CombinedOutput(); err != nil {
|
|
t.Skipf("ffmpeg cannot produce a fixture here: %v\n%s", err, out)
|
|
}
|
|
|
|
info, err := File(context.Background(), path)
|
|
if err != nil {
|
|
t.Fatalf("File: %v", err)
|
|
}
|
|
if info.VCodec != "h264" {
|
|
t.Errorf("VCodec = %q, want h264", info.VCodec)
|
|
}
|
|
if info.ACodec != "aac" {
|
|
t.Errorf("ACodec = %q, want aac", info.ACodec)
|
|
}
|
|
if info.Width != 64 || info.Height != 48 {
|
|
t.Errorf("dimensions = %dx%d, want 64x48", info.Width, info.Height)
|
|
}
|
|
if info.DurationMS < 500 || info.DurationMS > 2000 {
|
|
t.Errorf("DurationMS = %d, want ~1000", info.DurationMS)
|
|
}
|
|
}
|
|
|
|
// TestFileAudioOnly confirms an audio file reports no video codec, so a client
|
|
// does not mistake an mp3 for a video with a missing track.
|
|
func TestFileAudioOnly(t *testing.T) {
|
|
if !Available() {
|
|
t.Skip("ffprobe not on PATH; skipping probe test")
|
|
}
|
|
ffmpeg, err := exec.LookPath("ffmpeg")
|
|
if err != nil {
|
|
t.Skip("ffmpeg not on PATH; cannot generate a fixture")
|
|
}
|
|
|
|
path := filepath.Join(t.TempDir(), "tone.m4a")
|
|
gen := exec.Command(ffmpeg, "-v", "error", "-y",
|
|
"-f", "lavfi", "-i", "sine=frequency=440:duration=1",
|
|
"-c:a", "aac", path,
|
|
)
|
|
if out, err := gen.CombinedOutput(); err != nil {
|
|
t.Skipf("ffmpeg cannot produce a fixture here: %v\n%s", err, out)
|
|
}
|
|
|
|
info, err := File(context.Background(), path)
|
|
if err != nil {
|
|
t.Fatalf("File: %v", err)
|
|
}
|
|
if info.VCodec != "" {
|
|
t.Errorf("VCodec = %q, want empty for audio-only", info.VCodec)
|
|
}
|
|
if info.ACodec != "aac" {
|
|
t.Errorf("ACodec = %q, want aac", info.ACodec)
|
|
}
|
|
}
|
|
|
|
// TestFileMissing confirms a missing file is an error, not a silent zero Info.
|
|
func TestFileMissing(t *testing.T) {
|
|
if !Available() {
|
|
t.Skip("ffprobe not on PATH; skipping probe test")
|
|
}
|
|
if _, err := File(context.Background(), filepath.Join(t.TempDir(), "nope.mp4")); err == nil {
|
|
t.Fatal("File on a missing path: want error, got nil")
|
|
}
|
|
}
|