import Foundation /// Resolved episode display fields for catalog and playback surfaces. public struct ResolvedEpisodeDisplay: Sendable, Equatable { public var displayName: String public var episodeTitle: String? public init(displayName: String, episodeTitle: String?) { self.displayName = displayName self.episodeTitle = episodeTitle } } /// Canonical episode display-name resolution โ€” **Title Library lookup first**, /// then deterministic parse, then fallbacks. Never returns a raw release filename /// when season/episode are known. public enum LibraryDisplayNames { /// Optional MLX episode refiner โ€” consulted on Title Library miss after regex. public static var episodeRefiner: (any EpisodeTitleRefiner)? /// Resolve display strings for one episode file at scan or enrich time. public static func resolve(showName: String, path: String, sidecarEpisodeTitle: String? = nil, titleLibrary: TitleLibraryStore = TitleLibrary.store, episodeRefiner: (any EpisodeTitleRefiner)? = nil) -> ResolvedEpisodeDisplay { let refiner = episodeRefiner ?? Self.episodeRefiner let parsed = FilenameParser.parse(path: path) guard let season = parsed.season, let episode = parsed.episode else { let title = parsed.title.isEmpty ? "Unknown" : parsed.title return ResolvedEpisodeDisplay(displayName: title, episodeTitle: nil) } let key = TitleLibrary.contentKey(showName: showName, season: season, episode: episode) if let row = titleLibrary.lookup(key) { return ResolvedEpisodeDisplay(displayName: row.displayName, episodeTitle: row.episodeTitle) } let code = formatCode(season: season, episode: episode) if let sidecar = sidecarEpisodeTitle?.trimmingCharacters(in: .whitespacesAndNewlines), !sidecar.isEmpty { let display = format(season: season, episode: episode, title: sidecar) return ResolvedEpisodeDisplay(displayName: display, episodeTitle: sidecar) } let regexTail = titleAfterSxxEyy(path) if let tail = regexTail, !isDegenerateEpisodeTitle(tail, showName: showName) { let display = format(season: season, episode: episode, title: tail) titleLibrary.observe(TitleObservation(contentKey: key, showName: showName, season: season, episode: episode, episodeTitle: tail, provenance: .regex)) return ResolvedEpisodeDisplay(displayName: display, episodeTitle: tail) } if let refiner, let refined = refiner.refineEpisode(showName: showName, season: season, episode: episode, path: path, regexTitle: regexTail), !isDegenerateEpisodeTitle(refined.title, showName: showName) { let display = format(season: season, episode: episode, title: refined.title) titleLibrary.observe(TitleObservation(contentKey: key, showName: showName, season: season, episode: episode, episodeTitle: refined.title, provenance: .mlx, confidence: refined.confidence)) return ResolvedEpisodeDisplay(displayName: display, episodeTitle: refined.title) } return ResolvedEpisodeDisplay(displayName: code, episodeTitle: nil) } public static func format(season: Int, episode: Int, title: String) -> String { let code = formatCode(season: season, episode: episode) let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return code } return "\(code) ยท \(trimmed)" } public static func formatCode(season: Int, episode: Int) -> String { String(format: "S%02dE%02d", season, episode) } /// Text after the first SxxEyy marker in the filename โ€” common in scene releases. public static func titleAfterSxxEyy(_ path: String) -> String? { let base = ((path as NSString).lastPathComponent as NSString).deletingPathExtension guard let range = base.range(of: #"S\d{1,2}E\d{1,3}"#, options: [.regularExpression, .caseInsensitive]) else { return nil } var tail = String(base[range.upperBound...]) tail = tail.replacingOccurrences(of: "[._-]+", with: " ", options: .regularExpression) tail = tail.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) tail = tail.trimmingCharacters(in: .whitespaces) // Strip trailing release noise when it dominates the tail. if let cut = tail.range(of: #"\b(2160p|1080p|720p|480p|x ?26[45]|hevc|web-?dl|bluray)\b"#, options: [.regularExpression, .caseInsensitive]) { tail = String(tail[..= 2 ? tail : nil } /// Reject tails that are just the show name or pure release noise. static func isDegenerateEpisodeTitle(_ title: String, showName: String) -> Bool { let t = title.lowercased() let show = showName.lowercased() if t == show { return true } if t.replacingOccurrences(of: " ", with: "") == show.replacingOccurrences(of: " ", with: "") { return true } return t.range(of: #"^(2160p|1080p|720p|x ?26[45]|hdtv|web-?dl)"#, options: [.regularExpression, .caseInsensitive]) != nil } }