feat(core): player/playlist/porn-service rework + repo paths
This commit is contained in:
parent
117e234a3c
commit
9e38cf9f48
7 changed files with 211 additions and 42 deletions
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")’ can’t 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)’"
|
||||
: "Couldn’t 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() }
|
||||
|
|
|
|||
|
|
@ -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] = []
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
26
Sources/TVAnarchyCore/RepoPaths.swift
Normal file
26
Sources/TVAnarchyCore/RepoPaths.swift
Normal 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") }
|
||||
}
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue