feat(mediainfo): ResolveFFmpeg + DownloadFFmpeg mirroring ffprobe pattern

Adds the ffmpeg-binary half of the resolution stack so the upcoming
WebRTC streaming transcoder (Fase 3.3) has a single point of entry.

Search order matches ResolveFFprobe so operators don't need to learn a
second mental model:
  1. Explicit path  (--ffmpeg flag / library.ffmpeg_path config)
  2. FFMPEG_PATH env var
  3. "ffmpeg" on PATH (system install)
  4. Adjacent to the unarr executable (release tarball bundles it here —
     this is the preferred path; see Fase 3.2 goreleaser changes)
  5. Cache dir (sibling of the cached ffprobe binary)
  6. Auto-download from ffbinaries.com (~70MB) as last resort

Includes:
- internal/library/mediainfo/ffmpeg.go         — ResolveFFmpeg + actionable
  Docker / non-Docker error messages
- internal/library/mediainfo/ffmpeg_download.go — DownloadFFmpeg, reuses
  ffprobePlatformKey + ffprobeAPIClient + ffprobeDLClient + extractFromZip
  helpers; bumps maxZipSize to 200MB (ffmpeg static is ~70-100MB)
- internal/config: LibraryConfig.FFmpegPath toml field for explicit paths
- 4 unit tests: explicit OK, explicit missing, env var, sibling cache path

Tarball bundling and the actual transcoding pipeline land in the next
two commits.
This commit is contained in:
Deivid Soto 2026-05-06 09:49:32 +02:00
parent aa291320f5
commit 727ab19468
4 changed files with 274 additions and 0 deletions

View file

@ -84,6 +84,7 @@ type LibraryConfig struct {
ScanPath string `toml:"scan_path"` // remembered from last scan
Workers int `toml:"workers"` // concurrent ffprobe (default 8)
FFprobePath string `toml:"ffprobe_path"` // optional explicit path
FFmpegPath string `toml:"ffmpeg_path"` // optional explicit path (used by WebRTC streaming transcoder)
BackupDir string `toml:"backup_dir"` // for replaced files
AutoScan bool `toml:"auto_scan"` // enable daily auto-scan in daemon (default true)
ScanInterval string `toml:"scan_interval"` // e.g. "24h", "12h", "6h" (default "24h")

View file

@ -0,0 +1,79 @@
package mediainfo
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
)
// ResolveFFmpeg finds the ffmpeg binary. Search order mirrors ResolveFFprobe
// so the same operator setup works for both:
// 1. Explicit path (--ffmpeg flag / library.ffmpeg_path config)
// 2. FFMPEG_PATH env var
// 3. "ffmpeg" on PATH
// 4. Adjacent to the current executable (release tarball bundles ffmpeg
// next to the unarr binary — this is the preferred install path)
// 5. Previously downloaded in the unarr cache dir
// 6. Auto-download static binary as last resort (~50MB, slow start)
//
// ffmpeg is required for the WebRTC streaming pipeline; ffprobe alone can't
// transcode HEVC/MKV to browser-friendly H.264/MP4 fragments.
func ResolveFFmpeg(explicit string) (string, error) {
if explicit != "" {
if _, err := os.Stat(explicit); err == nil {
return explicit, nil
}
return "", fmt.Errorf("ffmpeg not found at explicit path: %s", explicit)
}
if envPath := os.Getenv("FFMPEG_PATH"); envPath != "" {
if _, err := os.Stat(envPath); err == nil {
return envPath, nil
}
}
if p, err := exec.LookPath("ffmpeg"); err == nil {
return p, nil
}
if exePath, err := os.Executable(); err == nil {
name := "ffmpeg"
if runtime.GOOS == "windows" {
name = "ffmpeg.exe"
}
adjacent := filepath.Join(filepath.Dir(exePath), name)
if _, err := os.Stat(adjacent); err == nil {
return adjacent, nil
}
}
if cached, err := FFmpegCachePath(); err == nil {
if _, err := os.Stat(cached); err == nil {
return cached, nil
}
}
if p, err := DownloadFFmpeg(); err == nil {
return p, nil
}
if isDocker() {
return "", fmt.Errorf(
"ffmpeg not found and auto-download failed (read-only filesystem?).\n" +
"Options:\n" +
" • Use the official image: torrentclaw/unarr (includes ffmpeg)\n" +
" • Set FFMPEG_PATH env var to point to a pre-installed ffmpeg binary\n" +
" • Add to config.toml: [library]\\nffmpeg_path = \"/path/to/ffmpeg\"",
)
}
return "", fmt.Errorf(
"ffmpeg not found and auto-download failed.\n" +
"Options:\n" +
" • Install ffmpeg: sudo apt install ffmpeg (or brew install ffmpeg)\n" +
" • Use the unarr release tarball — ffmpeg is bundled next to the binary\n" +
" • Set FFMPEG_PATH env var to point to the ffmpeg binary\n" +
" • Add to config.toml: [library]\\nffmpeg_path = \"/path/to/ffmpeg\"",
)
}

View file

