| | 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');
|
| |
|
| | const PORT = process.env.PORT || 3000;
|
| | const DATA_DIR = path.join(__dirname, 'data');
|
| |
|
| |
|
| | app.use(express.static(path.join(__dirname, 'public')));
|
| | app.use('/data', express.static(DATA_DIR));
|
| |
|
| |
|
| |
|
| |
|
| |
|
| | 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;
|
| |
|
| |
|
| | 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;
|
| |
|
| |
|
| | if (statusCode >= 300 && statusCode < 400 && headers.location) {
|
| | return fetchWithRedirects(headers.location, redirectCount + 1);
|
| | }
|
| |
|
| |
|
| | 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;
|
| | }
|
| |
|
| |
|
| | if (headers['content-type']) {
|
| | res.setHeader('Content-Type', headers['content-type']);
|
| | }
|
| | if (headers['content-length']) {
|
| | res.setHeader('Content-Length', headers['content-length']);
|
| | }
|
| |
|
| |
|
| | res.setHeader('Cache-Control', 'public, max-age=3600');
|
| | res.setHeader('X-Proxy-Source', 'huggingface');
|
| |
|
| |
|
| | proxyRes.pipe(res);
|
| |
|
| |
|
| | proxyRes.on('error', (err) => {
|
| | console.error('[Proxy] Stream error:', err.message);
|
| | if (!res.headersSent) {
|
| | res.status(500).json({ error: 'Stream error' });
|
| | }
|
| | });
|
| | });
|
| |
|
| |
|
| | request.on('error', (err) => {
|
| | console.error('[Proxy] Request error:', err.message);
|
| | if (!res.headersSent) {
|
| | res.status(502).json({ error: 'Failed to connect to upstream' });
|
| | }
|
| | });
|
| |
|
| |
|
| | request.on('timeout', () => {
|
| | console.error('[Proxy] Request timeout');
|
| | request.destroy();
|
| | if (!res.headersSent) {
|
| | res.status(504).json({ error: 'Upstream timeout' });
|
| | }
|
| | });
|
| | };
|
| |
|
| | fetchWithRedirects(targetUrl);
|
| | });
|
| |
|
| |
|
| | 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 []; }
|
| | }
|
| |
|
| |
|
| | app.get('/api/stories', (req, res) => {
|
| | try {
|
| |
|
| | const localStories = getLocalStories().map(s => {
|
| | const storyPath = s.path;
|
| | const chapters = fs.readdirSync(storyPath, { withFileTypes: true })
|
| | .filter(d => d.isDirectory());
|
| |
|
| |
|
| | 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'
|
| | };
|
| | });
|
| |
|
| |
|
| | const mergedMap = new Map();
|
| | const hfStories = getHFStories();
|
| |
|
| |
|
| | localStories.forEach(s => mergedMap.set(s.id, s));
|
| |
|
| |
|
| | Object.values(hfStories).forEach(hfStory => {
|
| |
|
| | 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' });
|
| | }
|
| | });
|
| |
|
| |
|
| | 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();
|
| |
|
| |
|
| | if (fs.existsSync(localPath)) {
|
| | const localDirs = fs.readdirSync(localPath, { withFileTypes: true })
|
| | .filter(dirent => dirent.isDirectory())
|
| | .map(d => d.name)
|
| | .sort(naturalSort);
|
| |
|
| | 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'
|
| | };
|
| | });
|
| | }
|
| |
|
| |
|
| | const hfStories = getHFStories();
|
| | const hfStory = hfStories[storyId];
|
| | if (hfStory) {
|
| | const hfChapterIds = Object.keys(hfStory.chapters).sort(naturalSort);
|
| |
|
| | 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'
|
| | });
|
| | }
|
| | });
|
| | }
|
| |
|
| |
|
| | chapters.sort((a, b) => naturalSort(a.id, b.id));
|
| |
|
| | res.json({
|
| | story: {
|
| | id: storyId,
|
| | name: formatStoryName(storyId)
|
| | },
|
| | chapters: chapters
|
| | });
|
| | } catch (error) {
|
| | console.error(error);
|
| | res.status(500).json({ error: 'Failed to load chapters' });
|
| | }
|
| | });
|
| |
|
| |
|
| | 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';
|
| |
|
| |
|
| | 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)
|
| | .map(f => `/data/${storyId}/${chapterId}/${f}`);
|
| | source = 'local';
|
| | }
|
| |
|
| | 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' });
|
| | }
|
| |
|
| |
|
| | let allChapterIds = new Set();
|
| |
|
| |
|
| | 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));
|
| | }
|
| |
|
| | if (hfStories[storyId]) {
|
| | Object.keys(hfStories[storyId].chapters).forEach(id => allChapterIds.add(id));
|
| | }
|
| |
|
| |
|
| | 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' });
|
| | }
|
| | });
|
| |
|
| |
|
| | app.get('/api/hf-status', (req, res) => {
|
| | res.json(getHFCacheStatus());
|
| | });
|
| |
|
| |
|
| | 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' });
|
| | }
|
| | });
|
| |
|
| |
|
| | app.get('*', (req, res) => {
|
| | res.sendFile(path.join(__dirname, 'public', 'index.html'));
|
| | });
|
| |
|
| |
|
| | app.listen(PORT, async () => {
|
| | console.log(`🚀 Web Truyện is running at http://localhost:${PORT}`);
|
| | await fetchHFData();
|
| | startAutoRefresh();
|
| | });
|
| |
|