package naming import "testing" func TestDetectSeriesRoot(t *testing.T) { tests := []struct { name string path string libraryType string wantRoot string wantOK bool }{ { name: "standard season directory", path: "/tv/Breaking Bad/Season 01/Breaking.Bad.S01E01.mkv", wantRoot: "/tv/Breaking Bad", wantOK: true, }, { name: "season directory case insensitive", path: "/tv/Show Name/season 03/Show.Name.S03E01.mkv", wantRoot: "/tv/Show Name", wantOK: true, }, { name: "one pace arc folder collapses to series root", path: "/television/anime/One Pace/Season 01 - Arc 01 - Romance Dawn/One.Piece.E01.1080p.mkv", wantRoot: "/television/anime/One Pace", wantOK: true, }, { name: "specials directory", path: "/tv/Show Name/Specials/Show.Name.S00E01.mkv", wantRoot: "/tv/Show Name", wantOK: true, }, { name: "episodic file without season dir", path: "/tv/Show Name/Show.Name.S01E01.mkv", wantRoot: "/tv/Show Name", wantOK: true, }, { name: "movie file no series root", path: "/movies/Arrival (2016)/Arrival.2016.1080p.mkv", wantRoot: "", wantOK: false, }, { name: "year folder is not a season dir", path: "/movies/2024/Some.Movie.mkv", wantRoot: "", wantOK: false, }, { name: "1080p in path is not a season dir", path: "/movies/Movie Name/1080p/Movie.Name.mkv", wantRoot: "", wantOK: false, }, { name: "movie library ignores episode shaped movie folder", path: "/movies/s01e03 (2020) {imdb-tt12261772} {tmdb-588077}/s01e03 (2020).mkv", libraryType: "movies", wantRoot: "", wantOK: false, }, { name: "mixed library movie folder beats bare episode token", path: "/mixed/s01e03 (2020) {imdb-tt12261772} {tmdb-588077}/s01e03 (2020).mkv", libraryType: "mixed", wantRoot: "", wantOK: false, }, { name: "series library remains authoritative", path: "/tv/Standalone Folder/random-file.mkv", libraryType: "series", wantRoot: "/tv/Standalone Folder", wantOK: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { root, ok := DetectSeriesRoot(tt.path, tt.libraryType) if ok != tt.wantOK { t.Fatalf("DetectSeriesRoot(%q, %q) ok = %v, want %v", tt.path, tt.libraryType, ok, tt.wantOK) } if !tt.wantOK { return } if root.RootPath != tt.wantRoot { t.Errorf("DetectSeriesRoot(%q, %q) root = %q, want %q", tt.path, tt.libraryType, root.RootPath, tt.wantRoot) } }) } } func TestParseFilename(t *testing.T) { tests := []struct { name string path string libraryType string wantTitle string wantYear int wantType string wantSeason int wantEp int wantAirDate string }{ { name: "standard series episode", path: "/tv/Breaking Bad (2008)/Season 01/Breaking.Bad.S01E01.mkv", wantTitle: "Breaking Bad", wantYear: 2008, wantType: "series", wantSeason: 1, wantEp: 1, }, { name: "extras maps to season zero", path: "/tv/Show Name/Extras/Show.Name.S00E01.mkv", wantTitle: "Show Name", wantYear: 0, wantType: "series", wantSeason: 0, wantEp: 1, }, { name: "anime release with decimal episode suffix and release junk", path: "/television/anime/A Sister's All You Need (2017) {tvdb-332994}/Season 01/A Sister's All You Need (2017) - S01E01.001 - I Only Need a Little Brother Who Can Cook a Beautiful Naked Girl and Friends I Can Relate to [Bluray-1080p][10bit][x265][AC3 5.1][EN+JA]-GHOST][.mkv", wantTitle: "A Sister's All You Need", wantYear: 2017, wantType: "series", wantSeason: 1, wantEp: 1, }, { name: "movie library episode token folder is movie", path: "/movies/s01e03 (2020) {imdb-tt12261772} {tmdb-588077}/s01e03 (2020).mkv", libraryType: "movies", wantTitle: "s01e03", wantYear: 2020, wantType: "movie", wantSeason: 0, wantEp: 0, }, { name: "mixed library obvious movie folder beats episode token", path: "/mixed/s01e03 (2020) {imdb-tt12261772} {tmdb-588077}/s01e03 (2020).mkv", libraryType: "mixed", wantTitle: "s01e03", wantYear: 2020, wantType: "movie", wantSeason: 0, wantEp: 0, }, { name: "mixed library flat tv folder stays series", path: "/mixed/Show Name/Show Name S01E03.mkv", libraryType: "mixed", wantTitle: "Show Name", wantYear: 0, wantType: "series", wantSeason: 1, wantEp: 3, }, { name: "mixed library season dir stays series", path: "/mixed/Show Name/Season 01/Show Name S01E03.mkv", libraryType: "mixed", wantTitle: "Show Name", wantYear: 0, wantType: "series", wantSeason: 1, wantEp: 3, }, { name: "movies library tv looking path stays movie", path: "/movies/Show Name/Season 01/Show Name S01E03.mkv", libraryType: "movies", wantTitle: "Show Name S01E03", wantYear: 0, wantType: "movie", wantSeason: 0, wantEp: 0, }, { name: "series daily date episode", path: "/tv/Jeopardy! (1984)/Season 2026/Jeopardy! (1984) - 2026-04-24 - Jamie Ding Zach Pollock Nicco Martinez.mkv", libraryType: "series", wantTitle: "Jeopardy!", wantYear: 1984, wantType: "series", wantSeason: 2026, wantEp: 0, wantAirDate: "2026-04-24", }, { name: "series daily date episode with dotted separator", path: "/tv/The Daily Show/The.Daily.Show.2024.02.15.Guest.Name.mkv", libraryType: "series", wantTitle: "The Daily Show", wantYear: 0, wantType: "series", wantSeason: 0, wantEp: 0, wantAirDate: "2024-02-15", }, { name: "movie library date does not become tv air date", path: "/movies/News Archive (2026)/News Archive 2026-04-24.mkv", libraryType: "movies", wantTitle: "News Archive", wantYear: 2026, wantType: "movie", wantSeason: 0, wantEp: 0, wantAirDate: "", }, { name: "movie in folder with year", path: "/movies/Arrival (2016)/Arrival.2016.1080p.mkv", wantTitle: "Arrival", wantYear: 2016, wantType: "movie", wantSeason: 0, wantEp: 0, }, { name: "movie with imdbid tag no year", path: "/movies/Big Buck Bunny [imdbid-tt1254207]/Big Buck Bunny.mp4", wantTitle: "Big Buck Bunny", wantYear: 0, wantType: "movie", wantSeason: 0, wantEp: 0, }, { name: "1080p in filename is not a year", path: "/movies/Movie Name (2020)/Movie.Name.1080p.BluRay.mkv", wantTitle: "Movie Name", wantYear: 2020, wantType: "movie", wantSeason: 0, wantEp: 0, }, { name: "numeric folder not treated as season without evidence", path: "/movies/2024/Some.Movie.mkv", wantTitle: "Some.Movie", wantYear: 0, wantType: "movie", wantSeason: 0, wantEp: 0, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { h := ParseFilename(tt.path, tt.libraryType) if h.Type != tt.wantType { t.Errorf("Type = %q, want %q", h.Type, tt.wantType) } if h.Title != tt.wantTitle { t.Errorf("Title = %q, want %q", h.Title, tt.wantTitle) } if h.Year != tt.wantYear { t.Errorf("Year = %d, want %d", h.Year, tt.wantYear) } if h.SeasonNum != tt.wantSeason { t.Errorf("SeasonNum = %d, want %d", h.SeasonNum, tt.wantSeason) } if h.EpisodeNum != tt.wantEp { t.Errorf("EpisodeNum = %d, want %d", h.EpisodeNum, tt.wantEp) } if h.AirDate != tt.wantAirDate { t.Errorf("AirDate = %q, want %q", h.AirDate, tt.wantAirDate) } }) } } func TestResolvePathContext(t *testing.T) { tests := []struct { name string path string libraryType string wantType string wantRoot string wantTitle string wantYear int wantSeason int wantEpisode int wantAirDate string wantAirDatePattern bool wantEpisodePattern bool wantSeasonStructure bool wantMovieFolderEvidence bool }{ { name: "movies library episode token folder is movie", path: "/movies/s01e03 (2020) {imdb-tt12261772} {tmdb-588077}/s01e03 (2020).mkv", libraryType: "movies", wantType: "movie", wantRoot: "/movies/s01e03 (2020) {imdb-tt12261772} {tmdb-588077}", wantTitle: "s01e03", wantYear: 2020, wantEpisodePattern: true, wantSeasonStructure: false, wantMovieFolderEvidence: true, }, { name: "mixed library obvious movie folder beats episode token", path: "/mixed/s01e03 (2020) {imdb-tt12261772} {tmdb-588077}/s01e03 (2020).mkv", libraryType: "mixed", wantType: "movie", wantRoot: "/mixed/s01e03 (2020) {imdb-tt12261772} {tmdb-588077}", wantTitle: "s01e03", wantYear: 2020, wantEpisodePattern: true, wantSeasonStructure: false, wantMovieFolderEvidence: true, }, { name: "mixed flat tv folder stays series", path: "/mixed/Show Name/Show Name S01E03.mkv", libraryType: "mixed", wantType: "series", wantRoot: "/mixed/Show Name", wantTitle: "Show Name", wantSeason: 1, wantEpisode: 3, wantEpisodePattern: true, wantSeasonStructure: false, wantMovieFolderEvidence: false, }, { name: "mixed season dir stays series", path: "/mixed/Show Name/Season 01/Show Name S01E03.mkv", libraryType: "mixed", wantType: "series", wantRoot: "/mixed/Show Name", wantTitle: "Show Name", wantSeason: 1, wantEpisode: 3, wantEpisodePattern: true, wantSeasonStructure: true, wantMovieFolderEvidence: false, }, { name: "anime release with decimal episode suffix keeps show root", path: "/television/anime/A Sister's All You Need (2017) {tvdb-332994}/Season 01/A Sister's All You Need (2017) - S01E01.001 - I Only Need a Little Brother Who Can Cook a Beautiful Naked Girl and Friends I Can Relate to [Bluray-1080p][10bit][x265][AC3 5.1][EN+JA]-GHOST][.mkv", libraryType: "series", wantType: "series", wantRoot: "/television/anime/A Sister's All You Need (2017) {tvdb-332994}", wantTitle: "A Sister's All You Need", wantYear: 2017, wantSeason: 1, wantEpisode: 1, wantEpisodePattern: true, wantSeasonStructure: true, wantMovieFolderEvidence: false, }, { name: "series daily date has air date but no episode number", path: "/tv/Jeopardy! (1984)/Season 2026/Jeopardy! (1984) - 2026_04_24 - Jamie Ding.mkv", libraryType: "series", wantType: "series", wantRoot: "/tv/Jeopardy! (1984)", wantTitle: "Jeopardy!", wantYear: 1984, wantSeason: 2026, wantEpisode: 0, wantAirDate: "2026-04-24", wantAirDatePattern: true, wantEpisodePattern: false, wantSeasonStructure: true, wantMovieFolderEvidence: false, }, { name: "numeric year folder is not season structure", path: "/movies/2024/Some.Movie.mkv", wantType: "movie", wantRoot: "/movies/2024/Some.Movie", wantTitle: "Some.Movie", wantEpisodePattern: false, wantSeasonStructure: false, wantMovieFolderEvidence: false, }, { name: "id tagged movie folder beats divergent release filename", path: "/movies/The Expendables 4 {imdb-tt3291150} {tmdb-299054}/Expend4bles (2023) [Remux-1080p 8-bit AVC TrueHD Atmos 7.1]-CiNEPHiLES.mkv", libraryType: "movies", wantType: "movie", wantRoot: "/movies/The Expendables 4 {imdb-tt3291150} {tmdb-299054}", wantTitle: "The Expendables 4", wantYear: 0, wantEpisodePattern: false, wantSeasonStructure: false, wantMovieFolderEvidence: true, }, { name: "mixed library id tagged movie folder beats divergent release filename", path: "/mixed/The Expendables 4 {imdb-tt3291150} {tmdb-299054}/Expend4bles (2023) [Remux-1080p 8-bit AVC TrueHD Atmos 7.1]-CiNEPHiLES.mkv", libraryType: "mixed", wantType: "movie", wantRoot: "/mixed/The Expendables 4 {imdb-tt3291150} {tmdb-299054}", wantTitle: "The Expendables 4", wantYear: 0, wantEpisodePattern: false, wantSeasonStructure: false, wantMovieFolderEvidence: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ctx := ResolvePathContext(tt.path, tt.libraryType) if ctx == nil { t.Fatal("expected non-nil PathContext") } if ctx.Type != tt.wantType { t.Errorf("Type = %q, want %q", ctx.Type, tt.wantType) } if ctx.RootPath != tt.wantRoot { t.Errorf("RootPath = %q, want %q", ctx.RootPath, tt.wantRoot) } if ctx.Title != tt.wantTitle { t.Errorf("Title = %q, want %q", ctx.Title, tt.wantTitle) } if ctx.Year != tt.wantYear { t.Errorf("Year = %d, want %d", ctx.Year, tt.wantYear) } if ctx.SeasonNum != tt.wantSeason { t.Errorf("SeasonNum = %d, want %d", ctx.SeasonNum, tt.wantSeason) } if ctx.EpisodeNum != tt.wantEpisode { t.Errorf("EpisodeNum = %d, want %d", ctx.EpisodeNum, tt.wantEpisode) } if ctx.AirDate != tt.wantAirDate { t.Errorf("AirDate = %q, want %q", ctx.AirDate, tt.wantAirDate) } if ctx.HasAirDatePattern != tt.wantAirDatePattern { t.Errorf("HasAirDatePattern = %v, want %v", ctx.HasAirDatePattern, tt.wantAirDatePattern) } if ctx.HasEpisodePattern != tt.wantEpisodePattern { t.Errorf("HasEpisodePattern = %v, want %v", ctx.HasEpisodePattern, tt.wantEpisodePattern) } if ctx.HasSeasonStructure != tt.wantSeasonStructure { t.Errorf("HasSeasonStructure = %v, want %v", ctx.HasSeasonStructure, tt.wantSeasonStructure) } if ctx.HasMovieFolderEvidence != tt.wantMovieFolderEvidence { t.Errorf("HasMovieFolderEvidence = %v, want %v", ctx.HasMovieFolderEvidence, tt.wantMovieFolderEvidence) } }) } } func TestDetectCanonicalRoot(t *testing.T) { tests := []struct { name string path string libraryType string wantRoot string wantType string wantOK bool }{ { name: "standard series root", path: "/tv/Breaking Bad/Season 01/Breaking.Bad.S01E01.mkv", wantRoot: "/tv/Breaking Bad", wantType: "series", wantOK: true, }, { name: "movie in folder", path: "/movies/Arrival (2016)/Arrival.2016.1080p.mkv", wantRoot: "/movies/Arrival (2016)", wantType: "movie", wantOK: true, }, { name: "loose movie file uses file stem as root", path: "/movies/Arrival (2016).mkv", wantRoot: "/movies/Arrival (2016)", wantType: "movie", wantOK: true, }, { name: "mixed library obvious movie folder uses movie root", path: "/mixed/s01e03 (2020) {imdb-tt12261772} {tmdb-588077}/s01e03 (2020).mkv", libraryType: "mixed", wantRoot: "/mixed/s01e03 (2020) {imdb-tt12261772} {tmdb-588077}", wantType: "movie", wantOK: true, }, { name: "movies library tv looking path still uses movie root", path: "/movies/Show Name/Season 01/Show Name S01E03.mkv", libraryType: "movies", wantRoot: "/movies/Show Name/Season 01/Show Name S01E03", wantType: "movie", wantOK: true, }, { name: "mixed flat tv folder uses series root", path: "/mixed/Show Name/Show Name S01E03.mkv", libraryType: "mixed", wantRoot: "/mixed/Show Name", wantType: "series", wantOK: true, }, { name: "movie root uses parent when comparable normalization differs", path: "/movies/WALL-E {imdb-tt0910970}/Wall-E.mkv", wantRoot: "/movies/WALL-E {imdb-tt0910970}", wantType: "movie", wantOK: true, }, { name: "movie root uses id tagged parent for divergent release filename", path: "/movies/The Expendables 4 {imdb-tt3291150} {tmdb-299054}/Expend4bles (2023) [Remux-1080p 8-bit AVC TrueHD Atmos 7.1]-CiNEPHiLES.mkv", wantRoot: "/movies/The Expendables 4 {imdb-tt3291150} {tmdb-299054}", wantType: "movie", wantOK: true, }, { name: "absolute root-level file returns false", path: "/single-file.mkv", wantOK: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cr, ok := DetectCanonicalRoot(tt.path, tt.libraryType) if ok != tt.wantOK { t.Fatalf("DetectCanonicalRoot(%q, %q) ok = %v, want %v", tt.path, tt.libraryType, ok, tt.wantOK) } if !tt.wantOK { return } if cr.RootPath != tt.wantRoot { t.Errorf("RootPath = %q, want %q", cr.RootPath, tt.wantRoot) } if cr.Type != tt.wantType { t.Errorf("Type = %q, want %q", cr.Type, tt.wantType) } }) } }