init
This commit is contained in:
234
webplayer/server.js
Normal file
234
webplayer/server.js
Normal 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);
|
||||
})();
|
||||
Reference in New Issue
Block a user