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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue