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
356
internal/arr/discovery.go
Normal file
356
internal/arr/discovery.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue