tv-anarchy/Sources/TVAnarchyCore/Library/LibraryDisplayNames.swift
Natalie 4a2ceb9781 feat(offline): inline star-to-keep and trash-to-cull on cache rows
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>
2026-06-30 00:12:41 -04:00

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
}
}