299 lines
14 KiB
Swift
299 lines
14 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) }
|
|
}
|
|
|
|
/// 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: 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:
|
|
queue = library.continueWatching
|
|
.filter { !$0.isAdult }
|
|
.prefix(limit)
|
|
.map { QueueItem(id: $0.path, title: $0.title, path: $0.path, posterPath: $0.posterPath) }
|
|
}
|
|
persist()
|
|
}
|
|
|
|
/// Replace the queue with a shuffle of a single type (the rail key, resolved
|
|
/// through the folder→type 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)) }
|
|
|
|
// 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?) {
|
|
player.enqueuePlaylist(queue.map(\.path), resumeFirst: resumeFirst)
|
|
}
|
|
|
|
/// 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)
|
|
player.enqueuePlaylist(queue.map(\.path), resumeFirst: item.positionSeconds)
|
|
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()
|
|
}
|
|
|
|
/// 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)
|
|
}
|
|
}
|