152 lines
5.9 KiB
Swift
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
|
|
}
|
|
}
|