unarr/internal/cmd/migrate.go
Deivid Soto 677a8fe083 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
2026-03-29 16:54:32 +02:00

701 lines
20 KiB
Go

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
}