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