unarr/internal/engine/strm.go
Deivid Soto 6adf1e2c4c 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
2026-05-05 20:35:08 +02:00

130 lines
4.2 KiB
Go

package engine
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/torrentclaw/unarr/internal/agent"
)
// StrmDest holds the resolved location for a .strm file to be written.
type StrmDest struct {
Dir string // directory the .strm file lives in (created if missing)
FileName string // filename including the .strm extension
FullPath string // Dir + FileName, joined
}
// StrmDestForTask computes where a .strm file should land for the given
// agent task, mirroring organize()'s naming so Plex/Jellyfin sees the same
// folder structure as a real download would have produced.
//
// Returns an error if cfg lacks the relevant library directory.
func StrmDestForTask(task agent.Task, cfg OrganizeConfig) (StrmDest, error) {
switch {
case task.ContentType == "show":
if cfg.TVShowsDir == "" {
return StrmDest{}, fmt.Errorf("strm: TVShowsDir not configured")
}
showName := task.ContentTitle
if showName == "" {
showName = cleanTitle(task.Title)
}
dir := filepath.Join(cfg.TVShowsDir, sanitizePath(showName))
var fileName string
if task.Season != nil {
dir = filepath.Join(dir, fmt.Sprintf("Season %02d", *task.Season))
if task.Episode != nil {
fileName = fmt.Sprintf("%s - S%02dE%02d.strm",
sanitizePath(showName), *task.Season, *task.Episode)
}
}
if fileName == "" {
// Missing season/episode metadata — fall back to a sanitised title.
fileName = sanitizePath(showName) + ".strm"
}
return StrmDest{Dir: dir, FileName: fileName, FullPath: filepath.Join(dir, fileName)}, nil
case task.CollectionName != "" && cfg.MoviesDir != "":
movieName := task.ContentTitle
if movieName == "" {
movieName = cleanTitle(task.Title)
}
year := strYear(task)
base := sanitizePath(movieName)
if year != "" {
base = fmt.Sprintf("%s (%s)", base, year)
}
dir := filepath.Join(cfg.MoviesDir, sanitizePath(task.CollectionName), base)
fileName := base + ".strm"
return StrmDest{Dir: dir, FileName: fileName, FullPath: filepath.Join(dir, fileName)}, nil
case task.ContentType == "movie":
if cfg.MoviesDir == "" {
return StrmDest{}, fmt.Errorf("strm: MoviesDir not configured")
}
movieName := task.ContentTitle
if movieName == "" {
movieName = cleanTitle(task.Title)
}
year := strYear(task)
base := sanitizePath(movieName)
if year != "" {
base = fmt.Sprintf("%s (%s)", base, year)
}
dir := filepath.Join(cfg.MoviesDir, base)
fileName := base + ".strm"
return StrmDest{Dir: dir, FileName: fileName, FullPath: filepath.Join(dir, fileName)}, nil
default:
// No metadata at all — drop into MoviesDir under a sanitised title so
// at least the file lands somewhere a library scan might find it.
if cfg.MoviesDir == "" {
return StrmDest{}, fmt.Errorf("strm: no library dir configured for content without metadata")
}
base := sanitizePath(cleanTitle(task.Title))
if base == "" {
base = "Unknown"
}
dir := filepath.Join(cfg.MoviesDir, base)
fileName := base + ".strm"
return StrmDest{Dir: dir, FileName: fileName, FullPath: filepath.Join(dir, fileName)}, nil
}
}
// WriteStrm writes a .strm file containing the given URL at the destination
// computed from the task. Creates parent dirs as needed. Atomic write
// (temp + rename) so a partial file never gets indexed.
func WriteStrm(task agent.Task, cfg OrganizeConfig) (string, error) {
if task.DirectURL == "" {
return "", fmt.Errorf("strm: task has no directUrl")
}
dest, err := StrmDestForTask(task, cfg)
if err != nil {
return "", err
}
if err := os.MkdirAll(dest.Dir, 0o755); err != nil {
return "", fmt.Errorf("strm: create dir: %w", err)
}
tmp := dest.FullPath + ".tmp"
if err := os.WriteFile(tmp, []byte(strings.TrimSpace(task.DirectURL)+"\n"), 0o644); err != nil {
return "", fmt.Errorf("strm: write temp: %w", err)
}
if err := os.Rename(tmp, dest.FullPath); err != nil {
_ = os.Remove(tmp)
return "", fmt.Errorf("strm: rename: %w", err)
}
return dest.FullPath, nil
}
// strYear is the agent.Task counterpart to organize.go's resolveYear.
func strYear(task agent.Task) string {
if task.ContentYear != nil && *task.ContentYear > 0 {
return fmt.Sprintf("%d", *task.ContentYear)
}
return yearRegex.FindString(task.Title)
}