From 9e38cf9f48a861a3c74ce608fa76189725ec2098 Mon Sep 17 00:00:00 2001 From: Natalie Date: Tue, 9 Jun 2026 05:50:02 -0700 Subject: [PATCH] feat(core): player/playlist/porn-service rework + repo paths --- Sources/TVAnarchyCore/MediaLaunchable.swift | 10 +- Sources/TVAnarchyCore/MpvTarget.swift | 13 +-- Sources/TVAnarchyCore/PlayerController.swift | 91 ++++++++++++++----- .../TVAnarchyCore/PlaylistController.swift | 50 ++++++++++ .../TVAnarchyCore/PornCollectionService.swift | 3 +- Sources/TVAnarchyCore/RepoPaths.swift | 26 ++++++ Tests/TVAnarchyCoreTests/PlaylistTests.swift | 60 ++++++++++++ 7 files changed, 211 insertions(+), 42 deletions(-) create mode 100644 Sources/TVAnarchyCore/RepoPaths.swift diff --git a/Sources/TVAnarchyCore/MediaLaunchable.swift b/Sources/TVAnarchyCore/MediaLaunchable.swift index fcd4666..23653f6 100644 --- a/Sources/TVAnarchyCore/MediaLaunchable.swift +++ b/Sources/TVAnarchyCore/MediaLaunchable.swift @@ -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) } diff --git a/Sources/TVAnarchyCore/MpvTarget.swift b/Sources/TVAnarchyCore/MpvTarget.swift index 9f5fc0c..33ef06e 100644 --- a/Sources/TVAnarchyCore/MpvTarget.swift +++ b/Sources/TVAnarchyCore/MpvTarget.swift @@ -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 diff --git a/Sources/TVAnarchyCore/PlayerController.swift b/Sources/TVAnarchyCore/PlayerController.swift index 72e2b95..cc5be60 100644 --- a/Sources/TVAnarchyCore/PlayerController.swift +++ b/Sources/TVAnarchyCore/PlayerController.swift @@ -34,6 +34,9 @@ public final class PlayerController { private var pollTask: Task? private var statsTask: Task? + /// 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() } diff --git a/Sources/TVAnarchyCore/PlaylistController.swift b/Sources/TVAnarchyCore/PlaylistController.swift index 386aa51..160b977 100644 --- a/Sources/TVAnarchyCore/PlaylistController.swift +++ b/Sources/TVAnarchyCore/PlaylistController.swift @@ -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] = [] diff --git a/Sources/TVAnarchyCore/PornCollectionService.swift b/Sources/TVAnarchyCore/PornCollectionService.swift index 62fc3fc..ed8b921 100644 --- a/Sources/TVAnarchyCore/PornCollectionService.swift +++ b/Sources/TVAnarchyCore/PornCollectionService.swift @@ -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 { diff --git a/Sources/TVAnarchyCore/RepoPaths.swift b/Sources/TVAnarchyCore/RepoPaths.swift new file mode 100644 index 0000000..d7b023b --- /dev/null +++ b/Sources/TVAnarchyCore/RepoPaths.swift @@ -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") } +} diff --git a/Tests/TVAnarchyCoreTests/PlaylistTests.swift b/Tests/TVAnarchyCoreTests/PlaylistTests.swift index b6aadb9..0f7e39b 100644 --- a/Tests/TVAnarchyCoreTests/PlaylistTests.swift +++ b/Tests/TVAnarchyCoreTests/PlaylistTests.swift @@ -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() {