stremio/apps/ios/VMC/Data/ApiClient.swift
Jos Vooges | STH 303383ea4e Add server URL choice, email login, and login lock controls.
Clients can use VMC or a custom server with email/password or device code; API locks after 3 failed logins with admin unlock.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 01:32:35 +02:00

322 lines
13 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) async throws -> BrowsePayload {
let id = catalogId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? catalogId
return try await get("/api/v1/client/catalog/\(id)?skip=\(skip)&take=\(take)")
}
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 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
}
}