import Foundation enum ApiError: LocalizedError { case message(String) case badStatus(Int, String) var errorDescription: String? { switch self { case .message(let m): return m case .badStatus(let code, let body): return body.isEmpty ? "HTTP \(code)" : body } } } actor ApiClient { private var baseUrl: String private var token: String? private let session: URLSession init(baseUrl: String = SessionStore.defaultApiBase, token: String? = nil) { self.baseUrl = baseUrl.trimmingCharacters(in: CharacterSet(charactersIn: "/")) self.token = token let config = URLSessionConfiguration.default config.timeoutIntervalForRequest = 30 config.timeoutIntervalForResource = 60 self.session = URLSession(configuration: config) } func configure(baseUrl: String, token: String?) { self.baseUrl = baseUrl.trimmingCharacters(in: CharacterSet(charactersIn: "/")) self.token = token } func createDeviceCode(deviceName: String) async throws -> DeviceCodeResponse { try await post("/api/v1/client/device/code", json: [ "deviceName": deviceName, "platform": "apple_tv", ], authed: false) } func pollDeviceCode(code: String) async throws -> PollResult { let encoded = code.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? code let data = try await getData("/api/v1/client/device/poll?code=\(encoded)", authed: false) let obj = try JSONSerialization.jsonObject(with: data) as? [String: Any] ?? [:] switch (obj["status"] as? String) ?? "" { case "linked": let token = obj["refreshToken"] as? String ?? "" let viewer = obj["viewer"] as? [String: Any] let name = (viewer?["name"] as? String) ?? (viewer?["email"] as? String) ?? "Kijker" return .linked(refreshToken: token, viewerName: name) case "expired": return .expired case "already_linked": return .alreadyLinked default: return .pending } } func login(email: String, password: String, deviceName: String) async throws -> (refreshToken: String, viewerName: String) { struct LoginResponse: Decodable { let refreshToken: String let viewer: ViewerLite? struct ViewerLite: Decodable { let name: String? let email: String? } } let res: LoginResponse = try await post("/api/v1/client/login", json: [ "email": email, "password": password, "deviceName": deviceName, "platform": "apple_tv", ], authed: false) let name = res.viewer?.name ?? res.viewer?.email ?? "Kijker" return (res.refreshToken, name) } func home() async throws -> HomePayload { try await get("/api/v1/client/home") } func search(query: String) async throws -> [MediaItem] { let q = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? query let payload: SearchPayload = try await get("/api/v1/client/search?q=\(q)&take=60") return payload.results } func browse( catalogId: String, skip: Int = 0, take: Int = 60, sort: String? = nil, letter: String? = nil ) async throws -> BrowsePayload { let id = catalogId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? catalogId var q = "skip=\(skip)&take=\(take)" if let sort, !sort.isEmpty { q += "&sort=\(sort.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? sort)" } if let letter, !letter.isEmpty { q += "&letter=\(letter.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? letter)" } return try await get("/api/v1/client/catalog/\(id)?\(q)") } func movie(id: String) async throws -> MovieDetail { let encoded = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id return try await get("/api/v1/client/movies/\(encoded)") } func series(id: String) async throws -> SeriesDetail { let encoded = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id return try await get("/api/v1/client/series/\(encoded)") } func nextEpisode(episodeId: String) async throws -> NextEpisode? { let encoded = episodeId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? episodeId struct Wrap: Decodable { let next: NextEpisode? } let wrap: Wrap = try await get("/api/v1/client/episodes/\(encoded)/next") return wrap.next } func play(mediaFileId: String) async throws -> PlayResult { try await post("/api/v1/client/play", json: ["mediaFileId": mediaFileId]) } func postProgress( mediaType: String, movieId: String? = nil, episodeId: String? = nil, seriesId: String? = nil, positionMs: Int64, durationMs: Int64 ) async throws { var body: [String: Any] = [ "mediaType": mediaType, "positionMs": positionMs, "durationMs": durationMs, ] if let movieId { body["movieId"] = movieId } if let episodeId { body["episodeId"] = episodeId } if let seriesId { body["seriesId"] = seriesId } let _: Empty = try await post("/api/v1/client/progress", json: body) } func markWatched(movieId: String? = nil, episodeId: String? = nil) async throws { var body: [String: Any] = [:] if let movieId { body["movieId"] = movieId } if let episodeId { body["episodeId"] = episodeId } let _: Empty = try await post("/api/v1/client/progress/mark-watched", json: body) } func dismissContinue(progressId: String) async throws { let encoded = progressId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? progressId let _: Empty = try await post("/api/v1/client/progress/\(encoded)/dismiss", json: [:]) } func listProfiles() async throws -> ProfilesPayload { try await get("/api/v1/client/profiles") } func selectProfile(profileId: String, pin: String? = nil) async throws -> ViewerProfile { var body: [String: Any] = [:] if let pin { let digits = pin.filter(\.isNumber) if !digits.isEmpty { body["pin"] = String(digits) } } let encoded = profileId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? profileId struct Wrap: Decodable { let profile: ViewerProfile } let wrap: Wrap = try await post("/api/v1/client/profiles/\(encoded)/select", json: body) return wrap.profile } func clearProfile() async throws { let _: Empty = try await post("/api/v1/client/profiles/clear", json: [:]) } func createProfile(name: String, avatarId: String, isKids: Bool, pin: String?) async throws -> ViewerProfile { var body: [String: Any] = [ "name": name, "avatarId": avatarId, "isKids": isKids, ] if let pin { let digits = pin.filter(\.isNumber) if digits.count == 4 { body["pin"] = String(digits) } } struct Wrap: Decodable { let profile: ViewerProfile } let wrap: Wrap = try await post("/api/v1/client/profiles", json: body) return wrap.profile } func updateProfile( profileId: String, name: String? = nil, avatarId: String? = nil, showAvatar: Bool? = nil, clearAvatarImage: Bool = false, isKids: Bool? = nil, pin: String? = nil, clearPin: Bool = false ) async throws -> ViewerProfile { var body: [String: Any] = [:] if let name { body["name"] = name } if let avatarId { body["avatarId"] = avatarId } if let showAvatar { body["showAvatar"] = showAvatar } if clearAvatarImage { body["clearAvatarImage"] = true } if let isKids { body["isKids"] = isKids } if clearPin { body["clearPin"] = true } else if let pin { let digits = pin.filter(\.isNumber) if digits.count == 4 { body["pin"] = String(digits) } } let encoded = profileId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? profileId struct Wrap: Decodable { let profile: ViewerProfile } let wrap: Wrap = try await patch("/api/v1/client/profiles/\(encoded)", json: body) return wrap.profile } func uploadProfileAvatar(profileId: String, imageBase64: String, mimeType: String = "image/jpeg") async throws -> ViewerProfile { let encoded = profileId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? profileId struct Wrap: Decodable { let profile: ViewerProfile } let wrap: Wrap = try await post("/api/v1/client/profiles/\(encoded)/avatar", json: [ "imageBase64": imageBase64, "mimeType": mimeType, ]) return wrap.profile } func absoluteUrl(_ path: String?) -> URL? { guard let path, !path.isEmpty else { return nil } if path.hasPrefix("http") { return URL(string: path) } return URL(string: baseUrl + path) } func bearerToken() -> String? { token } func deleteProfile(profileId: String) async throws { let encoded = profileId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? profileId let _: Empty = try await requestEmpty("/api/v1/client/profiles/\(encoded)", method: "DELETE") } func getShelfPrefs() async throws -> [ShelfPref] { struct Wrap: Decodable { let shelves: [ShelfPref] } let wrap: Wrap = try await get("/api/v1/client/shelves/prefs") return wrap.shelves } func saveShelfPrefs(_ prefs: [ShelfPref]) async throws { let shelves: [[String: Any]] = prefs.enumerated().map { index, p in [ "shelfId": p.shelfId, "visible": p.visible, "sortOrder": p.sortOrder >= 0 ? p.sortOrder : index, ] } _ = try await requestData( "/api/v1/client/shelves/prefs", method: "PUT", json: ["shelves": shelves], authed: true ) } func listFavorites() async throws -> [MediaItem] { struct Wrap: Decodable { let items: [MediaItem] } let wrap: Wrap = try await get("/api/v1/client/favorites") return wrap.items } func toggleFavorite(type: String, id: String) async throws -> FavoriteToggleResult { try await post("/api/v1/client/favorites/toggle", json: [ "type": type, "id": id, ]) } func addFavorite(type: String, id: String) async throws -> FavoriteToggleResult { try await post("/api/v1/client/favorites", json: [ "type": type, "id": id, ]) } func clientMe() async throws -> ClientMe { try await get("/api/v1/client/me") } func listDevices() async throws -> [LinkedDevice] { let payload: DevicesPayload = try await get("/api/v1/client/devices") return payload.devices } func revokeDevice(deviceId: String) async throws { let encoded = deviceId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? deviceId let _: Empty = try await post("/api/v1/client/devices/\(encoded)/revoke", json: [:]) } func approveDeviceCode(_ code: String) async throws -> ApproveDeviceResult { try await post("/api/v1/client/device/approve", json: ["code": code]) } func searchSubtitles(mediaFileId: String, languages: String = "nl,en") async throws -> SubtitleSearchResult { let id = mediaFileId.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? mediaFileId let langs = languages.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? languages return try await get("/api/v1/client/subtitles/search?mediaFileId=\(id)&languages=\(langs)") } func fetchSubtitle(fileId: Int, source: String) async throws -> FetchedSubtitle { try await post("/api/v1/client/subtitles/fetch", json: ["fileId": fileId, "source": source]) } func loadIptvCategories() async throws -> [IptvCategory] { struct Wrap: Decodable { let categories: [IptvCategory] } let wrap: Wrap = try await get("/api/v1/client/iptv/categories") return wrap.categories } func iptvChannels(categoryId: String? = nil, includeEpg: Bool = true) async throws -> [IptvChannel] { var params: [String] = [] if let categoryId { let enc = categoryId.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? categoryId params.append("categoryId=\(enc)") } if includeEpg { params.append("epg=1") } let q = params.isEmpty ? "" : "?\(params.joined(separator: "&"))" struct Wrap: Decodable { let channels: [IptvChannel] } let wrap: Wrap = try await get("/api/v1/client/iptv/channels\(q)") return wrap.channels } func iptvPlay(streamId: String) async throws -> IptvPlayInfo { let enc = Self.encodePathSegment(streamId) return try await get("/api/v1/client/iptv/channels/\(enc)/play") } func iptvEpg(streamId: String) async throws -> IptvEpg { let enc = Self.encodePathSegment(streamId) return try await get("/api/v1/client/iptv/channels/\(enc)/epg") } private static func encodePathSegment(_ raw: String) -> String { var allowed = CharacterSet.alphanumerics allowed.insert(charactersIn: "-._~") return raw.addingPercentEncoding(withAllowedCharacters: allowed) ?? raw } // MARK: - HTTP private struct Empty: Decodable {} private func get(_ path: String, authed: Bool = true) async throws -> T { let data = try await getData(path, authed: authed) return try JSONDecoder().decode(T.self, from: data) } private func post(_ path: String, json: [String: Any], authed: Bool = true) async throws -> T { let data = try await requestData(path, method: "POST", json: json, authed: authed) if T.self == Empty.self { return Empty() as! T } if data.isEmpty { throw ApiError.message("Leeg antwoord") } return try JSONDecoder().decode(T.self, from: data) } private func patch(_ path: String, json: [String: Any], authed: Bool = true) async throws -> T { let data = try await requestData(path, method: "PATCH", json: json, authed: authed) return try JSONDecoder().decode(T.self, from: data) } private func requestEmpty(_ path: String, method: String) async throws -> Empty { _ = try await requestData(path, method: method, json: nil, authed: true) return Empty() } private func getData(_ path: String, authed: Bool) async throws -> Data { try await requestData(path, method: "GET", json: nil, authed: authed) } private func requestData( _ path: String, method: String, json: [String: Any]?, authed: Bool ) async throws -> Data { guard let url = URL(string: baseUrl + path) else { throw ApiError.message("Ongeldige URL") } var req = URLRequest(url: url) req.httpMethod = method req.setValue("application/json", forHTTPHeaderField: "Accept") if let json { req.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type") req.httpBody = try JSONSerialization.data(withJSONObject: json) } if authed, let token, !token.isEmpty { req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } let (data, response) = try await session.data(for: req) guard let http = response as? HTTPURLResponse else { throw ApiError.message("Geen HTTP-antwoord") } guard (200..<300).contains(http.statusCode) else { let msg = (try? JSONDecoder().decode(ApiErrorBody.self, from: data))?.error?.message ?? String(data: data, encoding: .utf8) ?? "HTTP \(http.statusCode)" throw ApiError.badStatus(http.statusCode, msg) } return data } }