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:
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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user