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

86
internal/library/cache.go Normal file
View file

@ -0,0 +1,86 @@
package library
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"github.com/torrentclaw/torrentclaw-cli/internal/config"
)
// CachePath returns the default library cache file path.
func CachePath() string {
return filepath.Join(config.DataDir(), "library.json")
}
// LoadCache reads the library cache from disk. Returns nil if file doesn't exist.
func LoadCache() (*LibraryCache, error) {
return LoadCacheFrom(CachePath())
}
// LoadCacheFrom reads the library cache from a specific path.
func LoadCacheFrom(path string) (*LibraryCache, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("read cache: %w", err)
}
var cache LibraryCache
if err := json.Unmarshal(data, &cache); err != nil {
return nil, fmt.Errorf("parse cache: %w", err)
}
if cache.Version != cacheVersion {
return nil, nil // incompatible version, treat as missing
}
return &cache, nil
}
// SaveCache writes the library cache to disk atomically.
func SaveCache(cache *LibraryCache) error {
return SaveCacheTo(cache, CachePath())
}
// SaveCacheTo writes the library cache to a specific path atomically.
func SaveCacheTo(cache *LibraryCache, path string) error {
cache.Version = cacheVersion
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("create cache dir: %w", err)
}
data, err := json.MarshalIndent(cache, "", " ")
if err != nil {
return fmt.Errorf("encode cache: %w", err)
}
tmpPath := path + ".tmp"
if err := os.WriteFile(tmpPath, data, 0o644); err != nil {
return fmt.Errorf("write temp cache: %w", err)
}
if err := os.Rename(tmpPath, path); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("rename cache: %w", err)
}
return nil
}
// BuildCacheIndex creates a lookup map from filePath → index for incremental scanning.
func BuildCacheIndex(cache *LibraryCache) map[string]int {
if cache == nil {
return nil
}
idx := make(map[string]int, len(cache.Items))
for i, item := range cache.Items {
idx[item.FilePath] = i
}
return idx
}

View file

@ -0,0 +1,99 @@
package library
import (
"os"
"path/filepath"
"testing"
)
func TestSaveCacheAndLoad(t *testing.T) {
tmpDir := t.TempDir()
path := filepath.Join(tmpDir, "library.json")
cache := &LibraryCache{
Version: cacheVersion,
ScannedAt: "2026-03-29T10:00:00Z",
Path: "/media/movies",
Items: []LibraryItem{
{
FilePath: "/media/movies/Inception.mkv",
FileName: "Inception.mkv",
FileSize: 5000000000,
ModTime: "2026-01-15T12:00:00Z",
Title: "Inception",
Year: "2010",
Quality: "1080p",
},
},
}
// Save
if err := SaveCacheTo(cache, path); err != nil {
t.Fatalf("SaveCacheTo: %v", err)
}
// Verify file exists
if _, err := os.Stat(path); err != nil {
t.Fatalf("cache file not found: %v", err)
}
// Load
loaded, err := LoadCacheFrom(path)
if err != nil {
t.Fatalf("LoadCacheFrom: %v", err)
}
if loaded == nil {
t.Fatal("loaded cache is nil")
}
if loaded.Version != cacheVersion {
t.Errorf("version = %d, want %d", loaded.Version, cacheVersion)
}
if loaded.Path != "/media/movies" {
t.Errorf("path = %q, want %q", loaded.Path, "/media/movies")
}
if len(loaded.Items) != 1 {
t.Fatalf("items count = %d, want 1", len(loaded.Items))
}
if loaded.Items[0].Title != "Inception" {
t.Errorf("title = %q, want %q", loaded.Items[0].Title, "Inception")
}
}
func TestLoadCacheNonExistent(t *testing.T) {
cache, err := LoadCacheFrom("/tmp/nonexistent-unarr-test.json")
if err != nil {
t.Fatalf("expected nil error, got: %v", err)
}
if cache != nil {
t.Fatalf("expected nil cache, got: %v", cache)
}
}
func TestBuildCacheIndex(t *testing.T) {
cache := &LibraryCache{
Items: []LibraryItem{
{FilePath: "/a.mkv"},
{FilePath: "/b.mkv"},
{FilePath: "/c.mkv"},
},
}
idx := BuildCacheIndex(cache)
if idx["/a.mkv"] != 0 {
t.Errorf("expected index 0 for /a.mkv, got %d", idx["/a.mkv"])
}
if idx["/b.mkv"] != 1 {
t.Errorf("expected index 1 for /b.mkv, got %d", idx["/b.mkv"])
}
if idx["/c.mkv"] != 2 {
t.Errorf("expected index 2 for /c.mkv, got %d", idx["/c.mkv"])
}
}
func TestBuildCacheIndexNil(t *testing.T) {
idx := BuildCacheIndex(nil)
if idx != nil {
t.Errorf("expected nil, got %v", idx)
}
}

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"`
}

