tv-anarchy/Sources/TVAnarchyCore/PlaylistController.swift
Natalie 0cc33e30b6 feat(queue): add test-isolated queue state directory
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-06-08 22:49:38 -07:00

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)
}
}