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>
This commit is contained in:
2026-02-04 16:20:28 +01:00
parent b474182dd9
commit 83e25e3bd6
18 changed files with 5449 additions and 32 deletions

View File

@@ -4,6 +4,7 @@ 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
@@ -35,6 +36,14 @@ struct MangaDetailView: View {
.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: {
@@ -53,7 +62,22 @@ struct MangaDetailView: View {
viewModel.downloadAllChapters()
}
} message: {
Text("¿Cuántos capítulos quieres descargar?")
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()
@@ -192,6 +216,9 @@ struct MangaDetailView: View {
},
onDownloadToggle: {
await viewModel.downloadChapter(chapter)
},
onVPSDownloadToggle: {
await viewModel.downloadChapterToVPS(chapter)
}
)
}
@@ -219,10 +246,14 @@ struct ChapterRowView: View {
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) {
@@ -241,7 +272,7 @@ struct ChapterRowView: View {
.progressViewStyle(.linear)
}
// Mostrar progreso de descarga
// Mostrar progreso de descarga local
if let downloadTask = currentDownloadTask {
HStack {
ProgressView(value: downloadTask.progress)
@@ -253,11 +284,46 @@ struct ChapterRowView: View {
.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()
// Botón de descarga
// 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 {
@@ -294,12 +360,33 @@ struct ChapterRowView: View {
)
}
.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
@@ -310,6 +397,7 @@ class MangaDetailViewModel: ObservableObject {
@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
@@ -319,6 +407,7 @@ class MangaDetailViewModel: ObservableObject {
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
@@ -426,6 +515,126 @@ class MangaDetailViewModel: ObservableObject {
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