2026-06-08 22:04:22 -07:00
|
|
|
import XCTest
|
|
|
|
|
@testable import TVAnarchyCore
|
|
|
|
|
|
2026-06-09 22:22:56 -07:00
|
|
|
/// Locks the port of tv-anarchy-mcp's library.ts scan logic — name
|
2026-06-08 22:04:22 -07:00
|
|
|
/// 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() {
|
2026-06-30 00:12:41 -04:00
|
|
|
let lib = LibraryController(watchHistory: WatchHistoryController())
|
2026-06-08 22:04:22 -07:00
|
|
|
let ep = CachedEpisode(path: "/m/Psych/S01E04.mkv", season: 1, episode: 4, label: "E4")
|
|
|
|
|
let show = CachedShow(name: "Psych", rootDir: "/m/Psych", episodes: [ep])
|
|
|
|
|
|
2026-06-09 05:50:01 -07:00
|
|
|
// 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).
|
2026-06-08 22:04:22 -07:00
|
|
|
XCTAssertEqual(lib.launchRequest(show: show, episode: ep, targetKind: .blacktv),
|
2026-06-09 05:50:01 -07:00
|
|
|
.file(path: "/m/Psych/S01E04.mkv"))
|
|
|
|
|
// black with no episode → resume target by PATH (here the only episode); no
|
|
|
|
|
// name resolution anywhere anymore.
|
2026-06-08 22:04:22 -07:00
|
|
|
XCTAssertEqual(lib.launchRequest(show: show, episode: nil, targetKind: .blacktv),
|
2026-06-09 05:50:01 -07:00
|
|
|
.file(path: "/m/Psych/S01E04.mkv"))
|
2026-06-08 22:04:22 -07:00
|
|
|
// 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"))
|
|
|
|
|
}
|
2026-06-09 05:50:01 -07:00
|
|
|
|
|
|
|
|
/// 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() {
|
2026-06-30 00:12:41 -04:00
|
|
|
let lib = LibraryController(watchHistory: WatchHistoryController())
|
2026-06-09 05:50:01 -07:00
|
|
|
// 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)
|
|
|
|
|
}
|
2026-06-08 22:04:22 -07:00
|
|
|
}
|