UNPKG

@lzwme/m3u8-dl

Version:

Batch download of m3u8 files and convert to mp4

580 lines (579 loc) 26.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_path_1 = require("node:path"); const node_os_1 = require("node:os"); 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 m3u8_download_js_1 = require("../lib/m3u8-download.js"); const getM3u8Urls_js_1 = require("../lib/getM3u8Urls.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: (0, node_fs_1.existsSync)((0, node_path_1.resolve)(rootDir, 'client/ariang/index.html')), }; 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, }, }; /** 下载任务缓存 */ 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'); const pkgFile = (0, node_path_1.resolve)(rootDir, 'package.json'); if ((0, node_fs_1.existsSync)(pkgFile)) { const pkg = JSON.parse((0, node_fs_1.readFileSync)(pkgFile, 'utf8')); this.serverInfo.version = pkg.version; } if (opts.token) opts.token = (0, fe_utils_1.md5)(opts.token.trim()).slice(0, 8); this.init(); } async init() { this.readConfig(); if (this.cfg.dlOptions.debug) utils_js_1.logger.updateOptions({ levelType: 'debug' }); this.loadCache(); await this.createApp(); this.initRouters(); utils_js_1.logger.debug('Server initialized', 'cacheSize:', this.dlCache.size, this.options, this.cfg.dlOptions); } loadCache() { const cacheFile = (0, node_path_1.resolve)(this.options.cacheDir, 'cache.json'); if ((0, node_fs_1.existsSync)(cacheFile)) { JSON.parse((0, node_fs_1.readFileSync)(cacheFile, 'utf8')).forEach(([url, item]) => { if (item.status === 'resume') item.status = 'pause'; this.dlCache.set(url, item); }); this.checkDLFileIsExists(); } } checkDLFileLaest = 0; checkDLFileTimer = null; checkDLFileIsExists() { const now = Date.now(); const interval = 1000 * 60; clearTimeout(this.checkDLFileTimer); if (now - this.checkDLFileLaest < interval) { this.checkDLFileTimer = setTimeout(() => this.checkDLFileIsExists(), interval - (now - this.checkDLFileLaest)); return; } this.dlCache.forEach(item => { if (item.status === 'done' && (!item.localVideo || !(0, node_fs_1.existsSync)(item.localVideo))) { item.status = 'error'; 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'); const info = this.dlCacheClone(); (0, fe_utils_1.mkdirp)((0, node_path_1.dirname)(cacheFile)); (0, node_fs_1.writeFileSync)(cacheFile, JSON.stringify(info)); }, 1000); } readConfig(configPath) { try { if (!configPath) configPath = this.options.configPath; if ((0, node_fs_1.existsSync)(configPath)) (0, fe_utils_1.assign)(this.cfg, JSON.parse((0, node_fs_1.readFileSync)(configPath, 'utf8'))); } catch (error) { utils_js_1.logger.error('读取配置失败:', error); } return this.cfg.dlOptions; } saveConfig(config, configPath) { if (!configPath) configPath = this.options.configPath; 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)); (0, node_fs_1.writeFileSync)(configPath, JSON.stringify(this.cfg, null, 2)); } 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 }); this.app = app; this.wss = wss; app.use((req, res, next) => { if (['/', '/index.html'].includes(req.path)) { const version = this.serverInfo.version; let indexHtml = (0, node_fs_1.readFileSync)((0, node_path_1.resolve)(rootDir, 'client/index.html'), 'utf-8').replaceAll('{{version}}', version); if ((0, node_fs_1.existsSync)((0, node_path_1.resolve)(rootDir, 'client/local/cdn'))) { indexHtml = indexHtml .replaceAll('https://s4.zstatic.net/ajax/libs', 'local/cdn') .replaceAll(/integrity="[^"]+"\n?/g, '') .replace('https://cdn.tailwindcss.com/3.4.16', 'local/cdn/tailwindcss/3.4.16/tailwindcss.min.js'); } res.setHeader('content-type', 'text/html').send(indexHtml); } 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 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: '未授权,禁止访问', code: 401 }); 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 } = (0, format_options_js_1.formatOptions)(url, { ...this.cfg.dlOptions, ...options, cacheDir: this.options.cacheDir }); 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 && !(0, node_fs_1.existsSync)(cacheItem.localVideo)) delete cacheItem.localVideo; if (cacheItem.endTime) delete cacheItem.endTime; 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'; Object.assign(item, { ...stats, current, options: dlOptions, status, workPoll, url }); this.dlCache.set(url, item); this.saveCache(); this.wsSend('progress', url); return status !== 'pause'; }, }; const afterDownload = (r, url) => { const item = this.dlCache.get(url) || cacheItem; if (r.filepath && (0, node_fs_1.existsSync)(r.filepath)) { item.localVideo = r.filepath; item.downloadedSize = (0, node_fs_1.statSync)(r.filepath).size; } else if (!r.errmsg && opts.convert !== false) r.errmsg = '下载失败'; item.endTime = Date.now(); item.errmsg = r.errmsg; item.status = 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') { const vp = new index_js_1.VideoParser(); vp.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]); } } 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 config = req.body; this.saveConfig(config); res.json({ message: 'Config updated successfully', code: 0 }); }); app.get('/api/config', (_req, res) => { res.json({ ...this.cfg.dlOptions, ...this.cfg.webOptions }); }); // API to get all download progress app.get('/api/tasks', (_req, res) => { res.json(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({ 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'); res.json({ message: `已清空 ${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 { url, options = {}, list = [] } = req.body; try { if (list.length) { for (const item of list) { const { url, ...options } = item; if (url) this.startDownload(url, options); } } else if (url) this.startDownload(url, options); res.json({ message: `Download started: ${list.length || 1}`, code: 0 }); this.wsSend('tasks'); } catch (error) { res.status(500).json({ error: `Download failed: ${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(); } res.json({ message: `已暂停 ${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); res.json({ message: list.length ? `已恢复 ${list.length} 个下载任务` : '没有找到可恢复的下载任务', code: 0, count: list.length }); }); // API to delete download app.post('/api/delete', (req, res) => { const { urls, deleteCache = false, deleteVideo = false } = req.body; const urlsToDelete = urls; const list = []; for (const url of urlsToDelete) { const item = this.dlCache.get(url); if (item) { (0, m3u8_download_js_1.m3u8DLStop)(url, item.workPoll); this.dlCache.delete(url); list.push(item.url); if (deleteCache && item.current?.tsOut) { const cacheDir = (0, node_path_1.dirname)(item.current.tsOut); if ((0, node_fs_1.existsSync)(cacheDir)) { (0, node_fs_1.rmSync)(cacheDir, { recursive: true }); utils_js_1.logger.debug('删除缓存目录:', cacheDir); } } if (deleteVideo) { ['.ts', '.mp4'].forEach(ext => { const filepath = (0, node_path_1.resolve)(item.options.saveDir, item.options.filename + ext); if ((0, node_fs_1.existsSync)(filepath)) { (0, node_fs_1.unlinkSync)(filepath); utils_js_1.logger.debug('删除文件:', filepath); } }); } } } if (list.length) { this.wsSend('delete', list); this.saveCache(); this.startNextPending(); } res.json({ message: `已删除 ${list.length} 个下载任务`, code: 0, count: list.length }); }); app.get(/^\/localplay\/(.*)$/, (req, res) => { let filepath = decodeURIComponent(req.params[0]); if (filepath) { let ext = filepath.split('.').pop(); if (!ext) { ext = 'm3u8'; if (!(0, node_fs_1.existsSync)(filepath)) filepath += '.m3u8'; } const allowedDirs = [this.options.cacheDir, this.cfg.dlOptions.saveDir]; if (!(0, node_fs_1.existsSync)(filepath)) { for (const dir of allowedDirs) { const tpath = (0, node_path_1.resolve)(dir, filepath); if ((0, node_fs_1.existsSync)(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); res.send({ message: 'Access denied', code: 403 }); return; } } if ((0, node_fs_1.existsSync)(filepath)) { const stats = (0, node_fs_1.statSync)(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': '*', '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)) { let content = (0, node_fs_1.readFileSync)(filepath); if (ext === 'm3u8') { const baseDirName = (0, node_path_1.basename)(filepath, '.m3u8'); content = content .toString('utf8') .split('\n') .map(line => (line.endsWith('.ts') && !line.includes('/') ? `${baseDirName}/${line}` : line)) .join('\n'); } res.send(content); 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)); res.status(404).send({ message: 'Not Found', code: 404 }); }); app.post('/api/getM3u8Urls', (req, res) => { const { url, headers, subUrlRegex } = req.body; if (!url) { res.json({ code: 1001, message: '无效的 url 参数' }); } 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;