diff --git a/internal/config/config.go b/internal/config/config.go index cb53280..bb7498c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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") diff --git a/internal/library/mediainfo/ffmpeg.go b/internal/library/mediainfo/ffmpeg.go new file mode 100644 index 0000000..113e7c7 --- /dev/null +++ b/internal/library/mediainfo/ffmpeg.go @@ -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\"", + ) +} diff --git a/internal/library/mediainfo/ffmpeg_download.go b/internal/library/mediainfo/ffmpeg_download.go new file mode 100644 index 0000000..6d4f81c --- /dev/null +++ b/internal/library/mediainfo/ffmpeg_download.go @@ -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 +} diff --git a/internal/library/mediainfo/ffmpeg_test.go b/internal/library/mediainfo/ffmpeg_test.go new file mode 100644 index 0000000..f2dd9af --- /dev/null +++ b/internal/library/mediainfo/ffmpeg_test.go @@ -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) + } +}