tv-anarchy/Sources/TVAnarchyCore/PlaylistController.swift
Natalie ee7efad888 feat(adult): 🍿 collection detail view — clip checklist + offline download
Tapping a collection card on the Adult page now opens a detail view listing
every clip in that collection (goon, pmv, etc) instead of silently firing the
whole set at a host with nothing cached. Each row shows:
- queued state as a tickable checklist (build a session clip by clip)
- freshness / last-played
- offline-cached state, with a per-clip download-to-offline button

Plus a title filter (find e.g. a specific 'brain rot'/gooner clip), queue-all-
fresh, download-all-queued-offline, and play-queued. Downloads land in the
offline cache where the new star/trash row controls manage them. Quick-play the
old fire path stays on the card context menu.

Core: PornCollectionService.clips()/title() expose the full per-collection clip
list with freshness; PlaylistController gains single-item checklist queue ops
(isQueued/addToQueue/removeFromQueue) and pornClips().

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 00:50:05 -04:00

384 lines
19 KiB
Swift

import Foundation
import Observation
/// One playable entry in the queue a single file, with display metadata.
/// `Codable` so the queue survives relaunch (persisted via `QueueStore`).
public struct QueueItem: Identifiable, Sendable, Equatable, Codable {
public let id: String // stable key (rootDir or path)
public var title: String
public var path: String // plum-side path; the target translates per host
public var posterPath: String?
public init(id: String, title: String, path: String, posterPath: String? = nil) {
self.id = id; self.title = title; self.path = path; self.posterPath = posterPath
}
/// Adult content lives under an adult folder (per `LibraryConfig`). Never
/// persisted (see `QueueStore`), so a restored queue can't leak it past the
/// Home adult gate.
public var isAdult: Bool { LibraryConfig.isAdult(path: path) }
}
/// Boundary between Media Management (Library data, watch state via continueWatching)
/// and Playback Execution (viewer clients via enqueue/launch on PlayerController).
/// This class turns pure library episodes into queues for the playback piece.
/// No direct viewer client logic here; delegates to PlayerController (which owns targets).
/// See v2/plan.md "Media management vs. viewer client playback as two pieces".
/// Persists the play queue across launches. Mirrors `SmartPlaylistStore`, but
/// unlike smart playlists, which exclude porn by construction the live queue
/// can hold adult items (`applyPornCollection`), so we strip them on save: the
/// queue list is always visible and must not surface adult paths regardless of
/// the Home adult setting. Adult queues are therefore session-only by design.
public enum QueueStore {
/// State directory, overridable via `TV_ANARCHY_STATE_DIR`. Unlike the other
/// JSON stores, this one is written by a unit-tested controller (every queue
/// mutation persists), so the override lets tests redirect to a temp dir and
/// never read or clobber the user's real queue file.
private static var url: URL {
let base: URL
if let dir = ProcessInfo.processInfo.environment["TV_ANARCHY_STATE_DIR"], !dir.isEmpty {
base = URL(fileURLWithPath: dir, isDirectory: true)
} else {
base = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".local/state/tv-anarchy")
}
return base.appendingPathComponent("play-queue.json")
}
public static func load() -> [QueueItem] {
guard let d = try? Data(contentsOf: url),
let q = try? JSONDecoder().decode([QueueItem].self, from: d) else { return [] }
return q.filter { !$0.isAdult } // belt-and-braces: never restore adult, even if an old file held it
}
public static func save(_ queue: [QueueItem]) {
let safe = queue.filter { !$0.isAdult }
guard let d = try? JSONEncoder().encode(safe) else { return }
try? FileManager.default.createDirectory(at: url.deletingLastPathComponent(),
withIntermediateDirectories: true)
try? d.write(to: url, options: .atomic)
}
}
/// One-tap auto-playlist generators over the library snapshot.
public enum AutoPlaylist: String, CaseIterable, Sendable {
case recentlyAdded, continueWatching, shuffleAll
public var label: String {
switch self {
case .recentlyAdded: return "Recently Added"
case .continueWatching: return "Continue Watching"
case .shuffleAll: return "Shuffle Library"
}
}
public var icon: String {
switch self {
case .recentlyAdded: return "sparkles"
case .continueWatching: return "play.circle"
case .shuffleAll: return "shuffle"
}
}
}
/// Owns the play queue and the generators that fill it. Pure data + library
/// reads; firing the queue is delegated to PlayerController (it owns the targets).
/// Porn is excluded from the library generators here the dedicated porn
/// collections (with their own freshness state) are a separate, later source.
@Observable
@MainActor
public final class PlaylistController {
public private(set) var queue: [QueueItem] = []
public private(set) var smartPlaylists: [SmartPlaylist] = SmartPlaylistStore.load()
private let library: LibraryController
public init(library: LibraryController) {
self.library = library
self.queue = QueueStore.load() // restore the last session's queue (adult items excluded)
}
/// Write the current queue to disk after every mutation. Adult items are
/// filtered out inside `QueueStore.save` they stay session-only.
private func persist() { QueueStore.save(queue) }
// MARK: smart playlists (saved rules)
/// Add or replace a saved rule (matched by id), persisting the set.
public func upsert(_ smart: SmartPlaylist) {
if let i = smartPlaylists.firstIndex(where: { $0.id == smart.id }) { smartPlaylists[i] = smart }
else { smartPlaylists.append(smart) }
SmartPlaylistStore.save(smartPlaylists)
}
public func deleteSmart(id: String) {
smartPlaylists.removeAll { $0.id == id }
SmartPlaylistStore.save(smartPlaylists)
}
/// Re-evaluate a saved rule against the current library and load it as the queue.
public func apply(_ smart: SmartPlaylist) {
let watched = Set(library.resumePositions().keys)
queue = items(smart.resolve(from: library.shows, watchedPaths: watched))
persist()
}
public var isEmpty: Bool { queue.isEmpty }
public var count: Int { queue.count }
// MARK: manual editing
/// Add a library entry to the queue. A movie adds its single file; a series
/// expands to every episode in broadcast order, each its own queue item so
/// "Add to Queue" on a show queues the whole thing to play back-to-back.
/// Items already queued (matched by path) are skipped, so re-adding is a no-op.
public func append(show: CachedShow) {
let toAdd: [QueueItem]
if show.kind == .movie {
toAdd = Self.item(for: show).map { [$0] } ?? []
} else {
toAdd = show.seasons.flatMap(show.episodes(inSeason:)).map { Self.item(for: $0, in: show) }
}
appendItems(toAdd)
}
/// Add a single episode of a series to the queue (the per-episode action in
/// the show detail view). No-op if already queued.
public func append(episode: CachedEpisode, of show: CachedShow) {
appendItems([Self.item(for: episode, in: show)])
}
/// Append items not already present (matched by id), persisting if anything changed.
private func appendItems(_ candidates: [QueueItem]) {
let existing = Set(queue.map(\.id))
let fresh = candidates.filter { !existing.contains($0.id) }
guard !fresh.isEmpty else { return }
queue.append(contentsOf: fresh)
persist()
}
// `remove(atOffsets:)` / `move(fromOffsets:toOffset:)` come from SwiftUI; Core
// stays UI-free, so reimplement the same IndexSet semantics the List expects.
public func remove(atOffsets offsets: IndexSet) {
queue = queue.enumerated().filter { !offsets.contains($0.offset) }.map(\.element)
persist()
}
public func move(fromOffsets src: IndexSet, toOffset dst: Int) {
let moving = src.sorted().map { queue[$0] }
for i in src.sorted(by: >) { queue.remove(at: i) }
let insertAt = dst - src.filter { $0 < dst }.count
queue.insert(contentsOf: moving, at: max(0, min(insertAt, queue.count)))
persist()
}
public func clear() { queue.removeAll(); persist() }
// MARK: single-item checklist (collection detail view)
/// True when a file path is already in the live queue (checklist state).
public func isQueued(path: String) -> Bool { queue.contains { $0.path == path } }
/// Append one clip if not already queued (no replace). Used by the collection
/// detail checklist to build a session one clip at a time.
public func addToQueue(id: String, title: String, path: String) {
guard !isQueued(path: path) else { return }
queue.append(QueueItem(id: id, title: title, path: path))
persist()
}
/// Remove every queue entry for a path (checklist un-tick).
public func removeFromQueue(path: String) {
let before = queue.count
queue.removeAll { $0.path == path }
if queue.count != before { persist() }
}
// MARK: generators (replace the queue)
/// Replace the queue from a one-tap generator. `limit` caps the queue length.
public func generate(_ kind: AutoPlaylist, limit: Int = 30) {
switch kind {
case .recentlyAdded:
let pool = nonPorn.filter { $0.addedAt != nil }.sorted { $0.addedAt! > $1.addedAt! }
queue = items(Array(pool.prefix(limit)))
case .shuffleAll:
queue = items(Array(nonPorn.shuffled().prefix(limit)))
case .continueWatching:
// Expand each continue item to the full tail of its show (S3+ after S2 etc).
// This makes the generator actually "continue watching" the series instead of
// only queuing the single next episode per in-progress show.
var acc: [QueueItem] = []
for item in library.continueWatching.filter { !$0.isAdult }.prefix(limit) {
let remote = MediaPaths.toRemote(item.path)
let show = item.show.flatMap { name in library.shows.first { $0.name.lowercased() == name.lowercased() } }
?? library.shows.first { s in s.episodes.contains { MediaPaths.toRemote($0.path) == remote } }
if let show, show.kind == .series,
show.orderedEpisodes.contains(where: { MediaPaths.toRemote($0.path) == remote }) {
let tail = Self.fromHere(show: show, startPath: item.path)
acc.append(contentsOf: tail.map { Self.item(for: $0, in: show) })
} else {
acc.append(QueueItem(id: item.path, title: item.title, path: item.path, posterPath: item.posterPath))
}
}
queue = acc
}
persist()
}
/// Replace the queue with a shuffle of a single type (the rail key, resolved
/// through the foldertype config).
public func generateShuffle(category: String, limit: Int = 30) {
queue = items(Array(library.shows.filter { LibraryConfig.type(of: $0.category) == category }.shuffled().prefix(limit)))
persist()
}
// MARK: firing
public func play(on player: PlayerController) {
player.enqueuePlaylist(queue.map(\.path), adult: queueIsAdult)
}
private var queueIsAdult: Bool { !queue.isEmpty && queue.allSatisfy(\.isAdult) }
// MARK: unified playlist play from here, queue the rest
/// Load a series into the queue starting at `startPath` (inclusive) through the
/// end of the show the "start Daria S2 queue the rest of Daria" behavior.
/// Ordering is `orderedEpisodes`, so specials/movies (season 0) land at the end.
/// Falls back to the whole show when `startPath` isn't found.
public func loadFromHere(show: CachedShow, startPath: String?) {
queue = Self.fromHere(show: show, startPath: startPath).map { Self.item(for: $0, in: show) }
persist()
}
/// Pure helper (unit-tested): the from-here episode slice for a show. Matches the
/// start episode mount-agnostically (`toRemote`) so a VLC/watchlog path resolves
/// against the library's path; unknown start the whole show.
public static func fromHere(show: CachedShow, startPath: String?) -> [CachedEpisode] {
let eps = show.orderedEpisodes
guard !eps.isEmpty else { return [] }
let start = startPath.flatMap { p in
let r = MediaPaths.toRemote(p)
return eps.firstIndex { MediaPaths.toRemote($0.path) == r }
} ?? 0
return Array(eps[start...])
}
/// Fire the current queue at the active host, resuming the first item at
/// `resumeFirst` seconds (the rest start at 0).
public func play(on player: PlayerController, resumeFirst: Double?) {
var paths = queue.map(\.path)
let adult = queueIsAdult
// Offline local play: queue the contiguous run of downloaded episodes from the
// requested start, stopping at the first gap so a partly-cached series queues
// what you have instead of refusing the whole tail. Anchored on the start: we
// never skip ahead to a later cached episode (the ep1ep9 offline bug). If the
// requested start isn't on disk, keep just it so enqueuePlaylist reports the
// honest "not downloaded" message rather than silently playing a different ep.
if player.active?.kind.isLocal == true {
let offline = adult ? player.adultPlaybackMode == .offline : player.playbackMode == .offline
if offline {
let prefix = Array(paths.prefix { MediaPaths.localCopy(of: $0) != nil })
paths = prefix.isEmpty ? Array(paths.prefix(1)) : prefix
}
}
player.enqueuePlaylist(paths, resumeFirst: resumeFirst, adult: adult)
}
/// Continue-watching unified queue. Resolves the show for `item` (by name,
/// else by which show contains the episode), loads from that episode through the
/// end of the show, and fires it so resuming a series from the rail queues the
/// REST of the show (the bug where S3 never queued after S2). Returns false when
/// it can't (movie, no enqueue, unresolved) so the caller can single-launch.
@discardableResult
public func playContinue(_ item: ContinueItem, shows: [CachedShow], on player: PlayerController) -> Bool {
guard player.canEnqueue else { return false }
let remote = MediaPaths.toRemote(item.path)
let show = item.show.flatMap { name in shows.first { $0.name.lowercased() == name.lowercased() } }
?? shows.first { s in s.episodes.contains { MediaPaths.toRemote($0.path) == remote } }
guard let show, show.kind == .series,
show.orderedEpisodes.contains(where: { MediaPaths.toRemote($0.path) == remote }) else { return false }
loadFromHere(show: show, startPath: item.path)
guard !queue.isEmpty else { return false }
player.setActiveContext(series: show.name, category: show.category)
let fullPaths = queue.map(\.path)
let isAdult = LibraryConfig.isAdult(path: item.path) || LibraryConfig.isAdult(category: show.category)
let offline = isAdult ? player.adultPlaybackMode == .offline : player.playbackMode == .offline
// Prefer a live resume position (mid-ep watch) for this starting item over the
// rail item's (often nil) value. This ensures "resume last" or re-tapping a
// Continue card picks up exactly where you paused.
let startRemote = MediaPaths.toRemote(item.path)
let livePos = library.resumePositions()[startRemote] ?? 0
let resumePos = max(item.positionSeconds ?? 0, livePos)
// For local + offline: queue the contiguous run of downloaded episodes from the
// resume point, stopping at the first gap "all the (ready) episodes from here"
// instead of refusing the tail. Anchored on the start, so we never skip ahead to
// a later cached episode. If the start isn't on disk, keep just it so the
// enqueuePlaylist path messages "not downloaded" rather than substituting.
var toPlay = fullPaths
if player.active?.kind.isLocal == true, offline {
let prefix = Array(fullPaths.prefix { MediaPaths.localCopy(of: $0) != nil })
toPlay = prefix.isEmpty ? [item.path] : prefix
}
player.enqueuePlaylist(toPlay, resumeFirst: resumePos, adult: isAdult)
return true
}
// MARK: porn collections (native owns freshness, sourced from the index)
#if ENABLE_ADULT
public private(set) var pornCollections: [PornCollection] = []
/// The porn file pool from the library index: every file under an adult
/// category (loose clips land there as one-file movies). The candidate set
/// the collections filter over no NFS re-walk, works offline.
private var pornPool: [String] {
library.shows.filter { LibraryConfig.isAdult(category: $0.category) }.flatMap { $0.episodes.map(\.path) }
}
/// Recompute the collections (fresh/total) from the current index + play-log.
/// Cheap and synchronous (pure filtering), but the index read can be sizeable,
/// so it's hopped off the main actor.
public func loadPornCollections() async {
let pool = pornPool
pornCollections = await Task.detached(priority: .utility) {
PornCollectionService.collections(pool: pool)
}.value
}
/// Load a porn collection's fresh files as the queue (marks them played, so
/// the freshness state advances), then refresh the counts.
public func applyPornCollection(_ name: String, count: Int = 25) async {
let pool = pornPool
let paths = await Task.detached(priority: .utility) {
PornCollectionService.freshPaths(pool: pool, collection: name, count: count)
}.value
queue = paths.map { QueueItem(id: $0, title: Self.prettyPornTitle($0), path: $0) }
persist() // no-op on disk (adult items are filtered out) keeps any prior non-adult queue clear
await loadPornCollections()
}
/// Every clip in a collection (fresh + already-seen) for the detail checklist,
/// computed off the main actor over the same adult index pool the cards use.
public func pornClips(collection: String) async -> [PornClip] {
let pool = pornPool
return await Task.detached(priority: .utility) {
PornCollectionService.clips(pool: pool, collection: collection)
}.value
}
/// Strip the "EPORNER.COM - [id]" scrape prefix + extension for a readable row.
static func prettyPornTitle(_ path: String) -> String {
var t = ((path as NSString).lastPathComponent as NSString).deletingPathExtension
if let r = t.range(of: #"^EPORNER\.COM - \[[0-9A-Za-z]+\]\s*"#, options: .regularExpression) {
t.removeSubrange(r)
}
return t.isEmpty ? "clip" : t
}
#endif
// MARK: helpers
private var nonPorn: [CachedShow] { library.shows.filter { !LibraryConfig.isAdult(category: $0.category) } }
private func items(_ shows: [CachedShow]) -> [QueueItem] { shows.compactMap(Self.item(for:)) }
static func item(for show: CachedShow) -> QueueItem? {
guard let path = show.episodes.first?.path else { return nil }
return QueueItem(id: show.rootDir, title: show.name, path: path, posterPath: show.posterPath)
}
/// A queue item for one episode of a series keyed by its file path so it can
/// coexist with sibling episodes and dedupe correctly.
static func item(for episode: CachedEpisode, in show: CachedShow) -> QueueItem {
QueueItem(id: episode.path, title: "\(show.name) · \(episode.label)",
path: episode.path, posterPath: show.posterPath)
}
}