Files
MangaReader/ios-app/MangaReader/Sources/Services/VPSAPIClient.swift
Apple 89cdb5468f fix: Corregir force unwraps y mejorar seguridad del código
- ManhwaWebScraper.swift: Eliminar force unwrap en URL con guard let
- ManhwaWebScraperOptimized.swift: Eliminar 2 force unwraps en URLs
- StorageServiceOptimized.swift: Usar .first en lugar de subscript [0]
- ImageCache.swift: Usar .first en lugar de subscript [0]
- Agregar caso invalidURL a ScrapingError enum

Build exitoso para iOS 15.0+ (simulador y device)
IPA generado y listo para sideloading

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-02-08 13:56:58 -03:00

758 lines
24 KiB
Swift

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
/// Actor para serializar requests y evitar condiciones de carrera
private let requestActor = RequestActor()
/// Token de autenticación opcional
private var authToken: String?
/// Published download progress tracking
@Published var downloadProgress: [String: Double] = [:]
@Published var activeDownloads: Set<String> = []
// 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.urlCache = nil // Disable caching for requests
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 request = URLRequest(url: url)
let (data, _) = try await session.data(for: request)
struct HealthResponse: Codable {
let status: String
}
do {
let response = try JSONDecoder().decode(HealthResponse.self, from: data)
return response.status == "ok" || response.status == "healthy"
} catch {
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 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 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 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 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 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) -> VPSAPIError {
// Intentar leer mensaje de error del cuerpo de la respuesta
struct ErrorResponse: Codable {
let message: String?
}
let errorMessage: String?
if let response = try? JSONDecoder().decode(ErrorResponse.self, from: data),
let message = response.message {
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: - Request Actor
/// Actor para serializar requests y manejar estado de forma segura en concurrencia
private actor RequestActor {
private var activeRequests: Set<String> = []
func beginRequest(_ id: String) -> Bool {
guard !activeRequests.contains(id) else { return false }
activeRequests.insert(id)
return true
}
func endRequest(_ id: String) {
activeRequests.remove(id)
}
func isActive(_ id: String) -> Bool {
activeRequests.contains(id)
}
}
// MARK: - Response Models
struct VPSDownloadResult: Sendable {
let success: Bool
let alreadyDownloaded: Bool
let manifest: VPSChapterManifest?
let downloaded: Int?
let failed: Int?
}
struct VPSDownloadResponse: Codable, Sendable {
let success: Bool
let alreadyDownloaded: Bool?
let manifest: VPSChapterManifest?
let downloaded: Int?
let failed: Int?
}
struct VPSChapterManifest: Codable, Sendable {
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, Sendable {
let page: Int
let filename: String
let url: String
let size: Int
var sizeKB: String {
String(format: "%.2f", Double(size) / 1024)
}
}
struct VPSChapterInfo: Codable, Sendable {
let chapterNumber: Int
let downloadDate: String
let totalPages: Int
let downloadedPages: Int
let totalSize: Int
let totalSizeMB: String
}
struct VPSChaptersListResponse: Codable, Sendable {
let mangaSlug: String
let totalChapters: Int
let chapters: [VPSChapterInfo]
}
struct VPSStorageStats: Codable, Sendable {
let totalMangas: Int
let totalChapters: Int
let totalSize: Int
let totalSizeMB: String
let totalSizeFormatted: String
let mangaDetails: [VPSMangaDetail]
struct VPSMangaDetail: Codable, Sendable {
let mangaSlug: String
let chapters: Int
let totalSize: Int
let totalSizeMB: String
}
}