tv-anarchy/Sources/TVAnarchyiOS/BridgeClient.swift
Natalie 17cf518418 feat(ios): downloads (DownloadManager/DownloadsView), remote control view + bridge/player refinements
(cherry picked from commit a1f7e44e17bb41f48976b69f4dbe5278cbad06b2)
2026-06-09 06:38:45 -07:00

152 lines
5.9 KiB
Swift

// HTTP client for the plum-control-bridge. Covers the full surface: library,
// streaming + artwork URLs, watch progress/continue, remote transport, and
// downloads. Auth (when the bridge sets a token) rides as a Bearer header for
// JSON and a ?token= query for media URLs handed to VLCKit.
import Foundation
enum BridgeError: LocalizedError {
case badStatus(Int)
case transport(String)
case decode(String)
var errorDescription: String? {
switch self {
case .badStatus(let code): return "Bridge returned HTTP \(code)."
case .transport(let msg): return msg
case .decode(let msg): return "Bad response: \(msg)."
}
}
}
struct BridgeClient {
let baseURL: URL
let token: String?
// MARK: - Library / media
func fetchShows(refresh: Bool = false) async throws -> [BridgeShow] {
try await get("library/shows", query: refresh ? ["refresh": "1"] : [:], as: ShowsResponse.self).shows
}
/// URL VLCKit (or a download task) reads the raw video from. base64url id is path-safe.
func streamURL(episodeId: String) -> URL {
mediaURL(["stream", episodeId])
}
func artworkURL(episodeId: String) -> URL {
mediaURL(["artwork", episodeId])
}
// MARK: - Watch progress
func fetchContinue() async throws -> [ContinueItem] {
try await get("watch/continue", as: ContinueResponse.self).items
}
func resumePosition(episodeId: String) async -> Double {
// Best-effort: a missing resume just starts from 0.
(try? await get("watch", "episode", episodeId, as: ResumeResponse.self).positionSeconds) ?? 0
}
func reportProgress(episodeId: String, positionSeconds: Double, durationSeconds: Double?, finished: Bool) async {
var body: [String: Any] = ["episodeId": episodeId, "positionSeconds": positionSeconds, "finished": finished]
if let durationSeconds, durationSeconds > 0 { body["durationSeconds"] = durationSeconds }
_ = try? await postRaw("watch/progress", body: body)
}
// MARK: - Remote transport (Black TV)
func remoteStatus() async throws -> RemoteStatus {
try await get("remote/status", as: RemoteStatus.self)
}
func remoteCommand(action: String, value: Double? = nil) async throws {
var body: [String: Any] = ["action": action]
if let value { body["value"] = value }
try await postExpectOK("remote/command", body: body)
}
func remotePlay(show: String, season: Int? = nil, episode: Int? = nil) async throws {
var body: [String: Any] = ["show": show]
if let season { body["season"] = season }
if let episode { body["episode"] = episode }
try await postExpectOK("remote/play", body: body)
}
// MARK: - Downloads
func fetchTorrents() async throws -> [Torrent] {
try await get("torrents", as: TorrentsResponse.self).torrents
}
func searchTorrents(query: String, limit: Int = 20) async throws -> [SearchResult] {
try await get("torrents/search", query: ["q": query, "limit": String(limit)], as: SearchResponse.self).results
}
func addTorrent(magnet: String, category: String?) async throws {
var body: [String: Any] = ["magnet": magnet]
if let category { body["category"] = category }
try await postExpectOK("torrents", body: body)
}
func removeTorrent(id: Int, deleteData: Bool) async throws {
try await send("torrents/\(id)", method: "DELETE", query: deleteData ? ["delete": "1"] : [:], body: nil)
}
// MARK: - Request plumbing
private func mediaURL(_ components: [String]) -> URL {
var url = baseURL
for c in components { url.appendPathComponent(c) }
var comps = URLComponents(url: url, resolvingAgainstBaseURL: false)!
if let token { comps.queryItems = [URLQueryItem(name: "token", value: token)] }
return comps.url!
}
private func get<T: Decodable>(_ components: String..., query: [String: String] = [:], as type: T.Type) async throws -> T {
let data = try await send(path(components), method: "GET", query: query, body: nil)
do {
return try JSONDecoder().decode(T.self, from: data)
} catch {
throw BridgeError.decode(String(describing: error))
}
}
private func postRaw(_ path: String, body: [String: Any]) async throws -> Data {
try await send(path, method: "POST", query: [:], body: JSONSerialization.data(withJSONObject: body))
}
private func postExpectOK(_ path: String, body: [String: Any]) async throws {
_ = try await postRaw(path, body: body)
}
private func path(_ components: [String]) -> String { components.joined(separator: "/") }
@discardableResult
private func send(_ path: String, method: String, query: [String: String], body: Data?) async throws -> Data {
var comps = URLComponents(url: baseURL.appendingPathComponent(path), resolvingAgainstBaseURL: false)!
if !query.isEmpty {
comps.queryItems = query.map { URLQueryItem(name: $0.key, value: $0.value) }
}
var request = URLRequest(url: comps.url!)
request.httpMethod = method
request.timeoutInterval = 60 // black SSH commands can be slow
if let token { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") }
if let body {
request.httpBody = body
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
}
let data: Data
let response: URLResponse
do {
(data, response) = try await URLSession.shared.data(for: request)
} catch {
throw BridgeError.transport(error.localizedDescription)
}
guard let http = response as? HTTPURLResponse else { throw BridgeError.transport("No HTTP response.") }
guard (200..<300).contains(http.statusCode) else { throw BridgeError.badStatus(http.statusCode) }
return data
}
}