142
internal/library/resolve.go Normal file
View file

@ -0,0 +1,142 @@
package library
import (
"regexp"
"strings"
"github.com/torrentclaw/torrentclaw-cli/internal/library/mediainfo"
)
var (
seasonRegex = regexp.MustCompile(`(?i)S(\d{1,2})E(\d{1,2})`)
seasonOnly = regexp.MustCompile(`(?i)S(\d{1,2})(?:\b|$)`)
altEpRegex = regexp.MustCompile(`(?i)(\d{1,2})x(\d{2})`)
)
// ResolveResolution maps a pixel height to a standard resolution label.
func ResolveResolution(height int) string {
switch {
case height >= 2000:
return "2160p"
case height >= 900:
return "1080p"
case height >= 600:
return "720p"
case height >= 400:
return "480p"
default:
return ""
}
}
// DeriveContentType guesses "movie" or "show" from parsed metadata.
func DeriveContentType(item LibraryItem) string {
if item.Season > 0 || item.Episode > 0 {
return "show"
}
// Check filename for season/episode patterns
if seasonRegex.MatchString(item.FileName) || altEpRegex.MatchString(item.FileName) || seasonOnly.MatchString(item.FileName) {
return "show"
}
return "movie"
}
// ParseSeasonEpisode extracts season and episode numbers from a filename.
func ParseSeasonEpisode(filename string) (season, episode int) {
// S01E05
if m := seasonRegex.FindStringSubmatch(filename); len(m) > 2 {
season = atoi(m[1])
episode = atoi(m[2])
return
}
// 1x05
if m := altEpRegex.FindStringSubmatch(filename); len(m) > 2 {
season = atoi(m[1])
episode = atoi(m[2])
return
}
// S01 only (season pack)
if m := seasonOnly.FindStringSubmatch(filename); len(m) > 1 {
season = atoi(m[1])
return
}
return 0, 0
}
// PrimaryAudioTrack returns the codec and channel count of the default or first audio track.
func PrimaryAudioTrack(tracks []mediainfo.AudioTrack) (codec string, channels int) {
if len(tracks) == 0 {
return "", 0
}
for _, t := range tracks {
if t.Default {
return t.Codec, t.Channels
}
}
return tracks[0].Codec, tracks[0].Channels
}
// AudioLanguages extracts unique language codes from audio tracks.
func AudioLanguages(tracks []mediainfo.AudioTrack) []string {
return mediainfo.ComputeLanguages(tracks)
}
// SubtitleLanguages extracts unique language codes from subtitle tracks.
func SubtitleLanguages(tracks []mediainfo.SubtitleTrack) []string {
seen := make(map[string]struct{})
for _, t := range tracks {
if t.Lang != "" && t.Lang != "und" {
seen[t.Lang] = struct{}{}
}
}
result := make([]string, 0, len(seen))
for l := range seen {
result = append(result, l)
}
return result
}
// CleanTitle extracts a clean title from a filename for searching.
// Removes extension, replaces separators with spaces, strips release artifacts.
func CleanTitle(filename string) string {
// Remove extension
name := strings.TrimSuffix(filename, extOf(filename))
// Remove release group at end BEFORE replacing separators (e.g. "-SPARKS", "-FGT")
name = regexp.MustCompile(`-[A-Za-z0-9]+$`).ReplaceAllString(name, "")
// Remove brackets
name = regexp.MustCompile(`[\[\(].*?[\]\)]`).ReplaceAllString(name, "")
// Replace common separators with spaces
name = strings.NewReplacer(".", " ", "_", " ", "-", " ").Replace(name)
// Remove quality/codec/release artifacts
name = regexp.MustCompile(`(?i)\b(2160p|1080p|720p|480p|4K|UHD|BluRay|BDRip|WEBRip|WEB-DL|HDTV|DVDRip|BRRip|x264|x265|HEVC|AVC|AV1|AAC|DTS|AC3|Atmos|FLAC|10bit|HDR10?\+?|DV|DoVi|PROPER|REPACK|REMUX|EXTENDED|DUAL|MULTi)\b`).ReplaceAllString(name, "")
// Remove year
name = regexp.MustCompile(`\b(19|20)\d{2}\b`).ReplaceAllString(name, "")
// Collapse whitespace
name = regexp.MustCompile(`\s+`).ReplaceAllString(name, " ")
return strings.TrimSpace(name)
}
func extOf(filename string) string {
for i := len(filename) - 1; i >= 0; i-- {
if filename[i] == '.' {
return filename[i:]
}
}
return ""
}
func atoi(s string) int {
n := 0
for _, c := range s {
if c >= '0' && c <= '9' {
n = n*10 + int(c-'0')
}
}
return n
}

