unarr/internal/library/mediainfo/ffprobe_download.go
Deivid Soto 677a8fe083 feat: add migrate command, media server detection, and debrid auto-config
- Migration wizard from Sonarr/Radarr/Prowlarr (unarr migrate) [pre-beta]
  - Auto-detect instances via Docker, config files, port scan, Prowlarr
  - Import wanted list (monitored+missing movies/series)
  - Import download history and blocklist to avoid re-downloading
  - Extract debrid tokens from *arr download clients
  - Quality profile mapping to preferred_quality config
  - DISTINCT ON PostgreSQL query for optimal torrent selection
  - JSON export with --dry-run --json (text to stderr, JSON to stdout)
- Media server detection (Plex/Jellyfin/Emby) in unarr init
  - Detects library paths and offers them as download directory options
- Debrid auto-configuration in unarr init
  - Scans *arr instances for debrid tokens
  - Validates and saves via API if user confirms
- New preferred_quality setting in config (2160p/1080p/720p)
- Library scan command (unarr scan) with ffprobe metadata extraction
2026-03-29 16:54:32 +02:00

176 lines
4.2 KiB
Go

package mediainfo
import (
"archive/zip"
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"runtime"
"time"
)
var (
ffprobeAPIClient = &http.Client{Timeout: 30 * time.Second}
ffprobeDLClient = &http.Client{Timeout: 10 * time.Minute}
)
const maxFFprobeZipSize = 100 * 1024 * 1024 // 100MB
const ffbinariesAPI = "https://ffbinaries.com/api/v1/version/latest"
type ffbinariesResponse struct {
Version string `json:"version"`
Bin map[string]map[string]string `json:"bin"`
}
// ffprobePlatformKey maps GOOS/GOARCH to ffbinaries platform keys.
func ffprobePlatformKey() (string, error) {
switch runtime.GOOS {
case "linux":
switch runtime.GOARCH {
case "amd64":
return "linux-64", nil
case "arm64":
return "linux-arm64", nil
}
case "darwin":
return "osx-64", nil
case "windows":
if runtime.GOARCH == "amd64" {
return "windows-64", nil
}
}
return "", fmt.Errorf("unsupported platform: %s/%s", runtime.GOOS, runtime.GOARCH)
}
// FFprobeCacheDir returns the directory where the downloaded ffprobe binary is stored.
func FFprobeCacheDir() (string, error) {
cacheDir, err := os.UserCacheDir()
if err != nil {
return "", err
}
return filepath.Join(cacheDir, "unarr", "bin"), nil
}
// FFprobeCachePath returns the full path to the cached ffprobe binary.
func FFprobeCachePath() (string, error) {
dir, err := FFprobeCacheDir()
if err != nil {
return "", err
}
name := "ffprobe"
if runtime.GOOS == "windows" {
name = "ffprobe.exe"
}
return filepath.Join(dir, name), nil
}
// DownloadFFprobe downloads a static ffprobe binary for the current platform
// and caches it locally. Returns the path to the binary.
func DownloadFFprobe() (string, error) {
dest, err := FFprobeCachePath()
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 := resolveFFprobeURL(platform)
if err != nil {
return "", err
}
fmt.Fprintf(os.Stderr, "ffprobe not found — downloading for %s...\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, maxFFprobeZipSize))
if err != nil {
return "", fmt.Errorf("download read failed: %w", err)
}
name := "ffprobe"
if runtime.GOOS == "windows" {
name = "ffprobe.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 ffprobe binary: %w", err)
}
fmt.Fprintf(os.Stderr, "ffprobe installed to %s\n", dest)
return dest, nil
}
func resolveFFprobeURL(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 ffprobe binary available for platform %q", platform)
}
url, ok := bins["ffprobe"]
if !ok {
return "", fmt.Errorf("no ffprobe download URL for platform %q", platform)
}
return url, nil
}
func extractFromZip(data []byte, target string) ([]byte, error) {
r, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return nil, fmt.Errorf("cannot open downloaded archive: %w", err)
}
for _, f := range r.File {
if filepath.Base(f.Name) == target {
rc, err := f.Open()
if err != nil {
return nil, fmt.Errorf("cannot extract %s from archive: %w", target, err)
}
defer rc.Close()
return io.ReadAll(rc)
}
}
return nil, fmt.Errorf("%s not found in downloaded archive", target)
}