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