View file

@ -0,0 +1,156 @@
package library
import (
"testing"
"github.com/torrentclaw/torrentclaw-cli/internal/library/mediainfo"
)
func TestResolveResolution(t *testing.T) {
tests := []struct {
height int
want string
}{
{2160, "2160p"},
{2000, "2160p"},
{1080, "1080p"},
{1920, "1080p"}, // 1920 is width, not height — height for 1080p is ~1080
{900, "1080p"},
{720, "720p"},
{600, "720p"},
{576, "480p"},
{480, "480p"},
{400, "480p"},
{360, ""},
{0, ""},
}
for _, tt := range tests {
got := ResolveResolution(tt.height)
if got != tt.want {
t.Errorf("ResolveResolution(%d) = %q, want %q", tt.height, got, tt.want)
}
}
}
func TestDeriveContentType(t *testing.T) {
tests := []struct {
name string
item LibraryItem
want string
}{
{
"movie by default",
LibraryItem{FileName: "Inception.2010.1080p.mkv"},
"movie",
},
{
"show by season field",
LibraryItem{FileName: "something.mkv", Season: 1},
"show",
},
{
"show by episode field",
LibraryItem{FileName: "something.mkv", Episode: 5},
"show",
},
{
"show by S01E01 in filename",
LibraryItem{FileName: "Breaking.Bad.S01E01.1080p.mkv"},
"show",
},
{
"show by 1x05 in filename",
LibraryItem{FileName: "show.1x05.720p.mkv"},
"show",
},
{
"show by S02 in filename",
LibraryItem{FileName: "Show.Name.S02.Complete.mkv"},
"show",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := DeriveContentType(tt.item)
if got != tt.want {
t.Errorf("DeriveContentType() = %q, want %q", got, tt.want)
}
})
}
}
func TestParseSeasonEpisode(t *testing.T) {
tests := []struct {
filename string
season int
episode int
}{
{"Breaking.Bad.S01E05.1080p.mkv", 1, 5},
{"Show.S02E10.720p.mkv", 2, 10},
{"show.1x05.mkv", 1, 5},
{"show.12x03.mkv", 12, 3},
{"Show.S01.Complete.mkv", 1, 0},
{"Inception.2010.1080p.mkv", 0, 0},
{"s3e7.mkv", 3, 7},
}
for _, tt := range tests {
t.Run(tt.filename, func(t *testing.T) {
s, e := ParseSeasonEpisode(tt.filename)
if s != tt.season || e != tt.episode {
t.Errorf("ParseSeasonEpisode(%q) = (%d, %d), want (%d, %d)", tt.filename, s, e, tt.season, tt.episode)
}
})
}
}
func TestPrimaryAudioTrack(t *testing.T) {
// Default track
tracks := []mediainfo.AudioTrack{
{Lang: "en", Codec: "aac", Channels: 2, Default: false},
{Lang: "es", Codec: "ac3", Channels: 6, Default: true},
}
codec, ch := PrimaryAudioTrack(tracks)
if codec != "ac3" || ch != 6 {
t.Errorf("expected ac3/6, got %s/%d", codec, ch)
}
// No default → first
tracks2 := []mediainfo.AudioTrack{
{Lang: "en", Codec: "dts", Channels: 8},
{Lang: "es", Codec: "aac", Channels: 2},
}
codec, ch = PrimaryAudioTrack(tracks2)
if codec != "dts" || ch != 8 {
t.Errorf("expected dts/8, got %s/%d", codec, ch)
}
// Empty
codec, ch = PrimaryAudioTrack(nil)
if codec != "" || ch != 0 {
t.Errorf("expected empty, got %s/%d", codec, ch)
}
}
func TestCleanTitle(t *testing.T) {
tests := []struct {
input string
want string
}{
{"Inception.2010.1080p.BluRay.x264-SPARKS.mkv", "Inception"},
{"Breaking.Bad.S01E05.720p.HDTV.mkv", "Breaking Bad S01E05"},
{"The.Matrix.1999.2160p.UHD.BluRay.REMUX.mkv", "The Matrix"},
{"Movie [YTS.MX].mp4", "Movie"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := CleanTitle(tt.input)
if got != tt.want {
t.Errorf("CleanTitle(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}

210
internal/library/scanner.go Normal file
View file

@ -0,0 +1,210 @@
package library
import (
"context"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/torrentclaw/torrentclaw-cli/internal/library/mediainfo"
"github.com/torrentclaw/torrentclaw-cli/internal/parser"
)
// videoExts are file extensions considered as video files.
var videoExts = map[string]bool{
".mkv": true, ".mp4": true, ".avi": true, ".m4v": true,
".ts": true, ".wmv": true, ".mov": true, ".webm": true,
".flv": true, ".mpg": true, ".mpeg": true, ".vob": true,
}
// excludePatterns are path substrings that indicate non-content files.
var excludePatterns = []string{
"sample", "trailer", "featurette", "extras", "bonus",
"behind the scenes", "deleted scenes", "interview",
}
const minFileSize = 100 * 1024 * 1024 // 100MB minimum
// ScanOptions configures the library scanner.
type ScanOptions struct {
Workers int // concurrent ffprobe processes (default 8)
FFprobePath string // explicit path, or auto-resolve
Incremental bool // skip unchanged files (mtime+size match cache)
OnProgress func(scanned, total int, current string)
}
// Scan walks a directory recursively, finds video files, and runs ffprobe on each.
func Scan(ctx context.Context, dirPath string, existing *LibraryCache, opts ScanOptions) (*LibraryCache, error) {
if opts.Workers <= 0 {
opts.Workers = 8
}
// Resolve ffprobe
ffprobePath, err := mediainfo.ResolveFFprobe(opts.FFprobePath)
if err != nil {
return nil, fmt.Errorf("ffprobe: %w", err)
}
// Discover video files
files, err := discoverFiles(dirPath)
if err != nil {
return nil, fmt.Errorf("discover files: %w", err)
}
if len(files) == 0 {
return &LibraryCache{
Version: cacheVersion,
ScannedAt: time.Now().UTC().Format(time.RFC3339),
Path: dirPath,
}, nil
}
// Build cache index for incremental mode
cacheIdx := BuildCacheIndex(existing)
// Scan files concurrently
var (
scanned atomic.Int32
total = len(files)
mu sync.Mutex
items = make([]LibraryItem, 0, total)
)
sem := make(chan struct{}, opts.Workers)
var wg sync.WaitGroup
for _, filePath := range files {
select {
case <-ctx.Done():
break
case sem <- struct{}{}:
}
wg.Add(1)
go func(fp string) {
defer wg.Done()
defer func() { <-sem }()
item := scanSingleFile(ctx, ffprobePath, fp, cacheIdx, existing, opts.Incremental)
mu.Lock()
items = append(items, item)
mu.Unlock()
n := int(scanned.Add(1))
if opts.OnProgress != nil {
opts.OnProgress(n, total, filepath.Base(fp))
}
}(filePath)
}
wg.Wait()
return &LibraryCache{
Version: cacheVersion,
ScannedAt: time.Now().UTC().Format(time.RFC3339),
Path: dirPath,
Items: items,
}, nil
}
func scanSingleFile(ctx context.Context, ffprobePath, filePath string, cacheIdx map[string]int, existing *LibraryCache, incremental bool) LibraryItem {
info, err := os.Stat(filePath)
if err != nil {
return LibraryItem{
FilePath: filePath,
FileName: filepath.Base(filePath),
ScanError: err.Error(),
}
}
item := LibraryItem{
FilePath: filePath,
FileName: filepath.Base(filePath),
FileSize: info.Size(),
ModTime: info.ModTime().UTC().Format(time.RFC3339),
}
// Parse filename for title, year, quality, codec
parsed := parser.Parse(item.FileName)
item.Quality = parsed.Quality
item.Codec = parsed.Codec
item.Year = parsed.Year
// Extract title from filename
item.Title = CleanTitle(item.FileName)
if item.Title == "" {
item.Title = item.FileName
}
// Parse season/episode
item.Season, item.Episode = ParseSeasonEpisode(item.FileName)
// Incremental: skip if file hasn't changed
if incremental && existing != nil {
if idx, ok := cacheIdx[filePath]; ok {
cached := existing.Items[idx]
if cached.FileSize == item.FileSize && cached.ModTime == item.ModTime && cached.MediaInfo != nil {
item.MediaInfo = cached.MediaInfo
return item
}
}
}
// Run ffprobe
mi, err := mediainfo.ExtractMediaInfo(ctx, ffprobePath, filePath)
if err != nil {
item.ScanError = err.Error()
return item
}
item.MediaInfo = mi
return item
}
// discoverFiles walks a directory and returns paths of video files.
func discoverFiles(root string) ([]string, error) {
var files []string
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return nil // skip errors, continue walking
}
if d.IsDir() {
return nil
}
ext := strings.ToLower(filepath.Ext(path))
if !videoExts[ext] {
return nil
}
// Check file size (stat is lazy on some systems)
info, err := d.Info()
if err != nil {
return nil
}
if info.Size() < minFileSize {
return nil
}
// Exclude non-content files
lower := strings.ToLower(path)
for _, pattern := range excludePatterns {
if strings.Contains(lower, pattern) {
return nil
}
}
files = append(files, path)
return nil
})
return files, err
}

29
internal/library/types.go Normal file
View file

@ -0,0 +1,29 @@
package library
import "github.com/torrentclaw/torrentclaw-cli/internal/library/mediainfo"
// LibraryItem represents a single scanned media file.
type LibraryItem struct {
FilePath string `json:"filePath"`
FileName string `json:"fileName"`
FileSize int64 `json:"fileSize"`
ModTime string `json:"modTime"` // ISO 8601
Title string `json:"title"`
Year string `json:"year,omitempty"`
Season int `json:"season,omitempty"`
Episode int `json:"episode,omitempty"`
Quality string `json:"quality,omitempty"` // "1080p" etc (from filename)
Codec string `json:"codec,omitempty"` // "x265" etc (from filename)
MediaInfo *mediainfo.MediaInfo `json:"mediaInfo,omitempty"`
ScanError string `json:"scanError,omitempty"`
}
// LibraryCache is the on-disk cache of scanned library items.
type LibraryCache struct {
Version int `json:"version"`
ScannedAt string `json:"scannedAt"`
Path string `json:"path"`
Items []LibraryItem `json:"items"`
}
const cacheVersion = 1