🎯 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>
311 lines
8.1 KiB
JavaScript
311 lines
8.1 KiB
JavaScript
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();
|