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:
Deivid Soto 2026-03-29 16:54:32 +02:00
parent 0b6c6849b1
commit 677a8fe083
34 changed files with 4766 additions and 22 deletions

View file

@ -0,0 +1,281 @@
package mediainfo
import (
"context"
"encoding/json"
"fmt"
"math"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
)
// ffprobeOutput matches the JSON structure from `ffprobe -show_streams -show_format`.
type ffprobeOutput struct {
Streams []ffprobeStream `json:"streams"`
Format ffprobeFormat `json:"format"`
}
type ffprobeFormat struct {
Duration string `json:"duration"`
}
type ffprobeStream struct {
CodecType string `json:"codec_type"`
CodecName string `json:"codec_name"`
Profile string `json:"profile"`
Channels int `json:"channels"`
Width int `json:"width"`
Height int `json:"height"`
BitsPerRaw string `json:"bits_per_raw_sample"`
PixFmt string `json:"pix_fmt"`
ColorSpace string `json:"color_space"`
ColorTransfer string `json:"color_transfer"`
ColorPrimaries string `json:"color_primaries"`
RFrameRate string `json:"r_frame_rate"`
Duration string `json:"duration"`
Tags map[string]string `json:"tags"`
Disposition map[string]int `json:"disposition"`
SideDataList []sideData `json:"side_data_list"`
}
type sideData struct {
SideDataType string `json:"side_data_type"`
}
// hdrProfiles maps (color_space, color_transfer) to HDR type.
var hdrProfiles = map[[2]string]string{
{"bt2020nc", "smpte2084"}: "HDR10",
{"bt2020nc", "arib-std-b67"}: "HLG",
}
// ExtractMediaInfo runs ffprobe on a file and parses audio, subtitle, and video streams.
func ExtractMediaInfo(ctx context.Context, ffprobePath, filePath string) (*MediaInfo, error) {
cmd := exec.CommandContext(ctx, ffprobePath,
"-v", "error",
"-print_format", "json",
"-show_streams",
"-show_format",
filePath,
)
var stderr strings.Builder
cmd.Stderr = &stderr
output, err := cmd.Output()
if err != nil {
if _, statErr := os.Stat(filePath); statErr != nil {
return nil, fmt.Errorf("ffprobe: file not found: %s", filePath)
}
return nil, fmt.Errorf("ffprobe failed (file=%s): %s", filePath, stderr.String())
}
var data ffprobeOutput
if err := json.Unmarshal(output, &data); err != nil {
return nil, fmt.Errorf("ffprobe JSON parse failed: %w", err)
}
if len(data.Streams) == 0 {
return nil, fmt.Errorf("ffprobe returned no streams")
}
var audioTracks []AudioTrack
var subtitleTracks []SubtitleTrack
var videoInfo *VideoInfo
for _, s := range data.Streams {
switch s.CodecType {
case "audio":
langRaw := tagValue(s.Tags, "language")
track := AudioTrack{
Lang: NormalizeLang(langRaw),
Codec: s.CodecName,
Channels: s.Channels,
}
if title := tagValue(s.Tags, "title"); title != "" {
track.Title = title
}
if s.Disposition["default"] == 1 {
track.Default = true
}
audioTracks = append(audioTracks, track)
case "subtitle":
langRaw := tagValue(s.Tags, "language")
track := SubtitleTrack{
Lang: NormalizeLang(langRaw),
Codec: s.CodecName,
}
if title := tagValue(s.Tags, "title"); title != "" {
track.Title = title
}
if s.Disposition["forced"] == 1 {
track.Forced = true
}
subtitleTracks = append(subtitleTracks, track)
case "video":
if videoInfo != nil {
continue // only first video stream
}
vi := &VideoInfo{
Codec: s.CodecName,
Width: s.Width,
Height: s.Height,
}
// Bit depth
if s.BitsPerRaw != "" {
if bd, err := strconv.Atoi(s.BitsPerRaw); err == nil {
vi.BitDepth = bd
}
} else if containsAny(s.PixFmt, "10le", "10be", "p010") {
vi.BitDepth = 10
} else if containsAny(s.PixFmt, "12le", "12be") {
vi.BitDepth = 12
}
// HDR detection
hdrKey := [2]string{s.ColorSpace, s.ColorTransfer}
if hdr, ok := hdrProfiles[hdrKey]; ok {
vi.HDR = hdr
} else if s.ColorTransfer == "smpte2084" {
vi.HDR = "HDR10"
} else if s.ColorTransfer == "arib-std-b67" {
vi.HDR = "HLG"
}
// Dolby Vision via side_data_list
for _, sd := range s.SideDataList {
if sd.SideDataType == "DOVI configuration record" {
if vi.HDR != "" {
vi.HDR = "DV+" + vi.HDR
} else {
vi.HDR = "DV"
}
break
}
}
// Frame rate from r_frame_rate (e.g., "24000/1001")
if s.RFrameRate != "" && strings.Contains(s.RFrameRate, "/") {
parts := strings.SplitN(s.RFrameRate, "/", 2)
if num, err1 := strconv.ParseFloat(parts[0], 64); err1 == nil {
if den, err2 := strconv.ParseFloat(parts[1], 64); err2 == nil && den > 0 {
vi.FrameRate = math.Round(num/den*1000) / 1000
}
}
}
// Profile
if s.Profile != "" {
vi.Profile = s.Profile
}
// Duration: prefer format.duration, fallback to stream duration
if dur := parseDuration(data.Format.Duration); dur > 0 {
vi.Duration = dur
} else if dur := parseDuration(s.Duration); dur > 0 {
vi.Duration = dur
}
videoInfo = vi
}
}
result := &MediaInfo{
Video: videoInfo,
}
if len(audioTracks) > 0 {
result.Audio = audioTracks
result.Languages = ComputeLanguages(audioTracks)
}
if len(subtitleTracks) > 0 {
result.Subtitles = subtitleTracks
}
return result, nil
}
// ResolveFFprobe finds the ffprobe binary. Search order:
// 1. Explicit path (--ffprobe flag)
// 2. FFPROBE_PATH env var
// 3. "ffprobe" in PATH
// 4. Adjacent to the current executable
// 5. Previously downloaded in cache dir
// 6. Auto-download static binary
func ResolveFFprobe(explicit string) (string, error) {
if explicit != "" {
if _, err := os.Stat(explicit); err == nil {
return explicit, nil
}
return "", fmt.Errorf("ffprobe not found at explicit path: %s", explicit)
}
if envPath := os.Getenv("FFPROBE_PATH"); envPath != "" {
if _, err := os.Stat(envPath); err == nil {
return envPath, nil
}
}
if p, err := exec.LookPath("ffprobe"); err == nil {
return p, nil
}
if exePath, err := os.Executable(); err == nil {
name := "ffprobe"
if runtime.GOOS == "windows" {
name = "ffprobe.exe"
}
adjacent := filepath.Join(filepath.Dir(exePath), name)
if _, err := os.Stat(adjacent); err == nil {
return adjacent, nil
}
}
if cached, err := FFprobeCachePath(); err == nil {
if _, err := os.Stat(cached); err == nil {
return cached, nil
}
}
if p, err := DownloadFFprobe(); err == nil {
return p, nil
}
return "", fmt.Errorf("ffprobe not found. Install ffmpeg or provide --ffprobe path")
}
// tagValue gets a tag value case-insensitively.
func tagValue(tags map[string]string, key string) string {
if v, ok := tags[key]; ok {
return v
}
if v, ok := tags[strings.ToUpper(key)]; ok {
return v
}
return ""
}
func containsAny(s string, substrs ...string) bool {
for _, sub := range substrs {
if strings.Contains(s, sub) {
return true
}
}
return false
}
// parseDuration converts a duration string (e.g. "7423.500000") to float64 seconds.
func parseDuration(s string) float64 {
if s == "" {
return 0
}
d, err := strconv.ParseFloat(s, 64)
if err != nil || d <= 0 {
return 0
}
return math.Round(d*1000) / 1000
}