@ -0,0 +1,116 @@
package mediainfo
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"runtime"
)
const maxFFmpegZipSize = 200 * 1024 * 1024 // 200MB — ffmpeg static is ~70-100MB compressed
// FFmpegCachePath returns the full path to the cached ffmpeg binary
// (sibling of the cached ffprobe binary).
func FFmpegCachePath() (string, error) {
dir, err := FFprobeCacheDir()
if err != nil {
return "", err
}
name := "ffmpeg"
if runtime.GOOS == "windows" {
name = "ffmpeg.exe"
}
return filepath.Join(dir, name), nil
}
// DownloadFFmpeg downloads a static ffmpeg binary for the current platform
// and caches it locally. Returns the path to the binary. Reuses
// resolveFFprobeURL's ffbinaries.com discovery endpoint — that index ships
// both ffprobe and ffmpeg per platform.
func DownloadFFmpeg() (string, error) {
dest, err := FFmpegCachePath()
if err != nil {
return "", fmt.Errorf("cannot determine cache path: %w", err)
}
if _, err := os.Stat(dest); err == nil {
return dest, nil
}
platform, err := ffprobePlatformKey()
if err != nil {
return "", err
}
url, err := resolveFFmpegURL(platform)
if err != nil {
return "", err
}
fmt.Fprintf(os.Stderr, "ffmpeg not found — downloading for %s (~70MB)...\n", platform)
resp, err := ffprobeDLClient.Get(url)
if err != nil {
return "", fmt.Errorf("download failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("download failed: HTTP %d", resp.StatusCode)
}
zipData, err := io.ReadAll(io.LimitReader(resp.Body, maxFFmpegZipSize))
if err != nil {
return "", fmt.Errorf("download read failed: %w", err)
}
name := "ffmpeg"
if runtime.GOOS == "windows" {
name = "ffmpeg.exe"
}
binary, err := extractFromZip(zipData, name)
if err != nil {
return "", err
}
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return "", fmt.Errorf("cannot create cache directory: %w", err)
}
if err := os.WriteFile(dest, binary, 0o755); err != nil {
return "", fmt.Errorf("cannot write ffmpeg binary: %w", err)
}
fmt.Fprintf(os.Stderr, "ffmpeg installed to %s\n", dest)
return dest, nil
}
// resolveFFmpegURL fetches the ffbinaries index and returns the ffmpeg
// download URL for the requested platform key (e.g. "linux-64").
func resolveFFmpegURL(platform string) (string, error) {
resp, err := ffprobeAPIClient.Get(ffbinariesAPI)
if err != nil {
return "", fmt.Errorf("cannot reach ffbinaries.com: %w", err)
}
defer resp.Body.Close()
var data ffbinariesResponse
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
return "", fmt.Errorf("cannot parse ffbinaries response: %w", err)
}
bins, ok := data.Bin[platform]
if !ok {
return "", fmt.Errorf("no ffmpeg binary available for platform %q", platform)
}
url, ok := bins["ffmpeg"]
if !ok {
return "", fmt.Errorf("no ffmpeg download URL for platform %q", platform)
}
return url, nil
}

View file

@ -0,0 +1,78 @@
package mediainfo
import (
"os"
"path/filepath"
"runtime"
"testing"
)
// TestResolveFFmpeg_ExplicitOK verifies the explicit-path branch returns
// the requested binary if it exists on disk.
func TestResolveFFmpeg_ExplicitOK(t *testing.T) {
dir := t.TempDir()
fake := filepath.Join(dir, "ffmpeg")
if err := os.WriteFile(fake, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatalf("write fake: %v", err)
}
got, err := ResolveFFmpeg(fake)
if err != nil {
t.Fatalf("ResolveFFmpeg(explicit): %v", err)
}
if got != fake {
t.Fatalf("got %q want %q", got, fake)
}
}
// TestResolveFFmpeg_ExplicitMissing returns a clear error when the path
// the operator supplied doesn't exist — we do NOT silently fall back.
func TestResolveFFmpeg_ExplicitMissing(t *testing.T) {
_, err := ResolveFFmpeg("/nonexistent/path/ffmpeg-XXXXXX")
if err == nil {
t.Fatal("expected error for missing explicit path")
}
}
// TestResolveFFmpeg_EnvVar honours FFMPEG_PATH when no explicit path is given.
func TestResolveFFmpeg_EnvVar(t *testing.T) {
dir := t.TempDir()
fake := filepath.Join(dir, "ffmpeg")
if err := os.WriteFile(fake, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatalf("write fake: %v", err)
}
t.Setenv("FFMPEG_PATH", fake)
// Hide the real ffmpeg from PATH so the env var is the next branch hit.
t.Setenv("PATH", "/nonexistent")
got, err := ResolveFFmpeg("")
if err != nil {
t.Fatalf("ResolveFFmpeg(env): %v", err)
}
if got != fake {
t.Fatalf("got %q want %q (env-var branch)", got, fake)
}
}
// TestFFmpegCachePath returns a sibling path to the ffprobe cache,
// consistent with the install layout the tarball produces.
func TestFFmpegCachePath(t *testing.T) {
got, err := FFmpegCachePath()
if err != nil {
t.Fatalf("FFmpegCachePath: %v", err)
}
want := "ffmpeg"
if runtime.GOOS == "windows" {
want = "ffmpeg.exe"
}
if filepath.Base(got) != want {
t.Fatalf("cache path basename = %q want %q", filepath.Base(got), want)
}
probeCache, err := FFprobeCachePath()
if err != nil {
t.Fatalf("FFprobeCachePath: %v", err)
}
if filepath.Dir(got) != filepath.Dir(probeCache) {
t.Fatalf("ffmpeg cache (%s) and ffprobe cache (%s) should share a directory", got, probeCache)
}
}