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:
434
ios-app/Sources/Config/APIConfig.swift
Normal file
434
ios-app/Sources/Config/APIConfig.swift
Normal file
@@ -0,0 +1,434 @@
|
||||
import Foundation
|
||||
|
||||
/// Configuración centralizada de la API del backend VPS.
|
||||
///
|
||||
/// `APIConfig` proporciona todos los endpoints y parámetros de configuración
|
||||
/// necesarios para comunicarse con el backend VPS que gestiona el almacenamiento
|
||||
/// y serving de capítulos de manga.
|
||||
///
|
||||
/// # Example
|
||||
/// ```swift
|
||||
/// let baseURL = APIConfig.baseURL
|
||||
/// let downloadEndpoint = APIConfig.Endpoints.download(mangaSlug: "one-piece", chapterNumber: 1)
|
||||
/// print(downloadEndpoint) // "https://api.example.com/api/v1/download/one-piece/1"
|
||||
/// ```
|
||||
enum APIConfig {
|
||||
// MARK: - Base Configuration
|
||||
|
||||
/// URL base del backend VPS
|
||||
///
|
||||
/// Esta URL se usa para construir todos los endpoints de la API.
|
||||
/// Configurar según el entorno (desarrollo, staging, producción).
|
||||
///
|
||||
/// # Configuración Actual
|
||||
/// - Producción: `https://gitea.cbcren.online`
|
||||
/// - Puerto: `3001` (se añade automáticamente)
|
||||
///
|
||||
/// # Notas Importantes
|
||||
/// - Incluir el protocolo (`https://` o `http://`)
|
||||
/// - NO incluir el número de puerto aquí (usar la propiedad `port`)
|
||||
/// - NO incluir slash al final
|
||||
/// - Asegurarse de que el servidor sea accesible desde el dispositivo iOS
|
||||
///
|
||||
/// # Ejemplos
|
||||
/// - `https://gitea.cbcren.online` (VPS de producción)
|
||||
/// - `http://192.168.1.100` (desarrollo local)
|
||||
/// - `http://localhost` (simulador con servidor local)
|
||||
static let serverURL = "https://gitea.cbcren.online"
|
||||
|
||||
/// Puerto donde corre el backend API
|
||||
///
|
||||
/// # Valor por Defecto
|
||||
/// - `3001` - Puerto configurado en el backend VPS
|
||||
///
|
||||
/// # Notas
|
||||
/// - Asegurarse de que coincida con el puerto configurado en el servidor backend
|
||||
/// - Si se usa HTTPS, asegurar la configuración correcta del certificado SSL
|
||||
/// - Si se usan puertos estándar HTTP (80/443), se puede dejar vacío
|
||||
static let port: Int = 3001
|
||||
|
||||
/// URL base completa para requests a la API
|
||||
///
|
||||
/// Construye automáticamente la URL base combinando la URL del servidor y el puerto.
|
||||
/// Esta es la propiedad recomendada para usar al hacer requests a la API.
|
||||
///
|
||||
/// # Ejemplo
|
||||
/// ```swift
|
||||
/// let endpoint = "/api/v1/manga"
|
||||
/// let url = URL(string: APIConfig.baseURL + endpoint)
|
||||
/// ```
|
||||
static var baseURL: String {
|
||||
if port == 80 || port == 443 {
|
||||
return serverURL
|
||||
}
|
||||
return "\(serverURL):\(port)"
|
||||
}
|
||||
|
||||
/// Versión de la API
|
||||
static var apiVersion: String {
|
||||
return "v1"
|
||||
}
|
||||
|
||||
/// Path base de la API
|
||||
static var basePath: String {
|
||||
return "\(baseURL)/api/\(apiVersion)"
|
||||
}
|
||||
|
||||
/// Timeout por defecto para requests (en segundos)
|
||||
static var defaultTimeout: TimeInterval {
|
||||
return 30.0
|
||||
}
|
||||
|
||||
/// Timeout para requests de descarga (en segundos)
|
||||
static var downloadTimeout: TimeInterval {
|
||||
return 300.0 // 5 minutos
|
||||
}
|
||||
|
||||
// MARK: - HTTP Headers
|
||||
|
||||
/// Headers HTTP comunes para todas las requests
|
||||
static var commonHeaders: [String: String] {
|
||||
return [
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json"
|
||||
]
|
||||
}
|
||||
|
||||
/// Header de autenticación (si se requiere API key o token)
|
||||
///
|
||||
/// - Parameter token: Token de autenticación (API key, JWT, etc.)
|
||||
/// - Returns: Dictionary con el header de autorización
|
||||
static func authHeader(token: String) -> [String: String] {
|
||||
return [
|
||||
"Authorization": "Bearer \(token)"
|
||||
]
|
||||
}
|
||||
|
||||
// MARK: - Retry Configuration
|
||||
|
||||
/// Número máximo de intentos de retry para requests fallidas
|
||||
///
|
||||
/// # Valor por Defecto
|
||||
/// - `3` intentos de retry
|
||||
///
|
||||
/// # Comportamiento
|
||||
/// - Un valor de `0` significa sin reintentos
|
||||
/// - Los reintentos usan backoff exponencial
|
||||
/// - Solo se reintentan errores recuperables (fallos de red, timeouts, etc.)
|
||||
/// - Errores de cliente (4xx) típicamente no se reintentan
|
||||
static let maxRetries: Int = 3
|
||||
|
||||
/// Delay base entre intentos de retry en segundos
|
||||
///
|
||||
/// # Valor por Defecto
|
||||
/// - `1.0` segundo
|
||||
///
|
||||
/// # Fórmula
|
||||
/// El delay real usa backoff exponencial:
|
||||
/// ```
|
||||
/// delay = baseRetryDelay * (2 ^ numeroDeIntento)
|
||||
/// ```
|
||||
/// - Intento 1: 1 segundo de delay
|
||||
/// - Intento 2: 2 segundos de delay
|
||||
/// - Intento 3: 4 segundos de delay
|
||||
static let baseRetryDelay: TimeInterval = 1.0
|
||||
|
||||
// MARK: - Cache Configuration
|
||||
|
||||
/// Número máximo de respuestas de API a cachear en memoria
|
||||
///
|
||||
/// # Valor por Defecto
|
||||
/// - `100` respuestas cacheadas
|
||||
///
|
||||
/// # Notas
|
||||
/// - Cachear ayuda a reducir requests de red y mejorar performance
|
||||
/// - La caché se limpia automáticamente cuando se detecta presión de memoria
|
||||
/// - Valores más grandes pueden aumentar el uso de memoria
|
||||
static let cacheMaxMemoryUsage = 100
|
||||
|
||||
/// Tiempo de expiración de caché para respuestas de API en segundos
|
||||
///
|
||||
/// # Valor por Defecto
|
||||
/// - `300.0` segundos (5 minutos)
|
||||
///
|
||||
/// # Uso
|
||||
/// - Datos cacheados más viejos que esto se refrescarán del servidor
|
||||
/// - Configurar en `0` para deshabilitar caché
|
||||
/// - Aumentar para datos que cambian infrecuentemente
|
||||
static let cacheExpiryTime: TimeInterval = 300.0
|
||||
|
||||
// MARK: - Logging Configuration
|
||||
|
||||
/// Habilitar logging de requests para debugging
|
||||
///
|
||||
/// # Valor por Defecto
|
||||
/// - `false` (deshabilitado en producción)
|
||||
///
|
||||
/// # Comportamiento
|
||||
/// - Cuando es `true`, todas las requests y respuestas se loguean a consola
|
||||
/// - Útil para desarrollo y debugging
|
||||
/// - Debe ser `false` en builds de producción por seguridad
|
||||
///
|
||||
/// # Recomendación
|
||||
/// Usar configuraciones de build para habilitar solo en debug:
|
||||
/// ```swift
|
||||
/// #if DEBUG
|
||||
/// static let loggingEnabled = true
|
||||
/// #else
|
||||
/// static let loggingEnabled = false
|
||||
/// #endif
|
||||
/// ```
|
||||
static let loggingEnabled = false
|
||||
|
||||
// MARK: - Helper Methods
|
||||
|
||||
/// Construye una URL completa para un endpoint dado
|
||||
///
|
||||
/// - Parameter endpoint: El path del endpoint de la API (ej: "/manga/popular")
|
||||
/// - Returns: Una URL completa combinando la base URL y el endpoint
|
||||
///
|
||||
/// # Ejemplo
|
||||
/// ```swift
|
||||
/// let url = APIConfig.url(for: "/manga/popular")
|
||||
/// // Retorna: "https://gitea.cbcren.online:3001/api/v1/manga/popular"
|
||||
/// ```
|
||||
static func url(for endpoint: String) -> String {
|
||||
// Remover slash inicial si está presente para evitar dobles slashes
|
||||
let cleanEndpoint = endpoint.hasPrefix("/") ? String(endpoint.dropFirst()) : endpoint
|
||||
|
||||
// Añadir prefix de API si no está ya incluido
|
||||
if cleanEndpoint.hasPrefix("api/") {
|
||||
return baseURL + "/" + cleanEndpoint
|
||||
} else {
|
||||
return baseURL + "/" + cleanEndpoint
|
||||
}
|
||||
}
|
||||
|
||||
/// Crea un objeto URL para un endpoint dado
|
||||
///
|
||||
/// - Parameter endpoint: El path del endpoint de la API
|
||||
/// - Returns: Un objeto URL, o nil si el string es inválido
|
||||
///
|
||||
/// # Ejemplo
|
||||
/// ```swift
|
||||
/// if let url = APIConfig.urlObject(for: "/manga/popular") {
|
||||
/// var request = URLRequest(url: url)
|
||||
/// // Hacer request...
|
||||
/// }
|
||||
/// ```
|
||||
static func urlObject(for endpoint: String) -> URL? {
|
||||
return URL(string: url(for: endpoint))
|
||||
}
|
||||
|
||||
/// Retorna el timeout a usar para un tipo específico de request
|
||||
///
|
||||
/// - Parameter isResourceRequest: Si esta es una request intensiva de recursos (ej: descargar imágenes)
|
||||
/// - Returns: El valor de timeout apropiado
|
||||
///
|
||||
/// # Ejemplo
|
||||
/// ```swift
|
||||
/// let timeout = APIConfig.timeoutFor(isResourceRequest: true)
|
||||
/// // Retorna: 300.0 (downloadTimeout)
|
||||
/// ```
|
||||
static func timeoutFor(isResourceRequest: Bool = false) -> TimeInterval {
|
||||
return isResourceRequest ? downloadTimeout : defaultTimeout
|
||||
}
|
||||
|
||||
// MARK: - Validation
|
||||
|
||||
/// Valida que la configuración actual esté correctamente configurada
|
||||
///
|
||||
/// - Returns: `true` si la configuración parece válida, `false` en caso contrario
|
||||
///
|
||||
/// # Verificaciones Realizadas
|
||||
/// - URL del servidor no está vacía
|
||||
/// - Puerto está en rango válido (1-65535)
|
||||
/// - Valores de timeout son positivos
|
||||
/// - Cantidad de reintentos es no-negativa
|
||||
///
|
||||
/// # Uso
|
||||
/// Llamar durante el inicio de la app para asegurar configuración válida:
|
||||
/// ```swift
|
||||
/// assert(APIConfig.isValid, "Configuración de API inválida")
|
||||
/// ```
|
||||
static var isValid: Bool {
|
||||
// Verificar URL del servidor
|
||||
guard !serverURL.isEmpty else { return false }
|
||||
|
||||
// Verificar rango de puerto
|
||||
guard (1...65535).contains(port) else { return false }
|
||||
|
||||
// Verificar timeouts
|
||||
guard defaultTimeout > 0 && downloadTimeout > 0 else { return false }
|
||||
|
||||
// Verificar cantidad de reintentos
|
||||
guard maxRetries >= 0 else { return false }
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - Endpoints
|
||||
|
||||
/// Estructura que contiene todos los endpoints de la API
|
||||
enum Endpoints {
|
||||
/// Endpoint para solicitar la descarga de un capítulo al VPS
|
||||
///
|
||||
/// El backend iniciará el proceso de descarga de las imágenes
|
||||
/// y las almacenará en el VPS.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - mangaSlug: Slug del manga a descargar
|
||||
/// - chapterNumber: Número del capítulo
|
||||
/// - Returns: URL completa del endpoint
|
||||
static func download(mangaSlug: String, chapterNumber: Int) -> String {
|
||||
return "\(basePath)/download/\(mangaSlug)/\(chapterNumber)"
|
||||
}
|
||||
|
||||
/// Endpoint para verificar si un capítulo está descargado en el VPS
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - mangaSlug: Slug del manga
|
||||
/// - chapterNumber: Número del capítulo
|
||||
/// - Returns: URL completa del endpoint
|
||||
static func checkDownloaded(mangaSlug: String, chapterNumber: Int) -> String {
|
||||
return "\(basePath)/chapters/\(mangaSlug)/\(chapterNumber)"
|
||||
}
|
||||
|
||||
/// Endpoint para listar todos los capítulos descargados de un manga
|
||||
///
|
||||
/// - Parameter mangaSlug: Slug del manga
|
||||
/// - Returns: URL completa del endpoint
|
||||
static func listChapters(mangaSlug: String) -> String {
|
||||
return "\(basePath)/chapters/\(mangaSlug)"
|
||||
}
|
||||
|
||||
/// Endpoint para obtener la URL de una imagen específica de un capítulo
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - mangaSlug: Slug del manga
|
||||
/// - chapterNumber: Número del capítulo
|
||||
/// - pageIndex: Índice de la página (0-based)
|
||||
/// - Returns: URL completa del endpoint
|
||||
static func getImage(mangaSlug: String, chapterNumber: Int, pageIndex: Int) -> String {
|
||||
return "\(basePath)/images/\(mangaSlug)/\(chapterNumber)/\(pageIndex)"
|
||||
}
|
||||
|
||||
/// Endpoint para eliminar un capítulo del VPS
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - mangaSlug: Slug del manga
|
||||
/// - chapterNumber: Número del capítulo
|
||||
/// - Returns: URL completa del endpoint
|
||||
static func deleteChapter(mangaSlug: String, chapterNumber: Int) -> String {
|
||||
return "\(basePath)/chapters/\(mangaSlug)/\(chapterNumber)"
|
||||
}
|
||||
|
||||
/// Endpoint para obtener estadísticas de almacenamiento del VPS
|
||||
///
|
||||
/// - Returns: URL completa del endpoint
|
||||
static func storageStats() -> String {
|
||||
return "\(basePath)/storage/stats"
|
||||
}
|
||||
|
||||
/// Endpoint para hacer ping al servidor (health check)
|
||||
///
|
||||
/// - Returns: URL completa del endpoint
|
||||
static func health() -> String {
|
||||
return "\(basePath)/health"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Error Codes
|
||||
|
||||
/// Códigos de error específicos de la API
|
||||
enum ErrorCodes {
|
||||
static let chapterNotFound = 40401
|
||||
static let chapterAlreadyDownloaded = 40901
|
||||
static let storageLimitExceeded = 50701
|
||||
static let invalidImageFormat = 42201
|
||||
static let downloadFailed = 50001
|
||||
}
|
||||
|
||||
// MARK: - Environment Configuration
|
||||
|
||||
/// Configuración para entorno de desarrollo
|
||||
///
|
||||
/// # Uso
|
||||
/// Para usar configuración de desarrollo, modificar en builds de desarrollo:
|
||||
/// ```swift
|
||||
/// #if DEBUG
|
||||
/// static let serverURL = "http://192.168.1.100"
|
||||
/// #else
|
||||
/// static let serverURL = "https://gitea.cbcren.online"
|
||||
/// #endif
|
||||
/// ```
|
||||
static var development: (serverURL: String, port: Int, timeout: TimeInterval, logging: Bool) {
|
||||
return (
|
||||
serverURL: "http://192.168.1.100",
|
||||
port: 3001,
|
||||
timeout: 60.0,
|
||||
logging: true
|
||||
)
|
||||
}
|
||||
|
||||
/// Configuración para entorno de staging
|
||||
static var staging: (serverURL: String, port: Int, timeout: TimeInterval, logging: Bool) {
|
||||
return (
|
||||
serverURL: "https://staging.cbcren.online",
|
||||
port: 3001,
|
||||
timeout: 30.0,
|
||||
logging: true
|
||||
)
|
||||
}
|
||||
|
||||
/// Configuración para entorno de producción
|
||||
static var production: (serverURL: String, port: Int, timeout: TimeInterval, logging: Bool) {
|
||||
return (
|
||||
serverURL: "https://gitea.cbcren.online",
|
||||
port: 3001,
|
||||
timeout: 30.0,
|
||||
logging: false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Debug Support
|
||||
|
||||
#if DEBUG
|
||||
extension APIConfig {
|
||||
/// Configuración de test para unit testing
|
||||
///
|
||||
/// # Uso
|
||||
/// Usar esta configuración en unit tests para evitar hacer llamadas reales a la API:
|
||||
/// ```swift
|
||||
/// func testAPICall() {
|
||||
/// let testConfig = APIConfig.testing
|
||||
/// // Usar URL de servidor mock
|
||||
/// }
|
||||
/// ```
|
||||
static var testing: (serverURL: String, port: Int, timeout: TimeInterval, logging: Bool) {
|
||||
return (
|
||||
serverURL: "http://localhost:3001",
|
||||
port: 3001,
|
||||
timeout: 5.0,
|
||||
logging: true
|
||||
)
|
||||
}
|
||||
|
||||
/// Imprime la configuración actual a consola (solo debug)
|
||||
static func printConfiguration() {
|
||||
print("=== API Configuration ===")
|
||||
print("Server URL: \(serverURL)")
|
||||
print("Port: \(port)")
|
||||
print("Base URL: \(baseURL)")
|
||||
print("API Version: \(apiVersion)")
|
||||
print("Default Timeout: \(defaultTimeout)s")
|
||||
print("Download Timeout: \(downloadTimeout)s")
|
||||
print("Max Retries: \(maxRetries)")
|
||||
print("Logging Enabled: \(loggingEnabled)")
|
||||
print("Cache Enabled: \(cacheExpiryTime > 0)")
|
||||
print("=========================")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
290
ios-app/Sources/Config/APIConfigExample.swift
Normal file
290
ios-app/Sources/Config/APIConfigExample.swift
Normal file
@@ -0,0 +1,290 @@
|
||||
import Foundation
|
||||
|
||||
/// Ejemplos de uso de APIConfig
|
||||
///
|
||||
/// Este archivo demuestra cómo utilizar la configuración de la API
|
||||
/// en diferentes escenarios de la aplicación.
|
||||
class APIConfigExample {
|
||||
|
||||
/// Ejemplo 1: Configurar URLSession con timeouts de APIConfig
|
||||
func configureURLSession() -> URLSession {
|
||||
let configuration = URLSessionConfiguration.default
|
||||
configuration.timeoutIntervalForRequest = APIConfig.defaultTimeout
|
||||
configuration.timeoutIntervalForResource = APIConfig.downloadTimeout
|
||||
return URLSession(configuration: configuration)
|
||||
}
|
||||
|
||||
/// Ejemplo 2: Construir una URL completa para un endpoint
|
||||
func buildEndpointURL() {
|
||||
// Método 1: Usar la función helper
|
||||
let url1 = APIConfig.url(for: "manga/popular")
|
||||
print("URL completa: \(url1)")
|
||||
|
||||
// Método 2: Usar urlObject para obtener un objeto URL
|
||||
if let url2 = APIConfig.urlObject(for: "manga/popular") {
|
||||
print("URL object: \(url2)")
|
||||
}
|
||||
|
||||
// Método 3: Usar directamente baseURL
|
||||
let url3 = "\(APIConfig.basePath)/manga/popular"
|
||||
print("URL manual: \(url3)")
|
||||
}
|
||||
|
||||
/// Ejemplo 3: Usar los endpoints predefinidos
|
||||
func usePredefinedEndpoints() {
|
||||
// Endpoint de descarga
|
||||
let downloadURL = APIConfig.Endpoints.download(
|
||||
mangaSlug: "one-piece",
|
||||
chapterNumber: 1089
|
||||
)
|
||||
print("Download endpoint: \(downloadURL)")
|
||||
|
||||
// Endpoint de verificación
|
||||
let checkURL = APIConfig.Endpoints.checkDownloaded(
|
||||
mangaSlug: "one-piece",
|
||||
chapterNumber: 1089
|
||||
)
|
||||
print("Check endpoint: \(checkURL)")
|
||||
|
||||
// Endpoint de imagen
|
||||
let imageURL = APIConfig.Endpoints.getImage(
|
||||
mangaSlug: "one-piece",
|
||||
chapterNumber: 1089,
|
||||
pageIndex: 0
|
||||
)
|
||||
print("Image endpoint: \(imageURL)")
|
||||
|
||||
// Endpoint de health check
|
||||
let healthURL = APIConfig.Endpoints.health()
|
||||
print("Health endpoint: \(healthURL)")
|
||||
|
||||
// Endpoint de estadísticas de almacenamiento
|
||||
let statsURL = APIConfig.Endpoints.storageStats()
|
||||
print("Storage stats endpoint: \(statsURL)")
|
||||
}
|
||||
|
||||
/// Ejemplo 4: Crear una URLRequest con headers comunes
|
||||
func createRequest() -> URLRequest? {
|
||||
let endpoint = "manga/popular"
|
||||
|
||||
guard let url = APIConfig.urlObject(for: endpoint) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.timeoutInterval = APIConfig.defaultTimeout
|
||||
|
||||
// Añadir headers comunes
|
||||
for (key, value) in APIConfig.commonHeaders {
|
||||
request.setValue(value, forHTTPHeaderField: key)
|
||||
}
|
||||
|
||||
// Si se requiere autenticación
|
||||
// let token = "your-auth-token"
|
||||
// let authHeaders = APIConfig.authHeader(token: token)
|
||||
// for (key, value) in authHeaders {
|
||||
// request.setValue(value, forHTTPHeaderField: key)
|
||||
// }
|
||||
|
||||
return request
|
||||
}
|
||||
|
||||
/// Ejemplo 5: Validar la configuración al iniciar la app
|
||||
func validateConfiguration() {
|
||||
#if DEBUG
|
||||
// Imprimir configuración en debug
|
||||
APIConfig.printConfiguration()
|
||||
#endif
|
||||
|
||||
// Validar que la configuración sea correcta
|
||||
guard APIConfig.isValid else {
|
||||
print("ERROR: Configuración de API inválida")
|
||||
return
|
||||
}
|
||||
|
||||
print("Configuración válida: \(APIConfig.baseURL)")
|
||||
}
|
||||
|
||||
/// Ejemplo 6: Hacer una request simple
|
||||
func makeSimpleRequest() async throws {
|
||||
let endpoint = "manga/popular"
|
||||
guard let url = APIConfig.urlObject(for: endpoint) else {
|
||||
print("URL inválida")
|
||||
return
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.timeoutInterval = APIConfig.timeoutFor(isResourceRequest: false)
|
||||
|
||||
for (key, value) in APIConfig.commonHeaders {
|
||||
request.setValue(value, forHTTPHeaderField: key)
|
||||
}
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
|
||||
if let httpResponse = response as? HTTPURLResponse {
|
||||
print("Status code: \(httpResponse.statusCode)")
|
||||
}
|
||||
|
||||
// Procesar data...
|
||||
print("Recibidos \(data.count) bytes")
|
||||
}
|
||||
|
||||
/// Ejemplo 7: Usar timeouts apropiados según el tipo de request
|
||||
func demonstrateTimeouts() {
|
||||
// Request normal (usar defaultTimeout)
|
||||
let normalTimeout = APIConfig.timeoutFor(isResourceRequest: false)
|
||||
print("Normal timeout: \(normalTimeout)s") // 30.0s
|
||||
|
||||
// Request de descarga de imagen (usar downloadTimeout)
|
||||
let resourceTimeout = APIConfig.timeoutFor(isResourceRequest: true)
|
||||
print("Resource timeout: \(resourceTimeout)s") // 300.0s
|
||||
}
|
||||
|
||||
/// Ejemplo 8: Cambiar configuración según el entorno
|
||||
func configureForEnvironment() {
|
||||
#if DEBUG
|
||||
// En desarrollo, usar configuración local
|
||||
print("Modo desarrollo")
|
||||
// Nota: Para cambiar realmente la configuración, modificar las propiedades
|
||||
// estáticas en APIConfig usando compilación condicional
|
||||
#else
|
||||
// En producción, usar configuración de producción
|
||||
print("Modo producción")
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Ejemplo 9: Manejar errores específicos de la API
|
||||
func handleAPIError(errorCode: Int) {
|
||||
switch errorCode {
|
||||
case APIConfig.ErrorCodes.chapterNotFound:
|
||||
print("Capítulo no encontrado")
|
||||
case APIConfig.ErrorCodes.chapterAlreadyDownloaded:
|
||||
print("Capítulo ya descargado")
|
||||
case APIConfig.ErrorCodes.storageLimitExceeded:
|
||||
print("Límite de almacenamiento excedido")
|
||||
case APIConfig.ErrorCodes.invalidImageFormat:
|
||||
print("Formato de imagen inválido")
|
||||
case APIConfig.ErrorCodes.downloadFailed:
|
||||
print("Descarga fallida")
|
||||
default:
|
||||
print("Error desconocido: \(errorCode)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Ejemplo 10: Implementar retry con backoff exponencial
|
||||
func fetchWithRetry(endpoint: String, retryCount: Int = 0) async throws -> Data {
|
||||
guard let url = APIConfig.urlObject(for: endpoint) else {
|
||||
throw URLError(.badURL)
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.timeoutInterval = APIConfig.defaultTimeout
|
||||
|
||||
do {
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
|
||||
if let httpResponse = response as? HTTPURLResponse,
|
||||
httpResponse.statusCode == 200 {
|
||||
return data
|
||||
} else {
|
||||
throw URLError(.badServerResponse)
|
||||
}
|
||||
} catch {
|
||||
// Verificar si debemos reintentar
|
||||
if retryCount < APIConfig.maxRetries {
|
||||
// Calcular delay con backoff exponencial
|
||||
let delay = APIConfig.baseRetryDelay * pow(2.0, Double(retryCount))
|
||||
print("Retry \(retryCount + 1) después de \(delay)s")
|
||||
|
||||
try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
|
||||
|
||||
return try await fetchWithRetry(endpoint: endpoint, retryCount: retryCount + 1)
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Usage Examples
|
||||
|
||||
// Ejemplo de uso en una ViewModel o Service:
|
||||
class MangaServiceExample {
|
||||
|
||||
func fetchPopularManga() async throws {
|
||||
// Usar endpoint predefinido
|
||||
let endpoint = "manga/popular"
|
||||
guard let url = APIConfig.urlObject(for: endpoint) else {
|
||||
return
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.timeoutInterval = APIConfig.defaultTimeout
|
||||
|
||||
// Añadir headers
|
||||
for (key, value) in APIConfig.commonHeaders {
|
||||
request.setValue(value, forHTTPHeaderField: key)
|
||||
}
|
||||
|
||||
// Hacer request
|
||||
let (data, _) = try await URLSession.shared.data(for: request)
|
||||
|
||||
// Parsear respuesta...
|
||||
print("Datos recibidos: \(data.count) bytes")
|
||||
}
|
||||
|
||||
func downloadChapter(mangaSlug: String, chapterNumber: Int) async throws {
|
||||
// Usar endpoint predefinido
|
||||
let endpoint = APIConfig.Endpoints.download(
|
||||
mangaSlug: mangaSlug,
|
||||
chapterNumber: chapterNumber
|
||||
)
|
||||
|
||||
guard let url = URL(string: endpoint) else {
|
||||
return
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
// Usar timeout más largo para descargas
|
||||
request.timeoutInterval = APIConfig.downloadTimeout
|
||||
|
||||
for (key, value) in APIConfig.commonHeaders {
|
||||
request.setValue(value, forHTTPHeaderField: key)
|
||||
}
|
||||
|
||||
// Hacer request
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
|
||||
if let httpResponse = response as? HTTPURLResponse {
|
||||
print("Status: \(httpResponse.statusCode)")
|
||||
|
||||
// Manejar errores específicos
|
||||
if httpResponse.statusCode != 200 {
|
||||
// Aquí podrías usar APIConfig.ErrorCodes si el backend
|
||||
// retorna códigos de error personalizados
|
||||
throw URLError(.badServerResponse)
|
||||
}
|
||||
}
|
||||
|
||||
print("Descarga completada: \(data.count) bytes")
|
||||
}
|
||||
|
||||
func checkServerHealth() async throws {
|
||||
// Usar endpoint de health check
|
||||
let endpoint = APIConfig.Endpoints.health()
|
||||
|
||||
guard let url = URL(string: endpoint) else {
|
||||
return
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.timeoutInterval = APIConfig.defaultTimeout
|
||||
|
||||
let (_, response) = try await URLSession.shared.data(for: request)
|
||||
|
||||
if let httpResponse = response as? HTTPURLResponse {
|
||||
print("Server health status: \(httpResponse.statusCode)")
|
||||
}
|
||||
}
|
||||
}
|
||||
352
ios-app/Sources/Config/README.md
Normal file
352
ios-app/Sources/Config/README.md
Normal file
@@ -0,0 +1,352 @@
|
||||
# API Configuration for MangaReader iOS App
|
||||
|
||||
## Overview
|
||||
|
||||
This directory contains the API configuration for connecting the iOS app to the VPS backend. The configuration is centralized in `APIConfig.swift` and includes all necessary settings for API communication.
|
||||
|
||||
## Files
|
||||
|
||||
- **APIConfig.swift**: Main configuration file with all API settings, endpoints, and helper methods
|
||||
- **APIConfigExample.swift**: Comprehensive usage examples and demonstrations
|
||||
- **README.md** (this file): Documentation and usage guide
|
||||
|
||||
## Current Configuration
|
||||
|
||||
### Server Connection
|
||||
- **Server URL**: `https://gitea.cbcren.online`
|
||||
- **Port**: `3001`
|
||||
- **Full Base URL**: `https://gitea.cbcren.online:3001`
|
||||
- **API Version**: `v1`
|
||||
- **API Base Path**: `https://gitea.cbcren.online:3001/api/v1`
|
||||
|
||||
### Timeouts
|
||||
- **Default Request Timeout**: `30.0` seconds (for regular API calls)
|
||||
- **Resource Download Timeout**: `300.0` seconds (5 minutes, for large downloads)
|
||||
|
||||
### Retry Policy
|
||||
- **Max Retries**: `3` attempts
|
||||
- **Base Retry Delay**: `1.0` second (with exponential backoff)
|
||||
|
||||
### Cache Configuration
|
||||
- **Max Memory Usage**: `100` cached responses
|
||||
- **Cache Expiry**: `300.0` seconds (5 minutes)
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic URL Construction
|
||||
|
||||
```swift
|
||||
// Method 1: Use the helper function
|
||||
let url = APIConfig.url(for: "manga/popular")
|
||||
// Result: "https://gitea.cbcren.online:3001/manga/popular"
|
||||
|
||||
// Method 2: Get a URL object
|
||||
if let urlObj = APIConfig.urlObject(for: "manga/popular") {
|
||||
var request = URLRequest(url: urlObj)
|
||||
// Make request...
|
||||
}
|
||||
|
||||
// Method 3: Use predefined endpoints
|
||||
let endpoint = APIConfig.Endpoints.download(mangaSlug: "one-piece", chapterNumber: 1089)
|
||||
// Result: "https://gitea.cbcren.online:3001/api/v1/download/one-piece/1089"
|
||||
```
|
||||
|
||||
### URLSession Configuration
|
||||
|
||||
```swift
|
||||
let configuration = URLSessionConfiguration.default
|
||||
configuration.timeoutIntervalForRequest = APIConfig.defaultTimeout
|
||||
configuration.timeoutIntervalForResource = APIConfig.downloadTimeout
|
||||
let session = URLSession(configuration: configuration)
|
||||
```
|
||||
|
||||
### URLRequest with Headers
|
||||
|
||||
```swift
|
||||
var request = URLRequest(url: url)
|
||||
request.timeoutInterval = APIConfig.defaultTimeout
|
||||
|
||||
// Add common headers
|
||||
for (key, value) in APIConfig.commonHeaders {
|
||||
request.setValue(value, forHTTPHeaderField: key)
|
||||
}
|
||||
|
||||
// Add authentication if needed
|
||||
if let token = authToken {
|
||||
let authHeaders = APIConfig.authHeader(token: token)
|
||||
for (key, value) in authHeaders {
|
||||
request.setValue(value, forHTTPHeaderField: key)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Available Endpoints
|
||||
|
||||
### Download Endpoints
|
||||
|
||||
```swift
|
||||
// Request chapter download
|
||||
APIConfig.Endpoints.download(mangaSlug: "one-piece", chapterNumber: 1089)
|
||||
|
||||
// Check if chapter is downloaded
|
||||
APIConfig.Endpoints.checkDownloaded(mangaSlug: "one-piece", chapterNumber: 1089)
|
||||
|
||||
// List all downloaded chapters for a manga
|
||||
APIConfig.Endpoints.listChapters(mangaSlug: "one-piece")
|
||||
|
||||
// Get specific image from chapter
|
||||
APIConfig.Endpoints.getImage(mangaSlug: "one-piece", chapterNumber: 1089, pageIndex: 0)
|
||||
|
||||
// Delete a chapter
|
||||
APIConfig.Endpoints.deleteChapter(mangaSlug: "one-piece", chapterNumber: 1089)
|
||||
```
|
||||
|
||||
### Server Endpoints
|
||||
|
||||
```swift
|
||||
// Get storage statistics
|
||||
APIConfig.Endpoints.storageStats()
|
||||
|
||||
// Health check
|
||||
APIConfig.Endpoints.health()
|
||||
```
|
||||
|
||||
## Environment Configuration
|
||||
|
||||
The configuration includes presets for different environments:
|
||||
|
||||
### Development
|
||||
```swift
|
||||
APIConfig.development
|
||||
// - serverURL: "http://192.168.1.100"
|
||||
// - port: 3001
|
||||
// - timeout: 60.0s
|
||||
// - logging: true
|
||||
```
|
||||
|
||||
### Staging
|
||||
```swift
|
||||
APIConfig.staging
|
||||
// - serverURL: "https://staging.cbcren.online"
|
||||
// - port: 3001
|
||||
// - timeout: 30.0s
|
||||
// - logging: true
|
||||
```
|
||||
|
||||
### Production (Current)
|
||||
```swift
|
||||
APIConfig.production
|
||||
// - serverURL: "https://gitea.cbcren.online"
|
||||
// - port: 3001
|
||||
// - timeout: 30.0s
|
||||
// - logging: false
|
||||
```
|
||||
|
||||
### Testing (Debug Only)
|
||||
```swift
|
||||
#if DEBUG
|
||||
APIConfig.testing
|
||||
// - serverURL: "http://localhost:3001"
|
||||
// - port: 3001
|
||||
// - timeout: 5.0s
|
||||
// - logging: true
|
||||
#endif
|
||||
```
|
||||
|
||||
## Changing the Server URL
|
||||
|
||||
To change the API server URL, modify the `serverURL` property in `APIConfig.swift`:
|
||||
|
||||
```swift
|
||||
// In APIConfig.swift, line 37
|
||||
static let serverURL = "https://gitea.cbcren.online" // Change this
|
||||
```
|
||||
|
||||
For environment-specific URLs, use compile-time conditionals:
|
||||
|
||||
```swift
|
||||
#if DEBUG
|
||||
static let serverURL = "http://192.168.1.100" // Local development
|
||||
#else
|
||||
static let serverURL = "https://gitea.cbcren.online" // Production
|
||||
#endif
|
||||
```
|
||||
|
||||
## Error Codes
|
||||
|
||||
The API defines specific error codes for different scenarios:
|
||||
|
||||
```swift
|
||||
APIConfig.ErrorCodes.chapterNotFound // 40401
|
||||
APIConfig.ErrorCodes.chapterAlreadyDownloaded // 40901
|
||||
APIConfig.ErrorCodes.storageLimitExceeded // 50701
|
||||
APIConfig.ErrorCodes.invalidImageFormat // 42201
|
||||
APIConfig.ErrorCodes.downloadFailed // 50001
|
||||
```
|
||||
|
||||
## Validation
|
||||
|
||||
The configuration includes a validation method:
|
||||
|
||||
```swift
|
||||
if APIConfig.isValid {
|
||||
print("Configuration is valid")
|
||||
} else {
|
||||
print("Configuration is invalid")
|
||||
}
|
||||
```
|
||||
|
||||
This checks:
|
||||
- Server URL is not empty
|
||||
- Port is in valid range (1-65535)
|
||||
- Timeout values are positive
|
||||
- Retry count is non-negative
|
||||
|
||||
## Debug Support
|
||||
|
||||
In debug builds, you can print the current configuration:
|
||||
|
||||
```swift
|
||||
#if DEBUG
|
||||
APIConfig.printConfiguration()
|
||||
#endif
|
||||
```
|
||||
|
||||
This outputs:
|
||||
```
|
||||
=== API Configuration ===
|
||||
Server URL: https://gitea.cbcren.online
|
||||
Port: 3001
|
||||
Base URL: https://gitea.cbcren.online:3001
|
||||
API Version: v1
|
||||
Default Timeout: 30.0s
|
||||
Download Timeout: 300.0s
|
||||
Max Retries: 3
|
||||
Logging Enabled: false
|
||||
Cache Enabled: true
|
||||
=========================
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always use predefined endpoints** when available instead of manually constructing URLs
|
||||
2. **Use appropriate timeouts** - `defaultTimeout` for regular calls, `downloadTimeout` for large downloads
|
||||
3. **Validate configuration** on app startup
|
||||
4. **Use the helper methods** (`url()`, `urlObject()`) for URL construction
|
||||
5. **Include common headers** in all requests
|
||||
6. **Handle specific error codes** defined in `APIConfig.ErrorCodes`
|
||||
7. **Enable logging only in debug builds** for security
|
||||
|
||||
## Example: Making an API Call
|
||||
|
||||
```swift
|
||||
func fetchPopularManga() async throws -> [Manga] {
|
||||
// Construct URL
|
||||
guard let url = APIConfig.urlObject(for: "manga/popular") else {
|
||||
throw APIError.invalidURL
|
||||
}
|
||||
|
||||
// Create request
|
||||
var request = URLRequest(url: url)
|
||||
request.timeoutInterval = APIConfig.defaultTimeout
|
||||
|
||||
// Add headers
|
||||
for (key, value) in APIConfig.commonHeaders {
|
||||
request.setValue(value, forHTTPHeaderField: key)
|
||||
}
|
||||
|
||||
// Make request
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
|
||||
// Validate response
|
||||
guard let httpResponse = response as? HTTPURLResponse,
|
||||
httpResponse.statusCode == 200 else {
|
||||
throw APIError.requestFailed
|
||||
}
|
||||
|
||||
// Decode response
|
||||
let mangas = try JSONDecoder().decode([Manga].self, from: data)
|
||||
return mangas
|
||||
}
|
||||
```
|
||||
|
||||
## Example: Downloading with Retry
|
||||
|
||||
```swift
|
||||
func downloadChapterWithRetry(
|
||||
mangaSlug: String,
|
||||
chapterNumber: Int
|
||||
) async throws -> Data {
|
||||
let endpoint = APIConfig.Endpoints.download(
|
||||
mangaSlug: mangaSlug,
|
||||
chapterNumber: chapterNumber
|
||||
)
|
||||
|
||||
return try await fetchWithRetry(endpoint: endpoint, retryCount: 0)
|
||||
}
|
||||
|
||||
func fetchWithRetry(endpoint: String, retryCount: Int) async throws -> Data {
|
||||
guard let url = URL(string: endpoint),
|
||||
retryCount < APIConfig.maxRetries else {
|
||||
throw APIError.retryLimitExceeded
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.timeoutInterval = APIConfig.downloadTimeout
|
||||
|
||||
do {
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
|
||||
if let httpResponse = response as? HTTPURLResponse,
|
||||
httpResponse.statusCode == 200 {
|
||||
return data
|
||||
} else {
|
||||
throw APIError.requestFailed
|
||||
}
|
||||
} catch {
|
||||
// Calculate exponential backoff delay
|
||||
let delay = APIConfig.baseRetryDelay * pow(2.0, Double(retryCount))
|
||||
try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
|
||||
|
||||
return try await fetchWithRetry(endpoint: endpoint, retryCount: retryCount + 1)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Connection Issues
|
||||
|
||||
1. **Verify server URL**: Check that `serverURL` is correct and accessible
|
||||
2. **Check port**: Ensure `port` matches the backend server configuration
|
||||
3. **Test connectivity**: Use the health endpoint: `APIConfig.Endpoints.health()`
|
||||
4. **Enable logging**: Set `loggingEnabled = true` to see request details
|
||||
|
||||
### Timeout Issues
|
||||
|
||||
1. **For regular API calls**: Use `APIConfig.defaultTimeout` (30 seconds)
|
||||
2. **For large downloads**: Use `APIConfig.downloadTimeout` (300 seconds)
|
||||
3. **Slow networks**: Increase timeout values if needed
|
||||
|
||||
### SSL Certificate Issues
|
||||
|
||||
If using HTTPS with a self-signed certificate:
|
||||
1. Add the certificate to the app's bundle
|
||||
2. Configure URLSession to trust the certificate
|
||||
3. Or use HTTP for development (not recommended for production)
|
||||
|
||||
## Migration Notes
|
||||
|
||||
When migrating from the old configuration:
|
||||
|
||||
1. Replace hardcoded URLs with `APIConfig.url(for:)` or predefined endpoints
|
||||
2. Use `APIConfig.commonHeaders` instead of manually setting headers
|
||||
3. Replace hardcoded timeouts with `APIConfig.defaultTimeout` or `APIConfig.downloadTimeout`
|
||||
4. Add validation on app startup with `APIConfig.isValid`
|
||||
5. Use specific error codes from `APIConfig.ErrorCodes`
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- See `APIConfigExample.swift` for more comprehensive examples
|
||||
- Check the backend API documentation for available endpoints
|
||||
- Review the iOS app's Services directory for integration examples
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ struct MangaDetailView: View {
|
||||
let manga: Manga
|
||||
@StateObject private var viewModel: MangaDetailViewModel
|
||||
@StateObject private var storage = StorageService.shared
|
||||
@StateObject private var vpsClient = VPSAPIClient.shared
|
||||
|
||||
init(manga: Manga) {
|
||||
self.manga = manga
|
||||
@@ -35,6 +36,14 @@ struct MangaDetailView: View {
|
||||
.foregroundColor(viewModel.isFavorite ? .red : .primary)
|
||||
}
|
||||
|
||||
// VPS Download Button
|
||||
Button {
|
||||
viewModel.showingVPSDownloadAll = true
|
||||
} label: {
|
||||
Image(systemName: "icloud.and.arrow.down")
|
||||
}
|
||||
.disabled(viewModel.chapters.isEmpty)
|
||||
|
||||
Button {
|
||||
viewModel.showingDownloadAll = true
|
||||
} label: {
|
||||
@@ -53,7 +62,22 @@ struct MangaDetailView: View {
|
||||
viewModel.downloadAllChapters()
|
||||
}
|
||||
} message: {
|
||||
Text("¿Cuántos capítulos quieres descargar?")
|
||||
Text("¿Cuántos capítulos quieres descargar localmente?")
|
||||
}
|
||||
.alert("Descargar a VPS", isPresented: $viewModel.showingVPSDownloadAll) {
|
||||
Button("Cancelar", role: .cancel) { }
|
||||
Button("Últimos 10 a VPS") {
|
||||
Task {
|
||||
await viewModel.downloadLastChaptersToVPS(count: 10)
|
||||
}
|
||||
}
|
||||
Button("Todos a VPS") {
|
||||
Task {
|
||||
await viewModel.downloadAllChaptersToVPS()
|
||||
}
|
||||
}
|
||||
} message: {
|
||||
Text("¿Cuántos capítulos quieres descargar al servidor VPS?")
|
||||
}
|
||||
.task {
|
||||
await viewModel.loadChapters()
|
||||
@@ -192,6 +216,9 @@ struct MangaDetailView: View {
|
||||
},
|
||||
onDownloadToggle: {
|
||||
await viewModel.downloadChapter(chapter)
|
||||
},
|
||||
onVPSDownloadToggle: {
|
||||
await viewModel.downloadChapterToVPS(chapter)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -219,10 +246,14 @@ struct ChapterRowView: View {
|
||||
let mangaSlug: String
|
||||
let onTap: () -> Void
|
||||
let onDownloadToggle: () async -> Void
|
||||
let onVPSDownloadToggle: () async -> Void
|
||||
@StateObject private var storage = StorageService.shared
|
||||
@ObservedObject private var downloadManager = DownloadManager.shared
|
||||
@ObservedObject var vpsClient = VPSAPIClient.shared
|
||||
|
||||
@State private var isDownloading = false
|
||||
@State private var isVPSDownloaded = false
|
||||
@State private var isVPSChecked = false
|
||||
|
||||
var body: some View {
|
||||
Button(action: onTap) {
|
||||
@@ -241,7 +272,7 @@ struct ChapterRowView: View {
|
||||
.progressViewStyle(.linear)
|
||||
}
|
||||
|
||||
// Mostrar progreso de descarga
|
||||
// Mostrar progreso de descarga local
|
||||
if let downloadTask = currentDownloadTask {
|
||||
HStack {
|
||||
ProgressView(value: downloadTask.progress)
|
||||
@@ -253,11 +284,46 @@ struct ChapterRowView: View {
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
// Mostrar progreso de descarga VPS
|
||||
if vpsClient.activeDownloads.contains("\(mangaSlug)-\(chapter.number)"), let progress = vpsClient.downloadProgress["\(mangaSlug)-\(chapter.number)"] {
|
||||
HStack {
|
||||
Image(systemName: "icloud.and.arrow.down")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.blue)
|
||||
|
||||
ProgressView(value: progress)
|
||||
.progressViewStyle(.linear)
|
||||
.frame(maxWidth: 100)
|
||||
|
||||
Text("VPS \(Int(progress * 100))%")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Botón de descarga
|
||||
// VPS Download Button / Status
|
||||
if isVPSChecked {
|
||||
if isVPSDownloaded {
|
||||
Image(systemName: "icloud.fill")
|
||||
.foregroundColor(.blue)
|
||||
} else {
|
||||
Button {
|
||||
Task {
|
||||
await onVPSDownloadToggle()
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "icloud.and.arrow.up")
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
// Botón de descarga local
|
||||
if !storage.isChapterDownloaded(mangaSlug: mangaSlug, chapterNumber: chapter.number) {
|
||||
Button {
|
||||
Task {
|
||||
@@ -294,12 +360,33 @@ struct ChapterRowView: View {
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.task {
|
||||
// Check VPS status when row appears
|
||||
if !isVPSChecked {
|
||||
await checkVPSStatus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var currentDownloadTask: DownloadTask? {
|
||||
let taskId = "\(mangaSlug)-\(chapter.number)"
|
||||
return downloadManager.activeDownloads.first { $0.id == taskId }
|
||||
}
|
||||
|
||||
private func checkVPSStatus() async {
|
||||
do {
|
||||
let manifest = try await vpsClient.getChapterManifest(
|
||||
mangaSlug: mangaSlug,
|
||||
chapterNumber: chapter.number
|
||||
)
|
||||
isVPSDownloaded = manifest != nil
|
||||
isVPSChecked = true
|
||||
} catch {
|
||||
// If error, assume not downloaded on VPS
|
||||
isVPSDownloaded = false
|
||||
isVPSChecked = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ViewModel
|
||||
@@ -310,6 +397,7 @@ class MangaDetailViewModel: ObservableObject {
|
||||
@Published var isFavorite: Bool
|
||||
@Published var selectedChapter: Chapter?
|
||||
@Published var showingDownloadAll = false
|
||||
@Published var showingVPSDownloadAll = false
|
||||
@Published var isDownloading = false
|
||||
@Published var downloadProgress: [String: Double] = [:]
|
||||
@Published var showDownloadNotification = false
|
||||
@@ -319,6 +407,7 @@ class MangaDetailViewModel: ObservableObject {
|
||||
private let scraper = ManhwaWebScraper.shared
|
||||
private let storage = StorageService.shared
|
||||
private let downloadManager = DownloadManager.shared
|
||||
private let vpsClient = VPSAPIClient.shared
|
||||
|
||||
init(manga: Manga) {
|
||||
self.manga = manga
|
||||
@@ -426,6 +515,126 @@ class MangaDetailViewModel: ObservableObject {
|
||||
try? await Task.sleep(nanoseconds: 3_000_000_000)
|
||||
showDownloadNotification = false
|
||||
}
|
||||
|
||||
// MARK: - VPS Download Methods
|
||||
|
||||
/// Download a single chapter to VPS
|
||||
func downloadChapterToVPS(_ chapter: Chapter) async {
|
||||
do {
|
||||
// First, get the image URLs for the chapter
|
||||
let imageUrls = try await scraper.scrapeChapterImages(chapterSlug: chapter.slug)
|
||||
|
||||
// Download to VPS
|
||||
let result = try await vpsClient.downloadChapter(
|
||||
mangaSlug: manga.slug,
|
||||
chapterNumber: chapter.number,
|
||||
chapterSlug: chapter.slug,
|
||||
imageUrls: imageUrls
|
||||
)
|
||||
|
||||
if result.success {
|
||||
if result.alreadyDownloaded {
|
||||
notificationMessage = "Capítulo \(chapter.number) ya estaba en VPS"
|
||||
} else {
|
||||
notificationMessage = "Capítulo \(chapter.number) descargado a VPS"
|
||||
}
|
||||
} else {
|
||||
notificationMessage = "Error al descargar capítulo \(chapter.number) a VPS"
|
||||
}
|
||||
|
||||
showDownloadNotification = true
|
||||
|
||||
// Hide notification after 3 seconds
|
||||
try? await Task.sleep(nanoseconds: 3_000_000_000)
|
||||
showDownloadNotification = false
|
||||
} catch {
|
||||
notificationMessage = "Error VPS: \(error.localizedDescription)"
|
||||
showDownloadNotification = true
|
||||
}
|
||||
}
|
||||
|
||||
/// Download all chapters to VPS
|
||||
func downloadAllChaptersToVPS() async {
|
||||
isDownloading = true
|
||||
|
||||
var successCount = 0
|
||||
var failCount = 0
|
||||
|
||||
for chapter in chapters {
|
||||
do {
|
||||
let imageUrls = try await scraper.scrapeChapterImages(chapterSlug: chapter.slug)
|
||||
let result = try await vpsClient.downloadChapter(
|
||||
mangaSlug: manga.slug,
|
||||
chapterNumber: chapter.number,
|
||||
chapterSlug: chapter.slug,
|
||||
imageUrls: imageUrls
|
||||
)
|
||||
|
||||
if result.success {
|
||||
successCount += 1
|
||||
} else {
|
||||
failCount += 1
|
||||
}
|
||||
} catch {
|
||||
failCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
isDownloading = false
|
||||
|
||||
if failCount == 0 {
|
||||
notificationMessage = "\(successCount) capítulos descargados a VPS"
|
||||
} else {
|
||||
notificationMessage = "\(successCount) exitosos, \(failCount) fallidos en VPS"
|
||||
}
|
||||
|
||||
showDownloadNotification = true
|
||||
|
||||
try? await Task.sleep(nanoseconds: 3_000_000_000)
|
||||
showDownloadNotification = false
|
||||
}
|
||||
|
||||
/// Download last N chapters to VPS
|
||||
func downloadLastChaptersToVPS(count: Int) async {
|
||||
let lastChapters = Array(chapters.prefix(count))
|
||||
isDownloading = true
|
||||
|
||||
var successCount = 0
|
||||
var failCount = 0
|
||||
|
||||
for chapter in lastChapters {
|
||||
do {
|
||||
let imageUrls = try await scraper.scrapeChapterImages(chapterSlug: chapter.slug)
|
||||
let result = try await vpsClient.downloadChapter(
|
||||
mangaSlug: manga.slug,
|
||||
chapterNumber: chapter.number,
|
||||
chapterSlug: chapter.slug,
|
||||
imageUrls: imageUrls
|
||||
)
|
||||
|
||||
if result.success {
|
||||
successCount += 1
|
||||
} else {
|
||||
failCount += 1
|
||||
}
|
||||
} catch {
|
||||
failCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
isDownloading = false
|
||||
|
||||
if failCount == 0 {
|
||||
notificationMessage = "\(successCount) capítulos descargados a VPS"
|
||||
} else {
|
||||
notificationMessage = "\(successCount) exitosos, \(failCount) fallidos en VPS"
|
||||
}
|
||||
|
||||
showDownloadNotification = true
|
||||
|
||||
try? await Task.sleep(nanoseconds: 3_000_000_000)
|
||||
showDownloadNotification = false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - FlowLayout
|
||||
|
||||
@@ -4,6 +4,7 @@ struct ReaderView: View {
|
||||
let manga: Manga
|
||||
let chapter: Chapter
|
||||
@StateObject private var viewModel: ReaderViewModel
|
||||
@ObservedObject private var vpsClient = VPSAPIClient.shared
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
||||
init(manga: Manga, chapter: Chapter) {
|
||||
@@ -181,8 +182,14 @@ struct ReaderView: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
if viewModel.isVPSDownloaded {
|
||||
Label("VPS", systemImage: "icloud.fill")
|
||||
.font(.caption)
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
|
||||
if viewModel.isDownloaded {
|
||||
Label("Descargado", systemImage: "checkmark.circle.fill")
|
||||
Label("Local", systemImage: "checkmark.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundColor(.green)
|
||||
}
|
||||
@@ -327,10 +334,12 @@ struct PageView: View {
|
||||
let mangaSlug: String
|
||||
let chapterNumber: Int
|
||||
@ObservedObject var viewModel: ReaderViewModel
|
||||
@ObservedObject var vpsClient = VPSAPIClient.shared
|
||||
@State private var scale: CGFloat = 1.0
|
||||
@State private var lastScale: CGFloat = 1.0
|
||||
@State private var offset: CGSize = .zero
|
||||
@State private var lastOffset: CGSize = .zero
|
||||
@State private var useVPS = false
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
@@ -344,9 +353,15 @@ struct PageView: View {
|
||||
Image(uiImage: UIImage(contentsOfFile: localURL.path)!)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
} else {
|
||||
// Load from URL
|
||||
AsyncImage(url: URL(string: page.url)) { phase in
|
||||
} else if useVPS {
|
||||
// Load from VPS
|
||||
let vpsImageURL = vpsClient.getImageURL(
|
||||
mangaSlug: mangaSlug,
|
||||
chapterNumber: chapterNumber,
|
||||
pageIndex: page.index + 1 // VPS uses 1-based indexing
|
||||
)
|
||||
|
||||
AsyncImage(url: URL(string: vpsImageURL)) { phase in
|
||||
switch phase {
|
||||
case .empty:
|
||||
ProgressView()
|
||||
@@ -354,19 +369,16 @@ struct PageView: View {
|
||||
image
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.onAppear {
|
||||
// Cache image for offline reading
|
||||
Task {
|
||||
await viewModel.cachePage(page, image: image)
|
||||
}
|
||||
}
|
||||
case .failure:
|
||||
Image(systemName: "photo")
|
||||
.foregroundColor(.gray)
|
||||
// Fallback to original URL
|
||||
fallbackImage
|
||||
@unknown default:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Load from original URL
|
||||
fallbackImage
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
@@ -407,6 +419,39 @@ struct PageView: View {
|
||||
)
|
||||
)
|
||||
}
|
||||
.task {
|
||||
// Check if VPS has this chapter
|
||||
if let manifest = try? await vpsClient.getChapterManifest(
|
||||
mangaSlug: mangaSlug,
|
||||
chapterNumber: chapterNumber
|
||||
), manifest != nil {
|
||||
useVPS = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var fallbackImage: some View {
|
||||
AsyncImage(url: URL(string: page.url)) { phase in
|
||||
switch phase {
|
||||
case .empty:
|
||||
ProgressView()
|
||||
case .success(let image):
|
||||
image
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.onAppear {
|
||||
// Cache image for offline reading
|
||||
Task {
|
||||
await viewModel.cachePage(page, image: image)
|
||||
}
|
||||
}
|
||||
case .failure:
|
||||
Image(systemName: "photo")
|
||||
.foregroundColor(.gray)
|
||||
@unknown default:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -421,6 +466,7 @@ class ReaderViewModel: ObservableObject {
|
||||
@Published var showControls = true
|
||||
@Published var isFavorite = false
|
||||
@Published var isDownloaded = false
|
||||
@Published var isVPSDownloaded = false
|
||||
@Published var downloadProgress: Double?
|
||||
@Published var showingPageSlider = false
|
||||
@Published var showingSettings = false
|
||||
@@ -434,6 +480,7 @@ class ReaderViewModel: ObservableObject {
|
||||
private let chapter: Chapter
|
||||
private let scraper = ManhwaWebScraper.shared
|
||||
private let storage = StorageService.shared
|
||||
private let vpsClient = VPSAPIClient.shared
|
||||
|
||||
init(manga: Manga, chapter: Chapter) {
|
||||
self.manga = manga
|
||||
@@ -446,8 +493,25 @@ class ReaderViewModel: ObservableObject {
|
||||
isLoading = true
|
||||
|
||||
do {
|
||||
// Intentar cargar desde descarga local
|
||||
if let downloadedChapter = storage.getDownloadedChapter(mangaSlug: manga.slug, chapterNumber: chapter.number) {
|
||||
// Check if chapter is on VPS first
|
||||
if let vpsManifest = try await vpsClient.getChapterManifest(
|
||||
mangaSlug: manga.slug,
|
||||
chapterNumber: chapter.number
|
||||
) {
|
||||
// Load from VPS manifest - we'll load images dynamically
|
||||
let imageUrls = vpsManifest.images.map { $0.url }
|
||||
pages = imageUrls.enumerated().map { index, url in
|
||||
MangaPage(url: url, index: index)
|
||||
}
|
||||
isVPSDownloaded = true
|
||||
|
||||
// Load saved reading progress
|
||||
if let progress = storage.getReadingProgress(mangaSlug: manga.slug, chapterNumber: chapter.number) {
|
||||
currentPage = progress.pageNumber
|
||||
}
|
||||
}
|
||||
// Then try local storage
|
||||
else if let downloadedChapter = storage.getDownloadedChapter(mangaSlug: manga.slug, chapterNumber: chapter.number) {
|
||||
pages = downloadedChapter.pages
|
||||
isDownloaded = true
|
||||
|
||||
|
||||
Reference in New Issue
Block a user