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
|
||||
}
|
||||
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)
|
||||
}
|
||||
115
internal/library/mediainfo/lang.go
Normal file
115
internal/library/mediainfo/lang.go
Normal 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
|
||||
}
|
||||
64
internal/library/mediainfo/lang_test.go
Normal file
64
internal/library/mediainfo/lang_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
38
internal/library/mediainfo/types.go
Normal file
38
internal/library/mediainfo/types.go
Normal 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"`
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue