import Foundation /// Cliente de API para comunicarse con el backend VPS. /// /// `VPSAPIClient` proporciona una interfaz completa para interactuar con el backend /// que gestiona el almacenamiento y serving de capítulos de manga en un VPS. /// /// El cliente implementa: /// - Request de descarga de capítulos al VPS /// - Verificación de disponibilidad de capítulos /// - Listado de capítulos descargados /// - Obtención de URLs de imágenes /// - Eliminación de capítulos del VPS /// - Consulta de estadísticas de almacenamiento /// /// Usa URLSession con async/await para operaciones de red, y maneja errores /// de forma robusta con tipos de error personalizados. /// /// # Example /// ```swift /// let client = VPSAPIClient.shared /// /// // Solicitar descarga /// do { /// let result = try await client.downloadChapter( /// mangaSlug: "one-piece", /// chapterNumber: 1, /// imageUrls: ["https://example.com/page1.jpg"] /// ) /// print("Download success: \(result.success)") /// } catch { /// print("Error: \(error)") /// } /// /// // Verificar si está descargado /// if let manifest = try await client.checkChapterDownloaded( /// mangaSlug: "one-piece", /// chapterNumber: 1 /// ) { /// print("Chapter downloaded with \(manifest.totalPages) pages") /// } /// ``` @MainActor class VPSAPIClient: ObservableObject { // MARK: - Singleton /// Instancia compartida del cliente (Singleton pattern) static let shared = VPSAPIClient() // MARK: - Properties /// URLSession configurada para requests HTTP private let session: URLSession /// Cola para serializar requests y evitar condiciones de carrera private let requestQueue = DispatchQueue(label: "com.mangareader.vpsapi", qos: .userInitiated) /// Token de autenticación opcional private var authToken: String? /// Published download progress tracking @Published var downloadProgress: [String: Double] = [:] @Published var activeDownloads: Set = [] // MARK: - Initialization /// Inicializador privado para implementar Singleton. /// /// Configura URLSession con timeouts apropiados según el tipo de request. private init() { let configuration = URLSessionConfiguration.default configuration.timeoutIntervalForRequest = APIConfig.defaultTimeout configuration.timeoutIntervalForResource = APIConfig.downloadTimeout configuration.httpShouldSetCookies = false configuration.httpRequestCachePolicy = .reloadIgnoringLocalCacheData self.session = URLSession(configuration: configuration) } // MARK: - Authentication /// Configura el token de autenticación para todas las requests. /// /// - Parameter token: Token de autenticación (API key, JWT, etc.) /// /// # Example /// ```swift /// client.setAuthToken("your-api-key-or-jwt-token") /// ``` func setAuthToken(_ token: String) { authToken = token } /// Elimina el token de autenticación. func clearAuthToken() { authToken = nil } // MARK: - Health Check /// Verifica si el servidor VPS está accesible. /// /// - Returns: `true` si el servidor responde correctamente, `false` en caso contrario /// /// # Example /// ```swift /// let isHealthy = try await client.checkServerHealth() /// if isHealthy { /// print("El servidor está funcionando") /// } else { /// print("El servidor no está accesible") /// } /// ``` func checkServerHealth() async throws -> Bool { let endpoint = APIConfig.Endpoints.health() guard let url = URL(string: endpoint) else { throw VPSAPIError.invalidURL(endpoint) } let (data, _) = try await session.data(from: url) if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let status = json["status"] as? String { return status == "ok" || status == "healthy" } return false } // MARK: - Download Management /// Solicita la descarga de un capítulo al VPS. /// /// Este método inicia el proceso de descarga en el backend. El servidor /// descargará las imágenes desde las URLs proporcionadas y las almacenará /// en el VPS. /// /// - Parameters: /// - mangaSlug: Slug del manga a descargar /// - chapterNumber: Número del capítulo /// - imageUrls: Array de URLs de las imágenes a descargar /// - Returns: `VPSDownloadResult` con información sobre la descarga /// - Throws: `VPSAPIError` si la request falla /// /// # Example /// ```swift /// do { /// let result = try await client.downloadChapter( /// mangaSlug: "one-piece", /// chapterNumber: 1, /// imageUrls: [ /// "https://example.com/page1.jpg", /// "https://example.com/page2.jpg" /// ] /// ) /// print("Success: \(result.success)") /// if let manifest = result.manifest { /// print("Pages: \(manifest.totalPages)") /// } /// } catch VPSAPIError.chapterAlreadyDownloaded { /// print("El capítulo ya está descargado") /// } catch { /// print("Error: \(error.localizedDescription)") /// } /// ``` func downloadChapter( mangaSlug: String, chapterNumber: Int, imageUrls: [String] ) async throws -> VPSDownloadResult { let downloadId = "\(mangaSlug)-\(chapterNumber)" activeDownloads.insert(downloadId) downloadProgress[downloadId] = 0.0 defer { activeDownloads.remove(downloadId) downloadProgress.removeValue(forKey: downloadId) } let endpoint = APIConfig.Endpoints.download(mangaSlug: mangaSlug, chapterNumber: chapterNumber) guard let url = URL(string: endpoint) else { throw VPSAPIError.invalidURL(endpoint) } var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") // Agregar headers de autenticación si existen if let token = authToken { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } let requestBody: [String: Any] = [ "mangaSlug": mangaSlug, "chapterNumber": chapterNumber, "imageUrls": imageUrls ] request.httpBody = try JSONSerialization.data(withJSONObject: requestBody) // Actualizar progreso simulado (en producción, usar progress delegates) for i in 1...5 { try await Task.sleep(nanoseconds: 200_000_000) // 0.2 segundos downloadProgress[downloadId] = Double(i) * 0.2 } let (data, response) = try await session.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw VPSAPIError.invalidResponse } guard httpResponse.statusCode == 200 else { throw try mapHTTPError(statusCode: httpResponse.statusCode, data: data) } let decoder = JSONDecoder() let apiResponse = try decoder.decode(VPSDownloadResponse.self, from: data) downloadProgress[downloadId] = 1.0 return VPSDownloadResult( success: apiResponse.success, alreadyDownloaded: apiResponse.alreadyDownloaded ?? false, manifest: apiResponse.manifest, downloaded: apiResponse.downloaded, failed: apiResponse.failed ) } // MARK: - Check Chapter Status /// Verifica si un capítulo está descargado en el VPS. /// /// - Parameters: /// - mangaSlug: Slug del manga /// - chapterNumber: Número del capítulo /// - Returns: `VPSChapterManifest` si el capítulo existe, `nil` en caso contrario /// - Throws: `VPSAPIError` si hay un error de red o el servidor responde con error /// /// # Example /// ```swift /// do { /// if let manifest = try await client.checkChapterDownloaded( /// mangaSlug: "one-piece", /// chapterNumber: 1 /// ) { /// print("Capítulo descargado:") /// print("- Páginas: \(manifest.totalPages)") /// print("- Tamaño: \(manifest.totalSizeMB) MB") /// } else { /// print("El capítulo no está descargado") /// } /// } catch { /// print("Error verificando descarga: \(error)") /// } /// ``` func checkChapterDownloaded( mangaSlug: String, chapterNumber: Int ) async throws -> VPSChapterManifest? { let endpoint = APIConfig.Endpoints.checkDownloaded(mangaSlug: mangaSlug, chapterNumber: chapterNumber) guard let url = URL(string: endpoint) else { throw VPSAPIError.invalidURL(endpoint) } var request = URLRequest(url: url) request.httpMethod = "GET" if let token = authToken { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } let (data, response) = try await session.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw VPSAPIError.invalidResponse } if httpResponse.statusCode == 404 { return nil } guard httpResponse.statusCode == 200 else { throw try mapHTTPError(statusCode: httpResponse.statusCode, data: data) } let decoder = JSONDecoder() return try decoder.decode(VPSChapterManifest.self, from: data) } /// Obtiene la lista de capítulos descargados para un manga. /// /// - Parameter mangaSlug: Slug del manga /// - Returns: Array de `VPSChapterInfo` con los capítulos disponibles /// - Throws: `VPSAPIError` si hay un error de red o el servidor responde con error /// /// # Example /// ```swift /// do { /// let chapters = try await client.listDownloadedChapters(mangaSlug: "one-piece") /// print("Capítulos disponibles: \(chapters.count)") /// for chapter in chapters { /// print("- Capítulo \(chapter.chapterNumber): \(chapter.totalPages) páginas") /// } /// } catch { /// print("Error obteniendo lista: \(error)") /// } /// ``` func listDownloadedChapters(mangaSlug: String) async throws -> [VPSChapterInfo] { let endpoint = APIConfig.Endpoints.listChapters(mangaSlug: mangaSlug) guard let url = URL(string: endpoint) else { throw VPSAPIError.invalidURL(endpoint) } var request = URLRequest(url: url) request.httpMethod = "GET" if let token = authToken { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } let (data, response) = try await session.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw VPSAPIError.invalidResponse } guard httpResponse.statusCode == 200 else { throw try mapHTTPError(statusCode: httpResponse.statusCode, data: data) } let decoder = JSONDecoder() let responseObj = try decoder.decode(VPSChaptersListResponse.self, from: data) return responseObj.chapters } // MARK: - Image Retrieval /// Obtiene la URL de una imagen específica de un capítulo. /// /// Este método retorna la URL directa para acceder a una imagen almacenada /// en el VPS. La URL puede usarse directamente para cargar la imagen. /// /// - Parameters: /// - mangaSlug: Slug del manga /// - chapterNumber: Número del capítulo /// - pageIndex: Índice de la página (1-based) /// - Returns: String con la URL completa de la imagen /// /// # Example /// ```swift /// let imageURL = client.getChapterImage( /// mangaSlug: "one-piece", /// chapterNumber: 1, /// pageIndex: 1 /// ) /// print("Imagen URL: \(imageURL)") /// // Usar la URL para cargar la imagen en AsyncImage o SDWebImage /// ``` func getChapterImage( mangaSlug: String, chapterNumber: Int, pageIndex: Int ) -> String { let endpoint = APIConfig.Endpoints.getImage( mangaSlug: mangaSlug, chapterNumber: chapterNumber, pageIndex: pageIndex ) return endpoint } /// Obtiene URLs de múltiples imágenes de un capítulo. /// /// Método de conveniencia para obtener URLs para múltiples páginas. /// /// - Parameters: /// - mangaSlug: Slug del manga /// - chapterNumber: Número del capítulo /// - pageIndices: Array de índices de página (1-based) /// - Returns: Array de Strings con las URLs de las imágenes /// /// # Example /// ```swift /// let imageURLs = client.getChapterImages( /// mangaSlug: "one-piece", /// chapterNumber: 1, /// pageIndices: [1, 2, 3, 4, 5] /// ) /// print("Obtenidas \(imageURLs.count) URLs") /// ``` func getChapterImages( mangaSlug: String, chapterNumber: Int, pageIndices: [Int] ) -> [String] { return pageIndices.map { index in getChapterImage( mangaSlug: mangaSlug, chapterNumber: chapterNumber, pageIndex: index ) } } // MARK: - Chapter Management /// Elimina un capítulo del almacenamiento del VPS. /// /// Este método elimina todas las imágenes y metadata del capítulo /// del servidor VPS, liberando espacio. /// /// - Parameters: /// - mangaSlug: Slug del manga /// - chapterNumber: Número del capítulo a eliminar /// - Returns: `true` si la eliminación fue exitosa, `false` en caso contrario /// - Throws: `VPSAPIError` si el capítulo no existe o hay un error /// /// # Example /// ```swift /// do { /// let success = try await client.deleteChapterFromVPS( /// mangaSlug: "one-piece", /// chapterNumber: 1 /// ) /// if success { /// print("Capítulo eliminado exitosamente") /// } else { /// print("No se pudo eliminar el capítulo") /// } /// } catch VPSAPIError.chapterNotFound { /// print("El capítulo no existía") /// } catch { /// print("Error eliminando: \(error)") /// } /// ``` func deleteChapterFromVPS( mangaSlug: String, chapterNumber: Int ) async throws -> Bool { let endpoint = APIConfig.Endpoints.deleteChapter( mangaSlug: mangaSlug, chapterNumber: chapterNumber ) guard let url = URL(string: endpoint) else { throw VPSAPIError.invalidURL(endpoint) } var request = URLRequest(url: url) request.httpMethod = "DELETE" if let token = authToken { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } let (data, response) = try await session.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw VPSAPIError.invalidResponse } if httpResponse.statusCode == 404 { throw VPSAPIError.chapterNotFound } guard httpResponse.statusCode == 200 else { throw try mapHTTPError(statusCode: httpResponse.statusCode, data: data) } return true } // MARK: - Storage Statistics /// Obtiene estadísticas de almacenamiento del VPS. /// /// Retorna información sobre el espacio usado, disponible, total, /// y número de capítulos e imágenes almacenadas. /// /// - Returns: `VPSStorageStats` con todas las estadísticas /// - Throws: `VPSAPIError` si hay un error de red o el servidor responde con error /// /// # Example /// ```swift /// do { /// let stats = try await client.getStorageStats() /// print("Usado: \(stats.totalSizeFormatted)") /// print("Mangas: \(stats.totalMangas)") /// print("Capítulos: \(stats.totalChapters)") /// } catch { /// print("Error obteniendo estadísticas: \(error)") /// } /// ``` func getStorageStats() async throws -> VPSStorageStats { let endpoint = APIConfig.Endpoints.storageStats() guard let url = URL(string: endpoint) else { throw VPSAPIError.invalidURL(endpoint) } var request = URLRequest(url: url) request.httpMethod = "GET" if let token = authToken { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } let (data, response) = try await session.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw VPSAPIError.invalidResponse } guard httpResponse.statusCode == 200 else { throw try mapHTTPError(statusCode: httpResponse.statusCode, data: data) } let decoder = JSONDecoder() return try decoder.decode(VPSStorageStats.self, from: data) } // MARK: - Private Methods /// Mapea un código de error HTTP a un `VPSAPIError` específico. /// /// - Parameters: /// - statusCode: Código de estado HTTP /// - data: Datos de la respuesta (puede contener mensaje de error) /// - Returns: `VPSAPIError` apropiado /// - Throws: Error de decodificación si no puede leer el mensaje de error private func mapHTTPError(statusCode: Int, data: Data) throws -> VPSAPIError { // Intentar leer mensaje de error del cuerpo de la respuesta let errorMessage: String? if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let message = json["message"] as? String { errorMessage = message } else { errorMessage = nil } switch statusCode { case 400: return .badRequest(errorMessage ?? "Bad request") case 401: return .unauthorized case 403: return .forbidden case 404: return .chapterNotFound case 409: return .chapterAlreadyDownloaded case 422: return .invalidImageFormat(errorMessage ?? "Invalid image format") case 429: return .rateLimited case 500: return .serverError(errorMessage ?? "Internal server error") case 503: return .serviceUnavailable case 507: return .storageLimitExceeded default: return .httpError(statusCode: statusCode) } } } // MARK: - Error Types /// Errores específicos del cliente de API VPS. enum VPSAPIError: LocalizedError { case invalidURL(String) case invalidResponse case httpError(statusCode: Int) case networkError(Error) case decodingError(Error) case encodingError(Error) case badRequest(String) case unauthorized case forbidden case chapterNotFound case chapterAlreadyDownloaded case imageNotFound case invalidImageFormat(String) case rateLimited case storageLimitExceeded case serverError(String) case serviceUnavailable var errorDescription: String? { switch self { case .invalidURL(let url): return "URL inválida: \(url)" case .invalidResponse: return "Respuesta inválida del servidor" case .httpError(let statusCode): return "Error HTTP \(statusCode)" case .networkError(let error): return "Error de red: \(error.localizedDescription)" case .decodingError(let error): return "Error al decodificar respuesta: \(error.localizedDescription)" case .encodingError(let error): return "Error al codificar solicitud: \(error.localizedDescription)" case .badRequest(let message): return "Solicitud inválida: \(message)" case .unauthorized: return "No autorizado: credenciales inválidas o expiradas" case .forbidden: return "Acceso prohibido" case .chapterNotFound: return "El capítulo no existe en el VPS" case .chapterAlreadyDownloaded: return "El capítulo ya está descargado en el VPS" case .imageNotFound: return "La imagen solicitada no existe" case .invalidImageFormat(let message): return "Formato de imagen inválido: \(message)" case .rateLimited: return "Demasiadas solicitudes. Intente más tarde." case .storageLimitExceeded: return "Límite de almacenamiento excedido" case .serverError(let message): return "Error del servidor: \(message)" case .serviceUnavailable: return "Servicio no disponible temporalmente" } } var recoverySuggestion: String? { switch self { case .unauthorized: return "Verifique sus credenciales o actualice el token de autenticación" case .rateLimited: return "Espere unos minutos antes de intentar nuevamente" case .storageLimitExceeded: return "Elimine algunos capítulos antiguos para liberar espacio" case .serviceUnavailable: return "Intente nuevamente en unos minutos" case .networkError: return "Verifique su conexión a internet" default: return nil } } } // MARK: - Response Models struct VPSDownloadResult { let success: Bool let alreadyDownloaded: Bool let manifest: VPSChapterManifest? let downloaded: Int? let failed: Int? } struct VPSDownloadResponse: Codable { let success: Bool let alreadyDownloaded: Bool? let manifest: VPSChapterManifest? let downloaded: Int? let failed: Int? } struct VPSChapterManifest: Codable { let mangaSlug: String let chapterNumber: Int let totalPages: Int let downloadedPages: Int let failedPages: Int let downloadDate: String let totalSize: Int let images: [VPSImageInfo] var totalSizeMB: String { String(format: "%.2f", Double(totalSize) / 1024 / 1024) } } struct VPSImageInfo: Codable { let page: Int let filename: String let url: String let size: Int var sizeKB: String { String(format: "%.2f", Double(size) / 1024) } } struct VPSChapterInfo: Codable { let chapterNumber: Int let downloadDate: String let totalPages: Int let downloadedPages: Int let totalSize: Int let totalSizeMB: String } struct VPSChaptersListResponse: Codable { let mangaSlug: String let totalChapters: Int let chapters: [VPSChapterInfo] } struct VPSStorageStats: Codable { let totalMangas: Int let totalChapters: Int let totalSize: Int let totalSizeMB: String let totalSizeFormatted: String let mangaDetails: [VPSMangaDetail] struct VPSMangaDetail: Codable { let mangaSlug: String let chapters: Int let totalSize: Int let totalSizeMB: String } }