feat(organize): use server metadata for file organization and subtitle handling
This commit is contained in:
parent
48e4fb9f7b
commit
819c727bf5
6 changed files with 657 additions and 31 deletions
|
|
@ -71,6 +71,12 @@ type Task struct {
|
||||||
ReplacePath string `json:"replacePath,omitempty"` // File to replace after download (upgrade mode)
|
ReplacePath string `json:"replacePath,omitempty"` // File to replace after download (upgrade mode)
|
||||||
LibraryItemID int `json:"libraryItemId,omitempty"` // Library item being upgraded
|
LibraryItemID int `json:"libraryItemId,omitempty"` // Library item being upgraded
|
||||||
ForceStart bool `json:"forceStart,omitempty"` // Bypass queue (like Transmission's Force Start)
|
ForceStart bool `json:"forceStart,omitempty"` // Bypass queue (like Transmission's Force Start)
|
||||||
|
ContentType string `json:"contentType,omitempty"` // "movie" | "show" — from server metadata
|
||||||
|
ContentTitle string `json:"contentTitle,omitempty"` // Clean title from TMDB (e.g., "Frieren: Beyond Journey's End")
|
||||||
|
Season *int `json:"season,omitempty"` // Season number
|
||||||
|
Episode *int `json:"episode,omitempty"` // Episode number
|
||||||
|
ContentYear *int `json:"contentYear,omitempty"` // Year from TMDB (avoids regex on torrent title)
|
||||||
|
CollectionName string `json:"collectionName,omitempty"` // Collection name (e.g., "Harry Potter Collection")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TasksResponse wraps the array of tasks returned by the server.
|
// TasksResponse wraps the array of tasks returned by the server.
|
||||||
|
|
|
||||||
|
|
@ -232,6 +232,7 @@ func runDaemonStart() error {
|
||||||
Enabled: cfg.Organize.Enabled,
|
Enabled: cfg.Organize.Enabled,
|
||||||
MoviesDir: cfg.Organize.MoviesDir,
|
MoviesDir: cfg.Organize.MoviesDir,
|
||||||
TVShowsDir: cfg.Organize.TVShowsDir,
|
TVShowsDir: cfg.Organize.TVShowsDir,
|
||||||
|
OutputDir: cfg.Download.Dir,
|
||||||
},
|
},
|
||||||
}, reporter, torrentDl, debridDl, engine.NewUsenetDownloader(httpT.Client()))
|
}, reporter, torrentDl, debridDl, engine.NewUsenetDownloader(httpT.Client()))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,7 @@ func runDownload(input, method string) error {
|
||||||
Enabled: cfg.Organize.Enabled,
|
Enabled: cfg.Organize.Enabled,
|
||||||
MoviesDir: cfg.Organize.MoviesDir,
|
MoviesDir: cfg.Organize.MoviesDir,
|
||||||
TVShowsDir: cfg.Organize.TVShowsDir,
|
TVShowsDir: cfg.Organize.TVShowsDir,
|
||||||
|
OutputDir: outputDir,
|
||||||
},
|
},
|
||||||
}, reporter, torrentDl, debridDl)
|
}, reporter, torrentDl, debridDl)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package engine
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
|
@ -15,6 +16,17 @@ var (
|
||||||
seasonRegex = regexp.MustCompile(`(?i)S(\d{2})`)
|
seasonRegex = regexp.MustCompile(`(?i)S(\d{2})`)
|
||||||
episodeRegex = regexp.MustCompile(`(?i)S(\d{2})E(\d{2})`)
|
episodeRegex = regexp.MustCompile(`(?i)S(\d{2})E(\d{2})`)
|
||||||
altEpRegex = regexp.MustCompile(`(?i)(\d{1,2})x(\d{2})`) // 1x05 format
|
altEpRegex = regexp.MustCompile(`(?i)(\d{1,2})x(\d{2})`) // 1x05 format
|
||||||
|
pathReplacer = strings.NewReplacer(
|
||||||
|
"/", "-",
|
||||||
|
"\\", "-",
|
||||||
|
":", " -",
|
||||||
|
"?", "",
|
||||||
|
"*", "",
|
||||||
|
"\"", "",
|
||||||
|
"<", "",
|
||||||
|
">", "",
|
||||||
|
"|", "-",
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
// OrganizeConfig holds file organization settings.
|
// OrganizeConfig holds file organization settings.
|
||||||
|
|
@ -22,36 +34,95 @@ type OrganizeConfig struct {
|
||||||
Enabled bool
|
Enabled bool
|
||||||
MoviesDir string
|
MoviesDir string
|
||||||
TVShowsDir 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.
|
// organize moves a downloaded file into the proper directory structure.
|
||||||
// Movies: MoviesDir/Title (Year)/filename.ext
|
//
|
||||||
// TV: TVShowsDir/Title/Season XX/filename.ext
|
// 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) {
|
func organize(result *Result, task *Task, cfg OrganizeConfig) (string, error) {
|
||||||
if !cfg.Enabled || result == nil || result.FilePath == "" {
|
if !cfg.Enabled || result == nil || result.FilePath == "" {
|
||||||
return result.FilePath, nil
|
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
|
title := task.Title
|
||||||
if title == "" {
|
if title == "" {
|
||||||
title = result.FileName
|
title = result.FileName
|
||||||
}
|
}
|
||||||
|
|
||||||
isTV := strings.Contains(strings.ToLower(task.PreferredMethod), "show") ||
|
season := detectSeason(result.FileName)
|
||||||
seasonRegex.MatchString(result.FileName)
|
isTV := season != ""
|
||||||
|
|
||||||
// Detect season for TV (S01E05 or 1x05 format)
|
|
||||||
var season string
|
|
||||||
if m := episodeRegex.FindStringSubmatch(result.FileName); len(m) > 2 {
|
|
||||||
season = m[1]
|
|
||||||
isTV = true
|
|
||||||
} else if m := altEpRegex.FindStringSubmatch(result.FileName); len(m) > 2 {
|
|
||||||
season = fmt.Sprintf("%02s", m[1])
|
|
||||||
isTV = true
|
|
||||||
} else if m := seasonRegex.FindStringSubmatch(result.FileName); len(m) > 1 {
|
|
||||||
season = m[1]
|
|
||||||
isTV = true
|
|
||||||
}
|
|
||||||
|
|
||||||
var destDir string
|
var destDir string
|
||||||
if isTV && cfg.TVShowsDir != "" {
|
if isTV && cfg.TVShowsDir != "" {
|
||||||
|
|
@ -69,34 +140,38 @@ func organize(result *Result, task *Task, cfg OrganizeConfig) (string, error) {
|
||||||
destDir = filepath.Join(cfg.MoviesDir, movieName)
|
destDir = filepath.Join(cfg.MoviesDir, movieName)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return result.FilePath, nil // no organize dirs configured
|
return result.FilePath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate destination is within the expected base directory
|
return moveToDir(result, destDir, "", cfg)
|
||||||
var baseDir string
|
}
|
||||||
if isTV && cfg.TVShowsDir != "" {
|
|
||||||
baseDir = cfg.TVShowsDir
|
// moveToDir handles the actual directory creation and file move, including path traversal check.
|
||||||
} else {
|
// If destFileName is non-empty, the file is renamed to that name (instead of keeping the original).
|
||||||
baseDir = cfg.MoviesDir
|
func moveToDir(result *Result, destDir, destFileName string, cfg OrganizeConfig) (string, error) {
|
||||||
}
|
// Validate destination is within an expected base directory
|
||||||
if !isWithinDir(baseDir, destDir) {
|
if !((cfg.TVShowsDir != "" && isWithinDir(cfg.TVShowsDir, destDir)) ||
|
||||||
return "", fmt.Errorf("path traversal blocked: %q escapes %q", destDir, baseDir)
|
(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 {
|
if err := os.MkdirAll(destDir, 0o755); err != nil {
|
||||||
return "", fmt.Errorf("create dir: %w", err)
|
return "", fmt.Errorf("create dir: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
destPath := filepath.Join(destDir, filepath.Base(result.FilePath))
|
fileName := filepath.Base(result.FilePath)
|
||||||
|
if destFileName != "" {
|
||||||
|
fileName = destFileName
|
||||||
|
}
|
||||||
|
destPath := filepath.Join(destDir, fileName)
|
||||||
|
|
||||||
// Check if source is a directory (multi-file torrent)
|
|
||||||
srcInfo, err := os.Stat(result.FilePath)
|
srcInfo, err := os.Stat(result.FilePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("stat source: %w", err)
|
return "", fmt.Errorf("stat source: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if srcInfo.IsDir() {
|
if srcInfo.IsDir() {
|
||||||
// For directories: remove existing destination if present, then rename
|
|
||||||
if _, err := os.Stat(destPath); err == nil {
|
if _, err := os.Stat(destPath); err == nil {
|
||||||
os.RemoveAll(destPath)
|
os.RemoveAll(destPath)
|
||||||
}
|
}
|
||||||
|
|
@ -106,7 +181,6 @@ func organize(result *Result, task *Task, cfg OrganizeConfig) (string, error) {
|
||||||
return destPath, nil
|
return destPath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try rename first (same filesystem), fall back to copy+delete
|
|
||||||
if err := os.Rename(result.FilePath, destPath); err != nil {
|
if err := os.Rename(result.FilePath, destPath); err != nil {
|
||||||
if err := copyFile(result.FilePath, destPath); err != nil {
|
if err := copyFile(result.FilePath, destPath); err != nil {
|
||||||
return "", fmt.Errorf("move file: %w", err)
|
return "", fmt.Errorf("move file: %w", err)
|
||||||
|
|
@ -114,9 +188,162 @@ func organize(result *Result, task *Task, cfg OrganizeConfig) (string, error) {
|
||||||
os.Remove(result.FilePath)
|
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
|
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.
|
// cleanTitle extracts a clean title from a torrent title string.
|
||||||
func cleanTitle(title string) string {
|
func cleanTitle(title string) string {
|
||||||
// Remove year and everything after common separators
|
// Remove year and everything after common separators
|
||||||
|
|
|
||||||
|
|
@ -158,6 +158,385 @@ func TestOrganizeSeasonOnly(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Tests for server metadata organize path ---
|
||||||
|
|
||||||
|
func intPtr(v int) *int { return &v }
|
||||||
|
|
||||||
|
func TestOrganizeShowWithMetadata(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
srcFile := filepath.Join(tmp, "Frieren.Beyond.Journeys.End.S01E03.1080p.WEB-DL.mkv")
|
||||||
|
os.WriteFile(srcFile, []byte("data"), 0o644)
|
||||||
|
|
||||||
|
tvDir := filepath.Join(tmp, "TV Shows")
|
||||||
|
|
||||||
|
r := &Result{FilePath: srcFile, FileName: "Frieren.Beyond.Journeys.End.S01E03.1080p.WEB-DL.mkv"}
|
||||||
|
task := &Task{
|
||||||
|
Title: "Frieren.Beyond.Journeys.End.S01E03.1080p.WEB-DL",
|
||||||
|
ContentType: "show",
|
||||||
|
ContentTitle: "Frieren: Beyond Journey's End",
|
||||||
|
Season: intPtr(1),
|
||||||
|
Episode: intPtr(3),
|
||||||
|
}
|
||||||
|
|
||||||
|
path, err := organize(r, task, OrganizeConfig{
|
||||||
|
Enabled: true,
|
||||||
|
TVShowsDir: tvDir,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should be: TV Shows/Frieren - Beyond Journey's End/Season 01/Frieren - Beyond Journey's End - S01E03.mkv
|
||||||
|
dir := filepath.Dir(path)
|
||||||
|
if filepath.Base(dir) != "Season 01" {
|
||||||
|
t.Errorf("expected Season 01 directory, got %q", filepath.Base(dir))
|
||||||
|
}
|
||||||
|
showDir := filepath.Dir(dir)
|
||||||
|
if filepath.Base(showDir) != "Frieren - Beyond Journey's End" {
|
||||||
|
t.Errorf("expected show dir 'Frieren - Beyond Journey's End', got %q", filepath.Base(showDir))
|
||||||
|
}
|
||||||
|
// Filename should be clean
|
||||||
|
base := filepath.Base(path)
|
||||||
|
if base != "Frieren - Beyond Journey's End - S01E03.mkv" {
|
||||||
|
t.Errorf("filename = %q, want 'Frieren - Beyond Journey's End - S01E03.mkv'", base)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrganizeCollectionMovieWithMetadata(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
srcFile := filepath.Join(tmp, "Knives.Out.2019.1080p.BluRay.mkv")
|
||||||
|
os.WriteFile(srcFile, []byte("data"), 0o644)
|
||||||
|
|
||||||
|
moviesDir := filepath.Join(tmp, "Movies")
|
||||||
|
|
||||||
|
r := &Result{FilePath: srcFile, FileName: "Knives.Out.2019.1080p.BluRay.mkv"}
|
||||||
|
task := &Task{
|
||||||
|
Title: "Knives.Out.2019.1080p.BluRay",
|
||||||
|
ContentType: "movie",
|
||||||
|
ContentTitle: "Knives Out",
|
||||||
|
CollectionName: "Knives Out Collection",
|
||||||
|
}
|
||||||
|
|
||||||
|
path, err := organize(r, task, OrganizeConfig{
|
||||||
|
Enabled: true,
|
||||||
|
MoviesDir: moviesDir,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should be: Movies/Knives Out Collection/Knives Out (2019)/Knives Out (2019).mkv
|
||||||
|
movieDir := filepath.Dir(path)
|
||||||
|
if filepath.Base(movieDir) != "Knives Out (2019)" {
|
||||||
|
t.Errorf("expected movie dir 'Knives Out (2019)', got %q", filepath.Base(movieDir))
|
||||||
|
}
|
||||||
|
collDir := filepath.Dir(movieDir)
|
||||||
|
if filepath.Base(collDir) != "Knives Out Collection" {
|
||||||
|
t.Errorf("expected collection dir 'Knives Out Collection', got %q", filepath.Base(collDir))
|
||||||
|
}
|
||||||
|
base := filepath.Base(path)
|
||||||
|
if base != "Knives Out (2019).mkv" {
|
||||||
|
t.Errorf("filename = %q, want 'Knives Out (2019).mkv'", base)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrganizeMovieWithMetadata(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
srcFile := filepath.Join(tmp, "Oppenheimer.2023.2160p.UHD.BluRay.mkv")
|
||||||
|
os.WriteFile(srcFile, []byte("data"), 0o644)
|
||||||
|
|
||||||
|
moviesDir := filepath.Join(tmp, "Movies")
|
||||||
|
|
||||||
|
r := &Result{FilePath: srcFile, FileName: "Oppenheimer.2023.2160p.UHD.BluRay.mkv"}
|
||||||
|
task := &Task{
|
||||||
|
Title: "Oppenheimer.2023.2160p.UHD.BluRay",
|
||||||
|
ContentType: "movie",
|
||||||
|
ContentTitle: "Oppenheimer",
|
||||||
|
}
|
||||||
|
|
||||||
|
path, err := organize(r, task, OrganizeConfig{
|
||||||
|
Enabled: true,
|
||||||
|
MoviesDir: moviesDir,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should be: Movies/Oppenheimer (2023)/Oppenheimer (2023).mkv
|
||||||
|
movieDir := filepath.Dir(path)
|
||||||
|
if filepath.Base(movieDir) != "Oppenheimer (2023)" {
|
||||||
|
t.Errorf("expected movie dir 'Oppenheimer (2023)', got %q", filepath.Base(movieDir))
|
||||||
|
}
|
||||||
|
base := filepath.Base(path)
|
||||||
|
if base != "Oppenheimer (2023).mkv" {
|
||||||
|
t.Errorf("filename = %q, want 'Oppenheimer (2023).mkv'", base)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrganizeMultipleEpisodesSameFolder(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
tvDir := filepath.Join(tmp, "TV Shows")
|
||||||
|
|
||||||
|
// Simulate two episodes of the same show
|
||||||
|
for _, ep := range []int{1, 2} {
|
||||||
|
srcFile := filepath.Join(tmp, filepath.Base(t.TempDir())+".mkv")
|
||||||
|
os.WriteFile(srcFile, []byte("data"), 0o644)
|
||||||
|
|
||||||
|
r := &Result{FilePath: srcFile, FileName: filepath.Base(srcFile)}
|
||||||
|
task := &Task{
|
||||||
|
Title: "Frieren.S01E0" + string(rune('0'+ep)) + ".1080p",
|
||||||
|
ContentType: "show",
|
||||||
|
ContentTitle: "Frieren",
|
||||||
|
Season: intPtr(1),
|
||||||
|
Episode: intPtr(ep),
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := organize(r, task, OrganizeConfig{
|
||||||
|
Enabled: true,
|
||||||
|
TVShowsDir: tvDir,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("episode %d: %v", ep, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both episodes should be in the same directory
|
||||||
|
seasonDir := filepath.Join(tvDir, "Frieren", "Season 01")
|
||||||
|
entries, err := os.ReadDir(seasonDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read season dir: %v", err)
|
||||||
|
}
|
||||||
|
if len(entries) != 2 {
|
||||||
|
t.Errorf("expected 2 files in Season 01, got %d", len(entries))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrganizeCleanupSourceDir(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
// Simulate: outputDir/TorrentName/video.mkv + junk files
|
||||||
|
outputDir := filepath.Join(tmp, "downloads")
|
||||||
|
torrentDir := filepath.Join(outputDir, "Frieren.S01E03.1080p.WEB-DL")
|
||||||
|
os.MkdirAll(torrentDir, 0o755)
|
||||||
|
|
||||||
|
srcFile := filepath.Join(torrentDir, "Frieren.S01E03.1080p.WEB-DL.mkv")
|
||||||
|
os.WriteFile(srcFile, []byte("video"), 0o644)
|
||||||
|
os.WriteFile(filepath.Join(torrentDir, "info.nfo"), []byte("nfo"), 0o644)
|
||||||
|
os.WriteFile(filepath.Join(torrentDir, "readme.txt"), []byte("txt"), 0o644)
|
||||||
|
os.WriteFile(filepath.Join(torrentDir, "website.url"), []byte("url"), 0o644)
|
||||||
|
|
||||||
|
tvDir := filepath.Join(tmp, "TV Shows")
|
||||||
|
|
||||||
|
r := &Result{FilePath: srcFile, FileName: "Frieren.S01E03.1080p.WEB-DL.mkv"}
|
||||||
|
task := &Task{
|
||||||
|
Title: "Frieren.S01E03.1080p.WEB-DL",
|
||||||
|
ContentType: "show",
|
||||||
|
ContentTitle: "Frieren",
|
||||||
|
Season: intPtr(1),
|
||||||
|
Episode: intPtr(3),
|
||||||
|
}
|
||||||
|
|
||||||
|
path, err := organize(r, task, OrganizeConfig{
|
||||||
|
Enabled: true,
|
||||||
|
TVShowsDir: tvDir,
|
||||||
|
OutputDir: outputDir,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Video should be in organized location
|
||||||
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
t.Errorf("organized file should exist at %s", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Source torrent directory should be gone (only had junk left)
|
||||||
|
if _, err := os.Stat(torrentDir); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("torrent dir should have been cleaned up: %s", torrentDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OutputDir itself should still exist
|
||||||
|
if _, err := os.Stat(outputDir); err != nil {
|
||||||
|
t.Errorf("outputDir should still exist")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrganizeNoCleanupWhenVideoRemains(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
outputDir := filepath.Join(tmp, "downloads")
|
||||||
|
torrentDir := filepath.Join(outputDir, "MultiVideoTorrent")
|
||||||
|
os.MkdirAll(torrentDir, 0o755)
|
||||||
|
|
||||||
|
srcFile := filepath.Join(torrentDir, "episode1.mkv")
|
||||||
|
os.WriteFile(srcFile, []byte("video1"), 0o644)
|
||||||
|
// Another video file remains
|
||||||
|
os.WriteFile(filepath.Join(torrentDir, "episode2.mkv"), []byte("video2"), 0o644)
|
||||||
|
|
||||||
|
tvDir := filepath.Join(tmp, "TV Shows")
|
||||||
|
|
||||||
|
r := &Result{FilePath: srcFile, FileName: "episode1.mkv"}
|
||||||
|
task := &Task{
|
||||||
|
Title: "Show S01E01",
|
||||||
|
ContentType: "show",
|
||||||
|
ContentTitle: "Show",
|
||||||
|
Season: intPtr(1),
|
||||||
|
Episode: intPtr(1),
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := organize(r, task, OrganizeConfig{
|
||||||
|
Enabled: true,
|
||||||
|
TVShowsDir: tvDir,
|
||||||
|
OutputDir: outputDir,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Torrent dir should still exist because episode2.mkv is still there
|
||||||
|
if _, err := os.Stat(torrentDir); err != nil {
|
||||||
|
t.Errorf("torrent dir should NOT be cleaned up when video files remain")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSanitizePath(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"Normal Title", "Normal Title"},
|
||||||
|
{"Title: Subtitle", "Title - Subtitle"},
|
||||||
|
{"Title/Subtitle", "Title-Subtitle"},
|
||||||
|
{"What?", "What"},
|
||||||
|
{"A*B<C>D|E", "ABCD-E"},
|
||||||
|
{" Spaces ", "Spaces"},
|
||||||
|
{"Trailing...", "Trailing"},
|
||||||
|
{"", "Unknown"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.input, func(t *testing.T) {
|
||||||
|
got := sanitizePath(tt.input)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("sanitizePath(%q) = %q, want %q", tt.input, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveYear(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
task *Task
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"from ContentYear", &Task{ContentYear: intPtr(2023), Title: "Movie.2020.1080p"}, "2023"},
|
||||||
|
{"fallback to regex", &Task{Title: "Movie.2020.1080p"}, "2020"},
|
||||||
|
{"no year", &Task{Title: "Movie.1080p"}, ""},
|
||||||
|
{"zero year fallback", &Task{ContentYear: intPtr(0), Title: "Movie.2019.mkv"}, "2019"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := resolveYear(tt.task)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("resolveYear() = %q, want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsSubtitleFile(t *testing.T) {
|
||||||
|
for _, ext := range []string{".srt", ".sub", ".ass", ".ssa", ".vtt", ".idx"} {
|
||||||
|
if !isSubtitleFile("file" + ext) {
|
||||||
|
t.Errorf("expected %s to be subtitle", ext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, ext := range []string{".mkv", ".txt", ".nfo", ".jpg"} {
|
||||||
|
if isSubtitleFile("file" + ext) {
|
||||||
|
t.Errorf("expected %s to NOT be subtitle", ext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMoveSubtitles(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
srcDir := filepath.Join(tmp, "torrent")
|
||||||
|
destDir := filepath.Join(tmp, "dest")
|
||||||
|
os.MkdirAll(srcDir, 0o755)
|
||||||
|
os.MkdirAll(destDir, 0o755)
|
||||||
|
|
||||||
|
// Create video + subtitles in source
|
||||||
|
videoPath := filepath.Join(srcDir, "Movie.2023.1080p.mkv")
|
||||||
|
os.WriteFile(videoPath, []byte("video"), 0o644)
|
||||||
|
os.WriteFile(filepath.Join(srcDir, "Movie.2023.1080p.srt"), []byte("srt"), 0o644)
|
||||||
|
os.WriteFile(filepath.Join(srcDir, "Movie.2023.1080p.en.srt"), []byte("en srt"), 0o644)
|
||||||
|
os.WriteFile(filepath.Join(srcDir, "Other.srt"), []byte("other"), 0o644) // should NOT move
|
||||||
|
|
||||||
|
moveSubtitles(videoPath, destDir, "Oppenheimer (2023).mkv")
|
||||||
|
|
||||||
|
// Renamed subtitles should be in dest
|
||||||
|
if _, err := os.Stat(filepath.Join(destDir, "Oppenheimer (2023).srt")); err != nil {
|
||||||
|
t.Error("expected Oppenheimer (2023).srt in dest")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(destDir, "Oppenheimer (2023).en.srt")); err != nil {
|
||||||
|
t.Error("expected Oppenheimer (2023).en.srt in dest")
|
||||||
|
}
|
||||||
|
// Other.srt should NOT have moved
|
||||||
|
if _, err := os.Stat(filepath.Join(srcDir, "Other.srt")); err != nil {
|
||||||
|
t.Error("Other.srt should remain in source")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMoveSubtitlesNoRename(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
srcDir := filepath.Join(tmp, "torrent")
|
||||||
|
destDir := filepath.Join(tmp, "dest")
|
||||||
|
os.MkdirAll(srcDir, 0o755)
|
||||||
|
os.MkdirAll(destDir, 0o755)
|
||||||
|
|
||||||
|
videoPath := filepath.Join(srcDir, "Movie.mkv")
|
||||||
|
os.WriteFile(videoPath, []byte("video"), 0o644)
|
||||||
|
os.WriteFile(filepath.Join(srcDir, "Movie.srt"), []byte("srt"), 0o644)
|
||||||
|
|
||||||
|
moveSubtitles(videoPath, destDir, "") // no rename
|
||||||
|
|
||||||
|
if _, err := os.Stat(filepath.Join(destDir, "Movie.srt")); err != nil {
|
||||||
|
t.Error("expected Movie.srt in dest (no rename)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrganizeMovieWithContentYear(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
srcFile := filepath.Join(tmp, "Oppenheimer.UHD.BluRay.mkv")
|
||||||
|
os.WriteFile(srcFile, []byte("data"), 0o644)
|
||||||
|
|
||||||
|
moviesDir := filepath.Join(tmp, "Movies")
|
||||||
|
|
||||||
|
r := &Result{FilePath: srcFile, FileName: "Oppenheimer.UHD.BluRay.mkv"}
|
||||||
|
task := &Task{
|
||||||
|
Title: "Oppenheimer.UHD.BluRay", // no year in title!
|
||||||
|
ContentType: "movie",
|
||||||
|
ContentTitle: "Oppenheimer",
|
||||||
|
ContentYear: intPtr(2023),
|
||||||
|
}
|
||||||
|
|
||||||
|
path, err := organize(r, task, OrganizeConfig{
|
||||||
|
Enabled: true,
|
||||||
|
MoviesDir: moviesDir,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should use ContentYear even though title has no year
|
||||||
|
movieDir := filepath.Dir(path)
|
||||||
|
if filepath.Base(movieDir) != "Oppenheimer (2023)" {
|
||||||
|
t.Errorf("expected movie dir 'Oppenheimer (2023)', got %q", filepath.Base(movieDir))
|
||||||
|
}
|
||||||
|
base := filepath.Base(path)
|
||||||
|
if base != "Oppenheimer (2023).mkv" {
|
||||||
|
t.Errorf("filename = %q, want 'Oppenheimer (2023).mkv'", base)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCleanTitleEdgeCases(t *testing.T) {
|
func TestCleanTitleEdgeCases(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
input string
|
input string
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,12 @@ type Task struct {
|
||||||
NzbPassword string // Password for encrypted NZB archives
|
NzbPassword string // Password for encrypted NZB archives
|
||||||
ReplacePath string // File to replace after download (upgrade mode)
|
ReplacePath string // File to replace after download (upgrade mode)
|
||||||
LibraryItemID int // Library item being upgraded
|
LibraryItemID int // Library item being upgraded
|
||||||
|
ContentType string // "movie" | "show" — from server metadata
|
||||||
|
ContentTitle string // Clean title from TMDB
|
||||||
|
Season *int // Season number
|
||||||
|
Episode *int // Episode number
|
||||||
|
ContentYear *int // Year from TMDB (avoids regex on torrent title)
|
||||||
|
CollectionName string // Collection name (e.g., "Harry Potter Collection")
|
||||||
|
|
||||||
// Runtime state
|
// Runtime state
|
||||||
Status TaskStatus
|
Status TaskStatus
|
||||||
|
|
@ -92,6 +98,12 @@ func NewTaskFromAgent(at agent.Task) *Task {
|
||||||
NzbPassword: at.NzbPassword,
|
NzbPassword: at.NzbPassword,
|
||||||
ReplacePath: at.ReplacePath,
|
ReplacePath: at.ReplacePath,
|
||||||
LibraryItemID: at.LibraryItemID,
|
LibraryItemID: at.LibraryItemID,
|
||||||
|
ContentType: at.ContentType,
|
||||||
|
ContentTitle: at.ContentTitle,
|
||||||
|
ContentYear: at.ContentYear,
|
||||||
|
Season: at.Season,
|
||||||
|
Episode: at.Episode,
|
||||||
|
CollectionName: at.CollectionName,
|
||||||
Mode: mode,
|
Mode: mode,
|
||||||
Status: StatusClaimed,
|
Status: StatusClaimed,
|
||||||
ClaimedAt: time.Now(),
|
ClaimedAt: time.Now(),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue