- 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>
758 lines
24 KiB
Swift
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
|
|
}
|
|
}
|