2026-06-08 22:04:22 -07:00
|
|
|
import Foundation
|
|
|
|
|
import Observation
|
|
|
|
|
|
|
|
|
|
/// One playable entry in the queue — a single file, with display metadata.
|
2026-06-08 22:40:53 -07:00
|
|
|
/// `Codable` so the queue survives relaunch (persisted via `QueueStore`).
|
|
|
|
|
public struct QueueItem: Identifiable, Sendable, Equatable, Codable {
|
2026-06-08 22:04:22 -07:00
|
|
|
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
|
|
|
|
|
}
|
2026-06-09 19:51:12 -07:00
|
|
|
/// 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) }
|
2026-06-08 22:40:53 -07:00
|
|
|
}
|
|
|
|
|
|
2026-06-30 00:12:41 -04:00
|
|
|
/// 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".
|
2026-06-08 22:40:53 -07:00
|
|
|
/// 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 {
|
2026-06-08 22:49:38 -07:00
|
|
|
/// 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.
|
2026-06-08 22:40:53 -07:00
|
|
|
private static var url: URL {
|
2026-06-08 22:49:38 -07:00
|
|
|
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")
|
2026-06-08 22:40:53 -07:00
|
|
|
}
|
|
|
|
|
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)
|
|
|
|
|
}
|
2026-06-08 22:04:22 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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
|
2026-06-08 22:40:53 -07:00
|
|
|
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) }
|
2026-06-08 22:04:22 -07:00
|
|
|
|
|
|
|
|
// 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))
|
2026-06-08 22:40:53 -07:00
|
|
|
persist()
|
2026-06-08 22:04:22 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public var isEmpty: Bool { queue.isEmpty }
|
|
|
|
|
public var count: Int { queue.count }
|
|
|
|
|
|
|
|
|
|
// MARK: manual editing
|
|
|
|
|
|
2026-06-08 22:40:53 -07:00
|
|
|
/// 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.
|
2026-06-08 22:04:22 -07:00
|
|
|
public func append(show: CachedShow) {
|
2026-06-08 22:40:53 -07:00
|
|
|
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()
|
2026-06-08 22:04:22 -07:00
|
|
|
}
|
|
|
|
|
// `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)
|
2026-06-08 22:40:53 -07:00
|
|
|
persist()
|
2026-06-08 22:04:22 -07:00
|
|
|
}
|
|
|
|
|
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)))
|
2026-06-08 22:40:53 -07:00
|
|
|
persist()
|
2026-06-08 22:04:22 -07:00
|
|
|
}
|
2026-06-08 22:40:53 -07:00
|
|
|
public func clear() { queue.removeAll(); persist() }
|
2026-06-08 22:04:22 -07:00
|
|
|
|
2026-06-30 00:50:05 -04:00
|
|
|
// 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() }
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-08 22:04:22 -07:00
|
|
|
// 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:
|
2026-06-30 00:12:41 -04:00
|
|
|
// 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
|
2026-06-08 22:04:22 -07:00
|
|
|
}
|
2026-06-08 22:40:53 -07:00
|
|
|
persist()
|
2026-06-08 22:04:22 -07:00
|
|
|
}
|
|
|
|
|
|
2026-06-09 19:51:12 -07:00
|
|
|
/// Replace the queue with a shuffle of a single type (the rail key, resolved
|
|
|
|
|
/// through the folder→type config).
|
2026-06-08 22:04:22 -07:00
|
|
|
public func generateShuffle(category: String, limit: Int = 30) {
|
2026-06-09 19:51:12 -07:00
|
|
|
queue = items(Array(library.shows.filter { LibraryConfig.type(of: $0.category) == category }.shuffled().prefix(limit)))
|
2026-06-08 22:40:53 -07:00
|
|
|
persist()
|
2026-06-08 22:04:22 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: firing
|
|
|
|
|
|
2026-06-30 00:12:41 -04:00
|
|
|
public func play(on player: PlayerController) {
|
|
|
|
|
player.enqueuePlaylist(queue.map(\.path), adult: queueIsAdult)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private var queueIsAdult: Bool { !queue.isEmpty && queue.allSatisfy(\.isAdult) }
|
2026-06-08 22:04:22 -07:00
|
|
|
|
2026-06-09 05:50:02 -07:00
|
|
|
// 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?) {
|
2026-06-30 00:12:41 -04:00
|
|
|
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 ep1→ep9 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)
|
2026-06-09 05:50:02 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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)
|
2026-06-30 00:12:41 -04:00
|
|
|
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)
|
2026-06-09 05:50:02 -07:00
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 19:51:12 -07:00
|
|
|
// MARK: porn collections (native — owns freshness, sourced from the index)
|
2026-06-08 22:04:22 -07:00
|
|
|
|
2026-06-09 19:51:12 -07:00
|
|
|
#if ENABLE_ADULT
|
2026-06-08 22:04:22 -07:00
|
|
|
public private(set) var pornCollections: [PornCollection] = []
|
|
|
|
|
|
2026-06-09 19:51:12 -07:00
|
|
|
/// 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
|
|
|
|
|
}
|
2026-06-08 22:04:22 -07:00
|
|
|
|
|
|
|
|
/// Load a porn collection's fresh files as the queue (marks them played, so
|
2026-06-09 19:51:12 -07:00
|
|
|
/// the freshness state advances), then refresh the counts.
|
2026-06-08 22:04:22 -07:00
|
|
|
public func applyPornCollection(_ name: String, count: Int = 25) async {
|
2026-06-09 19:51:12 -07:00
|
|
|
let pool = pornPool
|
|
|
|
|
let paths = await Task.detached(priority: .utility) {
|
|
|
|
|
PornCollectionService.freshPaths(pool: pool, collection: name, count: count)
|
|
|
|
|
}.value
|
2026-06-08 22:04:22 -07:00
|
|
|
queue = paths.map { QueueItem(id: $0, title: Self.prettyPornTitle($0), path: $0) }
|
2026-06-08 22:40:53 -07:00
|
|
|
persist() // no-op on disk (adult items are filtered out) — keeps any prior non-adult queue clear
|
2026-06-09 19:51:12 -07:00
|
|
|
await loadPornCollections()
|
2026-06-08 22:04:22 -07:00
|
|
|
}
|
|
|
|
|
|
2026-06-30 00:50:05 -04:00
|
|
|
/// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-08 22:04:22 -07:00
|
|
|
/// 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
|
|
|
|
|
}
|
2026-06-09 19:51:12 -07:00
|
|
|
#endif
|
2026-06-08 22:04:22 -07:00
|
|
|
|
|
|
|
|
// MARK: helpers
|
|
|
|
|
|
2026-06-09 19:51:12 -07:00
|
|
|
private var nonPorn: [CachedShow] { library.shows.filter { !LibraryConfig.isAdult(category: $0.category) } }
|
2026-06-08 22:04:22 -07:00
|
|
|
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)
|
|
|
|
|
}
|
2026-06-08 22:40:53 -07:00
|
|
|
|
|
|
|
|
/// 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)
|
|
|
|
|
}
|
2026-06-08 22:04:22 -07:00
|
|
|
}
|