226 lines
10 KiB
Swift
226 lines
10 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 a `/porn/` media folder. Never persisted (see
|
|
/// `QueueStore`), so a restored queue can't leak it past the Home adult gate.
|
|
public var isAdult: Bool { path.contains("/porn/") }
|
|
}
|
|
|
|
/// 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 category.
|
|
public func generateShuffle(category: String, limit: Int = 30) {
|
|
queue = items(Array(library.shows.filter { $0.category == category }.shuffled().prefix(limit)))
|
|
persist()
|
|
}
|
|
|
|
// MARK: firing
|
|
|
|
public func play(on player: PlayerController) { player.enqueuePlaylist(queue.map(\.path)) }
|
|
|
|
// MARK: porn collections (bridged to porn-rotation.py — owns freshness)
|
|
|
|
public private(set) var pornCollections: [PornCollection] = []
|
|
|
|
public func loadPornCollections() async { pornCollections = await PornCollectionService.list() }
|
|
|
|
/// Load a porn collection's fresh files as the queue (marks them played, so
|
|
/// the shared freshness state advances).
|
|
public func applyPornCollection(_ name: String, count: Int = 25) async {
|
|
let paths = await PornCollectionService.paths(collection: name, count: count)
|
|
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
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
|
|
// MARK: helpers
|
|
|
|
private var nonPorn: [CachedShow] { library.shows.filter { $0.category != "porn" } }
|
|
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)
|
|
}
|
|
}
|