UNPKG

@lzwme/m3u8-dl

Version:

A free, open-source, and powerful m3u8 video batch downloader with multi-threaded downloading, play-while-downloading, WebUI management, video parsing, and more.

817 lines (816 loc) 40.7 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); Object.defineProperty(exports, "__esModule", { value: true }); exports.DLServer = void 0; const node_fs_1 = require("node:fs"); const node_os_1 = require("node:os"); const node_path_1 = require("node:path"); const fe_utils_1 = require("@lzwme/fe-utils"); const console_log_colors_1 = require("console-log-colors"); const file_download_js_1 = require("../lib/file-download.js"); const format_options_js_1 = require("../lib/format-options.js"); const getM3u8Urls_js_1 = require("../lib/getM3u8Urls.js"); const i18n_js_1 = require("../lib/i18n.js"); const init_proxy_js_1 = require("../lib/init-proxy.js"); const m3u8_download_js_1 = require("../lib/m3u8-download.js"); const utils_js_1 = require("../lib/utils.js"); const index_js_1 = require("../video-parser/index.js"); const rootDir = (0, node_path_1.resolve)(__dirname, '../..'); class DLServer { app = null; wss = null; /** DS 参数 */ options = { port: Number(process.env.DS_PORT) || 6600, cacheDir: process.env.DS_CACHE_DIR || (0, node_path_1.resolve)((0, node_os_1.homedir)(), '.m3u8-dl/cache'), token: process.env.DS_SECRET || process.env.DS_TOKEN || '', debug: process.env.DS_DEBUG === '1', limitFileAccess: ['1', 'true'].includes(process.env.DS_LIMTE_FILE_ACCESS), }; serverInfo = { version: '', ariang: false, }; cfg = { /** 支持 web 设置修改的参数 */ webOptions: { /** 最多同时下载数量。超过改值则设置为 pending */ maxDownloads: 3, /** 是否显示预览按钮 */ showPreview: true, /** 是否显示边下边播按钮 */ showLocalPlay: true, }, /** download 下载默认参数 */ dlOptions: { debug: process.env.DS_DEBUG === '1', saveDir: process.env.DS_SAVE_DIR || './downloads', threadNum: 4, ffmpegPath: process.env.DS_FFMPEG_PATH || undefined, // 代理配置改为字符串模式:'custom', 'system', 'disabled' proxyMode: process.env.DS_PROXY_MODE || 'system', proxyUrl: process.env.DS_PROXY_URL || undefined, noProxy: process.env.DS_NO_PROXY || undefined, }, }; /** 下载任务缓存 */ dlCache = new Map(); /** 正在下载的任务数量 */ get downloading() { return Array.from(this.dlCache.values()).filter(item => item.status === 'resume').length; } constructor(opts = {}) { opts = Object.assign(this.options, opts); opts.cacheDir = (0, node_path_1.resolve)(opts.cacheDir); if (!opts.configPath) opts.configPath = (0, node_path_1.resolve)(opts.cacheDir, 'config.json'); if (opts.token) opts.token = (0, fe_utils_1.md5)(opts.token.trim()).slice(0, 8); this.init(); } async init() { const pkgFile = (0, node_path_1.resolve)(rootDir, 'package.json'); if (await (0, utils_js_1.checkFileExists)(pkgFile)) { const pkg = JSON.parse(await node_fs_1.promises.readFile(pkgFile, 'utf8')); this.serverInfo.version = pkg.version; } this.serverInfo.ariang = await (0, utils_js_1.checkFileExists)((0, node_path_1.resolve)(rootDir, 'client/ariang/index.html')); await this.loadConfig(); if (this.cfg.dlOptions.debug) utils_js_1.logger.updateOptions({ levelType: 'debug' }); await this.loadCache(); await this.createApp(); this.initRouters(); utils_js_1.logger.debug('Server initialized', 'cacheSize:', this.dlCache.size, this.options, this.cfg.dlOptions); // 初始化 global-agent 代理 (0, init_proxy_js_1.initProxy)(this.cfg.dlOptions); } async loadCache() { const cacheFile = (0, node_path_1.resolve)(this.options.cacheDir, 'cache.json'); if (await (0, utils_js_1.checkFileExists)(cacheFile)) { JSON.parse(await node_fs_1.promises.readFile(cacheFile, 'utf8')).forEach(([url, item]) => { if (item.status === 'resume') item.status = 'pause'; this.dlCache.set(url, item); }); this.checkDLFileIsExists(); } } checkDLFileLaest = 0; checkDLFileTimer = null; async checkDLFileIsExists() { const now = Date.now(); const interval = 1000 * 60; clearTimeout(this.checkDLFileTimer); const delay = this.checkDLFileLaest + interval - now; if (delay > 0) { this.checkDLFileTimer = setTimeout(() => { this.checkDLFileIsExists(); }, delay + 100); return; } const tasks = [...this.dlCache.values()].map(item => () => this.checkItemStatus(item)); await (0, fe_utils_1.concurrency)(tasks, 3); this.checkDLFileLaest = now; } async checkItemStatus(item) { if (item.status === 'done') { if (item.current) { if (!item.cacheDir && item.current.tsOut) item.cacheDir = (0, node_path_1.dirname)(item.current.tsOut); delete item.current; } if (!(await (0, utils_js_1.checkFileExists)(item.localVideo)) && !(0, node_fs_1.existsSync)(item.localVideo)) { item.status = 'error'; item.errmsg = '已删除'; } } else if (item.status === 'error' && item.progress === 100) { if (await (0, utils_js_1.checkFileExists)(item.localVideo)) { item.status = 'done'; delete item.errmsg; } } } dlCacheClone() { const info = []; for (const [url, v] of this.dlCache) { const { workPoll, ...item } = v; for (const [key, value] of Object.entries(item)) { if (typeof value === 'function') delete item[key]; } info.push([url, item]); } return info; } cacheSaveTimer = null; saveCache() { clearTimeout(this.cacheSaveTimer); this.cacheSaveTimer = setTimeout(() => { const cacheFile = (0, node_path_1.resolve)(this.options.cacheDir, 'cache.json'); (0, fe_utils_1.mkdirp)((0, node_path_1.dirname)(cacheFile)); node_fs_1.promises.writeFile(cacheFile, JSON.stringify(this.dlCacheClone())); }, 1000); } async loadConfig(configPath) { try { if (!configPath) configPath = this.options.configPath; if (await (0, utils_js_1.checkFileExists)(configPath)) (0, fe_utils_1.assign)(this.cfg, JSON.parse(await node_fs_1.promises.readFile(configPath, 'utf8'))); } catch (error) { utils_js_1.logger.error('Load config failed:', error); } } async saveConfig(config, configPath) { if (!configPath) configPath = this.options.configPath; // 验证 ffmpegPath 是否存在 if (config.ffmpegPath?.trim()) { const ffmpegPath = config.ffmpegPath.trim(); if (!(await (0, utils_js_1.checkFileExists)(ffmpegPath))) { throw new Error(`ffmpeg 路径不存在: ${ffmpegPath}`); } // 检查是否为文件(不是目录) const stats = await node_fs_1.promises.stat(ffmpegPath); if (!stats.isFile()) { throw new Error(`ffmpeg 路径不是文件: ${ffmpegPath}`); } } for (const [key, value] of Object.entries(config)) { // @ts-expect-error 忽略类型错误 if (key in this.cfg.webOptions) this.cfg.webOptions[key] = value; // @ts-expect-error 忽略类型错误 else this.cfg.dlOptions[key] = value; } (0, fe_utils_1.mkdirp)((0, node_path_1.dirname)(configPath)); const result = await node_fs_1.promises.writeFile(configPath, JSON.stringify(this.cfg, null, 2)); // 重新初始化代理 await (0, init_proxy_js_1.initProxy)(this.cfg.dlOptions); return result; } async createApp() { const { default: express } = await Promise.resolve().then(() => __importStar(require('express'))); const { WebSocketServer } = await Promise.resolve().then(() => __importStar(require('ws'))); const app = express(); const server = app.listen(this.options.port, () => utils_js_1.logger.info(`Server running on port ${(0, console_log_colors_1.green)(this.options.port)}`)); const wss = new WebSocketServer({ server }); const hasLocalCdnDir = await (0, utils_js_1.checkFileExists)((0, node_path_1.resolve)(rootDir, 'client/local/cdn')); this.app = app; this.wss = wss; app.use(async (req, res, next) => { // 处理 SPA 路由:根路径和 /page/* 路径都返回 index.html const isIndexPage = ['/', '/index.html'].includes(req.path) || req.path.startsWith('/page/'); const isPlayPage = req.path.startsWith('/play.html'); const isApi = req.path.startsWith('/api/'); if (!isApi && (isIndexPage || isPlayPage)) { let htmlContent = await node_fs_1.promises.readFile((0, node_path_1.resolve)(rootDir, `client/${isPlayPage ? 'play' : 'index'}.html`), 'utf-8'); if (hasLocalCdnDir) { // 提取所有 zstatic.net 的 js 和 css 资源地址,若子路径存在于 local/cdn 目录下则替换为本地路径 const zstaticRegex = /https:\/\/s4\.zstatic\.net\/ajax\/libs\/[^\s"'`<>]+\.(js|css)/g; const zstaticMatches = htmlContent.match(zstaticRegex); if (zstaticMatches) { for (const match of zstaticMatches) { const relativePath = match.split('libs/')[1]; const localPath = (0, node_path_1.resolve)(rootDir, `client/local/cdn/${relativePath}`); if (await (0, utils_js_1.checkFileExists)(localPath)) { htmlContent = htmlContent.replaceAll(match, `/local/cdn/${relativePath}`); } } htmlContent = htmlContent.replaceAll(/integrity="[^"]+"\n?/g, ''); } } res.setHeader('content-type', 'text/html').send(htmlContent); } else { next(); } }); app.use(express.json()); app.use(express.static((0, node_path_1.resolve)(rootDir, 'client'))); app.use((req, res, next) => { res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); res.setHeader('Cache-Control', 'no-cache'); if (req.method === 'OPTIONS') { res.status(200).end(); return; } if (this.options.token && req.headers.authorization !== this.options.token) { const ignorePaths = ['/healthcheck', '/localplay']; if (!ignorePaths.some(d => req.url.includes(d))) { const lang = this.getLangFromRequest(req); const clientIp = req.headers['x-forwarded-for'] || req.socket.remoteAddress; utils_js_1.logger.warn('Unauthorized access:', clientIp, req.url, req.headers.authorization); res.status(401).json({ message: (0, i18n_js_1.t)('api.error.unauthorized', lang), code: 1008 }); return; } } next(); }); wss.on('connection', (ws, req) => { const clientIp = req.headers['x-forwarded-for'] || req.socket.remoteAddress; utils_js_1.logger.info('Client connected:', clientIp, req.url); if (this.options.token) { const token = (0, fe_utils_1.getUrlParams)(req.url).token; if (!token || token !== this.options.token) { utils_js_1.logger.error('Unauthorized client:', req.socket.remoteAddress); ws.close(1008, 'Unauthorized'); return; } } this.checkDLFileIsExists(); ws.send(JSON.stringify({ type: 'serverInfo', data: this.serverInfo })); ws.send(JSON.stringify({ type: 'tasks', data: Object.fromEntries(this.dlCacheClone()) })); }); wss.on('close', () => utils_js_1.logger.info('WebSocket server closed')); wss.on('error', err => utils_js_1.logger.error('WebSocket server error:', err)); wss.on('listening', () => utils_js_1.logger.info(`WebSocket server listening on port ${(0, console_log_colors_1.green)(this.options.port)}`)); return { app, wss }; } async startDownload(url, options) { if (!url) return utils_js_1.logger.error('[satartDownload]Invalid URL:', url); if (url.endsWith('.html')) { const item = Array.from(await (0, getM3u8Urls_js_1.getM3u8Urls)({ url, headers: options.headers }))[0]; if (!item) return utils_js_1.logger.error('[startDownload]不是有效(包含)M3U8的地址:', url); url = item[0]; if (!options.filename) options.filename = item[1]; } const { options: dlOptions } = await (0, format_options_js_1.formatOptions)(url, { ...this.cfg.dlOptions, ...options, cacheDir: this.options.cacheDir }); if (!dlOptions.saveDir) dlOptions.saveDir = this.cfg.dlOptions.saveDir; const cacheItem = this.dlCache.get(url) || { options, dlOptions, status: 'pending', url }; utils_js_1.logger.debug('startDownload', url, dlOptions, cacheItem.status); if (cacheItem.status === 'resume') return; if (cacheItem.localVideo) { if (await (0, utils_js_1.checkFileExists)(cacheItem.localVideo)) { if (cacheItem.status === 'done') return; } else { delete cacheItem.localVideo; if (cacheItem.endTime) delete cacheItem.endTime; } } else if (!this.dlCache.has(url)) { // 如果本地视频已存在,则重命名 filename const localVideo = (0, node_path_1.resolve)(dlOptions.saveDir, dlOptions.filename); const hasSameNameVideo = [...this.dlCache.values()].some(d => d.dlOptions.saveDir === dlOptions.saveDir && d.dlOptions.filename === dlOptions.filename); if (hasSameNameVideo || (await (0, utils_js_1.checkFileExists)(localVideo))) { const ext = (0, node_path_1.extname)(localVideo) || ''; dlOptions.filename = `${(0, node_path_1.basename)(localVideo, ext)}.${Date.now()}${ext}`; utils_js_1.logger.info('存在重名视频,重命名filename:', (0, console_log_colors_1.gray)(localVideo), '->', (0, console_log_colors_1.cyan)(dlOptions.filename)); } } cacheItem.status = this.downloading >= this.cfg.webOptions.maxDownloads ? 'pending' : 'resume'; // pending 优先级靠后 if (cacheItem.status === 'pending' && this.dlCache.has(url)) this.dlCache.delete(url); this.dlCache.set(url, cacheItem); this.wsSend('progress', url); if (cacheItem.status === 'pending') return; let workPoll = cacheItem.workPoll; const opts = { ...dlOptions, showProgress: dlOptions.debug || this.options.debug, onInited: (_s, _i, wp) => { workPoll = wp; }, onProgress: (_finished, _total, current, stats) => { const item = this.dlCache.get(url); if (!item) return false; // 已删除 const status = item.status || 'resume'; if (!item.cacheDir && current?.tsOut) item.cacheDir = (0, node_path_1.dirname)(current.tsOut); Object.assign(item, { ...stats, dlOptions, status, workPoll, url }); this.dlCache.set(url, item); this.saveCache(); this.wsSend('progress', url); return status !== 'pause'; }, }; const afterDownload = async (r, url) => { const item = this.dlCache.get(url) || cacheItem; if (r.filepath && (await (0, utils_js_1.checkFileExists)(r.filepath))) { item.localVideo = r.filepath; item.downloadedSize = (await node_fs_1.promises.stat(r.filepath)).size; } else if (!r.errmsg && opts.convert !== false) r.errmsg = '下载失败'; item.endTime = Date.now(); item.errmsg = r.errmsg; item.status = r.isExist ? 'done' : r.errmsg ? 'error' : 'done'; utils_js_1.logger.info('Download complete:', item.status, (0, console_log_colors_1.red)(r.errmsg), (0, console_log_colors_1.gray)(url), (0, console_log_colors_1.cyan)(r.filepath)); this.dlCache.set(url, item); this.wsSend('progress', url); this.saveCache(); this.startNextPending(); }; try { if (dlOptions.type === 'parser') { console.log('\n\nDownloading with VideoParser\n\n', dlOptions, url); index_js_1.VideoParser.download(url, opts).then(r => afterDownload(r, url)); } else if (dlOptions.type === 'file') { (0, file_download_js_1.fileDownload)(url, opts).then(r => afterDownload(r, url)); } else { (0, m3u8_download_js_1.m3u8Download)(url, opts).then(r => afterDownload(r, url)); } } catch (error) { afterDownload({ filepath: '', errmsg: error.message }, url); utils_js_1.logger.error('下载失败:', error); } } startNextPending() { // 找到一个 pending 的任务,开始下载 const nextItem = this.dlCache.entries().find(([_url, d]) => d.status === 'pending'); if (nextItem) { this.startDownload(nextItem[0], nextItem[1].options); this.wsSend('progress', nextItem[0]); } } getLangFromRequest(req) { // Try to get lang from query parameter const queryLang = req.query?.lang; if (queryLang && i18n_js_1.LANG_CODES.has(queryLang)) { return queryLang; } // Try to get lang from body const bodyLang = req.body?.lang; if (bodyLang && i18n_js_1.LANG_CODES.has(bodyLang)) { return bodyLang; } // Try to get lang from Accept-Language header const acceptLanguage = req.headers['accept-language']; if (acceptLanguage) { const langCode = acceptLanguage.toLowerCase().split(',')[0].split('-')[0].trim(); if (i18n_js_1.LANG_CODES.has(langCode)) { return langCode; } } // Fallback to default return (0, i18n_js_1.getLang)(); } wsSend(type = 'progress', data) { if (type === 'tasks' && !data) { data = Object.fromEntries(this.dlCacheClone()); } else if (type === 'progress' && typeof data === 'string') { const item = this.dlCache.get(data); if (!item) return; const { workPoll, ...stats } = item; data = [{ ...stats, url: data }]; } // 广播进度信息给所有客户端 this.wss.clients.forEach(client => { if (client.readyState === 1) client.send(JSON.stringify({ type, data })); }); } initRouters() { const { app } = this; app.get('/healthcheck', (_req, res) => { res.json({ message: 'ok', code: 0 }); }); app.post('/api/config', (req, res) => { const lang = this.getLangFromRequest(req); try { const config = req.body; this.saveConfig(config); res.json({ message: (0, i18n_js_1.t)('api.success.configUpdated', lang), code: 0 }); } catch (error) { const errorMessage = error instanceof Error ? error.message : (0, i18n_js_1.t)('api.error.configSaveFailed', lang); utils_js_1.logger.error('[saveConfig]', errorMessage); res.status(400).json({ message: errorMessage, code: 1 }); } }); app.get('/api/config', (_req, res) => { res.json({ code: 0, data: { ...this.cfg.dlOptions, ...this.cfg.webOptions } }); }); // API to get all download progress app.get('/api/tasks', (_req, res) => { res.json({ code: 0, data: Object.fromEntries(this.dlCacheClone()) }); }); // API to get queue status app.get('/api/queue/status', (_req, res) => { const pendingTasks = Array.from(this.dlCache.entries()).filter(([_, item]) => item.status === 'pending'); const activeTasks = Array.from(this.dlCache.entries()).filter(([_, item]) => item.status === 'resume'); res.json({ code: 0, data: { queueLength: pendingTasks.length, activeDownloads: activeTasks.map(([url]) => url), maxConcurrent: this.cfg.webOptions.maxDownloads, }, }); }); // API to clear queue app.post('/api/queue/clear', (req, res) => { let count = 0; for (const [url, item] of this.dlCache.entries()) { if (item.status === 'pending') { this.dlCache.delete(url); count++; } } if (count) this.wsSend('tasks'); const lang = this.getLangFromRequest(req); res.json({ message: (0, i18n_js_1.t)('api.success.queueCleared', lang, { count }), code: 0 }); }); // API to update task priority // app.post('/api/priority', (req, res) => { // const { url, priority } = req.body; // const item = this.dlCache.get(url); // if (!item) { // res.json({ message: '任务不存在', code: 1 }); // return; // } // item.options.priority = priority; // this.saveCache(); // res.json({ message: '已更新任务优先级', code: 0 }); // }); // API to start m3u8 download app.post('/api/download', (req, res) => { const { list = [] } = req.body; const lang = this.getLangFromRequest(req); try { let duplicateCount = 0; let startedCount = 0; // 检查并统计重复的 URL,但仍允许下载 for (const item of list) { const { url, ...options } = item; if (url) { if (this.dlCache.has(url)) { duplicateCount++; } else { startedCount++; } this.startDownload(url, options); } } let message = ''; if (duplicateCount > 0) { message = `${(0, i18n_js_1.t)('api.success.downloadStarted', lang, { count: list.length })}${(0, i18n_js_1.t)('api.error.duplicateDownload', lang, { count: duplicateCount })}`; } else { message = (0, i18n_js_1.t)('api.success.downloadStarted', lang, { count: list.length }); } res.json({ message, code: 0, duplicateCount, startedCount }); this.wsSend('tasks'); } catch (error) { res.status(500).json({ error: `${(0, i18n_js_1.t)('api.error.downloadFailed', lang)}: ${error.message || ''}` }); } }); // API to pause download app.post('/api/pause', (req, res) => { const { urls, all = false } = req.body; const urlsToPause = all ? [...this.dlCache.keys()] : urls; const list = []; for (const url of urlsToPause) { const item = this.dlCache.get(url); if (['resume', 'pending'].includes(item?.status)) { (0, m3u8_download_js_1.m3u8DLStop)(url, item.workPoll); item.status = item.tsSuccess > 0 && item.tsSuccess === item.tsCount ? 'done' : 'pause'; const { workPoll, ...tItem } = item; list.push(tItem); } } if (list.length) { this.wsSend('progress', list); this.startNextPending(); } const lang = this.getLangFromRequest(req); res.json({ message: (0, i18n_js_1.t)('api.success.paused', lang, { count: list.length }), code: 0, count: list.length }); }); // API to resume download app.post('/api/resume', (req, res) => { const { urls, all = false } = req.body; const urlsToResume = all ? [...this.dlCache.keys()] : urls; const list = []; for (const url of urlsToResume) { const item = this.dlCache.get(url); if (['pause', 'error'].includes(item?.status)) { this.startDownload(url, item.options); const { workPoll, ...t } = item; list.push(t); } else console.log(item?.status, url); } if (list.length) this.wsSend('progress', list); const lang = this.getLangFromRequest(req); res.json({ message: list.length ? (0, i18n_js_1.t)('api.success.resumed', lang, { count: list.length }) : (0, i18n_js_1.t)('api.success.noResumableTasks', lang), code: 0, count: list.length, }); }); // API to delete download app.post('/api/delete', async (req, res) => { const { urls, deleteCache = false, deleteVideo = false } = req.body; const urlsToDelete = urls; const list = []; const errors = []; for (const url of urlsToDelete) { const item = this.dlCache.get(url); if (item) { utils_js_1.logger.info('delete download task:', (0, console_log_colors_1.gray)(url), (0, console_log_colors_1.cyan)(item.status), (0, console_log_colors_1.cyan)(item.localVideo), deleteCache, deleteVideo); (0, m3u8_download_js_1.m3u8DLStop)(url, item.workPoll); this.dlCache.delete(url); list.push(item.url); if (deleteCache) { try { const cacheDir = item.cacheDir; if (cacheDir && (await (0, utils_js_1.checkFileExists)(cacheDir))) { await node_fs_1.promises.rm(cacheDir, { recursive: true, force: true }); utils_js_1.logger.info('删除缓存目录:', (0, console_log_colors_1.gray)(cacheDir)); } } catch (error) { const errorMsg = `删除缓存目录失败: ${error.message}`; utils_js_1.logger.error(errorMsg, (0, console_log_colors_1.gray)(item.cacheDir)); errors.push(errorMsg); } } if (deleteVideo) { try { // 优先使用 item.localVideo(实际文件路径) if (item.localVideo) { const filepath = item.localVideo; if (await (0, utils_js_1.checkFileExists)(filepath)) { try { await node_fs_1.promises.rm(filepath, { recursive: true, force: true }); utils_js_1.logger.info('删除文件:', (0, console_log_colors_1.gray)(filepath)); } catch (error) { const errorMsg = `删除文件失败: ${filepath}, 错误: ${error.message}`; utils_js_1.logger.error(errorMsg); errors.push(errorMsg); // 如果直接删除失败,可能是文件被占用 } } } else { // 如果 localVideo 不存在,尝试使用 dlOptions 构建路径(格式化后的参数更准确) const saveDir = item.dlOptions?.saveDir || item.options?.saveDir; const filename = item.dlOptions?.filename || item.options?.filename; if (saveDir && filename) { // 尝试多种可能的扩展名 for (const ext of ['', '.ts', '.mp4']) { const filepath = (0, node_path_1.resolve)(saveDir, filename + ext); if (await (0, utils_js_1.checkFileExists)(filepath)) { try { await node_fs_1.promises.rm(filepath, { recursive: true, force: true }); utils_js_1.logger.info('删除文件:', (0, console_log_colors_1.gray)(filepath)); break; // 找到并删除后退出循环 } catch (error) { const errorMsg = `删除文件失败: ${filepath}, 错误: ${error.message}`; utils_js_1.logger.error(errorMsg); errors.push(errorMsg); } } } } else { const errorMsg = `无法确定文件路径: saveDir=${saveDir}, filename=${filename}`; utils_js_1.logger.warn(errorMsg, (0, console_log_colors_1.gray)(url)); errors.push(errorMsg); } } } catch (error) { const errorMsg = `删除视频文件时发生错误: ${error.message}`; utils_js_1.logger.error(errorMsg, (0, console_log_colors_1.gray)(url)); errors.push(errorMsg); } } } } if (list.length) { this.wsSend('delete', list); this.saveCache(); this.startNextPending(); } const lang = this.getLangFromRequest(req); const message = errors.length ? `${(0, i18n_js_1.t)('api.success.deleted', lang, { count: list.length })},但有 ${errors.length} 个错误: ${errors.join('; ')}` : (0, i18n_js_1.t)('api.success.deleted', lang, { count: list.length }); res.json({ message, code: errors.length > 0 ? 1 : 0, count: list.length, errors: errors.length > 0 ? errors : undefined }); }); // API to rename download file app.post('/api/rename', async (req, res) => { const { url, newFilename } = req.body; const lang = this.getLangFromRequest(req); if (!url || !newFilename) { res.json({ code: 1001, message: (0, i18n_js_1.t)('api.error.invalidParams', lang) }); return; } // 检查新文件名是否包含非法字符 const invalidChars = /[<>:"/\\|?*]/; if (invalidChars.test(newFilename)) { res.json({ code: 1004, message: (0, i18n_js_1.t)('api.error.invalidFilename', lang) }); return; } const item = this.dlCache.get(url); if (!item) { res.json({ code: 1002, message: (0, i18n_js_1.t)('api.error.taskNotFound', lang) }); return; } await this.checkItemStatus(item); // 只允许重命名已完成且状态正常的任务 if (item.status !== 'done' || item.errmsg) { utils_js_1.logger.error('rename failed:', item.status, (0, console_log_colors_1.red)(item.errmsg), (0, console_log_colors_1.gray)(url)); res.json({ code: 1003, message: (0, i18n_js_1.t)('api.error.onlyRenameCompleted', lang) }); return; } try { const oldPath = item.localVideo; const oldDir = (0, node_path_1.dirname)(oldPath); const oldExt = oldPath.split('.').pop() || ''; const newFilenameBase = newFilename.replace(/\.[^.]+$/, ''); const newPath = (0, node_path_1.resolve)(oldDir, `${newFilenameBase}${oldExt ? `.${oldExt}` : ''}`); // 检查新文件名是否已存在 if (await (0, utils_js_1.checkFileExists)(newPath)) { res.json({ code: 1007, message: (0, i18n_js_1.t)('api.error.fileExists', lang) }); return; } // 重命名文件 await node_fs_1.promises.rename(oldPath, newPath); utils_js_1.logger.debug('重命名文件:', (0, console_log_colors_1.gray)(oldPath), '->', (0, console_log_colors_1.cyan)(newPath)); // 更新任务信息 item.localVideo = newPath; item.options.filename = item.filename = (0, node_path_1.basename)(newPath); this.dlCache.set(url, item); this.saveCache(); this.wsSend('progress', url); res.json({ message: (0, i18n_js_1.t)('api.success.renamed', lang), code: 0 }); } catch (error) { utils_js_1.logger.error('重命名失败:', error); res.json({ code: 1006, message: (0, i18n_js_1.t)('api.error.renameFailed', lang, { error: error.message }) }); } }); app.get(/^\/localplay\/(.*)$/, async (req, res) => { let filepath = decodeURIComponent(req.params[0]); if (filepath) { let ext = filepath.split('.').pop(); if (!ext) { ext = 'm3u8'; if (!(await (0, utils_js_1.checkFileExists)(filepath))) filepath += '.m3u8'; } const allowedDirs = [this.options.cacheDir, this.cfg.dlOptions.saveDir]; if (!(await (0, utils_js_1.checkFileExists)(filepath))) { for (const dir of allowedDirs) { const tpath = (0, node_path_1.resolve)(dir, filepath); if (await (0, utils_js_1.checkFileExists)(tpath)) { filepath = tpath; break; } } } else { filepath = (0, node_path_1.resolve)(filepath); const isAllow = !this.options.limitFileAccess || allowedDirs.some(d => filepath.startsWith((0, node_path_1.resolve)(d))); if (!isAllow) { utils_js_1.logger.error('[Localplay] Access denied:', filepath); const lang = this.getLangFromRequest(req); res.send({ message: (0, i18n_js_1.t)('api.error.accessDenied', lang), code: 403 }); return; } } if (await (0, utils_js_1.checkFileExists)(filepath)) { const stats = await node_fs_1.promises.stat(filepath); const headers = new Headers({ 'Last-Modified': stats.mtime.toUTCString(), 'Access-Control-Allow-Headers': '*', 'Access-Control-Allow-Methods': 'POST, GET, OPTIONS', 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Credentials': 'true', 'Content-Length': String(stats.size), 'Content-Type': ext === 'ts' ? 'video/mp2t' : ext === 'm3u8' ? 'application/vnd.apple.mpegurl' : ext === 'mp4' ? 'video/mp4' : 'text/plain', }); res.setHeaders(headers); if (ext === 'm3u8' || ('ts' === ext && stats.size < 1024 * 1024 * 3)) { const data = await node_fs_1.promises.readFile(filepath); res.send(data); utils_js_1.logger.debug('[Localplay]file sent:', (0, console_log_colors_1.gray)(filepath), 'Size:', stats.size, 'bytes'); } else { res.sendFile(filepath); } return; } } utils_js_1.logger.error('[Localplay]file not found:', (0, console_log_colors_1.red)(filepath)); const lang = this.getLangFromRequest(req); res.status(404).send({ message: (0, i18n_js_1.t)('api.error.notFound', lang), code: 404 }); }); app.post('/api/getM3u8Urls', (req, res) => { const { url, headers, subUrlRegex } = req.body; const lang = this.getLangFromRequest(req); if (!url) { res.json({ code: 1001, message: (0, i18n_js_1.t)('api.error.invalidUrl', lang) }); } else { (0, getM3u8Urls_js_1.getM3u8Urls)({ url, headers, subUrlRegex }) .then(d => res.json({ code: 0, data: Array.from(d) })) .catch(err => res.json({ code: 401, message: err.message })); } }); } } exports.DLServer = DLServer;