Surface the existing pin (keep-from-cull) and per-file delete actions as visible inline buttons on each offline cache row instead of context-menu-only: a star toggles protection from auto-cull (and restore-if-missing), a trash culls that file early. Aligns wording/icons to the star metaphor. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
111 lines
No EOL
5.7 KiB
Swift
111 lines
No EOL
5.7 KiB
Swift
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[..<cut.lowerBound]).trimmingCharacters(in: .whitespaces)
|
|
}
|
|
return tail.count >= 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
|
|
}
|
|
} |