✨ 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>
424 lines
12 KiB
Swift
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)"
|
|
}
|
|
}
|
|
}
|