View 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)
}

View file

@ -0,0 +1,115 @@
package mediainfo
import (
"sort"
"strings"
)
// langNormalize maps ISO 639-2/B, 639-2/T, 639-1 codes, and full English
// language names (as returned by some ffprobe metadata) to ISO 639-1.
var langNormalize = map[string]string{
// ISO codes
"eng": "en", "en": "en",
"spa": "es", "es": "es",
"fre": "fr", "fra": "fr", "fr": "fr",
"ger": "de", "deu": "de", "de": "de",
"ita": "it", "it": "it",
"por": "pt", "pt": "pt",
"rus": "ru", "ru": "ru",
"jpn": "ja", "ja": "ja",
"kor": "ko", "ko": "ko",
"chi": "zh", "zho": "zh", "zh": "zh",
"hin": "hi", "hi": "hi",
"ara": "ar", "ar": "ar",
"dut": "nl", "nld": "nl", "nl": "nl",
"pol": "pl", "pl": "pl",
"tur": "tr", "tr": "tr",
"swe": "sv", "sv": "sv",
"nor": "no", "nob": "no", "nno": "no", "no": "no",
"dan": "da", "da": "da",
"fin": "fi", "fi": "fi",
"cze": "cs", "ces": "cs", "cs": "cs",
"hun": "hu", "hu": "hu",
"rum": "ro", "ron": "ro", "ro": "ro",
"gre": "el", "ell": "el", "el": "el",
"tha": "th", "th": "th",
"vie": "vi", "vi": "vi",
"ind": "id", "id": "id",
"heb": "he", "he": "he",
"ukr": "uk", "uk": "uk",
"cat": "ca", "ca": "ca",
"bul": "bg", "bg": "bg",
"hrv": "hr", "hr": "hr",
"srp": "sr", "sr": "sr",
"slv": "sl", "sl": "sl",
"lit": "lt", "lt": "lt",
"lav": "lv", "lv": "lv",
"est": "et", "et": "et",
"per": "fa", "fas": "fa", "fa": "fa",
"may": "ms", "msa": "ms", "ms": "ms",
"tgl": "tl", "tl": "tl",
"tam": "ta", "ta": "ta",
"tel": "te", "te": "te",
"ben": "bn", "bn": "bn",
"urd": "ur", "ur": "ur",
"geo": "ka", "kat": "ka", "ka": "ka",
"arm": "hy", "hye": "hy", "hy": "hy",
"alb": "sq", "sqi": "sq", "sq": "sq",
"mac": "mk", "mkd": "mk", "mk": "mk",
"ice": "is", "isl": "is", "is": "is",
"glg": "gl", "gl": "gl",
"baq": "eu", "eus": "eu", "eu": "eu",
"wel": "cy", "cym": "cy", "cy": "cy",
"gle": "ga", "ga": "ga",
"mlt": "mt", "mt": "mt",
"swa": "sw", "sw": "sw",
"afr": "af", "af": "af",
"lat": "la", "la": "la",
// Full English names (ffprobe sometimes returns these instead of codes)
"english": "en", "spanish": "es", "french": "fr", "german": "de",
"italian": "it", "portuguese": "pt", "russian": "ru", "japanese": "ja",
"korean": "ko", "chinese": "zh", "hindi": "hi", "arabic": "ar",
"dutch": "nl", "polish": "pl", "turkish": "tr", "swedish": "sv",
"norwegian": "no", "danish": "da", "finnish": "fi", "czech": "cs",
"hungarian": "hu", "romanian": "ro", "greek": "el", "thai": "th",
"vietnamese": "vi", "indonesian": "id", "hebrew": "he", "ukrainian": "uk",
"catalan": "ca", "bulgarian": "bg", "croatian": "hr", "serbian": "sr",
"slovenian": "sl", "lithuanian": "lt", "latvian": "lv", "estonian": "et",
"persian": "fa", "malay": "ms", "tagalog": "tl", "tamil": "ta",
"telugu": "te", "bengali": "bn", "urdu": "ur", "georgian": "ka",
"armenian": "hy", "albanian": "sq", "macedonian": "mk", "icelandic": "is",
"galician": "gl", "basque": "eu", "welsh": "cy", "irish": "ga",
"maltese": "mt", "swahili": "sw", "afrikaans": "af", "latin": "la",
}
// NormalizeLang converts a language code to ISO 639-1.
// Returns "und" for empty input, the input lowercased if no mapping is found.
func NormalizeLang(raw string) string {
if raw == "" {
return "und"
}
lower := strings.ToLower(raw)
if mapped, ok := langNormalize[lower]; ok {
return mapped
}
return lower
}
// ComputeLanguages extracts unique ISO 639-1 language codes from audio tracks.
func ComputeLanguages(audioTracks []AudioTrack) []string {
seen := make(map[string]struct{})
for _, t := range audioTracks {
lang := t.Lang
if lang != "" && lang != "und" && len(lang) <= 3 {
seen[lang] = struct{}{}
}
}
result := make([]string, 0, len(seen))
for l := range seen {
result = append(result, l)
}
sort.Strings(result)
return result
}

