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) + } + }) + } +}