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:
2026-02-04 16:20:28 +01:00
parent b474182dd9
commit 83e25e3bd6
18 changed files with 5449 additions and 32 deletions

View 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)")
}
}
}