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

188
internal/arr/client.go Normal file
View file

@ -0,0 +1,188 @@
package arr
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// Client talks to a single *arr instance (Sonarr, Radarr, or Prowlarr).
type Client struct {
baseURL string
apiKey string
httpClient *http.Client
}
// NewClient creates a client for the given *arr instance.
func NewClient(baseURL, apiKey string) *Client {
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
apiKey: apiKey,
httpClient: &http.Client{Timeout: 15 * time.Second},
}
}
// SystemStatus returns version and app info. Works with all *arr apps.
func (c *Client) SystemStatus() (*SystemStatus, error) {
// Try v3 first (Sonarr/Radarr), then v1 (Prowlarr)
var s SystemStatus
if err := c.get("/api/v3/system/status", &s); err != nil {
if err2 := c.get("/api/v1/system/status", &s); err2 != nil {
return nil, fmt.Errorf("system/status v3: %w; v1: %v", err, err2)
}
}
return &s, nil
}
// ── Radarr ──────────────────────────────────────────────────────────
func (c *Client) Movies() ([]Movie, error) {
var m []Movie
if err := c.get("/api/v3/movie", &m); err != nil {
return nil, fmt.Errorf("movies: %w", err)
}
return m, nil
}
// ── Sonarr ──────────────────────────────────────────────────────────
func (c *Client) Series() ([]Series, error) {
var s []Series
if err := c.get("/api/v3/series", &s); err != nil {
return nil, fmt.Errorf("series: %w", err)
}
return s, nil
}
// ── Shared (Sonarr + Radarr use the same v3 endpoints) ─────────────
func (c *Client) QualityProfiles() ([]QualityProfile, error) {
var p []QualityProfile
if err := c.get("/api/v3/qualityprofile", &p); err != nil {
return nil, fmt.Errorf("quality profiles: %w", err)
}
return p, nil
}
func (c *Client) RootFolders() ([]RootFolder, error) {
var f []RootFolder
if err := c.get("/api/v3/rootfolder", &f); err != nil {
return nil, fmt.Errorf("root folders: %w", err)
}
return f, nil
}
func (c *Client) DownloadClients() ([]DownloadClient, error) {
var d []DownloadClient
if err := c.get("/api/v3/downloadclient", &d); err != nil {
return nil, fmt.Errorf("download clients: %w", err)
}
return d, nil
}
// DownloadClientDetails returns the full config (including fields) for a single download client.
func (c *Client) DownloadClientDetails(id int) ([]Field, error) {
path := fmt.Sprintf("/api/v3/downloadclient/%d", id)
var dc struct {
Fields []Field `json:"fields"`
}
if err := c.get(path, &dc); err != nil {
return nil, err
}
return dc.Fields, nil
}
// ── Shared (Sonarr + Radarr) ────────────────────────────────────────
func (c *Client) Tags() ([]Tag, error) {
var t []Tag
if err := c.get("/api/v3/tag", &t); err != nil {
return nil, fmt.Errorf("tags: %w", err)
}
return t, nil
}
// History returns download history records (grabbed + imported).
// pageSize controls how many records per page (max 250).
func (c *Client) History(pageSize int) ([]HistoryRecord, error) {
if pageSize <= 0 {
pageSize = 250
}
path := fmt.Sprintf("/api/v3/history?page=1&pageSize=%d&sortKey=date&sortDirection=descending", pageSize)
var resp HistoryResponse
if err := c.get(path, &resp); err != nil {
return nil, fmt.Errorf("history: %w", err)
}
return resp.Records, nil
}
// Blocklist returns releases the user has explicitly rejected.
func (c *Client) Blocklist(pageSize int) ([]BlocklistItem, error) {
if pageSize <= 0 {
pageSize = 250
}
path := fmt.Sprintf("/api/v3/blocklist?page=1&pageSize=%d", pageSize)
var resp BlocklistResponse
if err := c.get(path, &resp); err != nil {
return nil, fmt.Errorf("blocklist: %w", err)
}
return resp.Records, nil
}
// ── Prowlarr ────────────────────────────────────────────────────────
func (c *Client) Indexers() ([]Indexer, error) {
var idx []Indexer
if err := c.get("/api/v1/indexer", &idx); err != nil {
return nil, fmt.Errorf("indexers: %w", err)
}
return idx, nil
}
func (c *Client) Applications() ([]Application, error) {
var apps []Application
if err := c.get("/api/v1/applications", &apps); err != nil {
return nil, fmt.Errorf("applications: %w", err)
}
return apps, nil
}
// ── HTTP helper ─────────────────────────────────────────────────────
func (c *Client) get(path string, dst any) error {
req, err := http.NewRequest(http.MethodGet, c.baseURL+path, nil)
if err != nil {
return err
}
req.Header.Set("X-Api-Key", c.apiKey)
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 50<<20)) // 50MB limit for large libraries
if err != nil {
return fmt.Errorf("read body: %w", err)
}
if resp.StatusCode == http.StatusUnauthorized {
return fmt.Errorf("unauthorized — check your API key")
}
if resp.StatusCode >= 400 {
msg := string(body)
if len(msg) > 200 {
msg = msg[:200] + "..."
}
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, msg)
}
if err := json.Unmarshal(body, dst); err != nil {
return fmt.Errorf("decode JSON: %w", err)
}
return nil
}

View file

