import Foundation import Network // MARK: - wire models public struct OfflineFileInfo: Codable, Sendable, Equatable { public let path: String public let name: String public let bytes: Int64 } public struct OfflineStatusPayload: Codable, Sendable, Equatable { public let cacheDir: String public let onDiskFiles: Int public let onDiskBytes: Int64 public let diskTotalBytes: Int64? public let diskFreeBytes: Int64? public let budgetBytes: Int64 public let policy: OfflineCachePolicy public let caching: Bool public let lastPlanCount: Int public let downloadingLabel: String? public let downloadingProgress: Double? public let queueProgress: Double? public let downloadQueue: [OfflineQueueItem] public let downloadSummary: String? public let status: String? public let localDeviceId: String? public let offlineCacheEnabled: Bool public let files: [OfflineFileInfo] public let lastCullSummary: String? public let lastCulledAt: Date? public let lastCulledFiles: [String] public let planMissingCount: Int public let reconcileIntervalSeconds: Int } public struct DevicesPayload: Codable, Sendable { public let devices: [DeviceConfig] } public struct LocalDevicePayload: Codable, Sendable { public let device: DeviceConfig public let offlinePolicy: OfflineCachePolicy } public struct SettingsPayload: Codable, Sendable { public let settings: AppSettings } public struct PlayerStatusPayload: Codable, Sendable { public let activeId: String public let activeName: String? public let playbackMode: PlaybackMode } public struct PlayShowRequest: Codable, Sendable { public let show: String public var season: Int? public var episode: Int? } public struct PlayResultPayload: Codable, Sendable { public let activeId: String public let activeName: String? public let show: String public let season: Int public let episode: Int public let label: String public let path: String public let queueLength: Int } // MARK: - server /// Localhost HTTP API so MCP / Claude can read live app state (offline progress, /// device policy, app settings) without guessing from log files. When the Mac app /// is running it is the single source of truth — patches go through LibraryController /// / PlayerController so memory and disk stay aligned. @MainActor public final class AppLocalAPI { public static let defaultPort: UInt16 = 8791 public static var port: UInt16 { if let raw = ProcessInfo.processInfo.environment["TV_ANARCHY_APP_PORT"], let n = UInt16(raw) { return n } return defaultPort } private let queue = DispatchQueue(label: "tv-anarchy.app-api", qos: .userInitiated) private var listener: NWListener? private weak var offline: OfflineCacheController? private weak var player: PlayerController? private weak var library: LibraryController? private weak var playlist: PlaylistController? public init() {} public func attach(offline: OfflineCacheController, player: PlayerController, library: LibraryController, playlist: PlaylistController) { self.offline = offline self.player = player self.library = library self.playlist = playlist } public func start() { guard listener == nil else { return } let p = NWEndpoint.Port(rawValue: Self.port)! do { let params = NWParameters.tcp params.allowLocalEndpointReuse = true listener = try NWListener(using: params, on: p) } catch { Log.error("app API: failed to bind :\(Self.port) — \(error)") return } listener?.newConnectionHandler = { [weak self] conn in Task { @MainActor in self?.accept(conn) } } listener?.stateUpdateHandler = { state in if case .failed(let err) = state { Log.error("app API listener failed: \(err)") } } listener?.start(queue: queue) // Security (v1 review + v2 plan): local-only (127 loopback by client + log; no network exposure intended). // See correlation/cross-cutting.md "Infra Security Surfaces" + plan.md expanded checklist (mandatory auth/PathGuard for surfaces). // For app-internal + MCP app_cfg only; do not expose. (Full NW localhost-only bind is client-enforced + firewall.) Log.info("app API listening on 127.0.0.1:\(Self.port) (local-only; security per v2 plan)") } public func stop() { listener?.cancel() listener = nil } // MARK: handlers (testable) public func offlineStatus() -> OfflineStatusPayload { let policy = DevicesConfig.localOfflinePolicy() let root = OfflineCacheController.destRoot(for: policy).path let cfg = DevicesConfig.loadOrSeed() let local = cfg.localDevice let off = offline return OfflineStatusPayload( cacheDir: root, onDiskFiles: off?.diskFileCount ?? OfflineCacheController.scanDisk().0, onDiskBytes: off?.diskBytes ?? OfflineCacheController.scanDisk().1, diskTotalBytes: OfflineCacheController.storageTotalBytes(at: root), diskFreeBytes: OfflineCacheController.storageFreeBytes(at: root), budgetBytes: OfflineCacheController.budgetBytes(policy: policy), policy: policy, caching: off?.caching ?? false, lastPlanCount: off?.lastPlanCount ?? 0, downloadingLabel: off?.downloadingLabel, downloadingProgress: off?.downloadingProgress, queueProgress: off?.queueProgress, downloadQueue: off?.downloadQueue ?? [], downloadSummary: off?.downloadSummaryLine, status: off?.status, localDeviceId: local?.id, offlineCacheEnabled: local?.services.offlineCache ?? false, files: Self.listCachedFiles(policy: policy), lastCullSummary: off?.lastCullSummary, lastCulledAt: off?.lastCulledAt, lastCulledFiles: off?.lastCulledFiles ?? [], planMissingCount: off?.planMissingCount ?? 0, reconcileIntervalSeconds: Int(OfflineCacheController.reconcileInterval)) } public func settingsSnapshot() -> AppSettings { library?.appSettings ?? SettingsStore.load() } public func patchSettings(_ patch: Data) throws -> SettingsPayload { guard let library else { throw APIError.unavailable("library not attached") } let partial = try JSONDecoder().decode(AppSettingsPatch.self, from: patch) library.patchSettings(partial) if partial.forwardMediaKeys != nil || partial.forwardVolumeKeys != nil { player?.applyMediaKeyForwarding() } return SettingsPayload(settings: library.appSettings) } public func patchOfflinePolicy(deviceId: String, patch: Data) throws -> LocalDevicePayload { guard let player else { throw APIError.unavailable("player not attached") } let partial = try JSONDecoder().decode(OfflinePolicyPatch.self, from: patch) var cur = DevicesConfig.loadOrSeed().devices .first { $0.id == deviceId }?.resolvedOfflinePolicy() ?? .defaults if let v = partial.warmupEnabled { cur.warmupEnabled = v } if let v = partial.episodesAhead { cur.episodesAhead = v } if let v = partial.episodesBehind { cur.episodesBehind = v } if let v = partial.shows { cur.shows = v } if let v = partial.fromContinueWatching { cur.fromContinueWatching = v } if let v = partial.cullEnabled { cur.cullEnabled = v } if let v = partial.budgetPercent { cur.budgetPercent = v } if let v = partial.reserveFreeGB { cur.reserveFreeGB = v } if let v = partial.cacheDir { cur.cacheDir = v.isEmpty ? nil : v } if let v = partial.pinned { cur.pinned = v } player.updateOfflinePolicy(deviceId: deviceId, cur) guard let d = DevicesConfig.loadOrSeed().devices.first(where: { $0.id == deviceId }) else { throw APIError.notFound("device \(deviceId)") } return LocalDevicePayload(device: d, offlinePolicy: d.resolvedOfflinePolicy()) } public func triggerWarmup() { guard let offline else { return } // Explicit warmup uses saved policy immediately (not the debounced actuation copy). let saved = DevicesConfig.localOfflinePolicy() Task { await offline.warmupIfEnabled(policy: saved) } } public func triggerDestroy() { guard let offline else { return } // Fire-and-forget like warmup; caller (UI or bridge) can poll /offline/status after. let saved = DevicesConfig.localOfflinePolicy() Task { _ = await offline.destroyAllOfflineMedia(policy: saved) } } public func playerStatus() throws -> PlayerStatusPayload { guard let player else { throw APIError.unavailable("player not attached") } return PlayerStatusPayload(activeId: player.activeID, activeName: player.active?.name, playbackMode: player.playbackMode) } public func setActivePlayer(_ body: Data) throws -> PlayerStatusPayload { guard let player else { throw APIError.unavailable("player not attached") } struct Body: Decodable { let deviceId: String } let req = try JSONDecoder().decode(Body.self, from: body) guard DevicesConfig.loadOrSeed().devices.contains(where: { $0.id == req.deviceId }) else { throw APIError.notFound("device \(req.deviceId)") } player.setActive(req.deviceId) return try playerStatus() } /// Play a series episode on whatever host the user last selected in the app — /// does not switch devices. Queues the rest of the show when the host supports it. public func playShow(_ body: Data) throws -> PlayResultPayload { guard let player, let library, let playlist else { throw APIError.unavailable("player not attached") } let req = try JSONDecoder().decode(PlayShowRequest.self, from: body) let season = req.season ?? 1 let episode = req.episode ?? 1 guard let show = Self.resolveShow(named: req.show, in: library.shows) else { throw APIError.notFound("show not found: \(req.show)") } guard let ep = show.orderedEpisodes.first(where: { $0.season == season && $0.episode == episode }) else { throw APIError.notFound("no S\(season)E\(episode) in \(show.name)") } let queueLen: Int if show.kind == .series, player.canEnqueue { playlist.loadFromHere(show: show, startPath: ep.path) player.setActiveContext(series: show.name, category: show.category) playlist.play(on: player, resumeFirst: nil) queueLen = playlist.queue.count } else { guard let kind = player.activeKind, let launch = library.launchRequest(show: show, episode: ep, targetKind: kind) else { throw APIError.unavailable("no player selected") } player.launch(launch, series: show.name, category: show.category) queueLen = 1 } return PlayResultPayload(activeId: player.activeID, activeName: player.active?.name, show: show.name, season: ep.season, episode: ep.episode, label: ep.label, path: ep.path, queueLength: queueLen) } /// Resume a show from the watchlog frontier on the user's selected host. public func resumeShow(_ body: Data) throws -> PlayResultPayload { guard let player, let library, let playlist else { throw APIError.unavailable("player not attached") } let req = try JSONDecoder().decode(PlayShowRequest.self, from: body) guard let show = Self.resolveShow(named: req.show, in: library.shows) else { throw APIError.notFound("show not found: \(req.show)") } guard let item = library.continueWatching.first(where: { $0.show?.lowercased() == show.name.lowercased() }) ?? library.continueWatching.first(where: { cw in show.episodes.contains { MediaPaths.toRemote($0.path) == MediaPaths.toRemote(cw.path) } }) else { throw APIError.notFound("nothing to continue for \(show.name)") } if playlist.playContinue(item, shows: library.shows, on: player) { return PlayResultPayload(activeId: player.activeID, activeName: player.active?.name, show: show.name, season: item.season ?? 0, episode: item.episode ?? 0, label: item.title, path: item.path, queueLength: playlist.queue.count) } guard let kind = player.activeKind, let launch = library.launchRequest(continue: item, targetKind: kind) else { throw APIError.unavailable("no player selected") } player.launch(launch, series: item.show, resumeSeconds: item.positionSeconds) return PlayResultPayload(activeId: player.activeID, activeName: player.active?.name, show: show.name, season: item.season ?? 0, episode: item.episode ?? 0, label: item.title, path: item.path, queueLength: 1) } nonisolated static func resolveShow(named query: String, in shows: [CachedShow]) -> CachedShow? { let q = query.trimmingCharacters(in: .whitespaces).lowercased() guard !q.isEmpty else { return nil } let hits = shows.filter { $0.name.lowercased().contains(q) || q.contains($0.name.lowercased()) } if hits.count == 1 { return hits[0] } if let exact = hits.first(where: { $0.name.lowercased() == q }) { return exact } return hits.min(by: { $0.name.count < $1.name.count }) } nonisolated static func listCachedFiles(policy: OfflineCachePolicy) -> [OfflineFileInfo] { let fm = FileManager.default let root = OfflineCacheController.destRoot(for: policy).path guard let entries = try? fm.contentsOfDirectory(atPath: root) else { return [] } var files: [OfflineFileInfo] = [] var stack = entries.map { root + "/" + $0 } while let dir = stack.popLast() { guard let kids = try? fm.contentsOfDirectory(atPath: dir) else { continue } for name in kids { let p = dir + "/" + name var isDir: ObjCBool = false guard fm.fileExists(atPath: p, isDirectory: &isDir) else { continue } if isDir.boolValue { stack.append(p); continue } guard DownloadsIndex.isVideoFilename(name) else { continue } let bytes = (try? fm.attributesOfItem(atPath: p)[.size] as? Int64) ?? 0 files.append(OfflineFileInfo(path: p, name: name, bytes: bytes)) } } return files.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } } // MARK: connection private func accept(_ connection: NWConnection) { connection.start(queue: queue) var buffer = Data() func receive() { connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { data, _, isComplete, err in if let data { buffer.append(data) } if err != nil || isComplete { connection.cancel() return } guard let req = HTTPParser.parse(buffer) else { receive() return } Task { @MainActor in let resp = self.dispatch(req) Self.send(resp, on: connection, queue: self.queue) } } } receive() } private func dispatch(_ req: HTTPRequest) -> HTTPResponse { let path = req.path.split(separator: "?", maxSplits: 1).first.map(String.init) ?? req.path switch (req.method, path) { case ("GET", "/healthz"): return .json(200, ["ok": true]) case ("GET", "/offline/status"): return .json(200, offlineStatus()) case ("POST", "/offline/warmup"): triggerWarmup() return .json(202, ["accepted": true]) case ("POST", "/offline/destroy"): triggerDestroy() return .json(202, ["accepted": true]) case ("GET", "/devices"): return .json(200, DevicesPayload(devices: DevicesConfig.loadOrSeed().devices)) case ("GET", "/devices/local"): guard let d = DevicesConfig.loadOrSeed().localDevice else { return .json(404, ["error": "no local device"]) } return .json(200, LocalDevicePayload(device: d, offlinePolicy: d.resolvedOfflinePolicy())) case ("GET", "/settings"): return .json(200, SettingsPayload(settings: settingsSnapshot())) case ("GET", "/player/status"): do { return .json(200, try playerStatus()) } catch let e as APIError { return .json(e.status, ["error": e.message]) } catch { return .json(400, ["error": error.localizedDescription]) } case ("PATCH", "/player/active"): do { return .json(200, try setActivePlayer(req.body)) } catch let e as APIError { return .json(e.status, ["error": e.message]) } catch { return .json(400, ["error": error.localizedDescription]) } case ("POST", "/play/show"): do { return .json(202, try playShow(req.body)) } catch let e as APIError { return .json(e.status, ["error": e.message]) } catch { return .json(400, ["error": error.localizedDescription]) } case ("POST", "/play/resume-show"): do { return .json(202, try resumeShow(req.body)) } catch let e as APIError { return .json(e.status, ["error": e.message]) } catch { return .json(400, ["error": error.localizedDescription]) } case ("PATCH", "/settings"): do { let body = try patchSettings(req.body) return .json(200, body) } catch let e as APIError { return .json(e.status, ["error": e.message]) } catch { return .json(400, ["error": error.localizedDescription]) } default: if req.method == "PATCH", path.hasPrefix("/devices/"), path.hasSuffix("/offline-policy") { let id = String(path.dropFirst("/devices/".count).dropLast("/offline-policy".count)) do { let body = try patchOfflinePolicy(deviceId: id, patch: req.body) return .json(200, body) } catch let e as APIError { return .json(e.status, ["error": e.message]) } catch { return .json(400, ["error": error.localizedDescription]) } } return .json(404, ["error": "not found"]) } } private static func send(_ resp: HTTPResponse, on connection: NWConnection, queue: DispatchQueue) { var payload = resp.body if payload.isEmpty, let enc = try? JSONEncoder().encode(EmptyJSON()) { payload = enc } let headers = [ "HTTP/1.1 \(resp.status) \(HTTPResponse.phrase(resp.status))", "Content-Type: application/json", "Content-Length: \(payload.count)", "Connection: close", "", "", ].joined(separator: "\r\n") var out = Data(headers.utf8) out.append(payload) connection.send(content: out, completion: .contentProcessed { _ in connection.cancel() }) } } private struct EmptyJSON: Encodable {} private struct OfflinePolicyPatch: Decodable { var warmupEnabled: Bool? var episodesAhead: Int? var episodesBehind: Int? var shows: Int? var fromContinueWatching: Bool? var cullEnabled: Bool? var budgetPercent: Int? var reserveFreeGB: Int? var cacheDir: String? var pinned: [String]? } enum APIError: Error { case notFound(String) case unavailable(String) var status: Int { switch self { case .notFound: 404 case .unavailable: 503 } } var message: String { switch self { case .notFound(let m), .unavailable(let m): m } } } // MARK: - minimal HTTP struct HTTPRequest { let method: String let path: String let body: Data } struct HTTPResponse { let status: Int let body: Data static func json(_ status: Int, _ value: T) -> HTTPResponse { let body = (try? JSONEncoder().encode(value)) ?? Data() return HTTPResponse(status: status, body: body) } static func json(_ status: Int, _ dict: [String: Any]) -> HTTPResponse { let body = (try? JSONSerialization.data(withJSONObject: dict)) ?? Data() return HTTPResponse(status: status, body: body) } static func phrase(_ code: Int) -> String { switch code { case 200: "OK" case 202: "Accepted" case 400: "Bad Request" case 404: "Not Found" case 503: "Service Unavailable" default: "OK" } } } enum HTTPParser { static func parse(_ data: Data) -> HTTPRequest? { guard let raw = String(data: data, encoding: .utf8) else { return nil } guard let headerEnd = raw.range(of: "\r\n\r\n") else { return nil } let headerPart = raw[..= 2 else { return nil } let method = String(parts[0]) let path = String(parts[1]) var contentLength = 0 for line in lines.dropFirst() { let lower = line.lowercased() if lower.hasPrefix("content-length:") { contentLength = Int(lower.dropFirst("content-length:".count).trimmingCharacters(in: .whitespaces)) ?? 0 } } let bodyStr = String(bodyPart) let bodyData = Data(bodyStr.utf8) if bodyData.count < contentLength { return nil } let body = bodyData.prefix(contentLength) return HTTPRequest(method: method, path: path, body: Data(body)) } }