manga / server.js
haaaaus's picture
Upload 17 files
78dd59e verified
const express = require('express');
const path = require('path');
const fs = require('fs');
const https = require('https');
const { formatStoryName, naturalSort } = require('./utils');
const { fetchHFData, forceRefreshHFData, startAutoRefresh, getHFStories, getHFCacheStatus, getHFAssetBase } = require('./hfService');
const app = express();
app.disable('x-powered-by'); // Hide "Express" header
const PORT = process.env.PORT || 3000;
const DATA_DIR = path.join(__dirname, 'data');
// Serve static files
app.use(express.static(path.join(__dirname, 'public')));
app.use('/data', express.static(DATA_DIR));
// ============================================
// Image Proxy Endpoint
// Proxy images from Hugging Face with proper URL encoding
// ============================================
app.get('/api/proxy/*', (req, res) => {
const assetPath = req.params[0];
if (!assetPath) {
return res.status(400).json({ error: 'Missing asset path' });
}
const MAX_REDIRECTS = 5;
const TIMEOUT_MS = 30000; // 30 seconds timeout
// Build properly encoded URL
const targetUrl = encodeURI(`${getHFAssetBase()}/${assetPath}`);
const fetchWithRedirects = (url, redirectCount = 0) => {
if (redirectCount > MAX_REDIRECTS) {
console.error('[Proxy] Too many redirects');
if (!res.headersSent) {
return res.status(502).json({ error: 'Too many redirects' });
}
return;
}
const request = https.get(url, { timeout: TIMEOUT_MS }, (proxyRes) => {
const { statusCode, headers } = proxyRes;
// Handle redirects (301, 302, 303, 307, 308)
if (statusCode >= 300 && statusCode < 400 && headers.location) {
return fetchWithRedirects(headers.location, redirectCount + 1);
}
// Handle non-success status codes
if (statusCode !== 200) {
console.error(`[Proxy] Upstream error: ${statusCode} for ${url.substring(0, 80)}`);
if (!res.headersSent) {
return res.status(statusCode).json({
error: 'Failed to fetch resource',
upstream_status: statusCode
});
}
return;
}
// Forward response headers
if (headers['content-type']) {
res.setHeader('Content-Type', headers['content-type']);
}
if (headers['content-length']) {
res.setHeader('Content-Length', headers['content-length']);
}
// Set caching headers (1 hour for images)
res.setHeader('Cache-Control', 'public, max-age=3600');
res.setHeader('X-Proxy-Source', 'huggingface');
// Stream response to client
proxyRes.pipe(res);
// Handle stream errors
proxyRes.on('error', (err) => {
console.error('[Proxy] Stream error:', err.message);
if (!res.headersSent) {
res.status(500).json({ error: 'Stream error' });
}
});
});
// Handle request errors
request.on('error', (err) => {
console.error('[Proxy] Request error:', err.message);
if (!res.headersSent) {
res.status(502).json({ error: 'Failed to connect to upstream' });
}
});
// Handle timeout
request.on('timeout', () => {
console.error('[Proxy] Request timeout');
request.destroy();
if (!res.headersSent) {
res.status(504).json({ error: 'Upstream timeout' });
}
});
};
fetchWithRedirects(targetUrl);
});
// Helper: Get local stories
function getLocalStories() {
try {
if (!fs.existsSync(DATA_DIR)) return [];
return fs.readdirSync(DATA_DIR, { withFileTypes: true })
.filter(dirent => dirent.isDirectory())
.map(dirent => {
const storyPath = path.join(DATA_DIR, dirent.name);
return { id: dirent.name, path: storyPath };
});
} catch (e) { return []; }
}
// API: Get all stories (Merged)
app.get('/api/stories', (req, res) => {
try {
// 1. Get Local Stories
const localStories = getLocalStories().map(s => {
const storyPath = s.path;
const chapters = fs.readdirSync(storyPath, { withFileTypes: true })
.filter(d => d.isDirectory());
// Cover logic
let cover = null;
const thumbFiles = fs.readdirSync(storyPath)
.filter(f => /^thum\.(jpg|jpeg|png|webp|gif)$/i.test(f));
if (thumbFiles.length > 0) {
cover = `/data/${s.id}/${thumbFiles[0]}`;
} else if (chapters.length > 0) {
try {
const firstChapterPath = path.join(storyPath, chapters[0].name);
const images = fs.readdirSync(firstChapterPath)
.filter(f => /\.(png|jpg|jpeg|webp|gif)$/i.test(f)).sort();
if (images.length > 0) cover = `/data/${s.id}/${chapters[0].name}/${images[0]}`;
} catch (e) { }
}
return {
id: s.id,
name: formatStoryName(s.id),
chapterCount: chapters.length,
cover: cover,
source: 'local'
};
});
// 2. Merge with HF Stories
const mergedMap = new Map();
const hfStories = getHFStories();
// Add local first
localStories.forEach(s => mergedMap.set(s.id, s));
// Merge HF
Object.values(hfStories).forEach(hfStory => {
// Construct proxy URL for cover
const proxyCover = hfStory.cover ? `/api/proxy/${hfStory.cover}` : null;
if (mergedMap.has(hfStory.id)) {
const local = mergedMap.get(hfStory.id);
if (!local.cover && proxyCover) local.cover = proxyCover;
} else {
mergedMap.set(hfStory.id, {
id: hfStory.id,
name: formatStoryName(hfStory.id),
chapterCount: Object.keys(hfStory.chapters).length,
cover: proxyCover,
source: 'remote'
});
}
});
res.json(Array.from(mergedMap.values()));
} catch (error) {
console.error(error);
res.status(500).json({ error: 'Failed to load stories' });
}
});
// API: Get chapters of a story
app.get('/api/stories/:story/chapters', (req, res) => {
try {
const storyId = req.params.story;
const localPath = path.join(DATA_DIR, storyId);
let chapters = [];
const seenChapters = new Set();
// 1. Local Chapters
if (fs.existsSync(localPath)) {
const localDirs = fs.readdirSync(localPath, { withFileTypes: true })
.filter(dirent => dirent.isDirectory())
.map(d => d.name)
.sort(naturalSort); // Use natural sort
chapters = localDirs.map(chapterName => {
seenChapters.add(chapterName);
const chapterPath = path.join(localPath, chapterName);
const images = fs.readdirSync(chapterPath).filter(f => /\.(png|jpg|jpeg|webp|gif)$/i.test(f));
let thumb = null;
if (images.length > 0) thumb = `/data/${storyId}/${chapterName}/${images[0]}`;
return {
id: chapterName,
name: `Chapter ${chapterName}`,
imageCount: images.length,
thumbnail: thumb,
source: 'local'
};
});
}
// 2. HF Chapters
const hfStories = getHFStories();
const hfStory = hfStories[storyId];
if (hfStory) {
const hfChapterIds = Object.keys(hfStory.chapters).sort(naturalSort); // Use natural sort
hfChapterIds.forEach(chId => {
if (!seenChapters.has(chId)) {
const imgs = hfStory.chapters[chId];
const thumb = imgs.length > 0 ? `/api/proxy/${imgs[0].path}` : null;
chapters.push({
id: chId,
name: `Chapter ${chId}`,
imageCount: imgs.length,
thumbnail: thumb,
source: 'remote'
});
}
});
}
// Re-sort all chapters
chapters.sort((a, b) => naturalSort(a.id, b.id)); // Use natural sort
res.json({
story: {
id: storyId,
name: formatStoryName(storyId)
},
chapters: chapters
});
} catch (error) {
console.error(error);
res.status(500).json({ error: 'Failed to load chapters' });
}
});
// API: Get images of a chapter
app.get('/api/stories/:story/chapters/:chapter/images', (req, res) => {
try {
const storyId = req.params.story;
const chapterId = req.params.chapter;
const hfStories = getHFStories();
let images = [];
let source = 'unknown';
// 1. Try Local first
const chapterPath = path.join(DATA_DIR, storyId, chapterId);
if (fs.existsSync(chapterPath)) {
images = fs.readdirSync(chapterPath)
.filter(f => /\.(png|jpg|jpeg|webp|gif)$/i.test(f))
.sort(naturalSort) // Optional: Sort images naturally too if they are numbered
.map(f => `/data/${storyId}/${chapterId}/${f}`);
source = 'local';
}
// 2. If not local, try HF
if (images.length === 0 && hfStories[storyId]?.chapters[chapterId]) {
images = hfStories[storyId].chapters[chapterId].map(img => `/api/proxy/${img.path}`);
source = 'remote';
}
if (images.length === 0) {
return res.status(404).json({ error: 'Chapter not found' });
}
// Navigation Logic
let allChapterIds = new Set();
// Local IDs
if (fs.existsSync(path.join(DATA_DIR, storyId))) {
fs.readdirSync(path.join(DATA_DIR, storyId), { withFileTypes: true })
.filter(d => d.isDirectory())
.forEach(d => allChapterIds.add(d.name));
}
// HF IDs
if (hfStories[storyId]) {
Object.keys(hfStories[storyId].chapters).forEach(id => allChapterIds.add(id));
}
// Sort using natural sort
const sortedChapters = Array.from(allChapterIds).sort(naturalSort);
const currentIndex = sortedChapters.indexOf(chapterId);
res.json({
story: { id: storyId, name: formatStoryName(storyId) },
chapter: { id: chapterId, name: `Chapter ${chapterId}` },
images: images,
source: source,
navigation: {
prevChapter: currentIndex > 0 ? sortedChapters[currentIndex - 1] : null,
nextChapter: currentIndex < sortedChapters.length - 1 ? sortedChapters[currentIndex + 1] : null,
totalChapters: sortedChapters.length,
currentIndex: currentIndex + 1
}
});
} catch (error) {
console.error(error);
res.status(500).json({ error: 'Failed to load images' });
}
});
// API: Get cache status and manual refresh
app.get('/api/hf-status', (req, res) => {
res.json(getHFCacheStatus());
});
// Refresh HF data - supports both GET and POST
app.get('/api/refresh', async (req, res) => {
try {
const result = await forceRefreshHFData();
res.json({ success: true, ...result });
} catch (error) {
res.status(500).json({ success: false, error: 'Failed to refresh' });
}
});
app.post('/api/refresh', async (req, res) => {
try {
const result = await forceRefreshHFData();
res.json({ success: true, ...result });
} catch (error) {
res.status(500).json({ success: false, error: 'Failed to refresh' });
}
});
// Fallback to index.html for SPA routes - MUST BE LAST
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
// Start server and fetch data
app.listen(PORT, async () => {
console.log(`🚀 Web Truyện is running at http://localhost:${PORT}`);
await fetchHFData();
startAutoRefresh(); // Enable auto-refresh every 15 minutes
});