feat: improve error handling and add initialization checks

- Add custom error types and error codes
- Add configuration validation
- Add dependency checks
- Add safe cleanup handling
- Improve code organization
This commit is contained in:
kevinwatt
2025-02-21 00:34:18 +08:00
parent 480e2c62ad
commit 8b1a44d7b4
7 changed files with 4365 additions and 98 deletions

13
jest.config.mjs Normal file
View File

@@ -0,0 +1,13 @@
export default {
preset: 'ts-jest/presets/default-esm',
testEnvironment: 'node',
extensionsToTreatAsEsm: ['.ts', '.mts'],
moduleNameMapper: {
'^(\\.{1,2}/.*)\\.m?js$': '$1',
},
transform: {
'^.+\\.m?[tj]s$': ['ts-jest', {
useESM: true,
}],
},
};

3867
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "@kevinwatt/yt-dlp-mcp",
"version": "0.6.9",
"version": "0.6.10",
"description": "yt-dlp MCP Server - Download video content via Model Context Protocol",
"keywords": [
"mcp",
@@ -26,7 +26,8 @@
],
"main": "./lib/index.mjs",
"scripts": {
"prepare": "tsc && shx chmod +x ./lib/index.mjs"
"prepare": "tsc && shx chmod +x ./lib/index.mjs",
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js --detectOpenHandles --forceExit"
},
"author": "Dewei Yen <k@funmula.com>",
"license": "MIT",
@@ -42,7 +43,11 @@
"spawn-rx": "^4.0.0"
},
"devDependencies": {
"@jest/globals": "^29.7.0",
"@types/jest": "^29.5.14",
"jest": "^29.7.0",
"shx": "^0.3.4",
"ts-jest": "^29.2.5",
"ts-node": "^10.9.2",
"typescript": "^5.6.3"
}

View File

@@ -0,0 +1,79 @@
// @ts-nocheck
// @jest-environment node
import { jest } from '@jest/globals';
import { describe, test, expect, beforeAll, afterAll, beforeEach } from '@jest/globals';
import * as path from 'path';
import * as os from 'os';
// 簡化 mock
jest.mock('spawn-rx', () => ({
spawnPromise: jest.fn().mockImplementation(async (cmd, args) => {
if (args.includes('--get-filename')) {
return 'mock_video.mp4';
}
return 'Download completed';
})
}));
jest.mock('rimraf', () => ({
rimraf: { sync: jest.fn() }
}));
import { downloadVideo } from '../index.mts';
describe('downloadVideo', () => {
const mockTimestamp = '2024-03-20_12-30-00';
let originalDateToISOString: () => string;
// 全局清理
afterAll(done => {
// 清理所有計時器
jest.useRealTimers();
// 確保所有異步操作完成
process.nextTick(done);
});
beforeAll(() => {
originalDateToISOString = Date.prototype.toISOString;
Date.prototype.toISOString = jest.fn(() => '2024-03-20T12:30:00.000Z');
});
afterAll(() => {
Date.prototype.toISOString = originalDateToISOString;
});
beforeEach(() => {
jest.clearAllMocks();
});
test('downloads video successfully with correct format', async () => {
const result = await downloadVideo('https://www.youtube.com/watch?v=dQw4w9WgXcQ');
// 驗證基本功能
expect(result).toMatch(/Video successfully downloaded as/);
expect(result).toContain(mockTimestamp);
expect(result).toContain(os.homedir());
expect(result).toContain('Downloads');
});
test('handles special characters in video URL', async () => {
// 使用有效的視頻 ID但包含需要編碼的字符
const result = await downloadVideo('https://www.youtube.com/watch?v=dQw4w9WgXcQ&title=特殊字符');
expect(result).toMatch(/Video successfully downloaded as/);
expect(result).toContain(mockTimestamp);
});
test('uses correct resolution format', async () => {
const resolutions = ['480p', '720p', '1080p', 'best'];
// 使用 Promise.all 並行執行測試
const results = await Promise.all(resolutions.map(resolution => downloadVideo(
'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
resolution
)));
results.forEach(result => {
expect(result).toMatch(/Video successfully downloaded as/);
});
});
});

