Files
MangaReader/ios-app/Sources/Views/MangaDetailView.swift
renato97 83e25e3bd6 feat: Add VPS storage system and complete integration
🎯 Overview:
Implemented complete VPS-based storage system allowing the iOS app to download
and store manga chapters on the VPS for ad-free offline reading.

📦 Backend Changes:
- Added storage.js service for managing chapter downloads (270 lines)
- Updated server.js with 6 new storage endpoints:
  - POST /api/download - Download chapters to VPS
  - GET /api/storage/chapters/:mangaSlug - List downloaded chapters
  - GET /api/storage/chapter/:mangaSlug/:chapterNumber - Check download status
  - GET /api/storage/image/:mangaSlug/:chapterNumber/:pageIndex - Serve images
  - DELETE /api/storage/chapter/:mangaSlug/:chapterNumber - Delete chapters
  - GET /api/storage/stats - Get storage statistics
- Fixed scraper.js Puppeteer compatibility issues (waitForTimeout, networkidle0)
- Added comprehensive test suite:
  - test-vps-flow.js (13 tests - 100% pass rate)
  - test-concurrent-downloads.js (10 tests for parallel operations)
  - run-tests.sh automation script

📱 iOS App Changes:
- Created APIConfig.swift with VPS connection settings
- Created VPSAPIClient.swift service (727 lines) for backend communication
- Updated MangaDetailView.swift with VPS download integration:
  - Cloud icon for VPS-available chapters
  - Upload button to download chapters to VPS
  - Progress indicators for active downloads
  - Bulk download options (last 10 or all chapters)
- Updated ReaderView.swift to load images from VPS first
- Progressive enhancement: app works without VPS, enhances when available

 Tests:
- All 13 VPS flow tests passing (100%)
- Tests verify: scraping, downloading, storage, serving, deletion, stats
- Chapter 789 download test: 21 images, 4.68 MB
- Concurrent download tests verify no race conditions

🔧 Configuration:
- VPS URL: https://gitea.cbcren.online:3001
- Storage location: /home/ren/ios/MangaReader/storage/
- Static file serving: /storage path

📚 Documentation:
- Added VPS_INTEGRATION_SUMMARY.md - Complete feature overview
- Added CHANGES.md - Detailed code changes reference
- Added TEST_README.md, TEST_QUICK_START.md, TEST_SUMMARY.md
- Added APIConfig README with usage examples

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-04 16:20:28 +01:00

705 lines
25 KiB
Swift

