// HTTP client for the tv-anarchy-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 } func fetchMovies(refresh: Bool = false) async throws -> [BridgeMovie] { try await get("library/movies", query: refresh ? ["refresh": "1"] : [:], as: MoviesResponse.self).movies } /// 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 func fetchRemoteTargets() async throws -> [RemoteTarget] { try await get("remote/targets", as: TargetsResponse.self).targets } /// Target-scoped when a target id is given; the legacy Black-TV routes otherwise. private func remotePath(_ verb: String, target: String?) -> String { target.map { "remote/t/\($0)/\(verb)" } ?? "remote/\(verb)" } func remoteStatus(target: String? = nil) async throws -> RemoteStatus { try await get(remotePath("status", target: target), as: RemoteStatus.self) } func remoteCommand(action: String, value: Double? = nil, target: String? = nil) async throws { var body: [String: Any] = ["action": action] if let value { body["value"] = value } try await postExpectOK(remotePath("command", target: target), body: body) } func remotePlay(show: String, season: Int? = nil, episode: Int? = nil, target: String? = nil) async throws { var body: [String: Any] = ["show": show] if let season { body["season"] = season } if let episode { body["episode"] = episode } try await postExpectOK(remotePath("play", target: target), 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(_ 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 } }