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:
13
jest.config.mjs
Normal file
13
jest.config.mjs
Normal 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
3867
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
|
||||
79
src/__tests__/index.test.ts
Normal file
79
src/__tests__/index.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
481
src/index.mts
481
src/index.mts
@@ -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
8
tsconfig.jest.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./lib",
|
||||
"module": "ES2020",
|
||||
"target": "ES2020"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user