tv-anarchy/Sources/TVAnarchyCore/Library/LibraryModels.swift

172 lines
8 KiB
Swift

import Foundation
/// One episode file in the library. `metaPath` points at the `.meta` sidecar
/// once Phase 4 enrichment runs; nil until then.
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 metaPath: String?
public var id: String { path }
public init(path: String, season: Int, episode: Int, label: String, metaPath: String? = nil) {
self.path = path; self.season = season; self.episode = episode
self.label = label; self.metaPath = 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, unioned from the plum
/// watchlog and VLC's recents. `show`/`season`/`episode` are present for
/// watchlog entries (so black can `resume-show`); VLC recents carry only a path.
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 the porn media dir used to keep adult content out
/// of the Home rails (Continue Watching / Recently Added) regardless of where
/// it was watched. Matches the `porn` library category by path.
public var isAdult: Bool { path.contains("/porn/") }
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
}
}