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:
Deivid Soto 2026-03-29 16:54:32 +02:00
parent 0b6c6849b1
commit 677a8fe083
34 changed files with 4766 additions and 22 deletions

View 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
}

View 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)
}
})
}
}