Files
Skit-Panel/webplayer/server.js
DengDai 519589f8f5 init
2025-12-08 14:45:14 +08:00

235 lines
9.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 引入 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);
})();