unarr/internal/engine/organize.go

429 lines
13 KiB
Go

package engine
import (
"fmt"
"io"
"log"
"os"
"path/filepath"
"regexp"
"strings"
"time"
)
var (
yearRegex = regexp.MustCompile(`\b(19|20)\d{2}\b`)
seasonRegex = regexp.MustCompile(`(?i)S(\d{2})`)
episodeRegex = regexp.MustCompile(`(?i)S(\d{2})E(\d{2})`)
altEpRegex = regexp.MustCompile(`(?i)(\d{1,2})x(\d{2})`) // 1x05 format
pathReplacer = strings.NewReplacer(
"/", "-",
"\\", "-",
":", " -",
"?", "",
"*", "",
"\"", "",
"<", "",
">", "",
"|", "-",
)
)
// OrganizeConfig holds file organization settings.
type OrganizeConfig struct {
Enabled bool
MoviesDir string
TVShowsDir string
OutputDir string // download directory — used to clean up torrent subdirectories after move
}
// organize moves a downloaded file into the proper directory structure.
//
// When server metadata is available (ContentType, ContentTitle, Season, CollectionName):
// - Shows: TVShowsDir/ContentTitle/Season XX/filename.ext
// - Collections: MoviesDir/CollectionName/ContentTitle (Year)/filename.ext
// - Movies: MoviesDir/ContentTitle (Year)/filename.ext
//
// Falls back to legacy regex-based detection when metadata is missing.
func organize(result *Result, task *Task, cfg OrganizeConfig) (string, error) {
if !cfg.Enabled || result == nil || result.FilePath == "" {
return result.FilePath, nil
}
var destDir string
var destFileName string // empty = keep original filename
ext := filepath.Ext(result.FileName)
if ext == "" {
ext = filepath.Ext(result.FilePath)
}
if task.ContentType == "show" && cfg.TVShowsDir != "" {
// TV show: use clean title from server, group all episodes under one folder
showName := task.ContentTitle
if showName == "" {
showName = cleanTitle(task.Title) // fallback
}
destDir = filepath.Join(cfg.TVShowsDir, sanitizePath(showName))
if task.Season != nil {
destDir = filepath.Join(destDir, fmt.Sprintf("Season %02d", *task.Season))
// Rename: "ShowName - S01E03.mkv" so media players identify it
if task.Episode != nil {
destFileName = fmt.Sprintf("%s - S%02dE%02d%s", sanitizePath(showName), *task.Season, *task.Episode, ext)
}
} else if season := detectSeason(result.FileName); season != "" {
destDir = filepath.Join(destDir, fmt.Sprintf("Season %s", season))
}
} else if task.CollectionName != "" && cfg.MoviesDir != "" {
// Collection movie: CollectionName/MovieTitle (Year)/file
collDir := sanitizePath(task.CollectionName)
movieName := task.ContentTitle
if movieName == "" {
movieName = cleanTitle(task.Title)
}
year := resolveYear(task)
if year != "" {
destDir = filepath.Join(cfg.MoviesDir, collDir, fmt.Sprintf("%s (%s)", sanitizePath(movieName), year))
destFileName = fmt.Sprintf("%s (%s)%s", sanitizePath(movieName), year, ext)
} else {
destDir = filepath.Join(cfg.MoviesDir, collDir, sanitizePath(movieName))
destFileName = fmt.Sprintf("%s%s", sanitizePath(movieName), ext)
}
} else if task.ContentType == "movie" && cfg.MoviesDir != "" {
// Regular movie with server metadata
movieName := task.ContentTitle
if movieName == "" {
movieName = cleanTitle(task.Title)
}
year := resolveYear(task)
if year != "" {
destDir = filepath.Join(cfg.MoviesDir, fmt.Sprintf("%s (%s)", sanitizePath(movieName), year))
destFileName = fmt.Sprintf("%s (%s)%s", sanitizePath(movieName), year, ext)
} else {
destDir = filepath.Join(cfg.MoviesDir, sanitizePath(movieName))
destFileName = fmt.Sprintf("%s%s", sanitizePath(movieName), ext)
}
} else {
// No server metadata: fall back to legacy regex-based detection
return organizeLegacy(result, task, cfg)
}
return moveToDir(result, destDir, destFileName, cfg)
}
// organizeLegacy is the original regex-based organize logic for tasks without server metadata.
func organizeLegacy(result *Result, task *Task, cfg OrganizeConfig) (string, error) {
title := task.Title
if title == "" {
title = result.FileName
}
season := detectSeason(result.FileName)
isTV := season != ""
var destDir string
if isTV && cfg.TVShowsDir != "" {
showName := cleanTitle(title)
destDir = filepath.Join(cfg.TVShowsDir, showName)
if season != "" {
destDir = filepath.Join(destDir, fmt.Sprintf("Season %s", season))
}
} else if cfg.MoviesDir != "" {
movieName := cleanTitle(title)
year := yearRegex.FindString(title)
if year != "" {
destDir = filepath.Join(cfg.MoviesDir, fmt.Sprintf("%s (%s)", movieName, year))
} else {
destDir = filepath.Join(cfg.MoviesDir, movieName)
}
} else {
return result.FilePath, nil
}
return moveToDir(result, destDir, "", cfg)
}
// moveToDir handles the actual directory creation and file move, including path traversal check.
// If destFileName is non-empty, the file is renamed to that name (instead of keeping the original).
func moveToDir(result *Result, destDir, destFileName string, cfg OrganizeConfig) (string, error) {
// Validate destination is within an expected base directory
if !((cfg.TVShowsDir != "" && isWithinDir(cfg.TVShowsDir, destDir)) ||
(cfg.MoviesDir != "" && isWithinDir(cfg.MoviesDir, destDir)) ||
(cfg.OutputDir != "" && isWithinDir(cfg.OutputDir, destDir))) {
return "", fmt.Errorf("path traversal blocked: %q is not within any configured directory", destDir)
}
if err := os.MkdirAll(destDir, 0o755); err != nil {
return "", fmt.Errorf("create dir: %w", err)
}
fileName := filepath.Base(result.FilePath)
if destFileName != "" {
fileName = destFileName
}
destPath := filepath.Join(destDir, fileName)
srcInfo, err := os.Stat(result.FilePath)
if err != nil {
return "", fmt.Errorf("stat source: %w", err)
}
if srcInfo.IsDir() {
if _, err := os.Stat(destPath); err == nil {
os.RemoveAll(destPath)
}
if err := os.Rename(result.FilePath, destPath); err != nil {
return "", fmt.Errorf("move directory: %w", err)
}
return destPath, nil
}
if err := os.Rename(result.FilePath, destPath); err != nil {
if err := copyFile(result.FilePath, destPath); err != nil {
return "", fmt.Errorf("move file: %w", err)
}
os.Remove(result.FilePath)
}
// Move subtitle files alongside the video
moveSubtitles(result.FilePath, destDir, destFileName)
// Clean up the source torrent directory if it's a subdirectory of OutputDir
// and now empty or only contains junk files (nfo, txt, url, etc.)
cleanupSourceDir(result.FilePath, cfg.OutputDir)
return destPath, nil
}
// cleanupSourceDir removes the parent directory of srcFile if:
// - it's a subdirectory of outputDir (any depth, e.g. outputDir/TorrentName/ or outputDir/category/TorrentName/)
// - it contains no video files or subdirectories after the move
//
// This cleans up leftover junk files (nfo, txt, url, jpg) from multi-file torrents.
func cleanupSourceDir(srcFile, outputDir string) {
if outputDir == "" {
return
}
srcDir := filepath.Dir(srcFile)
absOutput, err1 := filepath.Abs(outputDir)
absSrcDir, err2 := filepath.Abs(srcDir)
if err1 != nil || err2 != nil {
return
}
// Never delete outputDir itself
if absSrcDir == absOutput {
return
}
// Must be within outputDir
if !strings.HasPrefix(absSrcDir, absOutput+string(os.PathSeparator)) {
return
}
entries, err := os.ReadDir(absSrcDir)
if err != nil {
return
}
for _, e := range entries {
if e.IsDir() {
return // has subdirectories, don't touch
}
if isVideoFile(e.Name()) || isSubtitleFile(e.Name()) {
return // still has video/subtitle files, don't clean
}
}
// Only junk files remain — remove the entire directory
if err := os.RemoveAll(absSrcDir); err != nil {
log.Printf("[organize] cleanup warning: failed to remove %s: %v", absSrcDir, err)
}
}
// isVideoFile checks if a filename has a common video extension.
func isVideoFile(name string) bool {
ext := strings.ToLower(filepath.Ext(name))
switch ext {
case ".mkv", ".mp4", ".avi", ".wmv", ".mov", ".flv", ".webm", ".m4v", ".ts", ".m2ts":
return true
}
return false
}
// detectSeason extracts the season number from a filename using regex (for fallback).
func detectSeason(fileName string) string {
if m := episodeRegex.FindStringSubmatch(fileName); len(m) > 2 {
return m[1]
}
if m := altEpRegex.FindStringSubmatch(fileName); len(m) > 2 {
return fmt.Sprintf("%02s", m[1])
}
if m := seasonRegex.FindStringSubmatch(fileName); len(m) > 1 {
return m[1]
}
return ""
}
// sanitizePath removes characters that are invalid in file/directory names.
func sanitizePath(name string) string {
s := pathReplacer.Replace(name)
s = strings.TrimSpace(s)
s = strings.TrimRight(s, ".")
if s == "" {
return "Unknown"
}
return s
}
// moveSubtitles moves subtitle files from the source directory to destDir.
// If destFileName is set (video was renamed), subtitles are renamed to match.
// Matches subtitles by video base name (e.g., "Movie.srt", "Movie.en.srt").
func moveSubtitles(srcVideoPath, destDir, destFileName string) {
srcDir := filepath.Dir(srcVideoPath)
videoBase := strings.TrimSuffix(filepath.Base(srcVideoPath), filepath.Ext(srcVideoPath))
destVideoBase := ""
if destFileName != "" {
destVideoBase = strings.TrimSuffix(destFileName, filepath.Ext(destFileName))
}
entries, err := os.ReadDir(srcDir)
if err != nil {
return
}
for _, e := range entries {
if e.IsDir() || !isSubtitleFile(e.Name()) {
continue
}
// Match: subtitle must start with the video base name
// e.g., "Movie.srt", "Movie.en.srt", "Movie.forced.eng.srt"
if !strings.HasPrefix(e.Name(), videoBase) {
continue
}
subSrc := filepath.Join(srcDir, e.Name())
subDest := e.Name()
// Rename subtitle to match new video name if video was renamed
// e.g., "Movie.en.srt" → "Oppenheimer (2023).en.srt"
if destVideoBase != "" {
suffix := strings.TrimPrefix(e.Name(), videoBase) // ".en.srt" or ".srt"
subDest = destVideoBase + suffix
}
destPath := filepath.Join(destDir, subDest)
if err := os.Rename(subSrc, destPath); err != nil {
if err := copyFile(subSrc, destPath); err != nil {
log.Printf("[organize] warning: failed to move subtitle %s: %v", e.Name(), err)
continue
}
os.Remove(subSrc)
}
}
}
// resolveYear returns the content year as a string.
// Prefers the server-provided ContentYear; falls back to regex extraction from the torrent title.
func resolveYear(task *Task) string {
if task.ContentYear != nil && *task.ContentYear > 0 {
return fmt.Sprintf("%d", *task.ContentYear)
}
return yearRegex.FindString(task.Title)
}
// isSubtitleFile checks if a filename has a common subtitle extension.
func isSubtitleFile(name string) bool {
ext := strings.ToLower(filepath.Ext(name))
switch ext {
case ".srt", ".sub", ".ass", ".ssa", ".vtt", ".idx":
return true
}
return false
}
// cleanTitle extracts a clean title from a torrent title string.
func cleanTitle(title string) string {
// Remove year and everything after common separators
t := title
if idx := strings.Index(t, " ("); idx > 0 {
t = t[:idx]
}
// Remove resolution and codec markers
for _, pattern := range []string{"1080p", "720p", "2160p", "480p", "BluRay", "WEB-DL", "HDTV", "x264", "x265", "HEVC"} {
if idx := strings.Index(strings.ToLower(t), strings.ToLower(pattern)); idx > 0 {
t = t[:idx]
}
}
t = strings.TrimRight(t, " .-_")
if t == "" {
return title
}
return t
}
// replaceFile moves the old file to a backup dir, then moves the new file to the old path.
// Used by upgrade downloads to replace an existing file with a better version.
func replaceFile(oldPath, newPath, backupDir string) error {
if _, err := os.Stat(oldPath); err != nil {
return fmt.Errorf("original file not found: %w", err)
}
if backupDir == "" {
home, _ := os.UserHomeDir()
backupDir = filepath.Join(home, ".local", "share", "unarr", "replaced")
}
if err := os.MkdirAll(backupDir, 0o755); err != nil {
return fmt.Errorf("create backup dir: %w", err)
}
// Move old file to backup (with timestamp to avoid collisions)
base := filepath.Base(oldPath)
ext := filepath.Ext(base)
nameNoExt := strings.TrimSuffix(base, ext)
backupName := fmt.Sprintf("%s.%d%s", nameNoExt, time.Now().Unix(), ext)
backupPath := filepath.Join(backupDir, backupName)
if err := os.Rename(oldPath, backupPath); err != nil {
// Cross-device: copy + delete
if err := copyFile(oldPath, backupPath); err != nil {
return fmt.Errorf("backup failed: %w", err)
}
os.Remove(oldPath)
}
// Move new file to old path
if err := os.MkdirAll(filepath.Dir(oldPath), 0o755); err != nil {
return fmt.Errorf("create target dir: %w", err)
}
if err := os.Rename(newPath, oldPath); err != nil {
// Cross-device: copy + delete
if err := copyFile(newPath, oldPath); err != nil {
// Rollback: restore backup
os.Rename(backupPath, oldPath)
return fmt.Errorf("replace failed: %w", err)
}
os.Remove(newPath)
}
return nil
}
func copyFile(src, dst string) error {
s, err := os.Open(src)
if err != nil {
return err
}
defer s.Close()
d, err := os.Create(dst)
if err != nil {
return err
}
defer d.Close()
_, err = io.Copy(d, s)
return err
}