feat(mediaserver): Plex/Jellyfin/Emby auto-refresh + .strm instant mode

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
This commit is contained in:
Deivid Soto 2026-05-05 20:35:08 +02:00
parent 6955b6144b
commit 6adf1e2c4c
13 changed files with 1065 additions and 16 deletions

View file

@ -87,6 +87,17 @@ func Detect() DetectedPaths {
// ── 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 == "" {
@ -95,7 +106,7 @@ func plexLibraryPaths() []string {
// Read token from Preferences.xml
prefsPath := filepath.Join(configDir, "Preferences.xml")
token := plexTokenFromPrefs(prefsPath)
token := PlexTokenFromPrefs(prefsPath)
if token == "" {
return nil
}
@ -154,7 +165,10 @@ type plexPrefs struct {
PlexOnlineToken string `xml:"PlexOnlineToken,attr"`
}
func plexTokenFromPrefs(path string) string {
// 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 ""

View file

@ -79,7 +79,7 @@ func TestPlexTokenFromPrefs(t *testing.T) {
<Preferences PlexOnlineToken="my-secret-token" OldestPreviousVersion="1.0"/>`
os.WriteFile(prefsPath, []byte(xml), 0o644)
token := plexTokenFromPrefs(prefsPath)
token := PlexTokenFromPrefs(prefsPath)
if token != "my-secret-token" {
t.Errorf("token = %q, want my-secret-token", token)
}
@ -91,14 +91,14 @@ func TestPlexTokenFromPrefs(t *testing.T) {
xml := `<?xml version="1.0"?><Preferences/>`
os.WriteFile(prefsPath, []byte(xml), 0o644)
token := plexTokenFromPrefs(prefsPath)
token := PlexTokenFromPrefs(prefsPath)
if token != "" {
t.Errorf("token = %q, want empty", token)
}
})
t.Run("file not found", func(t *testing.T) {
token := plexTokenFromPrefs("/nonexistent/Preferences.xml")
token := PlexTokenFromPrefs("/nonexistent/Preferences.xml")
if token != "" {
t.Errorf("token = %q, want empty", token)
}
@ -109,7 +109,7 @@ func TestPlexTokenFromPrefs(t *testing.T) {
prefsPath := filepath.Join(dir, "Preferences.xml")
os.WriteFile(prefsPath, []byte("not xml at all"), 0o644)
token := plexTokenFromPrefs(prefsPath)
token := PlexTokenFromPrefs(prefsPath)
if token != "" {
t.Errorf("token = %q, want empty", token)
}

View file

@ -0,0 +1,280 @@
package mediaserver
import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
// ServerConfig describes a media server that should be refreshed after a
// download (or .strm write) finishes. Stored in unarr's config.toml under
// [[mediaserver]].
type ServerConfig struct {
Kind string `toml:"kind"` // "plex" | "jellyfin" | "emby"
URL string `toml:"url"` // e.g. http://localhost:32400
Token string `toml:"token"` // Plex token / Jellyfin or Emby API key
Sections []int `toml:"sections"` // optional: Plex section IDs to refresh (else auto)
}
// Section describes a Plex library section.
type Section struct {
ID int
Title string
Locations []string
}
// httpClient is the shared HTTP client for refresh calls. Short timeouts
// because a refresh trigger is fire-and-forget.
var httpClient = &http.Client{Timeout: 8 * time.Second}
// plexSectionCache caches section lookups per server URL+token, so we don't
// re-fetch sections on every download.
var (
plexSectionMu sync.RWMutex
plexSectionCache = map[string][]Section{}
)
// Refresh fans out a refresh call to every configured media server. Errors
// are logged but never returned — a failed refresh is non-fatal because the
// download itself succeeded and the next periodic scan will pick it up.
//
// `filePath` is the path of the file that was just placed (or the .strm
// pointer); it's used to resolve the matching Plex section for partial
// refreshes. Pass "" to fall back to a full refresh.
//
// Each refresh runs in its own goroutine with a 15s timeout — the call
// returns immediately and never blocks the caller.
func Refresh(servers []ServerConfig, filePath string) {
for _, s := range servers {
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := refreshOne(ctx, s, filePath); err != nil {
log.Printf("mediaserver: %s refresh failed (%s): %v", s.Kind, s.URL, err)
}
}()
}
}
func refreshOne(ctx context.Context, s ServerConfig, filePath string) error {
switch strings.ToLower(s.Kind) {
case "plex":
return refreshPlex(ctx, s, filePath)
case "jellyfin", "emby":
return refreshJellyfin(ctx, s)
default:
return fmt.Errorf("unknown kind %q", s.Kind)
}
}
// ── Plex ────────────────────────────────────────────────────────────
func refreshPlex(ctx context.Context, s ServerConfig, filePath string) error {
if s.URL == "" || s.Token == "" {
return fmt.Errorf("plex: missing url or token")
}
sectionIDs := s.Sections
if len(sectionIDs) == 0 {
// Auto-resolve: fetch sections, pick whichever owns the file path.
sections, err := plexSections(ctx, s.URL, s.Token)
if err != nil {
return fmt.Errorf("fetch sections: %w", err)
}
if filePath != "" {
if id, ok := matchSectionByPath(sections, filePath); ok {
sectionIDs = []int{id}
}
}
if len(sectionIDs) == 0 {
// Fall back to refreshing every section.
for _, sec := range sections {
sectionIDs = append(sectionIDs, sec.ID)
}
}
}
var firstErr error
for _, id := range sectionIDs {
if err := plexRefreshSection(ctx, s.URL, s.Token, id, filePath); err != nil {
if firstErr == nil {
firstErr = err
}
log.Printf("mediaserver: plex section %d refresh failed: %v", id, err)
}
}
return firstErr
}
func plexRefreshSection(ctx context.Context, baseURL, token string, sectionID int, filePath string) error {
q := url.Values{}
if filePath != "" {
q.Set("path", filePath)
}
q.Set("X-Plex-Token", token)
endpoint := fmt.Sprintf("%s/library/sections/%d/refresh?%s",
strings.TrimRight(baseURL, "/"), sectionID, q.Encode())
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return err
}
req.Header.Set("Accept", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, resp.Body)
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil
}
return fmt.Errorf("status %d", resp.StatusCode)
}
// plexSections returns the cached or freshly fetched library sections for a
// Plex server. The cache lives for the agent's lifetime — Plex sections
// rarely change, so refetching on every download is wasteful.
func plexSections(ctx context.Context, baseURL, token string) ([]Section, error) {
key := baseURL + "|" + token
plexSectionMu.RLock()
cached, ok := plexSectionCache[key]
plexSectionMu.RUnlock()
if ok {
return cached, nil
}
sections, err := fetchPlexSections(ctx, baseURL, token)
if err != nil {
return nil, err
}
plexSectionMu.Lock()
plexSectionCache[key] = sections
plexSectionMu.Unlock()
return sections, nil
}
func fetchPlexSections(ctx context.Context, baseURL, token string) ([]Section, error) {
endpoint := strings.TrimRight(baseURL, "/") + "/library/sections"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("X-Plex-Token", token)
req.Header.Set("Accept", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("status %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, err
}
return parsePlexSectionsFull(body)
}
// parsePlexSectionsFull parses the full sections response with IDs.
func parsePlexSectionsFull(body []byte) ([]Section, error) {
var container struct {
MediaContainer struct {
Directory []struct {
Key string `json:"key"`
Title string `json:"title"`
Location []struct {
Path string `json:"path"`
} `json:"Location"`
} `json:"Directory"`
} `json:"MediaContainer"`
}
if err := json.Unmarshal(body, &container); err != nil {
return nil, err
}
var out []Section
for _, d := range container.MediaContainer.Directory {
var id int
_, _ = fmt.Sscanf(d.Key, "%d", &id)
if id == 0 {
continue
}
sec := Section{ID: id, Title: d.Title}
for _, loc := range d.Location {
if loc.Path != "" {
sec.Locations = append(sec.Locations, loc.Path)
}
}
out = append(out, sec)
}
return out, nil
}
// matchSectionByPath returns the ID of the section whose Locations contain
// (as prefix) the given file path. Picks the most specific (longest) match.
func matchSectionByPath(sections []Section, filePath string) (int, bool) {
bestID := 0
bestLen := 0
for _, s := range sections {
for _, loc := range s.Locations {
loc = strings.TrimRight(loc, "/")
if loc == "" {
continue
}
if filePath == loc || strings.HasPrefix(filePath, loc+"/") {
if len(loc) > bestLen {
bestLen = len(loc)
bestID = s.ID
}
}
}
}
return bestID, bestID != 0
}
// ── Jellyfin / Emby ─────────────────────────────────────────────────
func refreshJellyfin(ctx context.Context, s ServerConfig) error {
if s.URL == "" || s.Token == "" {
return fmt.Errorf("%s: missing url or token", s.Kind)
}
endpoint := strings.TrimRight(s.URL, "/") + "/Library/Refresh"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, nil)
if err != nil {
return err
}
// Both Jellyfin and Emby accept this header.
req.Header.Set("X-Emby-Token", s.Token)
req.Header.Set("Accept", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, resp.Body)
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil
}
return fmt.Errorf("status %d", resp.StatusCode)
}

View file

@ -0,0 +1,149 @@
package mediaserver
import (
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
)
func TestParsePlexSectionsFull(t *testing.T) {
body := []byte(`{
"MediaContainer": {
"Directory": [
{ "key": "1", "title": "Movies", "Location": [{"path": "/data/media/movies"}] },
{ "key": "2", "title": "TV Shows", "Location": [{"path": "/data/media/tv"}, {"path": "/mnt/tv2"}] },
{ "key": "0", "title": "Bogus", "Location": [{"path": "/skip"}] }
]
}
}`)
sections, err := parsePlexSectionsFull(body)
if err != nil {
t.Fatalf("parsePlexSectionsFull error: %v", err)
}
if len(sections) != 2 {
t.Fatalf("got %d sections, want 2 (id=0 should be skipped)", len(sections))
}
if sections[0].ID != 1 || sections[0].Title != "Movies" {
t.Errorf("section[0] = %+v", sections[0])
}
if sections[1].ID != 2 || len(sections[1].Locations) != 2 {
t.Errorf("section[1] = %+v", sections[1])
}
}
func TestMatchSectionByPath(t *testing.T) {
sections := []Section{
{ID: 1, Title: "Movies", Locations: []string{"/data/media/movies"}},
{ID: 2, Title: "TV", Locations: []string{"/data/media/tv"}},
{ID: 3, Title: "TV-HD", Locations: []string{"/data/media/tv/hd"}},
}
tests := []struct {
path string
wantID int
wantOK bool
}{
{"/data/media/movies/Inception (2010)/Inception.mkv", 1, true},
{"/data/media/tv/Show/Season 01/ep.mkv", 2, true},
{"/data/media/tv/hd/Show/Season 01/ep.mkv", 3, true}, // most specific wins
{"/data/media/movies", 1, true}, // exact
{"/elsewhere/foo.mkv", 0, false},
}
for _, tc := range tests {
gotID, gotOK := matchSectionByPath(sections, tc.path)
if gotID != tc.wantID || gotOK != tc.wantOK {
t.Errorf("matchSectionByPath(%q) = (%d,%v), want (%d,%v)",
tc.path, gotID, gotOK, tc.wantID, tc.wantOK)
}
}
}
func TestRefreshPlex_PartialRefreshWithPath(t *testing.T) {
var refreshHits int32
var gotPath, gotToken string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/library/sections":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"MediaContainer": {
"Directory": [
{ "key": "7", "title": "Movies", "Location": [{"path": "/m"}] }
]
}
}`))
case strings.HasPrefix(r.URL.Path, "/library/sections/7/refresh"):
atomic.AddInt32(&refreshHits, 1)
gotPath = r.URL.Query().Get("path")
gotToken = r.URL.Query().Get("X-Plex-Token")
w.WriteHeader(http.StatusOK)
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer srv.Close()
// Reset cache so the test server is hit fresh.
plexSectionMu.Lock()
plexSectionCache = map[string][]Section{}
plexSectionMu.Unlock()
cfg := ServerConfig{Kind: "plex", URL: srv.URL, Token: "tk-1"}
Refresh([]ServerConfig{cfg}, "/m/Inception/Inception.mkv")
// Refresh fans out goroutines — give it time.
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if atomic.LoadInt32(&refreshHits) > 0 {
break
}
time.Sleep(20 * time.Millisecond)
}
if atomic.LoadInt32(&refreshHits) != 1 {
t.Fatalf("refresh endpoint hit %d times, want 1", refreshHits)
}
if gotPath != "/m/Inception/Inception.mkv" {
t.Errorf("path query = %q", gotPath)
}
if gotToken != "tk-1" {
t.Errorf("token query = %q", gotToken)
}
}
func TestRefreshJellyfin(t *testing.T) {
var hits int32
var gotToken string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost && r.URL.Path == "/Library/Refresh" {
atomic.AddInt32(&hits, 1)
gotToken = r.Header.Get("X-Emby-Token")
w.WriteHeader(http.StatusNoContent)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
cfg := ServerConfig{Kind: "jellyfin", URL: srv.URL, Token: "jf-key"}
Refresh([]ServerConfig{cfg}, "")
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if atomic.LoadInt32(&hits) > 0 {
break
}
time.Sleep(20 * time.Millisecond)
}
if atomic.LoadInt32(&hits) != 1 {
t.Fatalf("Jellyfin hits = %d, want 1", hits)
}
if gotToken != "jf-key" {
t.Errorf("X-Emby-Token = %q", gotToken)
}
}