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 folder→type 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 { 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? }