Sprint 1 — Auto-refresh after download:
- New [[mediaserver]] TOML section with kind/url/token/sections
- mediaserver.Refresh() fans out to Plex (partial via section ID auto-mapping
from file path prefix) and Jellyfin/Emby (full library scan)
- Manager.OnFinalized callback wired in daemon to trigger refresh after
organize() completes — keeps engine package free of mediaserver dep
- New unarr mediaserver {setup,list,remove,test} commands
- unarr init wizard offers to configure refresh when a server is detected
Sprint 2 — .strm instant mode (cloud + agent):
- Mode strm-to-library handled in daemon dispatch: writes a one-line .strm
file pointing to the cloud-resolved debrid HTTPS URL, then triggers refresh
- engine.WriteStrm + StrmDestForTask mirror organize()'s naming so Plex/Jellyfin
see the expected folder structure (Movies/Title (Year)/, TV Shows/Show/Season XX/)
- Atomic write (temp + rename) so partial files never get indexed
- Reports completed/failed status to the cloud via existing agent client
295 lines
6.9 KiB
Go
295 lines
6.9 KiB
Go
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 ────────────────────────────────────────────────────────────
|
|
|
|
// LocalPlexToken returns the Plex auth token from the local Plex config
|
|
// directory, if Plex Media Server is installed on this host. Returns ""
|
|
// when Plex isn't installed or the token can't be read.
|
|
func LocalPlexToken() string {
|
|
dir := plexConfigDir()
|
|
if dir == "" {
|
|
return ""
|
|
}
|
|
return PlexTokenFromPrefs(filepath.Join(dir, "Preferences.xml"))
|
|
}
|
|
|
|
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"`
|
|
}
|
|
|
|
// PlexTokenFromPrefs reads the Plex auth token from a Preferences.xml file.
|
|
// Returns "" if the file can't be read or parsed. Used by the setup wizard
|
|
// when configuring a Plex server running on the same host as unarr.
|
|
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
|
|
}
|