✨ 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>
526 lines
18 KiB
Swift
526 lines
18 KiB
Swift
import Foundation
|
|
import SwiftUI
|
|
|
|
/// Servicio para manejar el almacenamiento local de capítulos y progreso de lectura.
|
|
///
|
|
/// `StorageService` centraliza todas las operaciones de persistencia de la aplicación,
|
|
/// incluyendo:
|
|
/// - Gestión de favoritos (UserDefaults)
|
|
/// - Seguimiento de progreso de lectura (UserDefaults)
|
|
/// - Metadata de capítulos descargados (JSON en disco)
|
|
/// - Almacenamiento de imágenes (FileManager)
|
|
///
|
|
/// El servicio usa UserDefaults para datos pequeños y simples, y FileManager para
|
|
/// almacenamiento de archivos binarios como imágenes.
|
|
///
|
|
/// # Example
|
|
/// ```swift
|
|
/// let storage = StorageService.shared
|
|
///
|
|
/// // Guardar favorito
|
|
/// storage.saveFavorite(mangaSlug: "one-piece")
|
|
///
|
|
/// // Guardar progreso
|
|
/// let progress = ReadingProgress(
|
|
/// mangaSlug: "one-piece",
|
|
/// chapterNumber: 1,
|
|
/// pageNumber: 15,
|
|
/// timestamp: Date()
|
|
/// )
|
|
/// storage.saveReadingProgress(progress)
|
|
///
|
|
/// // Verificar tamaño usado
|
|
/// let size = storage.getStorageSize()
|
|
/// print("Used: \(storage.formatFileSize(size))")
|
|
/// ```
|
|
class StorageService {
|
|
// MARK: - Singleton
|
|
|
|
/// Instancia compartida del servicio (Singleton pattern)
|
|
static let shared = StorageService()
|
|
|
|
// MARK: - Properties
|
|
|
|
/// FileManager para operaciones de sistema de archivos
|
|
private let fileManager = FileManager.default
|
|
|
|
/// Directorio de Documents de la app
|
|
private let documentsDirectory: URL
|
|
|
|
/// Subdirectorio para capítulos descargados
|
|
private let chaptersDirectory: URL
|
|
|
|
/// URL del archivo de metadata de descargas
|
|
private let metadataURL: URL
|
|
|
|
/// Claves para UserDefaults
|
|
private let favoritesKey = "favoriteMangas"
|
|
private let readingProgressKey = "readingProgress"
|
|
private let downloadedChaptersKey = "downloadedChaptersMetadata"
|
|
|
|
// MARK: - Initialization
|
|
|
|
/// Inicializador privado para implementar Singleton.
|
|
///
|
|
/// Configura las rutas de directorios y crea la estructura necesaria
|
|
/// si no existe.
|
|
private init() {
|
|
documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
|
chaptersDirectory = documentsDirectory.appendingPathComponent("Chapters")
|
|
metadataURL = documentsDirectory.appendingPathComponent("metadata.json")
|
|
|
|
createDirectoriesIfNeeded()
|
|
}
|
|
|
|
// MARK: - Directory Management
|
|
|
|
/// Crea el directorio de capítulos si no existe.
|
|
private func createDirectoriesIfNeeded() {
|
|
if !fileManager.fileExists(atPath: chaptersDirectory.path) {
|
|
try? fileManager.createDirectory(at: chaptersDirectory, withIntermediateDirectories: true)
|
|
}
|
|
}
|
|
|
|
func getChapterDirectory(mangaSlug: String, chapterNumber: Int) -> URL {
|
|
let chapterPath = "\(mangaSlug)/Chapter\(chapterNumber)"
|
|
return chaptersDirectory.appendingPathComponent(chapterPath)
|
|
}
|
|
|
|
// MARK: - Favorites
|
|
|
|
/// Retorna la lista de slugs de mangas favoritos.
|
|
///
|
|
/// - Returns: Array de strings con los slugs de mangas marcados como favoritos
|
|
func getFavorites() -> [String] {
|
|
UserDefaults.standard.stringArray(forKey: favoritesKey) ?? []
|
|
}
|
|
|
|
/// Guarda un manga como favorito.
|
|
///
|
|
/// Si el manga ya está en favoritos, no hace nada (no duplica).
|
|
///
|
|
/// - Parameter mangaSlug: Slug del manga a marcar como favorito
|
|
///
|
|
/// # Example
|
|
/// ```swift
|
|
/// storage.saveFavorite(mangaSlug: "one-piece_1695365223767")
|
|
/// ```
|
|
func saveFavorite(mangaSlug: String) {
|
|
var favorites = getFavorites()
|
|
if !favorites.contains(mangaSlug) {
|
|
favorites.append(mangaSlug)
|
|
UserDefaults.standard.set(favorites, forKey: favoritesKey)
|
|
}
|
|
}
|
|
|
|
/// Elimina un manga de favoritos.
|
|
///
|
|
/// Si el manga no está en favoritos, no hace nada.
|
|
///
|
|
/// - Parameter mangaSlug: Slug del manga a eliminar de favoritos
|
|
///
|
|
/// # Example
|
|
/// ```swift
|
|
/// storage.removeFavorite(mangaSlug: "one-piece_1695365223767")
|
|
/// ```
|
|
func removeFavorite(mangaSlug: String) {
|
|
var favorites = getFavorites()
|
|
favorites.removeAll { $0 == mangaSlug }
|
|
UserDefaults.standard.set(favorites, forKey: favoritesKey)
|
|
}
|
|
|
|
/// Verifica si un manga está marcado como favorito.
|
|
///
|
|
/// - Parameter mangaSlug: Slug del manga a verificar
|
|
/// - Returns: `true` si el manga está en favoritos, `false` en caso contrario
|
|
///
|
|
/// # Example
|
|
/// ```swift
|
|
/// if storage.isFavorite(mangaSlug: "one-piece_1695365223767") {
|
|
/// print("Este manga es favorito")
|
|
/// }
|
|
/// ```
|
|
func isFavorite(mangaSlug: String) -> Bool {
|
|
getFavorites().contains(mangaSlug)
|
|
}
|
|
|
|
// MARK: - Reading Progress
|
|
|
|
/// Guarda o actualiza el progreso de lectura de un capítulo.
|
|
///
|
|
/// Si ya existe progreso para el mismo manga y capítulo, lo actualiza.
|
|
/// Si no existe, agrega un nuevo registro.
|
|
///
|
|
/// - Parameter progress: Objeto `ReadingProgress` con la información a guardar
|
|
///
|
|
/// # Example
|
|
/// ```swift
|
|
/// let progress = ReadingProgress(
|
|
/// mangaSlug: "one-piece",
|
|
/// chapterNumber: 1,
|
|
/// pageNumber: 15,
|
|
/// timestamp: Date()
|
|
/// )
|
|
/// storage.saveReadingProgress(progress)
|
|
/// ```
|
|
func saveReadingProgress(_ progress: ReadingProgress) {
|
|
var allProgress = getAllReadingProgress()
|
|
|
|
// Actualizar o agregar el progreso
|
|
if let index = allProgress.firstIndex(where: { $0.mangaSlug == progress.mangaSlug && $0.chapterNumber == progress.chapterNumber }) {
|
|
allProgress[index] = progress
|
|
} else {
|
|
allProgress.append(progress)
|
|
}
|
|
|
|
saveProgressToDisk(allProgress)
|
|
}
|
|
|
|
/// Retorna el progreso de lectura de un capítulo específico.
|
|
///
|
|
/// - Parameters:
|
|
/// - mangaSlug: Slug del manga
|
|
/// - chapterNumber: Número del capítulo
|
|
/// - Returns: Objeto `ReadingProgress` si existe, `nil` en caso contrario
|
|
///
|
|
/// # Example
|
|
/// ```swift
|
|
/// if let progress = storage.getReadingProgress(
|
|
/// mangaSlug: "one-piece",
|
|
/// chapterNumber: 1
|
|
/// ) {
|
|
/// print("Última página: \(progress.pageNumber)")
|
|
/// }
|
|
/// ```
|
|
func getReadingProgress(mangaSlug: String, chapterNumber: Int) -> ReadingProgress? {
|
|
getAllReadingProgress().first { $0.mangaSlug == mangaSlug && $0.chapterNumber == chapterNumber }
|
|
}
|
|
|
|
/// Retorna todo el progreso de lectura almacenado.
|
|
///
|
|
/// - Returns: Array de todos los objetos `ReadingProgress` almacenados
|
|
func getAllReadingProgress() -> [ReadingProgress] {
|
|
guard let data = UserDefaults.standard.data(forKey: readingProgressKey),
|
|
let progress = try? JSONDecoder().decode([ReadingProgress].self, from: data) else {
|
|
return []
|
|
}
|
|
return progress
|
|
}
|
|
|
|
/// Retorna el capítulo más recientemente leído de un manga.
|
|
///
|
|
/// Busca entre todos los progresos del manga y retorna el que tiene
|
|
/// el timestamp más reciente.
|
|
///
|
|
/// - Parameter mangaSlug: Slug del manga
|
|
/// - Returns: Objeto `ReadingProgress` más reciente, o `nil` si no hay progreso
|
|
///
|
|
/// # Example
|
|
/// ```swift
|
|
/// if let lastRead = storage.getLastReadChapter(mangaSlug: "one-piece") {
|
|
/// print("Último capítulo leído: \(lastRead.chapterNumber)")
|
|
/// }
|
|
/// ```
|
|
func getLastReadChapter(mangaSlug: String) -> ReadingProgress? {
|
|
let progress = getAllReadingProgress().filter { $0.mangaSlug == mangaSlug }
|
|
return progress.max { $0.timestamp < $1.timestamp }
|
|
}
|
|
|
|
/// Guarda el array de progresos en UserDefaults.
|
|
///
|
|
/// - Parameter progress: Array de `ReadingProgress` a guardar
|
|
private func saveProgressToDisk(_ progress: [ReadingProgress]) {
|
|
if let data = try? JSONEncoder().encode(progress) {
|
|
UserDefaults.standard.set(data, forKey: readingProgressKey)
|
|
}
|
|
}
|
|
|
|
// MARK: - Downloaded Chapters
|
|
|
|
/// Guarda la metadata de un capítulo descargado.
|
|
///
|
|
/// Si el capítulo ya existe en la metadata, lo actualiza.
|
|
/// Si no existe, agrega un nuevo registro.
|
|
///
|
|
/// - Parameter chapter: Objeto `DownloadedChapter` con la metadata
|
|
///
|
|
/// # Example
|
|
/// ```swift
|
|
/// let downloaded = DownloadedChapter(
|
|
/// mangaSlug: "one-piece",
|
|
/// mangaTitle: "One Piece",
|
|
/// chapterNumber: 1,
|
|
/// pages: pages,
|
|
/// downloadedAt: Date()
|
|
/// )
|
|
/// storage.saveDownloadedChapter(downloaded)
|
|
/// ```
|
|
func saveDownloadedChapter(_ chapter: DownloadedChapter) {
|
|
var downloaded = getDownloadedChapters()
|
|
|
|
if let index = downloaded.firstIndex(where: { $0.id == chapter.id }) {
|
|
downloaded[index] = chapter
|
|
} else {
|
|
downloaded.append(chapter)
|
|
}
|
|
|
|
if let data = try? JSONEncoder().encode(downloaded) {
|
|
try? data.write(to: metadataURL)
|
|
}
|
|
}
|
|
|
|
/// Retorna todos los capítulos descargados.
|
|
///
|
|
/// - Returns: Array de objetos `DownloadedChapter`
|
|
///
|
|
/// # Example
|
|
/// ```swift
|
|
/// let downloads = storage.getDownloadedChapters()
|
|
/// print("Tienes \(downloads.count) capítulos descargados")
|
|
/// for chapter in downloads {
|
|
/// print("- \(chapter.displayTitle)")
|
|
/// }
|
|
/// ```
|
|
func getDownloadedChapters() -> [DownloadedChapter] {
|
|
guard let data = try? Data(contentsOf: metadataURL),
|
|
let downloaded = try? JSONDecoder().decode([DownloadedChapter].self, from: data) else {
|
|
return []
|
|
}
|
|
return downloaded
|
|
}
|
|
|
|
/// Retorna un capítulo descargado específico.
|
|
///
|
|
/// - Parameters:
|
|
/// - mangaSlug: Slug del manga
|
|
/// - chapterNumber: Número del capítulo
|
|
/// - Returns: Objeto `DownloadedChapter` si existe, `nil` en caso contrario
|
|
func getDownloadedChapter(mangaSlug: String, chapterNumber: Int) -> DownloadedChapter? {
|
|
getDownloadedChapters().first { $0.mangaSlug == mangaSlug && $0.chapterNumber == chapterNumber }
|
|
}
|
|
|
|
/// Verifica si un capítulo está descargado.
|
|
///
|
|
/// - Parameters:
|
|
/// - mangaSlug: Slug del manga
|
|
/// - chapterNumber: Número del capítulo
|
|
/// - Returns: `true` si el capítulo está descargado, `false` en caso contrario
|
|
///
|
|
/// # Example
|
|
/// ```swift
|
|
/// if storage.isChapterDownloaded(
|
|
/// mangaSlug: "one-piece",
|
|
/// chapterNumber: 1
|
|
/// ) {
|
|
/// print("Capítulo ya descargado")
|
|
/// }
|
|
/// ```
|
|
func isChapterDownloaded(mangaSlug: String, chapterNumber: Int) -> Bool {
|
|
getDownloadedChapter(mangaSlug: mangaSlug, chapterNumber: chapterNumber) != nil
|
|
}
|
|
|
|
/// Elimina un capítulo descargado (archivos y metadata).
|
|
///
|
|
/// Elimina todos los archivos de imagen del capítulo del disco
|
|
/// y remueve la metadata del registro de descargas.
|
|
///
|
|
/// - Parameters:
|
|
/// - mangaSlug: Slug del manga
|
|
/// - chapterNumber: Número del capítulo a eliminar
|
|
///
|
|
/// # Example
|
|
/// ```swift
|
|
/// storage.deleteDownloadedChapter(
|
|
/// mangaSlug: "one-piece",
|
|
/// chapterNumber: 1
|
|
/// )
|
|
/// ```
|
|
func deleteDownloadedChapter(mangaSlug: String, chapterNumber: Int) {
|
|
// Eliminar archivos
|
|
let chapterDir = getChapterDirectory(mangaSlug: mangaSlug, chapterNumber: chapterNumber)
|
|
try? fileManager.removeItem(at: chapterDir)
|
|
|
|
// Eliminar metadata
|
|
var downloaded = getDownloadedChapters()
|
|
downloaded.removeAll { $0.mangaSlug == mangaSlug && $0.chapterNumber == chapterNumber }
|
|
|
|
if let data = try? JSONEncoder().encode(downloaded) {
|
|
try? data.write(to: metadataURL)
|
|
}
|
|
}
|
|
|
|
// MARK: - Image Caching
|
|
|
|
/// Guarda una imagen en disco local.
|
|
///
|
|
/// Comprime la imagen como JPEG con 80% de calidad y la guarda
|
|
/// en el directorio del capítulo correspondiente.
|
|
///
|
|
/// - Parameters:
|
|
/// - image: Imagen (UIImage) a guardar
|
|
/// - mangaSlug: Slug del manga
|
|
/// - chapterNumber: Número del capítulo
|
|
/// - pageIndex: Índice de la página (para nombre de archivo)
|
|
/// - Returns: URL del archivo guardado
|
|
/// - Throws: Error si no se puede crear el directorio o guardar la imagen
|
|
///
|
|
/// # Example
|
|
/// ```swift
|
|
/// do {
|
|
/// let imageURL = try await storage.saveImage(
|
|
/// image: myUIImage,
|
|
/// mangaSlug: "one-piece",
|
|
/// chapterNumber: 1,
|
|
/// pageIndex: 0
|
|
/// )
|
|
/// print("Imagen guardada en: \(imageURL.path)")
|
|
/// } catch {
|
|
/// print("Error guardando imagen: \(error)")
|
|
/// }
|
|
/// ```
|
|
func saveImage(_ image: UIImage, mangaSlug: String, chapterNumber: Int, pageIndex: Int) async throws -> URL {
|
|
let chapterDir = getChapterDirectory(mangaSlug: mangaSlug, chapterNumber: chapterNumber)
|
|
|
|
// Crear directorio si no existe
|
|
if !fileManager.fileExists(atPath: chapterDir.path) {
|
|
try fileManager.createDirectory(at: chapterDir, withIntermediateDirectories: true)
|
|
}
|
|
|
|
let filename = "page_\(pageIndex).jpg"
|
|
let fileURL = chapterDir.appendingPathComponent(filename)
|
|
|
|
// Guardar imagen
|
|
if let data = image.jpegData(compressionQuality: 0.8) {
|
|
try data.write(to: fileURL)
|
|
return fileURL
|
|
}
|
|
|
|
throw NSError(domain: "StorageService", code: 1, userInfo: [NSLocalizedDescriptionKey: "Error saving image"])
|
|
}
|
|
|
|
/// Carga una imagen desde disco local.
|
|
///
|
|
/// - Parameters:
|
|
/// - mangaSlug: Slug del manga
|
|
/// - chapterNumber: Número del capítulo
|
|
/// - pageIndex: Índice de la página
|
|
/// - Returns: Objeto `UIImage` si el archivo existe, `nil` en caso contrario
|
|
///
|
|
/// # Example
|
|
/// ```swift
|
|
/// if let image = storage.loadImage(
|
|
/// mangaSlug: "one-piece",
|
|
/// chapterNumber: 1,
|
|
/// pageIndex: 0
|
|
/// ) {
|
|
/// print("Imagen cargada: \(image.size)")
|
|
/// }
|
|
/// ```
|
|
func loadImage(mangaSlug: String, chapterNumber: Int, pageIndex: Int) -> UIImage? {
|
|
let chapterDir = getChapterDirectory(mangaSlug: mangaSlug, chapterNumber: chapterNumber)
|
|
let filename = "page_\(pageIndex).jpg"
|
|
let fileURL = chapterDir.appendingPathComponent(filename)
|
|
|
|
guard fileManager.fileExists(atPath: fileURL.path) else {
|
|
return nil
|
|
}
|
|
|
|
return UIImage(contentsOfFile: fileURL.path)
|
|
}
|
|
|
|
/// Retorna la URL local de una imagen si está cacheada.
|
|
///
|
|
/// - Parameters:
|
|
/// - mangaSlug: Slug del manga
|
|
/// - chapterNumber: Número del capítulo
|
|
/// - pageIndex: Índice de la página
|
|
/// - Returns: URL del archivo si existe, `nil` en caso contrario
|
|
///
|
|
/// # Example
|
|
/// ```swift
|
|
/// if let imageURL = storage.getImageURL(
|
|
/// mangaSlug: "one-piece",
|
|
/// chapterNumber: 1,
|
|
/// pageIndex: 0
|
|
/// ) {
|
|
/// print("Imagen cacheada en: \(imageURL.path)")
|
|
/// }
|
|
/// ```
|
|
func getImageURL(mangaSlug: String, chapterNumber: Int, pageIndex: Int) -> URL? {
|
|
let chapterDir = getChapterDirectory(mangaSlug: mangaSlug, chapterNumber: chapterNumber)
|
|
let filename = "page_\(pageIndex).jpg"
|
|
let fileURL = chapterDir.appendingPathComponent(filename)
|
|
|
|
return fileManager.fileExists(atPath: fileURL.path) ? fileURL : nil
|
|
}
|
|
|
|
// MARK: - Storage Management
|
|
|
|
/// Calcula el tamaño total usado por los capítulos descargados.
|
|
///
|
|
/// Recursivamente suma el tamaño de todos los archivos en el
|
|
/// directorio de capítulos.
|
|
///
|
|
/// - Returns: Tamaño total en bytes
|
|
///
|
|
/// # Example
|
|
/// ```swift
|
|
/// let bytes = storage.getStorageSize()
|
|
/// let formatted = storage.formatFileSize(bytes)
|
|
/// print("Usando \(formatted) de espacio")
|
|
/// ```
|
|
func getStorageSize() -> Int64 {
|
|
var totalSize: Int64 = 0
|
|
|
|
if let enumerator = fileManager.enumerator(at: chaptersDirectory, includingPropertiesForKeys: [.fileSizeKey]) {
|
|
for case let fileURL as URL in enumerator {
|
|
if let resourceValues = try? fileURL.resourceValues(forKeys: [.fileSizeKey]),
|
|
let fileSize = resourceValues.fileSize {
|
|
totalSize += Int64(fileSize)
|
|
}
|
|
}
|
|
}
|
|
|
|
return totalSize
|
|
}
|
|
|
|
/// Elimina todos los capítulos descargados y su metadata.
|
|
///
|
|
/// Elimina completamente el directorio de capítulos y el archivo
|
|
/// de metadata, liberando todo el espacio usado.
|
|
///
|
|
/// # Example
|
|
/// ```swift
|
|
/// storage.clearAllDownloads()
|
|
/// print("Todos los descargados han sido eliminados")
|
|
/// ```
|
|
func clearAllDownloads() {
|
|
try? fileManager.removeItem(at: chaptersDirectory)
|
|
createDirectoriesIfNeeded()
|
|
|
|
// Limpiar metadata
|
|
try? fileManager.removeItem(at: metadataURL)
|
|
}
|
|
|
|
/// Formatea un tamaño en bytes a un string legible.
|
|
///
|
|
/// Usa `ByteCountFormatter` para convertir bytes a KB, MB, GB según
|
|
/// corresponda, con el formato apropiado para archivos.
|
|
///
|
|
/// - Parameter bytes: Tamaño en bytes
|
|
/// - Returns: String formateado (ej: "15.2 MB", "1.3 GB")
|
|
///
|
|
/// # Example
|
|
/// ```swift
|
|
/// print(storage.formatFileSize(1024)) // "1 KB"
|
|
/// print(storage.formatFileSize(15728640)) // "15 MB"
|
|
/// print(storage.formatFileSize(2147483648)) // "2 GB"
|
|
/// ```
|
|
func formatFileSize(_ bytes: Int64) -> String {
|
|
let formatter = ByteCountFormatter()
|
|
formatter.allowedUnits = [.useBytes, .useKB, .useMB, .useGB]
|
|
formatter.countStyle = .file
|
|
return formatter.string(fromByteCount: bytes)
|
|
}
|
|
}
|