UNPKG

@pickstar-2002/video-fetch-mcp

Version:

基于 MCP 协议的多平台视频下载服务端

366 lines (307 loc) 10.3 kB
/** * yt-dlp 服务类 - 核心视频下载服务 */ import { spawn, ChildProcess } from 'child_process'; import path from 'path'; import fs from 'fs-extra'; import { v4 as uuidv4 } from 'uuid'; import { VideoDownloadRequest, VideoDownloadResponse, VideoInfo, DownloadTask } from '../types.js'; import { logger } from '../utils/logger.js'; import config from '../config.js'; export class YtDlpService { private activeTasks: Map<string, DownloadTask> = new Map(); private activeProcesses: Map<string, ChildProcess> = new Map(); /** * 获取视频信息(不下载) */ async getVideoInfo(url: string): Promise<VideoInfo> { logger.info(`获取视频信息: ${url}`); return new Promise((resolve, reject) => { const args = [ '-m', 'yt_dlp', '--dump-json', '--no-warnings', url ]; const process = spawn(config.ytDlp.executablePath, args); let stdout = ''; let stderr = ''; process.stdout.on('data', (data) => { stdout += data.toString(); }); process.stderr.on('data', (data) => { stderr += data.toString(); }); process.on('close', (code) => { if (code === 0) { try { const info = JSON.parse(stdout); const videoInfo: VideoInfo = { title: info.title || '未知标题', description: info.description, duration: info.duration || 0, uploader: info.uploader || '未知上传者', platform: info.extractor || '未知平台', videoId: info.id || '', thumbnail: info.thumbnail, formats: info.formats?.map((format: any) => ({ formatId: format.format_id, formatNote: format.format_note || '', ext: format.ext, resolution: format.resolution, vcodec: format.vcodec, acodec: format.acodec, filesize: format.filesize, tbr: format.tbr, })) || [] }; logger.info(`成功获取视频信息: ${videoInfo.title}`); resolve(videoInfo); } catch (error) { logger.error('解析视频信息失败:', error); reject(new Error('解析视频信息失败')); } } else { logger.error(`yt-dlp 获取信息失败 (code: ${code}): ${stderr}`); reject(new Error(`获取视频信息失败: ${stderr}`)); } }); // 设置超时 setTimeout(() => { process.kill(); reject(new Error('获取视频信息超时')); }, config.ytDlp.timeout); }); } /** * 开始下载视频 */ async downloadVideo(request: VideoDownloadRequest): Promise<string> { const taskId = uuidv4(); logger.info(`开始下载任务 ${taskId}: ${request.url}`); // 确保输出目录存在 await fs.ensureDir(request.outputPath); // 创建下载任务 const task: DownloadTask = { id: taskId, request, status: 'pending', progress: 0, createdAt: new Date(), updatedAt: new Date(), }; this.activeTasks.set(taskId, task); // 异步执行下载 this.executeDownload(taskId).catch((error) => { logger.error(`下载任务 ${taskId} 失败:`, error); task.status = 'failed'; task.error = error.message; task.updatedAt = new Date(); }); return taskId; } /** * 执行实际的下载过程 */ private async executeDownload(taskId: string): Promise<void> { const task = this.activeTasks.get(taskId); if (!task) { throw new Error('任务不存在'); } try { // 更新任务状态 task.status = 'downloading'; task.updatedAt = new Date(); // 构建 yt-dlp 命令参数 const args = this.buildDownloadArgs(task.request); logger.info(`执行 yt-dlp 命令: ${config.ytDlp.executablePath} ${args.join(' ')}`); // 启动下载进程 const finalArgs = ['-m', 'yt_dlp', ...args]; const process = spawn(config.ytDlp.executablePath, finalArgs); this.activeProcesses.set(taskId, process); let stderr = ''; // 处理进度输出 process.stdout.on('data', (data) => { const output = data.toString(); this.parseProgress(taskId, output); }); process.stderr.on('data', (data) => { stderr += data.toString(); this.parseProgress(taskId, data.toString()); }); // 处理进程结束 process.on('close', (code) => { this.activeProcesses.delete(taskId); if (code === 0) { task.status = 'completed'; task.progress = 100; logger.info(`下载任务 ${taskId} 完成`); } else { task.status = 'failed'; task.error = `下载失败 (退出码: ${code}): ${stderr}`; logger.error(`下载任务 ${taskId} 失败: ${task.error}`); } task.updatedAt = new Date(); }); // 设置超时 setTimeout(() => { if (this.activeProcesses.has(taskId)) { process.kill(); task.status = 'failed'; task.error = '下载超时'; task.updatedAt = new Date(); this.activeProcesses.delete(taskId); } }, config.ytDlp.timeout); } catch (error) { task.status = 'failed'; task.error = error instanceof Error ? error.message : '未知错误'; task.updatedAt = new Date(); throw error; } } /** * 构建 yt-dlp 下载参数 */ private buildDownloadArgs(request: VideoDownloadRequest): string[] { const args: string[] = []; // 基础参数 args.push('--no-warnings'); args.push('--ignore-errors'); args.push('--no-check-certificate'); // 输出路径和文件名模板 const outputTemplate = request.outputTemplate || '%(title)s.%(ext)s'; args.push('-o', path.join(request.outputPath, outputTemplate)); // 视频质量 if (request.quality) { args.push('-f', request.quality); } // 提取音频 if (request.extractAudio) { args.push('--extract-audio'); if (request.audioFormat) { args.push('--audio-format', request.audioFormat); } } // 下载字幕 if (request.downloadSubtitles) { args.push('--write-subs'); if (request.subtitleLangs && request.subtitleLangs.length > 0) { args.push('--sub-langs', request.subtitleLangs.join(',')); } } // 添加 URL args.push(request.url); return args; } /** * 从网页中提取视频链接 */ async extractVideoUrls(pageUrl: string): Promise<string[]> { logger.info(`从网页提取视频链接: ${pageUrl}`); return new Promise((resolve, reject) => { const args = [ '-m', 'yt_dlp', '--get-url', '--no-warnings', pageUrl ]; const process = spawn(config.ytDlp.executablePath, args); let stdout = ''; let stderr = ''; process.stdout.on('data', (data) => { stdout += data.toString(); }); process.stderr.on('data', (data) => { stderr += data.toString(); }); process.on('close', (code) => { if (code === 0) { const urls = stdout.trim().split('\n').filter(url => url.trim()); logger.info(`成功提取到 ${urls.length} 个视频链接`); resolve(urls); } else { logger.error(`提取视频链接失败 (code: ${code}): ${stderr}`); reject(new Error(`提取视频链接失败: ${stderr}`)); } }); // 设置超时 setTimeout(() => { process.kill(); reject(new Error('提取视频链接超时')); }, config.ytDlp.timeout); }); } /** * 解析下载进度 */ private parseProgress(taskId: string, output: string): void { const task = this.activeTasks.get(taskId); if (!task) return; // 解析进度百分比 const progressMatch = output.match(/(\d+(?:\.\d+)?)%/); if (progressMatch) { task.progress = parseFloat(progressMatch[1]); } // 解析下载速度 const speedMatch = output.match(/(\d+(?:\.\d+)?(?:K|M|G)?iB\/s)/); if (speedMatch) { task.speed = speedMatch[1]; } // 解析预计剩余时间 const etaMatch = output.match(/ETA\s+(\d+:\d+)/); if (etaMatch) { task.eta = etaMatch[1]; } task.updatedAt = new Date(); } /** * 获取任务状态 */ getTaskStatus(taskId: string): DownloadTask | null { return this.activeTasks.get(taskId) || null; } /** * 取消下载任务 */ cancelTask(taskId: string): boolean { const process = this.activeProcesses.get(taskId); const task = this.activeTasks.get(taskId); if (process) { process.kill(); this.activeProcesses.delete(taskId); } if (task) { task.status = 'failed'; task.error = '用户取消'; task.updatedAt = new Date(); return true; } return false; } /** * 获取所有活跃任务 */ getAllTasks(): DownloadTask[] { return Array.from(this.activeTasks.values()); } /** * 清理过期任务 */ cleanupExpiredTasks(): void { const now = new Date(); const expiredTasks: string[] = []; for (const [taskId, task] of this.activeTasks) { const timeDiff = now.getTime() - task.updatedAt.getTime(); if (timeDiff > config.download.taskRetentionTime && (task.status === 'completed' || task.status === 'failed')) { expiredTasks.push(taskId); } } expiredTasks.forEach(taskId => { this.activeTasks.delete(taskId); logger.info(`清理过期任务: ${taskId}`); }); } } export default YtDlpService;