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
|
|
@ -10,6 +10,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
### Added
|
||||
- Init wizard with daemon install step (`unarr init`, replaces `unarr setup`)
|
||||
- Interactive config menu with 7 categories (`unarr config [category]`)
|
||||
- Migration wizard from Sonarr/Radarr/Prowlarr (`unarr migrate`) [pre-beta]
|
||||
- Auto-detect instances via Docker, config files, port scan, Prowlarr
|
||||
- Import download history and blocklist to avoid re-downloading
|
||||
- Detect Plex/Jellyfin/Emby media servers and library paths
|
||||
- Extract debrid tokens from *arr download clients
|
||||
- JSON export with `--dry-run --json`
|
||||
- Media server detection in `unarr init` (suggests library paths as download directory)
|
||||
- `preferred_quality` setting in config (2160p/1080p/720p)
|
||||
- Clean command to remove temp files, logs, and cached data (`unarr clean`)
|
||||
- Daemon mode with background download management (`unarr start`)
|
||||
- One-shot download command (`unarr download`)
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ unarr start
|
|||
|---------|-------------|
|
||||
| `unarr init` | First-time configuration wizard (API key, download dir, daemon) |
|
||||
| `unarr config` | Edit all settings interactively (speed, organization, etc.) |
|
||||
| `unarr migrate` | Import settings and wanted list from Sonarr/Radarr/Prowlarr [pre-beta] |
|
||||
|
||||
### Search & Discovery
|
||||
|
||||
|
|
|
|||
|
|
@ -153,6 +153,33 @@ func (c *Client) GetUsenetUsage(ctx context.Context) (*UsenetUsageResponse, erro
|
|||
return &resp, nil
|
||||
}
|
||||
|
||||
// ConfigureDebrid saves a debrid provider token for the user (used by unarr init/migrate).
|
||||
func (c *Client) ConfigureDebrid(ctx context.Context, req ConfigureDebridRequest) (*ConfigureDebridResponse, error) {
|
||||
var resp ConfigureDebridResponse
|
||||
if err := c.doPost(ctx, "/api/internal/agent/debrid-config", req, &resp); err != nil {
|
||||
return nil, fmt.Errorf("configure debrid: %w", err)
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// BatchDownload queues multiple items for download (used by unarr migrate).
|
||||
func (c *Client) BatchDownload(ctx context.Context, req BatchDownloadRequest) (*BatchDownloadResponse, error) {
|
||||
var resp BatchDownloadResponse
|
||||
if err := c.doPost(ctx, "/api/internal/agent/batch-download", req, &resp); err != nil {
|
||||
return nil, fmt.Errorf("batch download: %w", err)
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// SyncLibrary sends scanned library items to the server for matching and upgrade discovery.
|
||||
func (c *Client) SyncLibrary(ctx context.Context, req LibrarySyncRequest) (*LibrarySyncResponse, error) {
|
||||
var resp LibrarySyncResponse
|
||||
if err := c.doPost(ctx, "/api/internal/agent/library-sync", req, &resp); err != nil {
|
||||
return nil, fmt.Errorf("library sync: %w", err)
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// doPost sends a JSON POST request and decodes the response.
|
||||
func (c *Client) doPost(ctx context.Context, path string, body any, dst any) error {
|
||||
jsonBody, err := json.Marshal(body)
|
||||
|
|
|
|||
|
|
@ -68,6 +68,8 @@ type Task struct {
|
|||
DirectFileName string `json:"directFileName,omitempty"` // Original filename from direct URL
|
||||
NzbID string `json:"nzbId,omitempty"` // Pre-resolved NZB ID from server
|
||||
NzbPassword string `json:"nzbPassword,omitempty"` // Password for encrypted NZB archives
|
||||
ReplacePath string `json:"replacePath,omitempty"` // File to replace after download (upgrade mode)
|
||||
LibraryItemID int `json:"libraryItemId,omitempty"` // Library item being upgraded
|
||||
}
|
||||
|
||||
// TasksResponse wraps the array of tasks returned by the server.
|
||||
|
|
@ -197,3 +199,102 @@ type UsenetUsageResponse struct {
|
|||
RemainingBytes int64 `json:"remainingBytes"`
|
||||
QuotaResetDate string `json:"quotaResetDate"`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Batch download types (used by unarr migrate)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// BatchDownloadRequest sends a list of wanted items to queue for download.
|
||||
type BatchDownloadRequest struct {
|
||||
Items []WantedItem `json:"items"`
|
||||
ExcludeHashes []string `json:"excludeHashes,omitempty"` // blocklisted + already-downloaded hashes
|
||||
}
|
||||
|
||||
// WantedItem represents a movie or series the user wants.
|
||||
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"
|
||||
}
|
||||
|
||||
// BatchDownloadResponse reports the outcome of a batch download request.
|
||||
type BatchDownloadResponse struct {
|
||||
Queued int `json:"queued"`
|
||||
NotFound int `json:"notFound"`
|
||||
AlreadyActive int `json:"alreadyActive"`
|
||||
Items []BatchItem `json:"items"`
|
||||
}
|
||||
|
||||
// BatchItem is the per-item result of a batch download.
|
||||
type BatchItem struct {
|
||||
Title string `json:"title"`
|
||||
Status string `json:"status"` // "queued", "not_found", "already_active"
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Debrid config types (used by unarr init/migrate)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ConfigureDebridRequest configures a debrid provider.
|
||||
type ConfigureDebridRequest struct {
|
||||
Provider string `json:"provider"` // "real-debrid", "alldebrid", "torbox", "premiumize"
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
// ConfigureDebridResponse is returned after configuring a debrid provider.
|
||||
type ConfigureDebridResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Account DebridAccount `json:"account"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// DebridAccount holds verified debrid account info.
|
||||
type DebridAccount struct {
|
||||
Valid bool `json:"valid"`
|
||||
Premium bool `json:"premium"`
|
||||
Username string `json:"username"`
|
||||
ExpiresAt string `json:"expiresAt,omitempty"`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Library sync types (used by unarr scan)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// LibrarySyncRequest sends scanned media items to the server.
|
||||
type LibrarySyncRequest struct {
|
||||
Items []LibrarySyncItem `json:"items"`
|
||||
ScanPath string `json:"scanPath"`
|
||||
IsLastBatch bool `json:"isLastBatch"`
|
||||
}
|
||||
|
||||
// LibrarySyncItem is a single scanned media file with ffprobe metadata.
|
||||
type LibrarySyncItem struct {
|
||||
FilePath string `json:"filePath"`
|
||||
FileName string `json:"fileName"`
|
||||
FileSize int64 `json:"fileSize,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Year string `json:"year,omitempty"`
|
||||
Season int `json:"season,omitempty"`
|
||||
Episode int `json:"episode,omitempty"`
|
||||
ContentType string `json:"contentType"`
|
||||
Resolution string `json:"resolution,omitempty"`
|
||||
VideoCodec string `json:"videoCodec,omitempty"`
|
||||
HDR string `json:"hdr,omitempty"`
|
||||
BitDepth int `json:"bitDepth,omitempty"`
|
||||
AudioCodec string `json:"audioCodec,omitempty"`
|
||||
AudioChannels int `json:"audioChannels,omitempty"`
|
||||
AudioLanguages []string `json:"audioLanguages,omitempty"`
|
||||
SubtitleLanguages []string `json:"subtitleLanguages,omitempty"`
|
||||
AudioTracks any `json:"audioTracks,omitempty"`
|
||||
SubtitleTracks any `json:"subtitleTracks,omitempty"`
|
||||
VideoInfo any `json:"videoInfo,omitempty"`
|
||||
}
|
||||
|
||||
// LibrarySyncResponse is returned after syncing library items.
|
||||
type LibrarySyncResponse struct {
|
||||
Synced int `json:"synced"`
|
||||
Matched int `json:"matched"`
|
||||
Removed int `json:"removed"`
|
||||
}
|
||||
|
|
|
|||
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"
|
||||
}
|
||||
|
|
@ -158,6 +158,11 @@ func configDownloads(cfg *config.Config) error {
|
|||
cfg.Download.PreferredMethod = "auto"
|
||||
}
|
||||
|
||||
validQualities := map[string]bool{"": true, "720p": true, "1080p": true, "2160p": true}
|
||||
if !validQualities[cfg.Download.PreferredQuality] {
|
||||
cfg.Download.PreferredQuality = ""
|
||||
}
|
||||
|
||||
err := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewInput().
|
||||
|
|
@ -172,6 +177,16 @@ func configDownloads(cfg *config.Config) error {
|
|||
huh.NewOption("Usenet only (requires Pro)", "usenet"),
|
||||
).
|
||||
Value(&cfg.Download.PreferredMethod),
|
||||
huh.NewSelect[string]().
|
||||
Title("Preferred quality").
|
||||
Description("Hint for automatic torrent selection").
|
||||
Options(
|
||||
huh.NewOption("Any (best available)", ""),
|
||||
huh.NewOption("720p", "720p"),
|
||||
huh.NewOption("1080p", "1080p"),
|
||||
huh.NewOption("2160p (4K)", "2160p"),
|
||||
).
|
||||
Value(&cfg.Download.PreferredQuality),
|
||||
huh.NewSelect[string]().
|
||||
Title("Max concurrent downloads").
|
||||
Options(
|
||||
|
|
|
|||
|
|
@ -14,7 +14,9 @@ import (
|
|||
"github.com/google/uuid"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/torrentclaw/torrentclaw-cli/internal/agent"
|
||||
"github.com/torrentclaw/torrentclaw-cli/internal/arr"
|
||||
"github.com/torrentclaw/torrentclaw-cli/internal/config"
|
||||
"github.com/torrentclaw/torrentclaw-cli/internal/mediaserver"
|
||||
)
|
||||
|
||||
func newInitCmd() *cobra.Command {
|
||||
|
|
@ -52,6 +54,7 @@ func runInit(apiURLOverride string) error {
|
|||
bold := color.New(color.Bold)
|
||||
green := color.New(color.FgGreen)
|
||||
cyan := color.New(color.FgCyan)
|
||||
dim := color.New(color.FgHiBlack)
|
||||
|
||||
fmt.Println()
|
||||
bold.Println(" unarr init")
|
||||
|
|
@ -140,23 +143,73 @@ func runInit(apiURLOverride string) error {
|
|||
// ── Step 2/3: Download directory ────────────────────────────────
|
||||
|
||||
downloadDir := cfg.Download.Dir
|
||||
|
||||
// Detect media servers and library paths
|
||||
detected := mediaserver.Detect()
|
||||
if len(detected.Servers) > 0 {
|
||||
for _, s := range detected.Servers {
|
||||
cyan.Printf(" Detected %s at %s\n", s.Name, s.URL)
|
||||
}
|
||||
if len(detected.Paths) > 0 {
|
||||
dim.Printf(" Found media libraries: %s\n", strings.Join(detected.Paths, ", "))
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// If no dir yet and we detected media paths, offer a Select; otherwise show Input
|
||||
needsInput := true
|
||||
if downloadDir == "" && len(detected.Paths) > 0 {
|
||||
var options []huh.Option[string]
|
||||
for _, p := range detected.Paths {
|
||||
options = append(options, huh.NewOption(p, p))
|
||||
}
|
||||
if parent := mediaserver.ParentDir(detected.Paths); parent != "" {
|
||||
options = append(options, huh.NewOption(parent+" (parent directory)", parent))
|
||||
}
|
||||
options = append(options, huh.NewOption(defaultDownloadDir()+" (default)", defaultDownloadDir()))
|
||||
options = append(options, huh.NewOption("Custom path...", "__custom__"))
|
||||
|
||||
downloadDir = detected.Paths[0]
|
||||
err = huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[string]().
|
||||
Title("Step 2/3 — Download Directory").
|
||||
Description("Detected media libraries on your system").
|
||||
Options(options...).
|
||||
Value(&downloadDir),
|
||||
),
|
||||
).Run()
|
||||
if err != nil {
|
||||
if errors.Is(err, huh.ErrUserAborted) {
|
||||
fmt.Println("\n Init cancelled.")
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
needsInput = downloadDir == "__custom__"
|
||||
if needsInput {
|
||||
downloadDir = defaultDownloadDir()
|
||||
}
|
||||
}
|
||||
if downloadDir == "" {
|
||||
downloadDir = defaultDownloadDir()
|
||||
}
|
||||
err = huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewInput().
|
||||
Title("Step 2/3 — Download Directory").
|
||||
Description("Where should downloaded files be saved?").
|
||||
Value(&downloadDir),
|
||||
),
|
||||
).Run()
|
||||
if err != nil {
|
||||
if errors.Is(err, huh.ErrUserAborted) {
|
||||
fmt.Println("\n Init cancelled.")
|
||||
return nil
|
||||
if needsInput {
|
||||
err = huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewInput().
|
||||
Title("Step 2/3 — Download Directory").
|
||||
Description("Where should downloaded files be saved?").
|
||||
Value(&downloadDir),
|
||||
),
|
||||
).Run()
|
||||
if err != nil {
|
||||
if errors.Is(err, huh.ErrUserAborted) {
|
||||
fmt.Println("\n Init cancelled.")
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
return err
|
||||
}
|
||||
downloadDir = expandHome(strings.TrimSpace(downloadDir))
|
||||
|
||||
|
|
@ -226,6 +279,60 @@ func runInit(apiURLOverride string) error {
|
|||
}
|
||||
}
|
||||
|
||||
// ── Debrid auto-detection from *arr ─────────────────────────────
|
||||
|
||||
if resp.User.IsPro {
|
||||
debridTokens := detectDebridFromArr(dim)
|
||||
if len(debridTokens) > 0 {
|
||||
fmt.Println()
|
||||
cyan.Printf(" Found %d debrid token(s) from your *arr setup:\n", len(debridTokens))
|
||||
for _, dt := range debridTokens {
|
||||
masked := dt.Token
|
||||
if len(masked) > 8 {
|
||||
masked = masked[:8] + "..."
|
||||
}
|
||||
fmt.Printf(" %s (%s) — %s\n", dt.Provider, dt.Name, masked)
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
var configureDebrid bool
|
||||
err = huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewConfirm().
|
||||
Title("Configure debrid automatically?").
|
||||
Description("Validates and saves the token to your unarr account").
|
||||
Affirmative("Yes, configure").
|
||||
Negative("No, skip").
|
||||
Value(&configureDebrid),
|
||||
),
|
||||
).Run()
|
||||
if err == nil && configureDebrid {
|
||||
for _, dt := range debridTokens {
|
||||
fmt.Printf(" Configuring %s... ", dt.Provider)
|
||||
result, err := ac.ConfigureDebrid(context.Background(), agent.ConfigureDebridRequest{
|
||||
Provider: dt.Provider,
|
||||
Token: dt.Token,
|
||||
})
|
||||
if err != nil {
|
||||
color.New(color.FgYellow).Printf("failed: %s\n", err)
|
||||
} else if result.Success {
|
||||
green.Printf("OK")
|
||||
if result.Account.Username != "" {
|
||||
fmt.Printf(" (%s", result.Account.Username)
|
||||
if result.Account.Premium {
|
||||
fmt.Print(", premium")
|
||||
}
|
||||
fmt.Print(")")
|
||||
}
|
||||
fmt.Println()
|
||||
} else if result.Error != "" {
|
||||
color.New(color.FgYellow).Printf("failed: %s\n", result.Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Summary ─────────────────────────────────────────────────────
|
||||
|
||||
fmt.Println()
|
||||
|
|
@ -264,3 +371,31 @@ func runInit(apiURLOverride string) error {
|
|||
|
||||
return nil
|
||||
}
|
||||
|
||||
// detectDebridFromArr does a lightweight scan for *arr instances and extracts
|
||||
// debrid tokens from their download client configs.
|
||||
func detectDebridFromArr(dim *color.Color) []arr.DebridToken {
|
||||
dim.Println(" Scanning for *arr instances with debrid...")
|
||||
|
||||
instances := arr.Discover()
|
||||
if len(instances) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var tokens []arr.DebridToken
|
||||
for _, inst := range instances {
|
||||
if inst.App == "prowlarr" || inst.APIKey == "" {
|
||||
continue
|
||||
}
|
||||
client := arr.NewClient(inst.URL, inst.APIKey)
|
||||
dcs, _ := client.DownloadClients()
|
||||
if len(dcs) == 0 {
|
||||
continue
|
||||
}
|
||||
tokens = append(tokens, arr.ExtractDebridTokens(dcs, func(id int) []arr.Field {
|
||||
fields, _ := client.DownloadClientDetails(id)
|
||||
return fields
|
||||
})...)
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
|
|
|
|||
701
internal/cmd/migrate.go
Normal file
701
internal/cmd/migrate.go
Normal file
|
|
@ -0,0 +1,701 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/fatih/color"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/torrentclaw/torrentclaw-cli/internal/agent"
|
||||
"github.com/torrentclaw/torrentclaw-cli/internal/arr"
|
||||
"github.com/torrentclaw/torrentclaw-cli/internal/config"
|
||||
"github.com/torrentclaw/torrentclaw-cli/internal/mediaserver"
|
||||
)
|
||||
|
||||
func newMigrateCmd() *cobra.Command {
|
||||
var (
|
||||
dryRun bool
|
||||
skipWanted bool
|
||||
radarrURL string
|
||||
radarrKey string
|
||||
sonarrURL string
|
||||
sonarrKey string
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "migrate",
|
||||
Short: "[pre-beta] Import settings from Sonarr, Radarr, and Prowlarr",
|
||||
Long: `[PRE-BETA] This feature is under active development and may change.
|
||||
|
||||
Scans for existing *arr instances, imports your library preferences,
|
||||
and queues downloads for wanted content — replacing your entire *arr stack.
|
||||
|
||||
Detects instances automatically via Docker, config files, and network scan.
|
||||
You can also provide connection details manually with flags.
|
||||
|
||||
This command is read-only for your *arr apps — it only reads data,
|
||||
never modifies them.
|
||||
|
||||
Config file: ~/.config/unarr/config.toml`,
|
||||
Example: ` unarr migrate # Auto-detect and migrate
|
||||
unarr migrate --dry-run # Preview without applying changes
|
||||
unarr migrate --radarr-url http://localhost:7878 --radarr-key abc123`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runMigrate(migrateOpts{
|
||||
DryRun: dryRun,
|
||||
SkipWanted: skipWanted,
|
||||
RadarrURL: radarrURL,
|
||||
RadarrKey: radarrKey,
|
||||
SonarrURL: sonarrURL,
|
||||
SonarrKey: sonarrKey,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "preview changes without applying")
|
||||
cmd.Flags().BoolVar(&skipWanted, "skip-wanted", false, "don't import wanted list")
|
||||
cmd.Flags().StringVar(&radarrURL, "radarr-url", "", "Radarr URL (skip auto-detection)")
|
||||
cmd.Flags().StringVar(&radarrKey, "radarr-key", "", "Radarr API key")
|
||||
cmd.Flags().StringVar(&sonarrURL, "sonarr-url", "", "Sonarr URL (skip auto-detection)")
|
||||
cmd.Flags().StringVar(&sonarrKey, "sonarr-key", "", "Sonarr API key")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
type migrateOpts struct {
|
||||
DryRun bool
|
||||
SkipWanted bool
|
||||
RadarrURL string
|
||||
RadarrKey string
|
||||
SonarrURL string
|
||||
SonarrKey string
|
||||
}
|
||||
|
||||
func runMigrate(opts migrateOpts) error {
|
||||
// JSON mode: skip interactive parts, text → stderr, JSON → stdout
|
||||
jsonMode := jsonOut && opts.DryRun
|
||||
if !jsonMode && !isTerminal() {
|
||||
return fmt.Errorf("interactive mode requires a terminal")
|
||||
}
|
||||
|
||||
// In JSON mode, all progress text goes to stderr so stdout is clean JSON
|
||||
out := os.Stdout
|
||||
if jsonMode {
|
||||
out = os.Stderr
|
||||
}
|
||||
|
||||
bold := color.New(color.Bold)
|
||||
green := color.New(color.FgGreen)
|
||||
yellow := color.New(color.FgYellow)
|
||||
dim := color.New(color.FgHiBlack)
|
||||
cyan := color.New(color.FgCyan)
|
||||
|
||||
// Point all color writers to the chosen output
|
||||
bold.SetWriter(out)
|
||||
green.SetWriter(out)
|
||||
yellow.SetWriter(out)
|
||||
dim.SetWriter(out)
|
||||
cyan.SetWriter(out)
|
||||
|
||||
// Shorthand for writing to the output stream (not stdout in JSON mode)
|
||||
pr := func(format string, a ...any) { fmt.Fprintf(out, format, a...) }
|
||||
ln := func(a ...any) { fmt.Fprintln(out, a...) }
|
||||
|
||||
cfg := loadConfig()
|
||||
|
||||
// Check unarr is initialized
|
||||
if cfg.Auth.APIKey == "" {
|
||||
return fmt.Errorf("unarr is not configured yet — run 'unarr init' first")
|
||||
}
|
||||
|
||||
ln()
|
||||
bold.Println(" unarr migrate")
|
||||
yellow.Println(" [pre-beta] This feature is under active development.")
|
||||
ln()
|
||||
|
||||
// ── Phase 1: Discover instances ─────────────────────────────────
|
||||
|
||||
instances := discoverInstances(opts, dim)
|
||||
|
||||
if len(instances) == 0 {
|
||||
ln(" No *arr instances found automatically.")
|
||||
ln()
|
||||
|
||||
// Offer manual entry
|
||||
manual, err := manualInstanceEntry()
|
||||
if err != nil {
|
||||
if errors.Is(err, huh.ErrUserAborted) {
|
||||
ln("\n Migration cancelled.")
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
instances = manual
|
||||
}
|
||||
|
||||
if len(instances) == 0 {
|
||||
ln(" No instances to migrate from. Exiting.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Verify all instances and collect API keys where missing
|
||||
instances, err := verifyInstances(instances)
|
||||
if err != nil {
|
||||
if errors.Is(err, huh.ErrUserAborted) {
|
||||
ln("\n Migration cancelled.")
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// ── Phase 2: Extract data ───────────────────────────────────────
|
||||
|
||||
ln()
|
||||
dim.Println(" Fetching library data...")
|
||||
ln()
|
||||
|
||||
var (
|
||||
movies []arr.Movie
|
||||
series []arr.Series
|
||||
radarrProfiles []arr.QualityProfile
|
||||
sonarrProfiles []arr.QualityProfile
|
||||
radarrFolders []arr.RootFolder
|
||||
sonarrFolders []arr.RootFolder
|
||||
indexers []arr.Indexer
|
||||
downloadClients []arr.DownloadClient
|
||||
historyRecords []arr.HistoryRecord
|
||||
blocklistItems []arr.BlocklistItem
|
||||
)
|
||||
|
||||
// First pass: discover extra instances from Prowlarr before fetching data
|
||||
urlSet := make(map[string]bool, len(instances))
|
||||
for _, inst := range instances {
|
||||
urlSet[strings.ToLower(inst.URL)] = true
|
||||
}
|
||||
|
||||
var extraInstances []arr.Instance
|
||||
for _, inst := range instances {
|
||||
if inst.App != "prowlarr" {
|
||||
continue
|
||||
}
|
||||
client := arr.NewClient(inst.URL, inst.APIKey)
|
||||
if idx, err := client.Indexers(); err == nil {
|
||||
indexers = idx
|
||||
}
|
||||
extra := arr.DiscoverFromProwlarr(inst.URL, inst.APIKey)
|
||||
for _, e := range extra {
|
||||
key := strings.ToLower(e.URL)
|
||||
if !urlSet[key] {
|
||||
urlSet[key] = true
|
||||
extraInstances = append(extraInstances, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify and append Prowlarr-discovered instances
|
||||
for i := range extraInstances {
|
||||
if err := arr.Verify(&extraInstances[i]); err == nil {
|
||||
instances = append(instances, extraInstances[i])
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: fetch data from all Sonarr/Radarr instances
|
||||
for _, inst := range instances {
|
||||
client := arr.NewClient(inst.URL, inst.APIKey)
|
||||
|
||||
switch inst.App {
|
||||
case "radarr":
|
||||
if m, err := client.Movies(); err == nil {
|
||||
movies = m
|
||||
} else {
|
||||
yellow.Printf(" Warning: could not fetch Radarr movies: %s\n", err)
|
||||
}
|
||||
if p, err := client.QualityProfiles(); err == nil {
|
||||
radarrProfiles = p
|
||||
}
|
||||
if f, err := client.RootFolders(); err == nil {
|
||||
radarrFolders = f
|
||||
}
|
||||
if d, err := client.DownloadClients(); err == nil {
|
||||
downloadClients = append(downloadClients, d...)
|
||||
}
|
||||
if h, err := client.History(250); err == nil {
|
||||
historyRecords = append(historyRecords, h...)
|
||||
}
|
||||
if b, err := client.Blocklist(250); err == nil {
|
||||
blocklistItems = append(blocklistItems, b...)
|
||||
}
|
||||
|
||||
case "sonarr":
|
||||
if s, err := client.Series(); err == nil {
|
||||
series = s
|
||||
} else {
|
||||
yellow.Printf(" Warning: could not fetch Sonarr series: %s\n", err)
|
||||
}
|
||||
if p, err := client.QualityProfiles(); err == nil {
|
||||
sonarrProfiles = p
|
||||
}
|
||||
if f, err := client.RootFolders(); err == nil {
|
||||
sonarrFolders = f
|
||||
}
|
||||
if d, err := client.DownloadClients(); err == nil {
|
||||
downloadClients = append(downloadClients, d...)
|
||||
}
|
||||
if h, err := client.History(250); err == nil {
|
||||
historyRecords = append(historyRecords, h...)
|
||||
}
|
||||
if b, err := client.Blocklist(250); err == nil {
|
||||
blocklistItems = append(blocklistItems, b...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result := arr.BuildMigrationResult(
|
||||
movies, series,
|
||||
radarrProfiles, sonarrProfiles,
|
||||
radarrFolders, sonarrFolders,
|
||||
indexers, downloadClients,
|
||||
)
|
||||
|
||||
// Extract exclusion hashes from history and blocklist
|
||||
result.BlocklistedHashes = arr.ExtractBlocklistedHashes(blocklistItems)
|
||||
result.DownloadedHashes = arr.ExtractDownloadedHashes(historyRecords)
|
||||
|
||||
// Extract debrid tokens from download clients (once, not per-instance)
|
||||
if len(downloadClients) > 0 {
|
||||
// Use the first available Sonarr/Radarr client for fetching field details
|
||||
var fieldsClient *arr.Client
|
||||
for _, inst := range instances {
|
||||
if inst.App != "prowlarr" && inst.APIKey != "" {
|
||||
fieldsClient = arr.NewClient(inst.URL, inst.APIKey)
|
||||
break
|
||||
}
|
||||
}
|
||||
if fieldsClient != nil {
|
||||
result.DebridTokens = arr.ExtractDebridTokens(downloadClients, func(id int) []arr.Field {
|
||||
fields, _ := fieldsClient.DownloadClientDetails(id)
|
||||
return fields
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Detect media servers
|
||||
detected := mediaserver.Detect()
|
||||
for _, s := range detected.Servers {
|
||||
result.MediaServers = append(result.MediaServers, fmt.Sprintf("%s at %s", s.Name, s.URL))
|
||||
}
|
||||
|
||||
// ── Phase 3: Show instances table ───────────────────────────────
|
||||
|
||||
green.Printf(" ✓ Found %d instance(s):\n", len(instances))
|
||||
ln()
|
||||
pr(" %-12s %-35s %-14s %s\n", "App", "URL", "Source", "Library")
|
||||
dim.Printf(" %-12s %-35s %-14s %s\n", "───", "───", "──────", "───────")
|
||||
|
||||
for _, inst := range instances {
|
||||
lib := ""
|
||||
switch inst.App {
|
||||
case "radarr":
|
||||
wanted := len(result.WantedMovies)
|
||||
lib = fmt.Sprintf("%d movies (%d wanted)", result.TotalMovies, wanted)
|
||||
case "sonarr":
|
||||
wanted := len(result.WantedSeries)
|
||||
lib = fmt.Sprintf("%d series (%d wanted)", result.TotalSeries, wanted)
|
||||
case "prowlarr":
|
||||
lib = fmt.Sprintf("%d indexers", result.IndexerCount)
|
||||
}
|
||||
pr(" %-12s %-35s %-14s %s\n", inst.App, inst.URL, inst.Source, lib)
|
||||
}
|
||||
|
||||
// ── Phase 4: Migration preview ──────────────────────────────────
|
||||
|
||||
ln()
|
||||
ln(" ──────────────────────────────────────────────────────")
|
||||
ln()
|
||||
bold.Println(" Migration preview:")
|
||||
ln()
|
||||
|
||||
// Config changes
|
||||
bold.Println(" Config:")
|
||||
if result.MoviesDir != "" {
|
||||
pr(" Movies directory %-25s", result.MoviesDir)
|
||||
dim.Println(" (from Radarr root folder)")
|
||||
}
|
||||
if result.TVShowsDir != "" {
|
||||
pr(" TV Shows directory %-25s", result.TVShowsDir)
|
||||
dim.Println(" (from Sonarr root folder)")
|
||||
}
|
||||
if result.Quality != "" {
|
||||
pr(" Preferred quality %-25s", result.Quality)
|
||||
dim.Printf(" (from profile %q)\n", result.QualitySource)
|
||||
}
|
||||
if result.OrganizeEnabled {
|
||||
pr(" Auto-organize %-25s\n", "enabled")
|
||||
}
|
||||
|
||||
// Docker path warning
|
||||
if arr.HasDockerPaths(result) {
|
||||
ln()
|
||||
yellow.Println(" ⚠ These paths appear to be Docker container paths.")
|
||||
yellow.Println(" Your host paths may differ — verify after migration.")
|
||||
}
|
||||
|
||||
// Wanted list
|
||||
totalWanted := len(result.WantedMovies) + len(result.WantedSeries)
|
||||
if totalWanted > 0 && !opts.SkipWanted {
|
||||
ln()
|
||||
bold.Printf(" Downloads to queue: %d items\n", totalWanted)
|
||||
if len(result.WantedMovies) > 0 {
|
||||
pr(" %d movies", len(result.WantedMovies))
|
||||
dim.Println(" (monitored, not yet downloaded)")
|
||||
}
|
||||
if len(result.WantedSeries) > 0 {
|
||||
pr(" %d TV shows", len(result.WantedSeries))
|
||||
dim.Println(" (monitored, incomplete episodes)")
|
||||
}
|
||||
}
|
||||
|
||||
// Exclusions
|
||||
totalExcluded := len(result.BlocklistedHashes) + len(result.DownloadedHashes)
|
||||
if totalExcluded > 0 {
|
||||
ln()
|
||||
bold.Println(" Exclusions:")
|
||||
if len(result.DownloadedHashes) > 0 {
|
||||
pr(" %d already downloaded", len(result.DownloadedHashes))
|
||||
dim.Println(" (from *arr history — won't re-download)")
|
||||
}
|
||||
if len(result.BlocklistedHashes) > 0 {
|
||||
pr(" %d blocklisted", len(result.BlocklistedHashes))
|
||||
dim.Println(" (rejected releases — will be skipped)")
|
||||
}
|
||||
}
|
||||
|
||||
// Debrid tokens
|
||||
if len(result.DebridTokens) > 0 {
|
||||
ln()
|
||||
bold.Println(" Debrid tokens found:")
|
||||
for _, dt := range result.DebridTokens {
|
||||
masked := dt.Token
|
||||
if len(masked) > 8 {
|
||||
masked = masked[:8] + "..."
|
||||
}
|
||||
pr(" %s (%s) %s\n", dt.Provider, dt.Name, masked)
|
||||
}
|
||||
dim.Println(" Configure via: unarr config connection (or web dashboard)")
|
||||
}
|
||||
|
||||
// Media servers
|
||||
if len(result.MediaServers) > 0 {
|
||||
ln()
|
||||
bold.Println(" Media servers detected:")
|
||||
for _, ms := range result.MediaServers {
|
||||
green.Printf(" ✓ %s\n", ms)
|
||||
}
|
||||
dim.Println(" These will keep working with your existing library.")
|
||||
}
|
||||
|
||||
// Not needed anymore
|
||||
if result.IndexerCount > 0 || len(result.DownloadClients) > 0 {
|
||||
ln()
|
||||
bold.Println(" Not needed anymore:")
|
||||
if result.IndexerCount > 0 {
|
||||
pr(" %d indexers", result.IndexerCount)
|
||||
dim.Println(" (unarr searches 30+ sources automatically)")
|
||||
}
|
||||
if len(result.DownloadClients) > 0 {
|
||||
// Deduplicate client names
|
||||
seen := map[string]bool{}
|
||||
var names []string
|
||||
for _, n := range result.DownloadClients {
|
||||
if !seen[n] {
|
||||
seen[n] = true
|
||||
names = append(names, n)
|
||||
}
|
||||
}
|
||||
pr(" %s", strings.Join(names, ", "))
|
||||
dim.Println(" (unarr downloads directly via torrent/debrid/usenet)")
|
||||
}
|
||||
}
|
||||
|
||||
ln()
|
||||
ln(" ──────────────────────────────────────────────────────")
|
||||
ln()
|
||||
|
||||
// ── Phase 5: Confirm & apply ────────────────────────────────────
|
||||
|
||||
if opts.DryRun {
|
||||
if jsonMode {
|
||||
// JSON export for scripting — write to real stdout
|
||||
jsonBytes, _ := json.MarshalIndent(result, "", " ")
|
||||
_, _ = os.Stdout.Write(jsonBytes)
|
||||
_, _ = os.Stdout.Write([]byte("\n"))
|
||||
} else {
|
||||
cyan.Println(" Dry run — no changes applied.")
|
||||
ln()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var confirm bool
|
||||
err = huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewConfirm().
|
||||
Title("Apply these changes?").
|
||||
Value(&confirm),
|
||||
),
|
||||
).Run()
|
||||
if err != nil {
|
||||
if errors.Is(err, huh.ErrUserAborted) {
|
||||
ln("\n Migration cancelled.")
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if !confirm {
|
||||
dim.Println(" No changes applied.")
|
||||
ln()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Apply config changes (only overwrite if currently empty)
|
||||
changed := false
|
||||
if result.MoviesDir != "" && cfg.Organize.MoviesDir == "" {
|
||||
cfg.Organize.MoviesDir = result.MoviesDir
|
||||
changed = true
|
||||
}
|
||||
if result.TVShowsDir != "" && cfg.Organize.TVShowsDir == "" {
|
||||
cfg.Organize.TVShowsDir = result.TVShowsDir
|
||||
changed = true
|
||||
}
|
||||
if result.OrganizeEnabled && !cfg.Organize.Enabled {
|
||||
cfg.Organize.Enabled = true
|
||||
changed = true
|
||||
}
|
||||
if result.Quality != "" && cfg.Download.PreferredQuality == "" {
|
||||
cfg.Download.PreferredQuality = result.Quality
|
||||
changed = true
|
||||
}
|
||||
|
||||
if changed {
|
||||
if err := cfg.ValidatePaths(); err != nil {
|
||||
return fmt.Errorf("unsafe configuration: %w", err)
|
||||
}
|
||||
|
||||
configPath := configFilePath()
|
||||
if err := saveConfig(cfg, configPath); err != nil {
|
||||
return fmt.Errorf("save config: %w", err)
|
||||
}
|
||||
green.Println(" ✓ Configuration updated")
|
||||
}
|
||||
|
||||
// Import wanted list
|
||||
if totalWanted > 0 && !opts.SkipWanted {
|
||||
allWanted := make([]arr.WantedItem, 0, len(result.WantedMovies)+len(result.WantedSeries))
|
||||
allWanted = append(allWanted, result.WantedMovies...)
|
||||
allWanted = append(allWanted, result.WantedSeries...)
|
||||
|
||||
// Combine blocklisted + already-downloaded hashes to exclude
|
||||
excludeHashes := make([]string, 0, len(result.BlocklistedHashes)+len(result.DownloadedHashes))
|
||||
excludeHashes = append(excludeHashes, result.BlocklistedHashes...)
|
||||
excludeHashes = append(excludeHashes, result.DownloadedHashes...)
|
||||
|
||||
if err := importWantedList(cfg, allWanted, excludeHashes, green, yellow, dim); err != nil {
|
||||
yellow.Printf(" Warning: could not queue downloads: %s\n", err)
|
||||
ln(" You can queue them manually from the web dashboard.")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Phase 6: Next steps ─────────────────────────────────────────
|
||||
|
||||
ln()
|
||||
ln(" Your *arr apps are still running. When you're ready:")
|
||||
ln()
|
||||
ln(" 1. Verify downloads are working: " + bold.Sprint("unarr status"))
|
||||
ln(" 2. Stop *arr services: " + bold.Sprint("docker stop sonarr radarr prowlarr"))
|
||||
ln(" 3. Keep your media server: Plex / Jellyfin / Emby stays as-is")
|
||||
ln()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Discovery helpers ───────────────────────────────────────────────
|
||||
|
||||
func discoverInstances(opts migrateOpts, dim *color.Color) []arr.Instance {
|
||||
var instances []arr.Instance
|
||||
|
||||
// Manual flags take priority
|
||||
hasManualFlags := opts.RadarrURL != "" || opts.SonarrURL != ""
|
||||
if hasManualFlags {
|
||||
if opts.RadarrURL != "" {
|
||||
instances = append(instances, arr.Instance{
|
||||
App: "radarr",
|
||||
URL: opts.RadarrURL,
|
||||
APIKey: opts.RadarrKey,
|
||||
Source: "manual",
|
||||
})
|
||||
}
|
||||
if opts.SonarrURL != "" {
|
||||
instances = append(instances, arr.Instance{
|
||||
App: "sonarr",
|
||||
URL: opts.SonarrURL,
|
||||
APIKey: opts.SonarrKey,
|
||||
Source: "manual",
|
||||
})
|
||||
}
|
||||
return instances
|
||||
}
|
||||
|
||||
// Auto-discovery
|
||||
dim.Println(" Scanning for *arr instances...")
|
||||
return arr.Discover()
|
||||
}
|
||||
|
||||
func verifyInstances(instances []arr.Instance) ([]arr.Instance, error) {
|
||||
var verified []arr.Instance
|
||||
for _, inst := range instances {
|
||||
if inst.APIKey == "" {
|
||||
// Ask user for API key
|
||||
var key string
|
||||
err := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewInput().
|
||||
Title(fmt.Sprintf("API key for %s (%s)", inst.App, inst.URL)).
|
||||
Description("Found via " + inst.Source + " but no API key available").
|
||||
Placeholder("Enter API key or leave empty to skip").
|
||||
Value(&key),
|
||||
),
|
||||
).Run()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
continue // skip this instance
|
||||
}
|
||||
inst.APIKey = key
|
||||
}
|
||||
|
||||
if err := arr.Verify(&inst); err != nil {
|
||||
color.New(color.FgYellow).Printf(" Warning: %s at %s — %s (skipping)\n", inst.App, inst.URL, err)
|
||||
continue
|
||||
}
|
||||
verified = append(verified, inst)
|
||||
}
|
||||
return verified, nil
|
||||
}
|
||||
|
||||
func manualInstanceEntry() ([]arr.Instance, error) {
|
||||
var radarrURL, radarrKey, sonarrURL, sonarrKey string
|
||||
|
||||
err := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewInput().
|
||||
Title("Radarr URL").
|
||||
Description("Leave empty to skip").
|
||||
Placeholder("http://localhost:7878").
|
||||
Value(&radarrURL),
|
||||
huh.NewInput().
|
||||
Title("Radarr API key").
|
||||
Value(&radarrKey),
|
||||
huh.NewInput().
|
||||
Title("Sonarr URL").
|
||||
Description("Leave empty to skip").
|
||||
Placeholder("http://localhost:8989").
|
||||
Value(&sonarrURL),
|
||||
huh.NewInput().
|
||||
Title("Sonarr API key").
|
||||
Value(&sonarrKey),
|
||||
),
|
||||
).Run()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var instances []arr.Instance
|
||||
radarrURL = strings.TrimSpace(radarrURL)
|
||||
sonarrURL = strings.TrimSpace(sonarrURL)
|
||||
|
||||
if radarrURL != "" && strings.TrimSpace(radarrKey) != "" {
|
||||
instances = append(instances, arr.Instance{
|
||||
App: "radarr",
|
||||
URL: radarrURL,
|
||||
APIKey: strings.TrimSpace(radarrKey),
|
||||
Source: "manual",
|
||||
})
|
||||
}
|
||||
if sonarrURL != "" && strings.TrimSpace(sonarrKey) != "" {
|
||||
instances = append(instances, arr.Instance{
|
||||
App: "sonarr",
|
||||
URL: sonarrURL,
|
||||
APIKey: strings.TrimSpace(sonarrKey),
|
||||
Source: "manual",
|
||||
})
|
||||
}
|
||||
return instances, nil
|
||||
}
|
||||
|
||||
func importWantedList(cfg config.Config, items []arr.WantedItem, excludeHashes []string, green, yellow, dim *color.Color) error {
|
||||
apiURL := cfg.Auth.APIURL
|
||||
if apiURL == "" {
|
||||
apiURL = "https://torrentclaw.com"
|
||||
}
|
||||
|
||||
ac := agent.NewClient(apiURL, cfg.Auth.APIKey, "unarr/"+Version)
|
||||
|
||||
// Convert arr.WantedItem → agent.WantedItem
|
||||
agentItems := make([]agent.WantedItem, len(items))
|
||||
for i, item := range items {
|
||||
agentItems[i] = agent.WantedItem{
|
||||
TmdbID: item.TmdbID,
|
||||
ImdbID: item.ImdbID,
|
||||
Title: item.Title,
|
||||
Year: item.Year,
|
||||
Type: item.Type,
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := ac.BatchDownload(context.Background(), agent.BatchDownloadRequest{
|
||||
Items: agentItems,
|
||||
ExcludeHashes: excludeHashes,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
green.Printf(" ✓ %d downloads queued", resp.Queued)
|
||||
if resp.NotFound > 0 {
|
||||
fmt.Printf(" — %d not found in catalog", resp.NotFound)
|
||||
}
|
||||
if resp.AlreadyActive > 0 {
|
||||
fmt.Printf(" — %d already active", resp.AlreadyActive)
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
if resp.Queued > 0 {
|
||||
dim.Println(" They'll start when the daemon runs.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// configFilePath returns the config file path, respecting the --config flag.
|
||||
func configFilePath() string {
|
||||
if cfgFile != "" {
|
||||
return cfgFile
|
||||
}
|
||||
return config.FilePath()
|
||||
}
|
||||
|
||||
// saveConfig writes config to disk and updates the cached copy.
|
||||
func saveConfig(cfg config.Config, path string) error {
|
||||
if err := config.Save(cfg, path); err != nil {
|
||||
return err
|
||||
}
|
||||
appCfg = cfg
|
||||
return nil
|
||||
}
|
||||
|
|
@ -66,6 +66,8 @@ Source: https://github.com/torrentclaw/torrentclaw-cli`,
|
|||
initCmd.GroupID = "start"
|
||||
configCmd := newConfigCmd()
|
||||
configCmd.GroupID = "start"
|
||||
migrateCmd := newMigrateCmd()
|
||||
migrateCmd.GroupID = "start"
|
||||
|
||||
// Search & Discovery
|
||||
searchCmd := newSearchCmd()
|
||||
|
|
@ -109,10 +111,15 @@ Source: https://github.com/torrentclaw/torrentclaw-cli`,
|
|||
completionCmd := newCompletionCmd()
|
||||
completionCmd.GroupID = "system"
|
||||
|
||||
// Library
|
||||
scanCmd := newScanCmd()
|
||||
scanCmd.GroupID = "search"
|
||||
|
||||
rootCmd.AddCommand(
|
||||
// Getting Started
|
||||
initCmd,
|
||||
configCmd,
|
||||
migrateCmd,
|
||||
// Search & Discovery
|
||||
searchCmd,
|
||||
inspectCmd,
|
||||
|
|
@ -134,11 +141,12 @@ Source: https://github.com/torrentclaw/torrentclaw-cli`,
|
|||
selfUpdateCmd,
|
||||
versionCmd,
|
||||
completionCmd,
|
||||
// Library
|
||||
scanCmd,
|
||||
// Stubs for future commands
|
||||
newStubCmd("upgrade", "Find a better version of a torrent"),
|
||||
newStubCmd("moreseed", "Find same quality with more seeders"),
|
||||
newStubCmd("compare", "Compare two torrents side by side"),
|
||||
newStubCmd("scan", "Scan your media library for upgrades"),
|
||||
newStubCmd("add", "Search and add torrents to your client"),
|
||||
newStubCmd("monitor", "Watch for new episodes of a series"),
|
||||
newStubCmd("open", "Open content in the browser"),
|
||||
|
|
|
|||
340
internal/cmd/scan.go
Normal file
340
internal/cmd/scan.go
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sort"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/torrentclaw/torrentclaw-cli/internal/agent"
|
||||
"github.com/torrentclaw/torrentclaw-cli/internal/config"
|
||||
"github.com/torrentclaw/torrentclaw-cli/internal/library"
|
||||
)
|
||||
|
||||
func newScanCmd() *cobra.Command {
|
||||
var (
|
||||
workers int
|
||||
ffprobe string
|
||||
showStatus bool
|
||||
noSync bool
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "scan <path>",
|
||||
Short: "Scan your media library for quality analysis",
|
||||
Long: `Walk a folder recursively, analyze each video file with ffprobe,
|
||||
and sync the results to your TorrentClaw account.
|
||||
|
||||
After scanning, visit your Library page at torrentclaw.com/library
|
||||
to see available quality upgrades.`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if showStatus {
|
||||
return runScanStatus()
|
||||
}
|
||||
if len(args) == 0 {
|
||||
cfg := loadConfig()
|
||||
if cfg.Library.ScanPath != "" {
|
||||
args = append(args, cfg.Library.ScanPath)
|
||||
} else {
|
||||
return fmt.Errorf("usage: unarr scan <path>\n\nProvide a media folder to scan")
|
||||
}
|
||||
}
|
||||
return runScan(args[0], workers, ffprobe, noSync)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().IntVar(&workers, "workers", 0, "concurrent ffprobe workers (default: config or 8)")
|
||||
cmd.Flags().StringVar(&ffprobe, "ffprobe", "", "path to ffprobe binary")
|
||||
cmd.Flags().BoolVar(&showStatus, "status", false, "show summary of last scan")
|
||||
cmd.Flags().BoolVar(&noSync, "no-sync", false, "scan only, don't upload to server")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runScan(dirPath string, workers int, ffprobePath string, noSync bool) error {
|
||||
// Validate path
|
||||
info, err := os.Stat(dirPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("path not found: %s", dirPath)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return fmt.Errorf("not a directory: %s", dirPath)
|
||||
}
|
||||
|
||||
cfg := loadConfig()
|
||||
|
||||
// Resolve workers: flag → config → default 8
|
||||
if workers == 0 {
|
||||
workers = cfg.Library.Workers
|
||||
}
|
||||
if workers == 0 {
|
||||
workers = 8
|
||||
}
|
||||
|
||||
// Resolve ffprobe path from flag → config
|
||||
if ffprobePath == "" {
|
||||
ffprobePath = cfg.Library.FFprobePath
|
||||
}
|
||||
|
||||
// Load existing cache for incremental scanning
|
||||
existing, _ := library.LoadCache()
|
||||
|
||||
// Context with signal handling
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
bold := color.New(color.Bold)
|
||||
bold.Printf("\n Scanning %s...\n\n", dirPath)
|
||||
|
||||
// Scan
|
||||
cache, err := library.Scan(ctx, dirPath, existing, library.ScanOptions{
|
||||
Workers: workers,
|
||||
FFprobePath: ffprobePath,
|
||||
Incremental: existing != nil,
|
||||
OnProgress: func(scanned, total int, current string) {
|
||||
// Truncate filename for display
|
||||
if len(current) > 50 {
|
||||
current = "..." + current[len(current)-47:]
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "\r Scanning %d/%d — %s\033[K", scanned, total, current)
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("scan failed: %w", err)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "\r\033[K") // clear progress line
|
||||
|
||||
// Save cache
|
||||
if err := library.SaveCache(cache); err != nil {
|
||||
return fmt.Errorf("save cache: %w", err)
|
||||
}
|
||||
|
||||
// Remember scan path in config
|
||||
if cfg.Library.ScanPath != dirPath {
|
||||
cfg.Library.ScanPath = dirPath
|
||||
_ = config.Save(cfg, cfgFile)
|
||||
}
|
||||
|
||||
// Print summary
|
||||
printScanSummary(cache)
|
||||
|
||||
// JSON output mode
|
||||
if jsonOut {
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(cache)
|
||||
}
|
||||
|
||||
// Sync to server
|
||||
if !noSync {
|
||||
return syncToServer(ctx, cfg, cache)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func syncToServer(ctx context.Context, cfg config.Config, cache *library.LibraryCache) error {
|
||||
apiKey := apiKeyFlag
|
||||
if apiKey == "" {
|
||||
apiKey = cfg.Auth.APIKey
|
||||
}
|
||||
if apiKey == "" {
|
||||
color.Yellow("\n ⚠ No API key configured. Run 'unarr init' to set up, or use --no-sync.")
|
||||
return nil
|
||||
}
|
||||
|
||||
ac := agent.NewClient(cfg.Auth.APIURL, apiKey, "unarr/"+Version)
|
||||
|
||||
// Build sync items from cache
|
||||
items := make([]agent.LibrarySyncItem, 0, len(cache.Items))
|
||||
for _, item := range cache.Items {
|
||||
if item.ScanError != "" {
|
||||
continue // skip items with scan errors
|
||||
}
|
||||
si := agent.LibrarySyncItem{
|
||||
FilePath: item.FilePath,
|
||||
FileName: item.FileName,
|
||||
FileSize: item.FileSize,
|
||||
Title: item.Title,
|
||||
Year: item.Year,
|
||||
ContentType: library.DeriveContentType(item),
|
||||
Season: item.Season,
|
||||
Episode: item.Episode,
|
||||
}
|
||||
|
||||
if item.MediaInfo != nil {
|
||||
if item.MediaInfo.Video != nil {
|
||||
si.Resolution = library.ResolveResolution(item.MediaInfo.Video.Height)
|
||||
si.VideoCodec = item.MediaInfo.Video.Codec
|
||||
si.HDR = item.MediaInfo.Video.HDR
|
||||
si.BitDepth = item.MediaInfo.Video.BitDepth
|
||||
}
|
||||
codec, channels := library.PrimaryAudioTrack(item.MediaInfo.Audio)
|
||||
si.AudioCodec = codec
|
||||
si.AudioChannels = channels
|
||||
si.AudioLanguages = library.AudioLanguages(item.MediaInfo.Audio)
|
||||
si.SubtitleLanguages = library.SubtitleLanguages(item.MediaInfo.Subtitles)
|
||||
si.AudioTracks = item.MediaInfo.Audio
|
||||
si.SubtitleTracks = item.MediaInfo.Subtitles
|
||||
si.VideoInfo = item.MediaInfo.Video
|
||||
}
|
||||
|
||||
items = append(items, si)
|
||||
}
|
||||
|
||||
if len(items) == 0 {
|
||||
color.Yellow("\n No valid items to sync.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Send in batches of 100
|
||||
const batchSize = 100
|
||||
totalSynced := 0
|
||||
totalMatched := 0
|
||||
totalRemoved := 0
|
||||
|
||||
for i := 0; i < len(items); i += batchSize {
|
||||
end := i + batchSize
|
||||
if end > len(items) {
|
||||
end = len(items)
|
||||
}
|
||||
batch := items[i:end]
|
||||
isLast := end >= len(items)
|
||||
|
||||
fmt.Fprintf(os.Stderr, "\r Syncing %d/%d items...\033[K", end, len(items))
|
||||
|
||||
resp, err := ac.SyncLibrary(ctx, agent.LibrarySyncRequest{
|
||||
Items: batch,
|
||||
ScanPath: cache.Path,
|
||||
IsLastBatch: isLast,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("sync failed: %w", err)
|
||||
}
|
||||
|
||||
totalSynced += resp.Synced
|
||||
totalMatched += resp.Matched
|
||||
totalRemoved += resp.Removed
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "\r\033[K")
|
||||
|
||||
green := color.New(color.FgGreen)
|
||||
green.Printf("\n ✓ Synced %d items (%d matched, %d removed)\n", totalSynced, totalMatched, totalRemoved)
|
||||
|
||||
apiURL := strings.TrimSuffix(cfg.Auth.APIURL, "/")
|
||||
fmt.Printf(" → View upgrades at %s/library\n\n", apiURL)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runScanStatus() error {
|
||||
cache, err := library.LoadCache()
|
||||
if err != nil {
|
||||
return fmt.Errorf("load cache: %w", err)
|
||||
}
|
||||
if cache == nil {
|
||||
return fmt.Errorf("no library scan found. Run 'unarr scan <path>' first")
|
||||
}
|
||||
|
||||
printScanSummary(cache)
|
||||
return nil
|
||||
}
|
||||
|
||||
func printScanSummary(cache *library.LibraryCache) {
|
||||
bold := color.New(color.Bold)
|
||||
dim := color.New(color.Faint)
|
||||
|
||||
total := len(cache.Items)
|
||||
errors := 0
|
||||
resCount := map[string]int{}
|
||||
hdrCount := map[string]int{}
|
||||
langCount := map[string]int{}
|
||||
|
||||
for _, item := range cache.Items {
|
||||
if item.ScanError != "" {
|
||||
errors++
|
||||
continue
|
||||
}
|
||||
if item.MediaInfo == nil || item.MediaInfo.Video == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
res := library.ResolveResolution(item.MediaInfo.Video.Height)
|
||||
if res == "" {
|
||||
res = "other"
|
||||
}
|
||||
resCount[res]++
|
||||
|
||||
hdr := item.MediaInfo.Video.HDR
|
||||
if hdr == "" {
|
||||
hdr = "SDR"
|
||||
}
|
||||
hdrCount[hdr]++
|
||||
|
||||
for _, lang := range item.MediaInfo.Languages {
|
||||
langCount[lang]++
|
||||
}
|
||||
}
|
||||
|
||||
bold.Printf("\n Library scan complete — %d files in %s\n", total, cache.Path)
|
||||
dim.Printf(" Scanned at: %s\n\n", cache.ScannedAt)
|
||||
|
||||
// Resolution table
|
||||
bold.Println(" Resolution Files")
|
||||
dim.Println(" ─────────────────────")
|
||||
for _, res := range []string{"2160p", "1080p", "720p", "480p", "other"} {
|
||||
if count, ok := resCount[res]; ok {
|
||||
fmt.Printf(" %-14s%d\n", res, count)
|
||||
}
|
||||
}
|
||||
|
||||
// HDR table
|
||||
fmt.Println()
|
||||
bold.Println(" HDR Files")
|
||||
dim.Println(" ─────────────────────")
|
||||
hdrOrder := []string{"DV+HDR10", "DV", "HDR10", "HLG", "SDR"}
|
||||
for _, hdr := range hdrOrder {
|
||||
if count, ok := hdrCount[hdr]; ok {
|
||||
fmt.Printf(" %-14s%d\n", hdr, count)
|
||||
}
|
||||
}
|
||||
|
||||
// Top languages
|
||||
if len(langCount) > 0 {
|
||||
fmt.Println()
|
||||
type langEntry struct {
|
||||
lang string
|
||||
count int
|
||||
}
|
||||
var langs []langEntry
|
||||
for l, c := range langCount {
|
||||
langs = append(langs, langEntry{l, c})
|
||||
}
|
||||
sort.Slice(langs, func(i, j int) bool { return langs[i].count > langs[j].count })
|
||||
top := langs
|
||||
if len(top) > 5 {
|
||||
top = top[:5]
|
||||
}
|
||||
parts := make([]string, len(top))
|
||||
for i, l := range top {
|
||||
parts[i] = fmt.Sprintf("%s (%d)", strings.ToUpper(l.lang), l.count)
|
||||
}
|
||||
bold.Print(" Top languages: ")
|
||||
fmt.Println(strings.Join(parts, ", "))
|
||||
}
|
||||
|
||||
if errors > 0 {
|
||||
fmt.Println()
|
||||
color.Yellow(" Scan errors: %d files (run with --verbose for details)", errors)
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@ type Config struct {
|
|||
Daemon DaemonConfig `toml:"daemon"`
|
||||
Notifications NotificationsConfig `toml:"notifications"`
|
||||
General GeneralConfig `toml:"general"`
|
||||
Library LibraryConfig `toml:"library"`
|
||||
}
|
||||
|
||||
type AuthConfig struct {
|
||||
|
|
@ -36,6 +37,7 @@ type AgentConfig struct {
|
|||
type DownloadConfig struct {
|
||||
Dir string `toml:"dir"`
|
||||
PreferredMethod string `toml:"preferred_method"`
|
||||
PreferredQuality string `toml:"preferred_quality"` // "2160p", "1080p", "720p" — hint for auto-selection
|
||||
MaxConcurrent int `toml:"max_concurrent"`
|
||||
MaxDownloadSpeed string `toml:"max_download_speed"` // e.g. "10MB", "500KB", "0" = unlimited
|
||||
MaxUploadSpeed string `toml:"max_upload_speed"` // e.g. "1MB", "0" = unlimited
|
||||
|
|
@ -62,6 +64,13 @@ type GeneralConfig struct {
|
|||
NoColor bool `toml:"no_color"`
|
||||
}
|
||||
|
||||
type LibraryConfig struct {
|
||||
ScanPath string `toml:"scan_path"` // remembered from last scan
|
||||
Workers int `toml:"workers"` // concurrent ffprobe (default 8)
|
||||
FFprobePath string `toml:"ffprobe_path"` // optional explicit path
|
||||
BackupDir string `toml:"backup_dir"` // for replaced files
|
||||
}
|
||||
|
||||
// Default returns a Config with sensible defaults.
|
||||
func Default() Config {
|
||||
return Config{
|
||||
|
|
|
|||
|
|
@ -280,6 +280,19 @@ func (m *Manager) processTask(ctx context.Context, task *Task) {
|
|||
task.FilePath = finalPath
|
||||
task.mu.Unlock()
|
||||
|
||||
// 4b. Handle upgrade replacement (mode = "upgrade")
|
||||
if task.ReplacePath != "" {
|
||||
backupDir := "" // uses default ~/.local/share/unarr/replaced/
|
||||
if err := replaceFile(task.ReplacePath, finalPath, backupDir); err != nil {
|
||||
log.Printf("[%s] replace warning: %v (keeping new file at %s)", task.ID[:8], err, finalPath)
|
||||
} else {
|
||||
task.mu.Lock()
|
||||
task.FilePath = task.ReplacePath
|
||||
task.mu.Unlock()
|
||||
log.Printf("[%s] upgraded: replaced %s", task.ID[:8], task.ReplacePath)
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Complete
|
||||
if method == MethodTorrent && m.cfg.Organize.Enabled {
|
||||
// Could add seeding here in the future
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
|
|
@ -136,6 +137,53 @@ func cleanTitle(title string) string {
|
|||
return t
|
||||
}
|
||||
|
||||
// replaceFile moves the old file to a backup dir, then moves the new file to the old path.
|
||||
// Used by upgrade downloads to replace an existing file with a better version.
|
||||
func replaceFile(oldPath, newPath, backupDir string) error {
|
||||
if _, err := os.Stat(oldPath); err != nil {
|
||||
return fmt.Errorf("original file not found: %w", err)
|
||||
}
|
||||
|
||||
if backupDir == "" {
|
||||
home, _ := os.UserHomeDir()
|
||||
backupDir = filepath.Join(home, ".local", "share", "unarr", "replaced")
|
||||
}
|
||||
if err := os.MkdirAll(backupDir, 0o755); err != nil {
|
||||
return fmt.Errorf("create backup dir: %w", err)
|
||||
}
|
||||
|
||||
// Move old file to backup (with timestamp to avoid collisions)
|
||||
base := filepath.Base(oldPath)
|
||||
ext := filepath.Ext(base)
|
||||
nameNoExt := strings.TrimSuffix(base, ext)
|
||||
backupName := fmt.Sprintf("%s.%d%s", nameNoExt, time.Now().Unix(), ext)
|
||||
backupPath := filepath.Join(backupDir, backupName)
|
||||
|
||||
if err := os.Rename(oldPath, backupPath); err != nil {
|
||||
// Cross-device: copy + delete
|
||||
if err := copyFile(oldPath, backupPath); err != nil {
|
||||
return fmt.Errorf("backup failed: %w", err)
|
||||
}
|
||||
os.Remove(oldPath)
|
||||
}
|
||||
|
||||
// Move new file to old path
|
||||
if err := os.MkdirAll(filepath.Dir(oldPath), 0o755); err != nil {
|
||||
return fmt.Errorf("create target dir: %w", err)
|
||||
}
|
||||
if err := os.Rename(newPath, oldPath); err != nil {
|
||||
// Cross-device: copy + delete
|
||||
if err := copyFile(newPath, oldPath); err != nil {
|
||||
// Rollback: restore backup
|
||||
os.Rename(backupPath, oldPath)
|
||||
return fmt.Errorf("replace failed: %w", err)
|
||||
}
|
||||
os.Remove(newPath)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyFile(src, dst string) error {
|
||||
s, err := os.Open(src)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -135,15 +135,17 @@ func (ss *StreamServer) Shutdown(ctx context.Context) error {
|
|||
}
|
||||
|
||||
func (ss *StreamServer) handler(w http.ResponseWriter, r *http.Request) {
|
||||
// CORS headers — allow web player from any origin (HTTPS site → localhost)
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Range")
|
||||
w.Header().Set("Access-Control-Expose-Headers", "Content-Length, Content-Range, Accept-Ranges")
|
||||
// CORS headers — only when browser sends Origin (HTTPS site → localhost)
|
||||
if origin := r.Header.Get("Origin"); origin != "" {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Range")
|
||||
w.Header().Set("Access-Control-Expose-Headers", "Content-Length, Content-Range, Accept-Ranges")
|
||||
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
reader := ss.provider.NewFileReader(r.Context())
|
||||
|
|
|
|||
|
|
@ -50,6 +50,8 @@ type Task struct {
|
|||
DirectFileName string // Original filename from direct URL
|
||||
NzbID string // Pre-resolved NZB ID (usenet)
|
||||
NzbPassword string // Password for encrypted NZB archives
|
||||
ReplacePath string // File to replace after download (upgrade mode)
|
||||
LibraryItemID int // Library item being upgraded
|
||||
|
||||
// Runtime state
|
||||
Status TaskStatus
|
||||
|
|
@ -88,6 +90,8 @@ func NewTaskFromAgent(at agent.Task) *Task {
|
|||
DirectFileName: at.DirectFileName,
|
||||
NzbID: at.NzbID,
|
||||
NzbPassword: at.NzbPassword,
|
||||
ReplacePath: at.ReplacePath,
|
||||
LibraryItemID: at.LibraryItemID,
|
||||
Mode: mode,
|
||||
Status: StatusClaimed,
|
||||
ClaimedAt: time.Now(),
|
||||
|
|
|
|||
86
internal/library/cache.go
Normal file
86
internal/library/cache.go
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
package library
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/torrentclaw/torrentclaw-cli/internal/config"
|
||||
)
|
||||
|
||||
// CachePath returns the default library cache file path.
|
||||
func CachePath() string {
|
||||
return filepath.Join(config.DataDir(), "library.json")
|
||||
}
|
||||
|
||||
// LoadCache reads the library cache from disk. Returns nil if file doesn't exist.
|
||||
func LoadCache() (*LibraryCache, error) {
|
||||
return LoadCacheFrom(CachePath())
|
||||
}
|
||||
|
||||
// LoadCacheFrom reads the library cache from a specific path.
|
||||
func LoadCacheFrom(path string) (*LibraryCache, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("read cache: %w", err)
|
||||
}
|
||||
|
||||
var cache LibraryCache
|
||||
if err := json.Unmarshal(data, &cache); err != nil {
|
||||
return nil, fmt.Errorf("parse cache: %w", err)
|
||||
}
|
||||
|
||||
if cache.Version != cacheVersion {
|
||||
return nil, nil // incompatible version, treat as missing
|
||||
}
|
||||
|
||||
return &cache, nil
|
||||
}
|
||||
|
||||
// SaveCache writes the library cache to disk atomically.
|
||||
func SaveCache(cache *LibraryCache) error {
|
||||
return SaveCacheTo(cache, CachePath())
|
||||
}
|
||||
|
||||
// SaveCacheTo writes the library cache to a specific path atomically.
|
||||
func SaveCacheTo(cache *LibraryCache, path string) error {
|
||||
cache.Version = cacheVersion
|
||||
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("create cache dir: %w", err)
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(cache, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode cache: %w", err)
|
||||
}
|
||||
|
||||
tmpPath := path + ".tmp"
|
||||
if err := os.WriteFile(tmpPath, data, 0o644); err != nil {
|
||||
return fmt.Errorf("write temp cache: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Rename(tmpPath, path); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return fmt.Errorf("rename cache: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// BuildCacheIndex creates a lookup map from filePath → index for incremental scanning.
|
||||
func BuildCacheIndex(cache *LibraryCache) map[string]int {
|
||||
if cache == nil {
|
||||
return nil
|
||||
}
|
||||
idx := make(map[string]int, len(cache.Items))
|
||||
for i, item := range cache.Items {
|
||||
idx[item.FilePath] = i
|
||||
}
|
||||
return idx
|
||||
}
|
||||
99
internal/library/cache_test.go
Normal file
99
internal/library/cache_test.go
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
package library
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSaveCacheAndLoad(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
path := filepath.Join(tmpDir, "library.json")
|
||||
|
||||
cache := &LibraryCache{
|
||||
Version: cacheVersion,
|
||||
ScannedAt: "2026-03-29T10:00:00Z",
|
||||
Path: "/media/movies",
|
||||
Items: []LibraryItem{
|
||||
{
|
||||
FilePath: "/media/movies/Inception.mkv",
|
||||
FileName: "Inception.mkv",
|
||||
FileSize: 5000000000,
|
||||
ModTime: "2026-01-15T12:00:00Z",
|
||||
Title: "Inception",
|
||||
Year: "2010",
|
||||
Quality: "1080p",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Save
|
||||
if err := SaveCacheTo(cache, path); err != nil {
|
||||
t.Fatalf("SaveCacheTo: %v", err)
|
||||
}
|
||||
|
||||
// Verify file exists
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("cache file not found: %v", err)
|
||||
}
|
||||
|
||||
// Load
|
||||
loaded, err := LoadCacheFrom(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadCacheFrom: %v", err)
|
||||
}
|
||||
if loaded == nil {
|
||||
t.Fatal("loaded cache is nil")
|
||||
}
|
||||
|
||||
if loaded.Version != cacheVersion {
|
||||
t.Errorf("version = %d, want %d", loaded.Version, cacheVersion)
|
||||
}
|
||||
if loaded.Path != "/media/movies" {
|
||||
t.Errorf("path = %q, want %q", loaded.Path, "/media/movies")
|
||||
}
|
||||
if len(loaded.Items) != 1 {
|
||||
t.Fatalf("items count = %d, want 1", len(loaded.Items))
|
||||
}
|
||||
if loaded.Items[0].Title != "Inception" {
|
||||
t.Errorf("title = %q, want %q", loaded.Items[0].Title, "Inception")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCacheNonExistent(t *testing.T) {
|
||||
cache, err := LoadCacheFrom("/tmp/nonexistent-unarr-test.json")
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error, got: %v", err)
|
||||
}
|
||||
if cache != nil {
|
||||
t.Fatalf("expected nil cache, got: %v", cache)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCacheIndex(t *testing.T) {
|
||||
cache := &LibraryCache{
|
||||
Items: []LibraryItem{
|
||||
{FilePath: "/a.mkv"},
|
||||
{FilePath: "/b.mkv"},
|
||||
{FilePath: "/c.mkv"},
|
||||
},
|
||||
}
|
||||
|
||||
idx := BuildCacheIndex(cache)
|
||||
if idx["/a.mkv"] != 0 {
|
||||
t.Errorf("expected index 0 for /a.mkv, got %d", idx["/a.mkv"])
|
||||
}
|
||||
if idx["/b.mkv"] != 1 {
|
||||
t.Errorf("expected index 1 for /b.mkv, got %d", idx["/b.mkv"])
|
||||
}
|
||||
if idx["/c.mkv"] != 2 {
|
||||
t.Errorf("expected index 2 for /c.mkv, got %d", idx["/c.mkv"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCacheIndexNil(t *testing.T) {
|
||||
idx := BuildCacheIndex(nil)
|
||||
if idx != nil {
|
||||
t.Errorf("expected nil, got %v", idx)
|
||||
}
|
||||
}
|
||||
281
internal/library/mediainfo/ffprobe.go
Normal file
281
internal/library/mediainfo/ffprobe.go
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
package mediainfo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ffprobeOutput matches the JSON structure from `ffprobe -show_streams -show_format`.
|
||||
type ffprobeOutput struct {
|
||||
Streams []ffprobeStream `json:"streams"`
|
||||
Format ffprobeFormat `json:"format"`
|
||||
}
|
||||
|
||||
type ffprobeFormat struct {
|
||||
Duration string `json:"duration"`
|
||||
}
|
||||
|
||||
type ffprobeStream struct {
|
||||
CodecType string `json:"codec_type"`
|
||||
CodecName string `json:"codec_name"`
|
||||
Profile string `json:"profile"`
|
||||
Channels int `json:"channels"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
BitsPerRaw string `json:"bits_per_raw_sample"`
|
||||
PixFmt string `json:"pix_fmt"`
|
||||
ColorSpace string `json:"color_space"`
|
||||
ColorTransfer string `json:"color_transfer"`
|
||||
ColorPrimaries string `json:"color_primaries"`
|
||||
RFrameRate string `json:"r_frame_rate"`
|
||||
Duration string `json:"duration"`
|
||||
Tags map[string]string `json:"tags"`
|
||||
Disposition map[string]int `json:"disposition"`
|
||||
SideDataList []sideData `json:"side_data_list"`
|
||||
}
|
||||
|
||||
type sideData struct {
|
||||
SideDataType string `json:"side_data_type"`
|
||||
}
|
||||
|
||||
// hdrProfiles maps (color_space, color_transfer) to HDR type.
|
||||
var hdrProfiles = map[[2]string]string{
|
||||
{"bt2020nc", "smpte2084"}: "HDR10",
|
||||
{"bt2020nc", "arib-std-b67"}: "HLG",
|
||||
}
|
||||
|
||||
// ExtractMediaInfo runs ffprobe on a file and parses audio, subtitle, and video streams.
|
||||
func ExtractMediaInfo(ctx context.Context, ffprobePath, filePath string) (*MediaInfo, error) {
|
||||
cmd := exec.CommandContext(ctx, ffprobePath,
|
||||
"-v", "error",
|
||||
"-print_format", "json",
|
||||
"-show_streams",
|
||||
"-show_format",
|
||||
filePath,
|
||||
)
|
||||
|
||||
var stderr strings.Builder
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
if _, statErr := os.Stat(filePath); statErr != nil {
|
||||
return nil, fmt.Errorf("ffprobe: file not found: %s", filePath)
|
||||
}
|
||||
return nil, fmt.Errorf("ffprobe failed (file=%s): %s", filePath, stderr.String())
|
||||
}
|
||||
|
||||
var data ffprobeOutput
|
||||
if err := json.Unmarshal(output, &data); err != nil {
|
||||
return nil, fmt.Errorf("ffprobe JSON parse failed: %w", err)
|
||||
}
|
||||
|
||||
if len(data.Streams) == 0 {
|
||||
return nil, fmt.Errorf("ffprobe returned no streams")
|
||||
}
|
||||
|
||||
var audioTracks []AudioTrack
|
||||
var subtitleTracks []SubtitleTrack
|
||||
var videoInfo *VideoInfo
|
||||
|
||||
for _, s := range data.Streams {
|
||||
switch s.CodecType {
|
||||
case "audio":
|
||||
langRaw := tagValue(s.Tags, "language")
|
||||
track := AudioTrack{
|
||||
Lang: NormalizeLang(langRaw),
|
||||
Codec: s.CodecName,
|
||||
Channels: s.Channels,
|
||||
}
|
||||
if title := tagValue(s.Tags, "title"); title != "" {
|
||||
track.Title = title
|
||||
}
|
||||
if s.Disposition["default"] == 1 {
|
||||
track.Default = true
|
||||
}
|
||||
audioTracks = append(audioTracks, track)
|
||||
|
||||
case "subtitle":
|
||||
langRaw := tagValue(s.Tags, "language")
|
||||
track := SubtitleTrack{
|
||||
Lang: NormalizeLang(langRaw),
|
||||
Codec: s.CodecName,
|
||||
}
|
||||
if title := tagValue(s.Tags, "title"); title != "" {
|
||||
track.Title = title
|
||||
}
|
||||
if s.Disposition["forced"] == 1 {
|
||||
track.Forced = true
|
||||
}
|
||||
subtitleTracks = append(subtitleTracks, track)
|
||||
|
||||
case "video":
|
||||
if videoInfo != nil {
|
||||
continue // only first video stream
|
||||
}
|
||||
vi := &VideoInfo{
|
||||
Codec: s.CodecName,
|
||||
Width: s.Width,
|
||||
Height: s.Height,
|
||||
}
|
||||
|
||||
// Bit depth
|
||||
if s.BitsPerRaw != "" {
|
||||
if bd, err := strconv.Atoi(s.BitsPerRaw); err == nil {
|
||||
vi.BitDepth = bd
|
||||
}
|
||||
} else if containsAny(s.PixFmt, "10le", "10be", "p010") {
|
||||
vi.BitDepth = 10
|
||||
} else if containsAny(s.PixFmt, "12le", "12be") {
|
||||
vi.BitDepth = 12
|
||||
}
|
||||
|
||||
// HDR detection
|
||||
hdrKey := [2]string{s.ColorSpace, s.ColorTransfer}
|
||||
if hdr, ok := hdrProfiles[hdrKey]; ok {
|
||||
vi.HDR = hdr
|
||||
} else if s.ColorTransfer == "smpte2084" {
|
||||
vi.HDR = "HDR10"
|
||||
} else if s.ColorTransfer == "arib-std-b67" {
|
||||
vi.HDR = "HLG"
|
||||
}
|
||||
|
||||
// Dolby Vision via side_data_list
|
||||
for _, sd := range s.SideDataList {
|
||||
if sd.SideDataType == "DOVI configuration record" {
|
||||
if vi.HDR != "" {
|
||||
vi.HDR = "DV+" + vi.HDR
|
||||
} else {
|
||||
vi.HDR = "DV"
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Frame rate from r_frame_rate (e.g., "24000/1001")
|
||||
if s.RFrameRate != "" && strings.Contains(s.RFrameRate, "/") {
|
||||
parts := strings.SplitN(s.RFrameRate, "/", 2)
|
||||
if num, err1 := strconv.ParseFloat(parts[0], 64); err1 == nil {
|
||||
if den, err2 := strconv.ParseFloat(parts[1], 64); err2 == nil && den > 0 {
|
||||
vi.FrameRate = math.Round(num/den*1000) / 1000
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Profile
|
||||
if s.Profile != "" {
|
||||
vi.Profile = s.Profile
|
||||
}
|
||||
|
||||
// Duration: prefer format.duration, fallback to stream duration
|
||||
if dur := parseDuration(data.Format.Duration); dur > 0 {
|
||||
vi.Duration = dur
|
||||
} else if dur := parseDuration(s.Duration); dur > 0 {
|
||||
vi.Duration = dur
|
||||
}
|
||||
|
||||
videoInfo = vi
|
||||
}
|
||||
}
|
||||
|
||||
result := &MediaInfo{
|
||||
Video: videoInfo,
|
||||
}
|
||||
if len(audioTracks) > 0 {
|
||||
result.Audio = audioTracks
|
||||
result.Languages = ComputeLanguages(audioTracks)
|
||||
}
|
||||
if len(subtitleTracks) > 0 {
|
||||
result.Subtitles = subtitleTracks
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ResolveFFprobe finds the ffprobe binary. Search order:
|
||||
// 1. Explicit path (--ffprobe flag)
|
||||
// 2. FFPROBE_PATH env var
|
||||
// 3. "ffprobe" in PATH
|
||||
// 4. Adjacent to the current executable
|
||||
// 5. Previously downloaded in cache dir
|
||||
// 6. Auto-download static binary
|
||||
func ResolveFFprobe(explicit string) (string, error) {
|
||||
if explicit != "" {
|
||||
if _, err := os.Stat(explicit); err == nil {
|
||||
return explicit, nil
|
||||
}
|
||||
return "", fmt.Errorf("ffprobe not found at explicit path: %s", explicit)
|
||||
}
|
||||
|
||||
if envPath := os.Getenv("FFPROBE_PATH"); envPath != "" {
|
||||
if _, err := os.Stat(envPath); err == nil {
|
||||
return envPath, nil
|
||||
}
|
||||
}
|
||||
|
||||
if p, err := exec.LookPath("ffprobe"); err == nil {
|
||||
return p, nil
|
||||
}
|
||||
|
||||
if exePath, err := os.Executable(); err == nil {
|
||||
name := "ffprobe"
|
||||
if runtime.GOOS == "windows" {
|
||||
name = "ffprobe.exe"
|
||||
}
|
||||
adjacent := filepath.Join(filepath.Dir(exePath), name)
|
||||
if _, err := os.Stat(adjacent); err == nil {
|
||||
return adjacent, nil
|
||||
}
|
||||
}
|
||||
|
||||
if cached, err := FFprobeCachePath(); err == nil {
|
||||
if _, err := os.Stat(cached); err == nil {
|
||||
return cached, nil
|
||||
}
|
||||
}
|
||||
|
||||
if p, err := DownloadFFprobe(); err == nil {
|
||||
return p, nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("ffprobe not found. Install ffmpeg or provide --ffprobe path")
|
||||
}
|
||||
|
||||
// tagValue gets a tag value case-insensitively.
|
||||
func tagValue(tags map[string]string, key string) string {
|
||||
if v, ok := tags[key]; ok {
|
||||
return v
|
||||
}
|
||||
if v, ok := tags[strings.ToUpper(key)]; ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func containsAny(s string, substrs ...string) bool {
|
||||
for _, sub := range substrs {
|
||||
if strings.Contains(s, sub) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// parseDuration converts a duration string (e.g. "7423.500000") to float64 seconds.
|
||||
func parseDuration(s string) float64 {
|
||||
if s == "" {
|
||||
return 0
|
||||
}
|
||||
d, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil || d <= 0 {
|
||||
return 0
|
||||
}
|
||||
return math.Round(d*1000) / 1000
|
||||
}
|
||||
176
internal/library/mediainfo/ffprobe_download.go
Normal file
176
internal/library/mediainfo/ffprobe_download.go
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
package mediainfo
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ffprobeAPIClient = &http.Client{Timeout: 30 * time.Second}
|
||||
ffprobeDLClient = &http.Client{Timeout: 10 * time.Minute}
|
||||
)
|
||||
|
||||
const maxFFprobeZipSize = 100 * 1024 * 1024 // 100MB
|
||||
|
||||
const ffbinariesAPI = "https://ffbinaries.com/api/v1/version/latest"
|
||||
|
||||
type ffbinariesResponse struct {
|
||||
Version string `json:"version"`
|
||||
Bin map[string]map[string]string `json:"bin"`
|
||||
}
|
||||
|
||||
// ffprobePlatformKey maps GOOS/GOARCH to ffbinaries platform keys.
|
||||
func ffprobePlatformKey() (string, error) {
|
||||
switch runtime.GOOS {
|
||||
case "linux":
|
||||
switch runtime.GOARCH {
|
||||
case "amd64":
|
||||
return "linux-64", nil
|
||||
case "arm64":
|
||||
return "linux-arm64", nil
|
||||
}
|
||||
case "darwin":
|
||||
return "osx-64", nil
|
||||
case "windows":
|
||||
if runtime.GOARCH == "amd64" {
|
||||
return "windows-64", nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("unsupported platform: %s/%s", runtime.GOOS, runtime.GOARCH)
|
||||
}
|
||||
|
||||
// FFprobeCacheDir returns the directory where the downloaded ffprobe binary is stored.
|
||||
func FFprobeCacheDir() (string, error) {
|
||||
cacheDir, err := os.UserCacheDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(cacheDir, "unarr", "bin"), nil
|
||||
}
|
||||
|
||||
// FFprobeCachePath returns the full path to the cached ffprobe binary.
|
||||
func FFprobeCachePath() (string, error) {
|
||||
dir, err := FFprobeCacheDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
name := "ffprobe"
|
||||
if runtime.GOOS == "windows" {
|
||||
name = "ffprobe.exe"
|
||||
}
|
||||
return filepath.Join(dir, name), nil
|
||||
}
|
||||
|
||||
// DownloadFFprobe downloads a static ffprobe binary for the current platform
|
||||
// and caches it locally. Returns the path to the binary.
|
||||
func DownloadFFprobe() (string, error) {
|
||||
dest, err := FFprobeCachePath()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot determine cache path: %w", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(dest); err == nil {
|
||||
return dest, nil
|
||||
}
|
||||
|
||||
platform, err := ffprobePlatformKey()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
url, err := resolveFFprobeURL(platform)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "ffprobe not found — downloading for %s...\n", platform)
|
||||
|
||||
resp, err := ffprobeDLClient.Get(url)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("download failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("download failed: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
zipData, err := io.ReadAll(io.LimitReader(resp.Body, maxFFprobeZipSize))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("download read failed: %w", err)
|
||||
}
|
||||
|
||||
name := "ffprobe"
|
||||
if runtime.GOOS == "windows" {
|
||||
name = "ffprobe.exe"
|
||||
}
|
||||
|
||||
binary, err := extractFromZip(zipData, name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||
return "", fmt.Errorf("cannot create cache directory: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(dest, binary, 0o755); err != nil {
|
||||
return "", fmt.Errorf("cannot write ffprobe binary: %w", err)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "ffprobe installed to %s\n", dest)
|
||||
return dest, nil
|
||||
}
|
||||
|
||||
func resolveFFprobeURL(platform string) (string, error) {
|
||||
resp, err := ffprobeAPIClient.Get(ffbinariesAPI)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot reach ffbinaries.com: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var data ffbinariesResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||
return "", fmt.Errorf("cannot parse ffbinaries response: %w", err)
|
||||
}
|
||||
|
||||
bins, ok := data.Bin[platform]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("no ffprobe binary available for platform %q", platform)
|
||||
}
|
||||
|
||||
url, ok := bins["ffprobe"]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("no ffprobe download URL for platform %q", platform)
|
||||
}
|
||||
|
||||
return url, nil
|
||||
}
|
||||
|
||||
func extractFromZip(data []byte, target string) ([]byte, error) {
|
||||
r, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot open downloaded archive: %w", err)
|
||||
}
|
||||
|
||||
for _, f := range r.File {
|
||||
if filepath.Base(f.Name) == target {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot extract %s from archive: %w", target, err)
|
||||
}
|
||||
defer rc.Close()
|
||||
return io.ReadAll(rc)
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("%s not found in downloaded archive", target)
|
||||
}
|
||||
115
internal/library/mediainfo/lang.go
Normal file
115
internal/library/mediainfo/lang.go
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
package mediainfo
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// langNormalize maps ISO 639-2/B, 639-2/T, 639-1 codes, and full English
|
||||
// language names (as returned by some ffprobe metadata) to ISO 639-1.
|
||||
var langNormalize = map[string]string{
|
||||
// ISO codes
|
||||
"eng": "en", "en": "en",
|
||||
"spa": "es", "es": "es",
|
||||
"fre": "fr", "fra": "fr", "fr": "fr",
|
||||
"ger": "de", "deu": "de", "de": "de",
|
||||
"ita": "it", "it": "it",
|
||||
"por": "pt", "pt": "pt",
|
||||
"rus": "ru", "ru": "ru",
|
||||
"jpn": "ja", "ja": "ja",
|
||||
"kor": "ko", "ko": "ko",
|
||||
"chi": "zh", "zho": "zh", "zh": "zh",
|
||||
"hin": "hi", "hi": "hi",
|
||||
"ara": "ar", "ar": "ar",
|
||||
"dut": "nl", "nld": "nl", "nl": "nl",
|
||||
"pol": "pl", "pl": "pl",
|
||||
"tur": "tr", "tr": "tr",
|
||||
"swe": "sv", "sv": "sv",
|
||||
"nor": "no", "nob": "no", "nno": "no", "no": "no",
|
||||
"dan": "da", "da": "da",
|
||||
"fin": "fi", "fi": "fi",
|
||||
"cze": "cs", "ces": "cs", "cs": "cs",
|
||||
"hun": "hu", "hu": "hu",
|
||||
"rum": "ro", "ron": "ro", "ro": "ro",
|
||||
"gre": "el", "ell": "el", "el": "el",
|
||||
"tha": "th", "th": "th",
|
||||
"vie": "vi", "vi": "vi",
|
||||
"ind": "id", "id": "id",
|
||||
"heb": "he", "he": "he",
|
||||
"ukr": "uk", "uk": "uk",
|
||||
"cat": "ca", "ca": "ca",
|
||||
"bul": "bg", "bg": "bg",
|
||||
"hrv": "hr", "hr": "hr",
|
||||
"srp": "sr", "sr": "sr",
|
||||
"slv": "sl", "sl": "sl",
|
||||
"lit": "lt", "lt": "lt",
|
||||
"lav": "lv", "lv": "lv",
|
||||
"est": "et", "et": "et",
|
||||
"per": "fa", "fas": "fa", "fa": "fa",
|
||||
"may": "ms", "msa": "ms", "ms": "ms",
|
||||
"tgl": "tl", "tl": "tl",
|
||||
"tam": "ta", "ta": "ta",
|
||||
"tel": "te", "te": "te",
|
||||
"ben": "bn", "bn": "bn",
|
||||
"urd": "ur", "ur": "ur",
|
||||
"geo": "ka", "kat": "ka", "ka": "ka",
|
||||
"arm": "hy", "hye": "hy", "hy": "hy",
|
||||
"alb": "sq", "sqi": "sq", "sq": "sq",
|
||||
"mac": "mk", "mkd": "mk", "mk": "mk",
|
||||
"ice": "is", "isl": "is", "is": "is",
|
||||
"glg": "gl", "gl": "gl",
|
||||
"baq": "eu", "eus": "eu", "eu": "eu",
|
||||
"wel": "cy", "cym": "cy", "cy": "cy",
|
||||
"gle": "ga", "ga": "ga",
|
||||
"mlt": "mt", "mt": "mt",
|
||||
"swa": "sw", "sw": "sw",
|
||||
"afr": "af", "af": "af",
|
||||
"lat": "la", "la": "la",
|
||||
|
||||
// Full English names (ffprobe sometimes returns these instead of codes)
|
||||
"english": "en", "spanish": "es", "french": "fr", "german": "de",
|
||||
"italian": "it", "portuguese": "pt", "russian": "ru", "japanese": "ja",
|
||||
"korean": "ko", "chinese": "zh", "hindi": "hi", "arabic": "ar",
|
||||
"dutch": "nl", "polish": "pl", "turkish": "tr", "swedish": "sv",
|
||||
"norwegian": "no", "danish": "da", "finnish": "fi", "czech": "cs",
|
||||
"hungarian": "hu", "romanian": "ro", "greek": "el", "thai": "th",
|
||||
"vietnamese": "vi", "indonesian": "id", "hebrew": "he", "ukrainian": "uk",
|
||||
"catalan": "ca", "bulgarian": "bg", "croatian": "hr", "serbian": "sr",
|
||||
"slovenian": "sl", "lithuanian": "lt", "latvian": "lv", "estonian": "et",
|
||||
"persian": "fa", "malay": "ms", "tagalog": "tl", "tamil": "ta",
|
||||
"telugu": "te", "bengali": "bn", "urdu": "ur", "georgian": "ka",
|
||||
"armenian": "hy", "albanian": "sq", "macedonian": "mk", "icelandic": "is",
|
||||
"galician": "gl", "basque": "eu", "welsh": "cy", "irish": "ga",
|
||||
"maltese": "mt", "swahili": "sw", "afrikaans": "af", "latin": "la",
|
||||
}
|
||||
|
||||
// NormalizeLang converts a language code to ISO 639-1.
|
||||
// Returns "und" for empty input, the input lowercased if no mapping is found.
|
||||
func NormalizeLang(raw string) string {
|
||||
if raw == "" {
|
||||
return "und"
|
||||
}
|
||||
lower := strings.ToLower(raw)
|
||||
if mapped, ok := langNormalize[lower]; ok {
|
||||
return mapped
|
||||
}
|
||||
return lower
|
||||
}
|
||||
|
||||
// ComputeLanguages extracts unique ISO 639-1 language codes from audio tracks.
|
||||
func ComputeLanguages(audioTracks []AudioTrack) []string {
|
||||
seen := make(map[string]struct{})
|
||||
for _, t := range audioTracks {
|
||||
lang := t.Lang
|
||||
if lang != "" && lang != "und" && len(lang) <= 3 {
|
||||
seen[lang] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]string, 0, len(seen))
|
||||
for l := range seen {
|
||||
result = append(result, l)
|
||||
}
|
||||
sort.Strings(result)
|
||||
return result
|
||||
}
|
||||
64
internal/library/mediainfo/lang_test.go
Normal file
64
internal/library/mediainfo/lang_test.go
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
package mediainfo
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizeLang(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"", "und"},
|
||||
{"eng", "en"},
|
||||
{"spa", "es"},
|
||||
{"fre", "fr"},
|
||||
{"fra", "fr"},
|
||||
{"ger", "de"},
|
||||
{"deu", "de"},
|
||||
{"en", "en"},
|
||||
{"es", "es"},
|
||||
{"English", "en"},
|
||||
{"SPANISH", "es"},
|
||||
{"Japanese", "ja"},
|
||||
{"jpn", "ja"},
|
||||
{"chi", "zh"},
|
||||
{"zho", "zh"},
|
||||
{"und", "und"},
|
||||
{"xyz", "xyz"}, // unknown → lowercase passthrough
|
||||
{"POR", "pt"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := NormalizeLang(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("NormalizeLang(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeLanguages(t *testing.T) {
|
||||
tracks := []AudioTrack{
|
||||
{Lang: "en", Codec: "aac", Channels: 2},
|
||||
{Lang: "es", Codec: "ac3", Channels: 6},
|
||||
{Lang: "en", Codec: "dts", Channels: 6}, // duplicate
|
||||
{Lang: "und", Codec: "aac", Channels: 2},
|
||||
{Lang: "", Codec: "aac", Channels: 2},
|
||||
}
|
||||
|
||||
langs := ComputeLanguages(tracks)
|
||||
|
||||
if len(langs) != 2 {
|
||||
t.Fatalf("expected 2 languages, got %d: %v", len(langs), langs)
|
||||
}
|
||||
if langs[0] != "en" || langs[1] != "es" {
|
||||
t.Errorf("expected [en es], got %v", langs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeLanguagesEmpty(t *testing.T) {
|
||||
langs := ComputeLanguages(nil)
|
||||
if len(langs) != 0 {
|
||||
t.Errorf("expected empty, got %v", langs)
|
||||
}
|
||||
}
|
||||
38
internal/library/mediainfo/types.go
Normal file
38
internal/library/mediainfo/types.go
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
package mediainfo
|
||||
|
||||
// MediaInfo holds the media analysis result from ffprobe.
|
||||
type MediaInfo struct {
|
||||
Video *VideoInfo `json:"video"`
|
||||
Audio []AudioTrack `json:"audio"`
|
||||
Subtitles []SubtitleTrack `json:"subtitles"`
|
||||
Languages []string `json:"languages"` // derived from audio tracks
|
||||
}
|
||||
|
||||
// VideoInfo represents the primary video stream metadata.
|
||||
type VideoInfo struct {
|
||||
Codec string `json:"codec"` // "hevc", "h264", "av1"
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
BitDepth int `json:"bitDepth"` // 8, 10, 12
|
||||
HDR string `json:"hdr"` // "HDR10", "DV", "HLG", "DV+HDR10", ""
|
||||
FrameRate float64 `json:"frameRate"` // e.g. 23.976
|
||||
Profile string `json:"profile"` // e.g. "Main 10", "High"
|
||||
Duration float64 `json:"duration"` // seconds
|
||||
}
|
||||
|
||||
// AudioTrack represents a single audio stream.
|
||||
type AudioTrack struct {
|
||||
Lang string `json:"lang"` // ISO 639-1
|
||||
Codec string `json:"codec"` // "aac", "ac3", "dts", "truehd"
|
||||
Channels int `json:"channels"` // 2, 6, 8
|
||||
Title string `json:"title"`
|
||||
Default bool `json:"default"`
|
||||
}
|
||||
|
||||
// SubtitleTrack represents a single subtitle stream.
|
||||
type SubtitleTrack struct {
|
||||
Lang string `json:"lang"`
|
||||
Codec string `json:"codec"`
|
||||
Title string `json:"title"`
|
||||
Forced bool `json:"forced"`
|
||||
}
|
||||
142
internal/library/resolve.go
Normal file
142
internal/library/resolve.go
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
package library
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/torrentclaw/torrentclaw-cli/internal/library/mediainfo"
|
||||
)
|
||||
|
||||
var (
|
||||
seasonRegex = regexp.MustCompile(`(?i)S(\d{1,2})E(\d{1,2})`)
|
||||
seasonOnly = regexp.MustCompile(`(?i)S(\d{1,2})(?:\b|$)`)
|
||||
altEpRegex = regexp.MustCompile(`(?i)(\d{1,2})x(\d{2})`)
|
||||
)
|
||||
|
||||
// ResolveResolution maps a pixel height to a standard resolution label.
|
||||
func ResolveResolution(height int) string {
|
||||
switch {
|
||||
case height >= 2000:
|
||||
return "2160p"
|
||||
case height >= 900:
|
||||
return "1080p"
|
||||
case height >= 600:
|
||||
return "720p"
|
||||
case height >= 400:
|
||||
return "480p"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// DeriveContentType guesses "movie" or "show" from parsed metadata.
|
||||
func DeriveContentType(item LibraryItem) string {
|
||||
if item.Season > 0 || item.Episode > 0 {
|
||||
return "show"
|
||||
}
|
||||
// Check filename for season/episode patterns
|
||||
if seasonRegex.MatchString(item.FileName) || altEpRegex.MatchString(item.FileName) || seasonOnly.MatchString(item.FileName) {
|
||||
return "show"
|
||||
}
|
||||
return "movie"
|
||||
}
|
||||
|
||||
// ParseSeasonEpisode extracts season and episode numbers from a filename.
|
||||
func ParseSeasonEpisode(filename string) (season, episode int) {
|
||||
// S01E05
|
||||
if m := seasonRegex.FindStringSubmatch(filename); len(m) > 2 {
|
||||
season = atoi(m[1])
|
||||
episode = atoi(m[2])
|
||||
return
|
||||
}
|
||||
// 1x05
|
||||
if m := altEpRegex.FindStringSubmatch(filename); len(m) > 2 {
|
||||
season = atoi(m[1])
|
||||
episode = atoi(m[2])
|
||||
return
|
||||
}
|
||||
// S01 only (season pack)
|
||||
if m := seasonOnly.FindStringSubmatch(filename); len(m) > 1 {
|
||||
season = atoi(m[1])
|
||||
return
|
||||
}
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
// PrimaryAudioTrack returns the codec and channel count of the default or first audio track.
|
||||
func PrimaryAudioTrack(tracks []mediainfo.AudioTrack) (codec string, channels int) {
|
||||
if len(tracks) == 0 {
|
||||
return "", 0
|
||||
}
|
||||
for _, t := range tracks {
|
||||
if t.Default {
|
||||
return t.Codec, t.Channels
|
||||
}
|
||||
}
|
||||
return tracks[0].Codec, tracks[0].Channels
|
||||
}
|
||||
|
||||
// AudioLanguages extracts unique language codes from audio tracks.
|
||||
func AudioLanguages(tracks []mediainfo.AudioTrack) []string {
|
||||
return mediainfo.ComputeLanguages(tracks)
|
||||
}
|
||||
|
||||
// SubtitleLanguages extracts unique language codes from subtitle tracks.
|
||||
func SubtitleLanguages(tracks []mediainfo.SubtitleTrack) []string {
|
||||
seen := make(map[string]struct{})
|
||||
for _, t := range tracks {
|
||||
if t.Lang != "" && t.Lang != "und" {
|
||||
seen[t.Lang] = struct{}{}
|
||||
}
|
||||
}
|
||||
result := make([]string, 0, len(seen))
|
||||
for l := range seen {
|
||||
result = append(result, l)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// CleanTitle extracts a clean title from a filename for searching.
|
||||
// Removes extension, replaces separators with spaces, strips release artifacts.
|
||||
func CleanTitle(filename string) string {
|
||||
// Remove extension
|
||||
name := strings.TrimSuffix(filename, extOf(filename))
|
||||
|
||||
// Remove release group at end BEFORE replacing separators (e.g. "-SPARKS", "-FGT")
|
||||
name = regexp.MustCompile(`-[A-Za-z0-9]+$`).ReplaceAllString(name, "")
|
||||
|
||||
// Remove brackets
|
||||
name = regexp.MustCompile(`[\[\(].*?[\]\)]`).ReplaceAllString(name, "")
|
||||
|
||||
// Replace common separators with spaces
|
||||
name = strings.NewReplacer(".", " ", "_", " ", "-", " ").Replace(name)
|
||||
|
||||
// Remove quality/codec/release artifacts
|
||||
name = regexp.MustCompile(`(?i)\b(2160p|1080p|720p|480p|4K|UHD|BluRay|BDRip|WEBRip|WEB-DL|HDTV|DVDRip|BRRip|x264|x265|HEVC|AVC|AV1|AAC|DTS|AC3|Atmos|FLAC|10bit|HDR10?\+?|DV|DoVi|PROPER|REPACK|REMUX|EXTENDED|DUAL|MULTi)\b`).ReplaceAllString(name, "")
|
||||
|
||||
// Remove year
|
||||
name = regexp.MustCompile(`\b(19|20)\d{2}\b`).ReplaceAllString(name, "")
|
||||
|
||||
// Collapse whitespace
|
||||
name = regexp.MustCompile(`\s+`).ReplaceAllString(name, " ")
|
||||
return strings.TrimSpace(name)
|
||||
}
|
||||
|
||||
func extOf(filename string) string {
|
||||
for i := len(filename) - 1; i >= 0; i-- {
|
||||
if filename[i] == '.' {
|
||||
return filename[i:]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func atoi(s string) int {
|
||||
n := 0
|
||||
for _, c := range s {
|
||||
if c >= '0' && c <= '9' {
|
||||
n = n*10 + int(c-'0')
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
156
internal/library/resolve_test.go
Normal file
156
internal/library/resolve_test.go
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
package library
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/torrentclaw/torrentclaw-cli/internal/library/mediainfo"
|
||||
)
|
||||
|
||||
func TestResolveResolution(t *testing.T) {
|
||||
tests := []struct {
|
||||
height int
|
||||
want string
|
||||
}{
|
||||
{2160, "2160p"},
|
||||
{2000, "2160p"},
|
||||
{1080, "1080p"},
|
||||
{1920, "1080p"}, // 1920 is width, not height — height for 1080p is ~1080
|
||||
{900, "1080p"},
|
||||
{720, "720p"},
|
||||
{600, "720p"},
|
||||
{576, "480p"},
|
||||
{480, "480p"},
|
||||
{400, "480p"},
|
||||
{360, ""},
|
||||
{0, ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := ResolveResolution(tt.height)
|
||||
if got != tt.want {
|
||||
t.Errorf("ResolveResolution(%d) = %q, want %q", tt.height, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveContentType(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
item LibraryItem
|
||||
want string
|
||||
}{
|
||||
{
|
||||
"movie by default",
|
||||
LibraryItem{FileName: "Inception.2010.1080p.mkv"},
|
||||
"movie",
|
||||
},
|
||||
{
|
||||
"show by season field",
|
||||
LibraryItem{FileName: "something.mkv", Season: 1},
|
||||
"show",
|
||||
},
|
||||
{
|
||||
"show by episode field",
|
||||
LibraryItem{FileName: "something.mkv", Episode: 5},
|
||||
"show",
|
||||
},
|
||||
{
|
||||
"show by S01E01 in filename",
|
||||
LibraryItem{FileName: "Breaking.Bad.S01E01.1080p.mkv"},
|
||||
"show",
|
||||
},
|
||||
{
|
||||
"show by 1x05 in filename",
|
||||
LibraryItem{FileName: "show.1x05.720p.mkv"},
|
||||
"show",
|
||||
},
|
||||
{
|
||||
"show by S02 in filename",
|
||||
LibraryItem{FileName: "Show.Name.S02.Complete.mkv"},
|
||||
"show",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := DeriveContentType(tt.item)
|
||||
if got != tt.want {
|
||||
t.Errorf("DeriveContentType() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSeasonEpisode(t *testing.T) {
|
||||
tests := []struct {
|
||||
filename string
|
||||
season int
|
||||
episode int
|
||||
}{
|
||||
{"Breaking.Bad.S01E05.1080p.mkv", 1, 5},
|
||||
{"Show.S02E10.720p.mkv", 2, 10},
|
||||
{"show.1x05.mkv", 1, 5},
|
||||
{"show.12x03.mkv", 12, 3},
|
||||
{"Show.S01.Complete.mkv", 1, 0},
|
||||
{"Inception.2010.1080p.mkv", 0, 0},
|
||||
{"s3e7.mkv", 3, 7},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.filename, func(t *testing.T) {
|
||||
s, e := ParseSeasonEpisode(tt.filename)
|
||||
if s != tt.season || e != tt.episode {
|
||||
t.Errorf("ParseSeasonEpisode(%q) = (%d, %d), want (%d, %d)", tt.filename, s, e, tt.season, tt.episode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrimaryAudioTrack(t *testing.T) {
|
||||
// Default track
|
||||
tracks := []mediainfo.AudioTrack{
|
||||
{Lang: "en", Codec: "aac", Channels: 2, Default: false},
|
||||
{Lang: "es", Codec: "ac3", Channels: 6, Default: true},
|
||||
}
|
||||
codec, ch := PrimaryAudioTrack(tracks)
|
||||
if codec != "ac3" || ch != 6 {
|
||||
t.Errorf("expected ac3/6, got %s/%d", codec, ch)
|
||||
}
|
||||
|
||||
// No default → first
|
||||
tracks2 := []mediainfo.AudioTrack{
|
||||
{Lang: "en", Codec: "dts", Channels: 8},
|
||||
{Lang: "es", Codec: "aac", Channels: 2},
|
||||
}
|
||||
codec, ch = PrimaryAudioTrack(tracks2)
|
||||
if codec != "dts" || ch != 8 {
|
||||
t.Errorf("expected dts/8, got %s/%d", codec, ch)
|
||||
}
|
||||
|
||||
// Empty
|
||||
codec, ch = PrimaryAudioTrack(nil)
|
||||
if codec != "" || ch != 0 {
|
||||
t.Errorf("expected empty, got %s/%d", codec, ch)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanTitle(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"Inception.2010.1080p.BluRay.x264-SPARKS.mkv", "Inception"},
|
||||
{"Breaking.Bad.S01E05.720p.HDTV.mkv", "Breaking Bad S01E05"},
|
||||
{"The.Matrix.1999.2160p.UHD.BluRay.REMUX.mkv", "The Matrix"},
|
||||
{"Movie [YTS.MX].mp4", "Movie"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := CleanTitle(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("CleanTitle(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
210
internal/library/scanner.go
Normal file
210
internal/library/scanner.go
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
package library
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/torrentclaw/torrentclaw-cli/internal/library/mediainfo"
|
||||
"github.com/torrentclaw/torrentclaw-cli/internal/parser"
|
||||
)
|
||||
|
||||
// videoExts are file extensions considered as video files.
|
||||
var videoExts = map[string]bool{
|
||||
".mkv": true, ".mp4": true, ".avi": true, ".m4v": true,
|
||||
".ts": true, ".wmv": true, ".mov": true, ".webm": true,
|
||||
".flv": true, ".mpg": true, ".mpeg": true, ".vob": true,
|
||||
}
|
||||
|
||||
// excludePatterns are path substrings that indicate non-content files.
|
||||
var excludePatterns = []string{
|
||||
"sample", "trailer", "featurette", "extras", "bonus",
|
||||
"behind the scenes", "deleted scenes", "interview",
|
||||
}
|
||||
|
||||
const minFileSize = 100 * 1024 * 1024 // 100MB minimum
|
||||
|
||||
// ScanOptions configures the library scanner.
|
||||
type ScanOptions struct {
|
||||
Workers int // concurrent ffprobe processes (default 8)
|
||||
FFprobePath string // explicit path, or auto-resolve
|
||||
Incremental bool // skip unchanged files (mtime+size match cache)
|
||||
OnProgress func(scanned, total int, current string)
|
||||
}
|
||||
|
||||
// Scan walks a directory recursively, finds video files, and runs ffprobe on each.
|
||||
func Scan(ctx context.Context, dirPath string, existing *LibraryCache, opts ScanOptions) (*LibraryCache, error) {
|
||||
if opts.Workers <= 0 {
|
||||
opts.Workers = 8
|
||||
}
|
||||
|
||||
// Resolve ffprobe
|
||||
ffprobePath, err := mediainfo.ResolveFFprobe(opts.FFprobePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ffprobe: %w", err)
|
||||
}
|
||||
|
||||
// Discover video files
|
||||
files, err := discoverFiles(dirPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("discover files: %w", err)
|
||||
}
|
||||
|
||||
if len(files) == 0 {
|
||||
return &LibraryCache{
|
||||
Version: cacheVersion,
|
||||
ScannedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
Path: dirPath,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Build cache index for incremental mode
|
||||
cacheIdx := BuildCacheIndex(existing)
|
||||
|
||||
// Scan files concurrently
|
||||
var (
|
||||
scanned atomic.Int32
|
||||
total = len(files)
|
||||
mu sync.Mutex
|
||||
items = make([]LibraryItem, 0, total)
|
||||
)
|
||||
|
||||
sem := make(chan struct{}, opts.Workers)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for _, filePath := range files {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
break
|
||||
case sem <- struct{}{}:
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
go func(fp string) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
|
||||
item := scanSingleFile(ctx, ffprobePath, fp, cacheIdx, existing, opts.Incremental)
|
||||
|
||||
mu.Lock()
|
||||
items = append(items, item)
|
||||
mu.Unlock()
|
||||
|
||||
n := int(scanned.Add(1))
|
||||
if opts.OnProgress != nil {
|
||||
opts.OnProgress(n, total, filepath.Base(fp))
|
||||
}
|
||||
}(filePath)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
return &LibraryCache{
|
||||
Version: cacheVersion,
|
||||
ScannedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
Path: dirPath,
|
||||
Items: items,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func scanSingleFile(ctx context.Context, ffprobePath, filePath string, cacheIdx map[string]int, existing *LibraryCache, incremental bool) LibraryItem {
|
||||
info, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
return LibraryItem{
|
||||
FilePath: filePath,
|
||||
FileName: filepath.Base(filePath),
|
||||
ScanError: err.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
item := LibraryItem{
|
||||
FilePath: filePath,
|
||||
FileName: filepath.Base(filePath),
|
||||
FileSize: info.Size(),
|
||||
ModTime: info.ModTime().UTC().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
// Parse filename for title, year, quality, codec
|
||||
parsed := parser.Parse(item.FileName)
|
||||
item.Quality = parsed.Quality
|
||||
item.Codec = parsed.Codec
|
||||
item.Year = parsed.Year
|
||||
|
||||
// Extract title from filename
|
||||
item.Title = CleanTitle(item.FileName)
|
||||
if item.Title == "" {
|
||||
item.Title = item.FileName
|
||||
}
|
||||
|
||||
// Parse season/episode
|
||||
item.Season, item.Episode = ParseSeasonEpisode(item.FileName)
|
||||
|
||||
// Incremental: skip if file hasn't changed
|
||||
if incremental && existing != nil {
|
||||
if idx, ok := cacheIdx[filePath]; ok {
|
||||
cached := existing.Items[idx]
|
||||
if cached.FileSize == item.FileSize && cached.ModTime == item.ModTime && cached.MediaInfo != nil {
|
||||
item.MediaInfo = cached.MediaInfo
|
||||
return item
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Run ffprobe
|
||||
mi, err := mediainfo.ExtractMediaInfo(ctx, ffprobePath, filePath)
|
||||
if err != nil {
|
||||
item.ScanError = err.Error()
|
||||
return item
|
||||
}
|
||||
item.MediaInfo = mi
|
||||
|
||||
return item
|
||||
}
|
||||
|
||||
// discoverFiles walks a directory and returns paths of video files.
|
||||
func discoverFiles(root string) ([]string, error) {
|
||||
var files []string
|
||||
|
||||
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil // skip errors, continue walking
|
||||
}
|
||||
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
if !videoExts[ext] {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check file size (stat is lazy on some systems)
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if info.Size() < minFileSize {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Exclude non-content files
|
||||
lower := strings.ToLower(path)
|
||||
for _, pattern := range excludePatterns {
|
||||
if strings.Contains(lower, pattern) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
files = append(files, path)
|
||||
return nil
|
||||
})
|
||||
|
||||
return files, err
|
||||
}
|
||||
29
internal/library/types.go
Normal file
29
internal/library/types.go
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
package library
|
||||
|
||||
import "github.com/torrentclaw/torrentclaw-cli/internal/library/mediainfo"
|
||||
|
||||
// LibraryItem represents a single scanned media file.
|
||||
type LibraryItem struct {
|
||||
FilePath string `json:"filePath"`
|
||||
FileName string `json:"fileName"`
|
||||
FileSize int64 `json:"fileSize"`
|
||||
ModTime string `json:"modTime"` // ISO 8601
|
||||
Title string `json:"title"`
|
||||
Year string `json:"year,omitempty"`
|
||||
Season int `json:"season,omitempty"`
|
||||
Episode int `json:"episode,omitempty"`
|
||||
Quality string `json:"quality,omitempty"` // "1080p" etc (from filename)
|
||||
Codec string `json:"codec,omitempty"` // "x265" etc (from filename)
|
||||
MediaInfo *mediainfo.MediaInfo `json:"mediaInfo,omitempty"`
|
||||
ScanError string `json:"scanError,omitempty"`
|
||||
}
|
||||
|
||||
// LibraryCache is the on-disk cache of scanned library items.
|
||||
type LibraryCache struct {
|
||||
Version int `json:"version"`
|
||||
ScannedAt string `json:"scannedAt"`
|
||||
Path string `json:"path"`
|
||||
Items []LibraryItem `json:"items"`
|
||||
}
|
||||
|
||||
const cacheVersion = 1
|
||||
281
internal/mediaserver/detect.go
Normal file
281
internal/mediaserver/detect.go
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
package mediaserver
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Server represents a detected media server.
|
||||
type Server struct {
|
||||
Name string // "Plex", "Jellyfin", "Emby"
|
||||
URL string // "http://localhost:32400"
|
||||
}
|
||||
|
||||
// DetectedPaths holds media library paths discovered from servers and disk.
|
||||
type DetectedPaths struct {
|
||||
Servers []Server
|
||||
Paths []string // unique media library paths found
|
||||
}
|
||||
|
||||
var knownServers = []struct {
|
||||
Name string
|
||||
Port string
|
||||
}{
|
||||
{"Plex", "32400"},
|
||||
{"Jellyfin", "8096"},
|
||||
{"Emby", "8920"},
|
||||
}
|
||||
|
||||
// Detect scans for media servers and common media directories.
|
||||
func Detect() DetectedPaths {
|
||||
result := DetectedPaths{}
|
||||
pathSet := map[string]bool{}
|
||||
|
||||
addPath := func(p string) {
|
||||
p = filepath.Clean(p)
|
||||
if !pathSet[p] {
|
||||
pathSet[p] = true
|
||||
result.Paths = append(result.Paths, p)
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Detect media servers via port scan
|
||||
for _, s := range knownServers {
|
||||
conn, err := net.DialTimeout("tcp", "localhost:"+s.Port, 2*time.Second)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
_ = conn.Close()
|
||||
result.Servers = append(result.Servers, Server{
|
||||
Name: s.Name,
|
||||
URL: "http://localhost:" + s.Port,
|
||||
})
|
||||
}
|
||||
|
||||
// 2. Try to read Plex library paths from config
|
||||
for _, p := range plexLibraryPaths() {
|
||||
addPath(p)
|
||||
}
|
||||
|
||||
// 3. Try Jellyfin API (often allows local access without auth)
|
||||
for _, s := range result.Servers {
|
||||
if s.Name != "Jellyfin" {
|
||||
continue
|
||||
}
|
||||
for _, p := range jellyfinLibraryPaths(s.URL) {
|
||||
addPath(p)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Scan common media directories on disk
|
||||
for _, p := range commonMediaDirs() {
|
||||
if fi, err := os.Stat(p); err == nil && fi.IsDir() {
|
||||
addPath(p)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// ── Plex ────────────────────────────────────────────────────────────
|
||||
|
||||
func plexLibraryPaths() []string {
|
||||
configDir := plexConfigDir()
|
||||
if configDir == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Read token from Preferences.xml
|
||||
prefsPath := filepath.Join(configDir, "Preferences.xml")
|
||||
token := plexTokenFromPrefs(prefsPath)
|
||||
if token == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Query library sections
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
req, err := http.NewRequest("GET", "http://localhost:32400/library/sections", nil)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
req.Header.Set("X-Plex-Token", token)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return parsePlexSections(body)
|
||||
}
|
||||
|
||||
func plexConfigDir() string {
|
||||
switch runtime.GOOS {
|
||||
case "linux":
|
||||
home, _ := os.UserHomeDir()
|
||||
candidates := []string{
|
||||
filepath.Join(home, ".config", "Plex Media Server"),
|
||||
"/var/lib/plexmediaserver/Library/Application Support/Plex Media Server",
|
||||
}
|
||||
for _, d := range candidates {
|
||||
if fi, err := os.Stat(d); err == nil && fi.IsDir() {
|
||||
return d
|
||||
}
|
||||
}
|
||||
case "darwin":
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, "Library", "Application Support", "Plex Media Server")
|
||||
case "windows":
|
||||
return filepath.Join(os.Getenv("LOCALAPPDATA"), "Plex Media Server")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type plexPrefs struct {
|
||||
XMLName xml.Name `xml:"Preferences"`
|
||||
PlexOnlineToken string `xml:"PlexOnlineToken,attr"`
|
||||
}
|
||||
|
||||
func plexTokenFromPrefs(path string) string {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
var prefs plexPrefs
|
||||
if err := xml.Unmarshal(data, &prefs); err != nil {
|
||||
return ""
|
||||
}
|
||||
return prefs.PlexOnlineToken
|
||||
}
|
||||
|
||||
func parsePlexSections(body []byte) []string {
|
||||
// Plex JSON response has: MediaContainer.Directory[].Location[].path
|
||||
var container struct {
|
||||
MediaContainer struct {
|
||||
Directory []struct {
|
||||
Location []struct {
|
||||
Path string `json:"path"`
|
||||
} `json:"Location"`
|
||||
} `json:"Directory"`
|
||||
} `json:"MediaContainer"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &container); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var paths []string
|
||||
for _, dir := range container.MediaContainer.Directory {
|
||||
for _, loc := range dir.Location {
|
||||
if loc.Path != "" {
|
||||
paths = append(paths, loc.Path)
|
||||
}
|
||||
}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
// ── Jellyfin ────────────────────────────────────────────────────────
|
||||
|
||||
func jellyfinLibraryPaths(baseURL string) []string {
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
resp, err := client.Get(baseURL + "/Library/VirtualFolders")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var folders []struct {
|
||||
Locations []string `json:"Locations"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &folders); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var paths []string
|
||||
for _, f := range folders {
|
||||
paths = append(paths, f.Locations...)
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
// ── Common directories ──────────────────────────────────────────────
|
||||
|
||||
func commonMediaDirs() []string {
|
||||
home, _ := os.UserHomeDir()
|
||||
if home == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
candidates := []string{
|
||||
filepath.Join(home, "Media"),
|
||||
filepath.Join(home, "Movies"),
|
||||
filepath.Join(home, "Videos"),
|
||||
filepath.Join(home, "TV Shows"),
|
||||
}
|
||||
|
||||
// Also check /data/media pattern (common Docker/NAS setup)
|
||||
if runtime.GOOS == "linux" {
|
||||
candidates = append(candidates,
|
||||
"/data/media",
|
||||
"/data/media/movies",
|
||||
"/data/media/tv",
|
||||
"/srv/media",
|
||||
)
|
||||
}
|
||||
|
||||
return candidates
|
||||
}
|
||||
|
||||
// ParentDir returns the common parent of detected paths, useful for
|
||||
// suggesting a download directory that encompasses movie + TV paths.
|
||||
func ParentDir(paths []string) string {
|
||||
if len(paths) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Find the common prefix of all paths
|
||||
parent := filepath.Dir(paths[0])
|
||||
for _, p := range paths[1:] {
|
||||
d := filepath.Dir(p)
|
||||
for parent != "/" && parent != "." {
|
||||
if d == parent || strings.HasPrefix(d, parent+string(filepath.Separator)) {
|
||||
break
|
||||
}
|
||||
parent = filepath.Dir(parent)
|
||||
}
|
||||
}
|
||||
|
||||
// Don't return root or home as a suggestion
|
||||
home, _ := os.UserHomeDir()
|
||||
if parent == "/" || parent == "." || parent == home {
|
||||
return ""
|
||||
}
|
||||
return parent
|
||||
}
|
||||
98
internal/mediaserver/detect_test.go
Normal file
98
internal/mediaserver/detect_test.go
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
package mediaserver
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParsePlexSections(t *testing.T) {
|
||||
body := `{
|
||||
"MediaContainer": {
|
||||
"Directory": [
|
||||
{
|
||||
"title": "Movies",
|
||||
"Location": [{"path": "/data/media/movies"}]
|
||||
},
|
||||
{
|
||||
"title": "TV Shows",
|
||||
"Location": [{"path": "/data/media/tv"}]
|
||||
}
|
||||
]
|
||||
}
|
||||
}`
|
||||
|
||||
paths := parsePlexSections([]byte(body))
|
||||
if len(paths) != 2 {
|
||||
t.Fatalf("parsePlexSections = %d paths, want 2", len(paths))
|
||||
}
|
||||
if paths[0] != "/data/media/movies" {
|
||||
t.Errorf("paths[0] = %q, want /data/media/movies", paths[0])
|
||||
}
|
||||
if paths[1] != "/data/media/tv" {
|
||||
t.Errorf("paths[1] = %q, want /data/media/tv", paths[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePlexSections_Empty(t *testing.T) {
|
||||
paths := parsePlexSections([]byte(`{}`))
|
||||
if len(paths) != 0 {
|
||||
t.Errorf("parsePlexSections empty = %d paths, want 0", len(paths))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePlexSections_InvalidJSON(t *testing.T) {
|
||||
paths := parsePlexSections([]byte(`not json`))
|
||||
if paths != nil {
|
||||
t.Errorf("parsePlexSections invalid = %v, want nil", paths)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJellyfinParsing(t *testing.T) {
|
||||
body := `[
|
||||
{"Locations": ["/media/movies"]},
|
||||
{"Locations": ["/media/tv", "/media/anime"]}
|
||||
]`
|
||||
|
||||
var folders []struct {
|
||||
Locations []string `json:"Locations"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(body), &folders); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var paths []string
|
||||
for _, f := range folders {
|
||||
paths = append(paths, f.Locations...)
|
||||
}
|
||||
if len(paths) != 3 {
|
||||
t.Fatalf("got %d paths, want 3", len(paths))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParentDir(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
paths []string
|
||||
expect string
|
||||
}{
|
||||
{"empty", nil, ""},
|
||||
{"single", []string{"/data/media/movies"}, "/data/media"},
|
||||
{"siblings", []string{"/data/media/movies", "/data/media/tv"}, "/data/media"},
|
||||
{"different roots", []string{"/data/movies", "/srv/tv"}, "/"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := ParentDir(tt.paths)
|
||||
// "/" is filtered out (returns "")
|
||||
if tt.expect == "/" {
|
||||
if got != "" {
|
||||
t.Errorf("ParentDir = %q, want empty (root filtered)", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if got != tt.expect {
|
||||
t.Errorf("ParentDir = %q, want %q", got, tt.expect)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue