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