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