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:
310
backend/storage.js
Normal file
310
backend/storage.js
Normal file
@@ -0,0 +1,310 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// Configuración
|
||||
const STORAGE_BASE_DIR = path.join(__dirname, '../storage');
|
||||
const MANHWA_BASE_URL = 'https://manhwaweb.com';
|
||||
|
||||
/**
|
||||
* Servicio de almacenamiento para capítulos descargados
|
||||
* Gestiona la descarga, almacenamiento y serving de imágenes
|
||||
*/
|
||||
class StorageService {
|
||||
constructor() {
|
||||
this.ensureDirectories();
|
||||
}
|
||||
|
||||
/**
|
||||
* Crea los directorios necesarios si no existen
|
||||
*/
|
||||
ensureDirectories() {
|
||||
const dirs = [
|
||||
STORAGE_BASE_DIR,
|
||||
path.join(STORAGE_BASE_DIR, 'manga'),
|
||||
path.join(STORAGE_BASE_DIR, 'temp')
|
||||
];
|
||||
|
||||
dirs.forEach(dir => {
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
console.log(`📁 Directorio creado: ${dir}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene la ruta del directorio de un manga
|
||||
*/
|
||||
getMangaDir(mangaSlug) {
|
||||
const mangaDir = path.join(STORAGE_BASE_DIR, 'manga', mangaSlug);
|
||||
if (!fs.existsSync(mangaDir)) {
|
||||
fs.mkdirSync(mangaDir, { recursive: true });
|
||||
}
|
||||
return mangaDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene la ruta del directorio de un capítulo
|
||||
*/
|
||||
getChapterDir(mangaSlug, chapterNumber) {
|
||||
const mangaDir = this.getMangaDir(mangaSlug);
|
||||
const chapterDir = path.join(mangaDir, `chapter_${chapterNumber}`);
|
||||
if (!fs.existsSync(chapterDir)) {
|
||||
fs.mkdirSync(chapterDir, { recursive: true });
|
||||
}
|
||||
return chapterDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Descarga una imagen desde una URL y la guarda
|
||||
*/
|
||||
async downloadImage(url, filepath) {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
fs.writeFileSync(filepath, buffer);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
size: buffer.length,
|
||||
path: filepath
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error descargando ${url}:`, error.message);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Descarga todas las imágenes de un capítulo
|
||||
*/
|
||||
async downloadChapter(mangaSlug, chapterNumber, imageUrls) {
|
||||
const chapterDir = this.getChapterDir(mangaSlug, chapterNumber);
|
||||
const manifestPath = path.join(chapterDir, 'manifest.json');
|
||||
|
||||
// Verificar si ya está descargado
|
||||
if (fs.existsSync(manifestPath)) {
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
||||
return {
|
||||
success: true,
|
||||
alreadyDownloaded: true,
|
||||
manifest: manifest
|
||||
};
|
||||
}
|
||||
|
||||
console.log(`📥 Descargando capítulo ${chapterNumber} de ${mangaSlug}...`);
|
||||
console.log(` Directorio: ${chapterDir}`);
|
||||
|
||||
const downloaded = [];
|
||||
const failed = [];
|
||||
|
||||
// Descargar cada imagen
|
||||
for (let i = 0; i < imageUrls.length; i++) {
|
||||
const url = imageUrls[i];
|
||||
const filename = `page_${String(i + 1).padStart(3, '0')}.jpg`;
|
||||
const filepath = path.join(chapterDir, filename);
|
||||
|
||||
process.stdout.write(`\r ⏳ ${i + 1}/${imageUrls.length} (${Math.round((i / imageUrls.length) * 100)}%)`);
|
||||
|
||||
const result = await this.downloadImage(url, filepath);
|
||||
|
||||
if (result.success) {
|
||||
downloaded.push({
|
||||
page: i + 1,
|
||||
filename: filename,
|
||||
url: url,
|
||||
size: result.size,
|
||||
sizeKB: (result.size / 1024).toFixed(2)
|
||||
});
|
||||
process.stdout.write(`\r ✓ ${i + 1}/${imageUrls.length} (${((result.size / 1024)).toFixed(2)} KB) `);
|
||||
} else {
|
||||
failed.push({
|
||||
page: i + 1,
|
||||
url: url,
|
||||
error: result.error
|
||||
});
|
||||
process.stdout.write(`\r ✗ ${i + 1}/${imageUrls.length} (ERROR) `);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(); // Nueva línea
|
||||
|
||||
// Crear manifest
|
||||
const manifest = {
|
||||
mangaSlug: mangaSlug,
|
||||
chapterNumber: chapterNumber,
|
||||
totalPages: imageUrls.length,
|
||||
downloadedPages: downloaded.length,
|
||||
failedPages: failed.length,
|
||||
downloadDate: new Date().toISOString(),
|
||||
totalSize: downloaded.reduce((sum, img) => sum + img.size, 0),
|
||||
images: downloaded.map(img => ({
|
||||
page: img.page,
|
||||
filename: img.filename,
|
||||
url: img.url,
|
||||
size: img.size
|
||||
}))
|
||||
};
|
||||
|
||||
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
alreadyDownloaded: false,
|
||||
manifest: manifest,
|
||||
downloaded: downloaded.length,
|
||||
failed: failed.length
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica si un capítulo está descargado
|
||||
*/
|
||||
isChapterDownloaded(mangaSlug, chapterNumber) {
|
||||
const chapterDir = this.getChapterDir(mangaSlug, chapterNumber);
|
||||
const manifestPath = path.join(chapterDir, 'manifest.json');
|
||||
return fs.existsSync(manifestPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene el manifest de un capítulo descargado
|
||||
*/
|
||||
getChapterManifest(mangaSlug, chapterNumber) {
|
||||
const chapterDir = this.getChapterDir(mangaSlug, chapterNumber);
|
||||
const manifestPath = path.join(chapterDir, 'manifest.json');
|
||||
|
||||
if (!fs.existsSync(manifestPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene la ruta de una imagen específica
|
||||
*/
|
||||
getImagePath(mangaSlug, chapterNumber, pageIndex) {
|
||||
const chapterDir = this.getChapterDir(mangaSlug, chapterNumber);
|
||||
const filename = `page_${String(pageIndex).padStart(3, '0')}.jpg`;
|
||||
const imagePath = path.join(chapterDir, filename);
|
||||
|
||||
if (fs.existsSync(imagePath)) {
|
||||
return imagePath;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista todos los capítulos descargados de un manga
|
||||
*/
|
||||
listDownloadedChapters(mangaSlug) {
|
||||
const mangaDir = this.getMangaDir(mangaSlug);
|
||||
|
||||
if (!fs.existsSync(mangaDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const chapters = [];
|
||||
const items = fs.readdirSync(mangaDir);
|
||||
|
||||
items.forEach(item => {
|
||||
const match = item.match(/^chapter_(\d+)$/);
|
||||
if (match) {
|
||||
const chapterNumber = parseInt(match[1]);
|
||||
const manifest = this.getChapterManifest(mangaSlug, chapterNumber);
|
||||
|
||||
if (manifest) {
|
||||
chapters.push({
|
||||
chapterNumber: chapterNumber,
|
||||
downloadDate: manifest.downloadDate,
|
||||
totalPages: manifest.totalPages,
|
||||
downloadedPages: manifest.downloadedPages,
|
||||
totalSize: manifest.totalSize,
|
||||
totalSizeMB: (manifest.totalSize / 1024 / 1024).toFixed(2)
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return chapters.sort((a, b) => a.chapterNumber - b.chapterNumber);
|
||||
}
|
||||
|
||||
/**
|
||||
* Elimina un capítulo descargado
|
||||
*/
|
||||
deleteChapter(mangaSlug, chapterNumber) {
|
||||
const chapterDir = this.getChapterDir(mangaSlug, chapterNumber);
|
||||
|
||||
if (fs.existsSync(chapterDir)) {
|
||||
fs.rmSync(chapterDir, { recursive: true, force: true });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
return { success: false, error: 'Chapter not found' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene estadísticas de almacenamiento
|
||||
*/
|
||||
getStorageStats() {
|
||||
const stats = {
|
||||
totalMangas: 0,
|
||||
totalChapters: 0,
|
||||
totalSize: 0,
|
||||
mangaDetails: []
|
||||
};
|
||||
|
||||
const mangaDir = path.join(STORAGE_BASE_DIR, 'manga');
|
||||
|
||||
if (!fs.existsSync(mangaDir)) {
|
||||
return stats;
|
||||
}
|
||||
|
||||
const mangas = fs.readdirSync(mangaDir);
|
||||
|
||||
mangas.forEach(mangaSlug => {
|
||||
const chapters = this.listDownloadedChapters(mangaSlug);
|
||||
const totalSize = chapters.reduce((sum, ch) => sum + ch.totalSize, 0);
|
||||
|
||||
stats.totalMangas++;
|
||||
stats.totalChapters += chapters.length;
|
||||
stats.totalSize += totalSize;
|
||||
|
||||
stats.mangaDetails.push({
|
||||
mangaSlug: mangaSlug,
|
||||
chapters: chapters.length,
|
||||
totalSize: totalSize,
|
||||
totalSizeMB: (totalSize / 1024 / 1024).toFixed(2)
|
||||
});
|
||||
});
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatea tamaño de archivo
|
||||
*/
|
||||
formatFileSize(bytes) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
|
||||
}
|
||||
}
|
||||
|
||||
// Exportar instancia singleton
|
||||
export default new StorageService();
|
||||
Reference in New Issue
Block a user