tv-anarchy/Sources/TVAnarchyCore/AppLocalAPI.swift

547 lines
23 KiB
Swift
Raw Permalink Normal View History

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<T: Encodable>(_ 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[..<headerEnd.lowerBound]
let bodyPart = raw[headerEnd.upperBound...]
let lines = headerPart.split(separator: "\r\n", omittingEmptySubsequences: false)
guard let requestLine = lines.first else { return nil }
let parts = requestLine.split(separator: " ", maxSplits: 2)
guard parts.count >= 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))
}
}