Files
MangaReader/CHANGES.md
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

8.6 KiB

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

    func checkHealth() async throws -> Bool
    
  3. Download Operations

    func downloadChapter(...) async throws -> VPSDownloadResult
    
    • Progress tracking via @Published properties
    • Active download tracking
  4. Status Checking

    func getChapterManifest(...) async throws -> VPSChapterManifest?
    func listDownloadedChapters(...) async throws -> [VPSChapterInfo]
    
  5. Image URLs

    func getImageURL(...) -> String
    
  6. Management

    func deleteChapter(...) async throws -> Bool
    func getStorageStats() async throws -> VPSStorageStats
    

File: MangaDetailView.swift

Change 1: Import VPS Client

// Added to MangaDetailView struct
@StateObject private var vpsClient = VPSAPIClient.shared

Change 2: Add VPS Download Button to Toolbar

// In toolbar
Button {
    viewModel.showingVPSDownloadAll = true
} label: {
    Image(systemName: "icloud.and.arrow.down")
}
.disabled(viewModel.chapters.isEmpty)

Change 3: Add VPS Download Alert

.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

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

// 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

// 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

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

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

// 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

// Added to ReaderView struct
@ObservedObject private var vpsClient = VPSAPIClient.shared

Change 2: Update PageView for VPS Support

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

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

struct VPSDownloadResult {
    let success: Bool
    let alreadyDownloaded: Bool
    let manifest: VPSChapterManifest?
    let downloaded: Int?
    let failed: Int?
}

VPSChapterManifest

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

struct VPSChapterInfo: Codable {
    let chapterNumber: Int
    let downloadDate: String
    let totalPages: Int
    let downloadedPages: Int
    let totalSize: Int
    let totalSizeMB: String
}

VPSStorageStats

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:

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:

// Track active downloads
vpsClient.activeDownloads.contains("downloadId")

// Get progress
vpsClient.downloadProgress["downloadId"]

// Display in UI
ProgressView(value: progress)
Text("\(Int(progress * 100))%")