feat: Add VPS storage system and complete integration
🎯 Overview: Implemented complete VPS-based storage system allowing the iOS app to download and store manga chapters on the VPS for ad-free offline reading. 📦 Backend Changes: - Added storage.js service for managing chapter downloads (270 lines) - Updated server.js with 6 new storage endpoints: - POST /api/download - Download chapters to VPS - GET /api/storage/chapters/:mangaSlug - List downloaded chapters - GET /api/storage/chapter/:mangaSlug/:chapterNumber - Check download status - GET /api/storage/image/:mangaSlug/:chapterNumber/:pageIndex - Serve images - DELETE /api/storage/chapter/:mangaSlug/:chapterNumber - Delete chapters - GET /api/storage/stats - Get storage statistics - Fixed scraper.js Puppeteer compatibility issues (waitForTimeout, networkidle0) - Added comprehensive test suite: - test-vps-flow.js (13 tests - 100% pass rate) - test-concurrent-downloads.js (10 tests for parallel operations) - run-tests.sh automation script 📱 iOS App Changes: - Created APIConfig.swift with VPS connection settings - Created VPSAPIClient.swift service (727 lines) for backend communication - Updated MangaDetailView.swift with VPS download integration: - Cloud icon for VPS-available chapters - Upload button to download chapters to VPS - Progress indicators for active downloads - Bulk download options (last 10 or all chapters) - Updated ReaderView.swift to load images from VPS first - Progressive enhancement: app works without VPS, enhances when available ✅ Tests: - All 13 VPS flow tests passing (100%) - Tests verify: scraping, downloading, storage, serving, deletion, stats - Chapter 789 download test: 21 images, 4.68 MB - Concurrent download tests verify no race conditions 🔧 Configuration: - VPS URL: https://gitea.cbcren.online:3001 - Storage location: /home/ren/ios/MangaReader/storage/ - Static file serving: /storage path 📚 Documentation: - Added VPS_INTEGRATION_SUMMARY.md - Complete feature overview - Added CHANGES.md - Detailed code changes reference - Added TEST_README.md, TEST_QUICK_START.md, TEST_SUMMARY.md - Added APIConfig README with usage examples 🤖 Generated with Claude Code (https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
727
ios-app/Sources/Services/VPSAPIClient.swift
Normal file
727
ios-app/Sources/Services/VPSAPIClient.swift
Normal file
@@ -0,0 +1,727 @@
|
||||
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<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.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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user