Files
MangaReader/ios-app/Sources/Services/DownloadManager.swift
renato97 b474182dd9 Initial commit: MangaReader iOS App
 Features:
- App iOS completa para leer manga sin publicidad
- Scraper con WKWebView para manhwaweb.com
- Sistema de descargas offline
- Lector con zoom y navegación
- Favoritos y progreso de lectura
- Compatible con iOS 15+ y Sideloadly/3uTools

📦 Contenido:
- Backend Node.js con Puppeteer (opcional)
- App iOS con SwiftUI
- Scraper de capítulos e imágenes
- Sistema de almacenamiento local
- Testing completo
- Documentación exhaustiva

🧪 Prueba: Capítulo 789 de One Piece descargado exitosamente
  - 21 páginas descargadas
  - 4.68 MB total
  - URLs verificadas y funcionales

🎉 Generated with Claude Code (https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-04 15:34:18 +01:00

424 lines
12 KiB
Swift

import Foundation
import UIKit
import Combine
/// Estado de una descarga
enum DownloadState: Equatable {
case pending
case downloading(progress: Double)
case completed
case failed(error: String)
case cancelled
var isDownloading: Bool {
if case .downloading = self { return true }
return false
}
var isCompleted: Bool {
if case .completed = self { return true }
return false
}
var isTerminal: Bool {
switch self {
case .completed, .failed, .cancelled:
return true
default:
return false
}
}
}
/// Información de progreso de descarga
struct DownloadProgress {
let chapterId: String
let downloadedPages: Int
let totalPages: Int
let currentProgress: Double
let state: DownloadState
var progressFraction: Double {
return Double(downloadedPages) / Double(max(totalPages, 1))
}
}
/// Tarea de descarga individual
class DownloadTask: ObservableObject, Identifiable {
let id: String
let mangaSlug: String
let mangaTitle: String
let chapterNumber: Int
let chapterTitle: String
let imageURLs: [String]
@Published var state: DownloadState = .pending
@Published var downloadedPages: Int = 0
@Published var error: String?
private var cancellationToken: CancellationChecker = CancellationChecker()
var progress: Double {
return Double(downloadedPages) / Double(max(imageURLs.count, 1))
}
var isCancelled: Bool {
cancellationToken.isCancelled
}
init(mangaSlug: String, mangaTitle: String, chapterNumber: Int, chapterTitle: String, imageURLs: [String]) {
self.id = "\(mangaSlug)-\(chapterNumber)"
self.mangaSlug = mangaSlug
self.mangaTitle = mangaTitle
self.chapterNumber = chapterNumber
self.chapterTitle = chapterTitle
self.imageURLs = imageURLs
}
func cancel() {
cancellationToken.cancel()
state = .cancelled
}
func updateProgress(downloaded: Int, total: Int) {
downloadedPages = downloaded
state = .downloading(progress: Double(downloaded) / Double(max(total, 1)))
}
func complete() {
state = .completed
}
func fail(_ error: String) {
self.error = error
state = .failed(error: error)
}
}
/// Checker para cancelación asíncrona
class CancellationChecker {
private var _isCancelled = false
private let lock = NSLock()
var isCancelled: Bool {
lock.lock()
defer { lock.unlock() }
return _isCancelled
}
func cancel() {
lock.lock()
defer { lock.unlock() }
_isCancelled = true
}
}
/// Gerente de descargas de capítulos
@MainActor
class DownloadManager: ObservableObject {
static let shared = DownloadManager()
// MARK: - Published Properties
@Published var activeDownloads: [DownloadTask] = []
@Published var completedDownloads: [DownloadTask] = []
@Published var failedDownloads: [DownloadTask] = []
@Published var totalProgress: Double = 0.0
// MARK: - Dependencies
private let storage = StorageService.shared
private let scraper = ManhwaWebScraper.shared
private var downloadCancellations: [String: CancellationChecker] = [:]
// MARK: - Configuration
private let maxConcurrentDownloads = 3
private let maxConcurrentImagesPerChapter = 5
private init() {}
// MARK: - Public Methods
/// Descarga un capítulo completo
func downloadChapter(mangaSlug: String, mangaTitle: String, chapter: Chapter) async throws {
// Verificar si ya está descargado
if storage.isChapterDownloaded(mangaSlug: mangaSlug, chapterNumber: chapter.number) {
throw DownloadError.alreadyDownloaded
}
// Obtener URLs de imágenes
let imageURLs = try await scraper.scrapeChapterImages(chapterSlug: chapter.slug)
guard !imageURLs.isEmpty else {
throw DownloadError.noImagesFound
}
// Crear tarea de descarga
let task = DownloadTask(
mangaSlug: mangaSlug,
mangaTitle: mangaTitle,
chapterNumber: chapter.number,
chapterTitle: chapter.title,
imageURLs: imageURLs
)
activeDownloads.append(task)
downloadCancellations[task.id] = task.cancellationToken
do {
// Descargar imágenes con concurrencia limitada
try await downloadImages(for: task)
// Guardar metadata del capítulo descargado
let pages = imageURLs.enumerated().map { index, url in
MangaPage(url: url, index: index)
}
let downloadedChapter = DownloadedChapter(
mangaSlug: mangaSlug,
mangaTitle: mangaTitle,
chapterNumber: chapter.number,
pages: pages,
downloadedAt: Date(),
totalSize: 0 // Se calcula después
)
storage.saveDownloadedChapter(downloadedChapter)
// Mover a completados
task.complete()
moveTaskToCompleted(task)
} catch {
task.fail(error.localizedDescription)
moveTaskToFailed(task)
throw error
}
}
/// Descarga múltiples capítulos en paralelo
func downloadChapters(mangaSlug: String, mangaTitle: String, chapters: [Chapter]) async {
let limitedChapters = Array(chapters.prefix(maxConcurrentDownloads))
await withTaskGroup(of: Void.self) { group in
for chapter in limitedChapters {
group.addTask {
do {
try await self.downloadChapter(
mangaSlug: mangaSlug,
mangaTitle: mangaTitle,
chapter: chapter
)
} catch {
print("Error downloading chapter \(chapter.number): \(error.localizedDescription)")
}
}
}
}
}
/// Cancela una descarga activa
func cancelDownload(taskId: String) {
guard let index = activeDownloads.firstIndex(where: { $0.id == taskId }),
let canceller = downloadCancellations[taskId] else {
return
}
let task = activeDownloads[index]
task.cancel()
canceller.cancel()
// Remover de activos
activeDownloads.remove(at: index)
downloadCancellations.removeValue(forKey: taskId)
// Limpiar archivos parciales
Task {
try? storage.deleteDownloadedChapter(
mangaSlug: task.mangaSlug,
chapterNumber: task.chapterNumber
)
}
}
/// Cancela todas las descargas activas
func cancelAllDownloads() {
let tasks = activeDownloads
for task in tasks {
cancelDownload(taskId: task.id)
}
}
/// Limpia el historial de descargas completadas
func clearCompletedHistory() {
completedDownloads.removeAll()
}
/// Limpia el historial de descargas fallidas
func clearFailedHistory() {
failedDownloads.removeAll()
}
/// Reintenta una descarga fallida
func retryDownload(task: DownloadTask, chapter: Chapter) async throws {
// Remover de fallidos
failedDownloads.removeAll { $0.id == task.id }
// Reiniciar descarga
try await downloadChapter(
mangaSlug: task.mangaSlug,
mangaTitle: task.mangaTitle,
chapter: chapter
)
}
/// Obtiene el progreso general de descargas
func updateTotalProgress() {
guard !activeDownloads.isEmpty else {
totalProgress = 0.0
return
}
let totalProgress = activeDownloads.reduce(0.0) { sum, task in
return sum + task.progress
}
self.totalProgress = totalProgress / Double(activeDownloads.count)
}
// MARK: - Private Methods
private func downloadImages(for task: DownloadTask) async throws {
let imageURLs = task.imageURLs
let totalImages = imageURLs.count
// Usar concurrencia limitada para no saturar la red
try await withThrowingTaskGroup(of: (Int, UIImage).self) { group in
var downloadedCount = 0
var activeImageDownloads = 0
for (index, imageURL) in imageURLs.enumerated() {
// Esperar si hay demasiadas descargas activas
while activeImageDownloads >= maxConcurrentImagesPerChapter {
try await Task.sleep(nanoseconds: 100_000_000) // 0.1 segundos
}
// Verificar cancelación
if task.isCancelled {
throw DownloadError.cancelled
}
activeImageDownloads += 1
group.addTask {
let image = try await self.downloadImage(from: imageURL)
return (index, image)
}
// Procesar imágenes completadas
for try await (index, image) in group {
activeImageDownloads -= 1
downloadedCount += 1
// Guardar imagen
try await storage.saveImage(
image,
mangaSlug: task.mangaSlug,
chapterNumber: task.chapterNumber,
pageIndex: index
)
// Actualizar progreso
Task { @MainActor in
task.updateProgress(downloaded: downloadedCount, total: totalImages)
self.updateTotalProgress()
}
}
}
}
}
private func downloadImage(from urlString: String) async throws -> UIImage {
guard let url = URL(string: urlString) else {
throw DownloadError.invalidURL
}
let (data, response) = try await URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse else {
throw DownloadError.invalidResponse
}
guard httpResponse.statusCode == 200 else {
throw DownloadError.httpError(statusCode: httpResponse.statusCode)
}
guard let image = UIImage(data: data) else {
throw DownloadError.invalidImageData
}
return image
}
private func moveTaskToCompleted(_ task: DownloadTask) {
activeDownloads.removeAll { $0.id == task.id }
downloadCancellations.removeValue(forKey: task.id)
// Limitar historial a últimas 50 descargas
if completedDownloads.count >= 50 {
completedDownloads.removeFirst()
}
completedDownloads.append(task)
updateTotalProgress()
}
private func moveTaskToFailed(_ task: DownloadTask) {
activeDownloads.removeAll { $0.id == task.id }
downloadCancellations.removeValue(forKey: task.id)
// Limitar historial a últimos 20 fallos
if failedDownloads.count >= 20 {
failedDownloads.removeFirst()
}
failedDownloads.append(task)
updateTotalProgress()
}
}
// MARK: - Download Errors
enum DownloadError: LocalizedError {
case alreadyDownloaded
case noImagesFound
case invalidURL
case invalidResponse
case httpError(statusCode: Int)
case invalidImageData
case cancelled
case storageError(String)
var errorDescription: String? {
switch self {
case .alreadyDownloaded:
return "El capítulo ya está descargado"
case .noImagesFound:
return "No se encontraron imágenes"
case .invalidURL:
return "URL inválida"
case .invalidResponse:
return "Respuesta inválida del servidor"
case .httpError(let statusCode):
return "Error HTTP \(statusCode)"
case .invalidImageData:
return "Datos de imagen inválidos"
case .cancelled:
return "Descarga cancelada"
case .storageError(let message):
return "Error de almacenamiento: \(message)"
}
}
}