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
188
internal/arr/client.go
Normal file
188
internal/arr/client.go
Normal 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
|
||||
}
|
||||
180
internal/arr/discover_e2e_test.go
Normal file
180
internal/arr/discover_e2e_test.go
Normal 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
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
|
||||
}
|
||||
84
internal/arr/discovery_test.go
Normal file
84
internal/arr/discovery_test.go
Normal 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
312
internal/arr/mapper.go
Normal 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
230
internal/arr/mapper_test.go
Normal 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
207
internal/arr/types.go
Normal 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"
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue