This commit is contained in:
DengDai
2025-12-08 14:45:14 +08:00
commit 519589f8f5
60 changed files with 8191 additions and 0 deletions

234
webplayer/server.js Normal file
View File

@@ -0,0 +1,234 @@
// 引入 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);
})();