@ -0,0 +1,180 @@
package arr
import (
"fmt"
"net"
"os"
"testing"
"time"
)
// TestDiscoverE2E is an integration test that requires real *arr instances running.
// Skip if ports 8989/7878 are not reachable.
func TestDiscoverE2E(t *testing.T) {
if os.Getenv("ARR_E2E") == "" {
t.Skip("Set ARR_E2E=1 to run integration tests")
}
// Check ports are reachable
for _, port := range []string{"8989", "7878"} {
conn, err := net.DialTimeout("tcp", "localhost:"+port, 2*time.Second)
if err != nil {
t.Skipf("Port %s not reachable, skipping", port)
}
conn.Close()
}
t.Run("Discover", func(t *testing.T) {
instances := Discover()
if len(instances) == 0 {
t.Fatal("Discover() returned 0 instances")
}
for _, inst := range instances {
t.Logf("Found: %s at %s (source=%s, version=%s, hasKey=%v)",
inst.App, inst.URL, inst.Source, inst.Version, inst.APIKey != "")
}
})
t.Run("Radarr_Movies", func(t *testing.T) {
radarrKey := os.Getenv("RADARR_KEY")
if radarrKey == "" {
t.Skip("RADARR_KEY not set")
}
client := NewClient("http://localhost:7878", radarrKey)
status, err := client.SystemStatus()
if err != nil {
t.Fatalf("SystemStatus: %v", err)
}
t.Logf("Radarr %s", status.Version)
movies, err := client.Movies()
if err != nil {
t.Fatalf("Movies: %v", err)
}
t.Logf("Found %d movies", len(movies))
for _, m := range movies {
t.Logf(" %s (tmdb=%d, imdb=%s) monitored=%v hasFile=%v profile=%d",
m.Title, m.TmdbID, m.ImdbID, m.Monitored, m.HasFile, m.QualityProfileID)
}
if len(movies) < 3 {
t.Errorf("Expected at least 3 movies, got %d", len(movies))
}
profiles, err := client.QualityProfiles()
if err != nil {
t.Fatalf("QualityProfiles: %v", err)
}
t.Logf("Found %d quality profiles", len(profiles))
for _, p := range profiles {
mapped := MapQualityProfile(p)
t.Logf(" %s (id=%d) → %s", p.Name, p.ID, mapped)
}
folders, err := client.RootFolders()
if err != nil {
t.Fatalf("RootFolders: %v", err)
}
t.Logf("Found %d root folders", len(folders))
for _, f := range folders {
t.Logf(" %s", f.Path)
}
dcs, err := client.DownloadClients()
if err != nil {
t.Fatalf("DownloadClients: %v", err)
}
t.Logf("Found %d download clients", len(dcs))
// Test wanted extraction
wanted := ExtractWantedMovies(movies)
t.Logf("Wanted movies: %d", len(wanted))
for _, w := range wanted {
t.Logf(" %s (tmdb=%d)", w.Title, w.TmdbID)
}
if len(wanted) != 2 {
t.Errorf("Expected 2 wanted movies, got %d", len(wanted))
}
})
t.Run("Sonarr_Series", func(t *testing.T) {
sonarrKey := os.Getenv("SONARR_KEY")
if sonarrKey == "" {
t.Skip("SONARR_KEY not set")
}
client := NewClient("http://localhost:8989", sonarrKey)
status, err := client.SystemStatus()
if err != nil {
t.Fatalf("SystemStatus: %v", err)
}
t.Logf("Sonarr %s", status.Version)
series, err := client.Series()
if err != nil {
t.Fatalf("Series: %v", err)
}
t.Logf("Found %d series", len(series))
for _, s := range series {
t.Logf(" %s (tvdb=%d, imdb=%s) monitored=%v eps=%d/%d",
s.Title, s.TvdbID, s.ImdbID, s.Monitored,
s.Statistics.EpisodeFileCount, s.Statistics.EpisodeCount)
}
wanted := ExtractWantedSeries(series)
t.Logf("Wanted series: %d", len(wanted))
for _, w := range wanted {
t.Logf(" %s (imdb=%s)", w.Title, w.ImdbID)
}
if len(wanted) != 2 {
t.Errorf("Expected 2 wanted series, got %d", len(wanted))
}
})
t.Run("BuildMigrationResult", func(t *testing.T) {
radarrKey := os.Getenv("RADARR_KEY")
sonarrKey := os.Getenv("SONARR_KEY")
if radarrKey == "" || sonarrKey == "" {
t.Skip("RADARR_KEY and SONARR_KEY required")
}
rc := NewClient("http://localhost:7878", radarrKey)
sc := NewClient("http://localhost:8989", sonarrKey)
movies, _ := rc.Movies()
series, _ := sc.Series()
rp, _ := rc.QualityProfiles()
sp, _ := sc.QualityProfiles()
rf, _ := rc.RootFolders()
sf, _ := sc.RootFolders()
dcs, _ := rc.DownloadClients()
result := BuildMigrationResult(movies, series, rp, sp, rf, sf, nil, dcs)
fmt.Printf("\n=== Migration Result ===\n")
fmt.Printf(" MoviesDir: %s\n", result.MoviesDir)
fmt.Printf(" TVShowsDir: %s\n", result.TVShowsDir)
fmt.Printf(" Quality: %s (from %q)\n", result.Quality, result.QualitySource)
fmt.Printf(" Organize: %v\n", result.OrganizeEnabled)
fmt.Printf(" Movies: %d total, %d with files\n", result.TotalMovies, result.MoviesWithFiles)
fmt.Printf(" Series: %d total, %d complete\n", result.TotalSeries, result.SeriesComplete)
fmt.Printf(" Wanted movies: %d\n", len(result.WantedMovies))
fmt.Printf(" Wanted series: %d\n", len(result.WantedSeries))
if result.MoviesDir != "/data/media/movies" {
t.Errorf("MoviesDir = %q, want /data/media/movies", result.MoviesDir)
}
if result.TVShowsDir != "/data/media/tv" {
t.Errorf("TVShowsDir = %q, want /data/media/tv", result.TVShowsDir)
}
if len(result.WantedMovies) != 2 {
t.Errorf("WantedMovies = %d, want 2", len(result.WantedMovies))
}
if len(result.WantedSeries) != 2 {
t.Errorf("WantedSeries = %d, want 2", len(result.WantedSeries))
}
if !result.OrganizeEnabled {
t.Error("OrganizeEnabled should be true")
}
})
}

356
internal/arr/discovery.go Normal file
View file

@ -0,0 +1,356 @@
package arr
import (
"bytes"
"encoding/xml"
"fmt"
"io"
"net"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
)
// appInfo maps app names to their default ports and API versions.
var appInfo = map[string]struct {
Port string
Version string
}{
"sonarr": {Port: "8989", Version: "v3"},
"radarr": {Port: "7878", Version: "v3"},
"prowlarr": {Port: "9696", Version: "v1"},
}
// Discover scans for running *arr instances using multiple strategies.
// Returns instances in order: Docker, config files, port scan.
func Discover() []Instance {
seen := map[string]bool{} // dedupe by URL
var instances []Instance
add := func(inst Instance) {
key := strings.ToLower(inst.URL)
if seen[key] {
// Allow upgrading a no-key entry with one that has a key
if inst.APIKey != "" {
for i := range instances {
if strings.ToLower(instances[i].URL) == key && instances[i].APIKey == "" {
instances[i].APIKey = inst.APIKey
instances[i].Source = inst.Source
if inst.Version != "" {
instances[i].Version = inst.Version
}
break
}
}
}
return
}
seen[key] = true
instances = append(instances, inst)
}
// Strategy 1: Docker containers
for _, inst := range discoverDocker() {
add(inst)
}
// Strategy 2: Config files on disk
for _, inst := range discoverConfigFiles() {
add(inst)
}
// Strategy 3: Port scan on localhost
for _, inst := range discoverPorts() {
add(inst)
}
return instances
}
// ── Docker discovery ────────────────────────────────────────────────
func discoverDocker() []Instance {
dockerPath, err := exec.LookPath("docker")
if err != nil {
return nil
}
out, err := exec.Command(dockerPath, "ps", "--format", "{{.Names}}\t{{.Image}}\t{{.Ports}}").Output()
if err != nil {
return nil
}
var instances []Instance
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if line == "" {
continue
}
parts := strings.SplitN(line, "\t", 3)
if len(parts) < 3 {
continue
}
name, image, ports := parts[0], strings.ToLower(parts[1]), parts[2]
app := detectApp(image)
if app == "" {
continue
}
port := extractHostPort(ports, appInfo[app].Port)
if port == "" {
continue
}
url := "http://localhost:" + port
// Try to read API key from container's config.xml
apiKey := readDockerConfigXML(dockerPath, name)
inst := Instance{
App: app,
URL: url,
APIKey: apiKey,
Source: "docker",
}
// Verify connectivity if we have an API key
if apiKey != "" {
if status, err := NewClient(url, apiKey).SystemStatus(); err == nil {
inst.Version = status.Version
}
}
instances = append(instances, inst)
}
return instances
}
func detectApp(image string) string {
for _, app := range []string{"sonarr", "radarr", "prowlarr"} {
if strings.Contains(image, app) {
return app
}
}
return ""
}
// extractHostPort finds the host port mapped to the expected container port.
func extractHostPort(portsStr, containerPort string) string {
// Format: "0.0.0.0:8989->8989/tcp, ..."
for _, mapping := range strings.Split(portsStr, ",") {
mapping = strings.TrimSpace(mapping)
if strings.Contains(mapping, "->"+containerPort+"/") {
parts := strings.SplitN(mapping, "->", 2)
if len(parts) == 2 {
hostPart := parts[0]
// Remove IP prefix: "0.0.0.0:8989" → "8989"
if idx := strings.LastIndex(hostPart, ":"); idx >= 0 {
return hostPart[idx+1:]
}
return hostPart
}
}
}
// Fallback: check if the expected port appears at all
if strings.Contains(portsStr, containerPort) {
return containerPort
}
return ""
}
func readDockerConfigXML(dockerPath, containerName string) string {
out, err := exec.Command(dockerPath, "exec", containerName, "cat", "/config/config.xml").Output()
if err != nil {
return ""
}
_, apiKey, _ := parseConfigXML(bytes.NewReader(out))
return apiKey
}
// ── Config file discovery ───────────────────────────────────────────
func discoverConfigFiles() []Instance {
var instances []Instance
dirs := configDirs()
for _, app := range []string{"Sonarr", "Radarr", "Prowlarr"} {
for _, dir := range dirs {
cfgPath := filepath.Join(dir, app, "config.xml")
f, err := os.Open(cfgPath)
if err != nil {
continue
}
port, apiKey, urlBase := parseConfigXML(f)
_ = f.Close()
if port == "" || apiKey == "" {
continue
}
url := "http://localhost:" + port
if urlBase != "" {
url += "/" + strings.Trim(urlBase, "/")
}
inst := Instance{
App: strings.ToLower(app),
URL: url,
APIKey: apiKey,
Source: "config-file",
}
if status, err := NewClient(url, apiKey).SystemStatus(); err == nil {
inst.Version = status.Version
}
instances = append(instances, inst)
}
}
return instances
}
func configDirs() []string {
switch runtime.GOOS {
case "windows":
pd := os.Getenv("PROGRAMDATA")
if pd == "" {
pd = `C:\ProgramData`
}
return []string{pd}
default: // linux, darwin
home, _ := os.UserHomeDir()
return []string{
filepath.Join(home, ".config"),
"/var/lib",
}
}
}
// ── Port scan discovery ─────────────────────────────────────────────
func discoverPorts() []Instance {
var instances []Instance
// Fixed order iteration (map iteration is random in Go)
portOrder := []string{"sonarr", "radarr", "prowlarr"}
for _, app := range portOrder {
info := appInfo[app]
addr := "localhost:" + info.Port
conn, err := net.DialTimeout("tcp", addr, 2*time.Second)
if err != nil {
continue
}
_ = conn.Close()
url := "http://localhost:" + info.Port
inst := Instance{
App: app,
URL: url,
Source: "port-scan",
}
// Try to get status without API key (some configs allow local access)
if status, err := NewClient(url, "").SystemStatus(); err == nil {
inst.Version = status.Version
}
instances = append(instances, inst)
}
return instances
}
// ── Prowlarr application discovery ──────────────────────────────────
// DiscoverFromProwlarr extracts connected Sonarr/Radarr instances from Prowlarr.
func DiscoverFromProwlarr(prowlarrURL, prowlarrKey string) []Instance {
client := NewClient(prowlarrURL, prowlarrKey)
apps, err := client.Applications()
if err != nil {
return nil
}
var instances []Instance
for _, app := range apps {
var baseURL, apiKey string
for _, f := range app.Fields {
switch f.Name {
case "baseUrl", "BaseUrl":
if s, ok := f.Value.(string); ok {
baseURL = s
}
case "apiKey", "ApiKey":
if s, ok := f.Value.(string); ok {
apiKey = s
}
}
}
if baseURL == "" || apiKey == "" {
continue
}
appName := strings.ToLower(app.Name)
detectedApp := ""
for _, a := range []string{"sonarr", "radarr"} {
if strings.Contains(appName, a) {
detectedApp = a
break
}
}
if detectedApp == "" {
continue
}
inst := Instance{
App: detectedApp,
URL: strings.TrimRight(baseURL, "/"),
APIKey: apiKey,
Source: "prowlarr",
}
if status, err := NewClient(inst.URL, apiKey).SystemStatus(); err == nil {
inst.Version = status.Version
}
instances = append(instances, inst)
}
return instances
}
// ── Config XML parser ───────────────────────────────────────────────
type xmlConfig struct {
XMLName xml.Name `xml:"Config"`
Port string `xml:"Port"`
APIKey string `xml:"ApiKey"`
URLBase string `xml:"UrlBase"`
}
func parseConfigXML(r io.Reader) (port, apiKey, urlBase string) {
data, err := io.ReadAll(io.LimitReader(r, 1<<20)) // 1MB
if err != nil {
return "", "", ""
}
var cfg xmlConfig
if err := xml.Unmarshal(data, &cfg); err != nil {
return "", "", ""
}
return cfg.Port, cfg.APIKey, cfg.URLBase
}
// Verify checks that an instance is reachable and the API key is valid.
// Returns the system status on success.
func Verify(inst *Instance) error {
if inst.APIKey == "" {
return fmt.Errorf("no API key")
}
status, err := NewClient(inst.URL, inst.APIKey).SystemStatus()
if err != nil {
return err
}
inst.Version = status.Version
return nil
}

View file

@ -0,0 +1,84 @@
package arr
import (
"strings"
"testing"
)
func TestParseConfigXML(t *testing.T) {
xml := `<Config>
<Port>8989</Port>
<ApiKey>abc123def456</ApiKey>
<UrlBase>/sonarr</UrlBase>
</Config>`
port, apiKey, urlBase := parseConfigXML(strings.NewReader(xml))
if port != "8989" {
t.Errorf("port = %q, want 8989", port)
}
if apiKey != "abc123def456" {
t.Errorf("apiKey = %q, want abc123def456", apiKey)
}
if urlBase != "/sonarr" {
t.Errorf("urlBase = %q, want /sonarr", urlBase)
}
}
func TestParseConfigXML_Minimal(t *testing.T) {
xml := `<Config><Port>7878</Port><ApiKey>key</ApiKey></Config>`
port, apiKey, urlBase := parseConfigXML(strings.NewReader(xml))
if port != "7878" || apiKey != "key" || urlBase != "" {
t.Errorf("got port=%q apiKey=%q urlBase=%q", port, apiKey, urlBase)
}
}
func TestParseConfigXML_Invalid(t *testing.T) {
port, apiKey, _ := parseConfigXML(strings.NewReader("not xml"))
if port != "" || apiKey != "" {
t.Errorf("invalid XML should return empty values")
}
}
func TestExtractHostPort(t *testing.T) {
tests := []struct {
ports string
container string
want string
}{
{"0.0.0.0:8989->8989/tcp", "8989", "8989"},
{"0.0.0.0:9090->8989/tcp, :::9090->8989/tcp", "8989", "9090"},
{"0.0.0.0:7878->7878/tcp", "7878", "7878"},
{"", "8989", ""},
{"0.0.0.0:3000->3000/tcp", "8989", ""},
}
for _, tt := range tests {
t.Run(tt.ports, func(t *testing.T) {
got := extractHostPort(tt.ports, tt.container)
if got != tt.want {
t.Errorf("extractHostPort(%q, %q) = %q, want %q", tt.ports, tt.container, got, tt.want)
}
})
}
}
func TestDetectApp(t *testing.T) {
tests := []struct {
image string
want string
}{
{"linuxserver/sonarr:latest", "sonarr"},
{"hotio/radarr", "radarr"},
{"ghcr.io/linuxserver/prowlarr:develop", "prowlarr"},
{"nginx:latest", ""},
{"postgres:16", ""},
}
for _, tt := range tests {
t.Run(tt.image, func(t *testing.T) {
got := detectApp(tt.image)
if got != tt.want {
t.Errorf("detectApp(%q) = %q, want %q", tt.image, got, tt.want)
}
})
}
}

312
internal/arr/mapper.go Normal file
View file

@ -0,0 +1,312 @@
package arr
import "strings"
// MapQualityProfile determines the preferred resolution from a quality profile.
// Uses the cutoff quality as the primary signal (what the user "wants"),
// falling back to the highest allowed resolution.
func MapQualityProfile(profile QualityProfile) string {
maxResolution := 0
cutoffResolution := 0
var walk func(items []QualityItem)
walk = func(items []QualityItem) {
for _, item := range items {
if len(item.Items) > 0 {
walk(item.Items)
continue
}
if item.Quality == nil || !item.Allowed {
continue
}
if item.Quality.Resolution > maxResolution {
maxResolution = item.Quality.Resolution
}
if item.Quality.ID == profile.Cutoff && item.Quality.Resolution > 0 {
cutoffResolution = item.Quality.Resolution
}
}
}
walk(profile.Items)
// Prefer the cutoff (what user wants), fall back to max allowed
res := cutoffResolution
if res == 0 {
res = maxResolution
}
switch {
case res >= 2160:
return "2160p"
case res >= 1080:
return "1080p"
default:
return "720p"
}
}
// MostUsedProfile finds the quality profile used by the most items.
func MostUsedProfile(profileCounts map[int]int, profiles []QualityProfile) *QualityProfile {
if len(profiles) == 0 {
return nil
}
bestID := profiles[0].ID
bestCount := 0
for id, count := range profileCounts {
if count > bestCount {
bestCount = count
bestID = id
}
}
for i := range profiles {
if profiles[i].ID == bestID {
return &profiles[i]
}
}
return &profiles[0]
}
// MapRootFolders picks the most-used root folder from each app.
// Uses item paths to count which root folder is most popular.
func MapRootFolders(radarrFolders []RootFolder, sonarrFolders []RootFolder, movies []Movie, series []Series) (moviesDir, tvDir string) {
moviesDir = mostUsedFolder(radarrFolders, func() []string {
paths := make([]string, len(movies))
for i, m := range movies {
paths[i] = m.RootFolderPath
}
return paths
}())
tvDir = mostUsedFolder(sonarrFolders, func() []string {
paths := make([]string, len(series))
for i, s := range series {
paths[i] = s.RootFolderPath
}
return paths
}())
return moviesDir, tvDir
}
// mostUsedFolder returns the folder path used by the most items.
// Falls back to the first folder if no items reference any folder.
func mostUsedFolder(folders []RootFolder, itemPaths []string) string {
if len(folders) == 0 {
return ""
}
if len(folders) == 1 {
return folders[0].Path
}
counts := map[string]int{}
for _, p := range itemPaths {
if p != "" {
counts[p]++
}
}
best := folders[0].Path
bestCount := 0
for _, f := range folders {
if c := counts[f.Path]; c > bestCount {
bestCount = c
best = f.Path
}
}
return best
}
// ExtractWantedMovies returns movies that are monitored but missing files.
func ExtractWantedMovies(movies []Movie) []WantedItem {
var wanted []WantedItem
for _, m := range movies {
if m.Monitored && !m.HasFile && m.TmdbID > 0 {
wanted = append(wanted, WantedItem{
TmdbID: m.TmdbID,
ImdbID: m.ImdbID,
Title: m.Title,
Year: m.Year,
Type: "movie",
})
}
}
return wanted
}
// ExtractWantedSeries returns series that are monitored with missing episodes.
func ExtractWantedSeries(series []Series) []WantedItem {
var wanted []WantedItem
for _, s := range series {
if !s.Monitored {
continue
}
// Series with less than 100% of episodes downloaded
if s.Statistics.EpisodeCount > 0 && s.Statistics.EpisodeFileCount < s.Statistics.EpisodeCount {
id := s.ImdbID
wanted = append(wanted, WantedItem{
ImdbID: id,
Title: s.Title,
Year: s.Year,
Type: "show",
})
}
}
return wanted
}
// BuildMigrationResult aggregates all extracted data into a single result.
func BuildMigrationResult(
movies []Movie,
series []Series,
radarrProfiles, sonarrProfiles []QualityProfile,
radarrFolders, sonarrFolders []RootFolder,
indexers []Indexer,
downloadClients []DownloadClient,
) *MigrationResult {
result := &MigrationResult{}
// Stats
result.TotalMovies = len(movies)
for _, m := range movies {
if m.HasFile {
result.MoviesWithFiles++
}
}
result.TotalSeries = len(series)
for _, s := range series {
if s.Statistics.EpisodeCount > 0 && s.Statistics.EpisodeFileCount >= s.Statistics.EpisodeCount {
result.SeriesComplete++
}
}
result.IndexerCount = len(indexers)
for _, dc := range downloadClients {
if dc.Enable {
result.DownloadClients = append(result.DownloadClients, dc.ImplementationName)
}
}
// Root folders → paths (uses most-popular folder based on item paths)
result.MoviesDir, result.TVShowsDir = MapRootFolders(radarrFolders, sonarrFolders, movies, series)
if result.MoviesDir != "" || result.TVShowsDir != "" {
result.OrganizeEnabled = true
}
// Quality profile — use the most popular one across both apps
profileCounts := map[int]int{}
for _, m := range movies {
profileCounts[m.QualityProfileID]++
}
for _, s := range series {
profileCounts[s.QualityProfileID]++
}
allProfiles := make([]QualityProfile, 0, len(radarrProfiles)+len(sonarrProfiles))
allProfiles = append(allProfiles, radarrProfiles...)
allProfiles = append(allProfiles, sonarrProfiles...)
if p := MostUsedProfile(profileCounts, allProfiles); p != nil {
result.Quality = MapQualityProfile(*p)
result.QualitySource = p.Name
}
// Wanted lists
result.WantedMovies = ExtractWantedMovies(movies)
result.WantedSeries = ExtractWantedSeries(series)
return result
}
// ExtractBlocklistedHashes returns unique infoHashes from blocklist entries.
func ExtractBlocklistedHashes(items []BlocklistItem) []string {
seen := map[string]bool{}
var hashes []string
for _, item := range items {
h := strings.ToLower(strings.TrimSpace(item.Data.InfoHash))
if h != "" && !seen[h] {
seen[h] = true
hashes = append(hashes, h)
}
}
return hashes
}
// ExtractDownloadedHashes returns unique infoHashes from history (imported items).
func ExtractDownloadedHashes(records []HistoryRecord) []string {
seen := map[string]bool{}
var hashes []string
for _, r := range records {
// Only count actually imported downloads, not just grabs
if r.EventType != "downloadFolderImported" && r.EventType != "downloadImported" {
continue
}
h := strings.ToLower(strings.TrimSpace(r.Data.InfoHash))
if h != "" && !seen[h] {
seen[h] = true
hashes = append(hashes, h)
}
}
return hashes
}
// ExtractDebridTokens looks for debrid-related download clients and extracts tokens.
func ExtractDebridTokens(clients []DownloadClient, getFields func(id int) []Field) []DebridToken {
debridKeywords := map[string]string{
"realdebrid": "real-debrid",
"real-debrid": "real-debrid",
"alldebrid": "alldebrid",
"torbox": "torbox",
"premiumize": "premiumize",
}
var tokens []DebridToken
for _, dc := range clients {
if !dc.Enable {
continue
}
impl := strings.ToLower(dc.Implementation + dc.ImplementationName)
provider := ""
for kw, prov := range debridKeywords {
if strings.Contains(impl, kw) {
provider = prov
break
}
}
if provider == "" {
continue
}
// Get the fields for this download client to find the API key/token
fields := getFields(dc.ID)
for _, f := range fields {
name := strings.ToLower(f.Name)
if name == "apikey" || name == "api_key" || name == "token" || name == "apitoken" {
if s, ok := f.Value.(string); ok && s != "" {
tokens = append(tokens, DebridToken{
Provider: provider,
Token: s,
Name: dc.Name,
})
break
}
}
}
}
return tokens
}
// HasDockerPaths checks if any paths look like Docker container paths
// (e.g. /data, /movies, /tv) rather than real host paths.
func HasDockerPaths(result *MigrationResult) bool {
dockerPrefixes := []string{"/data/", "/movies", "/tv", "/media", "/downloads"}
for _, path := range []string{result.MoviesDir, result.TVShowsDir} {
if path == "" {
continue
}
for _, prefix := range dockerPrefixes {
if strings.HasPrefix(path, prefix) {
return true
}
}
}
return false
}

230
internal/arr/mapper_test.go Normal file
View file

@ -0,0 +1,230 @@
package arr
import (
"testing"
)
func TestMapQualityProfile_2160p(t *testing.T) {
profile := QualityProfile{
ID: 1,
Name: "Ultra-HD",
Cutoff: 31, // 2160p Remux
Items: []QualityItem{
{Quality: &Quality{ID: 31, Name: "Remux-2160p", Resolution: 2160}, Allowed: true},
{Quality: &Quality{ID: 18, Name: "HDTV-2160p", Resolution: 2160}, Allowed: true},
{Quality: &Quality{ID: 7, Name: "Bluray-1080p", Resolution: 1080}, Allowed: true},
},
}
if got := MapQualityProfile(profile); got != "2160p" {
t.Errorf("MapQualityProfile = %q, want 2160p", got)
}
}
func TestMapQualityProfile_1080p(t *testing.T) {
profile := QualityProfile{
ID: 2,
Name: "HD-1080p",
Cutoff: 7,
Items: []QualityItem{
{Quality: &Quality{ID: 7, Name: "Bluray-1080p", Resolution: 1080}, Allowed: true},
{Quality: &Quality{ID: 3, Name: "HDTV-720p", Resolution: 720}, Allowed: true},
},
}
if got := MapQualityProfile(profile); got != "1080p" {
t.Errorf("MapQualityProfile = %q, want 1080p", got)
}
}
func TestMapQualityProfile_720p_fallback(t *testing.T) {
profile := QualityProfile{
ID: 3,
Name: "SD",
Cutoff: 1,
Items: []QualityItem{
{Quality: &Quality{ID: 1, Name: "SDTV", Resolution: 480}, Allowed: true},
},
}
if got := MapQualityProfile(profile); got != "720p" {
t.Errorf("MapQualityProfile = %q, want 720p", got)
}
}
func TestMapQualityProfile_NestedGroups(t *testing.T) {
profile := QualityProfile{
ID: 4,
Name: "Any",
Cutoff: 7,
Items: []QualityItem{
{
Items: []QualityItem{
{Quality: &Quality{ID: 7, Name: "Bluray-1080p", Resolution: 1080}, Allowed: true},
{Quality: &Quality{ID: 3, Name: "HDTV-720p", Resolution: 720}, Allowed: true},
},
},
},
}
if got := MapQualityProfile(profile); got != "1080p" {
t.Errorf("MapQualityProfile = %q, want 1080p", got)
}
}
func TestMostUsedProfile(t *testing.T) {
profiles := []QualityProfile{
{ID: 1, Name: "HD-1080p"},
{ID: 2, Name: "Ultra-HD"},
{ID: 3, Name: "SD"},
}
counts := map[int]int{1: 5, 2: 20, 3: 3}
p := MostUsedProfile(counts, profiles)
if p == nil || p.ID != 2 {
t.Errorf("MostUsedProfile = %v, want profile ID 2", p)
}
}
func TestMostUsedProfile_EmptyCounts(t *testing.T) {
profiles := []QualityProfile{
{ID: 1, Name: "HD-1080p"},
}
p := MostUsedProfile(map[int]int{}, profiles)
if p == nil || p.ID != 1 {
t.Errorf("MostUsedProfile with empty counts should return first profile")
}
}
func TestExtractWantedMovies(t *testing.T) {
movies := []Movie{
{TmdbID: 1, Title: "Has file", Monitored: true, HasFile: true},
{TmdbID: 2, Title: "Wanted", Monitored: true, HasFile: false},
{TmdbID: 3, Title: "Unmonitored", Monitored: false, HasFile: false},
{TmdbID: 0, Title: "No TMDB", Monitored: true, HasFile: false}, // no tmdbId
}
wanted := ExtractWantedMovies(movies)
if len(wanted) != 1 {
t.Fatalf("ExtractWantedMovies = %d items, want 1", len(wanted))
}
if wanted[0].TmdbID != 2 || wanted[0].Type != "movie" {
t.Errorf("ExtractWantedMovies[0] = %+v, want tmdbId=2 type=movie", wanted[0])
}
}
func TestExtractWantedSeries(t *testing.T) {
series := []Series{
{ImdbID: "tt1", Title: "Complete", Monitored: true, Statistics: SeriesStatistics{EpisodeCount: 10, EpisodeFileCount: 10}},
{ImdbID: "tt2", Title: "Missing eps", Monitored: true, Statistics: SeriesStatistics{EpisodeCount: 10, EpisodeFileCount: 5}},
{ImdbID: "tt3", Title: "Unmonitored", Monitored: false, Statistics: SeriesStatistics{EpisodeCount: 10, EpisodeFileCount: 0}},
}
wanted := ExtractWantedSeries(series)
if len(wanted) != 1 {
t.Fatalf("ExtractWantedSeries = %d items, want 1", len(wanted))
}
if wanted[0].ImdbID != "tt2" || wanted[0].Type != "show" {
t.Errorf("ExtractWantedSeries[0] = %+v, want imdbId=tt2 type=show", wanted[0])
}
}
func TestExtractBlocklistedHashes(t *testing.T) {
items := []BlocklistItem{
{Data: BlocklistData{InfoHash: "AAAA"}},
{Data: BlocklistData{InfoHash: "AAAA"}}, // duplicate
{Data: BlocklistData{InfoHash: "BBBB"}},
{Data: BlocklistData{InfoHash: ""}}, // empty
}
hashes := ExtractBlocklistedHashes(items)
if len(hashes) != 2 {
t.Fatalf("ExtractBlocklistedHashes = %d, want 2", len(hashes))
}
}
func TestExtractDownloadedHashes(t *testing.T) {
records := []HistoryRecord{
{EventType: "downloadFolderImported", Data: HistoryData{InfoHash: "hash1"}},
{EventType: "grabbed", Data: HistoryData{InfoHash: "hash2"}}, // not imported
{EventType: "downloadFolderImported", Data: HistoryData{InfoHash: "hash1"}}, // duplicate
{EventType: "downloadFolderImported", Data: HistoryData{InfoHash: "hash3"}},
}
hashes := ExtractDownloadedHashes(records)
if len(hashes) != 2 {
t.Fatalf("ExtractDownloadedHashes = %d, want 2", len(hashes))
}
}
func TestMapRootFolders_MostUsed(t *testing.T) {
folders := []RootFolder{
{Path: "/data/movies1"},
{Path: "/data/movies2"},
}
movies := []Movie{
{RootFolderPath: "/data/movies1"},
{RootFolderPath: "/data/movies2"},
{RootFolderPath: "/data/movies2"},
{RootFolderPath: "/data/movies2"},
}
moviesDir, _ := MapRootFolders(folders, nil, movies, nil)
if moviesDir != "/data/movies2" {
t.Errorf("MapRootFolders = %q, want /data/movies2", moviesDir)
}
}
func TestMapRootFolders_SingleFolder(t *testing.T) {
folders := []RootFolder{{Path: "/data/movies"}}
moviesDir, _ := MapRootFolders(folders, nil, nil, nil)
if moviesDir != "/data/movies" {
t.Errorf("MapRootFolders = %q, want /data/movies", moviesDir)
}
}
func TestMapRootFolders_Empty(t *testing.T) {
moviesDir, tvDir := MapRootFolders(nil, nil, nil, nil)
if moviesDir != "" || tvDir != "" {
t.Errorf("MapRootFolders empty = %q, %q, want empty", moviesDir, tvDir)
}
}
func TestHasDockerPaths(t *testing.T) {
tests := []struct {
name string
movies string
tv string
expected bool
}{
{"docker paths", "/data/media/movies", "/data/media/tv", true},
{"host paths", "/home/user/Media/Movies", "/home/user/Media/TV", false},
{"empty", "", "", false},
{"mixed", "/home/user/movies", "/data/media/tv", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := &MigrationResult{MoviesDir: tt.movies, TVShowsDir: tt.tv}
if got := HasDockerPaths(r); got != tt.expected {
t.Errorf("HasDockerPaths = %v, want %v", got, tt.expected)
}
})
}
}
func TestExtractDebridTokens(t *testing.T) {
clients := []DownloadClient{
{ID: 1, Name: "TorBox", Enable: true, Implementation: "TorBox", ImplementationName: "TorBox"},
{ID: 2, Name: "qBittorrent", Enable: true, Implementation: "QBittorrent", ImplementationName: "qBittorrent"},
{ID: 3, Name: "Disabled Debrid", Enable: false, Implementation: "RealDebrid", ImplementationName: "Real-Debrid"},
}
getFields := func(id int) []Field {
if id == 1 {
return []Field{
{Name: "ApiKey", Value: "tb_test_token_123"},
{Name: "Host", Value: "torbox.app"},
}
}
return nil
}
tokens := ExtractDebridTokens(clients, getFields)
if len(tokens) != 1 {
t.Fatalf("ExtractDebridTokens = %d tokens, want 1", len(tokens))
}
if tokens[0].Provider != "torbox" || tokens[0].Token != "tb_test_token_123" {
t.Errorf("ExtractDebridTokens[0] = %+v, want torbox with token", tokens[0])
}
}

