import Foundation struct MediaTrack: Decodable, Equatable { let id: String let kind: String? let videoID: String? let title: String? let channel: String? let duration: Double? let status: String? let error: String? let audioURL: String? let artURL: String? enum CodingKeys: String, CodingKey { case id, kind, title, channel, duration, status, error case videoID = "video_id" case audioURL = "audio_url" case artURL = "art_url" } } struct MediaPendingJob: Decodable, Equatable { let jobID: String? let trackID: String? let stage: String? let progress: Double? let error: String? enum CodingKeys: String, CodingKey { case stage, progress, error case jobID = "job_id" case trackID = "track_id" } } struct MediaSeekHint: Decodable, Equatable { let ts: Double let position: Double } struct MediaState: Decodable, Equatable { let room: String? let track: MediaTrack? let desired: String? let reported: MediaReported? let seekHint: MediaSeekHint? let choices: [MediaChoice] let pending: [MediaPendingJob] let version: Int? enum CodingKeys: String, CodingKey { case room, track, desired, reported, choices, pending, version case seekHint = "seek_hint" } } struct MediaReported: Decodable, Equatable { let trackID: String? let state: String? let position: Double? let duration: Double? let ts: Double? enum CodingKeys: String, CodingKey { case state, position, duration, ts case trackID = "track_id" } } struct MediaChoice: Decodable, Equatable { let videoID: String? let title: String? let channel: String? let duration: Double? enum CodingKeys: String, CodingKey { case title, channel, duration case videoID = "video_id" } } struct MediaLibrary: Decodable, Equatable { let tracks: [MediaTrack] let version: Int? } struct MediaSearchResult: Decodable, Equatable { let videoID: String? let title: String? let channel: String? let duration: Double? let inLibrary: Bool? enum CodingKeys: String, CodingKey { case title, channel, duration case videoID = "video_id" case inLibrary = "in_library" } } struct MediaSearchResponse: Decodable, Equatable { let results: [MediaSearchResult] } enum MediaAPIError: LocalizedError { case invalidServerURL(String) case httpStatus(Int, String) case decodeFailed var errorDescription: String? { switch self { case .invalidServerURL(let raw): return "Invalid server URL: \(raw)" case .httpStatus(let code, let detail): return "Media API request failed with HTTP \(code). \(detail)" case .decodeFailed: return "Unexpected response from the media endpoint." } } } final class MediaAPI { let serverBase: URL private let baseURL: URL private let session: URLSession init(config: ServerConfig) { self.serverBase = config.baseURL self.baseURL = config.baseURL.appendingPathComponent("media/api") self.session = TLSPinning.session() } func state(room: String) async throws -> MediaState { var components = URLComponents(url: baseURL.appendingPathComponent("state"), resolvingAgainstBaseURL: false)! components.queryItems = [URLQueryItem(name: "room", value: room)] return try await get(MediaState.self, url: components.url!) } func report(room: String, state: String, trackID: String?, position: Double, duration: Double?) async throws { var body: [String: Any] = ["room": room, "state": state, "position": position] if let trackID { body["track_id"] = trackID } if let duration { body["duration"] = duration } try await post("report", body: body) } func play(room: String, videoID: String? = nil, title: String? = nil, channel: String? = nil, trackID: String? = nil, startPaused: Bool? = nil) async throws { var body: [String: Any] = ["room": room] if let videoID { body["video_id"] = videoID } if let title { body["title"] = title } if let channel { body["channel"] = channel } if let trackID { body["track_id"] = trackID } if let startPaused { body["start_paused"] = startPaused } try await post("play", body: body) } func control(room: String, action: String) async throws { try await post("control", body: ["room": room, "action": action]) } func search(room: String, query: String, limit: Int = 6) async throws -> MediaSearchResponse { try await postDecoded("search", body: ["room": room, "query": query, "limit": limit], as: MediaSearchResponse.self) } func library() async throws -> MediaLibrary { try await get(MediaLibrary.self, url: baseURL.appendingPathComponent("library")) } func deleteTrack(id: String) async throws { var request = URLRequest(url: baseURL.appendingPathComponent("tracks").appending(path: id)) request.httpMethod = "DELETE" let (data, response) = try await session.data(for: request) guard let http = response as? HTTPURLResponse else { throw MediaAPIError.decodeFailed } guard (200..<300).contains(http.statusCode) else { throw MediaAPIError.httpStatus(http.statusCode, String(data: data, encoding: .utf8) ?? "") } } func addStream(room: String, name: String, url: String) async throws { try await post("streams", body: ["room": room, "name": name, "url": url]) } func streamURL(trackID: String) async throws -> URL { let data = try await getRaw(url: baseURL.appendingPathComponent("stream").appending(path: trackID)) guard let raw = String(data: data, encoding: .utf8), !raw.isEmpty else { throw MediaAPIError.decodeFailed } if let url = URL(string: raw) { return url } if raw.hasPrefix("/") { return serverBase.appendingPathComponent(String(raw.dropFirst())) } return serverBase.appendingPathComponent(raw) } func healthz() async throws -> Bool { let data = try await getRaw(url: baseURL.appendingPathComponent("healthz")) guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return false } return json["ok"] as? Bool ?? false } private func get(_ type: T.Type, url: URL) async throws -> T { let data = try await getRaw(url: url) do { return try JSONDecoder().decode(T.self, from: data) } catch { throw MediaAPIError.decodeFailed } } private func getRaw(url: URL) async throws -> Data { let (data, response) = try await session.data(from: url) guard let http = response as? HTTPURLResponse else { throw MediaAPIError.decodeFailed } guard (200..<300).contains(http.statusCode) else { throw MediaAPIError.httpStatus(http.statusCode, String(data: data, encoding: .utf8) ?? "") } return data } private func post(_ path: String, body: [String: Any]) async throws { _ = try await postDecoded(path, body: body, as: Data.self) } private func postDecoded(_ path: String, body: [String: Any], as type: T.Type) async throws -> T { var request = URLRequest(url: baseURL.appendingPathComponent(path)) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try JSONSerialization.data(withJSONObject: body) let (data, response) = try await session.data(for: request) guard let http = response as? HTTPURLResponse else { throw MediaAPIError.decodeFailed } guard (200..<300).contains(http.statusCode) else { var detail = String(data: data, encoding: .utf8) ?? "" if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let error = json["error"] as? String { detail = error } throw MediaAPIError.httpStatus(http.statusCode, detail) } do { return try JSONDecoder().decode(T.self, from: data) } catch { throw MediaAPIError.decodeFailed } } }