View File

@@ -14,10 +14,77 @@ import * as path from "path";
import { spawnPromise } from "spawn-rx";
import { rimraf } from "rimraf";
const VERSION = '0.6.10';
/**
* 系統配置
*/
const CONFIG = {
MAX_FILENAME_LENGTH: 50,
DOWNLOADS_DIR: path.join(os.homedir(), "Downloads"),
TEMP_DIR_PREFIX: "ytdlp-",
REQUIRED_TOOLS: ['yt-dlp'] as const
} as const;
/**
* 驗證系統配置
* @throws {Error} 當配置無效時
*/
async function validateConfig(): Promise<void> {
// 檢查下載目錄
if (!fs.existsSync(CONFIG.DOWNLOADS_DIR)) {
throw new Error(`Downloads directory does not exist: ${CONFIG.DOWNLOADS_DIR}`);
}
// 檢查下載目錄權限
try {
const testFile = path.join(CONFIG.DOWNLOADS_DIR, '.write-test');
fs.writeFileSync(testFile, '');
fs.unlinkSync(testFile);
} catch (error) {
throw new Error(`No write permission in downloads directory: ${CONFIG.DOWNLOADS_DIR}`);
}
// 檢查臨時目錄權限
try {
const testDir = fs.mkdtempSync(path.join(os.tmpdir(), CONFIG.TEMP_DIR_PREFIX));
await safeCleanup(testDir);
} catch (error) {
throw new Error(`Cannot create temporary directory in: ${os.tmpdir()}`);
}
}
/**
* 檢查必要的外部依賴
* @throws {Error} 當依賴不滿足時
*/
async function checkDependencies(): Promise<void> {
for (const tool of CONFIG.REQUIRED_TOOLS) {
try {
await spawnPromise(tool, ["--version"]);
} catch (error) {
throw new Error(`Required tool '${tool}' is not installed or not accessible`);
}
}
}
/**
* 初始化服務
*/
async function initialize(): Promise<void> {
try {
await validateConfig();
await checkDependencies();
} catch (error) {
console.error('Initialization failed:', error);
process.exit(1);
}
}
const server = new Server(
{
name: "yt-dlp-mcp",
version: "0.6.9",
version: VERSION,
},
{
capabilities: {
@@ -65,7 +132,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
url: { type: "string", description: "URL of the video" },
resolution: {
type: "string",
description: "Video resolution (e.g., '720p', '1080p'). Optional, defaults to '720p'",
description: "Preferred video resolution. For YouTube: '480p', '720p', '1080p', 'best'. For other platforms: '480p' for low quality, '720p'/'1080p' for HD, 'best' for highest quality. Defaults to '720p'",
enum: ["480p", "720p", "1080p", "best"]
},
},
@@ -76,6 +143,95 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
};
});
/**
* 自定義錯誤類型
*/
class VideoDownloadError extends Error {
constructor(
message: string,
public readonly code: string = 'UNKNOWN_ERROR',
public readonly cause?: Error
) {
super(message);
this.name = 'VideoDownloadError';
}
}
class SubtitleError extends VideoDownloadError {
constructor(message: string, code: string = 'SUBTITLE_ERROR', cause?: Error) {
super(message, code, cause);
this.name = 'SubtitleError';
}
}
/**
* 錯誤代碼映射
*/
const ERROR_CODES = {
UNSUPPORTED_URL: 'Unsupported or invalid URL',
VIDEO_UNAVAILABLE: 'Video is not available or has been removed',
NETWORK_ERROR: 'Network connection error',
FORMAT_ERROR: 'Requested format is not available',
PERMISSION_ERROR: 'Permission denied when accessing download directory',
SUBTITLE_ERROR: 'Failed to process subtitles',
SUBTITLE_NOT_AVAILABLE: 'Subtitles are not available',
INVALID_LANGUAGE: 'Invalid language code provided',
UNKNOWN_ERROR: 'An unknown error occurred'
} as const;
/**
* 安全地清理臨時目錄
* @param directory 要清理的目錄路徑
*/
async function safeCleanup(directory: string): Promise<void> {
try {
rimraf.sync(directory);
} catch (error) {
console.error(`Failed to cleanup directory ${directory}:`, error);
}
}
/**
* 驗證 URL 格式
* @param url 要驗證的 URL
* @throws {VideoDownloadError} 當 URL 無效時
*/
function validateUrl(url: string, ErrorClass = VideoDownloadError): void {
try {
new URL(url);
} catch {
throw new ErrorClass(
ERROR_CODES.UNSUPPORTED_URL,
'UNSUPPORTED_URL'
);
}
}
/**
* 生成格式化的時間戳
* @returns 格式化的時間戳字符串
*/
function getFormattedTimestamp(): string {
return new Date().toISOString()
.replace(/[:.]/g, '-')
.replace('T', '_')
.split('.')[0];
}
/**
* 檢查是否為 YouTube URL
* @param url 要檢查的 URL
*/
function isYouTubeUrl(url: string): boolean {
try {
const urlObj = new URL(url);
return ['youtube.com', 'youtu.be', 'm.youtube.com']
.some(domain => urlObj.hostname.endsWith(domain));
} catch {
return false;
}
}
/**
* Lists all available subtitles for a video
* @param url The URL of the video
@@ -85,14 +241,30 @@ async function listSubtitles(url: string): Promise<string> {
const tempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "ytdlp-"));
try {
validateUrl(url, SubtitleError);
const result = await spawnPromise(
"yt-dlp",
["--list-subs", "--skip-download", url],
{ cwd: tempDirectory }
);
return result;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
if (errorMessage.includes('no subtitles')) {
throw new SubtitleError(
ERROR_CODES.SUBTITLE_NOT_AVAILABLE,
'SUBTITLE_NOT_AVAILABLE',
error as Error
);
}
throw new SubtitleError(
ERROR_CODES.SUBTITLE_ERROR,
'SUBTITLE_ERROR',
error as Error
);
} finally {
rimraf.sync(tempDirectory);
await safeCleanup(tempDirectory);
}
}
@@ -106,31 +278,76 @@ async function downloadSubtitles(url: string, language: string = "en"): Promise<
const tempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "ytdlp-"));
try {
await spawnPromise(
"yt-dlp",
[
"--write-sub",
"--write-auto-sub",
"--sub-lang",
language,
"--skip-download",
"--sub-format",
"srt",
url,
],
{ cwd: tempDirectory }
);
validateUrl(url, SubtitleError);
// 驗證語言代碼格式
if (!/^[a-z]{2,3}(-[A-Z][a-z]{3})?(-[A-Z]{2})?$/i.test(language)) {
throw new SubtitleError(
ERROR_CODES.INVALID_LANGUAGE,
'INVALID_LANGUAGE'
);
}
try {
await spawnPromise(
"yt-dlp",
[
"--write-sub",
"--write-auto-sub",
"--sub-lang",
language,
"--skip-download",
"--sub-format",
"srt",
url,
],
{ cwd: tempDirectory }
);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
if (errorMessage.includes('no subtitles')) {
throw new SubtitleError(
ERROR_CODES.SUBTITLE_NOT_AVAILABLE,
'SUBTITLE_NOT_AVAILABLE',
error as Error
);
}
throw new SubtitleError(
ERROR_CODES.SUBTITLE_ERROR,
'SUBTITLE_ERROR',
error as Error
);
}
let subtitlesContent = "";
const files = fs.readdirSync(tempDirectory);
if (files.length === 0) {
throw new SubtitleError(
ERROR_CODES.SUBTITLE_NOT_AVAILABLE,
'SUBTITLE_NOT_AVAILABLE'
);
}
for (const file of files) {
const filePath = path.join(tempDirectory, file);
const fileData = fs.readFileSync(filePath, "utf8");
subtitlesContent += `${file}\n====================\n${fileData}\n\n`;
try {
const fileData = fs.readFileSync(filePath, "utf8");
subtitlesContent += `${file}\n====================\n${fileData}\n\n`;
} catch (error) {
console.error(`Failed to read subtitle file ${file}:`, error);
}
}
if (!subtitlesContent) {
throw new SubtitleError(
ERROR_CODES.SUBTITLE_ERROR,
'SUBTITLE_ERROR'
);
}
return subtitlesContent;
} finally {
rimraf.sync(tempDirectory);
await safeCleanup(tempDirectory);
}
}
@@ -140,54 +357,156 @@ async function downloadSubtitles(url: string, language: string = "en"): Promise<
* @param resolution The desired video resolution
* @returns A detailed success message including the filename
*/
async function downloadVideo(url: string, resolution: string = "720p"): Promise<string> {
const userDownloadsDir = path.join(os.homedir(), "Downloads");
export async function downloadVideo(url: string, resolution = "720p"): Promise<string> {
const userDownloadsDir = CONFIG.DOWNLOADS_DIR;
try {
// Get current timestamp for filename
const timestamp = new Date().toISOString()
.replace(/[:.]/g, '-')
.replace('T', '_')
.split('.')[0];
validateUrl(url, VideoDownloadError);
const timestamp = getFormattedTimestamp();
// Map resolution to yt-dlp format
const formatMap: Record<string, string> = {
"480p": "bestvideo[height<=480]+bestaudio/best[height<=480]",
"720p": "bestvideo[height<=720]+bestaudio/best[height<=720]",
"1080p": "bestvideo[height<=1080]+bestaudio/best[height<=1080]",
"best": "bestvideo+bestaudio/best"
};
const format = formatMap[resolution] || formatMap["720p"];
let format: string;
if (isYouTubeUrl(url)) {
// YouTube-specific format selection
switch (resolution) {
case "480p":
format = "bestvideo[height<=480]+bestaudio/best[height<=480]/best";
break;
case "720p":
format = "bestvideo[height<=720]+bestaudio/best[height<=720]/best";
break;
case "1080p":
format = "bestvideo[height<=1080]+bestaudio/best[height<=1080]/best";
break;
case "best":
format = "bestvideo+bestaudio/best";
break;
default:
format = "bestvideo[height<=720]+bestaudio/best[height<=720]/best";
}
} else {
// For other platforms, use quality labels that are more generic
switch (resolution) {
case "480p":
format = "worst[height>=480]/best[height<=480]/worst";
break;
case "best":
format = "bestvideo+bestaudio/best";
break;
default: // Including 720p and 1080p cases
// Prefer HD quality but fallback to best available
format = "bestvideo[height>=720]+bestaudio/best[height>=720]/best";
}
}
const outputTemplate = path.join(
userDownloadsDir,
// Limit title length to 50 characters
`%(title).50s [%(id)s] ${timestamp}.%(ext)s`
`%(title).${CONFIG.MAX_FILENAME_LENGTH}s [%(id)s] ${timestamp}.%(ext)s`
);
// Get expected filename
const infoResult = await spawnPromise("yt-dlp", [
"--get-filename",
"-f", format,
"--output", outputTemplate,
url
]);
const expectedFilename = infoResult.trim();
let expectedFilename: string;
try {
expectedFilename = await spawnPromise("yt-dlp", [
"--get-filename",
"-f", format,
"--output", outputTemplate,
url
]);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
if (errorMessage.includes('Unsupported URL')) {
throw new VideoDownloadError(
ERROR_CODES.UNSUPPORTED_URL,
'UNSUPPORTED_URL',
error as Error
);
}
if (errorMessage.includes('not available')) {
throw new VideoDownloadError(
ERROR_CODES.VIDEO_UNAVAILABLE,
'VIDEO_UNAVAILABLE',
error as Error
);
}
throw new VideoDownloadError(
ERROR_CODES.UNKNOWN_ERROR,
'UNKNOWN_ERROR',
error as Error
);
}
expectedFilename = expectedFilename.trim();
// Download with progress info
await spawnPromise("yt-dlp", [
"--progress",
"--newline",
"--no-mtime",
"-f", format,
"--output", outputTemplate,
url
]);
try {
await spawnPromise("yt-dlp", [
"--progress",
"--newline",
"--no-mtime",
"-f", format,
"--output", outputTemplate,
url
]);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
if (errorMessage.includes('Permission denied')) {
throw new VideoDownloadError(
ERROR_CODES.PERMISSION_ERROR,
'PERMISSION_ERROR',
error as Error
);
}
if (errorMessage.includes('format not available')) {
throw new VideoDownloadError(
ERROR_CODES.FORMAT_ERROR,
'FORMAT_ERROR',
error as Error
);
}
throw new VideoDownloadError(
ERROR_CODES.UNKNOWN_ERROR,
'UNKNOWN_ERROR',
error as Error
);
}
return `Video successfully downloaded as "${path.basename(expectedFilename)}" to ${userDownloadsDir}`;
} catch (error) {
throw new Error(`Failed to download video: ${error}`);
if (error instanceof VideoDownloadError) {
throw error;
}
throw new VideoDownloadError(
ERROR_CODES.UNKNOWN_ERROR,
'UNKNOWN_ERROR',
error as Error
);
}
}
/**
* 處理工具執行並統一錯誤處理
* @param action 要執行的異步操作
* @param errorPrefix 錯誤訊息前綴
*/
async function handleToolExecution<T>(
action: () => Promise<T>,
errorPrefix: string
): Promise<{
content: Array<{ type: "text", text: string }>,
isError?: boolean
}> {
try {
const result = await action();
return {
content: [{ type: "text", text: String(result) }]
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
content: [{ type: "text", text: `${errorPrefix}: ${errorMessage}` }],
isError: true
};
}
}
@@ -205,43 +524,25 @@ server.setRequestHandler(
};
if (toolName === "list_video_subtitles") {
try {
const subtitlesList = await listSubtitles(args.url);
return {
content: [{ type: "text", text: subtitlesList }],
};
} catch (error) {
return {
content: [{ type: "text", text: `Error listing subtitles: ${error}` }],
isError: true,
};
}
return handleToolExecution(
() => listSubtitles(args.url),
"Error listing subtitles"
);
} else if (toolName === "download_video_srt") {
try {
const subtitles = await downloadSubtitles(args.url, args.language);
return {
content: [{ type: "text", text: subtitles }],
};
} catch (error) {
return {
content: [{ type: "text", text: `Error downloading subtitles: ${error}` }],
isError: true,
};
}
return handleToolExecution(
() => downloadSubtitles(args.url, args.language),
"Error downloading subtitles"
);
} else if (toolName === "download_video") {
try {
const message = await downloadVideo(args.url, args.resolution);
return {
content: [{ type: "text", text: message }],
};
} catch (error) {
return {
content: [{ type: "text", text: `Error downloading video: ${error}` }],
isError: true,
};
}
return handleToolExecution(
() => downloadVideo(args.url, args.resolution),
"Error downloading video"
);
} else {
throw new Error(`Unknown tool: ${toolName}`);
return {
content: [{ type: "text", text: `Unknown tool: ${toolName}` }],
isError: true
};
}
}
);
@@ -250,9 +551,13 @@ server.setRequestHandler(
* Starts the server using Stdio transport.
*/
async function startServer() {
await initialize();
const transport = new StdioServerTransport();
await server.connect(transport);
}
// Start the server and handle potential errors
startServer().catch(console.error);
// 導出錯誤類型供測試使用
export { VideoDownloadError, ERROR_CODES };

8
tsconfig.jest.json Normal file
View File

@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./lib",
"module": "ES2020",
"target": "ES2020"
}
}

View File

@@ -10,10 +10,10 @@
"noUnusedLocals": true,
"noImplicitThis": true,
"noUnusedParameters": true,
"module": "node16",
"moduleResolution": "node16",
"module": "ES2020",
"moduleResolution": "node",
"pretty": true,
"target": "es2015",
"target": "ES2020",
"outDir": "lib",
"lib": ["dom", "es2015"],
"esModuleInterop": true,