tv-anarchy/Sources/TVAnarchyCore/Library/LibraryModels.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

238 lines
11 KiB
Swift

import Foundation
/// One episode file in the library. `metaPath` points at the `.meta` sidecar
/// once Phase 4 enrichment runs; nil until then. `displayName` is the canonical
/// UI string never a raw release filename when indexed with SxxEyy.
public struct CachedEpisode: Codable, Sendable, Hashable, Identifiable {
public var path: String
public var season: Int
public var episode: Int
public var label: String
public var displayName: String
public var episodeTitle: String?
public var metaPath: String?
public var id: String { path }
public init(path: String, season: Int, episode: Int, label: String,
displayName: String? = nil, episodeTitle: String? = nil,
metaPath: String? = nil) {
self.path = path; self.season = season; self.episode = episode
let resolved = displayName ?? label
self.displayName = resolved
self.label = resolved
self.episodeTitle = episodeTitle
self.metaPath = metaPath
}
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
path = try c.decode(String.self, forKey: .path)
season = try c.decode(Int.self, forKey: .season)
episode = try c.decode(Int.self, forKey: .episode)
label = try c.decode(String.self, forKey: .label)
displayName = try c.decodeIfPresent(String.self, forKey: .displayName) ?? label
episodeTitle = try c.decodeIfPresent(String.self, forKey: .episodeTitle)
metaPath = try c.decodeIfPresent(String.self, forKey: .metaPath)
}
public func encode(to encoder: Encoder) throws {
var c = encoder.container(keyedBy: CodingKeys.self)
try c.encode(path, forKey: .path)
try c.encode(season, forKey: .season)
try c.encode(episode, forKey: .episode)
try c.encode(displayName, forKey: .label)
try c.encode(displayName, forKey: .displayName)
try c.encodeIfPresent(episodeTitle, forKey: .episodeTitle)
try c.encodeIfPresent(metaPath, forKey: .metaPath)
}
private enum CodingKeys: String, CodingKey {
case path, season, episode, label, displayName, episodeTitle, metaPath
}
}
/// What an entry is. Series have seasons/episodes and drill down; movies are a
/// single film (one playable file, no episode list). Drives both UI shape and
/// which keyless artwork provider to use.
public enum MediaKind: String, Codable, Sendable, Hashable {
case series
case movie
}
/// A library entry grouped from the scan. Despite the name it holds both TV
/// series and movies (`kind`); kept as `CachedShow` to avoid a rename across the
/// stack. `category` is the top-level media folder it lives in ("tv", "anime",
/// "movies", "cartoons", "porn", "unsorted"). `posterPath`/`overview` are filled
/// by enrichment and survive rescans (merged back in by rootDir).
public struct CachedShow: Codable, Sendable, Hashable, Identifiable {
public var name: String
public var rootDir: String
public var category: String
public var kind: MediaKind
public var posterPath: String?
public var overview: String?
public var episodes: [CachedEpisode]
/// Release/air year parsed from the folder name (for franchise chronology).
public var year: Int?
/// Set when we know counts without the full episode list (e.g. the offline
/// registry, which carries a season range but no per-episode data). nil when
/// the count should be derived from `episodes`.
public var seasonCount: Int?
public var episodeCount: Int?
/// Newest file mtime under this show's folder, captured at scan time. Drives
/// the Home "Recently Added" rail. nil for snapshots written before this field
/// existed (they surface once rescanned) and for the offline registry.
public var addedAt: Date?
public var id: String { rootDir }
public init(name: String, rootDir: String, category: String = "",
kind: MediaKind = .series, posterPath: String? = nil,
overview: String? = nil, episodes: [CachedEpisode],
year: Int? = nil, seasonCount: Int? = nil, episodeCount: Int? = nil,
addedAt: Date? = nil) {
self.name = name; self.rootDir = rootDir
self.category = category; self.kind = kind
self.posterPath = posterPath
self.overview = overview; self.episodes = episodes
self.year = year
self.seasonCount = seasonCount; self.episodeCount = episodeCount
self.addedAt = addedAt
}
/// Tolerant decode: a snapshot written before `category`/`kind` existed (or
/// any future field drift) still loads missing fields take sensible
/// defaults rather than failing the whole snapshot. encode stays synthesized.
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
name = try c.decode(String.self, forKey: .name)
rootDir = try c.decode(String.self, forKey: .rootDir)
category = try c.decodeIfPresent(String.self, forKey: .category) ?? ""
kind = try c.decodeIfPresent(MediaKind.self, forKey: .kind) ?? .series
posterPath = try c.decodeIfPresent(String.self, forKey: .posterPath)
overview = try c.decodeIfPresent(String.self, forKey: .overview)
episodes = try c.decodeIfPresent([CachedEpisode].self, forKey: .episodes) ?? []
year = try c.decodeIfPresent(Int.self, forKey: .year)
seasonCount = try c.decodeIfPresent(Int.self, forKey: .seasonCount)
episodeCount = try c.decodeIfPresent(Int.self, forKey: .episodeCount)
addedAt = try c.decodeIfPresent(Date.self, forKey: .addedAt)
}
/// Distinct season numbers, ascending but **season 0 sorts LAST**, not first.
/// Season 0 is the TVDB convention for specials / TV-movies (e.g. Daria's two
/// movies), which belong after the numbered run, not before episode 1.
public var seasons: [Int] {
Array(Set(episodes.map(\.season))).sorted { Self.seasonRank($0) < Self.seasonRank($1) }
}
/// Sort key that pushes season 0 (specials/movies) to the end.
static func seasonRank(_ s: Int) -> Int { s == 0 ? Int.max : s }
/// Display label for a season number "Specials" for season 0 (movies/extras).
public func seasonLabel(_ s: Int) -> String { s == 0 ? "Specials & Movies" : "Season \(s)" }
public func episodes(inSeason s: Int) -> [CachedEpisode] {
episodes.filter { $0.season == s }.sorted { $0.episode < $1.episode }
}
/// Every episode in play order: season order (0/specials last), then episode
/// order. The basis for the unified "play from here queue the rest" feature.
public var orderedEpisodes: [CachedEpisode] {
seasons.flatMap(episodes(inSeason:))
}
/// Known episode count, preferring the real list over a metadata-only count.
public var knownEpisodeCount: Int? {
episodes.isEmpty ? episodeCount : episodes.count
}
/// Known season count, preferring the real list over a metadata-only count.
public var knownSeasonCount: Int? {
seasons.isEmpty ? seasonCount : seasons.count
}
/// "120 eps · 6 seasons" / "5 seasons" / "" never the misleading "0/0".
/// Movies carry one synthetic episode (the file); "1 ep" would be noise, so
/// they get no summary here.
public var countSummary: String {
if kind == .movie { return "" }
var parts: [String] = []
if let e = knownEpisodeCount, e > 0 { parts.append("\(e) ep\(e == 1 ? "" : "s")") }
if let s = knownSeasonCount, s > 0 { parts.append("\(s) season\(s == 1 ? "" : "s")") }
return parts.joined(separator: " · ")
}
}
/// The on-disk library snapshot browsable offline. Source records how it was
/// built ("scan" from ~/media, "registry" from media-recommender's list).
public struct LibrarySnapshot: Codable, Sendable {
public var shows: [CachedShow]
public var capturedAt: Date
public var source: String
public init(shows: [CachedShow], capturedAt: Date, source: String) {
self.shows = shows; self.capturedAt = capturedAt; self.source = source
}
}
/// A resume candidate for the "Continue watching" rail from shared watch state.
/// `source` is the writing client (`app`, `mcp`, `bridge`, `governor`, `black`).
public struct ContinueItem: Sendable, Equatable, Identifiable {
public var title: String
public var path: String
public var show: String?
public var season: Int?
public var episode: Int?
public var positionSeconds: Double?
public var lastSeen: Date?
public var source: String
/// Cover art resolved by matching the watch entry to a library show (the
/// watchlog/VLC recents carry no artwork themselves). Filled by the controller.
public var posterPath: String?
public var id: String { path }
/// True for items under an adult folder used to keep adult content out of
/// the Home rails (Continue Watching / Recently Added) regardless of where it
/// was watched. Resolves the path's folder against the configured adult set
/// (`LibraryConfig`), so it follows the foldertype config, not a literal.
public var isAdult: Bool { LibraryConfig.isAdult(path: path) }
public init(title: String, path: String, show: String? = nil, season: Int? = nil,
episode: Int? = nil, positionSeconds: Double? = nil,
lastSeen: Date? = nil, source: String, posterPath: String? = nil) {
self.title = title; self.path = path; self.show = show; self.season = season
self.episode = episode; self.positionSeconds = positionSeconds
self.lastSeen = lastSeen; self.source = source; self.posterPath = posterPath
}
}
/// Abstraction over the media management side (Library pillar + related) that the
/// playback execution side (PlayerController + viewer clients like VLCTarget etc.)
/// depends on. This enforces Dependency Inversion (DIP) and the architectural
/// separation of the "two pieces" (management prepares the model/state/cache;
/// playback executes on specific viewer backends and reports progress back via
/// the watchlog SSOT).
///
/// LibraryController provides the concrete implementation. Playback code depends
/// only on this protocol (or arrays of data), not the concrete type. See
/// v2/plan.md "Media management vs. viewer client playback as two pieces" and
/// the comments in PlayerController/PlaylistController.
public protocol LibraryProviding: AnyObject {
var shows: [CachedShow] { get }
var continueWatching: [ContinueItem] { get }
var activePlayerId: String? { get set }
var playbackDisplayId: UInt32? { get set }
var playbackMode: PlaybackMode { get set }
var adultPlaybackMode: PlaybackMode { get set }
func resumePositions() -> [String: Double]
var episodeProgress: [String: WatchHistory.EpisodeProgress] { get }
var playedPaths: Set<String> { get }
func recordPlay(path: String, resumeSeconds: Double?, finished: Bool)
func recordPosition(path: String, resumeSeconds: Double, durationSeconds: Double?)
func launchRequest(continue item: ContinueItem, targetKind: HostKind) -> LaunchRequest?
}