View file

@ -0,0 +1,64 @@
package mediainfo
import "testing"
func TestNormalizeLang(t *testing.T) {
tests := []struct {
input string
want string
}{
{"", "und"},
{"eng", "en"},
{"spa", "es"},
{"fre", "fr"},
{"fra", "fr"},
{"ger", "de"},
{"deu", "de"},
{"en", "en"},
{"es", "es"},
{"English", "en"},
{"SPANISH", "es"},
{"Japanese", "ja"},
{"jpn", "ja"},
{"chi", "zh"},
{"zho", "zh"},
{"und", "und"},
{"xyz", "xyz"}, // unknown → lowercase passthrough
{"POR", "pt"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := NormalizeLang(tt.input)
if got != tt.want {
t.Errorf("NormalizeLang(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestComputeLanguages(t *testing.T) {
tracks := []AudioTrack{
{Lang: "en", Codec: "aac", Channels: 2},
{Lang: "es", Codec: "ac3", Channels: 6},
{Lang: "en", Codec: "dts", Channels: 6}, // duplicate
{Lang: "und", Codec: "aac", Channels: 2},
{Lang: "", Codec: "aac", Channels: 2},
}
langs := ComputeLanguages(tracks)
if len(langs) != 2 {
t.Fatalf("expected 2 languages, got %d: %v", len(langs), langs)
}
if langs[0] != "en" || langs[1] != "es" {
t.Errorf("expected [en es], got %v", langs)
}
}
func TestComputeLanguagesEmpty(t *testing.T) {
langs := ComputeLanguages(nil)
if len(langs) != 0 {
t.Errorf("expected empty, got %v", langs)
}
}

View file

@ -0,0 +1,38 @@
package mediainfo
// MediaInfo holds the media analysis result from ffprobe.
type MediaInfo struct {
Video *VideoInfo `json:"video"`
Audio []AudioTrack `json:"audio"`
Subtitles []SubtitleTrack `json:"subtitles"`
Languages []string `json:"languages"` // derived from audio tracks
}
// VideoInfo represents the primary video stream metadata.
type VideoInfo struct {
Codec string `json:"codec"` // "hevc", "h264", "av1"
Width int `json:"width"`
Height int `json:"height"`
BitDepth int `json:"bitDepth"` // 8, 10, 12
HDR string `json:"hdr"` // "HDR10", "DV", "HLG", "DV+HDR10", ""
FrameRate float64 `json:"frameRate"` // e.g. 23.976
Profile string `json:"profile"` // e.g. "Main 10", "High"
Duration float64 `json:"duration"` // seconds
}
// AudioTrack represents a single audio stream.
type AudioTrack struct {
Lang string `json:"lang"` // ISO 639-1
Codec string `json:"codec"` // "aac", "ac3", "dts", "truehd"
Channels int `json:"channels"` // 2, 6, 8
Title string `json:"title"`
Default bool `json:"default"`
}
// SubtitleTrack represents a single subtitle stream.
type SubtitleTrack struct {
Lang string `json:"lang"`
Codec string `json:"codec"`
Title string `json:"title"`
Forced bool `json:"forced"`
}