Yattee v2 rewrite

This commit is contained in:
Arkadiusz Fal
2026-02-08 18:31:16 +01:00
parent 20d0cfc0c7
commit 05f921d605
1043 changed files with 163875 additions and 68430 deletions

View File

@@ -0,0 +1,163 @@
//
// APIError.swift
// Yattee
//
// Typed errors for network operations.
//
import Foundation
/// Errors that can occur during API operations.
enum APIError: Error, LocalizedError, Equatable, Sendable {
/// The URL could not be constructed.
case invalidURL
/// The request failed with an HTTP status code.
case httpError(statusCode: Int, message: String?)
/// The response could not be decoded.
case decodingError(String)
/// The request timed out.
case timeout
/// No network connection available.
case noConnection
/// The request was cancelled.
case cancelled
/// Server returned an error message.
case serverError(String)
/// Rate limited by the server.
case rateLimited(retryAfter: TimeInterval?)
/// Authentication required or failed.
case unauthorized
/// Resource not found, with optional server-provided detail message.
case notFound(String?)
/// Comments are disabled for this video.
case commentsDisabled
/// No suitable instance available.
case noInstance
/// No playable streams available.
case noStreams
/// The request parameters were invalid.
case invalidRequest
/// Operation not supported by this instance type.
case notSupported
/// An unknown error occurred.
case unknown(String)
var errorDescription: String? {
switch self {
case .invalidURL:
return "Invalid URL"
case .httpError(let statusCode, let message):
if let message {
return message
}
return "HTTP error: \(statusCode)"
case .decodingError(let message):
return "Failed to decode response: \(message)"
case .timeout:
return "Request timed out"
case .noConnection:
return "No network connection"
case .cancelled:
return "Request was cancelled"
case .serverError(let message):
return "Server error: \(message)"
case .rateLimited(let retryAfter):
if let retryAfter {
return "Rate limited. Retry after \(Int(retryAfter)) seconds"
}
return "Rate limited"
case .unauthorized:
return "Authentication required"
case .notFound(let detail):
if let detail {
return detail
}
return "Resource not found"
case .commentsDisabled:
return "Comments are disabled"
case .noInstance:
return "No suitable instance available"
case .noStreams:
return "No playable streams available"
case .invalidRequest:
return "Invalid request"
case .notSupported:
return "Operation not supported"
case .unknown(let message):
return message
}
}
/// Whether this error is likely recoverable by retrying.
var isRetryable: Bool {
switch self {
case .timeout, .noConnection, .rateLimited:
return true
case .httpError(let statusCode, _):
return statusCode >= 500 || statusCode == 429
default:
return false
}
}
static func == (lhs: APIError, rhs: APIError) -> Bool {
switch (lhs, rhs) {
case (.invalidURL, .invalidURL),
(.timeout, .timeout),
(.noConnection, .noConnection),
(.cancelled, .cancelled),
(.unauthorized, .unauthorized),
(.commentsDisabled, .commentsDisabled),
(.noInstance, .noInstance),
(.noStreams, .noStreams),
(.invalidRequest, .invalidRequest),
(.notSupported, .notSupported):
return true
case (.notFound(let lDetail), .notFound(let rDetail)):
return lDetail == rDetail
case (.httpError(let lCode, let lMsg), .httpError(let rCode, let rMsg)):
return lCode == rCode && lMsg == rMsg
case (.decodingError(let lMsg), .decodingError(let rMsg)):
return lMsg == rMsg
case (.serverError(let lMsg), .serverError(let rMsg)):
return lMsg == rMsg
case (.rateLimited(let lRetry), .rateLimited(let rRetry)):
return lRetry == rRetry
case (.unknown(let lMsg), .unknown(let rMsg)):
return lMsg == rMsg
default:
return false
}
}
/// Creates a decoding error from a Swift DecodingError.
static func decodingError(_ error: DecodingError) -> APIError {
switch error {
case .typeMismatch(let type, let context):
return .decodingError("Type mismatch for \(type): \(context.debugDescription)")
case .valueNotFound(let type, let context):
return .decodingError("Value not found for \(type): \(context.debugDescription)")
case .keyNotFound(let key, let context):
return .decodingError("Key '\(key.stringValue)' not found: \(context.debugDescription)")
case .dataCorrupted(let context):
return .decodingError("Data corrupted: \(context.debugDescription)")
@unknown default:
return .decodingError(error.localizedDescription)
}
}
}

View File

@@ -0,0 +1,135 @@
//
// Endpoint.swift
// Yattee
//
// Type-safe endpoint protocol for API requests.
//
import Foundation
/// HTTP methods supported by the API.
enum HTTPMethod: String, Sendable {
case get = "GET"
case post = "POST"
case put = "PUT"
case patch = "PATCH"
case delete = "DELETE"
}
/// Protocol defining an API endpoint.
protocol Endpoint: Sendable {
/// The path component of the URL (e.g., "/api/v1/videos").
var path: String { get }
/// The HTTP method for this endpoint.
var method: HTTPMethod { get }
/// Query parameters to append to the URL.
var queryItems: [URLQueryItem]? { get }
/// HTTP headers to include in the request.
var headers: [String: String]? { get }
/// The body data for POST/PUT/PATCH requests.
var body: Data? { get }
/// Timeout interval for this specific request.
var timeout: TimeInterval { get }
}
// MARK: - Default Implementations
extension Endpoint {
var method: HTTPMethod { .get }
var queryItems: [URLQueryItem]? { nil }
var headers: [String: String]? { nil }
var body: Data? { nil }
var timeout: TimeInterval { 30 }
/// Constructs a URLRequest from this endpoint and a base URL.
func urlRequest(baseURL: URL) throws -> URLRequest {
var components = URLComponents(url: baseURL.appendingPathComponent(path), resolvingAgainstBaseURL: true)
components?.queryItems = queryItems?.isEmpty == false ? queryItems : nil
guard let url = components?.url else {
throw APIError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = method.rawValue
request.timeoutInterval = timeout
// Set default headers
request.setValue("application/json", forHTTPHeaderField: "Accept")
// Set custom headers
headers?.forEach { key, value in
request.setValue(value, forHTTPHeaderField: key)
}
// Set body for non-GET requests
if let body, method != .get {
request.httpBody = body
if request.value(forHTTPHeaderField: "Content-Type") == nil {
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
}
}
return request
}
}
// MARK: - Generic Endpoints
/// A generic endpoint that can be configured inline.
struct GenericEndpoint: Endpoint, Sendable {
let path: String
let method: HTTPMethod
let queryItems: [URLQueryItem]?
let headers: [String: String]?
let body: Data?
let timeout: TimeInterval
init(
path: String,
method: HTTPMethod = .get,
queryItems: [URLQueryItem]? = nil,
headers: [String: String]? = nil,
body: Data? = nil,
timeout: TimeInterval = 30
) {
self.path = path
self.method = method
self.queryItems = queryItems
self.headers = headers
self.body = body
self.timeout = timeout
}
/// Creates a GET endpoint with query parameters.
nonisolated static func get(_ path: String, query: [String: String] = [:]) -> GenericEndpoint {
let queryItems = query.isEmpty ? nil : query.map { URLQueryItem(name: $0.key, value: $0.value) }
return GenericEndpoint(path: path, queryItems: queryItems)
}
/// Creates a GET endpoint with custom headers.
nonisolated static func get(_ path: String, customHeaders: [String: String]) -> GenericEndpoint {
return GenericEndpoint(path: path, headers: customHeaders)
}
/// Creates a POST endpoint with an encodable body.
nonisolated static func post<T: Encodable>(_ path: String, body: T, encoder: JSONEncoder = JSONEncoder()) -> GenericEndpoint {
let bodyData = try? encoder.encode(body)
return GenericEndpoint(path: path, method: .post, body: bodyData)
}
/// Creates a POST endpoint without a body (e.g., for subscription endpoints).
nonisolated static func post(_ path: String) -> GenericEndpoint {
return GenericEndpoint(path: path, method: .post)
}
/// Creates a DELETE endpoint.
nonisolated static func delete(_ path: String) -> GenericEndpoint {
return GenericEndpoint(path: path, method: .delete)
}
}

View File

@@ -0,0 +1,270 @@
//
// HTTPClient.swift
// Yattee
//
// Modern async/await networking layer using URLSession.
//
import Foundation
/// Actor-based HTTP client for making network requests.
actor HTTPClient {
// MARK: - Properties
private let session: URLSession
private let decoder: JSONDecoder
private var userAgent: String?
private var randomizeUserAgentPerRequest: Bool = false
// MARK: - Initialization
init(session: URLSession = .shared, decoder: JSONDecoder = .init()) {
self.session = session
self.decoder = decoder
// Configure decoder for common API patterns
self.decoder.keyDecodingStrategy = .convertFromSnakeCase
self.decoder.dateDecodingStrategy = .iso8601
}
// MARK: - Configuration
/// Sets the User-Agent header to use for all requests.
/// - Parameter userAgent: The User-Agent string to use.
func setUserAgent(_ userAgent: String) {
self.userAgent = userAgent
}
/// Sets whether to generate a new random User-Agent for each request.
/// - Parameter enabled: If true, ignores the fixed userAgent and generates a new random one per request.
func setRandomizeUserAgentPerRequest(_ enabled: Bool) {
self.randomizeUserAgentPerRequest = enabled
}
// MARK: - Public Methods
/// Fetches and decodes a response from the given endpoint.
/// - Parameters:
/// - endpoint: The endpoint to fetch from.
/// - baseURL: The base URL to use for the request.
/// - customHeaders: Optional custom headers to add to the request (e.g., API keys).
/// - Returns: The decoded response.
func fetch<T: Decodable & Sendable>(
_ endpoint: Endpoint,
baseURL: URL,
customHeaders: [String: String]? = nil
) async throws -> T {
let request = try endpoint.urlRequest(baseURL: baseURL)
return try await perform(request, customHeaders: customHeaders)
}
/// Fetches raw data from the given endpoint.
/// - Parameters:
/// - endpoint: The endpoint to fetch from.
/// - baseURL: The base URL to use for the request.
/// - customHeaders: Optional custom headers to add to the request (e.g., API keys).
/// - Returns: The raw response data.
func fetchData(
_ endpoint: Endpoint,
baseURL: URL,
customHeaders: [String: String]? = nil
) async throws -> Data {
let request = try endpoint.urlRequest(baseURL: baseURL)
return try await performRaw(request, customHeaders: customHeaders)
}
/// Sends a request without expecting a response body.
/// Used for POST/PUT/DELETE operations that return empty responses.
/// - Parameters:
/// - endpoint: The endpoint to send the request to.
/// - baseURL: The base URL to use for the request.
/// - customHeaders: Optional custom headers to add to the request (e.g., cookies).
func sendRequest(
_ endpoint: Endpoint,
baseURL: URL,
customHeaders: [String: String]? = nil
) async throws {
let request = try endpoint.urlRequest(baseURL: baseURL)
_ = try await performRaw(request, customHeaders: customHeaders)
}
/// Performs a request and returns the raw data without decoding.
/// - Parameters:
/// - request: The URLRequest to perform.
/// - customHeaders: Optional custom headers to add to the request (e.g., API keys).
/// - Returns: The raw response data.
func performRaw(_ request: URLRequest, customHeaders: [String: String]? = nil) async throws -> Data {
var mutableRequest = request
// Apply User-Agent header
if randomizeUserAgentPerRequest {
// Generate a fresh random User-Agent for this request
mutableRequest.setValue(UserAgentGenerator.generateRandom(), forHTTPHeaderField: "User-Agent")
} else if let userAgent {
mutableRequest.setValue(userAgent, forHTTPHeaderField: "User-Agent")
}
// Apply custom headers (e.g., X-API-Key for authenticated requests)
if let customHeaders {
for (key, value) in customHeaders {
mutableRequest.setValue(value, forHTTPHeaderField: key)
}
}
let method = mutableRequest.httpMethod ?? "GET"
let requestURL = mutableRequest.url ?? URL(string: "unknown")!
let finalRequest = mutableRequest
await MainActor.run {
LoggingService.shared.logAPIRequest(method, url: requestURL)
}
// Check if task was already cancelled before making the request
if Task.isCancelled {
await MainActor.run {
LoggingService.shared.debug("Request cancelled before execution: \(requestURL)", category: .api)
}
throw APIError.cancelled
}
let startTime = Date()
let data: Data
let response: URLResponse
do {
(data, response) = try await session.data(for: finalRequest)
} catch let error as URLError {
let apiError = mapURLError(error)
// Add more context for cancelled requests
if error.code == .cancelled {
await MainActor.run {
LoggingService.shared.debug(
"URLSession request cancelled - Task.isCancelled: \(Task.isCancelled)",
category: .api
)
}
}
await MainActor.run {
LoggingService.shared.logAPIError(requestURL, error: apiError)
}
throw apiError
} catch {
await MainActor.run {
LoggingService.shared.logAPIError(requestURL, error: error)
}
throw APIError.unknown(error.localizedDescription)
}
let duration = Date().timeIntervalSince(startTime)
do {
try validateResponse(response, data: data)
} catch {
await MainActor.run {
LoggingService.shared.logAPIError(requestURL, error: error)
}
throw error
}
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0
await MainActor.run {
LoggingService.shared.logAPIResponse(requestURL, statusCode: statusCode, duration: duration)
}
return data
}
// MARK: - Private Methods
private func perform<T: Decodable & Sendable>(
_ request: URLRequest,
customHeaders: [String: String]? = nil
) async throws -> T {
let data = try await performRaw(request, customHeaders: customHeaders)
do {
return try decoder.decode(T.self, from: data)
} catch let error as DecodingError {
let errorDescription = describeDecodingError(error)
Task { @MainActor in
LoggingService.shared.error("API decoding error: \(errorDescription)", category: .api)
}
throw APIError.decodingError(errorDescription)
}
}
private func validateResponse(_ response: URLResponse, data: Data) throws {
guard let httpResponse = response as? HTTPURLResponse else {
return
}
let statusCode = httpResponse.statusCode
switch statusCode {
case 200...299:
return
case 401:
throw APIError.unauthorized
case 404:
let detail = parseErrorDetail(from: data)
throw APIError.notFound(detail)
case 429:
let retryAfter = httpResponse.value(forHTTPHeaderField: "Retry-After")
.flatMap { TimeInterval($0) }
throw APIError.rateLimited(retryAfter: retryAfter)
default:
let detail = parseErrorDetail(from: data)
throw APIError.httpError(statusCode: statusCode, message: detail)
}
}
private func mapURLError(_ error: URLError) -> APIError {
switch error.code {
case .timedOut:
return .timeout
case .notConnectedToInternet, .networkConnectionLost:
return .noConnection
case .cancelled:
return .cancelled
default:
return .unknown(error.localizedDescription)
}
}
private func describeDecodingError(_ error: DecodingError) -> String {
switch error {
case .keyNotFound(let key, let context):
return "Missing key '\(key.stringValue)' at \(context.codingPath.map(\.stringValue).joined(separator: "."))"
case .typeMismatch(let type, let context):
return "Type mismatch for \(type) at \(context.codingPath.map(\.stringValue).joined(separator: "."))"
case .valueNotFound(let type, let context):
return "Null value for \(type) at \(context.codingPath.map(\.stringValue).joined(separator: "."))"
case .dataCorrupted(let context):
return "Data corrupted at \(context.codingPath.map(\.stringValue).joined(separator: "."))"
@unknown default:
return error.localizedDescription
}
}
/// Parses error detail from JSON response body.
/// Supports common API error formats: {"detail": "..."}, {"error": "..."}, {"message": "..."}
private func parseErrorDetail(from data: Data) -> String? {
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return nil
}
// Try common error message fields
if let detail = json["detail"] as? String {
return detail
}
if let error = json["error"] as? String {
return error
}
if let message = json["message"] as? String {
return message
}
return nil
}
}

View File

@@ -0,0 +1,40 @@
//
// HTTPClientFactory.swift
// Yattee
//
// Factory for creating HTTPClient instances with appropriate SSL settings.
//
import Foundation
/// Factory for creating HTTPClient instances based on instance SSL settings.
final class HTTPClientFactory: Sendable {
private let sessionFactory: URLSessionFactory
init(sessionFactory: URLSessionFactory = .shared) {
self.sessionFactory = sessionFactory
}
/// Creates an HTTPClient configured for the given instance's SSL requirements.
/// - Parameter instance: The instance to create a client for.
/// - Returns: An HTTPClient with appropriate SSL settings.
func createClient(for instance: Instance) -> HTTPClient {
let session = sessionFactory.session(allowInvalidCertificates: instance.allowInvalidCertificates)
return HTTPClient(session: session)
}
/// Creates an HTTPClient with explicit SSL settings.
/// - Parameter allowInvalidCertificates: Whether to bypass SSL certificate validation.
/// - Returns: An HTTPClient with the specified SSL settings.
func createClient(allowInvalidCertificates: Bool) -> HTTPClient {
let session = sessionFactory.session(allowInvalidCertificates: allowInvalidCertificates)
return HTTPClient(session: session)
}
/// Creates an HTTPClient with low network priority for background/prefetch work.
/// - Returns: An HTTPClient configured with `.background` network service type.
func createLowPriorityClient() -> HTTPClient {
let session = sessionFactory.lowPrioritySession()
return HTTPClient(session: session)
}
}

View File

@@ -0,0 +1,29 @@
//
// InsecureURLSessionDelegate.swift
// Yattee
//
// URLSessionDelegate that bypasses SSL certificate validation.
// Used for connections to servers with self-signed or invalid certificates.
//
import Foundation
/// URLSessionDelegate that accepts all server certificates, bypassing SSL validation.
/// Only use this for trusted servers with self-signed certificates.
final class InsecureURLSessionDelegate: NSObject, URLSessionDelegate {
func urlSession(
_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
// For server trust challenges, accept the certificate without validation
if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
let serverTrust = challenge.protectionSpace.serverTrust {
let credential = URLCredential(trust: serverTrust)
completionHandler(.useCredential, credential)
} else {
// For other authentication methods, use default handling
completionHandler(.performDefaultHandling, nil)
}
}
}

View File

@@ -0,0 +1,61 @@
//
// URLSessionFactory.swift
// Yattee
//
// Factory for creating URLSession instances with appropriate SSL settings.
//
import Foundation
/// Factory for creating URLSession instances based on SSL validation requirements.
final class URLSessionFactory: Sendable {
/// Shared instance for app-wide use.
static let shared = URLSessionFactory()
/// The delegate used for bypassing SSL certificate validation.
private let insecureDelegate = InsecureURLSessionDelegate()
/// Cached insecure URLSession instance.
private let insecureSession: URLSession
/// Cached low-priority URLSession for background/prefetch work.
private let lowPriorityURLSession: URLSession
init() {
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 30
config.timeoutIntervalForResource = 300
self.insecureSession = URLSession(
configuration: config,
delegate: insecureDelegate,
delegateQueue: nil
)
// Low-priority session for DeArrow and similar background work
let lowPriorityConfig = URLSessionConfiguration.default
lowPriorityConfig.timeoutIntervalForRequest = 30
lowPriorityConfig.timeoutIntervalForResource = 300
lowPriorityConfig.networkServiceType = .background
lowPriorityConfig.waitsForConnectivity = true
self.lowPriorityURLSession = URLSession(configuration: lowPriorityConfig)
}
/// Returns an appropriate URLSession based on SSL validation requirements.
/// - Parameter allowInvalidCertificates: If true, returns a session that bypasses SSL validation.
/// - Returns: A URLSession configured for the requested SSL mode.
func session(allowInvalidCertificates: Bool) -> URLSession {
if allowInvalidCertificates {
return insecureSession
} else {
return URLSession.shared
}
}
/// Returns a low-priority URLSession for background/prefetch work.
/// Uses `.background` network service type for OS-level bandwidth deprioritization.
func lowPrioritySession() -> URLSession {
lowPriorityURLSession
}
}