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
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
701
internal/cmd/migrate.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
340
internal/cmd/scan.go
Normal 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()
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue