diff --git a/internal/library/scan.go b/internal/library/scan.go index adfe9d2..fb4e5c8 100644 --- a/internal/library/scan.go +++ b/internal/library/scan.go @@ -17,6 +17,7 @@ import ( "github.com/mithral/vellum/internal/core" "github.com/mithral/vellum/internal/jobs" "github.com/mithral/vellum/internal/natsort" + "github.com/mithral/vellum/internal/probe" ) const ( @@ -130,6 +131,17 @@ func Scan(ctx context.Context, db *sql.DB, lib Library, log *slog.Logger) (ScanR } batch := groupKeys[start:end] + // Probe the changed media files in this batch BEFORE opening the write + // transaction, so a slow ffprobe (a stalled mount, a huge file) can + // never hold SQLite's write lock. + // + // The probe map is deliberately built from the same changed-only test + // the transaction uses below. Probing on the unchanged path would turn + // every rescan into a full ffprobe sweep of the entire library; the + // size+mtime skip is what keeps rescans cheap, and the probe must live + // on the changed side of it. + probed := probeBatch(ctx, lib, groups, batch, knownFiles, &res) + err := commitBatch(ctx, db, func(tx *sql.Tx) error { for _, key := range batch { g := groups[key] @@ -150,7 +162,8 @@ func Scan(ctx context.Context, db *sql.DB, lib Library, log *slog.Logger) (ScanR continue } - if err := upsertFile(ctx, tx, itemID, c); err != nil { + info, wasProbed := probed[c.relPath] + if err := upsertFile(ctx, tx, itemID, c, info, wasProbed, now); err != nil { res.addError(fmt.Sprintf("%s: %v", c.relPath, err)) continue } @@ -431,21 +444,97 @@ func upsertItem(ctx context.Context, tx *sql.Tx, lib Library, key, title string) return id, nil } -func upsertFile(ctx context.Context, tx *sql.Tx, itemID int64, c candidate) error { +func upsertFile(ctx context.Context, tx *sql.Tx, itemID int64, c candidate, info probe.Info, probed bool, now string) error { + // A non-probed file (an ebook, a photo, or any file on a host without + // ffprobe) must keep whatever codec facts a previous scan recorded, so its + // upsert never mentions those columns at all. + if !probed { + _, err := tx.ExecContext(ctx, ` + INSERT INTO media_files (item_id, path, size, mtime, inode, container, missing) + VALUES (?, ?, ?, ?, ?, ?, 0) + ON CONFLICT (path) + DO UPDATE SET item_id = excluded.item_id, size = excluded.size, + mtime = excluded.mtime, inode = excluded.inode, + container = excluded.container, missing = 0`, + itemID, c.relPath, c.size, fmt.Sprint(c.mtime), c.inode, c.ext) + if err != nil { + return fmt.Errorf("upsert file: %w", err) + } + return nil + } + _, err := tx.ExecContext(ctx, ` - INSERT INTO media_files (item_id, path, size, mtime, inode, container, missing) - VALUES (?, ?, ?, ?, ?, ?, 0) + INSERT INTO media_files (item_id, path, size, mtime, inode, container, missing, + duration_ms, vcodec, acodec, width, height, probed_at) + VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?) ON CONFLICT (path) DO UPDATE SET item_id = excluded.item_id, size = excluded.size, mtime = excluded.mtime, inode = excluded.inode, - container = excluded.container, missing = 0`, - itemID, c.relPath, c.size, fmt.Sprint(c.mtime), c.inode, c.ext) + container = excluded.container, missing = 0, + duration_ms = excluded.duration_ms, vcodec = excluded.vcodec, + acodec = excluded.acodec, width = excluded.width, + height = excluded.height, probed_at = excluded.probed_at`, + itemID, c.relPath, c.size, fmt.Sprint(c.mtime), c.inode, c.ext, + info.DurationMS, info.VCodec, info.ACodec, info.Width, info.Height, now) if err != nil { return fmt.Errorf("upsert file: %w", err) } return nil } +// probeBatch probes every new or changed video/audio file in one batch and +// returns the facts keyed by relative path. +// +// Only files the transaction will treat as changed (a size+mtime mismatch, or a +// row that is absent or flagged missing) are probed; unchanged files are absent +// from the result, so no ffprobe runs and no codec column is rewritten for +// them. Non-media kinds (ebooks, comics, photos, games) are never probed: they +// hold no media streams, and running ffprobe over a library of them would be +// pure waste. +// +// It runs before commitBatch opens the write transaction, so the write lock is +// never held across an ffprobe. A probe failure is recorded through the bounded +// error mechanism and the file is written with empty codec fields rather than +// failing the scan. +func probeBatch(ctx context.Context, lib Library, groups map[string]group, batch []string, known map[string]knownFile, res *ScanResult) map[string]probe.Info { + if !probeable(lib.Kind) || !probe.Available() { + return nil + } + + out := make(map[string]probe.Info) + for _, key := range batch { + for _, c := range groups[key].files { + if prev, ok := known[c.relPath]; ok && !prev.missing && prev.size == c.size && prev.mtime == c.mtime { + continue // unchanged: never re-probe + } + + abs := filepath.Join(lib.RootPath, filepath.FromSlash(c.relPath)) + info, err := probe.File(ctx, abs) + if err != nil { + res.addError(fmt.Sprintf("%s: %v", c.relPath, err)) + // Record the attempt with empty fields; the codecs stay empty + // until the file changes again. + out[c.relPath] = probe.Info{} + continue + } + out[c.relPath] = info + } + } + return out +} + +// probeable reports whether a library kind holds files worth probing for codec +// facts. Only video and the audio kinds have media streams; ebooks, comics, +// photos, and games are never probed. +func probeable(kind string) bool { + switch kind { + case "video", "audiobooks", "music": + return true + default: + return false + } +} + func markItemMissing(ctx context.Context, tx *sql.Tx, libraryID int64, key, now string) error { _, err := tx.ExecContext(ctx, ` UPDATE media_items SET missing = 1 diff --git a/internal/media/handlers.go b/internal/media/handlers.go index da7fbdb..512c1e0 100644 --- a/internal/media/handlers.go +++ b/internal/media/handlers.go @@ -305,19 +305,40 @@ func (h *Handlers) authorized(w http.ResponseWriter, r *http.Request, u auth.Use return it, true } -func (h *Handlers) files(r *http.Request, itemID int64) ([]FileEntry, error) { +// fileDTO is one physical file in an item's detail response. It carries the +// ffprobe codec facts alongside the container, because direct play is a +// decision each client makes for itself: the server reports what is in the file +// and never transcodes. The wire shape is frozen by MediaFile in +// web/src/lib/api.ts. +type fileDTO struct { + ID int64 `json:"id"` + Path string `json:"path"` + Size int64 `json:"size"` + Container string `json:"container"` + Missing bool `json:"missing"` + DurationMS int64 `json:"duration_ms"` + VCodec string `json:"vcodec"` + ACodec string `json:"acodec"` + Width int `json:"width"` + Height int `json:"height"` +} + +func (h *Handlers) files(r *http.Request, itemID int64) ([]fileDTO, error) { rows, err := h.db.QueryContext(r.Context(), ` - SELECT id, path, size, container, missing FROM media_files WHERE item_id = ? ORDER BY id`, itemID) + SELECT id, path, size, container, missing, + duration_ms, vcodec, acodec, width, height + FROM media_files WHERE item_id = ? ORDER BY id`, itemID) if err != nil { return nil, err } defer rows.Close() - out := []FileEntry{} + out := []fileDTO{} for rows.Next() { - var f FileEntry + var f fileDTO var missing int - if err := rows.Scan(&f.ID, &f.Path, &f.Size, &f.Container, &missing); err != nil { + if err := rows.Scan(&f.ID, &f.Path, &f.Size, &f.Container, &missing, + &f.DurationMS, &f.VCodec, &f.ACodec, &f.Width, &f.Height); err != nil { return nil, err } f.Missing = missing == 1 diff --git a/internal/probe/probe.go b/internal/probe/probe.go new file mode 100644 index 0000000..bef714f --- /dev/null +++ b/internal/probe/probe.go @@ -0,0 +1,133 @@ +// 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"` +} diff --git a/internal/probe/probe_test.go b/internal/probe/probe_test.go new file mode 100644 index 0000000..c5e463e --- /dev/null +++ b/internal/probe/probe_test.go @@ -0,0 +1,96 @@ +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") + } +} diff --git a/internal/stream/stream.go b/internal/stream/stream.go index a70acaf..620a90e 100644 --- a/internal/stream/stream.go +++ b/internal/stream/stream.go @@ -38,11 +38,17 @@ var contentTypes = map[string]string{ ".ogg": "audio/ogg", ".wav": "audio/wav", // Video. + // + // This table and the library extension registry (internal/library/registry.go) + // must agree: an extension in the video registry with no MIME entry here is + // served as application/octet-stream, which browsers download instead of + // playing. .avi was in the registry but missing here. ".mp4": "video/mp4", ".m4v": "video/mp4", ".mkv": "video/x-matroska", ".webm": "video/webm", ".mov": "video/quicktime", + ".avi": "video/x-msvideo", // Images (comic pages, covers, photos). ".jpg": "image/jpeg", ".jpeg": "image/jpeg", diff --git a/migrations/0010_media_probe.sql b/migrations/0010_media_probe.sql new file mode 100644 index 0000000..8ea4032 --- /dev/null +++ b/migrations/0010_media_probe.sql @@ -0,0 +1,28 @@ +-- 0010_media_probe.sql — record the codec facts a client needs to choose direct +-- play. +-- +-- Vellum does not transcode. 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 job is only to expose what is actually inside +-- each file and let every client decide for itself. These columns are that +-- exposure; without them a client has nothing to base the decision on. +-- +-- The values come from ffprobe at scan time. They are empty/zero until the +-- scanner probes the file, and stay that way when ffprobe is not installed: +-- Vellum still works without it, just with less information. duration_ms +-- already exists on media_files from 0001, so it is reused rather than re-added. + +-- ffprobe codec name, e.g. "h264", "hevc", "vp9", "av1". "" when there is no +-- video track (audio, or embedded cover art only). +ALTER TABLE media_files ADD COLUMN vcodec TEXT NOT NULL DEFAULT ''; +-- ffprobe codec name, e.g. "aac", "opus", "flac", "mp3". "" when there is no +-- audio track. +ALTER TABLE media_files ADD COLUMN acodec TEXT NOT NULL DEFAULT ''; +-- Pixel dimensions of the video track; 0 when there is none. +ALTER TABLE media_files ADD COLUMN width INTEGER NOT NULL DEFAULT 0; +ALTER TABLE media_files ADD COLUMN height INTEGER NOT NULL DEFAULT 0; +-- When the codec facts above were last written, so a rescan can be proven not +-- to have re-probed an unchanged file. '' means never probed. +ALTER TABLE media_files ADD COLUMN probed_at TEXT NOT NULL DEFAULT ''; diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index fa08744..dba1f93 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -269,9 +269,30 @@ export interface Home { total_items: number } +// MediaFile carries the ffprobe facts about one file on disk. +// +// The codec fields are what make "direct play only" workable: playability is a +// property of the CLIENT, not the file, so the server reports what is in the +// file and each client decides for itself. An empty vcodec means there is no +// video track (audio, or a comic). +export interface MediaFile { + id: number + path: string + size: number + container: string + missing: boolean + duration_ms: number + /** ffprobe codec name, e.g. "h264", "hevc", "vp9", "av1". "" when absent. */ + vcodec: string + /** ffprobe codec name, e.g. "aac", "opus", "flac", "mp3". "" when absent. */ + acodec: string + width: number + height: number +} + export interface ItemDetail { item: Item - files: { id: number; path: string; size: number; container: string; missing: boolean }[] + files: MediaFile[] has_cover: boolean authors: string[] | null progress?: Progress diff --git a/web/src/lib/components/AudiobookPlayer.svelte b/web/src/lib/components/AudiobookPlayer.svelte index de5b297..e0ad4ac 100644 --- a/web/src/lib/components/AudiobookPlayer.svelte +++ b/web/src/lib/components/AudiobookPlayer.svelte @@ -1,11 +1,35 @@ + +{#if error} +
{notice}
+ Download the file +Playing video directly — no transcoding.
+ {/if} + + + +Space play/pause · ←/→ seek {SEEK_STEP}s · F fullscreen · M mute
+ + {#if files.length > 1} +