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:
parent
0b6c6849b1
commit
677a8fe083
34 changed files with 4766 additions and 22 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue