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
This commit is contained in:
parent
0b6c6849b1
commit
677a8fe083
34 changed files with 4766 additions and 22 deletions
176
internal/library/mediainfo/ffprobe_download.go
Normal file
176
internal/library/mediainfo/ffprobe_download.go
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue