unarr/internal/arr/discovery.go
Deivid Soto 677a8fe083 feat: add migrate command, media server detection, and debrid auto-config
- Migration wizard from Sonarr/Radarr/Prowlarr (unarr migrate) [pre-beta]
  - Auto-detect instances via Docker, config files, port scan, Prowlarr
  - Import wanted list (monitored+missing movies/series)
  - Import download history and blocklist to avoid re-downloading
  - Extract debrid tokens from *arr download clients
  - Quality profile mapping to preferred_quality config
  - DISTINCT ON PostgreSQL query for optimal torrent selection
  - JSON export with --dry-run --json (text to stderr, JSON to stdout)
- Media server detection (Plex/Jellyfin/Emby) in unarr init
  - Detects library paths and offers them as download directory options
- Debrid auto-configuration in unarr init
  - Scans *arr instances for debrid tokens
  - Validates and saves via API if user confirms
- New preferred_quality setting in config (2160p/1080p/720p)
- Library scan command (unarr scan) with ffprobe metadata extraction
2026-03-29 16:54:32 +02:00

356 lines
8.4 KiB
Go

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
}