diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4e0aee4..c7a8710 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Init wizard with daemon install step (`unarr init`, replaces `unarr setup`)
- Interactive config menu with 7 categories (`unarr config [category]`)
+- Migration wizard from Sonarr/Radarr/Prowlarr (`unarr migrate`) [pre-beta]
+ - Auto-detect instances via Docker, config files, port scan, Prowlarr
+ - Import download history and blocklist to avoid re-downloading
+ - Detect Plex/Jellyfin/Emby media servers and library paths
+ - Extract debrid tokens from *arr download clients
+ - JSON export with `--dry-run --json`
+- Media server detection in `unarr init` (suggests library paths as download directory)
+- `preferred_quality` setting in config (2160p/1080p/720p)
- Clean command to remove temp files, logs, and cached data (`unarr clean`)
- Daemon mode with background download management (`unarr start`)
- One-shot download command (`unarr download`)
diff --git a/README.md b/README.md
index 44a06cc..2b57702 100644
--- a/README.md
+++ b/README.md
@@ -71,6 +71,7 @@ unarr start
|---------|-------------|
| `unarr init` | First-time configuration wizard (API key, download dir, daemon) |
| `unarr config` | Edit all settings interactively (speed, organization, etc.) |
+| `unarr migrate` | Import settings and wanted list from Sonarr/Radarr/Prowlarr [pre-beta] |
### Search & Discovery
diff --git a/internal/agent/client.go b/internal/agent/client.go
index f6189ee..d0dd7ad 100644
--- a/internal/agent/client.go
+++ b/internal/agent/client.go
@@ -153,6 +153,33 @@ func (c *Client) GetUsenetUsage(ctx context.Context) (*UsenetUsageResponse, erro
return &resp, nil
}
+// ConfigureDebrid saves a debrid provider token for the user (used by unarr init/migrate).
+func (c *Client) ConfigureDebrid(ctx context.Context, req ConfigureDebridRequest) (*ConfigureDebridResponse, error) {
+ var resp ConfigureDebridResponse
+ if err := c.doPost(ctx, "/api/internal/agent/debrid-config", req, &resp); err != nil {
+ return nil, fmt.Errorf("configure debrid: %w", err)
+ }
+ return &resp, nil
+}
+
+// BatchDownload queues multiple items for download (used by unarr migrate).
+func (c *Client) BatchDownload(ctx context.Context, req BatchDownloadRequest) (*BatchDownloadResponse, error) {
+ var resp BatchDownloadResponse
+ if err := c.doPost(ctx, "/api/internal/agent/batch-download", req, &resp); err != nil {
+ return nil, fmt.Errorf("batch download: %w", err)
+ }
+ return &resp, nil
+}
+
+// SyncLibrary sends scanned library items to the server for matching and upgrade discovery.
+func (c *Client) SyncLibrary(ctx context.Context, req LibrarySyncRequest) (*LibrarySyncResponse, error) {
+ var resp LibrarySyncResponse
+ if err := c.doPost(ctx, "/api/internal/agent/library-sync", req, &resp); err != nil {
+ return nil, fmt.Errorf("library sync: %w", err)
+ }
+ return &resp, nil
+}
+
// doPost sends a JSON POST request and decodes the response.
func (c *Client) doPost(ctx context.Context, path string, body any, dst any) error {
jsonBody, err := json.Marshal(body)
diff --git a/internal/agent/types.go b/internal/agent/types.go
index 1ab20fd..25990cf 100644
--- a/internal/agent/types.go
+++ b/internal/agent/types.go
@@ -68,6 +68,8 @@ type Task struct {
DirectFileName string `json:"directFileName,omitempty"` // Original filename from direct URL
NzbID string `json:"nzbId,omitempty"` // Pre-resolved NZB ID from server
NzbPassword string `json:"nzbPassword,omitempty"` // Password for encrypted NZB archives
+ ReplacePath string `json:"replacePath,omitempty"` // File to replace after download (upgrade mode)
+ LibraryItemID int `json:"libraryItemId,omitempty"` // Library item being upgraded
}
// TasksResponse wraps the array of tasks returned by the server.
@@ -197,3 +199,102 @@ type UsenetUsageResponse struct {
RemainingBytes int64 `json:"remainingBytes"`
QuotaResetDate string `json:"quotaResetDate"`
}
+
+// ---------------------------------------------------------------------------
+// Batch download types (used by unarr migrate)
+// ---------------------------------------------------------------------------
+
+// BatchDownloadRequest sends a list of wanted items to queue for download.
+type BatchDownloadRequest struct {
+ Items []WantedItem `json:"items"`
+ ExcludeHashes []string `json:"excludeHashes,omitempty"` // blocklisted + already-downloaded hashes
+}
+
+// WantedItem represents a movie or series the user wants.
+type WantedItem struct {
+ TmdbID int `json:"tmdbId,omitempty"`
+ ImdbID string `json:"imdbId,omitempty"`
+ Title string `json:"title"`
+ Year int `json:"year,omitempty"`
+ Type string `json:"type"` // "movie" or "show"
+}
+
+// BatchDownloadResponse reports the outcome of a batch download request.
+type BatchDownloadResponse struct {
+ Queued int `json:"queued"`
+ NotFound int `json:"notFound"`
+ AlreadyActive int `json:"alreadyActive"`
+ Items []BatchItem `json:"items"`
+}
+
+// BatchItem is the per-item result of a batch download.
+type BatchItem struct {
+ Title string `json:"title"`
+ Status string `json:"status"` // "queued", "not_found", "already_active"
+}
+
+// ---------------------------------------------------------------------------
+// Debrid config types (used by unarr init/migrate)
+// ---------------------------------------------------------------------------
+
+// ConfigureDebridRequest configures a debrid provider.
+type ConfigureDebridRequest struct {
+ Provider string `json:"provider"` // "real-debrid", "alldebrid", "torbox", "premiumize"
+ Token string `json:"token"`
+}
+
+// ConfigureDebridResponse is returned after configuring a debrid provider.
+type ConfigureDebridResponse struct {
+ Success bool `json:"success"`
+ Account DebridAccount `json:"account"`
+ Error string `json:"error,omitempty"`
+}
+
+// DebridAccount holds verified debrid account info.
+type DebridAccount struct {
+ Valid bool `json:"valid"`
+ Premium bool `json:"premium"`
+ Username string `json:"username"`
+ ExpiresAt string `json:"expiresAt,omitempty"`
+}
+
+// ---------------------------------------------------------------------------
+// Library sync types (used by unarr scan)
+// ---------------------------------------------------------------------------
+
+// LibrarySyncRequest sends scanned media items to the server.
+type LibrarySyncRequest struct {
+ Items []LibrarySyncItem `json:"items"`
+ ScanPath string `json:"scanPath"`
+ IsLastBatch bool `json:"isLastBatch"`
+}
+
+// LibrarySyncItem is a single scanned media file with ffprobe metadata.
+type LibrarySyncItem struct {
+ FilePath string `json:"filePath"`
+ FileName string `json:"fileName"`
+ FileSize int64 `json:"fileSize,omitempty"`
+ Title string `json:"title"`
+ Year string `json:"year,omitempty"`
+ Season int `json:"season,omitempty"`
+ Episode int `json:"episode,omitempty"`
+ ContentType string `json:"contentType"`
+ Resolution string `json:"resolution,omitempty"`
+ VideoCodec string `json:"videoCodec,omitempty"`
+ HDR string `json:"hdr,omitempty"`
+ BitDepth int `json:"bitDepth,omitempty"`
+ AudioCodec string `json:"audioCodec,omitempty"`
+ AudioChannels int `json:"audioChannels,omitempty"`
+ AudioLanguages []string `json:"audioLanguages,omitempty"`
+ SubtitleLanguages []string `json:"subtitleLanguages,omitempty"`
+ AudioTracks any `json:"audioTracks,omitempty"`
+ SubtitleTracks any `json:"subtitleTracks,omitempty"`
+ VideoInfo any `json:"videoInfo,omitempty"`
+}
+
+// LibrarySyncResponse is returned after syncing library items.
+type LibrarySyncResponse struct {
+ Synced int `json:"synced"`
+ Matched int `json:"matched"`
+ Removed int `json:"removed"`
+}
diff --git a/internal/arr/client.go b/internal/arr/client.go
new file mode 100644
index 0000000..96857a6
--- /dev/null
+++ b/internal/arr/client.go
@@ -0,0 +1,188 @@
+package arr
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "time"
+)
+
+// Client talks to a single *arr instance (Sonarr, Radarr, or Prowlarr).
+type Client struct {
+ baseURL string
+ apiKey string
+ httpClient *http.Client
+}
+
+// NewClient creates a client for the given *arr instance.
+func NewClient(baseURL, apiKey string) *Client {
+ return &Client{
+ baseURL: strings.TrimRight(baseURL, "/"),
+ apiKey: apiKey,
+ httpClient: &http.Client{Timeout: 15 * time.Second},
+ }
+}
+
+// SystemStatus returns version and app info. Works with all *arr apps.
+func (c *Client) SystemStatus() (*SystemStatus, error) {
+ // Try v3 first (Sonarr/Radarr), then v1 (Prowlarr)
+ var s SystemStatus
+ if err := c.get("/api/v3/system/status", &s); err != nil {
+ if err2 := c.get("/api/v1/system/status", &s); err2 != nil {
+ return nil, fmt.Errorf("system/status v3: %w; v1: %v", err, err2)
+ }
+ }
+ return &s, nil
+}
+
+// ── Radarr ──────────────────────────────────────────────────────────
+
+func (c *Client) Movies() ([]Movie, error) {
+ var m []Movie
+ if err := c.get("/api/v3/movie", &m); err != nil {
+ return nil, fmt.Errorf("movies: %w", err)
+ }
+ return m, nil
+}
+
+// ── Sonarr ──────────────────────────────────────────────────────────
+
+func (c *Client) Series() ([]Series, error) {
+ var s []Series
+ if err := c.get("/api/v3/series", &s); err != nil {
+ return nil, fmt.Errorf("series: %w", err)
+ }
+ return s, nil
+}
+
+// ── Shared (Sonarr + Radarr use the same v3 endpoints) ─────────────
+
+func (c *Client) QualityProfiles() ([]QualityProfile, error) {
+ var p []QualityProfile
+ if err := c.get("/api/v3/qualityprofile", &p); err != nil {
+ return nil, fmt.Errorf("quality profiles: %w", err)
+ }
+ return p, nil
+}
+
+func (c *Client) RootFolders() ([]RootFolder, error) {
+ var f []RootFolder
+ if err := c.get("/api/v3/rootfolder", &f); err != nil {
+ return nil, fmt.Errorf("root folders: %w", err)
+ }
+ return f, nil
+}
+
+func (c *Client) DownloadClients() ([]DownloadClient, error) {
+ var d []DownloadClient
+ if err := c.get("/api/v3/downloadclient", &d); err != nil {
+ return nil, fmt.Errorf("download clients: %w", err)
+ }
+ return d, nil
+}
+
+// DownloadClientDetails returns the full config (including fields) for a single download client.
+func (c *Client) DownloadClientDetails(id int) ([]Field, error) {
+ path := fmt.Sprintf("/api/v3/downloadclient/%d", id)
+ var dc struct {
+ Fields []Field `json:"fields"`
+ }
+ if err := c.get(path, &dc); err != nil {
+ return nil, err
+ }
+ return dc.Fields, nil
+}
+
+// ── Shared (Sonarr + Radarr) ────────────────────────────────────────
+
+func (c *Client) Tags() ([]Tag, error) {
+ var t []Tag
+ if err := c.get("/api/v3/tag", &t); err != nil {
+ return nil, fmt.Errorf("tags: %w", err)
+ }
+ return t, nil
+}
+
+// History returns download history records (grabbed + imported).
+// pageSize controls how many records per page (max 250).
+func (c *Client) History(pageSize int) ([]HistoryRecord, error) {
+ if pageSize <= 0 {
+ pageSize = 250
+ }
+ path := fmt.Sprintf("/api/v3/history?page=1&pageSize=%d&sortKey=date&sortDirection=descending", pageSize)
+ var resp HistoryResponse
+ if err := c.get(path, &resp); err != nil {
+ return nil, fmt.Errorf("history: %w", err)
+ }
+ return resp.Records, nil
+}
+
+// Blocklist returns releases the user has explicitly rejected.
+func (c *Client) Blocklist(pageSize int) ([]BlocklistItem, error) {
+ if pageSize <= 0 {
+ pageSize = 250
+ }
+ path := fmt.Sprintf("/api/v3/blocklist?page=1&pageSize=%d", pageSize)
+ var resp BlocklistResponse
+ if err := c.get(path, &resp); err != nil {
+ return nil, fmt.Errorf("blocklist: %w", err)
+ }
+ return resp.Records, nil
+}
+
+// ── Prowlarr ────────────────────────────────────────────────────────
+
+func (c *Client) Indexers() ([]Indexer, error) {
+ var idx []Indexer
+ if err := c.get("/api/v1/indexer", &idx); err != nil {
+ return nil, fmt.Errorf("indexers: %w", err)
+ }
+ return idx, nil
+}
+
+func (c *Client) Applications() ([]Application, error) {
+ var apps []Application
+ if err := c.get("/api/v1/applications", &apps); err != nil {
+ return nil, fmt.Errorf("applications: %w", err)
+ }
+ return apps, nil
+}
+
+// ── HTTP helper ─────────────────────────────────────────────────────
+
+func (c *Client) get(path string, dst any) error {
+ req, err := http.NewRequest(http.MethodGet, c.baseURL+path, nil)
+ if err != nil {
+ return err
+ }
+ req.Header.Set("X-Api-Key", c.apiKey)
+
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(io.LimitReader(resp.Body, 50<<20)) // 50MB limit for large libraries
+ if err != nil {
+ return fmt.Errorf("read body: %w", err)
+ }
+
+ if resp.StatusCode == http.StatusUnauthorized {
+ return fmt.Errorf("unauthorized — check your API key")
+ }
+ if resp.StatusCode >= 400 {
+ msg := string(body)
+ if len(msg) > 200 {
+ msg = msg[:200] + "..."
+ }
+ return fmt.Errorf("HTTP %d: %s", resp.StatusCode, msg)
+ }
+
+ if err := json.Unmarshal(body, dst); err != nil {
+ return fmt.Errorf("decode JSON: %w", err)
+ }
+ return nil
+}
diff --git a/internal/arr/discover_e2e_test.go b/internal/arr/discover_e2e_test.go
new file mode 100644
index 0000000..6a0a07a
--- /dev/null
+++ b/internal/arr/discover_e2e_test.go
@@ -0,0 +1,180 @@
+package arr
+
+import (
+ "fmt"
+ "net"
+ "os"
+ "testing"
+ "time"
+)
+
+// TestDiscoverE2E is an integration test that requires real *arr instances running.
+// Skip if ports 8989/7878 are not reachable.
+func TestDiscoverE2E(t *testing.T) {
+ if os.Getenv("ARR_E2E") == "" {
+ t.Skip("Set ARR_E2E=1 to run integration tests")
+ }
+
+ // Check ports are reachable
+ for _, port := range []string{"8989", "7878"} {
+ conn, err := net.DialTimeout("tcp", "localhost:"+port, 2*time.Second)
+ if err != nil {
+ t.Skipf("Port %s not reachable, skipping", port)
+ }
+ conn.Close()
+ }
+
+ t.Run("Discover", func(t *testing.T) {
+ instances := Discover()
+ if len(instances) == 0 {
+ t.Fatal("Discover() returned 0 instances")
+ }
+ for _, inst := range instances {
+ t.Logf("Found: %s at %s (source=%s, version=%s, hasKey=%v)",
+ inst.App, inst.URL, inst.Source, inst.Version, inst.APIKey != "")
+ }
+ })
+
+ t.Run("Radarr_Movies", func(t *testing.T) {
+ radarrKey := os.Getenv("RADARR_KEY")
+ if radarrKey == "" {
+ t.Skip("RADARR_KEY not set")
+ }
+ client := NewClient("http://localhost:7878", radarrKey)
+
+ status, err := client.SystemStatus()
+ if err != nil {
+ t.Fatalf("SystemStatus: %v", err)
+ }
+ t.Logf("Radarr %s", status.Version)
+
+ movies, err := client.Movies()
+ if err != nil {
+ t.Fatalf("Movies: %v", err)
+ }
+ t.Logf("Found %d movies", len(movies))
+ for _, m := range movies {
+ t.Logf(" %s (tmdb=%d, imdb=%s) monitored=%v hasFile=%v profile=%d",
+ m.Title, m.TmdbID, m.ImdbID, m.Monitored, m.HasFile, m.QualityProfileID)
+ }
+ if len(movies) < 3 {
+ t.Errorf("Expected at least 3 movies, got %d", len(movies))
+ }
+
+ profiles, err := client.QualityProfiles()
+ if err != nil {
+ t.Fatalf("QualityProfiles: %v", err)
+ }
+ t.Logf("Found %d quality profiles", len(profiles))
+ for _, p := range profiles {
+ mapped := MapQualityProfile(p)
+ t.Logf(" %s (id=%d) → %s", p.Name, p.ID, mapped)
+ }
+
+ folders, err := client.RootFolders()
+ if err != nil {
+ t.Fatalf("RootFolders: %v", err)
+ }
+ t.Logf("Found %d root folders", len(folders))
+ for _, f := range folders {
+ t.Logf(" %s", f.Path)
+ }
+
+ dcs, err := client.DownloadClients()
+ if err != nil {
+ t.Fatalf("DownloadClients: %v", err)
+ }
+ t.Logf("Found %d download clients", len(dcs))
+
+ // Test wanted extraction
+ wanted := ExtractWantedMovies(movies)
+ t.Logf("Wanted movies: %d", len(wanted))
+ for _, w := range wanted {
+ t.Logf(" %s (tmdb=%d)", w.Title, w.TmdbID)
+ }
+ if len(wanted) != 2 {
+ t.Errorf("Expected 2 wanted movies, got %d", len(wanted))
+ }
+ })
+
+ t.Run("Sonarr_Series", func(t *testing.T) {
+ sonarrKey := os.Getenv("SONARR_KEY")
+ if sonarrKey == "" {
+ t.Skip("SONARR_KEY not set")
+ }
+ client := NewClient("http://localhost:8989", sonarrKey)
+
+ status, err := client.SystemStatus()
+ if err != nil {
+ t.Fatalf("SystemStatus: %v", err)
+ }
+ t.Logf("Sonarr %s", status.Version)
+
+ series, err := client.Series()
+ if err != nil {
+ t.Fatalf("Series: %v", err)
+ }
+ t.Logf("Found %d series", len(series))
+ for _, s := range series {
+ t.Logf(" %s (tvdb=%d, imdb=%s) monitored=%v eps=%d/%d",
+ s.Title, s.TvdbID, s.ImdbID, s.Monitored,
+ s.Statistics.EpisodeFileCount, s.Statistics.EpisodeCount)
+ }
+
+ wanted := ExtractWantedSeries(series)
+ t.Logf("Wanted series: %d", len(wanted))
+ for _, w := range wanted {
+ t.Logf(" %s (imdb=%s)", w.Title, w.ImdbID)
+ }
+ if len(wanted) != 2 {
+ t.Errorf("Expected 2 wanted series, got %d", len(wanted))
+ }
+ })
+
+ t.Run("BuildMigrationResult", func(t *testing.T) {
+ radarrKey := os.Getenv("RADARR_KEY")
+ sonarrKey := os.Getenv("SONARR_KEY")
+ if radarrKey == "" || sonarrKey == "" {
+ t.Skip("RADARR_KEY and SONARR_KEY required")
+ }
+
+ rc := NewClient("http://localhost:7878", radarrKey)
+ sc := NewClient("http://localhost:8989", sonarrKey)
+
+ movies, _ := rc.Movies()
+ series, _ := sc.Series()
+ rp, _ := rc.QualityProfiles()
+ sp, _ := sc.QualityProfiles()
+ rf, _ := rc.RootFolders()
+ sf, _ := sc.RootFolders()
+ dcs, _ := rc.DownloadClients()
+
+ result := BuildMigrationResult(movies, series, rp, sp, rf, sf, nil, dcs)
+
+ fmt.Printf("\n=== Migration Result ===\n")
+ fmt.Printf(" MoviesDir: %s\n", result.MoviesDir)
+ fmt.Printf(" TVShowsDir: %s\n", result.TVShowsDir)
+ fmt.Printf(" Quality: %s (from %q)\n", result.Quality, result.QualitySource)
+ fmt.Printf(" Organize: %v\n", result.OrganizeEnabled)
+ fmt.Printf(" Movies: %d total, %d with files\n", result.TotalMovies, result.MoviesWithFiles)
+ fmt.Printf(" Series: %d total, %d complete\n", result.TotalSeries, result.SeriesComplete)
+ fmt.Printf(" Wanted movies: %d\n", len(result.WantedMovies))
+ fmt.Printf(" Wanted series: %d\n", len(result.WantedSeries))
+
+ if result.MoviesDir != "/data/media/movies" {
+ t.Errorf("MoviesDir = %q, want /data/media/movies", result.MoviesDir)
+ }
+ if result.TVShowsDir != "/data/media/tv" {
+ t.Errorf("TVShowsDir = %q, want /data/media/tv", result.TVShowsDir)
+ }
+ if len(result.WantedMovies) != 2 {
+ t.Errorf("WantedMovies = %d, want 2", len(result.WantedMovies))
+ }
+ if len(result.WantedSeries) != 2 {
+ t.Errorf("WantedSeries = %d, want 2", len(result.WantedSeries))
+ }
+ if !result.OrganizeEnabled {
+ t.Error("OrganizeEnabled should be true")
+ }
+ })
+}
diff --git a/internal/arr/discovery.go b/internal/arr/discovery.go
new file mode 100644
index 0000000..f97e68e
--- /dev/null
+++ b/internal/arr/discovery.go
@@ -0,0 +1,356 @@
+package arr
+
+import (
+ "bytes"
+ "encoding/xml"
+ "fmt"
+ "io"
+ "net"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "time"
+)
+
+// appInfo maps app names to their default ports and API versions.
+var appInfo = map[string]struct {
+ Port string
+ Version string
+}{
+ "sonarr": {Port: "8989", Version: "v3"},
+ "radarr": {Port: "7878", Version: "v3"},
+ "prowlarr": {Port: "9696", Version: "v1"},
+}
+
+// Discover scans for running *arr instances using multiple strategies.
+// Returns instances in order: Docker, config files, port scan.
+func Discover() []Instance {
+ seen := map[string]bool{} // dedupe by URL
+ var instances []Instance
+
+ add := func(inst Instance) {
+ key := strings.ToLower(inst.URL)
+ if seen[key] {
+ // Allow upgrading a no-key entry with one that has a key
+ if inst.APIKey != "" {
+ for i := range instances {
+ if strings.ToLower(instances[i].URL) == key && instances[i].APIKey == "" {
+ instances[i].APIKey = inst.APIKey
+ instances[i].Source = inst.Source
+ if inst.Version != "" {
+ instances[i].Version = inst.Version
+ }
+ break
+ }
+ }
+ }
+ return
+ }
+ seen[key] = true
+ instances = append(instances, inst)
+ }
+
+ // Strategy 1: Docker containers
+ for _, inst := range discoverDocker() {
+ add(inst)
+ }
+
+ // Strategy 2: Config files on disk
+ for _, inst := range discoverConfigFiles() {
+ add(inst)
+ }
+
+ // Strategy 3: Port scan on localhost
+ for _, inst := range discoverPorts() {
+ add(inst)
+ }
+
+ return instances
+}
+
+// ── Docker discovery ────────────────────────────────────────────────
+
+func discoverDocker() []Instance {
+ dockerPath, err := exec.LookPath("docker")
+ if err != nil {
+ return nil
+ }
+
+ out, err := exec.Command(dockerPath, "ps", "--format", "{{.Names}}\t{{.Image}}\t{{.Ports}}").Output()
+ if err != nil {
+ return nil
+ }
+
+ var instances []Instance
+ for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
+ if line == "" {
+ continue
+ }
+ parts := strings.SplitN(line, "\t", 3)
+ if len(parts) < 3 {
+ continue
+ }
+ name, image, ports := parts[0], strings.ToLower(parts[1]), parts[2]
+
+ app := detectApp(image)
+ if app == "" {
+ continue
+ }
+
+ port := extractHostPort(ports, appInfo[app].Port)
+ if port == "" {
+ continue
+ }
+
+ url := "http://localhost:" + port
+
+ // Try to read API key from container's config.xml
+ apiKey := readDockerConfigXML(dockerPath, name)
+
+ inst := Instance{
+ App: app,
+ URL: url,
+ APIKey: apiKey,
+ Source: "docker",
+ }
+
+ // Verify connectivity if we have an API key
+ if apiKey != "" {
+ if status, err := NewClient(url, apiKey).SystemStatus(); err == nil {
+ inst.Version = status.Version
+ }
+ }
+
+ instances = append(instances, inst)
+ }
+ return instances
+}
+
+func detectApp(image string) string {
+ for _, app := range []string{"sonarr", "radarr", "prowlarr"} {
+ if strings.Contains(image, app) {
+ return app
+ }
+ }
+ return ""
+}
+
+// extractHostPort finds the host port mapped to the expected container port.
+func extractHostPort(portsStr, containerPort string) string {
+ // Format: "0.0.0.0:8989->8989/tcp, ..."
+ for _, mapping := range strings.Split(portsStr, ",") {
+ mapping = strings.TrimSpace(mapping)
+ if strings.Contains(mapping, "->"+containerPort+"/") {
+ parts := strings.SplitN(mapping, "->", 2)
+ if len(parts) == 2 {
+ hostPart := parts[0]
+ // Remove IP prefix: "0.0.0.0:8989" → "8989"
+ if idx := strings.LastIndex(hostPart, ":"); idx >= 0 {
+ return hostPart[idx+1:]
+ }
+ return hostPart
+ }
+ }
+ }
+ // Fallback: check if the expected port appears at all
+ if strings.Contains(portsStr, containerPort) {
+ return containerPort
+ }
+ return ""
+}
+
+func readDockerConfigXML(dockerPath, containerName string) string {
+ out, err := exec.Command(dockerPath, "exec", containerName, "cat", "/config/config.xml").Output()
+ if err != nil {
+ return ""
+ }
+ _, apiKey, _ := parseConfigXML(bytes.NewReader(out))
+ return apiKey
+}
+
+// ── Config file discovery ───────────────────────────────────────────
+
+func discoverConfigFiles() []Instance {
+ var instances []Instance
+
+ dirs := configDirs()
+ for _, app := range []string{"Sonarr", "Radarr", "Prowlarr"} {
+ for _, dir := range dirs {
+ cfgPath := filepath.Join(dir, app, "config.xml")
+ f, err := os.Open(cfgPath)
+ if err != nil {
+ continue
+ }
+
+ port, apiKey, urlBase := parseConfigXML(f)
+ _ = f.Close()
+
+ if port == "" || apiKey == "" {
+ continue
+ }
+
+ url := "http://localhost:" + port
+ if urlBase != "" {
+ url += "/" + strings.Trim(urlBase, "/")
+ }
+
+ inst := Instance{
+ App: strings.ToLower(app),
+ URL: url,
+ APIKey: apiKey,
+ Source: "config-file",
+ }
+
+ if status, err := NewClient(url, apiKey).SystemStatus(); err == nil {
+ inst.Version = status.Version
+ }
+
+ instances = append(instances, inst)
+ }
+ }
+ return instances
+}
+
+func configDirs() []string {
+ switch runtime.GOOS {
+ case "windows":
+ pd := os.Getenv("PROGRAMDATA")
+ if pd == "" {
+ pd = `C:\ProgramData`
+ }
+ return []string{pd}
+ default: // linux, darwin
+ home, _ := os.UserHomeDir()
+ return []string{
+ filepath.Join(home, ".config"),
+ "/var/lib",
+ }
+ }
+}
+
+// ── Port scan discovery ─────────────────────────────────────────────
+
+func discoverPorts() []Instance {
+ var instances []Instance
+
+ // Fixed order iteration (map iteration is random in Go)
+ portOrder := []string{"sonarr", "radarr", "prowlarr"}
+ for _, app := range portOrder {
+ info := appInfo[app]
+ addr := "localhost:" + info.Port
+ conn, err := net.DialTimeout("tcp", addr, 2*time.Second)
+ if err != nil {
+ continue
+ }
+ _ = conn.Close()
+
+ url := "http://localhost:" + info.Port
+ inst := Instance{
+ App: app,
+ URL: url,
+ Source: "port-scan",
+ }
+
+ // Try to get status without API key (some configs allow local access)
+ if status, err := NewClient(url, "").SystemStatus(); err == nil {
+ inst.Version = status.Version
+ }
+
+ instances = append(instances, inst)
+ }
+ return instances
+}
+
+// ── Prowlarr application discovery ──────────────────────────────────
+
+// DiscoverFromProwlarr extracts connected Sonarr/Radarr instances from Prowlarr.
+func DiscoverFromProwlarr(prowlarrURL, prowlarrKey string) []Instance {
+ client := NewClient(prowlarrURL, prowlarrKey)
+ apps, err := client.Applications()
+ if err != nil {
+ return nil
+ }
+
+ var instances []Instance
+ for _, app := range apps {
+ var baseURL, apiKey string
+ for _, f := range app.Fields {
+ switch f.Name {
+ case "baseUrl", "BaseUrl":
+ if s, ok := f.Value.(string); ok {
+ baseURL = s
+ }
+ case "apiKey", "ApiKey":
+ if s, ok := f.Value.(string); ok {
+ apiKey = s
+ }
+ }
+ }
+ if baseURL == "" || apiKey == "" {
+ continue
+ }
+
+ appName := strings.ToLower(app.Name)
+ detectedApp := ""
+ for _, a := range []string{"sonarr", "radarr"} {
+ if strings.Contains(appName, a) {
+ detectedApp = a
+ break
+ }
+ }
+ if detectedApp == "" {
+ continue
+ }
+
+ inst := Instance{
+ App: detectedApp,
+ URL: strings.TrimRight(baseURL, "/"),
+ APIKey: apiKey,
+ Source: "prowlarr",
+ }
+
+ if status, err := NewClient(inst.URL, apiKey).SystemStatus(); err == nil {
+ inst.Version = status.Version
+ }
+
+ instances = append(instances, inst)
+ }
+ return instances
+}
+
+// ── Config XML parser ───────────────────────────────────────────────
+
+type xmlConfig struct {
+ XMLName xml.Name `xml:"Config"`
+ Port string `xml:"Port"`
+ APIKey string `xml:"ApiKey"`
+ URLBase string `xml:"UrlBase"`
+}
+
+func parseConfigXML(r io.Reader) (port, apiKey, urlBase string) {
+ data, err := io.ReadAll(io.LimitReader(r, 1<<20)) // 1MB
+ if err != nil {
+ return "", "", ""
+ }
+ var cfg xmlConfig
+ if err := xml.Unmarshal(data, &cfg); err != nil {
+ return "", "", ""
+ }
+ return cfg.Port, cfg.APIKey, cfg.URLBase
+}
+
+// Verify checks that an instance is reachable and the API key is valid.
+// Returns the system status on success.
+func Verify(inst *Instance) error {
+ if inst.APIKey == "" {
+ return fmt.Errorf("no API key")
+ }
+ status, err := NewClient(inst.URL, inst.APIKey).SystemStatus()
+ if err != nil {
+ return err
+ }
+ inst.Version = status.Version
+ return nil
+}
diff --git a/internal/arr/discovery_test.go b/internal/arr/discovery_test.go
new file mode 100644
index 0000000..499877c
--- /dev/null
+++ b/internal/arr/discovery_test.go
@@ -0,0 +1,84 @@
+package arr
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestParseConfigXML(t *testing.T) {
+ xml := `
+ 8989
+ abc123def456
+ /sonarr
+ `
+
+ port, apiKey, urlBase := parseConfigXML(strings.NewReader(xml))
+ if port != "8989" {
+ t.Errorf("port = %q, want 8989", port)
+ }
+ if apiKey != "abc123def456" {
+ t.Errorf("apiKey = %q, want abc123def456", apiKey)
+ }
+ if urlBase != "/sonarr" {
+ t.Errorf("urlBase = %q, want /sonarr", urlBase)
+ }
+}
+
+func TestParseConfigXML_Minimal(t *testing.T) {
+ xml := `7878key`
+
+ port, apiKey, urlBase := parseConfigXML(strings.NewReader(xml))
+ if port != "7878" || apiKey != "key" || urlBase != "" {
+ t.Errorf("got port=%q apiKey=%q urlBase=%q", port, apiKey, urlBase)
+ }
+}
+
+func TestParseConfigXML_Invalid(t *testing.T) {
+ port, apiKey, _ := parseConfigXML(strings.NewReader("not xml"))
+ if port != "" || apiKey != "" {
+ t.Errorf("invalid XML should return empty values")
+ }
+}
+
+func TestExtractHostPort(t *testing.T) {
+ tests := []struct {
+ ports string
+ container string
+ want string
+ }{
+ {"0.0.0.0:8989->8989/tcp", "8989", "8989"},
+ {"0.0.0.0:9090->8989/tcp, :::9090->8989/tcp", "8989", "9090"},
+ {"0.0.0.0:7878->7878/tcp", "7878", "7878"},
+ {"", "8989", ""},
+ {"0.0.0.0:3000->3000/tcp", "8989", ""},
+ }
+ for _, tt := range tests {
+ t.Run(tt.ports, func(t *testing.T) {
+ got := extractHostPort(tt.ports, tt.container)
+ if got != tt.want {
+ t.Errorf("extractHostPort(%q, %q) = %q, want %q", tt.ports, tt.container, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestDetectApp(t *testing.T) {
+ tests := []struct {
+ image string
+ want string
+ }{
+ {"linuxserver/sonarr:latest", "sonarr"},
+ {"hotio/radarr", "radarr"},
+ {"ghcr.io/linuxserver/prowlarr:develop", "prowlarr"},
+ {"nginx:latest", ""},
+ {"postgres:16", ""},
+ }
+ for _, tt := range tests {
+ t.Run(tt.image, func(t *testing.T) {
+ got := detectApp(tt.image)
+ if got != tt.want {
+ t.Errorf("detectApp(%q) = %q, want %q", tt.image, got, tt.want)
+ }
+ })
+ }
+}
diff --git a/internal/arr/mapper.go b/internal/arr/mapper.go
new file mode 100644
index 0000000..113e078
--- /dev/null
+++ b/internal/arr/mapper.go
@@ -0,0 +1,312 @@
+package arr
+
+import "strings"
+
+// MapQualityProfile determines the preferred resolution from a quality profile.
+// Uses the cutoff quality as the primary signal (what the user "wants"),
+// falling back to the highest allowed resolution.
+func MapQualityProfile(profile QualityProfile) string {
+ maxResolution := 0
+ cutoffResolution := 0
+
+ var walk func(items []QualityItem)
+ walk = func(items []QualityItem) {
+ for _, item := range items {
+ if len(item.Items) > 0 {
+ walk(item.Items)
+ continue
+ }
+ if item.Quality == nil || !item.Allowed {
+ continue
+ }
+ if item.Quality.Resolution > maxResolution {
+ maxResolution = item.Quality.Resolution
+ }
+ if item.Quality.ID == profile.Cutoff && item.Quality.Resolution > 0 {
+ cutoffResolution = item.Quality.Resolution
+ }
+ }
+ }
+ walk(profile.Items)
+
+ // Prefer the cutoff (what user wants), fall back to max allowed
+ res := cutoffResolution
+ if res == 0 {
+ res = maxResolution
+ }
+
+ switch {
+ case res >= 2160:
+ return "2160p"
+ case res >= 1080:
+ return "1080p"
+ default:
+ return "720p"
+ }
+}
+
+// MostUsedProfile finds the quality profile used by the most items.
+func MostUsedProfile(profileCounts map[int]int, profiles []QualityProfile) *QualityProfile {
+ if len(profiles) == 0 {
+ return nil
+ }
+
+ bestID := profiles[0].ID
+ bestCount := 0
+
+ for id, count := range profileCounts {
+ if count > bestCount {
+ bestCount = count
+ bestID = id
+ }
+ }
+
+ for i := range profiles {
+ if profiles[i].ID == bestID {
+ return &profiles[i]
+ }
+ }
+ return &profiles[0]
+}
+
+// MapRootFolders picks the most-used root folder from each app.
+// Uses item paths to count which root folder is most popular.
+func MapRootFolders(radarrFolders []RootFolder, sonarrFolders []RootFolder, movies []Movie, series []Series) (moviesDir, tvDir string) {
+ moviesDir = mostUsedFolder(radarrFolders, func() []string {
+ paths := make([]string, len(movies))
+ for i, m := range movies {
+ paths[i] = m.RootFolderPath
+ }
+ return paths
+ }())
+ tvDir = mostUsedFolder(sonarrFolders, func() []string {
+ paths := make([]string, len(series))
+ for i, s := range series {
+ paths[i] = s.RootFolderPath
+ }
+ return paths
+ }())
+ return moviesDir, tvDir
+}
+
+// mostUsedFolder returns the folder path used by the most items.
+// Falls back to the first folder if no items reference any folder.
+func mostUsedFolder(folders []RootFolder, itemPaths []string) string {
+ if len(folders) == 0 {
+ return ""
+ }
+ if len(folders) == 1 {
+ return folders[0].Path
+ }
+
+ counts := map[string]int{}
+ for _, p := range itemPaths {
+ if p != "" {
+ counts[p]++
+ }
+ }
+
+ best := folders[0].Path
+ bestCount := 0
+ for _, f := range folders {
+ if c := counts[f.Path]; c > bestCount {
+ bestCount = c
+ best = f.Path
+ }
+ }
+ return best
+}
+
+// ExtractWantedMovies returns movies that are monitored but missing files.
+func ExtractWantedMovies(movies []Movie) []WantedItem {
+ var wanted []WantedItem
+ for _, m := range movies {
+ if m.Monitored && !m.HasFile && m.TmdbID > 0 {
+ wanted = append(wanted, WantedItem{
+ TmdbID: m.TmdbID,
+ ImdbID: m.ImdbID,
+ Title: m.Title,
+ Year: m.Year,
+ Type: "movie",
+ })
+ }
+ }
+ return wanted
+}
+
+// ExtractWantedSeries returns series that are monitored with missing episodes.
+func ExtractWantedSeries(series []Series) []WantedItem {
+ var wanted []WantedItem
+ for _, s := range series {
+ if !s.Monitored {
+ continue
+ }
+ // Series with less than 100% of episodes downloaded
+ if s.Statistics.EpisodeCount > 0 && s.Statistics.EpisodeFileCount < s.Statistics.EpisodeCount {
+ id := s.ImdbID
+ wanted = append(wanted, WantedItem{
+ ImdbID: id,
+ Title: s.Title,
+ Year: s.Year,
+ Type: "show",
+ })
+ }
+ }
+ return wanted
+}
+
+// BuildMigrationResult aggregates all extracted data into a single result.
+func BuildMigrationResult(
+ movies []Movie,
+ series []Series,
+ radarrProfiles, sonarrProfiles []QualityProfile,
+ radarrFolders, sonarrFolders []RootFolder,
+ indexers []Indexer,
+ downloadClients []DownloadClient,
+) *MigrationResult {
+ result := &MigrationResult{}
+
+ // Stats
+ result.TotalMovies = len(movies)
+ for _, m := range movies {
+ if m.HasFile {
+ result.MoviesWithFiles++
+ }
+ }
+ result.TotalSeries = len(series)
+ for _, s := range series {
+ if s.Statistics.EpisodeCount > 0 && s.Statistics.EpisodeFileCount >= s.Statistics.EpisodeCount {
+ result.SeriesComplete++
+ }
+ }
+ result.IndexerCount = len(indexers)
+
+ for _, dc := range downloadClients {
+ if dc.Enable {
+ result.DownloadClients = append(result.DownloadClients, dc.ImplementationName)
+ }
+ }
+
+ // Root folders → paths (uses most-popular folder based on item paths)
+ result.MoviesDir, result.TVShowsDir = MapRootFolders(radarrFolders, sonarrFolders, movies, series)
+ if result.MoviesDir != "" || result.TVShowsDir != "" {
+ result.OrganizeEnabled = true
+ }
+
+ // Quality profile — use the most popular one across both apps
+ profileCounts := map[int]int{}
+ for _, m := range movies {
+ profileCounts[m.QualityProfileID]++
+ }
+ for _, s := range series {
+ profileCounts[s.QualityProfileID]++
+ }
+ allProfiles := make([]QualityProfile, 0, len(radarrProfiles)+len(sonarrProfiles))
+ allProfiles = append(allProfiles, radarrProfiles...)
+ allProfiles = append(allProfiles, sonarrProfiles...)
+ if p := MostUsedProfile(profileCounts, allProfiles); p != nil {
+ result.Quality = MapQualityProfile(*p)
+ result.QualitySource = p.Name
+ }
+
+ // Wanted lists
+ result.WantedMovies = ExtractWantedMovies(movies)
+ result.WantedSeries = ExtractWantedSeries(series)
+
+ return result
+}
+
+// ExtractBlocklistedHashes returns unique infoHashes from blocklist entries.
+func ExtractBlocklistedHashes(items []BlocklistItem) []string {
+ seen := map[string]bool{}
+ var hashes []string
+ for _, item := range items {
+ h := strings.ToLower(strings.TrimSpace(item.Data.InfoHash))
+ if h != "" && !seen[h] {
+ seen[h] = true
+ hashes = append(hashes, h)
+ }
+ }
+ return hashes
+}
+
+// ExtractDownloadedHashes returns unique infoHashes from history (imported items).
+func ExtractDownloadedHashes(records []HistoryRecord) []string {
+ seen := map[string]bool{}
+ var hashes []string
+ for _, r := range records {
+ // Only count actually imported downloads, not just grabs
+ if r.EventType != "downloadFolderImported" && r.EventType != "downloadImported" {
+ continue
+ }
+ h := strings.ToLower(strings.TrimSpace(r.Data.InfoHash))
+ if h != "" && !seen[h] {
+ seen[h] = true
+ hashes = append(hashes, h)
+ }
+ }
+ return hashes
+}
+
+// ExtractDebridTokens looks for debrid-related download clients and extracts tokens.
+func ExtractDebridTokens(clients []DownloadClient, getFields func(id int) []Field) []DebridToken {
+ debridKeywords := map[string]string{
+ "realdebrid": "real-debrid",
+ "real-debrid": "real-debrid",
+ "alldebrid": "alldebrid",
+ "torbox": "torbox",
+ "premiumize": "premiumize",
+ }
+
+ var tokens []DebridToken
+ for _, dc := range clients {
+ if !dc.Enable {
+ continue
+ }
+ impl := strings.ToLower(dc.Implementation + dc.ImplementationName)
+ provider := ""
+ for kw, prov := range debridKeywords {
+ if strings.Contains(impl, kw) {
+ provider = prov
+ break
+ }
+ }
+ if provider == "" {
+ continue
+ }
+
+ // Get the fields for this download client to find the API key/token
+ fields := getFields(dc.ID)
+ for _, f := range fields {
+ name := strings.ToLower(f.Name)
+ if name == "apikey" || name == "api_key" || name == "token" || name == "apitoken" {
+ if s, ok := f.Value.(string); ok && s != "" {
+ tokens = append(tokens, DebridToken{
+ Provider: provider,
+ Token: s,
+ Name: dc.Name,
+ })
+ break
+ }
+ }
+ }
+ }
+ return tokens
+}
+
+// HasDockerPaths checks if any paths look like Docker container paths
+// (e.g. /data, /movies, /tv) rather than real host paths.
+func HasDockerPaths(result *MigrationResult) bool {
+ dockerPrefixes := []string{"/data/", "/movies", "/tv", "/media", "/downloads"}
+ for _, path := range []string{result.MoviesDir, result.TVShowsDir} {
+ if path == "" {
+ continue
+ }
+ for _, prefix := range dockerPrefixes {
+ if strings.HasPrefix(path, prefix) {
+ return true
+ }
+ }
+ }
+ return false
+}
diff --git a/internal/arr/mapper_test.go b/internal/arr/mapper_test.go
new file mode 100644
index 0000000..6c1585c
--- /dev/null
+++ b/internal/arr/mapper_test.go
@@ -0,0 +1,230 @@
+package arr
+
+import (
+ "testing"
+)
+
+func TestMapQualityProfile_2160p(t *testing.T) {
+ profile := QualityProfile{
+ ID: 1,
+ Name: "Ultra-HD",
+ Cutoff: 31, // 2160p Remux
+ Items: []QualityItem{
+ {Quality: &Quality{ID: 31, Name: "Remux-2160p", Resolution: 2160}, Allowed: true},
+ {Quality: &Quality{ID: 18, Name: "HDTV-2160p", Resolution: 2160}, Allowed: true},
+ {Quality: &Quality{ID: 7, Name: "Bluray-1080p", Resolution: 1080}, Allowed: true},
+ },
+ }
+ if got := MapQualityProfile(profile); got != "2160p" {
+ t.Errorf("MapQualityProfile = %q, want 2160p", got)
+ }
+}
+
+func TestMapQualityProfile_1080p(t *testing.T) {
+ profile := QualityProfile{
+ ID: 2,
+ Name: "HD-1080p",
+ Cutoff: 7,
+ Items: []QualityItem{
+ {Quality: &Quality{ID: 7, Name: "Bluray-1080p", Resolution: 1080}, Allowed: true},
+ {Quality: &Quality{ID: 3, Name: "HDTV-720p", Resolution: 720}, Allowed: true},
+ },
+ }
+ if got := MapQualityProfile(profile); got != "1080p" {
+ t.Errorf("MapQualityProfile = %q, want 1080p", got)
+ }
+}
+
+func TestMapQualityProfile_720p_fallback(t *testing.T) {
+ profile := QualityProfile{
+ ID: 3,
+ Name: "SD",
+ Cutoff: 1,
+ Items: []QualityItem{
+ {Quality: &Quality{ID: 1, Name: "SDTV", Resolution: 480}, Allowed: true},
+ },
+ }
+ if got := MapQualityProfile(profile); got != "720p" {
+ t.Errorf("MapQualityProfile = %q, want 720p", got)
+ }
+}
+
+func TestMapQualityProfile_NestedGroups(t *testing.T) {
+ profile := QualityProfile{
+ ID: 4,
+ Name: "Any",
+ Cutoff: 7,
+ Items: []QualityItem{
+ {
+ Items: []QualityItem{
+ {Quality: &Quality{ID: 7, Name: "Bluray-1080p", Resolution: 1080}, Allowed: true},
+ {Quality: &Quality{ID: 3, Name: "HDTV-720p", Resolution: 720}, Allowed: true},
+ },
+ },
+ },
+ }
+ if got := MapQualityProfile(profile); got != "1080p" {
+ t.Errorf("MapQualityProfile = %q, want 1080p", got)
+ }
+}
+
+func TestMostUsedProfile(t *testing.T) {
+ profiles := []QualityProfile{
+ {ID: 1, Name: "HD-1080p"},
+ {ID: 2, Name: "Ultra-HD"},
+ {ID: 3, Name: "SD"},
+ }
+ counts := map[int]int{1: 5, 2: 20, 3: 3}
+
+ p := MostUsedProfile(counts, profiles)
+ if p == nil || p.ID != 2 {
+ t.Errorf("MostUsedProfile = %v, want profile ID 2", p)
+ }
+}
+
+func TestMostUsedProfile_EmptyCounts(t *testing.T) {
+ profiles := []QualityProfile{
+ {ID: 1, Name: "HD-1080p"},
+ }
+ p := MostUsedProfile(map[int]int{}, profiles)
+ if p == nil || p.ID != 1 {
+ t.Errorf("MostUsedProfile with empty counts should return first profile")
+ }
+}
+
+func TestExtractWantedMovies(t *testing.T) {
+ movies := []Movie{
+ {TmdbID: 1, Title: "Has file", Monitored: true, HasFile: true},
+ {TmdbID: 2, Title: "Wanted", Monitored: true, HasFile: false},
+ {TmdbID: 3, Title: "Unmonitored", Monitored: false, HasFile: false},
+ {TmdbID: 0, Title: "No TMDB", Monitored: true, HasFile: false}, // no tmdbId
+ }
+ wanted := ExtractWantedMovies(movies)
+ if len(wanted) != 1 {
+ t.Fatalf("ExtractWantedMovies = %d items, want 1", len(wanted))
+ }
+ if wanted[0].TmdbID != 2 || wanted[0].Type != "movie" {
+ t.Errorf("ExtractWantedMovies[0] = %+v, want tmdbId=2 type=movie", wanted[0])
+ }
+}
+
+func TestExtractWantedSeries(t *testing.T) {
+ series := []Series{
+ {ImdbID: "tt1", Title: "Complete", Monitored: true, Statistics: SeriesStatistics{EpisodeCount: 10, EpisodeFileCount: 10}},
+ {ImdbID: "tt2", Title: "Missing eps", Monitored: true, Statistics: SeriesStatistics{EpisodeCount: 10, EpisodeFileCount: 5}},
+ {ImdbID: "tt3", Title: "Unmonitored", Monitored: false, Statistics: SeriesStatistics{EpisodeCount: 10, EpisodeFileCount: 0}},
+ }
+ wanted := ExtractWantedSeries(series)
+ if len(wanted) != 1 {
+ t.Fatalf("ExtractWantedSeries = %d items, want 1", len(wanted))
+ }
+ if wanted[0].ImdbID != "tt2" || wanted[0].Type != "show" {
+ t.Errorf("ExtractWantedSeries[0] = %+v, want imdbId=tt2 type=show", wanted[0])
+ }
+}
+
+func TestExtractBlocklistedHashes(t *testing.T) {
+ items := []BlocklistItem{
+ {Data: BlocklistData{InfoHash: "AAAA"}},
+ {Data: BlocklistData{InfoHash: "AAAA"}}, // duplicate
+ {Data: BlocklistData{InfoHash: "BBBB"}},
+ {Data: BlocklistData{InfoHash: ""}}, // empty
+ }
+ hashes := ExtractBlocklistedHashes(items)
+ if len(hashes) != 2 {
+ t.Fatalf("ExtractBlocklistedHashes = %d, want 2", len(hashes))
+ }
+}
+
+func TestExtractDownloadedHashes(t *testing.T) {
+ records := []HistoryRecord{
+ {EventType: "downloadFolderImported", Data: HistoryData{InfoHash: "hash1"}},
+ {EventType: "grabbed", Data: HistoryData{InfoHash: "hash2"}}, // not imported
+ {EventType: "downloadFolderImported", Data: HistoryData{InfoHash: "hash1"}}, // duplicate
+ {EventType: "downloadFolderImported", Data: HistoryData{InfoHash: "hash3"}},
+ }
+ hashes := ExtractDownloadedHashes(records)
+ if len(hashes) != 2 {
+ t.Fatalf("ExtractDownloadedHashes = %d, want 2", len(hashes))
+ }
+}
+
+func TestMapRootFolders_MostUsed(t *testing.T) {
+ folders := []RootFolder{
+ {Path: "/data/movies1"},
+ {Path: "/data/movies2"},
+ }
+ movies := []Movie{
+ {RootFolderPath: "/data/movies1"},
+ {RootFolderPath: "/data/movies2"},
+ {RootFolderPath: "/data/movies2"},
+ {RootFolderPath: "/data/movies2"},
+ }
+ moviesDir, _ := MapRootFolders(folders, nil, movies, nil)
+ if moviesDir != "/data/movies2" {
+ t.Errorf("MapRootFolders = %q, want /data/movies2", moviesDir)
+ }
+}
+
+func TestMapRootFolders_SingleFolder(t *testing.T) {
+ folders := []RootFolder{{Path: "/data/movies"}}
+ moviesDir, _ := MapRootFolders(folders, nil, nil, nil)
+ if moviesDir != "/data/movies" {
+ t.Errorf("MapRootFolders = %q, want /data/movies", moviesDir)
+ }
+}
+
+func TestMapRootFolders_Empty(t *testing.T) {
+ moviesDir, tvDir := MapRootFolders(nil, nil, nil, nil)
+ if moviesDir != "" || tvDir != "" {
+ t.Errorf("MapRootFolders empty = %q, %q, want empty", moviesDir, tvDir)
+ }
+}
+
+func TestHasDockerPaths(t *testing.T) {
+ tests := []struct {
+ name string
+ movies string
+ tv string
+ expected bool
+ }{
+ {"docker paths", "/data/media/movies", "/data/media/tv", true},
+ {"host paths", "/home/user/Media/Movies", "/home/user/Media/TV", false},
+ {"empty", "", "", false},
+ {"mixed", "/home/user/movies", "/data/media/tv", true},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ r := &MigrationResult{MoviesDir: tt.movies, TVShowsDir: tt.tv}
+ if got := HasDockerPaths(r); got != tt.expected {
+ t.Errorf("HasDockerPaths = %v, want %v", got, tt.expected)
+ }
+ })
+ }
+}
+
+func TestExtractDebridTokens(t *testing.T) {
+ clients := []DownloadClient{
+ {ID: 1, Name: "TorBox", Enable: true, Implementation: "TorBox", ImplementationName: "TorBox"},
+ {ID: 2, Name: "qBittorrent", Enable: true, Implementation: "QBittorrent", ImplementationName: "qBittorrent"},
+ {ID: 3, Name: "Disabled Debrid", Enable: false, Implementation: "RealDebrid", ImplementationName: "Real-Debrid"},
+ }
+
+ getFields := func(id int) []Field {
+ if id == 1 {
+ return []Field{
+ {Name: "ApiKey", Value: "tb_test_token_123"},
+ {Name: "Host", Value: "torbox.app"},
+ }
+ }
+ return nil
+ }
+
+ tokens := ExtractDebridTokens(clients, getFields)
+ if len(tokens) != 1 {
+ t.Fatalf("ExtractDebridTokens = %d tokens, want 1", len(tokens))
+ }
+ if tokens[0].Provider != "torbox" || tokens[0].Token != "tb_test_token_123" {
+ t.Errorf("ExtractDebridTokens[0] = %+v, want torbox with token", tokens[0])
+ }
+}
diff --git a/internal/arr/types.go b/internal/arr/types.go
new file mode 100644
index 0000000..8e341ae
--- /dev/null
+++ b/internal/arr/types.go
@@ -0,0 +1,207 @@
+package arr
+
+// SystemStatus is returned by GET /api/v{n}/system/status.
+type SystemStatus struct {
+ AppName string `json:"appName"`
+ Version string `json:"version"`
+ StartupPath string `json:"startupPath"`
+}
+
+// QualityProfile represents a quality configuration in Sonarr/Radarr.
+type QualityProfile struct {
+ ID int `json:"id"`
+ Name string `json:"name"`
+ Items []QualityItem `json:"items"`
+ Cutoff int `json:"cutoff"`
+}
+
+// QualityItem is a single entry (or group) inside a quality profile.
+type QualityItem struct {
+ Quality *Quality `json:"quality"`
+ Items []QualityItem `json:"items"` // nested quality groups
+ Allowed bool `json:"allowed"`
+}
+
+// Quality describes a single quality level.
+type Quality struct {
+ ID int `json:"id"`
+ Name string `json:"name"`
+ Resolution int `json:"resolution"`
+}
+
+// RootFolder is a media root folder configured in Sonarr/Radarr.
+type RootFolder struct {
+ ID int `json:"id"`
+ Path string `json:"path"`
+ FreeSpace int64 `json:"freeSpace"`
+}
+
+// Movie is a Radarr movie record from GET /api/v3/movie.
+type Movie struct {
+ ID int `json:"id"`
+ TmdbID int `json:"tmdbId"`
+ ImdbID string `json:"imdbId"`
+ Title string `json:"title"`
+ Year int `json:"year"`
+ Path string `json:"path"`
+ RootFolderPath string `json:"rootFolderPath"`
+ QualityProfileID int `json:"qualityProfileId"`
+ Monitored bool `json:"monitored"`
+ HasFile bool `json:"hasFile"`
+ SizeOnDisk int64 `json:"sizeOnDisk"`
+}
+
+// Series is a Sonarr series record from GET /api/v3/series.
+type Series struct {
+ ID int `json:"id"`
+ TvdbID int `json:"tvdbId"`
+ ImdbID string `json:"imdbId"`
+ Title string `json:"title"`
+ Year int `json:"year"`
+ Path string `json:"path"`
+ RootFolderPath string `json:"rootFolderPath"`
+ QualityProfileID int `json:"qualityProfileId"`
+ Monitored bool `json:"monitored"`
+ Statistics SeriesStatistics `json:"statistics"`
+}
+
+// SeriesStatistics holds episode-level stats for a series.
+type SeriesStatistics struct {
+ EpisodeCount int `json:"episodeCount"`
+ EpisodeFileCount int `json:"episodeFileCount"`
+ SizeOnDisk int64 `json:"sizeOnDisk"`
+ PercentOfEpisodes float64 `json:"percentOfEpisodes"`
+}
+
+// Indexer is a Prowlarr indexer from GET /api/v1/indexer.
+type Indexer struct {
+ ID int `json:"id"`
+ Name string `json:"name"`
+ Enable bool `json:"enable"`
+ ImplementationName string `json:"implementationName"`
+}
+
+// Application is a Prowlarr-connected app from GET /api/v1/applications.
+type Application struct {
+ ID int `json:"id"`
+ Name string `json:"name"`
+ Fields []Field `json:"fields"`
+}
+
+// Field is a dynamic key-value pair used in Prowlarr indexer/app configs.
+type Field struct {
+ Name string `json:"name"`
+ Value any `json:"value"`
+}
+
+// DownloadClient is a download client configured in Sonarr/Radarr.
+type DownloadClient struct {
+ ID int `json:"id"`
+ Name string `json:"name"`
+ Enable bool `json:"enable"`
+ Protocol string `json:"protocol"` // "torrent" or "usenet"
+ Implementation string `json:"implementation"`
+ ImplementationName string `json:"implementationName"`
+}
+
+// Tag is a label applied to movies/series in Sonarr/Radarr.
+type Tag struct {
+ ID int `json:"id"`
+ Label string `json:"label"`
+}
+
+// HistoryRecord is a single entry from /api/v3/history.
+type HistoryRecord struct {
+ ID int `json:"id"`
+ EventType string `json:"eventType"` // "grabbed", "downloadFolderImported", etc.
+ DownloadID string `json:"downloadId"`
+ SourceTitle string `json:"sourceTitle"`
+ Data HistoryData `json:"data"`
+}
+
+// HistoryData holds the nested data of a history record.
+type HistoryData struct {
+ InfoHash string `json:"torrentInfoHash"`
+ DownloadURL string `json:"downloadUrl"`
+}
+
+// HistoryResponse wraps the paginated history from *arr.
+type HistoryResponse struct {
+ Records []HistoryRecord `json:"records"`
+ TotalRecords int `json:"totalRecords"`
+}
+
+// BlocklistItem is an item the user explicitly rejected.
+type BlocklistItem struct {
+ ID int `json:"id"`
+ SourceTitle string `json:"sourceTitle"`
+ Data BlocklistData `json:"data"`
+}
+
+// BlocklistData holds torrent info from a blocklist entry.
+type BlocklistData struct {
+ InfoHash string `json:"torrentInfoHash"`
+}
+
+// BlocklistResponse wraps paginated blocklist from *arr.
+type BlocklistResponse struct {
+ Records []BlocklistItem `json:"records"`
+ TotalRecords int `json:"totalRecords"`
+}
+
+// Instance represents a discovered *arr application.
+type Instance struct {
+ App string // "sonarr", "radarr", "prowlarr"
+ URL string // "http://localhost:8989"
+ APIKey string // from config.xml or user input
+ Version string // from /system/status
+ Source string // "docker", "port-scan", "config-file", "prowlarr", "manual"
+}
+
+// MigrationResult holds the mapped data ready to apply.
+type MigrationResult struct {
+ // Config changes
+ MoviesDir string
+ TVShowsDir string
+ Quality string // "2160p", "1080p", "720p"
+ QualitySource string // name of the quality profile used
+ OrganizeEnabled bool
+
+ // Wanted list
+ WantedMovies []WantedItem
+ WantedSeries []WantedItem
+
+ // Exclusions
+ BlocklistedHashes []string // infoHashes the user has rejected
+ DownloadedHashes []string // infoHashes already downloaded (from history)
+
+ // Debrid
+ DebridTokens []DebridToken // tokens extracted from *arr download clients
+
+ // Media servers
+ MediaServers []string // detected media servers (e.g. "Plex at localhost:32400")
+
+ // Stats (informational)
+ TotalMovies int
+ MoviesWithFiles int
+ TotalSeries int
+ SeriesComplete int
+ IndexerCount int
+ DownloadClients []string // names of detected download clients
+}
+
+// DebridToken is a debrid API token extracted from an *arr download client.
+type DebridToken struct {
+ Provider string // "real-debrid", "alldebrid", "torbox", "premiumize"
+ Token string
+ Name string // download client name from *arr
+}
+
+// WantedItem is a movie or series the user wants but doesn't have yet.
+type WantedItem struct {
+ TmdbID int `json:"tmdbId,omitempty"`
+ ImdbID string `json:"imdbId,omitempty"`
+ Title string `json:"title"`
+ Year int `json:"year,omitempty"`
+ Type string `json:"type"` // "movie" or "show"
+}
diff --git a/internal/cmd/config_menu.go b/internal/cmd/config_menu.go
index 66139f3..82e2090 100644
--- a/internal/cmd/config_menu.go
+++ b/internal/cmd/config_menu.go
@@ -158,6 +158,11 @@ func configDownloads(cfg *config.Config) error {
cfg.Download.PreferredMethod = "auto"
}
+ validQualities := map[string]bool{"": true, "720p": true, "1080p": true, "2160p": true}
+ if !validQualities[cfg.Download.PreferredQuality] {
+ cfg.Download.PreferredQuality = ""
+ }
+
err := huh.NewForm(
huh.NewGroup(
huh.NewInput().
@@ -172,6 +177,16 @@ func configDownloads(cfg *config.Config) error {
huh.NewOption("Usenet only (requires Pro)", "usenet"),
).
Value(&cfg.Download.PreferredMethod),
+ huh.NewSelect[string]().
+ Title("Preferred quality").
+ Description("Hint for automatic torrent selection").
+ Options(
+ huh.NewOption("Any (best available)", ""),
+ huh.NewOption("720p", "720p"),
+ huh.NewOption("1080p", "1080p"),
+ huh.NewOption("2160p (4K)", "2160p"),
+ ).
+ Value(&cfg.Download.PreferredQuality),
huh.NewSelect[string]().
Title("Max concurrent downloads").
Options(
diff --git a/internal/cmd/init.go b/internal/cmd/init.go
index 8e7b3c7..3bca2c3 100644
--- a/internal/cmd/init.go
+++ b/internal/cmd/init.go
@@ -14,7 +14,9 @@ import (
"github.com/google/uuid"
"github.com/spf13/cobra"
"github.com/torrentclaw/torrentclaw-cli/internal/agent"
+ "github.com/torrentclaw/torrentclaw-cli/internal/arr"
"github.com/torrentclaw/torrentclaw-cli/internal/config"
+ "github.com/torrentclaw/torrentclaw-cli/internal/mediaserver"
)
func newInitCmd() *cobra.Command {
@@ -52,6 +54,7 @@ func runInit(apiURLOverride string) error {
bold := color.New(color.Bold)
green := color.New(color.FgGreen)
cyan := color.New(color.FgCyan)
+ dim := color.New(color.FgHiBlack)
fmt.Println()
bold.Println(" unarr init")
@@ -140,23 +143,73 @@ func runInit(apiURLOverride string) error {
// ── Step 2/3: Download directory ────────────────────────────────
downloadDir := cfg.Download.Dir
+
+ // Detect media servers and library paths
+ detected := mediaserver.Detect()
+ if len(detected.Servers) > 0 {
+ for _, s := range detected.Servers {
+ cyan.Printf(" Detected %s at %s\n", s.Name, s.URL)
+ }
+ if len(detected.Paths) > 0 {
+ dim.Printf(" Found media libraries: %s\n", strings.Join(detected.Paths, ", "))
+ }
+ fmt.Println()
+ }
+
+ // If no dir yet and we detected media paths, offer a Select; otherwise show Input
+ needsInput := true
+ if downloadDir == "" && len(detected.Paths) > 0 {
+ var options []huh.Option[string]
+ for _, p := range detected.Paths {
+ options = append(options, huh.NewOption(p, p))
+ }
+ if parent := mediaserver.ParentDir(detected.Paths); parent != "" {
+ options = append(options, huh.NewOption(parent+" (parent directory)", parent))
+ }
+ options = append(options, huh.NewOption(defaultDownloadDir()+" (default)", defaultDownloadDir()))
+ options = append(options, huh.NewOption("Custom path...", "__custom__"))
+
+ downloadDir = detected.Paths[0]
+ err = huh.NewForm(
+ huh.NewGroup(
+ huh.NewSelect[string]().
+ Title("Step 2/3 — Download Directory").
+ Description("Detected media libraries on your system").
+ Options(options...).
+ Value(&downloadDir),
+ ),
+ ).Run()
+ if err != nil {
+ if errors.Is(err, huh.ErrUserAborted) {
+ fmt.Println("\n Init cancelled.")
+ return nil
+ }
+ return err
+ }
+ needsInput = downloadDir == "__custom__"
+ if needsInput {
+ downloadDir = defaultDownloadDir()
+ }
+ }
if downloadDir == "" {
downloadDir = defaultDownloadDir()
}
- err = huh.NewForm(
- huh.NewGroup(
- huh.NewInput().
- Title("Step 2/3 — Download Directory").
- Description("Where should downloaded files be saved?").
- Value(&downloadDir),
- ),
- ).Run()
- if err != nil {
- if errors.Is(err, huh.ErrUserAborted) {
- fmt.Println("\n Init cancelled.")
- return nil
+ if needsInput {
+ err = huh.NewForm(
+ huh.NewGroup(
+ huh.NewInput().
+ Title("Step 2/3 — Download Directory").
+ Description("Where should downloaded files be saved?").
+ Value(&downloadDir),
+ ),
+ ).Run()
+ if err != nil {
+ if errors.Is(err, huh.ErrUserAborted) {
+ fmt.Println("\n Init cancelled.")
+ return nil
+ }
+ return err
}
- return err
}
downloadDir = expandHome(strings.TrimSpace(downloadDir))
@@ -226,6 +279,60 @@ func runInit(apiURLOverride string) error {
}
}
+ // ── Debrid auto-detection from *arr ─────────────────────────────
+
+ if resp.User.IsPro {
+ debridTokens := detectDebridFromArr(dim)
+ if len(debridTokens) > 0 {
+ fmt.Println()
+ cyan.Printf(" Found %d debrid token(s) from your *arr setup:\n", len(debridTokens))
+ for _, dt := range debridTokens {
+ masked := dt.Token
+ if len(masked) > 8 {
+ masked = masked[:8] + "..."
+ }
+ fmt.Printf(" %s (%s) — %s\n", dt.Provider, dt.Name, masked)
+ }
+ fmt.Println()
+
+ var configureDebrid bool
+ err = huh.NewForm(
+ huh.NewGroup(
+ huh.NewConfirm().
+ Title("Configure debrid automatically?").
+ Description("Validates and saves the token to your unarr account").
+ Affirmative("Yes, configure").
+ Negative("No, skip").
+ Value(&configureDebrid),
+ ),
+ ).Run()
+ if err == nil && configureDebrid {
+ for _, dt := range debridTokens {
+ fmt.Printf(" Configuring %s... ", dt.Provider)
+ result, err := ac.ConfigureDebrid(context.Background(), agent.ConfigureDebridRequest{
+ Provider: dt.Provider,
+ Token: dt.Token,
+ })
+ if err != nil {
+ color.New(color.FgYellow).Printf("failed: %s\n", err)
+ } else if result.Success {
+ green.Printf("OK")
+ if result.Account.Username != "" {
+ fmt.Printf(" (%s", result.Account.Username)
+ if result.Account.Premium {
+ fmt.Print(", premium")
+ }
+ fmt.Print(")")
+ }
+ fmt.Println()
+ } else if result.Error != "" {
+ color.New(color.FgYellow).Printf("failed: %s\n", result.Error)
+ }
+ }
+ }
+ }
+ }
+
// ── Summary ─────────────────────────────────────────────────────
fmt.Println()
@@ -264,3 +371,31 @@ func runInit(apiURLOverride string) error {
return nil
}
+
+// detectDebridFromArr does a lightweight scan for *arr instances and extracts
+// debrid tokens from their download client configs.
+func detectDebridFromArr(dim *color.Color) []arr.DebridToken {
+ dim.Println(" Scanning for *arr instances with debrid...")
+
+ instances := arr.Discover()
+ if len(instances) == 0 {
+ return nil
+ }
+
+ var tokens []arr.DebridToken
+ for _, inst := range instances {
+ if inst.App == "prowlarr" || inst.APIKey == "" {
+ continue
+ }
+ client := arr.NewClient(inst.URL, inst.APIKey)
+ dcs, _ := client.DownloadClients()
+ if len(dcs) == 0 {
+ continue
+ }
+ tokens = append(tokens, arr.ExtractDebridTokens(dcs, func(id int) []arr.Field {
+ fields, _ := client.DownloadClientDetails(id)
+ return fields
+ })...)
+ }
+ return tokens
+}
diff --git a/internal/cmd/migrate.go b/internal/cmd/migrate.go
new file mode 100644
index 0000000..bcdde3a
--- /dev/null
+++ b/internal/cmd/migrate.go
@@ -0,0 +1,701 @@
+package cmd
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "strings"
+
+ "github.com/charmbracelet/huh"
+ "github.com/fatih/color"
+ "github.com/spf13/cobra"
+ "github.com/torrentclaw/torrentclaw-cli/internal/agent"
+ "github.com/torrentclaw/torrentclaw-cli/internal/arr"
+ "github.com/torrentclaw/torrentclaw-cli/internal/config"
+ "github.com/torrentclaw/torrentclaw-cli/internal/mediaserver"
+)
+
+func newMigrateCmd() *cobra.Command {
+ var (
+ dryRun bool
+ skipWanted bool
+ radarrURL string
+ radarrKey string
+ sonarrURL string
+ sonarrKey string
+ )
+
+ cmd := &cobra.Command{
+ Use: "migrate",
+ Short: "[pre-beta] Import settings from Sonarr, Radarr, and Prowlarr",
+ Long: `[PRE-BETA] This feature is under active development and may change.
+
+Scans for existing *arr instances, imports your library preferences,
+and queues downloads for wanted content — replacing your entire *arr stack.
+
+Detects instances automatically via Docker, config files, and network scan.
+You can also provide connection details manually with flags.
+
+This command is read-only for your *arr apps — it only reads data,
+never modifies them.
+
+Config file: ~/.config/unarr/config.toml`,
+ Example: ` unarr migrate # Auto-detect and migrate
+ unarr migrate --dry-run # Preview without applying changes
+ unarr migrate --radarr-url http://localhost:7878 --radarr-key abc123`,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ return runMigrate(migrateOpts{
+ DryRun: dryRun,
+ SkipWanted: skipWanted,
+ RadarrURL: radarrURL,
+ RadarrKey: radarrKey,
+ SonarrURL: sonarrURL,
+ SonarrKey: sonarrKey,
+ })
+ },
+ }
+
+ cmd.Flags().BoolVar(&dryRun, "dry-run", false, "preview changes without applying")
+ cmd.Flags().BoolVar(&skipWanted, "skip-wanted", false, "don't import wanted list")
+ cmd.Flags().StringVar(&radarrURL, "radarr-url", "", "Radarr URL (skip auto-detection)")
+ cmd.Flags().StringVar(&radarrKey, "radarr-key", "", "Radarr API key")
+ cmd.Flags().StringVar(&sonarrURL, "sonarr-url", "", "Sonarr URL (skip auto-detection)")
+ cmd.Flags().StringVar(&sonarrKey, "sonarr-key", "", "Sonarr API key")
+
+ return cmd
+}
+
+type migrateOpts struct {
+ DryRun bool
+ SkipWanted bool
+ RadarrURL string
+ RadarrKey string
+ SonarrURL string
+ SonarrKey string
+}
+
+func runMigrate(opts migrateOpts) error {
+ // JSON mode: skip interactive parts, text → stderr, JSON → stdout
+ jsonMode := jsonOut && opts.DryRun
+ if !jsonMode && !isTerminal() {
+ return fmt.Errorf("interactive mode requires a terminal")
+ }
+
+ // In JSON mode, all progress text goes to stderr so stdout is clean JSON
+ out := os.Stdout
+ if jsonMode {
+ out = os.Stderr
+ }
+
+ bold := color.New(color.Bold)
+ green := color.New(color.FgGreen)
+ yellow := color.New(color.FgYellow)
+ dim := color.New(color.FgHiBlack)
+ cyan := color.New(color.FgCyan)
+
+ // Point all color writers to the chosen output
+ bold.SetWriter(out)
+ green.SetWriter(out)
+ yellow.SetWriter(out)
+ dim.SetWriter(out)
+ cyan.SetWriter(out)
+
+ // Shorthand for writing to the output stream (not stdout in JSON mode)
+ pr := func(format string, a ...any) { fmt.Fprintf(out, format, a...) }
+ ln := func(a ...any) { fmt.Fprintln(out, a...) }
+
+ cfg := loadConfig()
+
+ // Check unarr is initialized
+ if cfg.Auth.APIKey == "" {
+ return fmt.Errorf("unarr is not configured yet — run 'unarr init' first")
+ }
+
+ ln()
+ bold.Println(" unarr migrate")
+ yellow.Println(" [pre-beta] This feature is under active development.")
+ ln()
+
+ // ── Phase 1: Discover instances ─────────────────────────────────
+
+ instances := discoverInstances(opts, dim)
+
+ if len(instances) == 0 {
+ ln(" No *arr instances found automatically.")
+ ln()
+
+ // Offer manual entry
+ manual, err := manualInstanceEntry()
+ if err != nil {
+ if errors.Is(err, huh.ErrUserAborted) {
+ ln("\n Migration cancelled.")
+ return nil
+ }
+ return err
+ }
+ instances = manual
+ }
+
+ if len(instances) == 0 {
+ ln(" No instances to migrate from. Exiting.")
+ return nil
+ }
+
+ // Verify all instances and collect API keys where missing
+ instances, err := verifyInstances(instances)
+ if err != nil {
+ if errors.Is(err, huh.ErrUserAborted) {
+ ln("\n Migration cancelled.")
+ return nil
+ }
+ return err
+ }
+
+ // ── Phase 2: Extract data ───────────────────────────────────────
+
+ ln()
+ dim.Println(" Fetching library data...")
+ ln()
+
+ var (
+ movies []arr.Movie
+ series []arr.Series
+ radarrProfiles []arr.QualityProfile
+ sonarrProfiles []arr.QualityProfile
+ radarrFolders []arr.RootFolder
+ sonarrFolders []arr.RootFolder
+ indexers []arr.Indexer
+ downloadClients []arr.DownloadClient
+ historyRecords []arr.HistoryRecord
+ blocklistItems []arr.BlocklistItem
+ )
+
+ // First pass: discover extra instances from Prowlarr before fetching data
+ urlSet := make(map[string]bool, len(instances))
+ for _, inst := range instances {
+ urlSet[strings.ToLower(inst.URL)] = true
+ }
+
+ var extraInstances []arr.Instance
+ for _, inst := range instances {
+ if inst.App != "prowlarr" {
+ continue
+ }
+ client := arr.NewClient(inst.URL, inst.APIKey)
+ if idx, err := client.Indexers(); err == nil {
+ indexers = idx
+ }
+ extra := arr.DiscoverFromProwlarr(inst.URL, inst.APIKey)
+ for _, e := range extra {
+ key := strings.ToLower(e.URL)
+ if !urlSet[key] {
+ urlSet[key] = true
+ extraInstances = append(extraInstances, e)
+ }
+ }
+ }
+
+ // Verify and append Prowlarr-discovered instances
+ for i := range extraInstances {
+ if err := arr.Verify(&extraInstances[i]); err == nil {
+ instances = append(instances, extraInstances[i])
+ }
+ }
+
+ // Second pass: fetch data from all Sonarr/Radarr instances
+ for _, inst := range instances {
+ client := arr.NewClient(inst.URL, inst.APIKey)
+
+ switch inst.App {
+ case "radarr":
+ if m, err := client.Movies(); err == nil {
+ movies = m
+ } else {
+ yellow.Printf(" Warning: could not fetch Radarr movies: %s\n", err)
+ }
+ if p, err := client.QualityProfiles(); err == nil {
+ radarrProfiles = p
+ }
+ if f, err := client.RootFolders(); err == nil {
+ radarrFolders = f
+ }
+ if d, err := client.DownloadClients(); err == nil {
+ downloadClients = append(downloadClients, d...)
+ }
+ if h, err := client.History(250); err == nil {
+ historyRecords = append(historyRecords, h...)
+ }
+ if b, err := client.Blocklist(250); err == nil {
+ blocklistItems = append(blocklistItems, b...)
+ }
+
+ case "sonarr":
+ if s, err := client.Series(); err == nil {
+ series = s
+ } else {
+ yellow.Printf(" Warning: could not fetch Sonarr series: %s\n", err)
+ }
+ if p, err := client.QualityProfiles(); err == nil {
+ sonarrProfiles = p
+ }
+ if f, err := client.RootFolders(); err == nil {
+ sonarrFolders = f
+ }
+ if d, err := client.DownloadClients(); err == nil {
+ downloadClients = append(downloadClients, d...)
+ }
+ if h, err := client.History(250); err == nil {
+ historyRecords = append(historyRecords, h...)
+ }
+ if b, err := client.Blocklist(250); err == nil {
+ blocklistItems = append(blocklistItems, b...)
+ }
+ }
+ }
+
+ result := arr.BuildMigrationResult(
+ movies, series,
+ radarrProfiles, sonarrProfiles,
+ radarrFolders, sonarrFolders,
+ indexers, downloadClients,
+ )
+
+ // Extract exclusion hashes from history and blocklist
+ result.BlocklistedHashes = arr.ExtractBlocklistedHashes(blocklistItems)
+ result.DownloadedHashes = arr.ExtractDownloadedHashes(historyRecords)
+
+ // Extract debrid tokens from download clients (once, not per-instance)
+ if len(downloadClients) > 0 {
+ // Use the first available Sonarr/Radarr client for fetching field details
+ var fieldsClient *arr.Client
+ for _, inst := range instances {
+ if inst.App != "prowlarr" && inst.APIKey != "" {
+ fieldsClient = arr.NewClient(inst.URL, inst.APIKey)
+ break
+ }
+ }
+ if fieldsClient != nil {
+ result.DebridTokens = arr.ExtractDebridTokens(downloadClients, func(id int) []arr.Field {
+ fields, _ := fieldsClient.DownloadClientDetails(id)
+ return fields
+ })
+ }
+ }
+
+ // Detect media servers
+ detected := mediaserver.Detect()
+ for _, s := range detected.Servers {
+ result.MediaServers = append(result.MediaServers, fmt.Sprintf("%s at %s", s.Name, s.URL))
+ }
+
+ // ── Phase 3: Show instances table ───────────────────────────────
+
+ green.Printf(" ✓ Found %d instance(s):\n", len(instances))
+ ln()
+ pr(" %-12s %-35s %-14s %s\n", "App", "URL", "Source", "Library")
+ dim.Printf(" %-12s %-35s %-14s %s\n", "───", "───", "──────", "───────")
+
+ for _, inst := range instances {
+ lib := ""
+ switch inst.App {
+ case "radarr":
+ wanted := len(result.WantedMovies)
+ lib = fmt.Sprintf("%d movies (%d wanted)", result.TotalMovies, wanted)
+ case "sonarr":
+ wanted := len(result.WantedSeries)
+ lib = fmt.Sprintf("%d series (%d wanted)", result.TotalSeries, wanted)
+ case "prowlarr":
+ lib = fmt.Sprintf("%d indexers", result.IndexerCount)
+ }
+ pr(" %-12s %-35s %-14s %s\n", inst.App, inst.URL, inst.Source, lib)
+ }
+
+ // ── Phase 4: Migration preview ──────────────────────────────────
+
+ ln()
+ ln(" ──────────────────────────────────────────────────────")
+ ln()
+ bold.Println(" Migration preview:")
+ ln()
+
+ // Config changes
+ bold.Println(" Config:")
+ if result.MoviesDir != "" {
+ pr(" Movies directory %-25s", result.MoviesDir)
+ dim.Println(" (from Radarr root folder)")
+ }
+ if result.TVShowsDir != "" {
+ pr(" TV Shows directory %-25s", result.TVShowsDir)
+ dim.Println(" (from Sonarr root folder)")
+ }
+ if result.Quality != "" {
+ pr(" Preferred quality %-25s", result.Quality)
+ dim.Printf(" (from profile %q)\n", result.QualitySource)
+ }
+ if result.OrganizeEnabled {
+ pr(" Auto-organize %-25s\n", "enabled")
+ }
+
+ // Docker path warning
+ if arr.HasDockerPaths(result) {
+ ln()
+ yellow.Println(" ⚠ These paths appear to be Docker container paths.")
+ yellow.Println(" Your host paths may differ — verify after migration.")
+ }
+
+ // Wanted list
+ totalWanted := len(result.WantedMovies) + len(result.WantedSeries)
+ if totalWanted > 0 && !opts.SkipWanted {
+ ln()
+ bold.Printf(" Downloads to queue: %d items\n", totalWanted)
+ if len(result.WantedMovies) > 0 {
+ pr(" %d movies", len(result.WantedMovies))
+ dim.Println(" (monitored, not yet downloaded)")
+ }
+ if len(result.WantedSeries) > 0 {
+ pr(" %d TV shows", len(result.WantedSeries))
+ dim.Println(" (monitored, incomplete episodes)")
+ }
+ }
+
+ // Exclusions
+ totalExcluded := len(result.BlocklistedHashes) + len(result.DownloadedHashes)
+ if totalExcluded > 0 {
+ ln()
+ bold.Println(" Exclusions:")
+ if len(result.DownloadedHashes) > 0 {
+ pr(" %d already downloaded", len(result.DownloadedHashes))
+ dim.Println(" (from *arr history — won't re-download)")
+ }
+ if len(result.BlocklistedHashes) > 0 {
+ pr(" %d blocklisted", len(result.BlocklistedHashes))
+ dim.Println(" (rejected releases — will be skipped)")
+ }
+ }
+
+ // Debrid tokens
+ if len(result.DebridTokens) > 0 {
+ ln()
+ bold.Println(" Debrid tokens found:")
+ for _, dt := range result.DebridTokens {
+ masked := dt.Token
+ if len(masked) > 8 {
+ masked = masked[:8] + "..."
+ }
+ pr(" %s (%s) %s\n", dt.Provider, dt.Name, masked)
+ }
+ dim.Println(" Configure via: unarr config connection (or web dashboard)")
+ }
+
+ // Media servers
+ if len(result.MediaServers) > 0 {
+ ln()
+ bold.Println(" Media servers detected:")
+ for _, ms := range result.MediaServers {
+ green.Printf(" ✓ %s\n", ms)
+ }
+ dim.Println(" These will keep working with your existing library.")
+ }
+
+ // Not needed anymore
+ if result.IndexerCount > 0 || len(result.DownloadClients) > 0 {
+ ln()
+ bold.Println(" Not needed anymore:")
+ if result.IndexerCount > 0 {
+ pr(" %d indexers", result.IndexerCount)
+ dim.Println(" (unarr searches 30+ sources automatically)")
+ }
+ if len(result.DownloadClients) > 0 {
+ // Deduplicate client names
+ seen := map[string]bool{}
+ var names []string
+ for _, n := range result.DownloadClients {
+ if !seen[n] {
+ seen[n] = true
+ names = append(names, n)
+ }
+ }
+ pr(" %s", strings.Join(names, ", "))
+ dim.Println(" (unarr downloads directly via torrent/debrid/usenet)")
+ }
+ }
+
+ ln()
+ ln(" ──────────────────────────────────────────────────────")
+ ln()
+
+ // ── Phase 5: Confirm & apply ────────────────────────────────────
+
+ if opts.DryRun {
+ if jsonMode {
+ // JSON export for scripting — write to real stdout
+ jsonBytes, _ := json.MarshalIndent(result, "", " ")
+ _, _ = os.Stdout.Write(jsonBytes)
+ _, _ = os.Stdout.Write([]byte("\n"))
+ } else {
+ cyan.Println(" Dry run — no changes applied.")
+ ln()
+ }
+ return nil
+ }
+
+ var confirm bool
+ err = huh.NewForm(
+ huh.NewGroup(
+ huh.NewConfirm().
+ Title("Apply these changes?").
+ Value(&confirm),
+ ),
+ ).Run()
+ if err != nil {
+ if errors.Is(err, huh.ErrUserAborted) {
+ ln("\n Migration cancelled.")
+ return nil
+ }
+ return err
+ }
+ if !confirm {
+ dim.Println(" No changes applied.")
+ ln()
+ return nil
+ }
+
+ // Apply config changes (only overwrite if currently empty)
+ changed := false
+ if result.MoviesDir != "" && cfg.Organize.MoviesDir == "" {
+ cfg.Organize.MoviesDir = result.MoviesDir
+ changed = true
+ }
+ if result.TVShowsDir != "" && cfg.Organize.TVShowsDir == "" {
+ cfg.Organize.TVShowsDir = result.TVShowsDir
+ changed = true
+ }
+ if result.OrganizeEnabled && !cfg.Organize.Enabled {
+ cfg.Organize.Enabled = true
+ changed = true
+ }
+ if result.Quality != "" && cfg.Download.PreferredQuality == "" {
+ cfg.Download.PreferredQuality = result.Quality
+ changed = true
+ }
+
+ if changed {
+ if err := cfg.ValidatePaths(); err != nil {
+ return fmt.Errorf("unsafe configuration: %w", err)
+ }
+
+ configPath := configFilePath()
+ if err := saveConfig(cfg, configPath); err != nil {
+ return fmt.Errorf("save config: %w", err)
+ }
+ green.Println(" ✓ Configuration updated")
+ }
+
+ // Import wanted list
+ if totalWanted > 0 && !opts.SkipWanted {
+ allWanted := make([]arr.WantedItem, 0, len(result.WantedMovies)+len(result.WantedSeries))
+ allWanted = append(allWanted, result.WantedMovies...)
+ allWanted = append(allWanted, result.WantedSeries...)
+
+ // Combine blocklisted + already-downloaded hashes to exclude
+ excludeHashes := make([]string, 0, len(result.BlocklistedHashes)+len(result.DownloadedHashes))
+ excludeHashes = append(excludeHashes, result.BlocklistedHashes...)
+ excludeHashes = append(excludeHashes, result.DownloadedHashes...)
+
+ if err := importWantedList(cfg, allWanted, excludeHashes, green, yellow, dim); err != nil {
+ yellow.Printf(" Warning: could not queue downloads: %s\n", err)
+ ln(" You can queue them manually from the web dashboard.")
+ }
+ }
+
+ // ── Phase 6: Next steps ─────────────────────────────────────────
+
+ ln()
+ ln(" Your *arr apps are still running. When you're ready:")
+ ln()
+ ln(" 1. Verify downloads are working: " + bold.Sprint("unarr status"))
+ ln(" 2. Stop *arr services: " + bold.Sprint("docker stop sonarr radarr prowlarr"))
+ ln(" 3. Keep your media server: Plex / Jellyfin / Emby stays as-is")
+ ln()
+
+ return nil
+}
+
+// ── Discovery helpers ───────────────────────────────────────────────
+
+func discoverInstances(opts migrateOpts, dim *color.Color) []arr.Instance {
+ var instances []arr.Instance
+
+ // Manual flags take priority
+ hasManualFlags := opts.RadarrURL != "" || opts.SonarrURL != ""
+ if hasManualFlags {
+ if opts.RadarrURL != "" {
+ instances = append(instances, arr.Instance{
+ App: "radarr",
+ URL: opts.RadarrURL,
+ APIKey: opts.RadarrKey,
+ Source: "manual",
+ })
+ }
+ if opts.SonarrURL != "" {
+ instances = append(instances, arr.Instance{
+ App: "sonarr",
+ URL: opts.SonarrURL,
+ APIKey: opts.SonarrKey,
+ Source: "manual",
+ })
+ }
+ return instances
+ }
+
+ // Auto-discovery
+ dim.Println(" Scanning for *arr instances...")
+ return arr.Discover()
+}
+
+func verifyInstances(instances []arr.Instance) ([]arr.Instance, error) {
+ var verified []arr.Instance
+ for _, inst := range instances {
+ if inst.APIKey == "" {
+ // Ask user for API key
+ var key string
+ err := huh.NewForm(
+ huh.NewGroup(
+ huh.NewInput().
+ Title(fmt.Sprintf("API key for %s (%s)", inst.App, inst.URL)).
+ Description("Found via " + inst.Source + " but no API key available").
+ Placeholder("Enter API key or leave empty to skip").
+ Value(&key),
+ ),
+ ).Run()
+ if err != nil {
+ return nil, err
+ }
+ key = strings.TrimSpace(key)
+ if key == "" {
+ continue // skip this instance
+ }
+ inst.APIKey = key
+ }
+
+ if err := arr.Verify(&inst); err != nil {
+ color.New(color.FgYellow).Printf(" Warning: %s at %s — %s (skipping)\n", inst.App, inst.URL, err)
+ continue
+ }
+ verified = append(verified, inst)
+ }
+ return verified, nil
+}
+
+func manualInstanceEntry() ([]arr.Instance, error) {
+ var radarrURL, radarrKey, sonarrURL, sonarrKey string
+
+ err := huh.NewForm(
+ huh.NewGroup(
+ huh.NewInput().
+ Title("Radarr URL").
+ Description("Leave empty to skip").
+ Placeholder("http://localhost:7878").
+ Value(&radarrURL),
+ huh.NewInput().
+ Title("Radarr API key").
+ Value(&radarrKey),
+ huh.NewInput().
+ Title("Sonarr URL").
+ Description("Leave empty to skip").
+ Placeholder("http://localhost:8989").
+ Value(&sonarrURL),
+ huh.NewInput().
+ Title("Sonarr API key").
+ Value(&sonarrKey),
+ ),
+ ).Run()
+ if err != nil {
+ return nil, err
+ }
+
+ var instances []arr.Instance
+ radarrURL = strings.TrimSpace(radarrURL)
+ sonarrURL = strings.TrimSpace(sonarrURL)
+
+ if radarrURL != "" && strings.TrimSpace(radarrKey) != "" {
+ instances = append(instances, arr.Instance{
+ App: "radarr",
+ URL: radarrURL,
+ APIKey: strings.TrimSpace(radarrKey),
+ Source: "manual",
+ })
+ }
+ if sonarrURL != "" && strings.TrimSpace(sonarrKey) != "" {
+ instances = append(instances, arr.Instance{
+ App: "sonarr",
+ URL: sonarrURL,
+ APIKey: strings.TrimSpace(sonarrKey),
+ Source: "manual",
+ })
+ }
+ return instances, nil
+}
+
+func importWantedList(cfg config.Config, items []arr.WantedItem, excludeHashes []string, green, yellow, dim *color.Color) error {
+ apiURL := cfg.Auth.APIURL
+ if apiURL == "" {
+ apiURL = "https://torrentclaw.com"
+ }
+
+ ac := agent.NewClient(apiURL, cfg.Auth.APIKey, "unarr/"+Version)
+
+ // Convert arr.WantedItem → agent.WantedItem
+ agentItems := make([]agent.WantedItem, len(items))
+ for i, item := range items {
+ agentItems[i] = agent.WantedItem{
+ TmdbID: item.TmdbID,
+ ImdbID: item.ImdbID,
+ Title: item.Title,
+ Year: item.Year,
+ Type: item.Type,
+ }
+ }
+
+ resp, err := ac.BatchDownload(context.Background(), agent.BatchDownloadRequest{
+ Items: agentItems,
+ ExcludeHashes: excludeHashes,
+ })
+ if err != nil {
+ return err
+ }
+
+ green.Printf(" ✓ %d downloads queued", resp.Queued)
+ if resp.NotFound > 0 {
+ fmt.Printf(" — %d not found in catalog", resp.NotFound)
+ }
+ if resp.AlreadyActive > 0 {
+ fmt.Printf(" — %d already active", resp.AlreadyActive)
+ }
+ fmt.Println()
+
+ if resp.Queued > 0 {
+ dim.Println(" They'll start when the daemon runs.")
+ }
+
+ return nil
+}
+
+// configFilePath returns the config file path, respecting the --config flag.
+func configFilePath() string {
+ if cfgFile != "" {
+ return cfgFile
+ }
+ return config.FilePath()
+}
+
+// saveConfig writes config to disk and updates the cached copy.
+func saveConfig(cfg config.Config, path string) error {
+ if err := config.Save(cfg, path); err != nil {
+ return err
+ }
+ appCfg = cfg
+ return nil
+}
diff --git a/internal/cmd/root.go b/internal/cmd/root.go
index 998bf5a..87c8f40 100644
--- a/internal/cmd/root.go
+++ b/internal/cmd/root.go
@@ -66,6 +66,8 @@ Source: https://github.com/torrentclaw/torrentclaw-cli`,
initCmd.GroupID = "start"
configCmd := newConfigCmd()
configCmd.GroupID = "start"
+ migrateCmd := newMigrateCmd()
+ migrateCmd.GroupID = "start"
// Search & Discovery
searchCmd := newSearchCmd()
@@ -109,10 +111,15 @@ Source: https://github.com/torrentclaw/torrentclaw-cli`,
completionCmd := newCompletionCmd()
completionCmd.GroupID = "system"
+ // Library
+ scanCmd := newScanCmd()
+ scanCmd.GroupID = "search"
+
rootCmd.AddCommand(
// Getting Started
initCmd,
configCmd,
+ migrateCmd,
// Search & Discovery
searchCmd,
inspectCmd,
@@ -134,11 +141,12 @@ Source: https://github.com/torrentclaw/torrentclaw-cli`,
selfUpdateCmd,
versionCmd,
completionCmd,
+ // Library
+ scanCmd,
// Stubs for future commands
newStubCmd("upgrade", "Find a better version of a torrent"),
newStubCmd("moreseed", "Find same quality with more seeders"),
newStubCmd("compare", "Compare two torrents side by side"),
- newStubCmd("scan", "Scan your media library for upgrades"),
newStubCmd("add", "Search and add torrents to your client"),
newStubCmd("monitor", "Watch for new episodes of a series"),
newStubCmd("open", "Open content in the browser"),
diff --git a/internal/cmd/scan.go b/internal/cmd/scan.go
new file mode 100644
index 0000000..8872215
--- /dev/null
+++ b/internal/cmd/scan.go
@@ -0,0 +1,340 @@
+package cmd
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "os"
+ "os/signal"
+ "sort"
+ "strings"
+ "syscall"
+
+ "github.com/fatih/color"
+ "github.com/spf13/cobra"
+ "github.com/torrentclaw/torrentclaw-cli/internal/agent"
+ "github.com/torrentclaw/torrentclaw-cli/internal/config"
+ "github.com/torrentclaw/torrentclaw-cli/internal/library"
+)
+
+func newScanCmd() *cobra.Command {
+ var (
+ workers int
+ ffprobe string
+ showStatus bool
+ noSync bool
+ )
+
+ cmd := &cobra.Command{
+ Use: "scan ",
+ Short: "Scan your media library for quality analysis",
+ Long: `Walk a folder recursively, analyze each video file with ffprobe,
+and sync the results to your TorrentClaw account.
+
+After scanning, visit your Library page at torrentclaw.com/library
+to see available quality upgrades.`,
+ Args: cobra.MaximumNArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if showStatus {
+ return runScanStatus()
+ }
+ if len(args) == 0 {
+ cfg := loadConfig()
+ if cfg.Library.ScanPath != "" {
+ args = append(args, cfg.Library.ScanPath)
+ } else {
+ return fmt.Errorf("usage: unarr scan \n\nProvide a media folder to scan")
+ }
+ }
+ return runScan(args[0], workers, ffprobe, noSync)
+ },
+ }
+
+ cmd.Flags().IntVar(&workers, "workers", 0, "concurrent ffprobe workers (default: config or 8)")
+ cmd.Flags().StringVar(&ffprobe, "ffprobe", "", "path to ffprobe binary")
+ cmd.Flags().BoolVar(&showStatus, "status", false, "show summary of last scan")
+ cmd.Flags().BoolVar(&noSync, "no-sync", false, "scan only, don't upload to server")
+
+ return cmd
+}
+
+func runScan(dirPath string, workers int, ffprobePath string, noSync bool) error {
+ // Validate path
+ info, err := os.Stat(dirPath)
+ if err != nil {
+ return fmt.Errorf("path not found: %s", dirPath)
+ }
+ if !info.IsDir() {
+ return fmt.Errorf("not a directory: %s", dirPath)
+ }
+
+ cfg := loadConfig()
+
+ // Resolve workers: flag → config → default 8
+ if workers == 0 {
+ workers = cfg.Library.Workers
+ }
+ if workers == 0 {
+ workers = 8
+ }
+
+ // Resolve ffprobe path from flag → config
+ if ffprobePath == "" {
+ ffprobePath = cfg.Library.FFprobePath
+ }
+
+ // Load existing cache for incremental scanning
+ existing, _ := library.LoadCache()
+
+ // Context with signal handling
+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+ defer stop()
+
+ bold := color.New(color.Bold)
+ bold.Printf("\n Scanning %s...\n\n", dirPath)
+
+ // Scan
+ cache, err := library.Scan(ctx, dirPath, existing, library.ScanOptions{
+ Workers: workers,
+ FFprobePath: ffprobePath,
+ Incremental: existing != nil,
+ OnProgress: func(scanned, total int, current string) {
+ // Truncate filename for display
+ if len(current) > 50 {
+ current = "..." + current[len(current)-47:]
+ }
+ fmt.Fprintf(os.Stderr, "\r Scanning %d/%d — %s\033[K", scanned, total, current)
+ },
+ })
+ if err != nil {
+ return fmt.Errorf("scan failed: %w", err)
+ }
+
+ fmt.Fprintf(os.Stderr, "\r\033[K") // clear progress line
+
+ // Save cache
+ if err := library.SaveCache(cache); err != nil {
+ return fmt.Errorf("save cache: %w", err)
+ }
+
+ // Remember scan path in config
+ if cfg.Library.ScanPath != dirPath {
+ cfg.Library.ScanPath = dirPath
+ _ = config.Save(cfg, cfgFile)
+ }
+
+ // Print summary
+ printScanSummary(cache)
+
+ // JSON output mode
+ if jsonOut {
+ enc := json.NewEncoder(os.Stdout)
+ enc.SetIndent("", " ")
+ return enc.Encode(cache)
+ }
+
+ // Sync to server
+ if !noSync {
+ return syncToServer(ctx, cfg, cache)
+ }
+
+ return nil
+}
+
+func syncToServer(ctx context.Context, cfg config.Config, cache *library.LibraryCache) error {
+ apiKey := apiKeyFlag
+ if apiKey == "" {
+ apiKey = cfg.Auth.APIKey
+ }
+ if apiKey == "" {
+ color.Yellow("\n ⚠ No API key configured. Run 'unarr init' to set up, or use --no-sync.")
+ return nil
+ }
+
+ ac := agent.NewClient(cfg.Auth.APIURL, apiKey, "unarr/"+Version)
+
+ // Build sync items from cache
+ items := make([]agent.LibrarySyncItem, 0, len(cache.Items))
+ for _, item := range cache.Items {
+ if item.ScanError != "" {
+ continue // skip items with scan errors
+ }
+ si := agent.LibrarySyncItem{
+ FilePath: item.FilePath,
+ FileName: item.FileName,
+ FileSize: item.FileSize,
+ Title: item.Title,
+ Year: item.Year,
+ ContentType: library.DeriveContentType(item),
+ Season: item.Season,
+ Episode: item.Episode,
+ }
+
+ if item.MediaInfo != nil {
+ if item.MediaInfo.Video != nil {
+ si.Resolution = library.ResolveResolution(item.MediaInfo.Video.Height)
+ si.VideoCodec = item.MediaInfo.Video.Codec
+ si.HDR = item.MediaInfo.Video.HDR
+ si.BitDepth = item.MediaInfo.Video.BitDepth
+ }
+ codec, channels := library.PrimaryAudioTrack(item.MediaInfo.Audio)
+ si.AudioCodec = codec
+ si.AudioChannels = channels
+ si.AudioLanguages = library.AudioLanguages(item.MediaInfo.Audio)
+ si.SubtitleLanguages = library.SubtitleLanguages(item.MediaInfo.Subtitles)
+ si.AudioTracks = item.MediaInfo.Audio
+ si.SubtitleTracks = item.MediaInfo.Subtitles
+ si.VideoInfo = item.MediaInfo.Video
+ }
+
+ items = append(items, si)
+ }
+
+ if len(items) == 0 {
+ color.Yellow("\n No valid items to sync.")
+ return nil
+ }
+
+ // Send in batches of 100
+ const batchSize = 100
+ totalSynced := 0
+ totalMatched := 0
+ totalRemoved := 0
+
+ for i := 0; i < len(items); i += batchSize {
+ end := i + batchSize
+ if end > len(items) {
+ end = len(items)
+ }
+ batch := items[i:end]
+ isLast := end >= len(items)
+
+ fmt.Fprintf(os.Stderr, "\r Syncing %d/%d items...\033[K", end, len(items))
+
+ resp, err := ac.SyncLibrary(ctx, agent.LibrarySyncRequest{
+ Items: batch,
+ ScanPath: cache.Path,
+ IsLastBatch: isLast,
+ })
+ if err != nil {
+ return fmt.Errorf("sync failed: %w", err)
+ }
+
+ totalSynced += resp.Synced
+ totalMatched += resp.Matched
+ totalRemoved += resp.Removed
+ }
+
+ fmt.Fprintf(os.Stderr, "\r\033[K")
+
+ green := color.New(color.FgGreen)
+ green.Printf("\n ✓ Synced %d items (%d matched, %d removed)\n", totalSynced, totalMatched, totalRemoved)
+
+ apiURL := strings.TrimSuffix(cfg.Auth.APIURL, "/")
+ fmt.Printf(" → View upgrades at %s/library\n\n", apiURL)
+
+ return nil
+}
+
+func runScanStatus() error {
+ cache, err := library.LoadCache()
+ if err != nil {
+ return fmt.Errorf("load cache: %w", err)
+ }
+ if cache == nil {
+ return fmt.Errorf("no library scan found. Run 'unarr scan ' first")
+ }
+
+ printScanSummary(cache)
+ return nil
+}
+
+func printScanSummary(cache *library.LibraryCache) {
+ bold := color.New(color.Bold)
+ dim := color.New(color.Faint)
+
+ total := len(cache.Items)
+ errors := 0
+ resCount := map[string]int{}
+ hdrCount := map[string]int{}
+ langCount := map[string]int{}
+
+ for _, item := range cache.Items {
+ if item.ScanError != "" {
+ errors++
+ continue
+ }
+ if item.MediaInfo == nil || item.MediaInfo.Video == nil {
+ continue
+ }
+
+ res := library.ResolveResolution(item.MediaInfo.Video.Height)
+ if res == "" {
+ res = "other"
+ }
+ resCount[res]++
+
+ hdr := item.MediaInfo.Video.HDR
+ if hdr == "" {
+ hdr = "SDR"
+ }
+ hdrCount[hdr]++
+
+ for _, lang := range item.MediaInfo.Languages {
+ langCount[lang]++
+ }
+ }
+
+ bold.Printf("\n Library scan complete — %d files in %s\n", total, cache.Path)
+ dim.Printf(" Scanned at: %s\n\n", cache.ScannedAt)
+
+ // Resolution table
+ bold.Println(" Resolution Files")
+ dim.Println(" ─────────────────────")
+ for _, res := range []string{"2160p", "1080p", "720p", "480p", "other"} {
+ if count, ok := resCount[res]; ok {
+ fmt.Printf(" %-14s%d\n", res, count)
+ }
+ }
+
+ // HDR table
+ fmt.Println()
+ bold.Println(" HDR Files")
+ dim.Println(" ─────────────────────")
+ hdrOrder := []string{"DV+HDR10", "DV", "HDR10", "HLG", "SDR"}
+ for _, hdr := range hdrOrder {
+ if count, ok := hdrCount[hdr]; ok {
+ fmt.Printf(" %-14s%d\n", hdr, count)
+ }
+ }
+
+ // Top languages
+ if len(langCount) > 0 {
+ fmt.Println()
+ type langEntry struct {
+ lang string
+ count int
+ }
+ var langs []langEntry
+ for l, c := range langCount {
+ langs = append(langs, langEntry{l, c})
+ }
+ sort.Slice(langs, func(i, j int) bool { return langs[i].count > langs[j].count })
+ top := langs
+ if len(top) > 5 {
+ top = top[:5]
+ }
+ parts := make([]string, len(top))
+ for i, l := range top {
+ parts[i] = fmt.Sprintf("%s (%d)", strings.ToUpper(l.lang), l.count)
+ }
+ bold.Print(" Top languages: ")
+ fmt.Println(strings.Join(parts, ", "))
+ }
+
+ if errors > 0 {
+ fmt.Println()
+ color.Yellow(" Scan errors: %d files (run with --verbose for details)", errors)
+ }
+ fmt.Println()
+}
diff --git a/internal/config/config.go b/internal/config/config.go
index 2de9f9b..3b96c1c 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -20,6 +20,7 @@ type Config struct {
Daemon DaemonConfig `toml:"daemon"`
Notifications NotificationsConfig `toml:"notifications"`
General GeneralConfig `toml:"general"`
+ Library LibraryConfig `toml:"library"`
}
type AuthConfig struct {
@@ -36,6 +37,7 @@ type AgentConfig struct {
type DownloadConfig struct {
Dir string `toml:"dir"`
PreferredMethod string `toml:"preferred_method"`
+ PreferredQuality string `toml:"preferred_quality"` // "2160p", "1080p", "720p" — hint for auto-selection
MaxConcurrent int `toml:"max_concurrent"`
MaxDownloadSpeed string `toml:"max_download_speed"` // e.g. "10MB", "500KB", "0" = unlimited
MaxUploadSpeed string `toml:"max_upload_speed"` // e.g. "1MB", "0" = unlimited
@@ -62,6 +64,13 @@ type GeneralConfig struct {
NoColor bool `toml:"no_color"`
}
+type LibraryConfig struct {
+ ScanPath string `toml:"scan_path"` // remembered from last scan
+ Workers int `toml:"workers"` // concurrent ffprobe (default 8)
+ FFprobePath string `toml:"ffprobe_path"` // optional explicit path
+ BackupDir string `toml:"backup_dir"` // for replaced files
+}
+
// Default returns a Config with sensible defaults.
func Default() Config {
return Config{
diff --git a/internal/engine/manager.go b/internal/engine/manager.go
index e4b3e4b..e9a1c7a 100644
--- a/internal/engine/manager.go
+++ b/internal/engine/manager.go
@@ -280,6 +280,19 @@ func (m *Manager) processTask(ctx context.Context, task *Task) {
task.FilePath = finalPath
task.mu.Unlock()
+ // 4b. Handle upgrade replacement (mode = "upgrade")
+ if task.ReplacePath != "" {
+ backupDir := "" // uses default ~/.local/share/unarr/replaced/
+ if err := replaceFile(task.ReplacePath, finalPath, backupDir); err != nil {
+ log.Printf("[%s] replace warning: %v (keeping new file at %s)", task.ID[:8], err, finalPath)
+ } else {
+ task.mu.Lock()
+ task.FilePath = task.ReplacePath
+ task.mu.Unlock()
+ log.Printf("[%s] upgraded: replaced %s", task.ID[:8], task.ReplacePath)
+ }
+ }
+
// 5. Complete
if method == MethodTorrent && m.cfg.Organize.Enabled {
// Could add seeding here in the future
diff --git a/internal/engine/organize.go b/internal/engine/organize.go
index a495856..ea2eec4 100644
--- a/internal/engine/organize.go
+++ b/internal/engine/organize.go
@@ -7,6 +7,7 @@ import (
"path/filepath"
"regexp"
"strings"
+ "time"
)
var (
@@ -136,6 +137,53 @@ func cleanTitle(title string) string {
return t
}
+// replaceFile moves the old file to a backup dir, then moves the new file to the old path.
+// Used by upgrade downloads to replace an existing file with a better version.
+func replaceFile(oldPath, newPath, backupDir string) error {
+ if _, err := os.Stat(oldPath); err != nil {
+ return fmt.Errorf("original file not found: %w", err)
+ }
+
+ if backupDir == "" {
+ home, _ := os.UserHomeDir()
+ backupDir = filepath.Join(home, ".local", "share", "unarr", "replaced")
+ }
+ if err := os.MkdirAll(backupDir, 0o755); err != nil {
+ return fmt.Errorf("create backup dir: %w", err)
+ }
+
+ // Move old file to backup (with timestamp to avoid collisions)
+ base := filepath.Base(oldPath)
+ ext := filepath.Ext(base)
+ nameNoExt := strings.TrimSuffix(base, ext)
+ backupName := fmt.Sprintf("%s.%d%s", nameNoExt, time.Now().Unix(), ext)
+ backupPath := filepath.Join(backupDir, backupName)
+
+ if err := os.Rename(oldPath, backupPath); err != nil {
+ // Cross-device: copy + delete
+ if err := copyFile(oldPath, backupPath); err != nil {
+ return fmt.Errorf("backup failed: %w", err)
+ }
+ os.Remove(oldPath)
+ }
+
+ // Move new file to old path
+ if err := os.MkdirAll(filepath.Dir(oldPath), 0o755); err != nil {
+ return fmt.Errorf("create target dir: %w", err)
+ }
+ if err := os.Rename(newPath, oldPath); err != nil {
+ // Cross-device: copy + delete
+ if err := copyFile(newPath, oldPath); err != nil {
+ // Rollback: restore backup
+ os.Rename(backupPath, oldPath)
+ return fmt.Errorf("replace failed: %w", err)
+ }
+ os.Remove(newPath)
+ }
+
+ return nil
+}
+
func copyFile(src, dst string) error {
s, err := os.Open(src)
if err != nil {
diff --git a/internal/engine/stream_server.go b/internal/engine/stream_server.go
index a2c0f31..9fbc937 100644
--- a/internal/engine/stream_server.go
+++ b/internal/engine/stream_server.go
@@ -135,15 +135,17 @@ func (ss *StreamServer) Shutdown(ctx context.Context) error {
}
func (ss *StreamServer) handler(w http.ResponseWriter, r *http.Request) {
- // CORS headers — allow web player from any origin (HTTPS site → localhost)
- w.Header().Set("Access-Control-Allow-Origin", "*")
- w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS")
- w.Header().Set("Access-Control-Allow-Headers", "Range")
- w.Header().Set("Access-Control-Expose-Headers", "Content-Length, Content-Range, Accept-Ranges")
+ // CORS headers — only when browser sends Origin (HTTPS site → localhost)
+ if origin := r.Header.Get("Origin"); origin != "" {
+ w.Header().Set("Access-Control-Allow-Origin", "*")
+ w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS")
+ w.Header().Set("Access-Control-Allow-Headers", "Range")
+ w.Header().Set("Access-Control-Expose-Headers", "Content-Length, Content-Range, Accept-Ranges")
- if r.Method == http.MethodOptions {
- w.WriteHeader(http.StatusNoContent)
- return
+ if r.Method == http.MethodOptions {
+ w.WriteHeader(http.StatusNoContent)
+ return
+ }
}
reader := ss.provider.NewFileReader(r.Context())
diff --git a/internal/engine/task.go b/internal/engine/task.go
index bb6ace7..3202187 100644
--- a/internal/engine/task.go
+++ b/internal/engine/task.go
@@ -50,6 +50,8 @@ type Task struct {
DirectFileName string // Original filename from direct URL
NzbID string // Pre-resolved NZB ID (usenet)
NzbPassword string // Password for encrypted NZB archives
+ ReplacePath string // File to replace after download (upgrade mode)
+ LibraryItemID int // Library item being upgraded
// Runtime state
Status TaskStatus
@@ -88,6 +90,8 @@ func NewTaskFromAgent(at agent.Task) *Task {
DirectFileName: at.DirectFileName,
NzbID: at.NzbID,
NzbPassword: at.NzbPassword,
+ ReplacePath: at.ReplacePath,
+ LibraryItemID: at.LibraryItemID,
Mode: mode,
Status: StatusClaimed,
ClaimedAt: time.Now(),
diff --git a/internal/library/cache.go b/internal/library/cache.go
new file mode 100644
index 0000000..f2cdc35
--- /dev/null
+++ b/internal/library/cache.go
@@ -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
+}
diff --git a/internal/library/cache_test.go b/internal/library/cache_test.go
new file mode 100644
index 0000000..fc5fe50
--- /dev/null
+++ b/internal/library/cache_test.go
@@ -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)
+ }
+}
diff --git a/internal/library/mediainfo/ffprobe.go b/internal/library/mediainfo/ffprobe.go
new file mode 100644
index 0000000..f2c70fb
--- /dev/null
+++ b/internal/library/mediainfo/ffprobe.go
@@ -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
+}
diff --git a/internal/library/mediainfo/ffprobe_download.go b/internal/library/mediainfo/ffprobe_download.go
new file mode 100644
index 0000000..cf2cc06
--- /dev/null
+++ b/internal/library/mediainfo/ffprobe_download.go
@@ -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)
+}
diff --git a/internal/library/mediainfo/lang.go b/internal/library/mediainfo/lang.go
new file mode 100644
index 0000000..0a5d42f
--- /dev/null
+++ b/internal/library/mediainfo/lang.go
@@ -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
+}
diff --git a/internal/library/mediainfo/lang_test.go b/internal/library/mediainfo/lang_test.go
new file mode 100644
index 0000000..af1e7eb
--- /dev/null
+++ b/internal/library/mediainfo/lang_test.go
@@ -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)
+ }
+}
diff --git a/internal/library/mediainfo/types.go b/internal/library/mediainfo/types.go
new file mode 100644
index 0000000..030a31d
--- /dev/null
+++ b/internal/library/mediainfo/types.go
@@ -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"`
+}
diff --git a/internal/library/resolve.go b/internal/library/resolve.go
new file mode 100644
index 0000000..049e4b8
--- /dev/null
+++ b/internal/library/resolve.go
@@ -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
+}
diff --git a/internal/library/resolve_test.go b/internal/library/resolve_test.go
new file mode 100644
index 0000000..933c2ad
--- /dev/null
+++ b/internal/library/resolve_test.go
@@ -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)
+ }
+ })
+ }
+}
diff --git a/internal/library/scanner.go b/internal/library/scanner.go
new file mode 100644
index 0000000..b6e4742
--- /dev/null
+++ b/internal/library/scanner.go
@@ -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
+}
diff --git a/internal/library/types.go b/internal/library/types.go
new file mode 100644
index 0000000..c6e5370
--- /dev/null
+++ b/internal/library/types.go
@@ -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
diff --git a/internal/mediaserver/detect.go b/internal/mediaserver/detect.go
new file mode 100644
index 0000000..e0b3030
--- /dev/null
+++ b/internal/mediaserver/detect.go
@@ -0,0 +1,281 @@
+package mediaserver
+
+import (
+ "encoding/json"
+ "encoding/xml"
+ "io"
+ "net"
+ "net/http"
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "time"
+)
+
+// Server represents a detected media server.
+type Server struct {
+ Name string // "Plex", "Jellyfin", "Emby"
+ URL string // "http://localhost:32400"
+}
+
+// DetectedPaths holds media library paths discovered from servers and disk.
+type DetectedPaths struct {
+ Servers []Server
+ Paths []string // unique media library paths found
+}
+
+var knownServers = []struct {
+ Name string
+ Port string
+}{
+ {"Plex", "32400"},
+ {"Jellyfin", "8096"},
+ {"Emby", "8920"},
+}
+
+// Detect scans for media servers and common media directories.
+func Detect() DetectedPaths {
+ result := DetectedPaths{}
+ pathSet := map[string]bool{}
+
+ addPath := func(p string) {
+ p = filepath.Clean(p)
+ if !pathSet[p] {
+ pathSet[p] = true
+ result.Paths = append(result.Paths, p)
+ }
+ }
+
+ // 1. Detect media servers via port scan
+ for _, s := range knownServers {
+ conn, err := net.DialTimeout("tcp", "localhost:"+s.Port, 2*time.Second)
+ if err != nil {
+ continue
+ }
+ _ = conn.Close()
+ result.Servers = append(result.Servers, Server{
+ Name: s.Name,
+ URL: "http://localhost:" + s.Port,
+ })
+ }
+
+ // 2. Try to read Plex library paths from config
+ for _, p := range plexLibraryPaths() {
+ addPath(p)
+ }
+
+ // 3. Try Jellyfin API (often allows local access without auth)
+ for _, s := range result.Servers {
+ if s.Name != "Jellyfin" {
+ continue
+ }
+ for _, p := range jellyfinLibraryPaths(s.URL) {
+ addPath(p)
+ }
+ }
+
+ // 4. Scan common media directories on disk
+ for _, p := range commonMediaDirs() {
+ if fi, err := os.Stat(p); err == nil && fi.IsDir() {
+ addPath(p)
+ }
+ }
+
+ return result
+}
+
+// ── Plex ────────────────────────────────────────────────────────────
+
+func plexLibraryPaths() []string {
+ configDir := plexConfigDir()
+ if configDir == "" {
+ return nil
+ }
+
+ // Read token from Preferences.xml
+ prefsPath := filepath.Join(configDir, "Preferences.xml")
+ token := plexTokenFromPrefs(prefsPath)
+ if token == "" {
+ return nil
+ }
+
+ // Query library sections
+ client := &http.Client{Timeout: 5 * time.Second}
+ req, err := http.NewRequest("GET", "http://localhost:32400/library/sections", nil)
+ if err != nil {
+ return nil
+ }
+ req.Header.Set("X-Plex-Token", token)
+ req.Header.Set("Accept", "application/json")
+
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != 200 {
+ return nil
+ }
+
+ body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
+ if err != nil {
+ return nil
+ }
+
+ return parsePlexSections(body)
+}
+
+func plexConfigDir() string {
+ switch runtime.GOOS {
+ case "linux":
+ home, _ := os.UserHomeDir()
+ candidates := []string{
+ filepath.Join(home, ".config", "Plex Media Server"),
+ "/var/lib/plexmediaserver/Library/Application Support/Plex Media Server",
+ }
+ for _, d := range candidates {
+ if fi, err := os.Stat(d); err == nil && fi.IsDir() {
+ return d
+ }
+ }
+ case "darwin":
+ home, _ := os.UserHomeDir()
+ return filepath.Join(home, "Library", "Application Support", "Plex Media Server")
+ case "windows":
+ return filepath.Join(os.Getenv("LOCALAPPDATA"), "Plex Media Server")
+ }
+ return ""
+}
+
+type plexPrefs struct {
+ XMLName xml.Name `xml:"Preferences"`
+ PlexOnlineToken string `xml:"PlexOnlineToken,attr"`
+}
+
+func plexTokenFromPrefs(path string) string {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return ""
+ }
+ var prefs plexPrefs
+ if err := xml.Unmarshal(data, &prefs); err != nil {
+ return ""
+ }
+ return prefs.PlexOnlineToken
+}
+
+func parsePlexSections(body []byte) []string {
+ // Plex JSON response has: MediaContainer.Directory[].Location[].path
+ var container struct {
+ MediaContainer struct {
+ Directory []struct {
+ Location []struct {
+ Path string `json:"path"`
+ } `json:"Location"`
+ } `json:"Directory"`
+ } `json:"MediaContainer"`
+ }
+ if err := json.Unmarshal(body, &container); err != nil {
+ return nil
+ }
+
+ var paths []string
+ for _, dir := range container.MediaContainer.Directory {
+ for _, loc := range dir.Location {
+ if loc.Path != "" {
+ paths = append(paths, loc.Path)
+ }
+ }
+ }
+ return paths
+}
+
+// ── Jellyfin ────────────────────────────────────────────────────────
+
+func jellyfinLibraryPaths(baseURL string) []string {
+ client := &http.Client{Timeout: 5 * time.Second}
+ resp, err := client.Get(baseURL + "/Library/VirtualFolders")
+ if err != nil {
+ return nil
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != 200 {
+ return nil
+ }
+
+ body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
+ if err != nil {
+ return nil
+ }
+
+ var folders []struct {
+ Locations []string `json:"Locations"`
+ }
+ if err := json.Unmarshal(body, &folders); err != nil {
+ return nil
+ }
+
+ var paths []string
+ for _, f := range folders {
+ paths = append(paths, f.Locations...)
+ }
+ return paths
+}
+
+// ── Common directories ──────────────────────────────────────────────
+
+func commonMediaDirs() []string {
+ home, _ := os.UserHomeDir()
+ if home == "" {
+ return nil
+ }
+
+ candidates := []string{
+ filepath.Join(home, "Media"),
+ filepath.Join(home, "Movies"),
+ filepath.Join(home, "Videos"),
+ filepath.Join(home, "TV Shows"),
+ }
+
+ // Also check /data/media pattern (common Docker/NAS setup)
+ if runtime.GOOS == "linux" {
+ candidates = append(candidates,
+ "/data/media",
+ "/data/media/movies",
+ "/data/media/tv",
+ "/srv/media",
+ )
+ }
+
+ return candidates
+}
+
+// ParentDir returns the common parent of detected paths, useful for
+// suggesting a download directory that encompasses movie + TV paths.
+func ParentDir(paths []string) string {
+ if len(paths) == 0 {
+ return ""
+ }
+
+ // Find the common prefix of all paths
+ parent := filepath.Dir(paths[0])
+ for _, p := range paths[1:] {
+ d := filepath.Dir(p)
+ for parent != "/" && parent != "." {
+ if d == parent || strings.HasPrefix(d, parent+string(filepath.Separator)) {
+ break
+ }
+ parent = filepath.Dir(parent)
+ }
+ }
+
+ // Don't return root or home as a suggestion
+ home, _ := os.UserHomeDir()
+ if parent == "/" || parent == "." || parent == home {
+ return ""
+ }
+ return parent
+}
diff --git a/internal/mediaserver/detect_test.go b/internal/mediaserver/detect_test.go
new file mode 100644
index 0000000..fc5b00e
--- /dev/null
+++ b/internal/mediaserver/detect_test.go
@@ -0,0 +1,98 @@
+package mediaserver
+
+import (
+ "encoding/json"
+ "testing"
+)
+
+func TestParsePlexSections(t *testing.T) {
+ body := `{
+ "MediaContainer": {
+ "Directory": [
+ {
+ "title": "Movies",
+ "Location": [{"path": "/data/media/movies"}]
+ },
+ {
+ "title": "TV Shows",
+ "Location": [{"path": "/data/media/tv"}]
+ }
+ ]
+ }
+ }`
+
+ paths := parsePlexSections([]byte(body))
+ if len(paths) != 2 {
+ t.Fatalf("parsePlexSections = %d paths, want 2", len(paths))
+ }
+ if paths[0] != "/data/media/movies" {
+ t.Errorf("paths[0] = %q, want /data/media/movies", paths[0])
+ }
+ if paths[1] != "/data/media/tv" {
+ t.Errorf("paths[1] = %q, want /data/media/tv", paths[1])
+ }
+}
+
+func TestParsePlexSections_Empty(t *testing.T) {
+ paths := parsePlexSections([]byte(`{}`))
+ if len(paths) != 0 {
+ t.Errorf("parsePlexSections empty = %d paths, want 0", len(paths))
+ }
+}
+
+func TestParsePlexSections_InvalidJSON(t *testing.T) {
+ paths := parsePlexSections([]byte(`not json`))
+ if paths != nil {
+ t.Errorf("parsePlexSections invalid = %v, want nil", paths)
+ }
+}
+
+func TestJellyfinParsing(t *testing.T) {
+ body := `[
+ {"Locations": ["/media/movies"]},
+ {"Locations": ["/media/tv", "/media/anime"]}
+ ]`
+
+ var folders []struct {
+ Locations []string `json:"Locations"`
+ }
+ if err := json.Unmarshal([]byte(body), &folders); err != nil {
+ t.Fatal(err)
+ }
+
+ var paths []string
+ for _, f := range folders {
+ paths = append(paths, f.Locations...)
+ }
+ if len(paths) != 3 {
+ t.Fatalf("got %d paths, want 3", len(paths))
+ }
+}
+
+func TestParentDir(t *testing.T) {
+ tests := []struct {
+ name string
+ paths []string
+ expect string
+ }{
+ {"empty", nil, ""},
+ {"single", []string{"/data/media/movies"}, "/data/media"},
+ {"siblings", []string{"/data/media/movies", "/data/media/tv"}, "/data/media"},
+ {"different roots", []string{"/data/movies", "/srv/tv"}, "/"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := ParentDir(tt.paths)
+ // "/" is filtered out (returns "")
+ if tt.expect == "/" {
+ if got != "" {
+ t.Errorf("ParentDir = %q, want empty (root filtered)", got)
+ }
+ return
+ }
+ if got != tt.expect {
+ t.Errorf("ParentDir = %q, want %q", got, tt.expect)
+ }
+ })
+ }
+}