// 引入 dotenv,并执行 config() // 这会让 Node.js 从 .env 文件中加载环境变量 require('dotenv').config(); const express = require('express'); const path = require('path'); const fs = require('fs'); const fsp = fs.promises; // 引入 fs.promises 以便使用 async/await const fetch = (...args) => import('node-fetch').then(({default: fetch}) => fetch(...args)); const app = express(); // --- 1. 配置和全局变量 --- // 从环境变量中读取配置,如果环境变量未设置,则使用默认值 const port = process.env.PORT || 8101; // Docker 容器内的视频目录路径,将通过 Docker volumes 映射到宿主机上的实际目录 const videosDir = process.env.VIDEOS_DIR || '/videos'; const UPDATE_INTERVAL_MS = parseInt(process.env.UPDATE_INTERVAL_MS, 10) || 30 * 60 * 1000; // 默认30分钟 // 从环境变量读取有效的子目录列表,以逗号分隔,并去除空格 const VALID_SUBDIRECTORIES = process.env.VALID_SUBDIRECTORIES ? process.env.VALID_SUBDIRECTORIES.split(',').map(s => s.trim()) : ['分集']; const IYUU_KEY = process.env.IYUU_KEY || "IYUU20053Tcb524cc74f1cbdc348e75a25124525282bd78bd6"; const indexFilePath = path.join(__dirname, 'series_index.json'); // 索引文件将存放在项目根目录 // 内存缓存,所有 /api/series 请求都将从这里以极速读取数据 let seriesCache = []; // --- 2. 中间件 --- app.use(express.json()); // 用于解析 /api/request 的 JSON body // 托管 'public' 文件夹,用于前端文件 app.use(express.static('public')); // 将 E:/videos/short 目录映射到 URL 的 /videos 路径下 app.use('/videos', express.static(videosDir)); // --- 3. 核心索引更新函数 (新增) --- /** * 异步扫描视频目录,生成剧集列表,更新缓存并写入 JSON 文件。 * 这个函数会优先在剧集根目录查找海报,如果找不到,则会继续搜索 VALID_SUBDIRECTORIES 中定义的子目录。 */ async function updateAndCacheSeries() { console.log('(后台任务)开始更新剧集索引...'); try { const dirents = await fsp.readdir(videosDir, { withFileTypes: true }); const seriesPromises = dirents .filter(dirent => dirent.isDirectory()) .map(async (dirent) => { const seriesName = dirent.name; const seriesPath = path.join(videosDir, seriesName); const potentialPosters = ['poster.png', 'poster.jpg', '0.jpg', '0.png', "00.jpg", "poster.jpeg", "poster.webp", "0.jpeg", "00.png"]; // --- 核心修改:分步查找海报 --- let posterRelativePath = null; // 1. 优先在剧集根目录查找 for (const filename of potentialPosters) { try { await fsp.access(path.join(seriesPath, filename)); posterRelativePath = filename; // 路径就是文件名本身 break; // 找到了就跳出循环 } catch (e) { /* 文件不存在,继续 */ } } // 2. 如果根目录没找到,则到指定的子目录中查找 if (!posterRelativePath) { try { const itemsInSeries = await fsp.readdir(seriesPath, { withFileTypes: true }); const subdirsToScan = itemsInSeries .filter(item => item.isDirectory() && VALID_SUBDIRECTORIES.includes(item.name)) .map(item => item.name); // 使用 labeled loop 以便找到后能跳出所有循环 search_in_subdirs: for (const subdir of subdirsToScan) { for (const filename of potentialPosters) { const posterFullPath = path.join(seriesPath, subdir, filename); try { await fsp.access(posterFullPath); // 【关键】路径必须包含子目录,并使用'/'作为分隔符 posterRelativePath = path.posix.join(subdir, filename); break search_in_subdirs; // 找到一个就彻底退出 } catch (e) { /* 文件不存在,继续 */ } } } } catch(err) { console.warn(`[警告] 扫描 ${seriesName} 的子目录时出错:`, err.message); } } if (!posterRelativePath) { console.warn(`[警告] 在 ${seriesName} 目录及其子目录中均未找到任何海报文件。`); } // 使用找到的相对路径构建最终URL const posterUrl = posterRelativePath // posterRelativePath 可能是 'poster.jpg' 或 '分集/poster.jpg' ? `./videos/${encodeURIComponent(seriesName)}/${posterRelativePath}` : './default-poster.png'; // 备用默认海报 return { name: seriesName, posterUrl: posterUrl, }; }); const newSeriesList = await Promise.all(seriesPromises); newSeriesList.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN')); seriesCache = newSeriesList; await fsp.writeFile(indexFilePath, JSON.stringify(seriesCache, null, 2), 'utf-8'); console.log(`(后台任务)索引更新成功!共找到 ${seriesCache.length} 个剧集。`); } catch (err) { console.error('(后台任务)更新索引时发生严重错误:', err); } } // --- 4. API 路由 --- // 您的心愿单请求API app.post('/api/request', async (req, res) => { const { dramaName } = req.body; if (!dramaName || typeof dramaName !== 'string' || dramaName.trim() === '') { return res.status(400).json({ message: '剧集名称不能为空' }); } const IYUU_WEBHOOK_URL = `https://iyuu.cn/${IYUU_KEY}.send`; const message = `收到想看剧集:${dramaName.trim()}`; const targetUrl = `${IYUU_WEBHOOK_URL}?text=${encodeURIComponent(message)}`; try { console.log(`正在向 IYUU 推送心愿: ${dramaName}`); const response = await fetch(targetUrl, { method: 'POST' }); if (!response.ok) { const errorText = await response.text(); console.error(`Webhook 推送失败: ${response.status} - ${errorText}`); throw new Error(`推送服务返回错误`); } res.status(200).json({ message: "心愿提交成功!" }); } catch (error) { console.error("处理心愿请求时服务器内部错误:", error); res.status(500).json({ message: "服务器在推送心愿时发生错误" }); } }); // API: 获取短剧列表 (现在从内存缓存读取,极速响应) app.get('/api/series', (req, res) => { res.json(seriesCache); }); // API: 获取某个短剧的视频文件列表 (改为 async/await,代码更清晰) app.get('/api/videos/:seriesName', async (req, res) => { // --- 修改:不再在此处定义,而是使用全局常量 --- // const validSubdirectories = ['分集', '剧集', 'episodes', 'vids']; try { const seriesName = decodeURIComponent(req.params.seriesName); const seriesPath = path.join(videosDir, seriesName); if (!seriesPath.startsWith(videosDir)) { return res.status(403).send('非法访问'); } let allVideoFiles = []; const dirents = await fsp.readdir(seriesPath, { withFileTypes: true }); const rootVideoFiles = dirents .filter(dirent => dirent.isFile() && ['.mp4', '.webm', '.mov', '.mkv', '.avi'].includes(path.extname(dirent.name).toLowerCase())) .map(dirent => dirent.name); allVideoFiles.push(...rootVideoFiles); // --- 修改:使用全局常量 VALID_SUBDIRECTORIES --- const subdirsToScan = dirents .filter(dirent => dirent.isDirectory() && VALID_SUBDIRECTORIES.includes(dirent.name)) .map(dirent => dirent.name); const subdirVideoPromises = subdirsToScan.map(async (subdirName) => { const subdirPath = path.join(seriesPath, subdirName); const filesInSubdir = await fsp.readdir(subdirPath); return filesInSubdir .filter(file => ['.mp4', '.webm', '.mov', '.mkv', '.avi'].includes(path.extname(file).toLowerCase())) .map(videoFile => path.posix.join(subdirName, videoFile)); }); const nestedVideoArrays = await Promise.all(subdirVideoPromises); allVideoFiles.push(...nestedVideoArrays.flat()); const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }); allVideoFiles.sort(collator.compare); res.json(allVideoFiles); } catch (err) { console.error(`读取短剧 "${req.params.seriesName}" 目录时出错:`, err); if (err.code === 'ENOENT') { return res.status(404).send('短剧不存在'); } res.status(500).send('服务器内部错误'); } }); // --- 5. 服务器启动逻辑 (新增的启动模式) --- (async () => { // 步骤 A: 尝试从文件加载现有索引,实现快速启动,避免冷启动时列表为空 try { const indexData = await fsp.readFile(indexFilePath, 'utf-8'); seriesCache = JSON.parse(indexData); console.log(`成功从索引文件加载了 ${seriesCache.length} 条剧集数据到缓存。`); } catch (err) { console.log('未找到现有索引文件或加载失败。将在后台立即创建新的索引。'); } // 步骤 B: 启动 Express 服务器,使其可以立即响应请求 app.listen(port, () => { // 【保持您的日志风格】 console.log(`Server running on http://localhost:${port}`); console.log(`Frontend served from 'public' directory.`); console.log(`Videos & Posters served from '${videosDir}' and mapped to '/videos' URL.`); }); // 步骤 C: 在后台立即执行一次完整的索引更新,以获取最新数据 await updateAndCacheSeries(); // 步骤 D: 设置定时器,定期在后台更新索引 setInterval(updateAndCacheSeries, UPDATE_INTERVAL_MS); })();