207
internal/arr/types.go Normal file
View file

@ -0,0 +1,207 @@
package arr
// SystemStatus is returned by GET /api/v{n}/system/status.
type SystemStatus struct {
AppName string `json:"appName"`
Version string `json:"version"`
StartupPath string `json:"startupPath"`
}
// QualityProfile represents a quality configuration in Sonarr/Radarr.
type QualityProfile struct {
ID int `json:"id"`
Name string `json:"name"`
Items []QualityItem `json:"items"`
Cutoff int `json:"cutoff"`
}
// QualityItem is a single entry (or group) inside a quality profile.
type QualityItem struct {
Quality *Quality `json:"quality"`
Items []QualityItem `json:"items"` // nested quality groups
Allowed bool `json:"allowed"`
}
// Quality describes a single quality level.
type Quality struct {
ID int `json:"id"`
Name string `json:"name"`
Resolution int `json:"resolution"`
}
// RootFolder is a media root folder configured in Sonarr/Radarr.
type RootFolder struct {
ID int `json:"id"`
Path string `json:"path"`
FreeSpace int64 `json:"freeSpace"`
}
// Movie is a Radarr movie record from GET /api/v3/movie.
type Movie struct {
ID int `json:"id"`
TmdbID int `json:"tmdbId"`
ImdbID string `json:"imdbId"`
Title string `json:"title"`
Year int `json:"year"`
Path string `json:"path"`
RootFolderPath string `json:"rootFolderPath"`
QualityProfileID int `json:"qualityProfileId"`
Monitored bool `json:"monitored"`
HasFile bool `json:"hasFile"`
SizeOnDisk int64 `json:"sizeOnDisk"`
}
// Series is a Sonarr series record from GET /api/v3/series.
type Series struct {
ID int `json:"id"`
TvdbID int `json:"tvdbId"`
ImdbID string `json:"imdbId"`
Title string `json:"title"`
Year int `json:"year"`
Path string `json:"path"`
RootFolderPath string `json:"rootFolderPath"`
QualityProfileID int `json:"qualityProfileId"`
Monitored bool `json:"monitored"`
Statistics SeriesStatistics `json:"statistics"`
}
// SeriesStatistics holds episode-level stats for a series.
type SeriesStatistics struct {
EpisodeCount int `json:"episodeCount"`
EpisodeFileCount int `json:"episodeFileCount"`
SizeOnDisk int64 `json:"sizeOnDisk"`
PercentOfEpisodes float64 `json:"percentOfEpisodes"`
}
// Indexer is a Prowlarr indexer from GET /api/v1/indexer.
type Indexer struct {
ID int `json:"id"`
Name string `json:"name"`
Enable bool `json:"enable"`
ImplementationName string `json:"implementationName"`
}
// Application is a Prowlarr-connected app from GET /api/v1/applications.
type Application struct {
ID int `json:"id"`
Name string `json:"name"`
Fields []Field `json:"fields"`
}
// Field is a dynamic key-value pair used in Prowlarr indexer/app configs.
type Field struct {
Name string `json:"name"`
Value any `json:"value"`
}
// DownloadClient is a download client configured in Sonarr/Radarr.
type DownloadClient struct {
ID int `json:"id"`
Name string `json:"name"`
Enable bool `json:"enable"`
Protocol string `json:"protocol"` // "torrent" or "usenet"
Implementation string `json:"implementation"`
ImplementationName string `json:"implementationName"`
}
// Tag is a label applied to movies/series in Sonarr/Radarr.
type Tag struct {
ID int `json:"id"`
Label string `json:"label"`
}
// HistoryRecord is a single entry from /api/v3/history.
type HistoryRecord struct {
ID int `json:"id"`
EventType string `json:"eventType"` // "grabbed", "downloadFolderImported", etc.
DownloadID string `json:"downloadId"`
SourceTitle string `json:"sourceTitle"`
Data HistoryData `json:"data"`
}
// HistoryData holds the nested data of a history record.
type HistoryData struct {
InfoHash string `json:"torrentInfoHash"`
DownloadURL string `json:"downloadUrl"`
}
// HistoryResponse wraps the paginated history from *arr.
type HistoryResponse struct {
Records []HistoryRecord `json:"records"`
TotalRecords int `json:"totalRecords"`
}
// BlocklistItem is an item the user explicitly rejected.
type BlocklistItem struct {
ID int `json:"id"`
SourceTitle string `json:"sourceTitle"`
Data BlocklistData `json:"data"`
}
// BlocklistData holds torrent info from a blocklist entry.
type BlocklistData struct {
InfoHash string `json:"torrentInfoHash"`
}
// BlocklistResponse wraps paginated blocklist from *arr.
type BlocklistResponse struct {
Records []BlocklistItem `json:"records"`
TotalRecords int `json:"totalRecords"`
}
// Instance represents a discovered *arr application.
type Instance struct {
App string // "sonarr", "radarr", "prowlarr"
URL string // "http://localhost:8989"
APIKey string // from config.xml or user input
Version string // from /system/status
Source string // "docker", "port-scan", "config-file", "prowlarr", "manual"
}
// MigrationResult holds the mapped data ready to apply.
type MigrationResult struct {
// Config changes
MoviesDir string
TVShowsDir string
Quality string // "2160p", "1080p", "720p"
QualitySource string // name of the quality profile used
OrganizeEnabled bool
// Wanted list
WantedMovies []WantedItem
WantedSeries []WantedItem
// Exclusions
BlocklistedHashes []string // infoHashes the user has rejected
DownloadedHashes []string // infoHashes already downloaded (from history)
// Debrid
DebridTokens []DebridToken // tokens extracted from *arr download clients
// Media servers
MediaServers []string // detected media servers (e.g. "Plex at localhost:32400")
// Stats (informational)
TotalMovies int
MoviesWithFiles int
TotalSeries int
SeriesComplete int
IndexerCount int
DownloadClients []string // names of detected download clients
}
// DebridToken is a debrid API token extracted from an *arr download client.
type DebridToken struct {
Provider string // "real-debrid", "alldebrid", "torbox", "premiumize"
Token string
Name string // download client name from *arr
}
// WantedItem is a movie or series the user wants but doesn't have yet.
type WantedItem struct {
TmdbID int `json:"tmdbId,omitempty"`
ImdbID string `json:"imdbId,omitempty"`
Title string `json:"title"`
Year int `json:"year,omitempty"`
Type string `json:"type"` // "movie" or "show"
}