feat: add migrate command, media server detection, and debrid auto-config

- Migration wizard from Sonarr/Radarr/Prowlarr (unarr migrate) [pre-beta]
  - Auto-detect instances via Docker, config files, port scan, Prowlarr
  - Import wanted list (monitored+missing movies/series)
  - Import download history and blocklist to avoid re-downloading
  - Extract debrid tokens from *arr download clients
  - Quality profile mapping to preferred_quality config
  - DISTINCT ON PostgreSQL query for optimal torrent selection
  - JSON export with --dry-run --json (text to stderr, JSON to stdout)
- Media server detection (Plex/Jellyfin/Emby) in unarr init
  - Detects library paths and offers them as download directory options
- Debrid auto-configuration in unarr init
  - Scans *arr instances for debrid tokens
  - Validates and saves via API if user confirms
- New preferred_quality setting in config (2160p/1080p/720p)
- Library scan command (unarr scan) with ffprobe metadata extraction
This commit is contained in:
Deivid Soto 2026-03-29 16:54:32 +02:00
parent 0b6c6849b1
commit 677a8fe083
34 changed files with 4766 additions and 22 deletions

View file

@ -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`)

View file

@ -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

View file

@ -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)

View file

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

188
internal/arr/client.go Normal file
View file

@ -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
}

View file

@ -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")
}
})
}

356
internal/arr/discovery.go Normal file
View file

@ -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
}

View file

@ -0,0 +1,84 @@
package arr
import (
"strings"
"testing"
)
func TestParseConfigXML(t *testing.T) {
xml := `<Config>
<Port>8989</Port>
<ApiKey>abc123def456</ApiKey>
<UrlBase>/sonarr</UrlBase>
</Config>`
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 := `<Config><Port>7878</Port><ApiKey>key</ApiKey></Config>`
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)
}
})
}
}

312
internal/arr/mapper.go Normal file
View file

@ -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
}

230
internal/arr/mapper_test.go Normal file
View file

@ -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])
}
}

207
internal/arr/types.go Normal file
View file

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

View file

@ -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(

View file

@ -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,9 +143,58 @@ 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()
}
if needsInput {
err = huh.NewForm(
huh.NewGroup(
huh.NewInput().
@ -158,6 +210,7 @@ func runInit(apiURLOverride string) error {
}
return err
}
}
downloadDir = expandHome(strings.TrimSpace(downloadDir))
// ── Step 3/3: Install daemon ────────────────────────────────────
@ -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
}

701
internal/cmd/migrate.go Normal file
View file

@ -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
}

View file

@ -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"),

340
internal/cmd/scan.go Normal file
View file

@ -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 <path>",
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 <path>\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 <path>' 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()
}

View file

@ -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{

View file

@ -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

View file

@ -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 {

View file

@ -135,7 +135,8 @@ 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)
// 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")
@ -145,6 +146,7 @@ func (ss *StreamServer) handler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
return
}
}
reader := ss.provider.NewFileReader(r.Context())
if reader == nil {

View file

@ -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(),

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

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

View file

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

View file

@ -0,0 +1,281 @@
package mediainfo
import (
"context"
"encoding/json"
"fmt"
"math"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
)
// ffprobeOutput matches the JSON structure from `ffprobe -show_streams -show_format`.
type ffprobeOutput struct {
Streams []ffprobeStream `json:"streams"`
Format ffprobeFormat `json:"format"`
}
type ffprobeFormat struct {
Duration string `json:"duration"`
}
type ffprobeStream struct {
CodecType string `json:"codec_type"`
CodecName string `json:"codec_name"`
Profile string `json:"profile"`
Channels int `json:"channels"`
Width int `json:"width"`
Height int `json:"height"`
BitsPerRaw string `json:"bits_per_raw_sample"`
PixFmt string `json:"pix_fmt"`
ColorSpace string `json:"color_space"`
ColorTransfer string `json:"color_transfer"`
ColorPrimaries string `json:"color_primaries"`
RFrameRate string `json:"r_frame_rate"`
Duration string `json:"duration"`
Tags map[string]string `json:"tags"`
Disposition map[string]int `json:"disposition"`
SideDataList []sideData `json:"side_data_list"`
}
type sideData struct {
SideDataType string `json:"side_data_type"`
}
// hdrProfiles maps (color_space, color_transfer) to HDR type.
var hdrProfiles = map[[2]string]string{
{"bt2020nc", "smpte2084"}: "HDR10",
{"bt2020nc", "arib-std-b67"}: "HLG",
}
// ExtractMediaInfo runs ffprobe on a file and parses audio, subtitle, and video streams.
func ExtractMediaInfo(ctx context.Context, ffprobePath, filePath string) (*MediaInfo, error) {
cmd := exec.CommandContext(ctx, ffprobePath,
"-v", "error",
"-print_format", "json",
"-show_streams",
"-show_format",
filePath,
)
var stderr strings.Builder
cmd.Stderr = &stderr
output, err := cmd.Output()
if err != nil {
if _, statErr := os.Stat(filePath); statErr != nil {
return nil, fmt.Errorf("ffprobe: file not found: %s", filePath)
}
return nil, fmt.Errorf("ffprobe failed (file=%s): %s", filePath, stderr.String())
}
var data ffprobeOutput
if err := json.Unmarshal(output, &data); err != nil {
return nil, fmt.Errorf("ffprobe JSON parse failed: %w", err)
}
if len(data.Streams) == 0 {
return nil, fmt.Errorf("ffprobe returned no streams")
}
var audioTracks []AudioTrack
var subtitleTracks []SubtitleTrack
var videoInfo *VideoInfo
for _, s := range data.Streams {
switch s.CodecType {
case "audio":
langRaw := tagValue(s.Tags, "language")
track := AudioTrack{
Lang: NormalizeLang(langRaw),
Codec: s.CodecName,
Channels: s.Channels,
}
if title := tagValue(s.Tags, "title"); title != "" {
track.Title = title
}
if s.Disposition["default"] == 1 {
track.Default = true
}
audioTracks = append(audioTracks, track)
case "subtitle":
langRaw := tagValue(s.Tags, "language")
track := SubtitleTrack{
Lang: NormalizeLang(langRaw),
Codec: s.CodecName,
}
if title := tagValue(s.Tags, "title"); title != "" {
track.Title = title
}
if s.Disposition["forced"] == 1 {
track.Forced = true
}
subtitleTracks = append(subtitleTracks, track)
case "video":
if videoInfo != nil {
continue // only first video stream
}
vi := &VideoInfo{
Codec: s.CodecName,
Width: s.Width,
Height: s.Height,
}
// Bit depth
if s.BitsPerRaw != "" {
if bd, err := strconv.Atoi(s.BitsPerRaw); err == nil {
vi.BitDepth = bd
}
} else if containsAny(s.PixFmt, "10le", "10be", "p010") {
vi.BitDepth = 10
} else if containsAny(s.PixFmt, "12le", "12be") {
vi.BitDepth = 12
}
// HDR detection
hdrKey := [2]string{s.ColorSpace, s.ColorTransfer}
if hdr, ok := hdrProfiles[hdrKey]; ok {
vi.HDR = hdr
} else if s.ColorTransfer == "smpte2084" {
vi.HDR = "HDR10"
} else if s.ColorTransfer == "arib-std-b67" {
vi.HDR = "HLG"
}
// Dolby Vision via side_data_list
for _, sd := range s.SideDataList {
if sd.SideDataType == "DOVI configuration record" {
if vi.HDR != "" {
vi.HDR = "DV+" + vi.HDR
} else {
vi.HDR = "DV"
}
break
}
}
// Frame rate from r_frame_rate (e.g., "24000/1001")
if s.RFrameRate != "" && strings.Contains(s.RFrameRate, "/") {
parts := strings.SplitN(s.RFrameRate, "/", 2)
if num, err1 := strconv.ParseFloat(parts[0], 64); err1 == nil {
if den, err2 := strconv.ParseFloat(parts[1], 64); err2 == nil && den > 0 {
vi.FrameRate = math.Round(num/den*1000) / 1000
}
}
}
// Profile
if s.Profile != "" {
vi.Profile = s.Profile
}
// Duration: prefer format.duration, fallback to stream duration
if dur := parseDuration(data.Format.Duration); dur > 0 {
vi.Duration = dur
} else if dur := parseDuration(s.Duration); dur > 0 {
vi.Duration = dur
}
videoInfo = vi
}
}
result := &MediaInfo{
Video: videoInfo,
}
if len(audioTracks) > 0 {
result.Audio = audioTracks
result.Languages = ComputeLanguages(audioTracks)
}
if len(subtitleTracks) > 0 {
result.Subtitles = subtitleTracks
}
return result, nil
}
// ResolveFFprobe finds the ffprobe binary. Search order:
// 1. Explicit path (--ffprobe flag)
// 2. FFPROBE_PATH env var
// 3. "ffprobe" in PATH
// 4. Adjacent to the current executable
// 5. Previously downloaded in cache dir
// 6. Auto-download static binary
func ResolveFFprobe(explicit string) (string, error) {
if explicit != "" {
if _, err := os.Stat(explicit); err == nil {
return explicit, nil
}
return "", fmt.Errorf("ffprobe not found at explicit path: %s", explicit)
}
if envPath := os.Getenv("FFPROBE_PATH"); envPath != "" {
if _, err := os.Stat(envPath); err == nil {
return envPath, nil
}
}
if p, err := exec.LookPath("ffprobe"); err == nil {
return p, nil
}
if exePath, err := os.Executable(); err == nil {
name := "ffprobe"
if runtime.GOOS == "windows" {
name = "ffprobe.exe"
}
adjacent := filepath.Join(filepath.Dir(exePath), name)
if _, err := os.Stat(adjacent); err == nil {
return adjacent, nil
}
}
if cached, err := FFprobeCachePath(); err == nil {
if _, err := os.Stat(cached); err == nil {
return cached, nil
}
}
if p, err := DownloadFFprobe(); err == nil {
return p, nil
}
return "", fmt.Errorf("ffprobe not found. Install ffmpeg or provide --ffprobe path")
}
// tagValue gets a tag value case-insensitively.
func tagValue(tags map[string]string, key string) string {
if v, ok := tags[key]; ok {
return v
}
if v, ok := tags[strings.ToUpper(key)]; ok {
return v
}
return ""
}
func containsAny(s string, substrs ...string) bool {
for _, sub := range substrs {
if strings.Contains(s, sub) {
return true
}
}
return false
}
// parseDuration converts a duration string (e.g. "7423.500000") to float64 seconds.
func parseDuration(s string) float64 {
if s == "" {
return 0
}
d, err := strconv.ParseFloat(s, 64)
if err != nil || d <= 0 {
return 0
}
return math.Round(d*1000) / 1000
}

View file

@ -0,0 +1,176 @@
package mediainfo
import (
"archive/zip"
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"runtime"
"time"
)
var (
ffprobeAPIClient = &http.Client{Timeout: 30 * time.Second}
ffprobeDLClient = &http.Client{Timeout: 10 * time.Minute}
)
const maxFFprobeZipSize = 100 * 1024 * 1024 // 100MB
const ffbinariesAPI = "https://ffbinaries.com/api/v1/version/latest"
type ffbinariesResponse struct {
Version string `json:"version"`
Bin map[string]map[string]string `json:"bin"`
}
// ffprobePlatformKey maps GOOS/GOARCH to ffbinaries platform keys.
func ffprobePlatformKey() (string, error) {
switch runtime.GOOS {
case "linux":
switch runtime.GOARCH {
case "amd64":
return "linux-64", nil
case "arm64":
return "linux-arm64", nil
}
case "darwin":
return "osx-64", nil
case "windows":
if runtime.GOARCH == "amd64" {
return "windows-64", nil
}
}
return "", fmt.Errorf("unsupported platform: %s/%s", runtime.GOOS, runtime.GOARCH)
}
// FFprobeCacheDir returns the directory where the downloaded ffprobe binary is stored.
func FFprobeCacheDir() (string, error) {
cacheDir, err := os.UserCacheDir()
if err != nil {
return "", err
}
return filepath.Join(cacheDir, "unarr", "bin"), nil
}
// FFprobeCachePath returns the full path to the cached ffprobe binary.
func FFprobeCachePath() (string, error) {
dir, err := FFprobeCacheDir()
if err != nil {
return "", err
}
name := "ffprobe"
if runtime.GOOS == "windows" {
name = "ffprobe.exe"
}
return filepath.Join(dir, name), nil
}
// DownloadFFprobe downloads a static ffprobe binary for the current platform
// and caches it locally. Returns the path to the binary.
func DownloadFFprobe() (string, error) {
dest, err := FFprobeCachePath()
if err != nil {
return "", fmt.Errorf("cannot determine cache path: %w", err)
}
if _, err := os.Stat(dest); err == nil {
return dest, nil
}
platform, err := ffprobePlatformKey()
if err != nil {
return "", err
}
url, err := resolveFFprobeURL(platform)
if err != nil {
return "", err
}
fmt.Fprintf(os.Stderr, "ffprobe not found — downloading for %s...\n", platform)
resp, err := ffprobeDLClient.Get(url)
if err != nil {
return "", fmt.Errorf("download failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("download failed: HTTP %d", resp.StatusCode)
}
zipData, err := io.ReadAll(io.LimitReader(resp.Body, maxFFprobeZipSize))
if err != nil {
return "", fmt.Errorf("download read failed: %w", err)
}
name := "ffprobe"
if runtime.GOOS == "windows" {
name = "ffprobe.exe"
}
binary, err := extractFromZip(zipData, name)
if err != nil {
return "", err
}
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return "", fmt.Errorf("cannot create cache directory: %w", err)
}
if err := os.WriteFile(dest, binary, 0o755); err != nil {
return "", fmt.Errorf("cannot write ffprobe binary: %w", err)
}
fmt.Fprintf(os.Stderr, "ffprobe installed to %s\n", dest)
return dest, nil
}
func resolveFFprobeURL(platform string) (string, error) {
resp, err := ffprobeAPIClient.Get(ffbinariesAPI)
if err != nil {
return "", fmt.Errorf("cannot reach ffbinaries.com: %w", err)
}
defer resp.Body.Close()
var data ffbinariesResponse
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
return "", fmt.Errorf("cannot parse ffbinaries response: %w", err)
}
bins, ok := data.Bin[platform]
if !ok {
return "", fmt.Errorf("no ffprobe binary available for platform %q", platform)
}
url, ok := bins["ffprobe"]
if !ok {
return "", fmt.Errorf("no ffprobe download URL for platform %q", platform)
}
return url, nil
}
func extractFromZip(data []byte, target string) ([]byte, error) {
r, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return nil, fmt.Errorf("cannot open downloaded archive: %w", err)
}
for _, f := range r.File {
if filepath.Base(f.Name) == target {
rc, err := f.Open()
if err != nil {
return nil, fmt.Errorf("cannot extract %s from archive: %w", target, err)
}
defer rc.Close()
return io.ReadAll(rc)
}
}
return nil, fmt.Errorf("%s not found in downloaded archive", target)
}

View file

@ -0,0 +1,115 @@
package mediainfo
import (
"sort"
"strings"
)
// langNormalize maps ISO 639-2/B, 639-2/T, 639-1 codes, and full English
// language names (as returned by some ffprobe metadata) to ISO 639-1.
var langNormalize = map[string]string{
// ISO codes
"eng": "en", "en": "en",
"spa": "es", "es": "es",
"fre": "fr", "fra": "fr", "fr": "fr",
"ger": "de", "deu": "de", "de": "de",
"ita": "it", "it": "it",
"por": "pt", "pt": "pt",
"rus": "ru", "ru": "ru",
"jpn": "ja", "ja": "ja",
"kor": "ko", "ko": "ko",
"chi": "zh", "zho": "zh", "zh": "zh",
"hin": "hi", "hi": "hi",
"ara": "ar", "ar": "ar",
"dut": "nl", "nld": "nl", "nl": "nl",
"pol": "pl", "pl": "pl",
"tur": "tr", "tr": "tr",
"swe": "sv", "sv": "sv",
"nor": "no", "nob": "no", "nno": "no", "no": "no",
"dan": "da", "da": "da",
"fin": "fi", "fi": "fi",
"cze": "cs", "ces": "cs", "cs": "cs",
"hun": "hu", "hu": "hu",
"rum": "ro", "ron": "ro", "ro": "ro",
"gre": "el", "ell": "el", "el": "el",
"tha": "th", "th": "th",
"vie": "vi", "vi": "vi",
"ind": "id", "id": "id",
"heb": "he", "he": "he",
"ukr": "uk", "uk": "uk",
"cat": "ca", "ca": "ca",
"bul": "bg", "bg": "bg",
"hrv": "hr", "hr": "hr",
"srp": "sr", "sr": "sr",
"slv": "sl", "sl": "sl",
"lit": "lt", "lt": "lt",
"lav": "lv", "lv": "lv",
"est": "et", "et": "et",
"per": "fa", "fas": "fa", "fa": "fa",
"may": "ms", "msa": "ms", "ms": "ms",
"tgl": "tl", "tl": "tl",
"tam": "ta", "ta": "ta",
"tel": "te", "te": "te",
"ben": "bn", "bn": "bn",
"urd": "ur", "ur": "ur",
"geo": "ka", "kat": "ka", "ka": "ka",
"arm": "hy", "hye": "hy", "hy": "hy",
"alb": "sq", "sqi": "sq", "sq": "sq",
"mac": "mk", "mkd": "mk", "mk": "mk",
"ice": "is", "isl": "is", "is": "is",
"glg": "gl", "gl": "gl",
"baq": "eu", "eus": "eu", "eu": "eu",
"wel": "cy", "cym": "cy", "cy": "cy",
"gle": "ga", "ga": "ga",
"mlt": "mt", "mt": "mt",
"swa": "sw", "sw": "sw",
"afr": "af", "af": "af",
"lat": "la", "la": "la",
// Full English names (ffprobe sometimes returns these instead of codes)
"english": "en", "spanish": "es", "french": "fr", "german": "de",
"italian": "it", "portuguese": "pt", "russian": "ru", "japanese": "ja",
"korean": "ko", "chinese": "zh", "hindi": "hi", "arabic": "ar",
"dutch": "nl", "polish": "pl", "turkish": "tr", "swedish": "sv",
"norwegian": "no", "danish": "da", "finnish": "fi", "czech": "cs",
"hungarian": "hu", "romanian": "ro", "greek": "el", "thai": "th",
"vietnamese": "vi", "indonesian": "id", "hebrew": "he", "ukrainian": "uk",
"catalan": "ca", "bulgarian": "bg", "croatian": "hr", "serbian": "sr",
"slovenian": "sl", "lithuanian": "lt", "latvian": "lv", "estonian": "et",
"persian": "fa", "malay": "ms", "tagalog": "tl", "tamil": "ta",
"telugu": "te", "bengali": "bn", "urdu": "ur", "georgian": "ka",
"armenian": "hy", "albanian": "sq", "macedonian": "mk", "icelandic": "is",
"galician": "gl", "basque": "eu", "welsh": "cy", "irish": "ga",
"maltese": "mt", "swahili": "sw", "afrikaans": "af", "latin": "la",
}
// NormalizeLang converts a language code to ISO 639-1.
// Returns "und" for empty input, the input lowercased if no mapping is found.
func NormalizeLang(raw string) string {
if raw == "" {
return "und"
}
lower := strings.ToLower(raw)
if mapped, ok := langNormalize[lower]; ok {
return mapped
}
return lower
}
// ComputeLanguages extracts unique ISO 639-1 language codes from audio tracks.
func ComputeLanguages(audioTracks []AudioTrack) []string {
seen := make(map[string]struct{})
for _, t := range audioTracks {
lang := t.Lang
if lang != "" && lang != "und" && len(lang) <= 3 {
seen[lang] = struct{}{}
}
}
result := make([]string, 0, len(seen))
for l := range seen {
result = append(result, l)
}
sort.Strings(result)
return result
}

View file

@ -0,0 +1,64 @@
package mediainfo
import "testing"
func TestNormalizeLang(t *testing.T) {
tests := []struct {
input string
want string
}{
{"", "und"},
{"eng", "en"},
{"spa", "es"},
{"fre", "fr"},
{"fra", "fr"},
{"ger", "de"},
{"deu", "de"},
{"en", "en"},
{"es", "es"},
{"English", "en"},
{"SPANISH", "es"},
{"Japanese", "ja"},
{"jpn", "ja"},
{"chi", "zh"},
{"zho", "zh"},
{"und", "und"},
{"xyz", "xyz"}, // unknown → lowercase passthrough
{"POR", "pt"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := NormalizeLang(tt.input)
if got != tt.want {
t.Errorf("NormalizeLang(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestComputeLanguages(t *testing.T) {
tracks := []AudioTrack{
{Lang: "en", Codec: "aac", Channels: 2},
{Lang: "es", Codec: "ac3", Channels: 6},
{Lang: "en", Codec: "dts", Channels: 6}, // duplicate
{Lang: "und", Codec: "aac", Channels: 2},
{Lang: "", Codec: "aac", Channels: 2},
}
langs := ComputeLanguages(tracks)
if len(langs) != 2 {
t.Fatalf("expected 2 languages, got %d: %v", len(langs), langs)
}
if langs[0] != "en" || langs[1] != "es" {
t.Errorf("expected [en es], got %v", langs)
}
}
func TestComputeLanguagesEmpty(t *testing.T) {
langs := ComputeLanguages(nil)
if len(langs) != 0 {
t.Errorf("expected empty, got %v", langs)
}
}

View file

@ -0,0 +1,38 @@
package mediainfo
// MediaInfo holds the media analysis result from ffprobe.
type MediaInfo struct {
Video *VideoInfo `json:"video"`
Audio []AudioTrack `json:"audio"`
Subtitles []SubtitleTrack `json:"subtitles"`
Languages []string `json:"languages"` // derived from audio tracks
}
// VideoInfo represents the primary video stream metadata.
type VideoInfo struct {
Codec string `json:"codec"` // "hevc", "h264", "av1"
Width int `json:"width"`
Height int `json:"height"`
BitDepth int `json:"bitDepth"` // 8, 10, 12
HDR string `json:"hdr"` // "HDR10", "DV", "HLG", "DV+HDR10", ""
FrameRate float64 `json:"frameRate"` // e.g. 23.976
Profile string `json:"profile"` // e.g. "Main 10", "High"
Duration float64 `json:"duration"` // seconds
}
// AudioTrack represents a single audio stream.
type AudioTrack struct {
Lang string `json:"lang"` // ISO 639-1
Codec string `json:"codec"` // "aac", "ac3", "dts", "truehd"
Channels int `json:"channels"` // 2, 6, 8
Title string `json:"title"`
Default bool `json:"default"`
}
// SubtitleTrack represents a single subtitle stream.
type SubtitleTrack struct {
Lang string `json:"lang"`
Codec string `json:"codec"`
Title string `json:"title"`
Forced bool `json:"forced"`
}

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

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

View file

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

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

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

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

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

View file

@ -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
}

View file

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