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>
238 lines
11 KiB
Swift
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 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<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?
|
|
}
|