import XCTest @testable import TVAnarchyCore /// Locks the port of tv-anarchy-mcp's library.ts scan logic — name /// normalization and SxxEyy parsing must match so black's `play-show` fuzzy /// match resolves the same titles the Swift scan produces. final class LibraryScannerTests: XCTestCase { func testParseSxxEyy() { XCTAssertEqual(LibraryScanner.parseSxxEyy("Psych.S01E04.720p.mkv").map { [$0.0, $0.1] }, [1, 4]) XCTAssertEqual(LibraryScanner.parseSxxEyy("show s02e15 web.mp4").map { [$0.0, $0.1] }, [2, 15]) XCTAssertEqual(LibraryScanner.parseSxxEyy("Anime - S01E120.mkv").map { [$0.0, $0.1] }, [1, 120]) XCTAssertNil(LibraryScanner.parseSxxEyy("just-a-movie-1080p.mkv")) } func testNormalizeStripsReleaseNoise() { XCTAssertEqual(LibraryScanner.normalizeShowName("Psych.S01.1080p.BluRay.x265-GROUP"), "Psych") XCTAssertEqual(LibraryScanner.normalizeShowName("Community (2009) [1080p]"), "Community") XCTAssertEqual(LibraryScanner.normalizeShowName("The.Office.US.Complete.720p"), "The Office US") XCTAssertEqual(LibraryScanner.normalizeShowName("Bridgerton.S02.HEVC"), "Bridgerton") } func testNormalizeKeepsPlainName() { XCTAssertEqual(LibraryScanner.normalizeShowName("Psych"), "Psych") XCTAssertEqual(LibraryScanner.normalizeShowName("It's Always Sunny"), "It's Always Sunny") } func testRegistryParse() { let md = """ # Media Registry _Generated 2026-05-28 on black_ ## tv (164) - Santa Clarita Diet — S02 720p x264 - Agatha All Along (2024) - Babylon 5 (1993) — S01-S05 """ let shows = RegistryIngest.parse(md) let names = shows.map(\.name) XCTAssertTrue(names.contains("Santa Clarita Diet"), "got \(names)") XCTAssertTrue(names.contains("Agatha All Along")) XCTAssertTrue(names.contains("Babylon 5")) XCTAssertFalse(names.contains(where: { $0.contains("Generated") }), "header leaked: \(names)") XCTAssertTrue(shows.allSatisfy { $0.episodes.isEmpty }) // Season range from the note → known season count (not "0/0"). let babylon = shows.first { $0.name == "Babylon 5" } XCTAssertEqual(babylon?.seasonCount, 5) // S01-S05 XCTAssertEqual(babylon?.knownSeasonCount, 5) XCTAssertEqual(shows.first { $0.name == "Santa Clarita Diet" }?.seasonCount, 1) // S02 XCTAssertEqual(shows.first { $0.name == "Agatha All Along" }?.seasonCount, nil) // no note XCTAssertEqual(babylon?.countSummary, "5 seasons") } func testSeasonCountParsing() { XCTAssertEqual(RegistryIngest.seasonCount(from: "S01-S05 720p H264"), 5) XCTAssertEqual(RegistryIngest.seasonCount(from: "S02 720p x264"), 1) XCTAssertEqual(RegistryIngest.seasonCount(from: "S01-S06"), 6) XCTAssertNil(RegistryIngest.seasonCount(from: "720p BluRay x265")) } func testFranchisePrefixBoundary() { XCTAssertTrue(LibraryController.nameHasPrefix("psych 2 lassie come home", "psych")) XCTAssertTrue(LibraryController.nameHasPrefix("psych: the movie", "psych")) XCTAssertTrue(LibraryController.nameHasPrefix("psych 3 this is gus", "psych")) XCTAssertFalse(LibraryController.nameHasPrefix("psycho", "psych")) XCTAssertFalse(LibraryController.nameHasPrefix("psychoelectric girl", "psych")) XCTAssertFalse(LibraryController.nameHasPrefix("psych", "psych")) } func testParseYear() { XCTAssertEqual(LibraryScanner.parseYear("/m/tv/Psych (2006)/Season 1/S01E06.mkv"), 2006) XCTAssertEqual(LibraryScanner.parseYear("/m/movies/The Matrix 1999 1080p.mkv"), 1999) XCTAssertNil(LibraryScanner.parseYear("/m/movies/No Year Here.mkv")) } func testComponentsAfterAndSampleDetection() { XCTAssertEqual(LibraryScanner.componentsAfter(mediaRoot: "/media", path: "/media/movies/Inception"), ["movies", "Inception"]) XCTAssertEqual(LibraryScanner.componentsAfter(mediaRoot: "/media", path: "/media/tv"), ["tv"]) XCTAssertTrue(LibraryScanner.isSampleOrExtra("/m/Film/sample.mkv")) XCTAssertTrue(LibraryScanner.isSampleOrExtra("/m/Film/Film-trailer.mp4")) XCTAssertFalse(LibraryScanner.isSampleOrExtra("/m/Film/Film.1080p.mkv")) } /// scan() must bucket by category, keep SxxEyy folders as series, and capture /// non-episodic videos as movies (folder → largest non-sample file; loose /// files → one each) — the latter were silently dropped before. func testScanCategoriesSeriesAndMovies() throws { let fm = FileManager.default let root = fm.temporaryDirectory.appendingPathComponent("plumscan-\(UUID().uuidString)") let tv = root.appendingPathComponent("tv/Psych/Season 1") let film = root.appendingPathComponent("movies/Inception (2010)") try fm.createDirectory(at: tv, withIntermediateDirectories: true) try fm.createDirectory(at: film, withIntermediateDirectories: true) try Data(count: 10).write(to: tv.appendingPathComponent("Psych.S01E01.720p.mkv")) try Data(count: 10).write(to: film.appendingPathComponent("sample.mkv")) try Data(count: 9000).write(to: film.appendingPathComponent("Inception.2010.1080p.mkv")) try Data(count: 500).write(to: root.appendingPathComponent("movies/Loose.Movie.2020.1080p.mkv")) setenv("MEDIA_ROOTS", root.path, 1) defer { unsetenv("MEDIA_ROOTS"); try? fm.removeItem(at: root) } let shows = LibraryScanner.scan() let psych = shows.first { $0.name == "Psych" } XCTAssertEqual(psych?.kind, .series) XCTAssertEqual(psych?.category, "tv") XCTAssertEqual(psych?.episodes.count, 1) let inception = shows.first { $0.name == "Inception" } XCTAssertEqual(inception?.kind, .movie) XCTAssertEqual(inception?.category, "movies") XCTAssertEqual(inception?.episodes.first?.path.hasSuffix("Inception.2010.1080p.mkv"), true, "movie folder should pick the main file, not the sample") let loose = shows.first { $0.name == "Loose Movie" } XCTAssertEqual(loose?.kind, .movie) XCTAssertEqual(loose?.category, "movies") } /// Bridgerton ships as sibling top-level folders (Bridgerton.S01, .S02, .S03) /// that each scan to a separate "Bridgerton" series. mergeSeriesByName must /// collapse them into one show, unioning seasons and deduping by SxxEyy, while /// leaving distinct titles and movies untouched. func testMergeSeriesByName() { func series(_ name: String, _ cat: String, _ eps: [(Int, Int)], year: Int? = nil) -> CachedShow { CachedShow(name: name, rootDir: "/m/\(cat)/\(name)", category: cat, kind: .series, episodes: eps.map { CachedEpisode(path: "/m/\(cat)/\(name)/S\($0.0)E\($0.1).mkv", season: $0.0, episode: $0.1, label: "E\($0.1)") }, year: year) } // Bridgerton ships as three sibling season folders — DISJOINT seasons → // collapse into one show. let s1 = series("Bridgerton", "tv", [(1, 1), (1, 2)], year: 2020) let s2 = series("Bridgerton", "tv", [(2, 1)], year: 2022) let s3 = series("Bridgerton", "tv", [(3, 1)], year: 2024) // Two genuinely different shows that share a name + category, both starting // at S01 — OVERLAPPING seasons → must stay separate, not interleave. let dupA = series("Cherry Magic", "anime", [(1, 1), (1, 2)], year: 2020) let dupB = series("Cherry Magic", "anime", [(1, 1)], year: 2024) let other = series("Psych", "tv", [(1, 1)]) let movie = CachedShow(name: "Bridgerton", rootDir: "/m/movies/Bridgerton", category: "movies", kind: .movie, episodes: [CachedEpisode(path: "/m/movies/Bridgerton/film.mkv", season: 0, episode: 0, label: "film")]) let merged = LibraryScanner.mergeSeriesByName([s1, s2, s3, dupA, dupB, other, movie]) let bridgerton = merged.filter { $0.name == "Bridgerton" && $0.kind == .series } XCTAssertEqual(bridgerton.count, 1, "three disjoint season-folders collapse to one show") XCTAssertEqual(bridgerton.first?.episodes.count, 4, "S01E01,S01E02,S02E01,S03E01") XCTAssertEqual(bridgerton.first?.year, 2020, "earliest year wins") // overlapping-season same-name shows are kept distinct XCTAssertEqual(merged.filter { $0.name == "Cherry Magic" && $0.kind == .series }.count, 2, "distinct same-name shows must not interleave") // distinct series and same-name movie are left alone XCTAssertEqual(merged.filter { $0.name == "Psych" }.count, 1) XCTAssertEqual(merged.filter { $0.kind == .movie }.count, 1) } /// Loose files sharing a category root must each stay a movie even when one of /// them has a stray SxxEyy in its name — that match must NOT flip the whole pile /// into a single "series" and drop the rest (the bug that ate ~940 loose files). func testLooseFilesWithStrayEpisodeMatchStayMovies() throws { let fm = FileManager.default let root = fm.temporaryDirectory.appendingPathComponent("plumscan-\(UUID().uuidString)") let cat = root.appendingPathComponent("clips") try fm.createDirectory(at: cat, withIntermediateDirectories: true) try Data(count: 100).write(to: cat.appendingPathComponent("A Random Clip.mp4")) try Data(count: 100).write(to: cat.appendingPathComponent("Another Clip.mp4")) try Data(count: 100).write(to: cat.appendingPathComponent("Some S01E01 thing.mp4")) setenv("MEDIA_ROOTS", root.path, 1) defer { unsetenv("MEDIA_ROOTS"); try? fm.removeItem(at: root) } let clips = LibraryScanner.scan().filter { $0.category == "clips" } XCTAssertEqual(clips.count, 3, "all 3 loose files are movies; the stray SxxEyy one must not eat the others") XCTAssertTrue(clips.allSatisfy { $0.kind == .movie }) } @MainActor func testLaunchRequestRoutingByKind() { let lib = LibraryController(watchHistory: WatchHistoryController()) let ep = CachedEpisode(path: "/m/Psych/S01E04.mkv", season: 1, episode: 4, label: "E4") let show = CachedShow(name: "Psych", rootDir: "/m/Psych", episodes: [ep]) // black plays a CHOSEN episode by its exact path (not by re-resolving the // show name — which misses a merged multi-season-folder show like Daria S3). XCTAssertEqual(lib.launchRequest(show: show, episode: ep, targetKind: .blacktv), .file(path: "/m/Psych/S01E04.mkv")) // black with no episode → resume target by PATH (here the only episode); no // name resolution anywhere anymore. XCTAssertEqual(lib.launchRequest(show: show, episode: nil, targetKind: .blacktv), .file(path: "/m/Psych/S01E04.mkv")) // VLC needs a concrete file path XCTAssertEqual(lib.launchRequest(show: show, episode: ep, targetKind: .vlc), .file(path: "/m/Psych/S01E04.mkv")) XCTAssertEqual(lib.launchRequest(show: show, episode: nil, targetKind: .vlc), .file(path: "/m/Psych/S01E04.mkv")) } /// Regression for the Daria bug — but general: a show MERGED from separate /// per-season folders (each season under its own top-level dir) must play EVERY /// season's episode by that episode's real path on black, not by re-resolving the /// show name (which found only the primary folder → S3 unplayable). Covers the /// whole class of `mergeSeriesByName` shows, not just Daria. @MainActor func testMergedMultiFolderShowPlaysEachSeasonByPath() { let lib = LibraryController(watchHistory: WatchHistoryController()) // Episodes live under DIFFERENT season folders, as on disk after a merge. let s2 = CachedEpisode(path: "/m/cartoons/Daria Season 2 [grpA]/Daria S02E01.mkv", season: 2, episode: 1, label: "S2E1") let s3 = CachedEpisode(path: "/m/cartoons/Daria Season 3 [grpB]/Daria S03E01.mkv", season: 3, episode: 1, label: "S3E1") let s4 = CachedEpisode(path: "/m/cartoons/Daria Season 4 [grpC]/Daria S04E01.mkv", season: 4, episode: 1, label: "S4E1") // rootDir is the Season-4 folder (what the merge happened to pick) — the very // mismatch that broke name-resolution for S2/S3. let daria = CachedShow(name: "Daria", rootDir: "/m/cartoons/Daria Season 4 [grpC]", category: "cartoons", kind: .series, episodes: [s2, s3, s4]) for ep in [s2, s3, s4] { XCTAssertEqual(lib.launchRequest(show: daria, episode: ep, targetKind: .mpvIPC), .file(path: ep.path), "S\(ep.season) must play its own folder's file") } // …and the unified queue from S2 walks straight into S3+ by path (no name step). let slice = PlaylistController.fromHere(show: daria, startPath: s2.path).map(\.path) XCTAssertEqual(slice, [s2.path, s3.path, s4.path]) XCTAssertTrue(slice.contains(s3.path)) // S3 is reachable — the original complaint // Show-level Resume is ALSO path-based now (no `.resume` name lookup): // an in-progress S3 resumes S3 at its position… let positions = [MediaPaths.toRemote(s3.path): 600.0] let t = LibraryController.resumeTarget(for: daria, positions: positions, played: []) XCTAssertEqual(t?.episode.path, s3.path) XCTAssertEqual(t?.position, 600) // …and with nothing in progress, Resume = the next unwatched (S2 watched → S3). let next = LibraryController.resumeTarget( for: daria, positions: [:], played: [MediaPaths.toRemote(s2.path)]) XCTAssertEqual(next?.episode.path, s3.path) XCTAssertNil(next?.position) } /// Regression for "press Resume on Daria → it starts S2". A stray, sub-threshold /// VLC scrub on the FURTHEST-touched episode (S2E6 @82s) must not pin Resume to S2: /// that episode is behind the frontier, so Resume advances to the next one (S3E1) /// from its start. A larger position on an EARLIER episode (S2E5 @838s) is /// irrelevant — only the furthest-touched episode drives the frontier. A genuinely /// mid-watch position (≥ `resumeMidEpisodeFloor`) on the furthest episode still /// resumes it in place. @MainActor func testResumeAdvancesPastStrayScrubOnFurthestEpisode() { let s2e5 = CachedEpisode(path: "/m/cartoons/Daria Season 2 [grpA]/Daria S02E05.mkv", season: 2, episode: 5, label: "S2E5") let s2e6 = CachedEpisode(path: "/m/cartoons/Daria Season 2 [grpA]/Daria S02E06.mkv", season: 2, episode: 6, label: "S2E6") let s3e1 = CachedEpisode(path: "/m/cartoons/Daria Season 3 [grpB]/Daria S03E01.mkv", season: 3, episode: 1, label: "S3E1") let s3e2 = CachedEpisode(path: "/m/cartoons/Daria Season 3 [grpB]/Daria S03E02.mkv", season: 3, episode: 2, label: "S3E2") let daria = CachedShow(name: "Daria", rootDir: "/m/cartoons/Daria Season 3 [grpB]", category: "cartoons", kind: .series, episodes: [s2e5, s2e6, s3e1, s3e2]) // Stray scrubs across S2 — furthest is S2E6 @82s (below the floor) → advance to S3E1. let stray = [MediaPaths.toRemote(s2e5.path): 838.0, MediaPaths.toRemote(s2e6.path): 82.0] let r = LibraryController.resumeTarget(for: daria, positions: stray, played: []) XCTAssertEqual(r?.episode.path, s3e1.path, "advance past the furthest scrub to the next episode") XCTAssertNil(r?.position) // But a real mid-watch position on the furthest episode resumes it in place. let midWatch = [MediaPaths.toRemote(s3e1.path): 600.0] let r2 = LibraryController.resumeTarget(for: daria, positions: midWatch, played: []) XCTAssertEqual(r2?.episode.path, s3e1.path) XCTAssertEqual(r2?.position, 600) } }