- 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
340 lines
8.4 KiB
Go
340 lines
8.4 KiB
Go
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()
|
|
}
|