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
130 lines
4.2 KiB
Go
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)
|
|
}
|