Files
jellyfin-plugin-floatplane/doc/research/reference-sources/fn_ios_FloatplaneAPI.swift
claude 9e33a343ab
Some checks failed
release / build-release (push) Has been cancelled
Floatplane channel plugin for Jellyfin 10.10.x: browse, HLS proxy, cookie session, CI release
- IChannel: creators -> sub-channels -> video posts (public listing API, search mapped)
- IRequiresMediaInfoCallback -> signed proxy URLs; stream controller rewrites HLS so the
  AES-128 key is fetched server-side with the session cookie, segments stay on the CDN
- delivery/info cached per token lifetime and throttled (~2/min)
- config page: paste sails.sid + validate, extra creators, quality, search test
- scripts/package.sh -> plugin folder + zip + md5 + manifest.json; Gitea release workflow
- research docs, HAR analysis (HAR itself gitignored), install-pi5.sh

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-25 09:42:27 +01:00

1347 lines
48 KiB
Swift

//
// FloatplaneAPI.swift
// FloatNative
//
// Complete Floatplane API Client
// Created by Claude on 2025-10-08.
//
import Foundation
// MARK: - API Error
enum FloatplaneAPIError: LocalizedError {
case invalidURL
case invalidResponse
case httpError(statusCode: Int, message: String?)
case decodingError(Error)
case notAuthenticated
case networkError(Error)
case malformedData(message: String?)
case unknown
var errorDescription: String? {
switch self {
case .invalidURL:
return "Invalid URL"
case .invalidResponse:
return "Invalid response from server"
case .httpError(let statusCode, let message):
return "HTTP Error \(statusCode): \(message ?? "Unknown error")"
case .decodingError(let error):
return "Failed to decode response: \(DecodingErrorFormatter.summary(error))"
case .notAuthenticated:
return "Not authenticated. Please log in."
case .networkError(let error):
return "Network error: \(error.localizedDescription)"
case .malformedData(let message):
return "Received unexpected data: \(message ?? "Malformed response")"
case .unknown:
return "An unknown error occurred"
}
}
}
// MARK: - Error Response Model
struct FloatplaneErrorResponse: Codable {
let errors: [ErrorDetail]?
let message: String?
struct ErrorDetail: Codable {
let id: String?
let message: String
let data: [String: String]?
}
}
// MARK: - Floatplane API Client
@MainActor
class FloatplaneAPI: ObservableObject {
// MARK: - Singleton
static let shared = FloatplaneAPI()
// MARK: - Configuration
private let baseURL = "https://www.floatplane.com"
private let imageBaseURL = "https://pbs.floatplane.com"
private let authBaseURL = "https://auth.floatplane.com"
// OAuth Configuration
private let realm = "floatplane"
#if os(tvOS)
private let clientId = "floatnative"
#else
private let clientId = "floatnative"
#endif
// private let redirectUri = "floatnative://auth" // For future implementation
// Endpoints
private var tokenEndpoint: String { "/realms/\(realm)/protocol/openid-connect/token" }
private var deviceAuthEndpoint: String { "/realms/\(realm)/protocol/openid-connect/auth/device" }
private var authEndpoint: String { "/realms/\(realm)/protocol/openid-connect/auth" }
// MARK: - Session
private var session: URLSession
private let cookieStorage: HTTPCookieStorage
// MARK: - Authentication State
@Published private(set) var isAuthenticated = false
@Published private(set) var currentUser: User?
@Published private(set) var currentUserDetails: UserSelfV3Response?
@Published var autoReloginEnabled = true // User preference for auto re-login
// OAuth State
@Published private(set) var accessToken: String?
private var refreshToken: String?
private var tokenExpiry: Date?
private var lastDPoPNonce: String? // Store latest server-provided nonce
// Legacy Cookie (for backward compatibility during migration)
private var authCookie: String? {
didSet {
// Only consider authenticated via cookie if no OAuth token
if accessToken == nil {
isAuthenticated = authCookie != nil
}
}
}
private var refreshTask: Task<Void, Error>?
// MARK: - Initialization
private init() {
let config = URLSessionConfiguration.default
config.httpCookieAcceptPolicy = .always
config.httpShouldSetCookies = true
self.cookieStorage = HTTPCookieStorage.shared
self.session = URLSession(configuration: config)
// Load saved settings
loadOAuthTokens()
loadAuthCookie() // Keep for legacy migration
loadAutoReloginPreference()
// Check authentication state
checkAuthState()
}
private func checkAuthState() {
// If we have a refresh token, we consider the user "potentially" authenticated.
// The first API call will fail with 401 if access token is expired,
// triggering the refresh flow automatically.
if refreshToken != nil {
isAuthenticated = true
} else if let expiry = tokenExpiry, expiry > Date() {
isAuthenticated = true
} else if authCookie != nil {
isAuthenticated = true
} else {
isAuthenticated = false
}
}
private func loadOAuthTokens() {
accessToken = KeychainManager.shared.getAccessToken()
refreshToken = KeychainManager.shared.getRefreshToken()
tokenExpiry = KeychainManager.shared.getTokenExpiry()
}
// MARK: - Cookie Management
private func loadAuthCookie() {
// Try Keychain first (more secure), then fall back to UserDefaults
let savedCookie = KeychainManager.shared.getAuthToken() ?? UserDefaults.standard.string(forKey: "sails.sid")
if let savedCookie = savedCookie {
self.authCookie = savedCookie
// Restore cookie to storage
if URL(string: baseURL) != nil {
let cookie = HTTPCookie(properties: [
.domain: ".floatplane.com",
.path: "/",
.name: "sails.sid",
.value: savedCookie,
.secure: "TRUE",
])
if let cookie = cookie {
cookieStorage.setCookie(cookie)
}
}
}
}
private func saveAuthCookie(_ cookie: String) {
self.authCookie = cookie
// Save to both Keychain (secure) and UserDefaults (backward compatibility)
_ = KeychainManager.shared.saveAuthToken(cookie)
UserDefaults.standard.set(cookie, forKey: "sails.sid")
// Restore cookie to storage (same as loadAuthCookie)
if URL(string: baseURL) != nil {
let httpCookie = HTTPCookie(properties: [
.domain: ".floatplane.com",
.path: "/",
.name: "sails.sid",
.value: cookie,
.secure: "TRUE",
])
if let httpCookie = httpCookie {
cookieStorage.setCookie(httpCookie)
}
}
}
private func clearAuthCookie() {
self.authCookie = nil
// Clear from both Keychain and UserDefaults
KeychainManager.shared.clearAuthToken()
UserDefaults.standard.removeObject(forKey: "sails.sid")
currentUser = nil
currentUserDetails = nil
// Clear cookies from storage
if let cookies = cookieStorage.cookies {
for cookie in cookies {
cookieStorage.deleteCookie(cookie)
}
}
// Clear OAuth state
clearOAuthState()
}
// MARK: - Auto Re-login Management
private func loadAutoReloginPreference() {
if UserDefaults.standard.object(forKey: "autoReloginEnabled") != nil {
autoReloginEnabled = UserDefaults.standard.bool(forKey: "autoReloginEnabled")
} else {
autoReloginEnabled = true // Default to enabled
}
}
func setAutoReloginEnabled(_ enabled: Bool) {
autoReloginEnabled = enabled
UserDefaults.standard.set(enabled, forKey: "autoReloginEnabled")
}
private func extractCookie(from response: HTTPURLResponse) {
if let headerFields = response.allHeaderFields as? [String: String],
let url = response.url {
let cookies = HTTPCookie.cookies(withResponseHeaderFields: headerFields, for: url)
for cookie in cookies where cookie.name == "sails.sid" {
saveAuthCookie(cookie.value)
}
}
}
// MARK: - Network Request Helpers
/// Request method for endpoints that don't return JSON (plain text responses)
private func requestWithoutResponse(
endpoint: String,
method: String = "GET",
body: Encodable? = nil,
requiresAuth: Bool = false,
retryCount: Int = 0,
allowRefreshOn403: Bool = false
) async throws {
guard let url = URL(string: baseURL + endpoint) else {
throw FloatplaneAPIError.invalidURL
}
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = method
urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
urlRequest.setValue("FloatNative/1.0 (iOS), CFNetwork", forHTTPHeaderField: "User-Agent")
// Add auth headers
if requiresAuth {
if let accessToken = accessToken {
// Add DPoP Proof
if let dpopProof = try? DPoPManager.shared.generateProof(
httpMethod: method,
httpUrl: baseURL + endpoint,
accessToken: accessToken,
nonce: lastDPoPNonce
) {
urlRequest.setValue(dpopProof, forHTTPHeaderField: "DPoP")
// DPoP-bound tokens must use the DPoP scheme
urlRequest.setValue("DPoP \(accessToken)", forHTTPHeaderField: "Authorization")
} else {
// Fallback to Bearer if DPoP generation fails (shouldn't happen)
urlRequest.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
}
} else if let authCookie = authCookie {
// Fallback to legacy cookie
urlRequest.setValue("sails.sid=\(authCookie)", forHTTPHeaderField: "Cookie")
} else {
throw FloatplaneAPIError.notAuthenticated
}
}
// Encode body if present
if let body = body {
urlRequest.httpBody = try JSONEncoder().encode(body)
}
// Perform request
let (_, response) = try await session.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
throw FloatplaneAPIError.invalidResponse
}
// Extract cookies from response
extractCookie(from: httpResponse)
// Create case-insensitive lookup for headers
let headers = Dictionary(uniqueKeysWithValues: httpResponse.allHeaderFields.map {
// Keys in allHeaderFields can be Any, usually String.
(String(describing: $0.key).lowercased(), String(describing: $0.value))
})
// Capture DPoP-Nonce if present
if let nonce = headers["dpop-nonce"] {
self.lastDPoPNonce = nonce
}
// Handle HTTP errors
guard (200...299).contains(httpResponse.statusCode) else {
// Check for authentication errors (401 Unauthorized or 403 Forbidden)
if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 {
// Check if error is due to DPoP Nonce mismatch (retry locally without refresh)
if let wwwAuth = headers["www-authenticate"],
wwwAuth.contains("use_dpop_nonce"),
retryCount < 3 {
return try await requestWithoutResponse(
endpoint: endpoint,
method: method,
body: body,
requiresAuth: requiresAuth,
retryCount: retryCount + 1
)
}
// Try OAuth Refresh for 401 Unauthorized, or for 403 Forbidden if explicitly allowed (e.g., initial API calls)
if (httpResponse.statusCode == 401 || (httpResponse.statusCode == 403 && allowRefreshOn403)) && refreshToken != nil {
do {
try await ensureRefreshed()
return try await requestWithoutResponse(
endpoint: endpoint,
method: method,
body: body,
requiresAuth: requiresAuth,
allowRefreshOn403: allowRefreshOn403
)
} catch {
throw error
}
}
}
throw FloatplaneAPIError.httpError(statusCode: httpResponse.statusCode, message: nil)
}
}
private func request<T: Decodable>(
endpoint: String,
method: String = "GET",
body: Encodable? = nil,
requiresAuth: Bool = false,
retryCount: Int = 0,
allowRefreshOn403: Bool = false
) async throws -> T {
guard let url = URL(string: baseURL + endpoint) else {
throw FloatplaneAPIError.invalidURL
}
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = method
urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
urlRequest.setValue("FloatNative/1.0 (iOS), CFNetwork", forHTTPHeaderField: "User-Agent")
// Add auth headers
if requiresAuth {
if let accessToken = accessToken {
// Add DPoP Proof
if let dpopProof = try? DPoPManager.shared.generateProof(
httpMethod: method,
httpUrl: baseURL + endpoint,
accessToken: accessToken,
nonce: lastDPoPNonce
) {
urlRequest.setValue(dpopProof, forHTTPHeaderField: "DPoP")
// DPoP-bound tokens must use the DPoP scheme
urlRequest.setValue("DPoP \(accessToken)", forHTTPHeaderField: "Authorization")
} else {
// Fallback to Bearer if DPoP generation fails (shouldn't happen)
urlRequest.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
}
} else if let authCookie = authCookie {
// Fallback to legacy cookie
urlRequest.setValue("sails.sid=\(authCookie)", forHTTPHeaderField: "Cookie")
} else {
throw FloatplaneAPIError.notAuthenticated
}
}
// Per-request auth/DPoP tracing was filling the console with multi-KB
// JWTs on every API call. Set FLOATNATIVE_LOG_REQUEST_AUTH=1 in the
// scheme's environment to re-enable when actually debugging auth.
#if DEBUG
if ProcessInfo.processInfo.environment["FLOATNATIVE_LOG_REQUEST_AUTH"] == "1" {
let auth = urlRequest.value(forHTTPHeaderField: "Authorization").map { "\($0.prefix(20))…" } ?? "NONE"
let dpop = urlRequest.value(forHTTPHeaderField: "DPoP") != nil ? "present" : "MISSING"
print("→ \(urlRequest.httpMethod ?? "?") \(endpoint) auth=\(auth) dpop=\(dpop)")
}
#endif
// Encode body if present
if let body = body {
urlRequest.httpBody = try JSONEncoder().encode(body)
}
// Perform request
let (data, response) = try await session.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
throw FloatplaneAPIError.invalidResponse
}
// Extract cookies from response
extractCookie(from: httpResponse)
// Create case-insensitive lookup for headers
let headers = Dictionary(uniqueKeysWithValues: httpResponse.allHeaderFields.map {
// Keys in allHeaderFields can be Any, usually String.
(String(describing: $0.key).lowercased(), String(describing: $0.value))
})
// Capture DPoP-Nonce if present
if let nonce = headers["dpop-nonce"] {
self.lastDPoPNonce = nonce
}
// Handle HTTP errors
guard (200...299).contains(httpResponse.statusCode) else {
// Check for authentication errors (401 Unauthorized or 403 Forbidden)
if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 {
// Check if error is due to DPoP Nonce mismatch (retry locally without refresh)
if let wwwAuth = headers["www-authenticate"],
wwwAuth.contains("use_dpop_nonce"),
retryCount < 3 {
return try await request(
endpoint: endpoint,
method: method,
body: body,
requiresAuth: requiresAuth,
retryCount: retryCount + 1
)
}
// Try OAuth Refresh for 401 Unauthorized, or for 403 Forbidden if explicitly allowed (e.g., initial API calls)
if (httpResponse.statusCode == 401 || (httpResponse.statusCode == 403 && allowRefreshOn403)) && refreshToken != nil {
do {
try await ensureRefreshed()
return try await request(
endpoint: endpoint,
method: method,
body: body,
requiresAuth: requiresAuth,
allowRefreshOn403: allowRefreshOn403
)
} catch {
throw error
}
}
}
let errorMessage = try? JSONDecoder().decode(FloatplaneErrorResponse.self, from: data)
let message = errorMessage?.message ?? errorMessage?.errors?.first?.message
throw FloatplaneAPIError.httpError(statusCode: httpResponse.statusCode, message: message)
}
// Decode response
do {
let decoder = JSONDecoder()
// Use flexible date decoding to handle various ISO8601 formats
decoder.dateDecodingStrategy = .custom { decoder in
let container = try decoder.singleValueContainer()
let dateString = try container.decode(String.self)
// Try standard ISO8601 formatter first
let formatter = ISO8601DateFormatter()
if let date = formatter.date(from: dateString) {
return date
}
// Try ISO8601 with fractional seconds
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let date = formatter.date(from: dateString) {
return date
}
// If both fail, throw error
throw DecodingError.dataCorrupted(
DecodingError.Context(
codingPath: decoder.codingPath,
debugDescription: "Date string '\(dateString)' does not match expected ISO8601 format"
)
)
}
return try decoder.decode(T.self, from: data)
} catch {
// Check if this is a malformed response (e.g., rate limit, unexpected structure)
// Try to decode as a generic error response first
if let errorResponse = try? JSONDecoder().decode(FloatplaneErrorResponse.self, from: data) {
let message = errorResponse.message ?? errorResponse.errors?.first?.message
DebugLogManager.shared.append(.api(
"API error structure on \(endpoint): \(message ?? "Unknown")",
detail: String(data: data, encoding: .utf8)
))
throw FloatplaneAPIError.malformedData(message: message)
}
// Check if response looks like a rate limit or other unexpected JSON structure
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
(json.keys.contains("rateLimit") || json.keys.contains("error") || json.keys.contains("status")) {
let message = (json["message"] as? String) ?? (json["error"] as? String) ?? "Unexpected response structure"
DebugLogManager.shared.append(.api(
"Unexpected JSON structure on \(endpoint): \(message)",
detail: String(data: data, encoding: .utf8)
))
throw FloatplaneAPIError.malformedData(message: message)
}
// Log decoding errors with full detail to the in-app debug log so
// users can copy/paste them into a bug report. Body is truncated
// to keep the buffer reasonable.
let summary = DecodingErrorFormatter.summary(error)
let body = (String(data: data, encoding: .utf8) ?? "<non-utf8 \(data.count) bytes>").redactingTokens
let snippet = body.count > 4096 ? String(body.prefix(4096)) + "\n…(truncated)" : body
DebugLogManager.shared.append(.decode(
message: "decode failed on \(endpoint): \(summary)",
verbose: "\(DecodingErrorFormatter.verbose(error))\n\nResponse body:\n\(snippet)"
))
#if DEBUG
print("❌ Decode error on \(endpoint): \(summary)")
print(DecodingErrorFormatter.verbose(error))
print("Response data: \(snippet)")
#endif
throw FloatplaneAPIError.decodingError(error)
}
}
// MARK: - Debug Helper
private func prettyPrintJSON(_ data: Data) -> String? {
guard let jsonObject = try? JSONSerialization.jsonObject(with: data),
let prettyData = try? JSONSerialization.data(withJSONObject: jsonObject, options: [.prettyPrinted, .sortedKeys]),
let prettyString = String(data: prettyData, encoding: .utf8) else {
return nil
}
return prettyString
}
// MARK: - OAuth Helpers
private func requestAuth<T: Decodable>(
endpoint: String,
method: String = "POST",
body: [String: String],
dpopProof: String? = nil
) async throws -> T {
guard let url = URL(string: authBaseURL + endpoint) else {
throw FloatplaneAPIError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = method
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
request.setValue("FloatNative/1.0 (iOS), CFNetwork", forHTTPHeaderField: "User-Agent")
if let dpopProof = dpopProof {
request.setValue(dpopProof, forHTTPHeaderField: "DPoP")
}
// Encode body params
let bodyString = body.map { "\($0.key)=\($0.value)" }
.joined(separator: "&")
request.httpBody = bodyString.data(using: .utf8)
#if DEBUG
if ProcessInfo.processInfo.environment["FLOATNATIVE_LOG_REQUEST_AUTH"] == "1" {
// Redact secret-bearing fields so credentials never land in console logs.
let sensitiveKeys: Set<String> = [
"refresh_token", "access_token", "code", "device_code",
"code_verifier", "client_secret", "password", "username",
]
let redactedBody = body.map { key, value -> String in
if sensitiveKeys.contains(key) {
let prefix = String(value.prefix(4))
return "\(key)=\(prefix)…(redacted, len=\(value.count))"
}
return "\(key)=\(value)"
}.joined(separator: "&")
let dpop = request.value(forHTTPHeaderField: "DPoP") != nil ? "present" : "MISSING"
print("→ \(method) \(endpoint) dpop=\(dpop) body=\(redactedBody)")
}
#endif
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw FloatplaneAPIError.invalidResponse
}
// Extract cookies (crucial for Hybrid DPoP/Cookie video playback)
extractCookie(from: httpResponse)
// Create case-insensitive lookup for headers
let headers = Dictionary(uniqueKeysWithValues: httpResponse.allHeaderFields.map {
// Keys in allHeaderFields can be Any, usually String.
(String(describing: $0.key).lowercased(), String(describing: $0.value))
})
// Capture DPoP-Nonce if present
if let nonce = headers["dpop-nonce"] {
self.lastDPoPNonce = nonce
}
#if DEBUG
// Don't dump full headers — `Set-Cookie: sails.sid=...` and DPoP-Nonce
// both show up here and don't belong in console logs.
let safeHeaderKeys = httpResponse.allHeaderFields.keys.compactMap { $0 as? String }
print("--------- AUTH RESPONSE ---------")
print("Status: \(httpResponse.statusCode)")
print("Header keys: \(safeHeaderKeys.sorted())")
#endif
guard (200...299).contains(httpResponse.statusCode) else {
// Parse error response
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let error = json["error"] as? String {
// Return descriptive error for polling (e.g., authorization_pending)
throw FloatplaneAPIError.httpError(statusCode: httpResponse.statusCode, message: error)
}
throw FloatplaneAPIError.httpError(statusCode: httpResponse.statusCode, message: nil)
}
let decoder = JSONDecoder()
return try decoder.decode(T.self, from: data)
}
private func handleOAuthResponse(_ response: OAuthTokenResponse) {
self.accessToken = response.accessToken
self.refreshToken = response.refreshToken
self.tokenExpiry = Date().addingTimeInterval(TimeInterval(response.expiresIn))
// Save to Keychain
_ = KeychainManager.shared.saveOAuthTokens(
accessToken: response.accessToken,
refreshToken: response.refreshToken,
expiresIn: response.expiresIn
)
self.isAuthenticated = true
print("✅ OAuth login successful. Token expires in \(response.expiresIn)s")
}
// MARK: - AUTHENTICATION
// MARK: OAuth Methods
/// Start Device Authorization Flow (TV)
func startDeviceAuth() async throws -> DeviceCodeResponse {
let body = [
"client_id": clientId,
"scope": "openid offline_access"
]
return try await requestAuth(endpoint: deviceAuthEndpoint, body: body)
}
/// Poll for token during Device Flow
func pollDeviceToken(deviceCode: String) async throws -> OAuthTokenResponse {
let body = [
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
"client_id": clientId,
"device_code": deviceCode
]
do {
// Generate DPoP proof for the token endpoint
let dpopProof = try DPoPManager.shared.generateProof(
httpMethod: "POST",
httpUrl: authBaseURL + tokenEndpoint,
nonce: lastDPoPNonce
)
let response: OAuthTokenResponse = try await requestAuth(
endpoint: tokenEndpoint,
body: body,
dpopProof: dpopProof
)
handleOAuthResponse(response)
return response
} catch {
throw error
}
}
/// Exchange Authorization Code for Token (iOS)
func exchangeAuthCode(code: String, verifier: String, redirectUri: String? = nil) async throws {
var body = [
"grant_type": "authorization_code",
"client_id": clientId,
"code": code,
"code_verifier": verifier
]
if let redirectUri = redirectUri {
body["redirect_uri"] = redirectUri
}
// Generate DPoP proof
let dpopProof = try DPoPManager.shared.generateProof(
httpMethod: "POST",
httpUrl: authBaseURL + tokenEndpoint,
nonce: lastDPoPNonce
)
let response: OAuthTokenResponse = try await requestAuth(
endpoint: tokenEndpoint,
body: body,
dpopProof: dpopProof
)
handleOAuthResponse(response)
// Fetch user info to populate currentUser
_ = try? await getCurrentUser()
}
/// Refresh Access Token
func refreshAccessToken() async throws {
guard let refreshToken = refreshToken else {
throw FloatplaneAPIError.notAuthenticated
}
let body = [
"grant_type": "refresh_token",
"client_id": clientId,
"refresh_token": refreshToken
]
do {
// Generate DPoP proof for the token endpoint
let dpopProof = try DPoPManager.shared.generateProof(
httpMethod: "POST",
httpUrl: authBaseURL + tokenEndpoint,
nonce: lastDPoPNonce
)
let response: OAuthTokenResponse = try await requestAuth(
endpoint: tokenEndpoint,
body: body,
dpopProof: dpopProof
)
handleOAuthResponse(response)
} catch {
// Check for cancellation (don't logout if task was simply cancelled)
if error is CancellationError {
throw error
}
let nsError = error as NSError
if nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorCancelled {
throw error
}
// If refresh fails (and not cancelled), clear tokens
print("❌ Token refresh failed: \(error.localizedDescription)")
clearOAuthState()
throw error
}
}
private func ensureRefreshed() async throws {
if let task = refreshTask {
return try await task.value
}
let task = Task {
try await refreshAccessToken()
}
self.refreshTask = task
do {
try await task.value
self.refreshTask = nil
} catch {
self.refreshTask = nil
throw error
}
}
private func clearOAuthState() {
self.accessToken = nil
self.refreshToken = nil
self.tokenExpiry = nil
KeychainManager.shared.clearOAuthTokens()
self.isAuthenticated = false
}
// MARK: - LOGOUT
/// Logout
func logout() async throws {
// Logout from Companion API (invalidate key)
await CompanionAPI.shared.logout()
// Try to call logout endpoint, but don't fail if it errors
do {
try await requestWithoutResponse(
endpoint: "/api/v2/auth/logout",
method: "POST",
requiresAuth: true
)
print("✅ Logout API call successful")
} catch {
// Ignore errors - endpoint may be deprecated (404) or network issues
// We'll clear local state anyway
print("⚠️ Logout API call failed (likely endpoint deprecated): \(error.localizedDescription)")
}
// Always clear local state, regardless of API call success
clearAuthCookie()
KeychainManager.shared.clearCredentials()
}
// MARK: - LIVE STREAM
/// Get details for specific creators (including live stream status)
func getCreatorsByIds(ids: [String]) async throws -> [Creator] {
// Construct query parameters manually since we have an array
// Format: ids[]=id1&ids[]=id2...
var components = URLComponents(string: baseURL + "/api/v3/creator/list")
var queryItems = [URLQueryItem]()
for id in ids {
queryItems.append(URLQueryItem(name: "ids[]", value: id))
}
// Append existing query items if any (though there shouldn't be for this base URL)
// components?.queryItems = queryItems // This overwrites, which is fine here
// We can't use components.queryItems directly because [String] params are sometimes encoded weirdly by Foundation
// But for ids[] it usually works ok. Let's try standard way.
components?.queryItems = queryItems
// If components fails, we fallback to manual string construction, but URLComponents usually handles [] correctly
// as "ids%5B%5D=..." which is standard.
// However, Floatplane API expects "ids[]=val", percent encoding might be issue.
// Let's verify if standard encoding works. Standard usually encodes [ ] as %5B %5D.
// If API fails, we might need manual query string construction.
// We'll trust URLComponents for now as it's cleaner.
// Actually, let's use the helper assuming endpoint string.
// But the helper `request` takes an endpoint string, not full URL.
// And it doesn't support complex query params in the argument list easily without building the string.
// Let's build the query string manually to pass as endpoint
let queryString = ids.map { "ids[]=\($0)" }.joined(separator: "&")
let endpoint = "/api/v3/creator/list?\(queryString)"
return try await request(endpoint: endpoint, requiresAuth: true)
}
/// Get captcha info
func getCaptchaInfo() async throws -> CaptchaInfo {
try await request(endpoint: "/api/v3/auth/captcha/info")
}
// MARK: - USER
/// Get current user info
func getCurrentUser() async throws -> User {
let userDetails: UserSelfV3Response = try await request(
endpoint: "/api/v3/user/self",
requiresAuth: true
)
currentUserDetails = userDetails
// Also set currentUser with basic info for compatibility
currentUser = UserModel(
id: userDetails.id,
username: userDetails.username,
profileImage: userDetails.profileImage
)
return currentUser!
}
/// Get user activity feed
func getUserActivity(userId: String) async throws -> UserActivity {
try await request(
endpoint: "/api/v3/user/activity?id=\(userId)",
requiresAuth: true
)
}
/// Get user notification settings
func getNotificationSettings() async throws -> [NotificationSetting] {
try await request(
endpoint: "/api/v3/user/notification/list",
requiresAuth: true
)
}
/// Update user notification setting
func updateNotificationSetting(
creator: String,
property: String,
newValue: Bool
) async throws -> Bool {
let updateRequest = NotificationUpdateRequest(
creator: creator,
property: property,
newValue: newValue
)
return try await request(
endpoint: "/api/v3/user/notification/update",
method: "POST",
body: updateRequest,
requiresAuth: true
)
}
// MARK: - SUBSCRIPTIONS
/// Get user's subscriptions
func getSubscriptions() async throws -> [Subscription] {
try await request(
endpoint: "/api/v3/user/subscriptions",
requiresAuth: true,
allowRefreshOn403: true
)
}
// MARK: - CREATORS
/// Get creator info by ID
func getCreator(id: String) async throws -> Creator {
try await request(
endpoint: "/api/v3/creator/info?id=\(id)",
requiresAuth: true
)
}
/// Get creator by URL name
func getCreator(urlName: String) async throws -> [Creator] {
let encodedName = urlName.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? urlName
return try await request(
endpoint: "/api/v3/creator/named?creatorURL[]=\(encodedName)",
requiresAuth: true
)
}
/// Search/list all creators
func searchCreators(query: String = "") async throws -> [Creator] {
let encodedQuery = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? query
return try await request(
endpoint: "/api/v3/creator/list?search=\(encodedQuery)",
requiresAuth: true
)
}
/// Get creator channels
func getCreatorChannels(creatorIds: [String]) async throws -> [Channel] {
let idsParam = creatorIds.enumerated()
.map { "ids[\($0.offset)]=\($0.element)" }
.joined(separator: "&")
return try await request(
endpoint: "/api/v3/creator/channels/list?\(idsParam)",
requiresAuth: true
)
}
// MARK: - CONTENT
/// Get blog posts from a single creator
func getCreatorContent(
creatorId: String,
channelId: String? = nil,
limit: Int = 20,
fetchAfter: Int = 0,
search: String? = nil,
tags: [String]? = nil,
hasVideo: Bool? = nil,
hasAudio: Bool? = nil,
hasPicture: Bool? = nil,
hasText: Bool? = nil,
sort: String = "DESC",
fromDuration: Int? = nil,
toDuration: Int? = nil,
fromDate: Date? = nil,
toDate: Date? = nil
) async throws -> [BlogPost] {
var params = [
"id=\(creatorId)",
"limit=\(limit)",
"fetchAfter=\(fetchAfter)",
"sort=\(sort)"
]
if let channelId = channelId {
params.append("channel=\(channelId)")
}
if let search = search, !search.isEmpty {
let encoded = search.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? search
params.append("search=\(encoded)")
}
if let tags = tags, !tags.isEmpty {
let tagsParam = tags.enumerated()
.map { "tags[\($0.offset)]=\($0.element)" }
.joined(separator: "&")
params.append(tagsParam)
}
if let hasVideo = hasVideo {
params.append("hasVideo=\(hasVideo)")
}
if let hasAudio = hasAudio {
params.append("hasAudio=\(hasAudio)")
}
if let hasPicture = hasPicture {
params.append("hasPicture=\(hasPicture)")
}
if let hasText = hasText {
params.append("hasText=\(hasText)")
}
if let fromDuration = fromDuration {
params.append("fromDuration=\(fromDuration)")
}
if let toDuration = toDuration {
params.append("toDuration=\(toDuration)")
}
let endpoint = "/api/v3/content/creator?\(params.joined(separator: "&"))"
return try await request(endpoint: endpoint, requiresAuth: true)
}
/// Get multi-creator home feed
func getMultiCreatorFeed(
creatorIds: [String],
limit: Int = 20,
fetchAfter: [FetchCursor]? = nil
) async throws -> CreatorListResponse {
var params = ["limit=\(limit)"]
let idsParam = creatorIds.enumerated()
.map { "ids[\($0.offset)]=\($0.element)" }
.joined(separator: "&")
params.append(idsParam)
if let fetchAfter = fetchAfter, !fetchAfter.isEmpty {
let cursorParams = fetchAfter.enumerated().flatMap { index, cursor -> [String] in
var cursorParts = [
"fetchAfter[\(index)][creatorId]=\(cursor.creatorId)",
"fetchAfter[\(index)][moreFetchable]=\(cursor.moreFetchable)"
]
// Only include blogPostId if it's not nil
if let blogPostId = cursor.blogPostId {
cursorParts.insert("fetchAfter[\(index)][blogPostId]=\(blogPostId)", at: 1)
}
return cursorParts
}.joined(separator: "&")
params.append(cursorParams)
}
let endpoint = "/api/v3/content/creator/list?\(params.joined(separator: "&"))"
return try await request(endpoint: endpoint, requiresAuth: true, allowRefreshOn403: true)
}
/// Get single blog post
func getBlogPost(id: String) async throws -> BlogPostDetailedWithInteraction {
try await request(
endpoint: "/api/v3/content/post?id=\(id)",
requiresAuth: true
)
}
/// Get related blog posts
func getRelatedPosts(postId: String) async throws -> [BlogPost] {
try await request(
endpoint: "/api/v3/content/related?id=\(postId)",
requiresAuth: true
)
}
/// Get content tags for creators
func getContentTags(creatorIds: [String]) async throws -> ContentTags {
let idsParam = creatorIds.enumerated()
.map { "creatorIds[\($0.offset)]=\($0.element)" }
.joined(separator: "&")
return try await request(
endpoint: "/api/v3/content/tags?\(idsParam)",
requiresAuth: true
)
}
// MARK: - VIDEO & MEDIA
/// Get video content details
func getVideoContent(id: String) async throws -> VideoContent {
try await request(
endpoint: "/api/v3/content/video?id=\(id)",
requiresAuth: true
)
}
/// Get picture content
func getPictureContent(id: String) async throws -> PictureContent {
try await request(
endpoint: "/api/v3/content/picture?id=\(id)",
requiresAuth: true
)
}
/// Get video delivery info (streaming URLs)
/// Get video delivery info (streaming URLs)
func getDeliveryInfo(
scenario: DeliveryScenario,
entityId: String,
outputKind: OutputKind? = .hlsFmp4
) async throws -> DeliveryInfo {
var endpoint = "/api/v3/delivery/info?scenario=\(scenario.rawValue)&entityId=\(entityId)"
if let outputKind = outputKind {
endpoint += "&outputKind=\(outputKind.rawValue)"
}
return try await request(endpoint: endpoint, requiresAuth: true)
}
/// Convenience method to get streaming URL for a video
func getStreamingURL(videoId: String, preferredQuality: String? = nil) async throws -> String {
let deliveryInfo = try await getDeliveryInfo(
scenario: .onDemand,
entityId: videoId,
outputKind: .hlsFmp4
)
let variants = deliveryInfo.availableVariants()
guard !variants.isEmpty else {
throw FloatplaneAPIError.invalidResponse
}
// If preferred quality specified, find it
if let preferredQuality = preferredQuality {
if let preferred = variants.first(where: { $0.name.contains(preferredQuality) }) {
return preferred.url
}
}
// Default to highest quality
return variants.first?.url ?? ""
}
/// Get live stream URL
func getLiveStreamURL(liveStreamId: String) async throws -> String {
let deliveryInfo = try await getDeliveryInfo(
scenario: .live,
entityId: liveStreamId,
outputKind: .hlsFmp4
)
let variants = deliveryInfo.availableVariants()
guard let url = variants.first?.url else {
throw FloatplaneAPIError.invalidResponse
}
return url
}
// MARK: - ENGAGEMENT
/// Like content
func likeContent(contentType: String = "blogPost", id: String) async throws -> [String] {
let request = ContentInteractionRequest(contentType: contentType, id: id)
return try await self.request(
endpoint: "/api/v3/content/like",
method: "POST",
body: request,
requiresAuth: true
)
}
/// Dislike content
func dislikeContent(contentType: String = "blogPost", id: String) async throws -> [String] {
let request = ContentInteractionRequest(contentType: contentType, id: id)
return try await self.request(
endpoint: "/api/v3/content/dislike",
method: "POST",
body: request,
requiresAuth: true
)
}
// MARK: - COMMENTS
/// Get comments for a blog post
func getComments(
blogPostId: String,
limit: Int = 20,
fetchAfter: String? = nil
) async throws -> [Comment] {
var params = [
"blogPost=\(blogPostId)",
"limit=\(limit)"
]
if let fetchAfter = fetchAfter {
params.append("fetchAfter=\(fetchAfter)")
}
let endpoint = "/api/v3/comment?\(params.joined(separator: "&"))"
return try await request(endpoint: endpoint, requiresAuth: true)
}
/// Get comment replies
func getCommentReplies(
commentId: String,
blogPostId: String,
limit: Int = 20,
lastReplyId: String
) async throws -> [Comment] {
let params = [
"comment=\(commentId)",
"blogPost=\(blogPostId)",
"limit=\(limit)",
"rid=\(lastReplyId)"
].joined(separator: "&")
return try await request(
endpoint: "/api/v3/comment/replies?\(params)",
requiresAuth: true
)
}
/// Post a comment or a reply. Pass `replyingTo` (parent CommentModel.id)
/// to post a reply, in which case the request is routed to the dedicated
/// `/api/v3/comment/reply` endpoint. The top-level `/api/v3/comment`
/// endpoint rejects any extra fields with a Joi 400 "X is not allowed"
/// — see spec-overlay.json's CommentV3ReplyRequest note (GH #13).
func postComment(blogPostId: String, text: String, replyingTo: String? = nil) async throws -> Comment {
if let parentId = replyingTo {
let req = PostReplyRequest(blogPost: blogPostId, text: text, replyTo: parentId)
return try await request(
endpoint: "/api/v3/comment/reply",
method: "POST",
body: req,
requiresAuth: true
)
} else {
let req = PostCommentRequest(blogPost: blogPostId, text: text)
return try await request(
endpoint: "/api/v3/comment",
method: "POST",
body: req,
requiresAuth: true
)
}
}
/// Like a comment (returns interaction ID when liking, null when unliking)
func likeComment(commentId: String, blogPostId: String) async throws -> String? {
let likeRequest = CommentInteractionRequest(comment: commentId, blogPost: blogPostId)
return try await request(
endpoint: "/api/v3/comment/like",
method: "POST",
body: likeRequest,
requiresAuth: true
)
}
/// Dislike a comment (returns interaction ID when disliking, null when undisliking)
func dislikeComment(commentId: String, blogPostId: String) async throws -> String? {
let dislikeRequest = CommentInteractionRequest(comment: commentId, blogPost: blogPostId)
return try await request(
endpoint: "/api/v3/comment/dislike",
method: "POST",
body: dislikeRequest,
requiresAuth: true
)
}
// MARK: - PROGRESS TRACKING
/// Get progress for multiple posts
func getProgress(postIds: [String], contentType: String = "blogPost") async throws -> [ProgressResponse] {
let idsParam = postIds.enumerated()
.map { "ids[\($0.offset)]=\($0.element)" }
.joined(separator: "&")
let endpoint = "/api/v3/content/progress?\(idsParam)&contentType=\(contentType)"
return try await request(endpoint: endpoint, requiresAuth: true)
}
/// Update video progress
func updateProgress(
videoId: String,
contentType: String = "video",
progress: Int
) async throws {
let updateRequest = UpdateProgressRequest(
id: videoId,
contentType: contentType,
progress: progress
)
try await requestWithoutResponse(
endpoint: "/api/v3/content/progress",
method: "POST",
body: updateRequest,
requiresAuth: true
)
}
// MARK: - WATCH HISTORY
/// Get watch history from server
func getWatchHistory(offset: Int = 0) async throws -> [WatchHistoryResponse] {
return try await request(
endpoint: "/api/v3/content/history?offset=\(offset)",
method: "GET",
requiresAuth: true
)
}
}