Owners can list/revoke devices and approve TV codes in the iOS app; TV shows a vmc:// QR, and Films/Series get visible chips, titles, and a non-overlapping A–Z index. Co-authored-by: Cursor <cursoragent@cursor.com>
349 lines
14 KiB
Swift
349 lines
14 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": "ios",
|
|
], 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": "ios",
|
|
], 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 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 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,
|
|
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 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 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: Int) async throws -> IptvPlayInfo {
|
|
try await get("/api/v1/client/iptv/channels/\(streamId)/play")
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|