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:
381
CHANGES.md
Normal file
381
CHANGES.md
Normal file
@@ -0,0 +1,381 @@
|
||||
# VPS Integration - Code Changes Reference
|
||||
|
||||
## File: VPSAPIClient.swift (NEW FILE)
|
||||
|
||||
Created a complete API client with these sections:
|
||||
|
||||
1. **Configuration & Initialization**
|
||||
- Singleton pattern
|
||||
- URLSession setup with appropriate timeouts
|
||||
- Base URL configuration
|
||||
|
||||
2. **Health Check**
|
||||
```swift
|
||||
func checkHealth() async throws -> Bool
|
||||
```
|
||||
|
||||
3. **Download Operations**
|
||||
```swift
|
||||
func downloadChapter(...) async throws -> VPSDownloadResult
|
||||
```
|
||||
- Progress tracking via `@Published` properties
|
||||
- Active download tracking
|
||||
|
||||
4. **Status Checking**
|
||||
```swift
|
||||
func getChapterManifest(...) async throws -> VPSChapterManifest?
|
||||
func listDownloadedChapters(...) async throws -> [VPSChapterInfo]
|
||||
```
|
||||
|
||||
5. **Image URLs**
|
||||
```swift
|
||||
func getImageURL(...) -> String
|
||||
```
|
||||
|
||||
6. **Management**
|
||||
```swift
|
||||
func deleteChapter(...) async throws -> Bool
|
||||
func getStorageStats() async throws -> VPSStorageStats
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File: MangaDetailView.swift
|
||||
|
||||
### Change 1: Import VPS Client
|
||||
```swift
|
||||
// Added to MangaDetailView struct
|
||||
@StateObject private var vpsClient = VPSAPIClient.shared
|
||||
```
|
||||
|
||||
### Change 2: Add VPS Download Button to Toolbar
|
||||
```swift
|
||||
// In toolbar
|
||||
Button {
|
||||
viewModel.showingVPSDownloadAll = true
|
||||
} label: {
|
||||
Image(systemName: "icloud.and.arrow.down")
|
||||
}
|
||||
.disabled(viewModel.chapters.isEmpty)
|
||||
```
|
||||
|
||||
### Change 3: Add VPS Download Alert
|
||||
```swift
|
||||
.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?")
|
||||
}
|
||||
```
|
||||
|
||||
### Change 4: Update ChapterRowView
|
||||
```swift
|
||||
struct ChapterRowView: View {
|
||||
// Added:
|
||||
let onVPSDownloadToggle: () async -> Void
|
||||
@ObservedObject var vpsClient = VPSAPIClient.shared
|
||||
@State private var isVPSDownloaded = false
|
||||
@State private var isVPSChecked = false
|
||||
```
|
||||
|
||||
### Change 5: Add VPS Status Indicator in ChapterRowView Body
|
||||
```swift
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Change 6: Add VPS Progress Display
|
||||
```swift
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Change 7: Add VPS Status Check Function
|
||||
```swift
|
||||
private func checkVPSStatus() async {
|
||||
do {
|
||||
let manifest = try await vpsClient.getChapterManifest(
|
||||
mangaSlug: mangaSlug,
|
||||
chapterNumber: chapter.number
|
||||
)
|
||||
isVPSDownloaded = manifest != nil
|
||||
isVPSChecked = true
|
||||
} catch {
|
||||
isVPSDownloaded = false
|
||||
isVPSChecked = true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Change 8: Update chaptersList to Pass VPS Callback
|
||||
```swift
|
||||
ChapterRowView(
|
||||
chapter: chapter,
|
||||
mangaSlug: manga.slug,
|
||||
onTap: {
|
||||
viewModel.selectedChapter = chapter
|
||||
},
|
||||
onDownloadToggle: {
|
||||
await viewModel.downloadChapter(chapter)
|
||||
},
|
||||
onVPSDownloadToggle: { // NEW
|
||||
await viewModel.downloadChapterToVPS(chapter)
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
### Change 9: ViewModel Additions
|
||||
```swift
|
||||
// New Published Property
|
||||
@Published var showingVPSDownloadAll = false
|
||||
|
||||
// New Dependency
|
||||
private let vpsClient = VPSAPIClient.shared
|
||||
|
||||
// New Methods
|
||||
func downloadChapterToVPS(_ chapter: Chapter) async {
|
||||
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
|
||||
)
|
||||
// Handle result and show notification
|
||||
} catch {
|
||||
// Handle error
|
||||
}
|
||||
}
|
||||
|
||||
func downloadAllChaptersToVPS() async { /* ... */ }
|
||||
func downloadLastChaptersToVPS(count: Int) async { /* ... */ }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File: ReaderView.swift
|
||||
|
||||
### Change 1: Add VPS Client
|
||||
```swift
|
||||
// Added to ReaderView struct
|
||||
@ObservedObject private var vpsClient = VPSAPIClient.shared
|
||||
```
|
||||
|
||||
### Change 2: Update PageView for VPS Support
|
||||
```swift
|
||||
struct PageView: View {
|
||||
// Added:
|
||||
@ObservedObject var vpsClient = VPSAPIClient.shared
|
||||
@State private var useVPS = false
|
||||
|
||||
var body: some View {
|
||||
// ...
|
||||
if let localURL = StorageService.shared.getImageURL(...) {
|
||||
// Load from local cache
|
||||
} else if useVPS {
|
||||
// Load from VPS
|
||||
let vpsImageURL = vpsClient.getImageURL(
|
||||
mangaSlug: mangaSlug,
|
||||
chapterNumber: chapterNumber,
|
||||
pageIndex: page.index + 1
|
||||
)
|
||||
AsyncImage(url: URL(string: vpsImageURL)) { /* ... */ }
|
||||
} else {
|
||||
// Load from original URL
|
||||
fallbackImage
|
||||
}
|
||||
// ...
|
||||
.task {
|
||||
// Check if VPS has this chapter
|
||||
if let manifest = try? await vpsClient.getChapterManifest(...) {
|
||||
useVPS = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var fallbackImage: some View { /* ... */ }
|
||||
}
|
||||
```
|
||||
|
||||
### Change 3: Update ReaderViewModel
|
||||
```swift
|
||||
// New Published Property
|
||||
@Published var isVPSDownloaded = false
|
||||
|
||||
// New Dependency
|
||||
private let vpsClient = VPSAPIClient.shared
|
||||
|
||||
// Updated loadPages()
|
||||
func loadPages() async {
|
||||
// 1. Check VPS first
|
||||
if let vpsManifest = try await vpsClient.getChapterManifest(...) {
|
||||
// Load from VPS
|
||||
isVPSDownloaded = true
|
||||
}
|
||||
// 2. Then local storage
|
||||
else if let downloadedChapter = storage.getDownloadedChapter(...) {
|
||||
// Load from local
|
||||
isDownloaded = true
|
||||
}
|
||||
// 3. Finally scrape
|
||||
else {
|
||||
// Scrape from original
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Change 4: Update Reader Footer
|
||||
```swift
|
||||
// Page indicator section
|
||||
if viewModel.isVPSDownloaded {
|
||||
Label("VPS", systemImage: "icloud.fill")
|
||||
.font(.caption)
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
|
||||
if viewModel.isDownloaded {
|
||||
Label("Local", systemImage: "checkmark.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundColor(.green)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data Models (in VPSAPIClient.swift)
|
||||
|
||||
### VPSDownloadResult
|
||||
```swift
|
||||
struct VPSDownloadResult {
|
||||
let success: Bool
|
||||
let alreadyDownloaded: Bool
|
||||
let manifest: VPSChapterManifest?
|
||||
let downloaded: Int?
|
||||
let failed: Int?
|
||||
}
|
||||
```
|
||||
|
||||
### VPSChapterManifest
|
||||
```swift
|
||||
struct VPSChapterManifest: Codable {
|
||||
let mangaSlug: String
|
||||
let chapterNumber: Int
|
||||
let totalPages: Int
|
||||
let downloadedPages: Int
|
||||
let failedPages: Int
|
||||
let downloadDate: String
|
||||
let totalSize: Int
|
||||
let images: [VPSImageInfo]
|
||||
}
|
||||
```
|
||||
|
||||
### VPSChapterInfo
|
||||
```swift
|
||||
struct VPSChapterInfo: Codable {
|
||||
let chapterNumber: Int
|
||||
let downloadDate: String
|
||||
let totalPages: Int
|
||||
let downloadedPages: Int
|
||||
let totalSize: Int
|
||||
let totalSizeMB: String
|
||||
}
|
||||
```
|
||||
|
||||
### VPSStorageStats
|
||||
```swift
|
||||
struct VPSStorageStats: Codable {
|
||||
let totalMangas: Int
|
||||
let totalChapters: Int
|
||||
let totalSize: Int
|
||||
let totalSizeMB: String
|
||||
let totalSizeFormatted: String
|
||||
let mangaDetails: [VPSMangaDetail]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Priority Order for Image Loading
|
||||
|
||||
1. **Local Device Storage** (fastest, offline)
|
||||
2. **VPS Storage** (fast, online)
|
||||
3. **Original URL** (slowest, may fail)
|
||||
|
||||
This ensures best performance and reliability.
|
||||
|
||||
---
|
||||
|
||||
## Error Handling Pattern
|
||||
|
||||
All VPS operations follow this pattern:
|
||||
|
||||
```swift
|
||||
do {
|
||||
let result = try await vpsClient.someMethod(...)
|
||||
// Handle success
|
||||
} catch {
|
||||
// Handle error - show user notification
|
||||
notificationMessage = "Error: \(error.localizedDescription)"
|
||||
showDownloadNotification = true
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Progress Tracking Pattern
|
||||
|
||||
For downloads:
|
||||
|
||||
```swift
|
||||
// Track active downloads
|
||||
vpsClient.activeDownloads.contains("downloadId")
|
||||
|
||||
// Get progress
|
||||
vpsClient.downloadProgress["downloadId"]
|
||||
|
||||
// Display in UI
|
||||
ProgressView(value: progress)
|
||||
Text("\(Int(progress * 100))%")
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user