Files
MangaReader/ios-app/Sources/Services/StorageService.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

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