feat(core): player/playlist/porn-service rework + repo paths

This commit is contained in:
Natalie 2026-06-09 05:50:02 -07:00
parent 117e234a3c
commit 9e38cf9f48
7 changed files with 211 additions and 42 deletions

View file

@ -1,11 +1,11 @@
import Foundation
/// What to start playing. `show`/`resume` are library-aware (black resolves the
/// title against its own library + resume state); `file` is a concrete path for
/// path-addressable players (VLC on plum).
/// What to start playing always a concrete file path. The library knows the exact
/// file for every episode / movie / resume target, so playback NEVER asks a host to
/// re-resolve a show by name (that missed merged multi-folder shows e.g. Daria S3
/// living in a separate "Season 3" folder). Black plays the path via `black-tv
/// play`; VLC opens it directly.
public enum LaunchRequest: Sendable, Equatable {
case show(name: String, season: Int?, episode: Int?)
case resume(name: String)
case file(path: String)
}

View file

@ -63,16 +63,9 @@ public final class MpvTarget: PlayerTarget, QualitySwitchable, HostStatsProvider
@discardableResult
public func launch(_ request: LaunchRequest) async -> Bool {
switch request {
case let .show(name, season, episode):
return await runCommand(commands?.launchShow,
["query": name, "season": season.map(String.init), "episode": episode.map(String.init)])
case let .resume(name):
return await runCommand(commands?.launchResume, ["query": name])
case let .file(path):
// black needs a black-side path, not plum's mount path.
return await runCommand(commands?.launchFile, ["path": MediaPaths.toRemote(path)])
}
guard case let .file(path) = request else { return false }
// black needs a black-side path, not plum's mount path.
return await runCommand(commands?.launchFile, ["path": MediaPaths.toRemote(path)])
}
// MARK: Enqueueable (generic IPC batched loadfile replace/append, one

View file

