unarr/internal/library/mediainfo/ffprobe.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

281 lines
7 KiB
Go

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
}