Files
MangaReader/backend/server.js
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

441 lines
11 KiB
JavaScript

import express from 'express';
import cors from 'cors';
import path from 'path';
import { fileURLToPath } from 'url';
import {
getMangaInfo,
getMangaChapters,
getChapterImages,
getPopularMangas
} from './scraper.js';
import storageService from './storage.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const app = express();
const PORT = process.env.PORT || 3000;
// Middleware
app.use(cors());
app.use(express.json());
// Serve static files from storage directory
app.use('/storage', express.static(path.join(__dirname, '../storage')));
// Cache simple (en memoria, se puede mejorar con Redis)
const cache = new Map();
const CACHE_TTL = 5 * 60 * 1000; // 5 minutos
function getCached(key) {
const cached = cache.get(key);
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
return cached.data;
}
return null;
}
function setCache(key, data) {
cache.set(key, { data, timestamp: Date.now() });
}
// Routes
/**
* @route GET /api/health
* @desc Health check endpoint
*/
app.get('/api/health', (req, res) => {
res.json({ status: 'ok', message: 'MangaReader API is running' });
});
/**
* @route GET /api/mangas/popular
* @desc Obtener lista de mangas populares
* @query ?force=true - Ignorar cache
*/
app.get('/api/mangas/popular', async (req, res) => {
try {
const { force } = req.query;
if (!force) {
const cached = getCached('popular');
if (cached) {
return res.json(cached);
}
}
const mangas = await getPopularMangas();
setCache('popular', mangas);
res.json(mangas);
} catch (error) {
console.error('Error getting popular mangas:', error);
res.status(500).json({
error: 'Error obteniendo mangas populares',
message: error.message
});
}
});
/**
* @route GET /api/manga/:slug
* @desc Obtener información de un manga específico
* @param slug - Slug del manga (ej: one-piece_1695365223767)
*/
app.get('/api/manga/:slug', async (req, res) => {
try {
const { slug } = req.params;
const cacheKey = `manga_${slug}`;
const cached = getCached(cacheKey);
if (cached) {
return res.json(cached);
}
const manga = await getMangaInfo(slug);
setCache(cacheKey, manga);
res.json(manga);
} catch (error) {
console.error('Error getting manga info:', error);
res.status(500).json({
error: 'Error obteniendo información del manga',
message: error.message
});
}
});
/**
* @route GET /api/manga/:slug/chapters
* @desc Obtener lista de capítulos de un manga
* @param slug - Slug del manga
*/
app.get('/api/manga/:slug/chapters', async (req, res) => {
try {
const { slug } = req.params;
const cacheKey = `chapters_${slug}`;
const cached = getCached(cacheKey);
if (cached) {
return res.json(cached);
}
const chapters = await getMangaChapters(slug);
setCache(cacheKey, chapters);
res.json(chapters);
} catch (error) {
console.error('Error getting manga chapters:', error);
res.status(500).json({
error: 'Error obteniendo capítulos del manga',
message: error.message
});
}
});
/**
* @route GET /api/chapter/:slug/images
* @desc Obtener imágenes de un capítulo
* @param slug - Slug del capítulo (ej: one-piece_1695365223767-1172)
* @query ?force=true - Ignorar cache
*/
app.get('/api/chapter/:slug/images', async (req, res) => {
try {
const { slug } = req.params;
const { force } = req.query;
const cacheKey = `images_${slug}`;
if (!force) {
const cached = getCached(cacheKey);
if (cached) {
return res.json(cached);
}
}
const images = await getChapterImages(slug);
setCache(cacheKey, images);
res.json(images);
} catch (error) {
console.error('Error getting chapter images:', error);
res.status(500).json({
error: 'Error obteniendo imágenes del capítulo',
message: error.message
});
}
});
/**
* @route GET /api/manga/:slug/full
* @desc Obtener info completa del manga con capítulos
* @param slug - Slug del manga
*/
app.get('/api/manga/:slug/full', async (req, res) => {
try {
const { slug } = req.params;
const cacheKey = `full_${slug}`;
const cached = getCached(cacheKey);
if (cached) {
return res.json(cached);
}
// Obtener info y capítulos en paralelo
const [manga, chapters] = await Promise.all([
getMangaInfo(slug),
getMangaChapters(slug)
]);
const fullData = {
...manga,
chapters: chapters
};
setCache(cacheKey, fullData);
res.json(fullData);
} catch (error) {
console.error('Error getting full manga data:', error);
res.status(500).json({
error: 'Error obteniendo datos completos del manga',
message: error.message
});
}
});
// ==================== STORAGE ENDPOINTS ====================
/**
* @route POST /api/download
* @desc Request to download a chapter
* @body { mangaSlug, chapterNumber, imageUrls }
*/
app.post('/api/download', async (req, res) => {
try {
const { mangaSlug, chapterNumber, imageUrls } = req.body;
if (!mangaSlug || !chapterNumber || !imageUrls) {
return res.status(400).json({
error: 'Missing required fields',
required: ['mangaSlug', 'chapterNumber', 'imageUrls']
});
}
if (!Array.isArray(imageUrls)) {
return res.status(400).json({
error: 'imageUrls must be an array'
});
}
console.log(`\n📥 Download request received:`);
console.log(` Manga: ${mangaSlug}`);
console.log(` Chapter: ${chapterNumber}`);
console.log(` Images: ${imageUrls.length}`);
const result = await storageService.downloadChapter(
mangaSlug,
chapterNumber,
imageUrls
);
res.json(result);
} catch (error) {
console.error('Error downloading chapter:', error);
res.status(500).json({
error: 'Error descargando capítulo',
message: error.message
});
}
});
/**
* @route GET /api/storage/chapters/:mangaSlug
* @desc List downloaded chapters for a manga
* @param mangaSlug - Slug of the manga
*/
app.get('/api/storage/chapters/:mangaSlug', (req, res) => {
try {
const { mangaSlug } = req.params;
const chapters = storageService.listDownloadedChapters(mangaSlug);
res.json({
mangaSlug: mangaSlug,
totalChapters: chapters.length,
chapters: chapters
});
} catch (error) {
console.error('Error listing downloaded chapters:', error);
res.status(500).json({
error: 'Error listando capítulos descargados',
message: error.message
});
}
});
/**
* @route GET /api/storage/chapter/:mangaSlug/:chapterNumber
* @desc Check if a chapter is downloaded and return manifest
* @param mangaSlug - Slug of the manga
* @param chapterNumber - Chapter number
*/
app.get('/api/storage/chapter/:mangaSlug/:chapterNumber', (req, res) => {
try {
const { mangaSlug, chapterNumber } = req.params;
const manifest = storageService.getChapterManifest(
mangaSlug,
parseInt(chapterNumber)
);
if (!manifest) {
return res.status(404).json({
error: 'Capítulo no encontrado',
message: `Chapter ${chapterNumber} of ${mangaSlug} is not downloaded`
});
}
res.json(manifest);
} catch (error) {
console.error('Error getting chapter manifest:', error);
res.status(500).json({
error: 'Error obteniendo manifest del capítulo',
message: error.message
});
}
});
/**
* @route GET /api/storage/image/:mangaSlug/:chapterNumber/:pageIndex
* @desc Serve an image from a downloaded chapter
* @param mangaSlug - Slug of the manga
* @param chapterNumber - Chapter number
* @param pageIndex - Page index (1-based)
*/
app.get('/api/storage/image/:mangaSlug/:chapterNumber/:pageIndex', (req, res) => {
try {
const { mangaSlug, chapterNumber, pageIndex } = req.params;
const imagePath = storageService.getImagePath(
mangaSlug,
parseInt(chapterNumber),
parseInt(pageIndex)
);
if (!imagePath) {
return res.status(404).json({
error: 'Imagen no encontrada',
message: `Page ${pageIndex} of chapter ${chapterNumber} not found`
});
}
res.sendFile(imagePath);
} catch (error) {
console.error('Error serving image:', error);
res.status(500).json({
error: 'Error sirviendo imagen',
message: error.message
});
}
});
/**
* @route DELETE /api/storage/chapter/:mangaSlug/:chapterNumber
* @desc Delete a downloaded chapter
* @param mangaSlug - Slug of the manga
* @param chapterNumber - Chapter number
*/
app.delete('/api/storage/chapter/:mangaSlug/:chapterNumber', (req, res) => {
try {
const { mangaSlug, chapterNumber } = req.params;
const result = storageService.deleteChapter(
mangaSlug,
parseInt(chapterNumber)
);
if (!result.success) {
return res.status(404).json({
error: 'Capítulo no encontrado',
message: result.error
});
}
res.json({
success: true,
message: `Chapter ${chapterNumber} of ${mangaSlug} deleted successfully`
});
} catch (error) {
console.error('Error deleting chapter:', error);
res.status(500).json({
error: 'Error eliminando capítulo',
message: error.message
});
}
});
/**
* @route GET /api/storage/stats
* @desc Get storage statistics
*/
app.get('/api/storage/stats', (req, res) => {
try {
const stats = storageService.getStorageStats();
res.json({
totalMangas: stats.totalMangas,
totalChapters: stats.totalChapters,
totalSize: stats.totalSize,
totalSizeMB: (stats.totalSize / 1024 / 1024).toFixed(2),
totalSizeFormatted: storageService.formatFileSize(stats.totalSize),
mangaDetails: stats.mangaDetails
});
} catch (error) {
console.error('Error getting storage stats:', error);
res.status(500).json({
error: 'Error obteniendo estadísticas de almacenamiento',
message: error.message
});
}
});
// ==================== END OF STORAGE ENDPOINTS ====================
// Error handler
app.use((err, req, res, next) => {
console.error('Unhandled error:', err);
res.status(500).json({
error: 'Error interno del servidor',
message: err.message
});
});
// 404 handler
app.use((req, res) => {
res.status(404).json({
error: 'Endpoint no encontrado',
path: req.path
});
});
// Start server
app.listen(PORT, () => {
console.log(`🚀 MangaReader API corriendo en puerto ${PORT}`);
console.log(`📚 API disponible en: http://localhost:${PORT}/api`);
console.log(`\nEndpoints disponibles:`);
console.log(`\n 📖 MANGA ENDPOINTS:`);
console.log(` GET /api/health`);
console.log(` GET /api/mangas/popular`);
console.log(` GET /api/manga/:slug`);
console.log(` GET /api/manga/:slug/chapters`);
console.log(` GET /api/chapter/:slug/images`);
console.log(` GET /api/manga/:slug/full`);
console.log(`\n 💾 STORAGE ENDPOINTS:`);
console.log(` POST /api/download`);
console.log(` GET /api/storage/chapters/:mangaSlug`);
console.log(` GET /api/storage/chapter/:mangaSlug/:chapterNumber`);
console.log(` GET /api/storage/image/:mangaSlug/:chapterNumber/:pageIndex`);
console.log(` DEL /api/storage/chapter/:mangaSlug/:chapterNumber`);
console.log(` GET /api/storage/stats`);
console.log(`\n 📁 Static files: /storage`);
});