From b3e8ed5f58aa33c5f1b834d40cf6dd6aa288bf7e Mon Sep 17 00:00:00 2001 From: kevin Date: Fri, 21 Feb 2025 17:14:28 +0800 Subject: [PATCH] feat: improve audio download support - Add support for various audio formats (m4a/mp3) - Update audio download format selection logic - Improve error handling and filename display - Bump version to 0.6.22 --- src/index.mts | 122 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 121 insertions(+), 1 deletion(-) diff --git a/src/index.mts b/src/index.mts index b2158cf..e1fd778 100644 --- a/src/index.mts +++ b/src/index.mts @@ -14,7 +14,7 @@ import * as path from "path"; import { spawnPromise } from "spawn-rx"; import { rimraf } from "rimraf"; -const VERSION = '0.6.21'; +const VERSION = '0.6.22'; /** * System Configuration @@ -139,6 +139,17 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { required: ["url"], }, }, + { + name: "download_audio", + description: "Download audio in best available quality (usually m4a/mp3 format) to the user's default Downloads folder (usually ~/Downloads).", + inputSchema: { + type: "object", + properties: { + url: { type: "string", description: "URL of the video" }, + }, + required: ["url"], + }, + }, ], }; }); @@ -460,6 +471,110 @@ export async function downloadVideo(url: string, resolution = "720p"): Promise { + const userDownloadsDir = CONFIG.DOWNLOADS_DIR; + + try { + validateUrl(url); + const timestamp = getFormattedTimestamp(); + + const outputTemplate = path.join( + userDownloadsDir, + `%(title).${CONFIG.MAX_FILENAME_LENGTH}s [%(id)s] ${timestamp}.%(ext)s` + ); + + let format: string; + if (isYouTubeUrl(url)) { + format = "140/bestaudio[ext=m4a]/bestaudio"; // 優先選擇 m4a + } else { + format = "bestaudio[ext=m4a]/bestaudio[ext=mp3]/bestaudio"; // 優先選擇 m4a/mp3 + } + + // Get expected filename + 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 audio + 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 `Audio successfully downloaded as "${path.basename(expectedFilename)}" to ${userDownloadsDir}`; + } catch (error) { + if (error instanceof VideoDownloadError) { + throw error; + } + throw new VideoDownloadError( + ERROR_CODES.UNKNOWN_ERROR, + 'UNKNOWN_ERROR', + error as Error + ); + } +} + /** * Handle tool execution with unified error handling * @param action Async operation to execute @@ -514,6 +629,11 @@ server.setRequestHandler( () => downloadVideo(args.url, args.resolution), "Error downloading video" ); + } else if (toolName === "download_audio") { + return handleToolExecution( + () => downloadAudio(args.url), + "Error downloading audio" + ); } else { return { content: [{ type: "text", text: `Unknown tool: ${toolName}` }],