import SwiftUI
struct MangaDetailView: View {
let manga: Manga
@StateObject private var viewModel: MangaDetailViewModel
@StateObject private var storage = StorageService.shared
@StateObject private var vpsClient = VPSAPIClient.shared
init(manga: Manga) {
self.manga = manga
_viewModel = StateObject(wrappedValue: MangaDetailViewModel(manga: manga))
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
// Header con info del manga
mangaHeader
Divider()
// Lista de capítulos
chaptersList
}
.padding()
}
.navigationTitle(manga.title)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
HStack {
Button {
viewModel.toggleFavorite()
} label: {
Image(systemName: viewModel.isFavorite ? "heart.fill" : "heart")
.foregroundColor(viewModel.isFavorite ? .red : .primary)
}
// VPS Download Button
Button {
viewModel.showingVPSDownloadAll = true
} label: {
Image(systemName: "icloud.and.arrow.down")
}
.disabled(viewModel.chapters.isEmpty)
Button {
viewModel.showingDownloadAll = true
} label: {
Image(systemName: "arrow.down.doc")
}
.disabled(viewModel.chapters.isEmpty)
}
}
}
.alert("Descargar capítulos", isPresented: $viewModel.showingDownloadAll) {
Button("Cancelar", role: .cancel) { }
Button("Descargar últimos 10") {
viewModel.downloadLastChapters(count: 10)
}
Button("Descargar todos") {
viewModel.downloadAllChapters()
}
} message: {
Text("¿Cuántos capítulos quieres descargar localmente?")
}
.alert("Descargar a VPS", isPresented: $viewModel.showingVPSDownloadAll) {
Button("Cancelar", role: .cancel) { }
Button("Últimos 10 a VPS") {
Task {
await viewModel.downloadLastChaptersToVPS(count: 10)
}
}
Button("Todos a VPS") {
Task {
await viewModel.downloadAllChaptersToVPS()
}
}
} message: {
Text("¿Cuántos capítulos quieres descargar al servidor VPS?")
}
.task {
await viewModel.loadChapters()
}
.overlay(
Group {
if viewModel.showDownloadNotification {
VStack {
Spacer()
HStack {
Image(systemName: viewModel.notificationMessage.contains("Error") ? "exclamationmark.triangle" : "checkmark.circle.fill")
.foregroundColor(viewModel.notificationMessage.contains("Error") ? .red : .green)
Text(viewModel.notificationMessage)
.font(.subheadline)
.foregroundColor(.primary)
Spacer()
}
.padding()
.background(
RoundedRectangle(cornerRadius: 12)
.fill(Color(.systemBackground))
.shadow(color: .black.opacity(0.2), radius: 10, x: 0, y: 5)
)
.padding(.horizontal, 16)
.padding(.bottom, 50)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
viewModel.showDownloadNotification = false
}
}
}
}
)
}
private var mangaHeader: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .top, spacing: 16) {
// Cover image
AsyncImage(url: URL(string: manga.coverImage ?? "")) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
Rectangle()
.fill(Color.gray.opacity(0.2))
.overlay(
Image(systemName: "book.closed")
.foregroundColor(.gray)
)
}
.frame(width: 100, height: 140)
.cornerRadius(8)
.clipped()
// Info
VStack(alignment: .leading, spacing: 8) {
Text(manga.title)
.font(.headline)
Text(manga.displayStatus)
.font(.caption)
.padding(.horizontal, 8)
.padding(.vertical, 2)
.background(statusColor.opacity(0.2))
.foregroundColor(statusColor)
.cornerRadius(4)
if !manga.genres.isEmpty {
FlowLayout(spacing: 4) {
ForEach(manga.genres, id: \.self) { genre in
Text(genre)
.font(.caption2)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.gray.opacity(0.2))
.cornerRadius(4)
}
}
}
}
Spacer()
}
if !manga.description.isEmpty {
Text(manga.description)
.font(.subheadline)
.foregroundColor(.secondary)
}
// Stats
HStack(spacing: 20) {
Label("\(viewModel.chapters.count) caps.", systemImage: "list.bullet")
if let lastRead = storage.getLastReadChapter(mangaSlug: manga.slug) {
Label("Último: \(lastRead.chapterNumber)", systemImage: "book.closed")
}
}
.font(.caption)
.foregroundColor(.secondary)
}
.padding()
.background(
RoundedRectangle(cornerRadius: 12)
.fill(Color(.systemBackground))
.shadow(color: .black.opacity(0.05), radius: 3, x: 0, y: 1)
)
}
private var chaptersList: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Capítulos")
.font(.headline)
if viewModel.isLoadingChapters {
ProgressView("Cargando capítulos...")
.frame(maxWidth: .infinity, minHeight: 200)
} else if viewModel.chapters.isEmpty {
Text("No hay capítulos disponibles")
.foregroundColor(.secondary)
.frame(maxWidth: .infinity, minHeight: 200)
} else {
LazyVStack(spacing: 8) {
ForEach(viewModel.chapters) { chapter in
ChapterRowView(
chapter: chapter,
mangaSlug: manga.slug,
onTap: {
viewModel.selectedChapter = chapter
},
onDownloadToggle: {
await viewModel.downloadChapter(chapter)
},
onVPSDownloadToggle: {
await viewModel.downloadChapterToVPS(chapter)
}
)
}
}
}
}
}
private var statusColor: Color {
switch manga.status {
case "PUBLICANDOSE":
return .green
case "FINALIZADO":
return .blue
case "EN_PAUSA", "EN_ESPERA":
return .orange
default:
return .gray
}
}
}
struct ChapterRowView: View {
let chapter: Chapter
let mangaSlug: String
let onTap: () -> Void
let onDownloadToggle: () async -> Void
let onVPSDownloadToggle: () async -> Void
@StateObject private var storage = StorageService.shared
@ObservedObject private var downloadManager = DownloadManager.shared
@ObservedObject var vpsClient = VPSAPIClient.shared
@State private var isDownloading = false
@State private var isVPSDownloaded = false
@State private var isVPSChecked = false
var body: some View {
Button(action: onTap) {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(chapter.displayNumber)
.font(.subheadline)
.fontWeight(.medium)
if let progress = storage.getReadingProgress(mangaSlug: mangaSlug, chapterNumber: chapter.number) {
Text("Leído hasta página \(progress.pageNumber)")
.font(.caption)
.foregroundColor(.secondary)
ProgressView(value: Double(progress.pageNumber), total: 100)
.progressViewStyle(.linear)
}
// Mostrar progreso de descarga local
if let downloadTask = currentDownloadTask {
HStack {
ProgressView(value: downloadTask.progress)
.progressViewStyle(.linear)
.frame(maxWidth: 150)
Text("\(Int(downloadTask.progress * 100))%")
.font(.caption2)
.foregroundColor(.secondary)
}
}
// Mostrar progreso de descarga VPS
if vpsClient.activeDownloads.contains("\(mangaSlug)-\(chapter.number)"), let progress = vpsClient.downloadProgress["\(mangaSlug)-\(chapter.number)"] {
HStack {
Image(systemName: "icloud.and.arrow.down")
.font(.caption2)
.foregroundColor(.blue)
ProgressView(value: progress)
.progressViewStyle(.linear)
.frame(maxWidth: 100)
Text("VPS \(Int(progress * 100))%")
.font(.caption2)
.foregroundColor(.blue)
}
}
}
Spacer()
// VPS Download Button / Status
if isVPSChecked {
if isVPSDownloaded {
Image(systemName: "icloud.fill")
.foregroundColor(.blue)
} else {
Button {
Task {
await onVPSDownloadToggle()
}
} label: {
Image(systemName: "icloud.and.arrow.up")
.foregroundColor(.blue)
}
.buttonStyle(.plain)
}
}
// Botón de descarga local
if !storage.isChapterDownloaded(mangaSlug: mangaSlug, chapterNumber: chapter.number) {
Button {
Task {
await onDownloadToggle()
}
} label: {
if currentDownloadTask != nil {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.red)
} else {
Image(systemName: "arrow.down.circle")
.foregroundColor(.blue)
}
}
.buttonStyle(.plain)
} else if chapter.isRead {
Image(systemName: "eye")
.foregroundColor(.blue)
}
if storage.isChapterDownloaded(mangaSlug: mangaSlug, chapterNumber: chapter.number) {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
}
Image(systemName: "chevron.right")
.font(.caption)
.foregroundColor(.secondary)
}
.padding()
.background(
RoundedRectangle(cornerRadius: 8)
.fill(Color(.systemGray6))
)
}
.buttonStyle(.plain)
.task {
// Check VPS status when row appears
if !isVPSChecked {
await checkVPSStatus()
}
}
}
private var currentDownloadTask: DownloadTask? {
let taskId = "\(mangaSlug)-\(chapter.number)"
return downloadManager.activeDownloads.first { $0.id == taskId }
}
private func checkVPSStatus() async {
do {
let manifest = try await vpsClient.getChapterManifest(
mangaSlug: mangaSlug,
chapterNumber: chapter.number
)
isVPSDownloaded = manifest != nil
isVPSChecked = true
} catch {
// If error, assume not downloaded on VPS
isVPSDownloaded = false
isVPSChecked = true
}
}
}
// MARK: - ViewModel
@MainActor
class MangaDetailViewModel: ObservableObject {
@Published var chapters: [Chapter] = []
@Published var isLoadingChapters = false
@Published var isFavorite: Bool
@Published var selectedChapter: Chapter?
@Published var showingDownloadAll = false
@Published var showingVPSDownloadAll = false
@Published var isDownloading = false
@Published var downloadProgress: [String: Double] = [:]
@Published var showDownloadNotification = false
@Published var notificationMessage = ""
private let manga: Manga
private let scraper = ManhwaWebScraper.shared
private let storage = StorageService.shared
private let downloadManager = DownloadManager.shared
private let vpsClient = VPSAPIClient.shared
init(manga: Manga) {
self.manga = manga
_isFavorite = Published(initialValue: storage.isFavorite(mangaSlug: manga.slug))
}
func loadChapters() async {
isLoadingChapters = true
do {
let fetchedChapters = try await scraper.scrapeChapters(mangaSlug: manga.slug)
// Marcar capítulos descargados
var chaptersWithStatus = fetchedChapters
for index in chaptersWithStatus.indices {
if storage.isChapterDownloaded(mangaSlug: manga.slug, chapterNumber: chaptersWithStatus[index].number) {
chaptersWithStatus[index].isDownloaded = true
}
if let progress = storage.getReadingProgress(mangaSlug: manga.slug, chapterNumber: chaptersWithStatus[index].number) {
chaptersWithStatus[index].lastReadPage = progress.pageNumber
chaptersWithStatus[index].isRead = progress.isCompleted
}
}
chapters = chaptersWithStatus
} catch {
print("Error loading chapters: \(error)")
}
isLoadingChapters = false
}
func toggleFavorite() {
if storage.isFavorite(mangaSlug: manga.slug) {
storage.removeFavorite(mangaSlug: manga.slug)
} else {
storage.saveFavorite(mangaSlug: manga.slug)
}
isFavorite.toggle()
}
func downloadAllChapters() {
isDownloading = true
Task {
await downloadManager.downloadChapters(
mangaSlug: manga.slug,
mangaTitle: manga.title,
chapters: chapters
)
await showDownloadCompletionNotification(chapters.count)
isDownloading = false
// Recargar estado de capítulos
await loadChapters()
}
}
func downloadLastChapters(count: Int) {
let lastChapters = Array(chapters.prefix(count))
isDownloading = true
Task {
await downloadManager.downloadChapters(
mangaSlug: manga.slug,
mangaTitle: manga.title,
chapters: lastChapters
)
await showDownloadCompletionNotification(lastChapters.count)
isDownloading = false
// Recargar estado de capítulos
await loadChapters()
}
}
func downloadChapter(_ chapter: Chapter) async {
do {
try await downloadManager.downloadChapter(
mangaSlug: manga.slug,
mangaTitle: manga.title,
chapter: chapter
)
await showDownloadCompletionNotification(1)
// Recargar estado de capítulos
await loadChapters()
} catch {
print("Error downloading chapter: \(error.localizedDescription)")
notificationMessage = "Error al descargar capítulo \(chapter.number)"
showDownloadNotification = true
}
}
func getDownloadProgress(for chapter: Chapter) -> Double? {
let taskId = "\(manga.slug)-\(chapter.number)"
return downloadManager.activeDownloads.first { $0.id == taskId }?.progress
}
func isDownloadingChapter(_ chapter: Chapter) -> Bool {
let taskId = "\(manga.slug)-\(chapter.number)"
return downloadManager.activeDownloads.contains { $0.id == taskId }
}
private func showDownloadCompletionNotification(_ count: Int) async {
notificationMessage = "\(count) capítulo(s) descargado(s) correctamente"
showDownloadNotification = true
// Ocultar notificación después de 3 segundos
try? await Task.sleep(nanoseconds: 3_000_000_000)
showDownloadNotification = false
}
// MARK: - VPS Download Methods
/// Download a single chapter to VPS
func downloadChapterToVPS(_ chapter: Chapter) async {
do {
// First, get the image URLs for the chapter
let imageUrls = try await scraper.scrapeChapterImages(chapterSlug: chapter.slug)
// Download to VPS
let result = try await vpsClient.downloadChapter(
mangaSlug: manga.slug,
chapterNumber: chapter.number,
chapterSlug: chapter.slug,
imageUrls: imageUrls
)
if result.success {
if result.alreadyDownloaded {
notificationMessage = "Capítulo \(chapter.number) ya estaba en VPS"
} else {
notificationMessage = "Capítulo \(chapter.number) descargado a VPS"
}
} else {
notificationMessage = "Error al descargar capítulo \(chapter.number) a VPS"
}
showDownloadNotification = true
// Hide notification after 3 seconds
try? await Task.sleep(nanoseconds: 3_000_000_000)
showDownloadNotification = false
} catch {
notificationMessage = "Error VPS: \(error.localizedDescription)"
showDownloadNotification = true
}
}
/// Download all chapters to VPS
func downloadAllChaptersToVPS() async {
isDownloading = true
var successCount = 0
var failCount = 0
for chapter in chapters {
do {
let imageUrls = try await scraper.scrapeChapterImages(chapterSlug: chapter.slug)
let result = try await vpsClient.downloadChapter(
mangaSlug: manga.slug,
chapterNumber: chapter.number,
chapterSlug: chapter.slug,
imageUrls: imageUrls
)
if result.success {
successCount += 1
} else {
failCount += 1
}
} catch {
failCount += 1
}
}
isDownloading = false
if failCount == 0 {
notificationMessage = "\(successCount) capítulos descargados a VPS"
} else {
notificationMessage = "\(successCount) exitosos, \(failCount) fallidos en VPS"
}
showDownloadNotification = true
try? await Task.sleep(nanoseconds: 3_000_000_000)
showDownloadNotification = false
}
/// Download last N chapters to VPS
func downloadLastChaptersToVPS(count: Int) async {
let lastChapters = Array(chapters.prefix(count))
isDownloading = true
var successCount = 0
var failCount = 0
for chapter in lastChapters {
do {
let imageUrls = try await scraper.scrapeChapterImages(chapterSlug: chapter.slug)
let result = try await vpsClient.downloadChapter(
mangaSlug: manga.slug,
chapterNumber: chapter.number,
chapterSlug: chapter.slug,
imageUrls: imageUrls
)
if result.success {
successCount += 1
} else {
failCount += 1
}
} catch {
failCount += 1
}
}
isDownloading = false
if failCount == 0 {
notificationMessage = "\(successCount) capítulos descargados a VPS"
} else {
notificationMessage = "\(successCount) exitosos, \(failCount) fallidos en VPS"
}
showDownloadNotification = true
try? await Task.sleep(nanoseconds: 3_000_000_000)
showDownloadNotification = false
}
}
// MARK: - FlowLayout
struct FlowLayout: Layout {
var spacing: CGFloat = 8
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let result = FlowResult(
in: proposal.replacingUnspecifiedDimensions().width,
subviews: subviews,
spacing: spacing
)
return result.size
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
let result = FlowResult(
in: bounds.width,
subviews: subviews,
spacing: spacing
)
for (index, subview) in subviews.enumerated() {
subview.place(at: CGPoint(x: bounds.minX + result.frames[index].minX, y: bounds.minY + result.frames[index].minY), proposal: .unspecified)
}
}
struct FlowResult {
var frames: [CGRect] = []
var size: CGSize = .zero
init(in maxWidth: CGFloat, subviews: Subviews, spacing: CGFloat) {
var currentX: CGFloat = 0
var currentY: CGFloat = 0
var lineHeight: CGFloat = 0
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
if currentX + size.width > maxWidth && currentX > 0 {
currentX = 0
currentY += lineHeight + spacing
lineHeight = 0
}
frames.append(CGRect(origin: CGPoint(x: currentX, y: currentY), size: size))
currentX += size.width + spacing
lineHeight = max(lineHeight, size.height)
}
self.size = CGSize(width: maxWidth, height: currentY + lineHeight)
}
}
}
#Preview {
NavigationView {
MangaDetailView(manga: Manga(
slug: "one-piece_1695365223767",
title: "One Piece",
description: "La historia de piratas y aventuras",
genres: ["Acción", "Aventura", "Comedia"],
status: "PUBLICANDOSE",
url: "https://manhwaweb.com/manga/one-piece_1695365223767",
coverImage: nil
))
}
}