From 819c727bf5bc7a7b17e5cef840daba4c9dc297b7 Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Sun, 5 Apr 2026 23:36:01 +0200 Subject: [PATCH] feat(organize): use server metadata for file organization and subtitle handling --- internal/agent/types.go | 6 + internal/cmd/daemon.go | 1 + internal/cmd/download.go | 1 + internal/engine/organize.go | 289 ++++++++++++++++-- internal/engine/organize_expand_test.go | 379 ++++++++++++++++++++++++ internal/engine/task.go | 12 + 6 files changed, 657 insertions(+), 31 deletions(-) diff --git a/internal/agent/types.go b/internal/agent/types.go index 09bf9bf..94e4751 100644 --- a/internal/agent/types.go +++ b/internal/agent/types.go @@ -71,6 +71,12 @@ type Task struct { ReplacePath string `json:"replacePath,omitempty"` // File to replace after download (upgrade mode) LibraryItemID int `json:"libraryItemId,omitempty"` // Library item being upgraded 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. diff --git a/internal/cmd/daemon.go b/internal/cmd/daemon.go index 06634e4..958b379 100644 --- a/internal/cmd/daemon.go +++ b/internal/cmd/daemon.go @@ -232,6 +232,7 @@ func runDaemonStart() error { Enabled: cfg.Organize.Enabled, MoviesDir: cfg.Organize.MoviesDir, TVShowsDir: cfg.Organize.TVShowsDir, + OutputDir: cfg.Download.Dir, }, }, reporter, torrentDl, debridDl, engine.NewUsenetDownloader(httpT.Client())) diff --git a/internal/cmd/download.go b/internal/cmd/download.go index 98e77d5..d7b150f 100644 --- a/internal/cmd/download.go +++ b/internal/cmd/download.go @@ -110,6 +110,7 @@ func runDownload(input, method string) error { Enabled: cfg.Organize.Enabled, MoviesDir: cfg.Organize.MoviesDir, TVShowsDir: cfg.Organize.TVShowsDir, + OutputDir: outputDir, }, }, reporter, torrentDl, debridDl) diff --git a/internal/engine/organize.go b/internal/engine/organize.go index ea2eec4..3026c3f 100644 --- a/internal/engine/organize.go +++ b/internal/engine/organize.go @@ -3,6 +3,7 @@ package engine import ( "fmt" "io" + "log" "os" "path/filepath" "regexp" @@ -15,6 +16,17 @@ var ( 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. @@ -22,36 +34,95 @@ 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. -// 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) { 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 } - isTV := strings.Contains(strings.ToLower(task.PreferredMethod), "show") || - seasonRegex.MatchString(result.FileName) - - // 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 - } + season := detectSeason(result.FileName) + isTV := season != "" var destDir string if isTV && cfg.TVShowsDir != "" { @@ -69,34 +140,38 @@ func organize(result *Result, task *Task, cfg OrganizeConfig) (string, error) { destDir = filepath.Join(cfg.MoviesDir, movieName) } } else { - return result.FilePath, nil // no organize dirs configured + return result.FilePath, nil } - // Validate destination is within the expected base directory - var baseDir string - if isTV && cfg.TVShowsDir != "" { - baseDir = cfg.TVShowsDir - } else { - baseDir = cfg.MoviesDir - } - if !isWithinDir(baseDir, destDir) { - return "", fmt.Errorf("path traversal blocked: %q escapes %q", destDir, baseDir) + 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) } - 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) if err != nil { return "", fmt.Errorf("stat source: %w", err) } if srcInfo.IsDir() { - // For directories: remove existing destination if present, then rename if _, err := os.Stat(destPath); err == nil { os.RemoveAll(destPath) } @@ -106,7 +181,6 @@ func organize(result *Result, task *Task, cfg OrganizeConfig) (string, error) { return destPath, nil } - // Try rename first (same filesystem), fall back to copy+delete if err := os.Rename(result.FilePath, destPath); err != nil { if err := copyFile(result.FilePath, destPath); err != nil { 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) } + // 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 diff --git a/internal/engine/organize_expand_test.go b/internal/engine/organize_expand_test.go index 0a7d2f2..272011c 100644 --- a/internal/engine/organize_expand_test.go +++ b/internal/engine/organize_expand_test.go @@ -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*BD|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) { tests := []struct { input string diff --git a/internal/engine/task.go b/internal/engine/task.go index d07a689..27c7462 100644 --- a/internal/engine/task.go +++ b/internal/engine/task.go @@ -52,6 +52,12 @@ type Task struct { NzbPassword string // Password for encrypted NZB archives ReplacePath string // File to replace after download (upgrade mode) 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 Status TaskStatus @@ -92,6 +98,12 @@ func NewTaskFromAgent(at agent.Task) *Task { NzbPassword: at.NzbPassword, ReplacePath: at.ReplacePath, LibraryItemID: at.LibraryItemID, + ContentType: at.ContentType, + ContentTitle: at.ContentTitle, + ContentYear: at.ContentYear, + Season: at.Season, + Episode: at.Episode, + CollectionName: at.CollectionName, Mode: mode, Status: StatusClaimed, ClaimedAt: time.Now(),