Some checks failed
release / build-release (push) Has been cancelled
- 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>
1347 lines
48 KiB
Swift
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
|
|
)
|
|
}
|
|
}
|