@ -34,6 +34,9 @@ public final class PlayerController {
private var pollTask: Task<Void, Never>?
private var statsTask: Task<Void, Never>?
/// Forwards the Mac's system transport (media keys / Control Center) to the
/// active target and publishes Now Playing. Gated by `forwardMediaKeys`.
private let nowPlaying = NowPlayingController()
private var polling = false // single-flight guard for status
private var tickCount = 0
private var lastReleaseKey: String?
@ -61,8 +64,8 @@ public final class PlayerController {
/// the TV (not persisted a stale persisted VLC was hijacking playback); the
/// session pick is preserved across an in-session reload.
public func reload() {
let cfg = HostsConfig.loadOrSeed()
targets = cfg.hosts.compactMap(Self.makeTarget)
let cfg = DevicesConfig.loadOrSeed()
targets = cfg.devices.compactMap(Self.makeTarget)
if active == nil { activeID = defaultTargetID }
var next: [String: Snapshot] = [:]
for t in targets {
@ -77,7 +80,7 @@ public final class PlayerController {
snapshots = next
}
static func makeTarget(_ h: HostConfig) -> (any PlayerTarget)? {
static func makeTarget(_ h: DeviceConfig) -> (any PlayerTarget)? {
switch h.kind {
case .vlc:
guard let v = h.vlc else { return nil }
@ -101,12 +104,12 @@ public final class PlayerController {
// MARK: Local player choice (Setup)
/// The configured local player kind (vlc/quicktime), if any.
public var localPlayerKind: HostKind? { HostsConfig.loadOrSeed().localPlayerKind }
public var localPlayerKind: HostKind? { DevicesConfig.loadOrSeed().localPlayerKind }
/// Swap the local player to `kind` (rewrites the local host in hosts.json) and
/// reload so the change takes effect immediately.
public func setLocalPlayer(_ kind: HostKind) {
var cfg = HostsConfig.loadOrSeed()
var cfg = DevicesConfig.loadOrSeed()
cfg.setLocalPlayer(kind)
try? cfg.save()
reload()
@ -115,28 +118,28 @@ public final class PlayerController {
// MARK: Host configuration (CRUD, persisted to hosts.json)
/// The on-disk host configs (for the editor distinct from live `targets`).
public var editableHosts: [HostConfig] { HostsConfig.loadOrSeed().hosts }
public var editableDevices: [DeviceConfig] { DevicesConfig.loadOrSeed().devices }
public func saveHosts(_ hosts: [HostConfig]) {
try? HostsConfig(hosts: hosts).save()
public func saveDevices(_ hosts: [DeviceConfig]) {
try? DevicesConfig(devices: hosts).save()
Log.info("saved hosts config (\(hosts.count): \(hosts.map(\.name).joined(separator: ", ")))")
reload()
}
/// Add a new host or replace the existing one with the same id.
public func upsertHost(_ host: HostConfig) {
var hosts = HostsConfig.loadOrSeed().hosts
public func upsertDevice(_ host: DeviceConfig) {
var hosts = DevicesConfig.loadOrSeed().devices
if let i = hosts.firstIndex(where: { $0.id == host.id }) { hosts[i] = host }
else { hosts.append(host) }
saveHosts(hosts)
saveDevices(hosts)
}
public func deleteHost(_ id: String) {
saveHosts(HostsConfig.loadOrSeed().hosts.filter { $0.id != id })
public func deleteDevice(_ id: String) {
saveDevices(DevicesConfig.loadOrSeed().devices.filter { $0.id != id })
}
/// Re-seed the default set (plum VLC + black mpv-ipc with LAN+overlay endpoints).
public func resetHostsToDefault() { saveHosts(HostsConfig.seeded().hosts) }
public func resetDevicesToDefault() { saveDevices(DevicesConfig.seeded().devices) }
/// True while the Player tab is on screen. Off-tab we still poll the active
/// target slowly so the HostSelector dots stay fresh and an armed sleep
@ -146,6 +149,7 @@ public final class PlayerController {
public var detailed = false { didSet { if detailed != oldValue { start() } } }
public func start() {
applyMediaKeyForwarding()
pollTask?.cancel()
pollTask = Task { [weak self] in
while !Task.isCancelled {
@ -167,7 +171,36 @@ public final class PlayerController {
}
}
public func stop() { pollTask?.cancel(); statsTask?.cancel() }
public func stop() { pollTask?.cancel(); statsTask?.cancel(); nowPlaying.disable() }
/// Enable/disable system media-key forwarding per the current setting. Called
/// on every `start()` (idempotent), so flipping the setting takes effect live.
/// Handlers route to the active target via the existing `command` path.
public func applyMediaKeyForwarding() {
guard SettingsStore.load().forwardMediaKeys else { nowPlaying.disable(); return }
nowPlaying.enable(.init(
toggle: { [weak self] in self?.command { await $0.playPause() } },
play: { [weak self] in self?.command { await $0.resume() } },
pause: { [weak self] in
guard let self, NowPlayingController.isActivelyPlaying(self.activeSnapshot.status) else { return }
self.command { await $0.playPause() }
},
next: { [weak self] in self?.command { await $0.next() } },
previous: { [weak self] in self?.command { await $0.previous() } },
seek: { [weak self] secs in self?.command { await $0.seek(toSeconds: Int(secs)) } }))
}
/// Push the active target's state to Now Playing / the system transport.
private func pushNowPlaying() {
guard nowPlaying.isEnabled else { return }
let s = activeSnapshot.status
nowPlaying.update(
title: s.title, posterPath: nil,
position: s.position, duration: s.duration,
playing: NowPlayingController.isActivelyPlaying(s),
canStep: NowPlayingController.canStep(playlistCount: s.playlistCount,
isEnqueueable: active is Enqueueable))
}
public func refreshStats() async {
guard let p = active as? HostStatsProvider else {
@ -198,6 +231,7 @@ public final class PlayerController {
apply(await t.poll(), to: t.id)
}
checkEndOfEpisode()
pushNowPlaying()
}
/// Send a command to the active target, then immediately refresh just it so
@ -232,6 +266,16 @@ public final class PlayerController {
/// LaunchRequest (black resolves by name; VLC needs a file path).
public var activeKind: HostKind? { active?.kind }
/// Whether the active target can play a multi-item queue (the unified
/// play-from-here feature falls back to a single launch when it can't).
public var canEnqueue: Bool { active is Enqueueable }
/// Set the launched-series context (so the Sub/Dub track choice keys to the
/// right series) when playback is driven by the queue rather than `launch`.
public func setActiveContext(series: String?, category: String?) {
activeSeries = series; activeCategory = category
}
/// THE single source of truth for where playback goes. Set by the shared
/// HostSelector (Player + Library use the same control); Library and Player
/// both launch to whatever this is. Defaults to the TV (see `reload`).
@ -249,7 +293,7 @@ public final class PlayerController {
/// silently doing nothing.
/// Fire a multi-item play queue at the active host (replaces its playlist and
/// starts the first). Surfaces success/failure via `actionMessage`, like launch.
public func enqueuePlaylist(_ paths: [String]) {
public func enqueuePlaylist(_ paths: [String], resumeFirst: Double? = nil) {
guard let target = active as? Enqueueable else {
actionMessage = "\(active?.name ?? "This host") cant play a queue"; return
}
@ -259,6 +303,8 @@ public final class PlayerController {
Log.info("enqueue \(paths.count) on \(hostName)")
Task {
let ok = await target.enqueue(paths, replace: true)
// Resume the first item where it was left off (the rest start at 0).
if ok, let r = resumeFirst, r > 1 { await active?.seek(toSeconds: Int(r)) }
await refreshActive()
actionMessage = ok ? "Queued \(paths.count) on \(hostName)"
: "Couldnt queue on \(hostName) — host unreachable"
@ -288,10 +334,10 @@ public final class PlayerController {
return
}
Log.info("launch ok on \(hostName)")
// Seek to a saved position after the file loads works for VLC files
// and for episodes launched on black (generic mpv absolute seek). Skip
// black's native `.resume`, which restores position itself.
if let resumeSeconds, resumeSeconds > 1, !isResumeRequest(request) {
// Seek to a saved position after the file loads works for VLC files and
// for episodes launched on black (generic mpv absolute seek). Everything
// launches by path now, so the app always owns the seek.
if let resumeSeconds, resumeSeconds > 1 {
try? await Task.sleep(for: .seconds(1.5))
await active?.seek(toSeconds: Int(resumeSeconds))
}
@ -336,11 +382,6 @@ public final class PlayerController {
Task { await applyTrackPreferenceToActive(); await refreshTracks() }
}
private func isResumeRequest(_ r: LaunchRequest) -> Bool {
if case .resume = r { return true }
return false
}
public func selectAudioTrack(_ id: Int) {
guard let t = active as? TrackSelectable else { return }
Task { await t.setAudioTrack(id); await refreshTracks() }

View file

@ -184,6 +184,56 @@ public final class PlaylistController {
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 (bridged to porn-rotation.py owns freshness)
public private(set) var pornCollections: [PornCollection] = []

View file

@ -18,8 +18,7 @@ public struct PornCollection: Identifiable, Sendable, Equatable, Decodable {
public enum PornCollectionService {
private static var script: String {
ProcessInfo.processInfo.environment["PORN_ROTATION_SCRIPT"]
?? FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Code/@applications/portable-net-tv/tools/porn-rotation.py").path
?? RepoPaths.governor.appendingPathComponent("tools/porn-rotation.py").path
}
private static func shq(_ s: String) -> String {

View file

@ -0,0 +1,26 @@
import Foundation
/// Single source of truth for the in-repo subsystem directories. The helper
/// subsystems (`mcp`, `governor`, `recommender`, `search`) used to be separate
/// repos the app reached into via scattered `~/Code/@applications/...` literals;
/// they are now folded into this one repo. Override the repo root with
/// `$TV_ANARCHY_REPO` for a non-default checkout location (CI, a clone elsewhere).
public enum RepoPaths {
/// The tv-anarchy repo root.
public static var root: URL {
if let p = ProcessInfo.processInfo.environment["TV_ANARCHY_REPO"], !p.isEmpty {
return URL(fileURLWithPath: p, isDirectory: true)
}
return FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Code/@applications/tv-anarchy")
}
/// `plum-control-mcp` CLI (VLC / black-tv / transmission / search bridge).
public static var mcp: URL { root.appendingPathComponent("mcp") }
/// `portable-net-tv` governor (watch + prefetch + porn-rotation tool).
public static var governor: URL { root.appendingPathComponent("governor") }
/// `media-recommender` (TMDB/IMDb enrich + local recs + registry.md).
public static var recommender: URL { root.appendingPathComponent("recommender") }
/// `torrent-search-mcp` fork (the Python search the `mcp` CLI shells into).
public static var search: URL { root.appendingPathComponent("search") }
}

View file

@ -86,6 +86,66 @@ final class PlaylistTests: XCTestCase {
XCTAssertEqual(pc.queue.count, 1)
}
// MARK: unified playlist (play from here) + season-0/specials ordering
/// A show whose season 0 holds the "movies" (Daria's case). Seasons must order
/// 1,2,,0 specials/movies LAST and orderedEpisodes follow that.
private func showWithMovies() -> CachedShow {
let eps = [
CachedEpisode(path: "/p/D/s1e1.mkv", season: 1, episode: 1, label: "S1E1"),
CachedEpisode(path: "/p/D/s1e2.mkv", season: 1, episode: 2, label: "S1E2"),
CachedEpisode(path: "/p/D/s2e1.mkv", season: 2, episode: 1, label: "S2E1"),
CachedEpisode(path: "/p/D/movie1.mkv", season: 0, episode: 1, label: "Is It Fall Yet?"),
CachedEpisode(path: "/p/D/movie2.mkv", season: 0, episode: 2, label: "Is It College Yet?"),
]
return CachedShow(name: "Daria", rootDir: "/r/Daria", category: "cartoons",
kind: .series, episodes: eps)
}
func testSeasonZeroSortsLastAsMovies() {
let d = showWithMovies()
XCTAssertEqual(d.seasons, [1, 2, 0]) // specials/movies last
XCTAssertEqual(d.seasonLabel(0), "Specials & Movies")
XCTAssertEqual(d.orderedEpisodes.map(\.label).last, "Is It College Yet?")
}
func testPlayFromHereQueuesRestThenMovies() {
// Starting Daria S2 queue S2E1, then the movies (season 0) at the end.
let d = showWithMovies()
let slice = PlaylistController.fromHere(show: d, startPath: "/p/D/s2e1.mkv")
XCTAssertEqual(slice.map(\.path), ["/p/D/s2e1.mkv", "/p/D/movie1.mkv", "/p/D/movie2.mkv"])
}
@MainActor
func testLoadFromHerePopulatesQueue() {
let pc = PlaylistController(library: LibraryController())
pc.loadFromHere(show: showWithMovies(), startPath: "/p/D/s1e2.mkv")
// from S1E2: S1E2, S2E1, then the two movies.
XCTAssertEqual(pc.queue.map(\.path),
["/p/D/s1e2.mkv", "/p/D/s2e1.mkv", "/p/D/movie1.mkv", "/p/D/movie2.mkv"])
}
/// The Daria bug: continuing from a S2 episode must queue S3+ (not just play one
/// episode). `loadFromHere` from a mid-show episode includes every later one.
@MainActor
func testContinueFromSeason2QueuesSeason3() {
// Daria-like: S1(2) S2(2) S3(2) + a movie.
let eps = [
CachedEpisode(path: "/p/D/s1e1.mkv", season: 1, episode: 1, label: "S1E1"),
CachedEpisode(path: "/p/D/s1e2.mkv", season: 1, episode: 2, label: "S1E2"),
CachedEpisode(path: "/p/D/s2e1.mkv", season: 2, episode: 1, label: "S2E1"),
CachedEpisode(path: "/p/D/s2e2.mkv", season: 2, episode: 2, label: "S2E2"),
CachedEpisode(path: "/p/D/s3e1.mkv", season: 3, episode: 1, label: "S3E1"),
CachedEpisode(path: "/p/D/s3e2.mkv", season: 3, episode: 2, label: "S3E2"),
CachedEpisode(path: "/p/D/movie.mkv", season: 0, episode: 1, label: "Movie"),
]
let daria = CachedShow(name: "Daria", rootDir: "/r/D", category: "cartoons", kind: .series, episodes: eps)
let slice = PlaylistController.fromHere(show: daria, startPath: "/p/D/s2e1.mkv").map(\.path)
XCTAssertEqual(slice, ["/p/D/s2e1.mkv", "/p/D/s2e2.mkv", "/p/D/s3e1.mkv",
"/p/D/s3e2.mkv", "/p/D/movie.mkv"])
XCTAssertTrue(slice.contains("/p/D/s3e1.mkv")) // S3 IS queued
}
// MARK: persistence
func testQueuePersistsAcrossControllers() {