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

@ -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,23 +143,73 @@ func runInit(apiURLOverride string) error {
// ── Step 2/3: Download directory ────────────────────────────────
downloadDir := cfg.Download.Dir
// Detect media servers and library paths
detected := mediaserver.Detect()
if len(detected.Servers) > 0 {
for _, s := range detected.Servers {
cyan.Printf(" Detected %s at %s\n", s.Name, s.URL)
}
if len(detected.Paths) > 0 {
dim.Printf(" Found media libraries: %s\n", strings.Join(detected.Paths, ", "))
}
fmt.Println()
}
// If no dir yet and we detected media paths, offer a Select; otherwise show Input
needsInput := true
if downloadDir == "" && len(detected.Paths) > 0 {
var options []huh.Option[string]
for _, p := range detected.Paths {
options = append(options, huh.NewOption(p, p))
}
if parent := mediaserver.ParentDir(detected.Paths); parent != "" {
options = append(options, huh.NewOption(parent+" (parent directory)", parent))
}
options = append(options, huh.NewOption(defaultDownloadDir()+" (default)", defaultDownloadDir()))
options = append(options, huh.NewOption("Custom path...", "__custom__"))
downloadDir = detected.Paths[0]
err = huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title("Step 2/3 — Download Directory").
Description("Detected media libraries on your system").
Options(options...).
Value(&downloadDir),
),
).Run()
if err != nil {
if errors.Is(err, huh.ErrUserAborted) {
fmt.Println("\n Init cancelled.")
return nil
}
return err
}
needsInput = downloadDir == "__custom__"
if needsInput {
downloadDir = defaultDownloadDir()
}
}
if downloadDir == "" {
downloadDir = defaultDownloadDir()
}
err = huh.NewForm(
huh.NewGroup(
huh.NewInput().
Title("Step 2/3 — Download Directory").
Description("Where should downloaded files be saved?").
Value(&downloadDir),
),
).Run()
if err != nil {
if errors.Is(err, huh.ErrUserAborted) {
fmt.Println("\n Init cancelled.")
return nil
if needsInput {
err = huh.NewForm(
huh.NewGroup(
huh.NewInput().
Title("Step 2/3 — Download Directory").
Description("Where should downloaded files be saved?").
Value(&downloadDir),
),
).Run()
if err != nil {
if errors.Is(err, huh.ErrUserAborted) {
fmt.Println("\n Init cancelled.")
return nil
}
return err
}
return err
}
downloadDir = expandHome(strings.TrimSpace(downloadDir))
@ -226,6 +279,60 @@ func runInit(apiURLOverride string) error {
}
}
// ── Debrid auto-detection from *arr ─────────────────────────────
if resp.User.IsPro {
debridTokens := detectDebridFromArr(dim)
if len(debridTokens) > 0 {
fmt.Println()
cyan.Printf(" Found %d debrid token(s) from your *arr setup:\n", len(debridTokens))
for _, dt := range debridTokens {
masked := dt.Token
if len(masked) > 8 {
masked = masked[:8] + "..."
}
fmt.Printf(" %s (%s) — %s\n", dt.Provider, dt.Name, masked)
}
fmt.Println()
var configureDebrid bool
err = huh.NewForm(
huh.NewGroup(
huh.NewConfirm().
Title("Configure debrid automatically?").
Description("Validates and saves the token to your unarr account").
Affirmative("Yes, configure").
Negative("No, skip").
Value(&configureDebrid),
),
).Run()
if err == nil && configureDebrid {
for _, dt := range debridTokens {
fmt.Printf(" Configuring %s... ", dt.Provider)
result, err := ac.ConfigureDebrid(context.Background(), agent.ConfigureDebridRequest{
Provider: dt.Provider,
Token: dt.Token,
})
if err != nil {
color.New(color.FgYellow).Printf("failed: %s\n", err)
} else if result.Success {
green.Printf("OK")
if result.Account.Username != "" {
fmt.Printf(" (%s", result.Account.Username)
if result.Account.Premium {
fmt.Print(", premium")
}
fmt.Print(")")
}
fmt.Println()
} else if result.Error != "" {
color.New(color.FgYellow).Printf("failed: %s\n", result.Error)
}
}
}
}
}
// ── Summary ─────────────────────────────────────────────────────
fmt.Println()
@ -264,3 +371,31 @@ func runInit(apiURLOverride string) error {
return nil
}
// detectDebridFromArr does a lightweight scan for *arr instances and extracts
// debrid tokens from their download client configs.
func detectDebridFromArr(dim *color.Color) []arr.DebridToken {
dim.Println(" Scanning for *arr instances with debrid...")
instances := arr.Discover()
if len(instances) == 0 {
return nil
}
var tokens []arr.DebridToken
for _, inst := range instances {
if inst.App == "prowlarr" || inst.APIKey == "" {
continue
}
client := arr.NewClient(inst.URL, inst.APIKey)
dcs, _ := client.DownloadClients()
if len(dcs) == 0 {
continue
}
tokens = append(tokens, arr.ExtractDebridTokens(dcs, func(id int) []arr.Field {
fields, _ := client.DownloadClientDetails(id)
return fields
})...)
}
return tokens
}

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