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:
parent
6955b6144b
commit
6adf1e2c4c
13 changed files with 1065 additions and 16 deletions
280
internal/mediaserver/refresh.go
Normal file
280
internal/mediaserver/refresh.go
Normal 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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue