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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user