stremio/apps/ios/VMCTV/Data/ApiClient.swift
Jos Vooges | STH 3e2549c33a Fix iOS Live TV streamId String decode and add Events + ClearKey path.
Restore channel list decoding for string/custom IDs, URL-encode play paths, parse DRM/heartbeat, and ship Events tab (0.5.0/12) with best-effort VLC ClearKey.
2026-09-19 03:59:51 +02:00

402 lines
16 KiB
Swift

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<T: Decodable>(_ 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<T: Decodable>(_ 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<T: Decodable>(_ 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
}
}