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
281
internal/library/mediainfo/ffprobe.go
Normal file
281
internal/library/mediainfo/ffprobe.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue