Renames Sources/PlumTV→TVAnarchy and PlumTVCore→TVAnarchyCore (the rename the auto-commit service couldn't stage — it git-add'd the old, now-gone paths and aborted every cycle), and commits the accumulated work: - Library: black-built index fast path (LibraryIndex + scanFromIndex) with NFS-walk fallback; incremental --add on download-complete; mtime staleness gate; loose-file series-collapse fix; determinate scan/index progress. - Cover art: keyless TVmaze cartoon-vs-live-action disambiguation (type/year). - Player: sleep timer (timed + end-of-episode); visibility-gated polling. - Home: Continue Watching cover art + live refresh; Recently Added; adult gate. - Logs: multi-line selection + copy; truncated giant tx-list errors. - Hover previews (opt-in) via black ffmpeg + scp. Also gitignores foreign project trees (governor/mcp/fleet/recommender) that sit in this directory but belong to their own repos. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
159 lines
7.2 KiB
Swift
159 lines
7.2 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.
|
|
public var seasons: [Int] {
|
|
Array(Set(episodes.map(\.season))).sorted()
|
|
}
|
|
|
|
public func episodes(inSeason s: Int) -> [CachedEpisode] {
|
|
episodes.filter { $0.season == s }.sorted { $0.episode < $1.episode }
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
}
|