#
tokens: 48553/50000 36/42 files (page 1/2)
lines: on (toggle) GitHub
raw markdown copy reset
This is page 1 of 2. Use http://codebase.md/kevinwatt/yt-dlp-mcp?lines=true&page={x} to view the full context.

# Directory Structure

```
├── .claude
│   └── skills
│       └── mcp-builder
│           ├── LICENSE.txt
│           ├── reference
│           │   ├── evaluation.md
│           │   ├── mcp_best_practices.md
│           │   ├── node_mcp_server.md
│           │   └── python_mcp_server.md
│           ├── scripts
│           │   ├── connections.py
│           │   ├── evaluation.py
│           │   ├── example_evaluation.xml
│           │   └── requirements.txt
│           └── SKILL.md
├── .gitignore
├── .npmignore
├── .prettierrc
├── CHANGELOG.md
├── CLAUDE.md
├── docs
│   ├── api.md
│   ├── configuration.md
│   ├── contributing.md
│   ├── error-handling.md
│   └── search-feature-demo.md
├── eslint.config.mjs
├── jest.config.mjs
├── LICENSE
├── package-lock.json
├── package.json
├── README.md
├── src
│   ├── __tests__
│   │   ├── audio.test.ts
│   │   ├── index.test.ts
│   │   ├── metadata.test.ts
│   │   ├── search.test.ts
│   │   ├── subtitle.test.ts
│   │   └── video.test.ts
│   ├── config.ts
│   ├── index.mts
│   └── modules
│       ├── audio.ts
│       ├── metadata.ts
│       ├── search.ts
│       ├── subtitle.ts
│       ├── utils.ts
│       └── video.ts
├── test-bilibili.mjs
├── test-mcp.mjs
├── test-real-video.mjs
├── tsconfig.jest.json
└── tsconfig.json
```

# Files

--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------

```
1 | {
2 |   "endOfLine": "auto"
3 | }
4 | 
```

--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------

```
1 | src/
2 | .git/
3 | .gitignore
4 | .prettierrc
5 | eslint.config.mjs
6 | tsconfig.json
```

--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------

```
 1 | # Logs
 2 | logs
 3 | *.log
 4 | 
 5 | # Runtime data
 6 | pids
 7 | *.pid
 8 | *.seed
 9 | 
10 | # Directory for instrumented libs generated by jscoverage/JSCover
11 | lib-cov
12 | 
13 | # Coverage directory used by tools like istanbul
14 | coverage
15 | 
16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
17 | .grunt
18 | 
19 | # node-waf configuration
20 | .lock-wscript
21 | 
22 | # Compiled binary addons (http://nodejs.org/api/addons.html)
23 | build/Release
24 | 
25 | # Dependency directory
26 | # https://docs.npmjs.com/misc/faq#should-i-check-my-node-modules-folder-into-git
27 | node_modules
28 | 
29 | lib
30 | test-dist
31 | 
32 | # WebStorm
33 | .idea/
34 | 
35 | dist/
36 | 
```

--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------

```markdown
  1 | # 🎬 yt-dlp-mcp
  2 | 
  3 | <div align="center">
  4 | 
  5 | **A powerful MCP server that brings video platform capabilities to your AI agents**
  6 | 
  7 | [![npm version](https://img.shields.io/npm/v/@kevinwatt/yt-dlp-mcp.svg)](https://www.npmjs.com/package/@kevinwatt/yt-dlp-mcp)
  8 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
  9 | [![Node.js Version](https://img.shields.io/badge/node-%3E%3D18-brightgreen)](https://nodejs.org/)
 10 | [![TypeScript](https://img.shields.io/badge/TypeScript-5.6-blue)](https://www.typescriptlang.org/)
 11 | 
 12 | Integrate yt-dlp with Claude, Dive, and other MCP-compatible AI systems. Download videos, extract metadata, get transcripts, and more — all through natural language.
 13 | 
 14 | [Features](#-features) • [Installation](#-installation) • [Tools](#-available-tools) • [Usage](#-usage-examples) • [Documentation](#-documentation)
 15 | 
 16 | </div>
 17 | 
 18 | ---
 19 | 
 20 | ## ✨ Features
 21 | 
 22 | <table>
 23 | <tr>
 24 | <td width="50%">
 25 | 
 26 | ### 🔍 **Search & Discovery**
 27 | - Search YouTube with pagination
 28 | - JSON or Markdown output formats
 29 | - Filter by relevance and quality
 30 | 
 31 | ### 📊 **Metadata Extraction**
 32 | - Comprehensive video information
 33 | - Channel details and statistics
 34 | - Upload dates, tags, categories
 35 | - No content download required
 36 | 
 37 | ### 📝 **Transcript & Subtitles**
 38 | - Download subtitles in VTT format
 39 | - Generate clean text transcripts
 40 | - Multi-language support
 41 | - Auto-generated captions
 42 | 
 43 | </td>
 44 | <td width="50%">
 45 | 
 46 | ### 🎥 **Video Downloads**
 47 | - Resolution control (480p-1080p)
 48 | - Video trimming support
 49 | - Platform-agnostic (YouTube, Facebook, etc.)
 50 | - Saved to Downloads folder
 51 | 
 52 | ### 🎵 **Audio Extraction**
 53 | - Best quality audio (M4A/MP3)
 54 | - Direct audio-only downloads
 55 | - Perfect for podcasts & music
 56 | 
 57 | ### 🛡️ **Privacy & Safety**
 58 | - No tracking or analytics
 59 | - Direct downloads via yt-dlp
 60 | - Zod schema validation
 61 | - Character limits for LLM safety
 62 | 
 63 | </td>
 64 | </tr>
 65 | </table>
 66 | 
 67 | ---
 68 | 
 69 | ## 🚀 Installation
 70 | 
 71 | ### Prerequisites
 72 | 
 73 | **Install yt-dlp** on your system:
 74 | 
 75 | <table>
 76 | <tr>
 77 | <th>Platform</th>
 78 | <th>Command</th>
 79 | </tr>
 80 | <tr>
 81 | <td>🪟 <strong>Windows</strong></td>
 82 | <td><code>winget install yt-dlp</code></td>
 83 | </tr>
 84 | <tr>
 85 | <td>🍎 <strong>macOS</strong></td>
 86 | <td><code>brew install yt-dlp</code></td>
 87 | </tr>
 88 | <tr>
 89 | <td>🐧 <strong>Linux</strong></td>
 90 | <td><code>pip install yt-dlp</code></td>
 91 | </tr>
 92 | </table>
 93 | 
 94 | ### Quick Setup with Dive Desktop
 95 | 
 96 | 1. Open [Dive Desktop](https://github.com/OpenAgentPlatform/Dive)
 97 | 2. Click **"+ Add MCP Server"**
 98 | 3. Paste this configuration:
 99 | 
100 | ```json
101 | {
102 |   "mcpServers": {
103 |     "yt-dlp": {
104 |       "command": "npx",
105 |       "args": ["-y", "@kevinwatt/yt-dlp-mcp"]
106 |     }
107 |   }
108 | }
109 | ```
110 | 
111 | 4. Click **"Save"** and you're ready! 🎉
112 | 
113 | ### Manual Installation
114 | 
115 | ```bash
116 | npm install -g @kevinwatt/yt-dlp-mcp
117 | ```
118 | 
119 | ---
120 | 
121 | ## 🛠️ Available Tools
122 | 
123 | All tools are prefixed with `ytdlp_` to avoid naming conflicts with other MCP servers.
124 | 
125 | ### 🔍 Search & Discovery
126 | 
127 | <table>
128 | <tr>
129 | <th width="30%">Tool</th>
130 | <th width="70%">Description</th>
131 | </tr>
132 | <tr>
133 | <td><code>ytdlp_search_videos</code></td>
134 | <td>
135 | 
136 | Search YouTube with pagination support
137 | - **Parameters**: `query`, `maxResults`, `offset`, `response_format`
138 | - **Returns**: Video list with titles, channels, durations, URLs
139 | - **Supports**: JSON and Markdown formats
140 | 
141 | </td>
142 | </tr>
143 | </table>
144 | 
145 | ### 📝 Subtitles & Transcripts
146 | 
147 | <table>
148 | <tr>
149 | <th width="30%">Tool</th>
150 | <th width="70%">Description</th>
151 | </tr>
152 | <tr>
153 | <td><code>ytdlp_list_subtitle_languages</code></td>
154 | <td>
155 | 
156 | List all available subtitle languages for a video
157 | - **Parameters**: `url`
158 | - **Returns**: Available languages, formats, auto-generated status
159 | 
160 | </td>
161 | </tr>
162 | <tr>
163 | <td><code>ytdlp_download_video_subtitles</code></td>
164 | <td>
165 | 
166 | Download subtitles in VTT format with timestamps
167 | - **Parameters**: `url`, `language` (optional)
168 | - **Returns**: Raw VTT subtitle content
169 | 
170 | </td>
171 | </tr>
172 | <tr>
173 | <td><code>ytdlp_download_transcript</code></td>
174 | <td>
175 | 
176 | Generate clean plain text transcript
177 | - **Parameters**: `url`, `language` (optional)
178 | - **Returns**: Cleaned text without timestamps or formatting
179 | 
180 | </td>
181 | </tr>
182 | </table>
183 | 
184 | ### 🎥 Video & Audio Downloads
185 | 
186 | <table>
187 | <tr>
188 | <th width="30%">Tool</th>
189 | <th width="70%">Description</th>
190 | </tr>
191 | <tr>
192 | <td><code>ytdlp_download_video</code></td>
193 | <td>
194 | 
195 | Download video to Downloads folder
196 | - **Parameters**: `url`, `resolution`, `startTime`, `endTime`
197 | - **Resolutions**: 480p, 720p, 1080p, best
198 | - **Supports**: Video trimming
199 | 
200 | </td>
201 | </tr>
202 | <tr>
203 | <td><code>ytdlp_download_audio</code></td>
204 | <td>
205 | 
206 | Extract and download audio only
207 | - **Parameters**: `url`
208 | - **Format**: Best quality M4A/MP3
209 | 
210 | </td>
211 | </tr>
212 | </table>
213 | 
214 | ### 📊 Metadata
215 | 
216 | <table>
217 | <tr>
218 | <th width="30%">Tool</th>
219 | <th width="70%">Description</th>
220 | </tr>
221 | <tr>
222 | <td><code>ytdlp_get_video_metadata</code></td>
223 | <td>
224 | 
225 | Extract comprehensive video metadata in JSON
226 | - **Parameters**: `url`, `fields` (optional array)
227 | - **Returns**: Complete metadata or filtered fields
228 | - **Includes**: Views, likes, upload date, tags, formats, etc.
229 | 
230 | </td>
231 | </tr>
232 | <tr>
233 | <td><code>ytdlp_get_video_metadata_summary</code></td>
234 | <td>
235 | 
236 | Get human-readable metadata summary
237 | - **Parameters**: `url`
238 | - **Returns**: Formatted text with key information
239 | 
240 | </td>
241 | </tr>
242 | </table>
243 | 
244 | ---
245 | 
246 | ## 💡 Usage Examples
247 | 
248 | ### Search Videos
249 | 
250 | ```
251 | "Search for Python programming tutorials"
252 | "Find the top 20 machine learning videos"
253 | "Search for 'react hooks tutorial' and show results 10-20"
254 | "Search for JavaScript courses in JSON format"
255 | ```
256 | 
257 | ### Get Metadata
258 | 
259 | ```
260 | "Get metadata for https://youtube.com/watch?v=..."
261 | "Show me the title, channel, and view count for this video"
262 | "Extract just the duration and upload date"
263 | "Give me a quick summary of this video's info"
264 | ```
265 | 
266 | ### Download Subtitles & Transcripts
267 | 
268 | ```
269 | "List available subtitles for https://youtube.com/watch?v=..."
270 | "Download English subtitles from this video"
271 | "Get a clean transcript of this video in Spanish"
272 | "Download Chinese (zh-Hant) transcript"
273 | ```
274 | 
275 | ### Download Content
276 | 
277 | ```
278 | "Download this video in 1080p: https://youtube.com/watch?v=..."
279 | "Download audio from this YouTube video"
280 | "Download this video from 1:30 to 2:45"
281 | "Save this Facebook video to my Downloads"
282 | ```
283 | 
284 | ---
285 | 
286 | ## 📖 Documentation
287 | 
288 | - **[API Reference](./docs/api.md)** - Detailed tool documentation
289 | - **[Configuration](./docs/configuration.md)** - Environment variables and settings
290 | - **[Error Handling](./docs/error-handling.md)** - Common errors and solutions
291 | - **[Contributing](./docs/contributing.md)** - How to contribute
292 | 
293 | ---
294 | 
295 | ## 🔧 Configuration
296 | 
297 | ### Environment Variables
298 | 
299 | ```bash
300 | # Downloads directory (default: ~/Downloads)
301 | YTDLP_DOWNLOADS_DIR=/path/to/downloads
302 | 
303 | # Default resolution (default: 720p)
304 | YTDLP_DEFAULT_RESOLUTION=1080p
305 | 
306 | # Default subtitle language (default: en)
307 | YTDLP_DEFAULT_SUBTITLE_LANG=en
308 | 
309 | # Character limit (default: 25000)
310 | YTDLP_CHARACTER_LIMIT=25000
311 | 
312 | # Max transcript length (default: 50000)
313 | YTDLP_MAX_TRANSCRIPT_LENGTH=50000
314 | ```
315 | 
316 | ---
317 | 
318 | ## 🏗️ Architecture
319 | 
320 | ### Built With
321 | 
322 | - **[yt-dlp](https://github.com/yt-dlp/yt-dlp)** - Video extraction engine
323 | - **[MCP SDK](https://github.com/modelcontextprotocol/typescript-sdk)** - Model Context Protocol
324 | - **[Zod](https://github.com/colinhacks/zod)** - TypeScript-first schema validation
325 | - **TypeScript** - Type safety and developer experience
326 | 
327 | ### Key Features
328 | 
329 | - ✅ **Type-Safe**: Full TypeScript with strict mode
330 | - ✅ **Validated Inputs**: Zod schemas for runtime validation
331 | - ✅ **Character Limits**: Automatic truncation to prevent context overflow
332 | - ✅ **Tool Annotations**: readOnly, destructive, idempotent hints
333 | - ✅ **Error Guidance**: Actionable error messages for LLMs
334 | - ✅ **Modular Design**: Clean separation of concerns
335 | 
336 | ---
337 | 
338 | ## 📊 Response Formats
339 | 
340 | ### JSON Format
341 | Perfect for programmatic processing:
342 | ```json
343 | {
344 |   "total": 50,
345 |   "count": 10,
346 |   "offset": 0,
347 |   "videos": [...],
348 |   "has_more": true,
349 |   "next_offset": 10
350 | }
351 | ```
352 | 
353 | ### Markdown Format
354 | Human-readable display:
355 | ```markdown
356 | Found 50 videos (showing 10):
357 | 
358 | 1. **Video Title**
359 |    📺 Channel: Creator Name
360 |    ⏱️  Duration: 10:30
361 |    🔗 URL: https://...
362 | ```
363 | 
364 | ---
365 | 
366 | ## 🔒 Privacy & Security
367 | 
368 | - **No Tracking**: Direct downloads, no analytics
369 | - **Input Validation**: Zod schemas prevent injection
370 | - **URL Validation**: Strict URL format checking
371 | - **Character Limits**: Prevents context overflow attacks
372 | - **Read-Only by Default**: Most tools don't modify system state
373 | 
374 | ---
375 | 
376 | ## 🤝 Contributing
377 | 
378 | Contributions are welcome! Please check out our [Contributing Guide](./docs/contributing.md).
379 | 
380 | 1. Fork the repository
381 | 2. Create a feature branch (`git checkout -b feature/amazing-feature`)
382 | 3. Commit your changes (`git commit -m 'Add amazing feature'`)
383 | 4. Push to the branch (`git push origin feature/amazing-feature`)
384 | 5. Open a Pull Request
385 | 
386 | ---
387 | 
388 | ## 📝 License
389 | 
390 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
391 | 
392 | ---
393 | 
394 | ## 🙏 Acknowledgments
395 | 
396 | - [yt-dlp](https://github.com/yt-dlp/yt-dlp) - The amazing video extraction tool
397 | - [Anthropic](https://www.anthropic.com/) - For the Model Context Protocol
398 | - [Dive](https://github.com/OpenAgentPlatform/Dive) - MCP-compatible AI platform
399 | 
400 | ---
401 | 
402 | ## 📚 Related Projects
403 | 
404 | - [MCP Servers](https://github.com/modelcontextprotocol/servers) - Official MCP server implementations
405 | - [yt-dlp](https://github.com/yt-dlp/yt-dlp) - Command-line video downloader
406 | - [Dive Desktop](https://github.com/OpenAgentPlatform/Dive) - AI agent platform
407 | 
408 | ---
409 | 
410 | <div align="center">
411 | 
412 | [⬆ Back to Top](#-yt-dlp-mcp)
413 | 
414 | </div>
415 | 
```

--------------------------------------------------------------------------------
/docs/contributing.md:
--------------------------------------------------------------------------------

```markdown
  1 | # Contributing Guide
  2 | 
  3 | ## Getting Started
  4 | 
  5 | 1. Fork the repository
  6 | 2. Clone your fork:
  7 | 
  8 | ```bash
  9 | git clone https://github.com/your-username/yt-dlp-mcp.git
 10 | cd yt-dlp-mcp
 11 | ```
 12 | 
 13 | 3. Install dependencies:
 14 | 
 15 | ```bash
 16 | npm install
 17 | ```
 18 | 
 19 | 4. Create a new branch:
 20 | 
 21 | ```bash
 22 | git checkout -b feature/your-feature-name
 23 | ```
 24 | 
 25 | ## Development Setup
 26 | 
 27 | ### Prerequisites
 28 | 
 29 | - Node.js 16.x or higher
 30 | - yt-dlp installed on your system
 31 | - TypeScript knowledge
 32 | - Jest for testing
 33 | 
 34 | ### Building
 35 | 
 36 | ```bash
 37 | npm run prepare
 38 | ```
 39 | 
 40 | ### Running Tests
 41 | 
 42 | ```bash
 43 | npm test
 44 | ```
 45 | 
 46 | For specific test files:
 47 | 
 48 | ```bash
 49 | npm test -- src/__tests__/video.test.ts
 50 | ```
 51 | 
 52 | ## Code Style
 53 | 
 54 | We use TypeScript and follow these conventions:
 55 | 
 56 | - Use meaningful variable and function names
 57 | - Add JSDoc comments for public APIs
 58 | - Follow the existing code style
 59 | - Use async/await for promises
 60 | - Handle errors appropriately
 61 | 
 62 | ### TypeScript Guidelines
 63 | 
 64 | ```typescript
 65 | // Use explicit types
 66 | function downloadVideo(url: string, config?: Config): Promise<string> {
 67 |   // Implementation
 68 | }
 69 | 
 70 | // Use interfaces for complex types
 71 | interface DownloadOptions {
 72 |   resolution: string;
 73 |   format: string;
 74 |   output: string;
 75 | }
 76 | 
 77 | // Use enums for fixed values
 78 | enum Resolution {
 79 |   SD = "480p",
 80 |   HD = "720p",
 81 |   FHD = "1080p",
 82 |   BEST = "best",
 83 | }
 84 | ```
 85 | 
 86 | ## Testing
 87 | 
 88 | ### Writing Tests
 89 | 
 90 | - Place tests in `src/__tests__` directory
 91 | - Name test files with `.test.ts` suffix
 92 | - Use descriptive test names
 93 | - Test both success and error cases
 94 | 
 95 | Example:
 96 | 
 97 | ```typescript
 98 | describe("downloadVideo", () => {
 99 |   test("downloads video successfully", async () => {
100 |     const result = await downloadVideo(testUrl);
101 |     expect(result).toMatch(/Video successfully downloaded/);
102 |   });
103 | 
104 |   test("handles invalid URL", async () => {
105 |     await expect(downloadVideo("invalid-url")).rejects.toThrow(
106 |       "Invalid or unsupported URL"
107 |     );
108 |   });
109 | });
110 | ```
111 | 
112 | ### Test Coverage
113 | 
114 | Aim for high test coverage:
115 | 
116 | ```bash
117 | npm run test:coverage
118 | ```
119 | 
120 | ## Documentation
121 | 
122 | ### JSDoc Comments
123 | 
124 | Add comprehensive JSDoc comments for all public APIs:
125 | 
126 | ````typescript
127 | /**
128 |  * Downloads a video from the specified URL.
129 |  *
130 |  * @param url - The URL of the video to download
131 |  * @param config - Optional configuration object
132 |  * @param resolution - Preferred video resolution
133 |  * @returns Promise resolving to success message with file path
134 |  * @throws {Error} When URL is invalid or download fails
135 |  *
136 |  * @example
137 |  * ```typescript
138 |  * const result = await downloadVideo('https://youtube.com/watch?v=...', config);
139 |  * console.log(result);
140 |  * ```
141 |  */
142 | export async function downloadVideo(
143 |   url: string,
144 |   config?: Config,
145 |   resolution?: string
146 | ): Promise<string> {
147 |   // Implementation
148 | }
149 | ````
150 | 
151 | ### README Updates
152 | 
153 | - Update README.md for new features
154 | - Keep examples up to date
155 | - Document breaking changes
156 | 
157 | ## Pull Request Process
158 | 
159 | 1. Update tests and documentation
160 | 2. Run all tests and linting
161 | 3. Update CHANGELOG.md
162 | 4. Create detailed PR description
163 | 5. Reference related issues
164 | 
165 | ### PR Checklist
166 | 
167 | - [ ] Tests added/updated
168 | - [ ] Documentation updated
169 | - [ ] CHANGELOG.md updated
170 | - [ ] Code follows style guidelines
171 | - [ ] All tests passing
172 | - [ ] No linting errors
173 | 
174 | ## Release Process
175 | 
176 | 1. Update version in package.json
177 | 2. Update CHANGELOG.md
178 | 3. Create release commit
179 | 4. Tag release
180 | 5. Push to main branch
181 | 
182 | ### Version Numbers
183 | 
184 | Follow semantic versioning:
185 | 
186 | - MAJOR: Breaking changes
187 | - MINOR: New features
188 | - PATCH: Bug fixes
189 | 
190 | ## Community
191 | 
192 | - Be respectful and inclusive
193 | - Help others when possible
194 | - Report bugs with detailed information
195 | - Suggest improvements
196 | - Share success stories
197 | 
198 | For more information, see the [README](./README.md) and [API Reference](./api.md).
199 | 
```

--------------------------------------------------------------------------------
/CLAUDE.md:
--------------------------------------------------------------------------------

```markdown
  1 | # CLAUDE.md
  2 | 
  3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
  4 | 
  5 | ## Development Commands
  6 | 
  7 | ### Build and Prepare
  8 | ```bash
  9 | npm run prepare  # Compile TypeScript and make binary executable
 10 | ```
 11 | 
 12 | ### Testing
 13 | ```bash
 14 | npm test  # Run Jest tests with ESM support
 15 | ```
 16 | 
 17 | ### Manual Testing
 18 | ```bash
 19 | npx @kevinwatt/yt-dlp-mcp  # Start MCP server manually
 20 | ```
 21 | 
 22 | ## Code Architecture
 23 | 
 24 | ### MCP Server Implementation
 25 | This is an MCP (Model Context Protocol) server that integrates with `yt-dlp` for video/audio downloading. The server:
 26 | 
 27 | - **Entry point**: `src/index.mts` - Main MCP server implementation with tool handlers
 28 | - **Modular design**: Each feature lives in `src/modules/` (video.ts, audio.ts, subtitle.ts, search.ts, metadata.ts)
 29 | - **Configuration**: `src/config.ts` - Centralized config with environment variable support and validation
 30 | - **Utility functions**: `src/modules/utils.ts` - Shared spawn and cleanup utilities
 31 | 
 32 | ### Tool Architecture
 33 | The server exposes 8 MCP tools:
 34 | 1. `search_videos` - YouTube video search
 35 | 2. `list_subtitle_languages` - List available subtitles
 36 | 3. `download_video_subtitles` - Download subtitle files  
 37 | 4. `download_video` - Download videos with resolution/trimming options
 38 | 5. `download_audio` - Extract and download audio
 39 | 6. `download_transcript` - Generate clean text transcripts
 40 | 7. `get_video_metadata` - Extract comprehensive video metadata (JSON format)
 41 | 8. `get_video_metadata_summary` - Get human-readable metadata summary
 42 | 
 43 | ### Key Patterns
 44 | - **Unified error handling**: `handleToolExecution()` wrapper for consistent error responses
 45 | - **Spawn management**: All external tool calls go through `_spawnPromise()` with cleanup
 46 | - **Configuration-driven**: All defaults and behavior configurable via environment variables
 47 | - **ESM modules**: Uses `.mts` extension and ESM imports throughout
 48 | - **Filename sanitization**: Cross-platform safe filename handling with length limits
 49 | - **Metadata extraction**: Uses `yt-dlp --dump-json` for comprehensive video information without downloading content
 50 | 
 51 | ### Dependencies
 52 | - **Required external**: `yt-dlp` must be installed and in PATH
 53 | - **Core MCP**: `@modelcontextprotocol/sdk` for server implementation
 54 | - **Process management**: `spawn-rx` for async process spawning
 55 | - **File operations**: `rimraf` for cleanup
 56 | 
 57 | ### Configuration System
 58 | `CONFIG` object loaded from `config.ts` supports:
 59 | - Download directory customization (defaults to ~/Downloads)
 60 | - Resolution/format preferences
 61 | - Filename sanitization rules
 62 | - Temporary directory management
 63 | - Environment variable overrides (YTDLP_* prefix)
 64 | 
 65 | ### Testing Setup
 66 | - **Jest with ESM**: Custom config for TypeScript + ESM support
 67 | - **Test isolation**: Tests run in separate environment with mocked dependencies
 68 | - **Coverage**: Tests for each module in `src/__tests__/`
 69 | 
 70 | ### TypeScript Configuration
 71 | - **Strict mode**: All strict TypeScript checks enabled
 72 | - **ES2020 target**: Modern JavaScript features
 73 | - **Declaration generation**: Types exported to `lib/` for consumption
 74 | - **Source maps**: Enabled for debugging
 75 | 
 76 | ### Build Output
 77 | - **Compiled code**: `lib/` directory with .js, .d.ts, and .map files
 78 | - **Executable**: `lib/index.mjs` with shebang for direct execution
 79 | - **Module structure**: Preserves source module organization
 80 | 
 81 | ## Metadata Module Details
 82 | 
 83 | ### VideoMetadata Interface
 84 | The `metadata.ts` module exports a comprehensive `VideoMetadata` interface containing fields like:
 85 | - Basic info: `id`, `title`, `description`, `duration`, `upload_date`
 86 | - Channel info: `channel`, `channel_id`, `channel_url`, `uploader`
 87 | - Analytics: `view_count`, `like_count`, `comment_count`
 88 | - Technical: `formats`, `thumbnails`, `subtitles`
 89 | - Content: `tags`, `categories`, `series`, `episode` data
 90 | 
 91 | ### Key Functions
 92 | - `getVideoMetadata(url, fields?, config?)` - Extract full or filtered metadata as JSON
 93 | - `getVideoMetadataSummary(url, config?)` - Generate human-readable summary
 94 | 
 95 | ### Testing
 96 | Comprehensive test suite in `src/__tests__/metadata.test.ts` covers:
 97 | - Field filtering and extraction
 98 | - Error handling for invalid URLs
 99 | - Format validation
100 | - Real-world integration with YouTube videos
```

--------------------------------------------------------------------------------
/.claude/skills/mcp-builder/scripts/requirements.txt:
--------------------------------------------------------------------------------

```
1 | anthropic>=0.39.0
2 | mcp>=1.1.0
3 | 
```

--------------------------------------------------------------------------------
/tsconfig.jest.json:
--------------------------------------------------------------------------------

```json
1 | {
2 |   "extends": "./tsconfig.json",
3 |   "compilerOptions": {
4 |     "outDir": "./lib",
5 |     "module": "ES2020",
6 |     "target": "ES2020"
7 |   }
8 | } 
```

--------------------------------------------------------------------------------
/jest.config.mjs:
--------------------------------------------------------------------------------

```
 1 | export default {
 2 |   preset: 'ts-jest/presets/default-esm',
 3 |   testEnvironment: 'node',
 4 |   extensionsToTreatAsEsm: ['.ts', '.mts'],
 5 |   moduleNameMapper: {
 6 |     '^(\\.{1,2}/.*)\\.m?js$': '$1',
 7 |   },
 8 |   transform: {
 9 |     '^.+\\.m?[tj]s$': ['ts-jest', {
10 |       useESM: true,
11 |     }],
12 |   },
13 | }; 
```

--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------

```json
 1 | {
 2 |   "compilerOptions": {
 3 |     "removeComments": false,
 4 |     "preserveConstEnums": true,
 5 |     "sourceMap": true,
 6 |     "declaration": true,
 7 |     "noImplicitAny": true,
 8 |     "noImplicitReturns": true,
 9 |     "strictNullChecks": true,
10 |     "noUnusedLocals": true,
11 |     "noImplicitThis": true,
12 |     "noUnusedParameters": true,
13 |     "module": "ES2020",
14 |     "moduleResolution": "node",
15 |     "pretty": true,
16 |     "target": "ES2020",
17 |     "outDir": "lib",
18 |     "lib": ["dom", "es2015"],
19 |     "esModuleInterop": true,
20 |     "allowJs": true
21 |   },
22 |   "formatCodeOptions": {
23 |     "indentSize": 2,
24 |     "tabSize": 2
25 |   },
26 |   "include": ["src/**/*.mts", "src/types/*.d.ts"],
27 |   "exclude": ["node_modules", "lib"]
28 | }
29 | 
```

--------------------------------------------------------------------------------
/.claude/skills/mcp-builder/scripts/example_evaluation.xml:
--------------------------------------------------------------------------------

```
 1 | <evaluation>
 2 |    <qa_pair>
 3 |       <question>Calculate the compound interest on $10,000 invested at 5% annual interest rate, compounded monthly for 3 years. What is the final amount in dollars (rounded to 2 decimal places)?</question>
 4 |       <answer>11614.72</answer>
 5 |    </qa_pair>
 6 |    <qa_pair>
 7 |       <question>A projectile is launched at a 45-degree angle with an initial velocity of 50 m/s. Calculate the total distance (in meters) it has traveled from the launch point after 2 seconds, assuming g=9.8 m/s². Round to 2 decimal places.</question>
 8 |       <answer>87.25</answer>
 9 |    </qa_pair>
10 |    <qa_pair>
11 |       <question>A sphere has a volume of 500 cubic meters. Calculate its surface area in square meters. Round to 2 decimal places.</question>
12 |       <answer>304.65</answer>
13 |    </qa_pair>
14 |    <qa_pair>
15 |       <question>Calculate the population standard deviation of this dataset: [12, 15, 18, 22, 25, 30, 35]. Round to 2 decimal places.</question>
16 |       <answer>7.61</answer>
17 |    </qa_pair>
18 |    <qa_pair>
19 |       <question>Calculate the pH of a solution with a hydrogen ion concentration of 3.5 × 10^-5 M. Round to 2 decimal places.</question>
20 |       <answer>4.46</answer>
21 |    </qa_pair>
22 | </evaluation>
23 | 
```

--------------------------------------------------------------------------------
/src/__tests__/audio.test.ts:
--------------------------------------------------------------------------------

```typescript
 1 | // @ts-nocheck
 2 | // @jest-environment node
 3 | import { describe, test, expect } from '@jest/globals';
 4 | import * as os from 'os';
 5 | import * as path from 'path';
 6 | import { downloadAudio } from '../modules/audio.js';
 7 | import { CONFIG } from '../config.js';
 8 | import * as fs from 'fs';
 9 | 
10 | describe('downloadAudio', () => {
11 |   const testUrl = 'https://www.youtube.com/watch?v=jNQXAC9IVRw';
12 |   const testConfig = {
13 |     ...CONFIG,
14 |     file: {
15 |       ...CONFIG.file,
16 |       downloadsDir: path.join(os.tmpdir(), 'yt-dlp-test-downloads'),
17 |       tempDirPrefix: 'yt-dlp-test-'
18 |     }
19 |   };
20 | 
21 |   beforeAll(async () => {
22 |     await fs.promises.mkdir(testConfig.file.downloadsDir, { recursive: true });
23 |   });
24 | 
25 |   afterAll(async () => {
26 |     await fs.promises.rm(testConfig.file.downloadsDir, { recursive: true, force: true });
27 |   });
28 | 
29 |   test('downloads audio successfully from YouTube', async () => {
30 |     const result = await downloadAudio(testUrl, testConfig);
31 |     expect(result).toContain('Audio successfully downloaded');
32 |     
33 |     const files = await fs.promises.readdir(testConfig.file.downloadsDir);
34 |     expect(files.length).toBeGreaterThan(0);
35 |     expect(files[0]).toMatch(/\.m4a$/);
36 |   }, 30000);
37 | 
38 |   test('handles invalid URL', async () => {
39 |     await expect(downloadAudio('invalid-url', testConfig))
40 |       .rejects
41 |       .toThrow();
42 |   });
43 | }); 
```

--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------

```json
 1 | {
 2 |   "name": "@kevinwatt/yt-dlp-mcp",
 3 |   "version": "0.7.0",
 4 |   "description": "An MCP server implementation that integrates with yt-dlp, providing video and audio content download capabilities (e.g. YouTube, Facebook, Tiktok, etc.) for LLMs.",
 5 |   "keywords": [
 6 |     "mcp",
 7 |     "youtube",
 8 |     "yt-dlp",
 9 |     "dive",
10 |     "llm"
11 |   ],
12 |   "homepage": "https://github.com/kevinwatt/yt-dlp-mcp#readme",
13 |   "bugs": {
14 |     "url": "https://github.com/kevinwatt/yt-dlp-mcp/issues"
15 |   },
16 |   "repository": {
17 |     "type": "git",
18 |     "url": "git+https://github.com/kevinwatt/yt-dlp-mcp.git"
19 |   },
20 |   "bin": {
21 |     "yt-dlp-mcp": "lib/index.mjs"
22 |   },
23 |   "files": [
24 |     "lib",
25 |     "README.md"
26 |   ],
27 |   "main": "./lib/index.mjs",
28 |   "scripts": {
29 |     "prepare": "tsc --skipLibCheck && chmod +x ./lib/index.mjs",
30 |     "test": "PYTHONPATH= PYTHONHOME= node --experimental-vm-modules node_modules/jest/bin/jest.js --detectOpenHandles --forceExit"
31 |   },
32 |   "author": "Dewei Yen <[email protected]>",
33 |   "license": "MIT",
34 |   "type": "module",
35 |   "exports": {
36 |     ".": {
37 |       "import": "./lib/index.mjs"
38 |     }
39 |   },
40 |   "dependencies": {
41 |     "@modelcontextprotocol/sdk": "0.7.0",
42 |     "rimraf": "^6.0.1",
43 |     "spawn-rx": "^4.0.0",
44 |     "zod": "^4.1.12"
45 |   },
46 |   "devDependencies": {
47 |     "@jest/globals": "^29.7.0",
48 |     "@types/jest": "^29.5.14",
49 |     "jest": "^29.7.0",
50 |     "shx": "^0.3.4",
51 |     "ts-jest": "^29.2.5",
52 |     "ts-node": "^10.9.2",
53 |     "typescript": "^5.6.3"
54 |   }
55 | }
56 | 
```

--------------------------------------------------------------------------------
/src/__tests__/index.test.ts:
--------------------------------------------------------------------------------

```typescript
 1 | // @ts-nocheck
 2 | // @jest-environment node
 3 | import { describe, test, expect } from '@jest/globals';
 4 | import * as os from 'os';
 5 | import * as path from 'path';
 6 | import { downloadVideo } from '../modules/video.js';
 7 | import { CONFIG } from '../config.js';
 8 | import * as fs from 'fs';
 9 | 
10 | // 設置 Python 環境
11 | process.env.PYTHONPATH = '';
12 | process.env.PYTHONHOME = '';
13 | 
14 | describe('downloadVideo', () => {
15 |   const testUrl = 'https://www.youtube.com/watch?v=jNQXAC9IVRw';
16 |   const testConfig = {
17 |     ...CONFIG,
18 |     file: {
19 |       ...CONFIG.file,
20 |       downloadsDir: path.join(os.tmpdir(), 'yt-dlp-test-downloads'),
21 |       tempDirPrefix: 'yt-dlp-test-'
22 |     }
23 |   };
24 | 
25 |   beforeEach(async () => {
26 |     await fs.promises.mkdir(testConfig.file.downloadsDir, { recursive: true });
27 |   });
28 | 
29 |   afterEach(async () => {
30 |     await fs.promises.rm(testConfig.file.downloadsDir, { recursive: true, force: true });
31 |   });
32 | 
33 |   test('downloads video successfully with correct format', async () => {
34 |     const result = await downloadVideo(testUrl, testConfig);
35 |     expect(result).toContain('Video successfully downloaded');
36 |     
37 |     const files = await fs.promises.readdir(testConfig.file.downloadsDir);
38 |     expect(files.length).toBeGreaterThan(0);
39 |     expect(files[0]).toMatch(/\.(mp4|webm|mkv)$/);
40 |   }, 30000);
41 | 
42 |   test('uses correct resolution format', async () => {
43 |     const result = await downloadVideo(testUrl, testConfig, '1080p');
44 |     expect(result).toContain('Video successfully downloaded');
45 |     
46 |     const files = await fs.promises.readdir(testConfig.file.downloadsDir);
47 |     expect(files.length).toBeGreaterThan(0);
48 |     expect(files[0]).toMatch(/\.(mp4|webm|mkv)$/);
49 |   }, 30000);
50 | 
51 |   test('handles invalid URL', async () => {
52 |     await expect(downloadVideo('invalid-url', testConfig))
53 |       .rejects
54 |       .toThrow();
55 |   });
56 | }); 
```

--------------------------------------------------------------------------------
/eslint.config.mjs:
--------------------------------------------------------------------------------

```
 1 | import typescriptEslint from "@typescript-eslint/eslint-plugin";
 2 | import prettier from "eslint-plugin-prettier";
 3 | import tsParser from "@typescript-eslint/parser";
 4 | import path from "node:path";
 5 | import { fileURLToPath } from "node:url";
 6 | import js from "@eslint/js";
 7 | import { FlatCompat } from "@eslint/eslintrc";
 8 | 
 9 | const __filename = fileURLToPath(import.meta.url);
10 | const __dirname = path.dirname(__filename);
11 | const compat = new FlatCompat({
12 |   baseDirectory: __dirname,
13 |   recommendedConfig: js.configs.recommended,
14 |   allConfig: js.configs.all,
15 | });
16 | 
17 | export default [
18 |   ...compat.extends(
19 |     "eslint:recommended",
20 |     "prettier",
21 |     "plugin:@typescript-eslint/eslint-recommended",
22 |     "plugin:@typescript-eslint/recommended",
23 |   ),
24 |   {
25 |     files: ["./src/*.{ts,tsx}", "./test/*.{ts,tsx}"],
26 |     plugins: {
27 |       "@typescript-eslint": typescriptEslint,
28 |       prettier,
29 |     },
30 | 
31 |     languageOptions: {
32 |       globals: {},
33 |       parser: tsParser,
34 |       ecmaVersion: 5,
35 |       sourceType: "script",
36 | 
37 |       parserOptions: {
38 |         project: "./tsconfig.json",
39 |       },
40 |     },
41 | 
42 |     rules: {
43 |       "prettier/prettier": "warn",
44 | 
45 |       "spaced-comment": [
46 |         "error",
47 |         "always",
48 |         {
49 |           markers: ["/"],
50 |         },
51 |       ],
52 | 
53 |       "no-fallthrough": "error",
54 |       "@typescript-eslint/ban-ts-comment": "warn",
55 | 
56 |       "@typescript-eslint/consistent-type-imports": [
57 |         "error",
58 |         {
59 |           prefer: "type-imports",
60 |         },
61 |       ],
62 | 
63 |       "@typescript-eslint/no-inferrable-types": [
64 |         "error",
65 |         {
66 |           ignoreParameters: false,
67 |           ignoreProperties: false,
68 |         },
69 |       ],
70 | 
71 |       "@typescript-eslint/no-non-null-assertion": "off",
72 |       "@typescript-eslint/no-floating-promises": "error",
73 | 
74 |       "@typescript-eslint/no-unused-vars": [
75 |         "warn",
76 |         {
77 |           args: "after-used",
78 |           argsIgnorePattern: "^_",
79 |           varsIgnorePattern: "^_",
80 |           ignoreRestSiblings: true,
81 |         },
82 |       ],
83 | 
84 |       "@typescript-eslint/no-empty-function": ["error"],
85 |       "@typescript-eslint/restrict-template-expressions": "off",
86 |     },
87 |   },
88 | ];
89 | 
```

--------------------------------------------------------------------------------
/src/__tests__/video.test.ts:
--------------------------------------------------------------------------------

```typescript
 1 | // @ts-nocheck
 2 | // @jest-environment node
 3 | import { describe, test, expect } from '@jest/globals';
 4 | import * as os from 'os';
 5 | import * as path from 'path';
 6 | import { downloadVideo } from '../modules/video.js';
 7 | import { CONFIG } from '../config.js';
 8 | import * as fs from 'fs';
 9 | 
10 | // 設置 Python 環境
11 | process.env.PYTHONPATH = '';
12 | process.env.PYTHONHOME = '';
13 | 
14 | describe('downloadVideo with trimming', () => {
15 |   const testUrl = 'https://www.youtube.com/watch?v=jNQXAC9IVRw';
16 |   const testConfig = {
17 |     ...CONFIG,
18 |     file: {
19 |       ...CONFIG.file,
20 |       downloadsDir: path.join(os.tmpdir(), 'yt-dlp-test-downloads'),
21 |       tempDirPrefix: 'yt-dlp-test-'
22 |     }
23 |   };
24 | 
25 |   beforeEach(async () => {
26 |     await fs.promises.mkdir(testConfig.file.downloadsDir, { recursive: true });
27 |   });
28 | 
29 |   afterEach(async () => {
30 |     await fs.promises.rm(testConfig.file.downloadsDir, { recursive: true, force: true });
31 |   });
32 | 
33 |   test('downloads video with start time trimming', async () => {
34 |     const result = await downloadVideo(testUrl, testConfig, '720p', '00:00:10');
35 |     expect(result).toContain('Video successfully downloaded');
36 |     
37 |     const files = await fs.promises.readdir(testConfig.file.downloadsDir);
38 |     expect(files.length).toBeGreaterThan(0);
39 |     expect(files[0]).toMatch(/\.(mp4|webm|mkv)$/);
40 |   }, 30000);
41 | 
42 |   test('downloads video with end time trimming', async () => {
43 |     const result = await downloadVideo(testUrl, testConfig, '720p', undefined, '00:00:20');
44 |     expect(result).toContain('Video successfully downloaded');
45 |     
46 |     const files = await fs.promises.readdir(testConfig.file.downloadsDir);
47 |     expect(files.length).toBeGreaterThan(0);
48 |     expect(files[0]).toMatch(/\.(mp4|webm|mkv)$/);
49 |   }, 30000);
50 | 
51 |   test('downloads video with both start and end time trimming', async () => {
52 |     const result = await downloadVideo(testUrl, testConfig, '720p', '00:00:10', '00:00:20');
53 |     expect(result).toContain('Video successfully downloaded');
54 |     
55 |     const files = await fs.promises.readdir(testConfig.file.downloadsDir);
56 |     expect(files.length).toBeGreaterThan(0);
57 |     expect(files[0]).toMatch(/\.(mp4|webm|mkv)$/);
58 |   }, 30000);
59 | 
60 |   test('downloads video without trimming when no times provided', async () => {
61 |     const result = await downloadVideo(testUrl, testConfig, '720p');
62 |     expect(result).toContain('Video successfully downloaded');
63 |     
64 |     const files = await fs.promises.readdir(testConfig.file.downloadsDir);
65 |     expect(files.length).toBeGreaterThan(0);
66 |     expect(files[0]).toMatch(/\.(mp4|webm|mkv)$/);
67 |   }, 30000);
68 | });
```

--------------------------------------------------------------------------------
/docs/search-feature-demo.md:
--------------------------------------------------------------------------------

```markdown
 1 | # Search Feature Demo
 2 | 
 3 | The search functionality has been successfully added to yt-dlp-mcp! This feature allows you to search for videos on YouTube using keywords and get formatted results with video information.
 4 | 
 5 | ## New Tool: `search_videos`
 6 | 
 7 | ### Description
 8 | Search for videos on YouTube using keywords. Returns title, uploader, duration, and URL for each result.
 9 | 
10 | ### Parameters
11 | - `query` (string, required): Search keywords or phrase
12 | - `maxResults` (number, optional): Maximum number of results to return (1-50, default: 10)
13 | 
14 | ### Example Usage
15 | 
16 | Ask your LLM to:
17 | ```
18 | "Search for Python tutorial videos"
19 | "Find JavaScript courses and show me the top 5 results"
20 | "Search for machine learning tutorials with 15 results"
21 | ```
22 | 
23 | ### Example Output
24 | 
25 | When searching for "javascript tutorial" with 3 results, you'll get:
26 | 
27 | ```
28 | Found 3 videos:
29 | 
30 | 1. **JavaScript Tutorial Full Course - Beginner to Pro**
31 |    📺 Channel: Traversy Media
32 |    ⏱️  Duration: 15663
33 |    🔗 URL: https://www.youtube.com/watch?v=EerdGm-ehJQ
34 |    🆔 ID: EerdGm-ehJQ
35 | 
36 | 2. **JavaScript Course for Beginners**
37 |    📺 Channel: FreeCodeCamp.org
38 |    ⏱️  Duration: 12402
39 |    🔗 URL: https://www.youtube.com/watch?v=W6NZfCO5SIk
40 |    🆔 ID: W6NZfCO5SIk
41 | 
42 | 3. **JavaScript Full Course for free 🌐 (2024)**
43 |    📺 Channel: Bro Code
44 |    ⏱️  Duration: 43200
45 |    🔗 URL: https://www.youtube.com/watch?v=lfmg-EJ8gm4
46 |    🆔 ID: lfmg-EJ8gm4
47 | 
48 | 💡 You can use any URL to download videos, audio, or subtitles!
49 | ```
50 | 
51 | ## Integration with Existing Features
52 | 
53 | After searching for videos, you can directly use the returned URLs with other tools:
54 | 
55 | 1. **Download video**: Use the URL with `download_video`
56 | 2. **Download audio**: Use the URL with `download_audio`
57 | 3. **Get subtitles**: Use the URL with `list_subtitle_languages` or `download_video_subtitles`
58 | 4. **Get transcript**: Use the URL with `download_transcript`
59 | 
60 | ## Test Results
61 | 
62 | All search functionality tests pass:
63 | - ✅ Successfully search and format results
64 | - ✅ Reject empty search queries
65 | - ✅ Validate maxResults parameter range
66 | - ✅ Handle search with different result counts
67 | - ✅ Return properly formatted results
68 | - ✅ Handle obscure search terms gracefully
69 | 
70 | ## Implementation Details
71 | 
72 | The search feature uses yt-dlp's built-in search capability with the syntax:
73 | - `ytsearch[N]:[query]` where N is the number of results
74 | - Uses `--print` options to extract: title, id, uploader, duration
75 | - Results are formatted in a user-friendly way with emojis and clear structure
76 | 
77 | This addresses the feature request from [Issue #14](https://github.com/kevinwatt/yt-dlp-mcp/issues/14) and provides a seamless search experience for users. 
```

--------------------------------------------------------------------------------
/test-mcp.mjs:
--------------------------------------------------------------------------------

```
  1 | #!/usr/bin/env node
  2 | /**
  3 |  * Simple MCP protocol test
  4 |  * This script tests if the MCP server responds correctly to basic protocol messages
  5 |  */
  6 | 
  7 | import { spawn } from 'child_process';
  8 | import { fileURLToPath } from 'url';
  9 | import { dirname, join } from 'path';
 10 | 
 11 | const __filename = fileURLToPath(import.meta.url);
 12 | const __dirname = dirname(__filename);
 13 | 
 14 | const serverPath = join(__dirname, 'lib', 'index.mjs');
 15 | 
 16 | console.log('🧪 Testing yt-dlp MCP Server\n');
 17 | console.log('Starting server from:', serverPath);
 18 | 
 19 | const server = spawn('node', [serverPath]);
 20 | 
 21 | let testsPassed = 0;
 22 | let testsFailed = 0;
 23 | let responseBuffer = '';
 24 | 
 25 | // Timeout to ensure tests complete
 26 | const timeout = setTimeout(() => {
 27 |   console.log('\n⏱️  Test timeout - killing server');
 28 |   server.kill();
 29 |   process.exit(testsFailed > 0 ? 1 : 0);
 30 | }, 10000);
 31 | 
 32 | server.stdout.on('data', (data) => {
 33 |   responseBuffer += data.toString();
 34 | 
 35 |   // Try to parse JSON-RPC responses
 36 |   const lines = responseBuffer.split('\n');
 37 |   responseBuffer = lines.pop() || ''; // Keep incomplete line in buffer
 38 | 
 39 |   lines.forEach(line => {
 40 |     if (line.trim()) {
 41 |       try {
 42 |         const response = JSON.parse(line);
 43 |         console.log('📨 Received:', JSON.stringify(response, null, 2));
 44 | 
 45 |         if (response.result) {
 46 |           testsPassed++;
 47 |           console.log('✅ Test passed\n');
 48 |         }
 49 |       } catch (e) {
 50 |         // Not JSON, might be regular output
 51 |         console.log('📝 Output:', line);
 52 |       }
 53 |     }
 54 |   });
 55 | });
 56 | 
 57 | server.stderr.on('data', (data) => {
 58 |   console.log('🔧 Server log:', data.toString().trim());
 59 | });
 60 | 
 61 | server.on('close', (code) => {
 62 |   clearTimeout(timeout);
 63 |   console.log(`\n📊 Test Results:`);
 64 |   console.log(`   ✅ Passed: ${testsPassed}`);
 65 |   console.log(`   ❌ Failed: ${testsFailed}`);
 66 |   console.log(`   Server exit code: ${code}`);
 67 |   process.exit(testsFailed > 0 ? 1 : 0);
 68 | });
 69 | 
 70 | // Wait a bit for server to start
 71 | setTimeout(() => {
 72 |   console.log('\n🔍 Test 1: Initialize');
 73 |   const initRequest = {
 74 |     jsonrpc: '2.0',
 75 |     id: 1,
 76 |     method: 'initialize',
 77 |     params: {
 78 |       protocolVersion: '2024-11-05',
 79 |       capabilities: {},
 80 |       clientInfo: {
 81 |         name: 'test-client',
 82 |         version: '1.0.0'
 83 |       }
 84 |     }
 85 |   };
 86 | 
 87 |   server.stdin.write(JSON.stringify(initRequest) + '\n');
 88 | 
 89 |   setTimeout(() => {
 90 |     console.log('\n🔍 Test 2: List Tools');
 91 |     const listToolsRequest = {
 92 |       jsonrpc: '2.0',
 93 |       id: 2,
 94 |       method: 'tools/list',
 95 |       params: {}
 96 |     };
 97 | 
 98 |     server.stdin.write(JSON.stringify(listToolsRequest) + '\n');
 99 | 
100 |     setTimeout(() => {
101 |       console.log('\n✅ Basic protocol tests completed');
102 |       server.kill();
103 |     }, 2000);
104 |   }, 2000);
105 | }, 1000);
106 | 
```

--------------------------------------------------------------------------------
/src/modules/audio.ts:
--------------------------------------------------------------------------------

```typescript
 1 | import { readdirSync } from "fs";
 2 | import * as path from "path";
 3 | import type { Config } from "../config.js";
 4 | import { sanitizeFilename } from "../config.js";
 5 | import { _spawnPromise, validateUrl, getFormattedTimestamp, isYouTubeUrl } from "./utils.js";
 6 | 
 7 | /**
 8 |  * Downloads audio from a video URL in the best available quality.
 9 |  * 
10 |  * @param url - The URL of the video to extract audio from
11 |  * @param config - Configuration object for download settings
12 |  * @returns Promise resolving to a success message with the downloaded file path
13 |  * @throws {Error} When URL is invalid or download fails
14 |  * 
15 |  * @example
16 |  * ```typescript
17 |  * // Download audio with default settings
18 |  * const result = await downloadAudio('https://youtube.com/watch?v=...');
19 |  * console.log(result);
20 |  * 
21 |  * // Download audio with custom config
22 |  * const customResult = await downloadAudio('https://youtube.com/watch?v=...', {
23 |  *   file: {
24 |  *     downloadsDir: '/custom/path',
25 |  *     // ... other config options
26 |  *   }
27 |  * });
28 |  * console.log(customResult);
29 |  * ```
30 |  */
31 | export async function downloadAudio(url: string, config: Config): Promise<string> {
32 |   const timestamp = getFormattedTimestamp();
33 | 
34 |   try {
35 |     validateUrl(url);
36 | 
37 |     const outputTemplate = path.join(
38 |       config.file.downloadsDir,
39 |       sanitizeFilename(`%(title)s [%(id)s] ${timestamp}`, config.file) + '.%(ext)s'
40 |     );
41 | 
42 |     const format = isYouTubeUrl(url)
43 |       ? "140/bestaudio[ext=m4a]/bestaudio"
44 |       : "bestaudio[ext=m4a]/bestaudio[ext=mp3]/bestaudio";
45 | 
46 |     await _spawnPromise("yt-dlp", [
47 |       "--ignore-config",
48 |       "--no-check-certificate",
49 |       "--verbose",
50 |       "--progress",
51 |       "--newline",
52 |       "--no-mtime",
53 |       "-f", format,
54 |       "--output", outputTemplate,
55 |       url
56 |     ]);
57 | 
58 |     const files = readdirSync(config.file.downloadsDir);
59 |     const downloadedFile = files.find(file => file.includes(timestamp));
60 |     if (!downloadedFile) {
61 |       throw new Error("Download completed but file not found. Check Downloads folder permissions.");
62 |     }
63 |     return `Audio successfully downloaded as "${downloadedFile}" to ${config.file.downloadsDir}`;
64 |   } catch (error) {
65 |     if (error instanceof Error) {
66 |       if (error.message.includes("Unsupported URL") || error.message.includes("extractor")) {
67 |         throw new Error(`Unsupported platform or video URL: ${url}. Ensure the URL is from a supported platform.`);
68 |       }
69 |       if (error.message.includes("Video unavailable") || error.message.includes("private")) {
70 |         throw new Error(`Video is unavailable or private: ${url}. Check the URL and video privacy settings.`);
71 |       }
72 |       if (error.message.includes("network") || error.message.includes("Connection")) {
73 |         throw new Error("Network error during audio extraction. Check your internet connection and retry.");
74 |       }
75 |     }
76 |     throw error;
77 |   }
78 | } 
79 | 
```

--------------------------------------------------------------------------------
/src/__tests__/search.test.ts:
--------------------------------------------------------------------------------

```typescript
 1 | // @ts-nocheck
 2 | // @jest-environment node
 3 | import { describe, test, expect } from '@jest/globals';
 4 | import { searchVideos } from '../modules/search.js';
 5 | import { CONFIG } from '../config.js';
 6 | 
 7 | describe('Search functionality tests', () => {
 8 |   
 9 |   describe('searchVideos', () => {
10 |     test('should successfully search for JavaScript tutorials', async () => {
11 |       const result = await searchVideos('javascript tutorial', 3, CONFIG);
12 |       
13 |       expect(result).toContain('Found 3 videos');
14 |       expect(result).toContain('Channel:');
15 |       expect(result).toContain('Duration:');
16 |       expect(result).toContain('URL:');
17 |       expect(result).toContain('ID:');
18 |       expect(result).toContain('https://www.youtube.com/watch?v=');
19 |       expect(result).toContain('You can use any URL to download videos, audio, or subtitles!');
20 |     }, 30000); // Increase timeout for real network calls
21 | 
22 |     test('should reject empty search queries', async () => {
23 |       await expect(searchVideos('', 10, CONFIG)).rejects.toThrow('Search query cannot be empty');
24 |       await expect(searchVideos('   ', 10, CONFIG)).rejects.toThrow('Search query cannot be empty');
25 |     });
26 | 
27 |     test('should validate maxResults parameter range', async () => {
28 |       await expect(searchVideos('test', 0, CONFIG)).rejects.toThrow('Number of results must be between 1 and 50');
29 |       await expect(searchVideos('test', 51, CONFIG)).rejects.toThrow('Number of results must be between 1 and 50');
30 |     });
31 | 
32 |     test('should handle search with different result counts', async () => {
33 |       const result1 = await searchVideos('python programming', 1, CONFIG);
34 |       const result5 = await searchVideos('python programming', 5, CONFIG);
35 | 
36 |       expect(result1).toContain('Found 1 video');
37 |       expect(result5).toContain('Found 5 videos');
38 |       
39 |       // Count number of video entries (each video has a numbered entry)
40 |       const count1 = (result1.match(/^\d+\./gm) || []).length;
41 |       const count5 = (result5.match(/^\d+\./gm) || []).length;
42 |       
43 |       expect(count1).toBe(1);
44 |       expect(count5).toBe(5);
45 |     }, 30000);
46 | 
47 |     test('should return properly formatted results', async () => {
48 |       const result = await searchVideos('react tutorial', 2, CONFIG);
49 |       
50 |       // Check for proper formatting
51 |       expect(result).toMatch(/Found \d+ videos?:/);
52 |       expect(result).toMatch(/\d+\. \*\*.*\*\*/); // Numbered list with bold titles
53 |       expect(result).toMatch(/📺 Channel: .+/);
54 |       expect(result).toMatch(/⏱️  Duration: .+/);
55 |       expect(result).toMatch(/🔗 URL: https:\/\/www\.youtube\.com\/watch\?v=.+/);
56 |       expect(result).toMatch(/🆔 ID: .+/);
57 |     }, 30000);
58 | 
59 |     test('should handle obscure search terms gracefully', async () => {
60 |       // Using a very specific and unlikely search term
61 |       const result = await searchVideos('asdfghjklqwertyuiopzxcvbnm12345', 1, CONFIG);
62 |       
63 |       // Even obscure terms should return some results, as YouTube's search is quite broad
64 |       // But if no results, it should be handled gracefully
65 |       expect(typeof result).toBe('string');
66 |       expect(result.length).toBeGreaterThan(0);
67 |     }, 30000);
68 |   });
69 | }); 
```

--------------------------------------------------------------------------------
/src/__tests__/subtitle.test.ts:
--------------------------------------------------------------------------------

```typescript
  1 | // @ts-nocheck
  2 | // @jest-environment node
  3 | import { describe, test, expect } from '@jest/globals';
  4 | import * as os from 'os';
  5 | import * as path from 'path';
  6 | import { listSubtitles, downloadSubtitles, downloadTranscript } from '../modules/subtitle.js';
  7 | import { cleanSubtitleToTranscript } from '../modules/utils.js';
  8 | import { CONFIG } from '../config.js';
  9 | import * as fs from 'fs';
 10 | 
 11 | describe('Subtitle Functions', () => {
 12 |   const testUrl = 'https://www.youtube.com/watch?v=jNQXAC9IVRw';
 13 |   const testConfig = {
 14 |     ...CONFIG,
 15 |     file: {
 16 |       ...CONFIG.file,
 17 |       downloadsDir: path.join(os.tmpdir(), 'yt-dlp-test-downloads'),
 18 |       tempDirPrefix: 'yt-dlp-test-'
 19 |     }
 20 |   };
 21 | 
 22 |   beforeEach(async () => {
 23 |     await fs.promises.mkdir(testConfig.file.downloadsDir, { recursive: true });
 24 |   });
 25 | 
 26 |   afterEach(async () => {
 27 |     await fs.promises.rm(testConfig.file.downloadsDir, { recursive: true, force: true });
 28 |   });
 29 | 
 30 |   describe('listSubtitles', () => {
 31 |     test('lists available subtitles', async () => {
 32 |       const result = await listSubtitles(testUrl);
 33 |       expect(result).toContain('Language');
 34 |     }, 30000);
 35 | 
 36 |     test('handles invalid URL', async () => {
 37 |       await expect(listSubtitles('invalid-url'))
 38 |         .rejects
 39 |         .toThrow();
 40 |     });
 41 |   });
 42 | 
 43 |   describe('downloadSubtitles', () => {
 44 |     test('downloads auto-generated subtitles successfully', async () => {
 45 |       const result = await downloadSubtitles(testUrl, 'en', testConfig);
 46 |       expect(result).toContain('WEBVTT');
 47 |     }, 30000);
 48 | 
 49 |     test('handles missing language', async () => {
 50 |       await expect(downloadSubtitles(testUrl, 'xx', testConfig))
 51 |         .rejects
 52 |         .toThrow();
 53 |     });
 54 |   });
 55 | 
 56 |   describe('downloadTranscript', () => {
 57 |     test('downloads and cleans transcript successfully', async () => {
 58 |       const result = await downloadTranscript(testUrl, 'en', testConfig);
 59 |       expect(typeof result).toBe('string');
 60 |       expect(result.length).toBeGreaterThan(0);
 61 |       expect(result).not.toContain('WEBVTT');
 62 |       expect(result).not.toContain('-->');
 63 |       expect(result).not.toMatch(/^\d+$/m);
 64 |     }, 30000);
 65 | 
 66 |     test('handles invalid URL', async () => {
 67 |       await expect(downloadTranscript('invalid-url', 'en', testConfig))
 68 |         .rejects
 69 |         .toThrow();
 70 |     });
 71 |   });
 72 | 
 73 |   describe('cleanSubtitleToTranscript', () => {
 74 |     test('cleans SRT content correctly', () => {
 75 |       const srtContent = `1
 76 | 00:00:01,000 --> 00:00:03,000
 77 | Hello <i>world</i>
 78 | 
 79 | 2
 80 | 00:00:04,000 --> 00:00:06,000
 81 | This is a test
 82 | 
 83 | 3
 84 | 00:00:07,000 --> 00:00:09,000
 85 | <b>Bold text</b> here`;
 86 | 
 87 |       const result = cleanSubtitleToTranscript(srtContent);
 88 |       expect(result).toBe('Hello world This is a test Bold text here');
 89 |     });
 90 | 
 91 |     test('handles empty content', () => {
 92 |       const result = cleanSubtitleToTranscript('');
 93 |       expect(result).toBe('');
 94 |     });
 95 | 
 96 |     test('removes timestamps and sequence numbers', () => {
 97 |       const srtContent = `1
 98 | 00:00:01,000 --> 00:00:03,000
 99 | First line
100 | 
101 | 2
102 | 00:00:04,000 --> 00:00:06,000
103 | Second line`;
104 | 
105 |       const result = cleanSubtitleToTranscript(srtContent);
106 |       expect(result).not.toContain('00:00');
107 |       expect(result).not.toMatch(/^\d+$/);
108 |       expect(result).toBe('First line Second line');
109 |     });
110 |   });
111 | }); 
```

--------------------------------------------------------------------------------
/docs/configuration.md:
--------------------------------------------------------------------------------

```markdown
  1 | # Configuration Guide
  2 | 
  3 | ## Overview
  4 | 
  5 | The yt-dlp-mcp package can be configured through environment variables or by passing a configuration object to the functions.
  6 | 
  7 | ## Configuration Object
  8 | 
  9 | ```typescript
 10 | interface Config {
 11 |   file: {
 12 |     maxFilenameLength: number;
 13 |     downloadsDir: string;
 14 |     tempDirPrefix: string;
 15 |     sanitize: {
 16 |       replaceChar: string;
 17 |       truncateSuffix: string;
 18 |       illegalChars: RegExp;
 19 |       reservedNames: readonly string[];
 20 |     };
 21 |   };
 22 |   tools: {
 23 |     required: readonly string[];
 24 |   };
 25 |   download: {
 26 |     defaultResolution: "480p" | "720p" | "1080p" | "best";
 27 |     defaultAudioFormat: "m4a" | "mp3";
 28 |     defaultSubtitleLanguage: string;
 29 |   };
 30 | }
 31 | ```
 32 | 
 33 | ## Environment Variables
 34 | 
 35 | | Variable | Description | Default |
 36 | |----------|-------------|---------|
 37 | | `YTDLP_MAX_FILENAME_LENGTH` | Maximum length for filenames | 50 |
 38 | | `YTDLP_DOWNLOADS_DIR` | Download directory path | `~/Downloads` |
 39 | | `YTDLP_TEMP_DIR_PREFIX` | Prefix for temporary directories | `ytdlp-` |
 40 | | `YTDLP_SANITIZE_REPLACE_CHAR` | Character to replace illegal characters | `_` |
 41 | | `YTDLP_SANITIZE_TRUNCATE_SUFFIX` | Suffix for truncated filenames | `...` |
 42 | | `YTDLP_SANITIZE_ILLEGAL_CHARS` | Regex pattern for illegal characters | `/[<>:"/\\|?*\x00-\x1F]/g` |
 43 | | `YTDLP_SANITIZE_RESERVED_NAMES` | Comma-separated list of reserved names | `CON,PRN,AUX,...` |
 44 | | `YTDLP_DEFAULT_RESOLUTION` | Default video resolution | `720p` |
 45 | | `YTDLP_DEFAULT_AUDIO_FORMAT` | Default audio format | `m4a` |
 46 | | `YTDLP_DEFAULT_SUBTITLE_LANG` | Default subtitle language | `en` |
 47 | 
 48 | ## File Configuration
 49 | 
 50 | ### Download Directory
 51 | 
 52 | The download directory can be configured in two ways:
 53 | 
 54 | 1. Environment variable:
 55 | ```bash
 56 | export YTDLP_DOWNLOADS_DIR="/path/to/downloads"
 57 | ```
 58 | 
 59 | 2. Configuration object:
 60 | ```javascript
 61 | const config = {
 62 |   file: {
 63 |     downloadsDir: "/path/to/downloads"
 64 |   }
 65 | };
 66 | ```
 67 | 
 68 | ### Filename Sanitization
 69 | 
 70 | Control how filenames are sanitized:
 71 | 
 72 | ```javascript
 73 | const config = {
 74 |   file: {
 75 |     maxFilenameLength: 100,
 76 |     sanitize: {
 77 |       replaceChar: '-',
 78 |       truncateSuffix: '___',
 79 |       illegalChars: /[<>:"/\\|?*\x00-\x1F]/g,
 80 |       reservedNames: ['CON', 'PRN', 'AUX', 'NUL']
 81 |     }
 82 |   }
 83 | };
 84 | ```
 85 | 
 86 | ## Download Configuration
 87 | 
 88 | ### Video Resolution
 89 | 
 90 | Set default video resolution:
 91 | 
 92 | ```javascript
 93 | const config = {
 94 |   download: {
 95 |     defaultResolution: "1080p" // "480p" | "720p" | "1080p" | "best"
 96 |   }
 97 | };
 98 | ```
 99 | 
100 | ### Audio Format
101 | 
102 | Configure audio format preferences:
103 | 
104 | ```javascript
105 | const config = {
106 |   download: {
107 |     defaultAudioFormat: "m4a" // "m4a" | "mp3"
108 |   }
109 | };
110 | ```
111 | 
112 | ### Subtitle Language
113 | 
114 | Set default subtitle language:
115 | 
116 | ```javascript
117 | const config = {
118 |   download: {
119 |     defaultSubtitleLanguage: "en"
120 |   }
121 | };
122 | ```
123 | 
124 | ## Tools Configuration
125 | 
126 | Configure required external tools:
127 | 
128 | ```javascript
129 | const config = {
130 |   tools: {
131 |     required: ['yt-dlp']
132 |   }
133 | };
134 | ```
135 | 
136 | ## Complete Configuration Example
137 | 
138 | ```javascript
139 | import { CONFIG } from '@kevinwatt/yt-dlp-mcp';
140 | 
141 | const customConfig = {
142 |   file: {
143 |     maxFilenameLength: 100,
144 |     downloadsDir: '/custom/downloads',
145 |     tempDirPrefix: 'ytdlp-temp-',
146 |     sanitize: {
147 |       replaceChar: '-',
148 |       truncateSuffix: '___',
149 |       illegalChars: /[<>:"/\\|?*\x00-\x1F]/g,
150 |       reservedNames: [
151 |         'CON', 'PRN', 'AUX', 'NUL',
152 |         'COM1', 'COM2', 'COM3', 'COM4', 'COM5',
153 |         'LPT1', 'LPT2', 'LPT3'
154 |       ]
155 |     }
156 |   },
157 |   tools: {
158 |     required: ['yt-dlp']
159 |   },
160 |   download: {
161 |     defaultResolution: '1080p',
162 |     defaultAudioFormat: 'm4a',
163 |     defaultSubtitleLanguage: 'en'
164 |   }
165 | };
166 | 
167 | // Use the custom configuration
168 | const result = await downloadVideo(url, customConfig);
169 | ``` 
```

--------------------------------------------------------------------------------
/docs/error-handling.md:
--------------------------------------------------------------------------------

```markdown
  1 | # Error Handling Guide
  2 | 
  3 | ## Common Errors
  4 | 
  5 | ### Invalid URL
  6 | 
  7 | When providing an invalid or unsupported URL:
  8 | 
  9 | ```javascript
 10 | try {
 11 |   await downloadVideo('invalid-url');
 12 | } catch (error) {
 13 |   if (error.message.includes('Invalid or unsupported URL')) {
 14 |     console.error('Please provide a valid YouTube or supported platform URL');
 15 |   }
 16 | }
 17 | ```
 18 | 
 19 | ### Missing Subtitles
 20 | 
 21 | When trying to download unavailable subtitles:
 22 | 
 23 | ```javascript
 24 | try {
 25 |   await downloadSubtitles(url, 'en');
 26 | } catch (error) {
 27 |   if (error.message.includes('No subtitle files found')) {
 28 |     console.warn('No subtitles available in the requested language');
 29 |   }
 30 | }
 31 | ```
 32 | 
 33 | ### yt-dlp Command Failures
 34 | 
 35 | When yt-dlp command execution fails:
 36 | 
 37 | ```javascript
 38 | try {
 39 |   await downloadVideo(url);
 40 | } catch (error) {
 41 |   if (error.message.includes('Failed with exit code')) {
 42 |     console.error('yt-dlp command failed:', error.message);
 43 |     // Check if yt-dlp is installed and up to date
 44 |   }
 45 | }
 46 | ```
 47 | 
 48 | ### File System Errors
 49 | 
 50 | When encountering file system issues:
 51 | 
 52 | ```javascript
 53 | try {
 54 |   await downloadVideo(url);
 55 | } catch (error) {
 56 |   if (error.message.includes('No write permission')) {
 57 |     console.error('Cannot write to downloads directory. Check permissions.');
 58 |   } else if (error.message.includes('Cannot create temporary directory')) {
 59 |     console.error('Cannot create temporary directory. Check system temp directory permissions.');
 60 |   }
 61 | }
 62 | ```
 63 | 
 64 | ## Comprehensive Error Handler
 65 | 
 66 | Here's a comprehensive error handler that covers most common scenarios:
 67 | 
 68 | ```javascript
 69 | async function handleDownload(url, options = {}) {
 70 |   try {
 71 |     // Attempt the download
 72 |     const result = await downloadVideo(url, options);
 73 |     return result;
 74 |   } catch (error) {
 75 |     // URL validation errors
 76 |     if (error.message.includes('Invalid or unsupported URL')) {
 77 |       throw new Error(`Invalid URL: ${url}. Please provide a valid video URL.`);
 78 |     }
 79 | 
 80 |     // File system errors
 81 |     if (error.message.includes('No write permission')) {
 82 |       throw new Error(`Permission denied: Cannot write to ${options.file?.downloadsDir || '~/Downloads'}`);
 83 |     }
 84 |     if (error.message.includes('Cannot create temporary directory')) {
 85 |       throw new Error('Cannot create temporary directory. Check system permissions.');
 86 |     }
 87 | 
 88 |     // yt-dlp related errors
 89 |     if (error.message.includes('Failed with exit code')) {
 90 |       if (error.message.includes('This video is unavailable')) {
 91 |         throw new Error('Video is unavailable or has been removed.');
 92 |       }
 93 |       if (error.message.includes('Video is private')) {
 94 |         throw new Error('This video is private and cannot be accessed.');
 95 |       }
 96 |       throw new Error('Download failed. Please check if yt-dlp is installed and up to date.');
 97 |     }
 98 | 
 99 |     // Subtitle related errors
100 |     if (error.message.includes('No subtitle files found')) {
101 |       throw new Error(`No subtitles available in ${options.language || 'the requested language'}.`);
102 |     }
103 | 
104 |     // Unknown errors
105 |     throw new Error(`Unexpected error: ${error.message}`);
106 |   }
107 | }
108 | ```
109 | 
110 | ## Error Prevention
111 | 
112 | ### URL Validation
113 | 
114 | Always validate URLs before processing:
115 | 
116 | ```javascript
117 | import { validateUrl, isYouTubeUrl } from '@kevinwatt/yt-dlp-mcp';
118 | 
119 | function validateVideoUrl(url) {
120 |   if (!validateUrl(url)) {
121 |     throw new Error('Invalid URL format');
122 |   }
123 |   
124 |   if (!isYouTubeUrl(url)) {
125 |     console.warn('URL is not from YouTube, some features might not work');
126 |   }
127 | }
128 | ```
129 | 
130 | ### Configuration Validation
131 | 
132 | Validate configuration before use:
133 | 
134 | ```javascript
135 | function validateConfig(config) {
136 |   if (!config.file.downloadsDir) {
137 |     throw new Error('Downloads directory must be specified');
138 |   }
139 | 
140 |   if (config.file.maxFilenameLength < 5) {
141 |     throw new Error('Filename length must be at least 5 characters');
142 |   }
143 | 
144 |   if (!['480p', '720p', '1080p', 'best'].includes(config.download.defaultResolution)) {
145 |     throw new Error('Invalid resolution specified');
146 |   }
147 | }
148 | ```
149 | 
150 | ### Safe Cleanup
151 | 
152 | Always use safe cleanup for temporary files:
153 | 
154 | ```javascript
155 | import { safeCleanup } from '@kevinwatt/yt-dlp-mcp';
156 | 
157 | try {
158 |   // Your download code here
159 | } catch (error) {
160 |   console.error('Download failed:', error);
161 | } finally {
162 |   await safeCleanup(tempDir);
163 | }
164 | ```
165 | 
166 | ## Best Practices
167 | 
168 | 1. Always wrap async operations in try-catch blocks
169 | 2. Validate inputs before processing
170 | 3. Use specific error types for different scenarios
171 | 4. Clean up temporary files in finally blocks
172 | 5. Log errors appropriately for debugging
173 | 6. Provide meaningful error messages to users
174 | 
175 | For more information about specific errors and their solutions, see the [API Reference](./api.md). 
```

--------------------------------------------------------------------------------
/src/modules/utils.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import * as fs from 'fs';
  2 | import { spawn } from 'child_process';
  3 | import { randomBytes } from 'crypto';
  4 | 
  5 | /**
  6 |  * Validates if a given string is a valid URL.
  7 |  * 
  8 |  * @param url - The URL string to validate
  9 |  * @returns True if the URL is valid, false otherwise
 10 |  * 
 11 |  * @example
 12 |  * ```typescript
 13 |  * if (validateUrl('https://youtube.com/watch?v=...')) {
 14 |  *   // URL is valid
 15 |  * }
 16 |  * ```
 17 |  */
 18 | export function validateUrl(url: string): boolean {
 19 |   try {
 20 |     new URL(url);
 21 |     return true;
 22 |   } catch {
 23 |     return false;
 24 |   }
 25 | }
 26 | 
 27 | /**
 28 |  * Checks if a URL is from YouTube.
 29 |  * 
 30 |  * @param url - The URL to check
 31 |  * @returns True if the URL is from YouTube, false otherwise
 32 |  * 
 33 |  * @example
 34 |  * ```typescript
 35 |  * if (isYouTubeUrl('https://youtube.com/watch?v=...')) {
 36 |  *   // URL is from YouTube
 37 |  * }
 38 |  * ```
 39 |  */
 40 | export function isYouTubeUrl(url: string): boolean {
 41 |   try {
 42 |     const parsedUrl = new URL(url);
 43 |     return parsedUrl.hostname.includes('youtube.com') || parsedUrl.hostname.includes('youtu.be');
 44 |   } catch {
 45 |     return false;
 46 |   }
 47 | }
 48 | 
 49 | /**
 50 |  * Safely cleans up a directory and its contents.
 51 |  * 
 52 |  * @param directory - Path to the directory to clean up
 53 |  * @returns Promise that resolves when cleanup is complete
 54 |  * @throws {Error} When directory cannot be removed
 55 |  * 
 56 |  * @example
 57 |  * ```typescript
 58 |  * try {
 59 |  *   await safeCleanup('/path/to/temp/dir');
 60 |  * } catch (error) {
 61 |  *   console.error('Cleanup failed:', error);
 62 |  * }
 63 |  * ```
 64 |  */
 65 | export async function safeCleanup(directory: string): Promise<void> {
 66 |   try {
 67 |     await fs.promises.rm(directory, { recursive: true, force: true });
 68 |   } catch (error) {
 69 |     console.error(`Error cleaning up directory ${directory}:`, error);
 70 |   }
 71 | }
 72 | 
 73 | /**
 74 |  * Spawns a child process and returns its output as a promise.
 75 |  * 
 76 |  * @param command - The command to execute
 77 |  * @param args - Array of command arguments
 78 |  * @returns Promise resolving to the command output
 79 |  * @throws {Error} When command execution fails
 80 |  * 
 81 |  * @example
 82 |  * ```typescript
 83 |  * try {
 84 |  *   const output = await _spawnPromise('yt-dlp', ['--version']);
 85 |  *   console.log('yt-dlp version:', output);
 86 |  * } catch (error) {
 87 |  *   console.error('Command failed:', error);
 88 |  * }
 89 |  * ```
 90 |  */
 91 | export function _spawnPromise(command: string, args: string[]): Promise<string> {
 92 |   return new Promise((resolve, reject) => {
 93 |     const process = spawn(command, args);
 94 |     let output = '';
 95 | 
 96 |     process.stdout.on('data', (data) => {
 97 |       output += data.toString();
 98 |     });
 99 | 
100 |     process.stderr.on('data', (data) => {
101 |       output += data.toString();
102 |     });
103 | 
104 |     process.on('close', (code) => {
105 |       if (code === 0) {
106 |         resolve(output);
107 |       } else {
108 |         reject(new Error(`Failed with exit code: ${code}\n${output}`));
109 |       }
110 |     });
111 |   });
112 | }
113 | 
114 | /**
115 |  * Generates a formatted timestamp string for file naming.
116 |  * 
117 |  * @returns Formatted timestamp string in the format 'YYYY-MM-DD_HH-mm-ss'
118 |  * 
119 |  * @example
120 |  * ```typescript
121 |  * const timestamp = getFormattedTimestamp();
122 |  * console.log(timestamp); // '2024-03-20_12-30-00'
123 |  * ```
124 |  */
125 | export function getFormattedTimestamp(): string {
126 |   return new Date().toISOString()
127 |     .replace(/[:.]/g, '-')
128 |     .replace('T', '_')
129 |     .split('.')[0];
130 | }
131 | 
132 | /**
133 |  * Generates a random filename with timestamp prefix.
134 |  * 
135 |  * @param extension - Optional file extension (default: 'mp4')
136 |  * @returns A random filename with timestamp
137 |  * 
138 |  * @example
139 |  * ```typescript
140 |  * const filename = generateRandomFilename('mp3');
141 |  * console.log(filename); // '2024-03-20_12-30-00_a1b2c3d4.mp3'
142 |  * ```
143 |  */
144 | export function generateRandomFilename(extension: string = 'mp4'): string {
145 |   const timestamp = getFormattedTimestamp();
146 |   const randomId = randomBytes(4).toString('hex');
147 |   return `${timestamp}_${randomId}.${extension}`;
148 | }
149 | 
150 | /**
151 |  * Cleans SRT subtitle content to produce a plain text transcript.
152 |  * Removes timestamps, sequence numbers, and HTML tags.
153 |  * 
154 |  * @param srtContent - Raw SRT subtitle content
155 |  * @returns Cleaned transcript text
156 |  * 
157 |  * @example
158 |  * ```typescript
159 |  * const cleanedText = cleanSubtitleToTranscript(srtContent);
160 |  * console.log(cleanedText); // 'Hello world this is a transcript...'
161 |  * ```
162 |  */
163 | export function cleanSubtitleToTranscript(srtContent: string): string {
164 |   return srtContent
165 |     .split('\n')
166 |     .filter(line => {
167 |       const trimmed = line.trim();
168 |       // Remove empty lines
169 |       if (!trimmed) return false;
170 |       // Remove sequence numbers (lines that are just digits)
171 |       if (/^\d+$/.test(trimmed)) return false;
172 |       // Remove timestamp lines
173 |       if (/^\d{2}:\d{2}:\d{2}[.,]\d{3}\s*-->\s*\d{2}:\d{2}:\d{2}[.,]\d{3}$/.test(trimmed)) return false;
174 |       return true;
175 |     })
176 |     .map(line => {
177 |       // Remove HTML tags
178 |       return line.replace(/<[^>]*>/g, '');
179 |     })
180 |     .join(' ')
181 |     .replace(/\s+/g, ' ')
182 |     .trim();
183 | } 
```

--------------------------------------------------------------------------------
/.claude/skills/mcp-builder/scripts/connections.py:
--------------------------------------------------------------------------------

```python
  1 | """Lightweight connection handling for MCP servers."""
  2 | 
  3 | from abc import ABC, abstractmethod
  4 | from contextlib import AsyncExitStack
  5 | from typing import Any
  6 | 
  7 | from mcp import ClientSession, StdioServerParameters
  8 | from mcp.client.sse import sse_client
  9 | from mcp.client.stdio import stdio_client
 10 | from mcp.client.streamable_http import streamablehttp_client
 11 | 
 12 | 
 13 | class MCPConnection(ABC):
 14 |     """Base class for MCP server connections."""
 15 | 
 16 |     def __init__(self):
 17 |         self.session = None
 18 |         self._stack = None
 19 | 
 20 |     @abstractmethod
 21 |     def _create_context(self):
 22 |         """Create the connection context based on connection type."""
 23 | 
 24 |     async def __aenter__(self):
 25 |         """Initialize MCP server connection."""
 26 |         self._stack = AsyncExitStack()
 27 |         await self._stack.__aenter__()
 28 | 
 29 |         try:
 30 |             ctx = self._create_context()
 31 |             result = await self._stack.enter_async_context(ctx)
 32 | 
 33 |             if len(result) == 2:
 34 |                 read, write = result
 35 |             elif len(result) == 3:
 36 |                 read, write, _ = result
 37 |             else:
 38 |                 raise ValueError(f"Unexpected context result: {result}")
 39 | 
 40 |             session_ctx = ClientSession(read, write)
 41 |             self.session = await self._stack.enter_async_context(session_ctx)
 42 |             await self.session.initialize()
 43 |             return self
 44 |         except BaseException:
 45 |             await self._stack.__aexit__(None, None, None)
 46 |             raise
 47 | 
 48 |     async def __aexit__(self, exc_type, exc_val, exc_tb):
 49 |         """Clean up MCP server connection resources."""
 50 |         if self._stack:
 51 |             await self._stack.__aexit__(exc_type, exc_val, exc_tb)
 52 |         self.session = None
 53 |         self._stack = None
 54 | 
 55 |     async def list_tools(self) -> list[dict[str, Any]]:
 56 |         """Retrieve available tools from the MCP server."""
 57 |         response = await self.session.list_tools()
 58 |         return [
 59 |             {
 60 |                 "name": tool.name,
 61 |                 "description": tool.description,
 62 |                 "input_schema": tool.inputSchema,
 63 |             }
 64 |             for tool in response.tools
 65 |         ]
 66 | 
 67 |     async def call_tool(self, tool_name: str, arguments: dict[str, Any]) -> Any:
 68 |         """Call a tool on the MCP server with provided arguments."""
 69 |         result = await self.session.call_tool(tool_name, arguments=arguments)
 70 |         return result.content
 71 | 
 72 | 
 73 | class MCPConnectionStdio(MCPConnection):
 74 |     """MCP connection using standard input/output."""
 75 | 
 76 |     def __init__(self, command: str, args: list[str] = None, env: dict[str, str] = None):
 77 |         super().__init__()
 78 |         self.command = command
 79 |         self.args = args or []
 80 |         self.env = env
 81 | 
 82 |     def _create_context(self):
 83 |         return stdio_client(
 84 |             StdioServerParameters(command=self.command, args=self.args, env=self.env)
 85 |         )
 86 | 
 87 | 
 88 | class MCPConnectionSSE(MCPConnection):
 89 |     """MCP connection using Server-Sent Events."""
 90 | 
 91 |     def __init__(self, url: str, headers: dict[str, str] = None):
 92 |         super().__init__()
 93 |         self.url = url
 94 |         self.headers = headers or {}
 95 | 
 96 |     def _create_context(self):
 97 |         return sse_client(url=self.url, headers=self.headers)
 98 | 
 99 | 
100 | class MCPConnectionHTTP(MCPConnection):
101 |     """MCP connection using Streamable HTTP."""
102 | 
103 |     def __init__(self, url: str, headers: dict[str, str] = None):
104 |         super().__init__()
105 |         self.url = url
106 |         self.headers = headers or {}
107 | 
108 |     def _create_context(self):
109 |         return streamablehttp_client(url=self.url, headers=self.headers)
110 | 
111 | 
112 | def create_connection(
113 |     transport: str,
114 |     command: str = None,
115 |     args: list[str] = None,
116 |     env: dict[str, str] = None,
117 |     url: str = None,
118 |     headers: dict[str, str] = None,
119 | ) -> MCPConnection:
120 |     """Factory function to create the appropriate MCP connection.
121 | 
122 |     Args:
123 |         transport: Connection type ("stdio", "sse", or "http")
124 |         command: Command to run (stdio only)
125 |         args: Command arguments (stdio only)
126 |         env: Environment variables (stdio only)
127 |         url: Server URL (sse and http only)
128 |         headers: HTTP headers (sse and http only)
129 | 
130 |     Returns:
131 |         MCPConnection instance
132 |     """
133 |     transport = transport.lower()
134 | 
135 |     if transport == "stdio":
136 |         if not command:
137 |             raise ValueError("Command is required for stdio transport")
138 |         return MCPConnectionStdio(command=command, args=args, env=env)
139 | 
140 |     elif transport == "sse":
141 |         if not url:
142 |             raise ValueError("URL is required for sse transport")
143 |         return MCPConnectionSSE(url=url, headers=headers)
144 | 
145 |     elif transport in ["http", "streamable_http", "streamable-http"]:
146 |         if not url:
147 |             raise ValueError("URL is required for http transport")
148 |         return MCPConnectionHTTP(url=url, headers=headers)
149 | 
150 |     else:
151 |         raise ValueError(f"Unsupported transport type: {transport}. Use 'stdio', 'sse', or 'http'")
152 | 
```

--------------------------------------------------------------------------------
/docs/api.md:
--------------------------------------------------------------------------------

```markdown
  1 | # API Reference
  2 | 
  3 | ## Video Operations
  4 | 
  5 | ### downloadVideo(url: string, config?: Config, resolution?: string, startTime?: string, endTime?: string): Promise<string>
  6 | 
  7 | Downloads a video from the specified URL with optional trimming.
  8 | 
  9 | **Parameters:**
 10 | - `url`: The URL of the video to download
 11 | - `config`: (Optional) Configuration object
 12 | - `resolution`: (Optional) Preferred video resolution ('480p', '720p', '1080p', 'best')
 13 | - `startTime`: (Optional) Start time for trimming (format: HH:MM:SS[.ms])
 14 | - `endTime`: (Optional) End time for trimming (format: HH:MM:SS[.ms])
 15 | 
 16 | **Returns:**
 17 | - Promise resolving to a success message with the downloaded file path
 18 | 
 19 | **Example:**
 20 | ```javascript
 21 | import { downloadVideo } from '@kevinwatt/yt-dlp-mcp';
 22 | 
 23 | // Download with default settings
 24 | const result = await downloadVideo('https://www.youtube.com/watch?v=jNQXAC9IVRw');
 25 | console.log(result);
 26 | 
 27 | // Download with specific resolution
 28 | const hdResult = await downloadVideo(
 29 |   'https://www.youtube.com/watch?v=jNQXAC9IVRw',
 30 |   undefined,
 31 |   '1080p'
 32 | );
 33 | console.log(hdResult);
 34 | 
 35 | // Download with trimming
 36 | const trimmedResult = await downloadVideo(
 37 |   'https://www.youtube.com/watch?v=jNQXAC9IVRw',
 38 |   undefined,
 39 |   '720p',
 40 |   '00:01:30',
 41 |   '00:02:45'
 42 | );
 43 | console.log(trimmedResult);
 44 | 
 45 | // Download with fractional seconds
 46 | const preciseTrim = await downloadVideo(
 47 |   'https://www.youtube.com/watch?v=jNQXAC9IVRw',
 48 |   undefined,
 49 |   '720p',
 50 |   '00:01:30.500',
 51 |   '00:02:45.250'
 52 | );
 53 | console.log(preciseTrim);
 54 | ```
 55 | 
 56 | ## Audio Operations
 57 | 
 58 | ### downloadAudio(url: string, config?: Config): Promise<string>
 59 | 
 60 | Downloads audio from the specified URL in the best available quality.
 61 | 
 62 | **Parameters:**
 63 | - `url`: The URL of the video to extract audio from
 64 | - `config`: (Optional) Configuration object
 65 | 
 66 | **Returns:**
 67 | - Promise resolving to a success message with the downloaded file path
 68 | 
 69 | **Example:**
 70 | ```javascript
 71 | import { downloadAudio } from '@kevinwatt/yt-dlp-mcp';
 72 | 
 73 | const result = await downloadAudio('https://www.youtube.com/watch?v=jNQXAC9IVRw');
 74 | console.log(result);
 75 | ```
 76 | 
 77 | ## Subtitle Operations
 78 | 
 79 | ### listSubtitles(url: string): Promise<string>
 80 | 
 81 | Lists all available subtitles for a video.
 82 | 
 83 | **Parameters:**
 84 | - `url`: The URL of the video
 85 | 
 86 | **Returns:**
 87 | - Promise resolving to a string containing the list of available subtitles
 88 | 
 89 | **Example:**
 90 | ```javascript
 91 | import { listSubtitles } from '@kevinwatt/yt-dlp-mcp';
 92 | 
 93 | const subtitles = await listSubtitles('https://www.youtube.com/watch?v=jNQXAC9IVRw');
 94 | console.log(subtitles);
 95 | ```
 96 | 
 97 | ### downloadSubtitles(url: string, language: string): Promise<string>
 98 | 
 99 | Downloads subtitles for a video in the specified language.
100 | 
101 | **Parameters:**
102 | - `url`: The URL of the video
103 | - `language`: Language code (e.g., 'en', 'zh-Hant', 'ja')
104 | 
105 | **Returns:**
106 | - Promise resolving to the subtitle content
107 | 
108 | **Example:**
109 | ```javascript
110 | import { downloadSubtitles } from '@kevinwatt/yt-dlp-mcp';
111 | 
112 | const subtitles = await downloadSubtitles(
113 |   'https://www.youtube.com/watch?v=jNQXAC9IVRw',
114 |   'en'
115 | );
116 | console.log(subtitles);
117 | ```
118 | 
119 | ## Metadata Operations
120 | 
121 | ### getVideoMetadata(url: string, fields?: string[]): Promise<string>
122 | 
123 | Extract comprehensive video metadata using yt-dlp without downloading the content.
124 | 
125 | **Parameters:**
126 | - `url`: The URL of the video to extract metadata from
127 | - `fields`: (Optional) Specific metadata fields to extract (e.g., `['id', 'title', 'description', 'channel']`). If omitted, returns all available metadata. If provided as an empty array `[]`, returns `{}`.
128 | 
129 | **Returns:**
130 | - Promise resolving to a JSON string of metadata (pretty-printed)
131 | 
132 | **Example:**
133 | ```javascript
134 | import { getVideoMetadata } from '@kevinwatt/yt-dlp-mcp';
135 | 
136 | // Get all metadata
137 | const all = await getVideoMetadata('https://www.youtube.com/watch?v=jNQXAC9IVRw');
138 | console.log(all);
139 | 
140 | // Get specific fields only
141 | const subset = await getVideoMetadata(
142 |   'https://www.youtube.com/watch?v=jNQXAC9IVRw',
143 |   ['id', 'title', 'description', 'channel']
144 | );
145 | console.log(subset);
146 | ```
147 | 
148 | ### getVideoMetadataSummary(url: string): Promise<string>
149 | 
150 | Get a human-readable summary of key video metadata fields.
151 | 
152 | **Parameters:**
153 | - `url`: The URL of the video
154 | 
155 | **Returns:**
156 | - Promise resolving to a formatted text summary (title, channel, duration, views, upload date, description preview, etc.)
157 | 
158 | **Example:**
159 | ```javascript
160 | import { getVideoMetadataSummary } from '@kevinwatt/yt-dlp-mcp';
161 | 
162 | const summary = await getVideoMetadataSummary('https://www.youtube.com/watch?v=jNQXAC9IVRw');
163 | console.log(summary);
164 | ```
165 | 
166 | ## Configuration
167 | 
168 | ### Config Interface
169 | 
170 | ```typescript
171 | interface Config {
172 |   file: {
173 |     maxFilenameLength: number;
174 |     downloadsDir: string;
175 |     tempDirPrefix: string;
176 |     sanitize: {
177 |       replaceChar: string;
178 |       truncateSuffix: string;
179 |       illegalChars: RegExp;
180 |       reservedNames: readonly string[];
181 |     };
182 |   };
183 |   tools: {
184 |     required: readonly string[];
185 |   };
186 |   download: {
187 |     defaultResolution: "480p" | "720p" | "1080p" | "best";
188 |     defaultAudioFormat: "m4a" | "mp3";
189 |     defaultSubtitleLanguage: string;
190 |   };
191 | }
192 | ```
193 | 
194 | For detailed configuration options, see [Configuration Guide](./configuration.md). 
```

--------------------------------------------------------------------------------
/src/modules/video.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import * as path from "path";
  2 | import type { Config } from "../config.js";
  3 | import { sanitizeFilename } from "../config.js";
  4 | import {
  5 |   _spawnPromise,
  6 |   validateUrl,
  7 |   getFormattedTimestamp,
  8 |   isYouTubeUrl,
  9 |   generateRandomFilename
 10 | } from "./utils.js";
 11 | 
 12 | /**
 13 |  * Downloads a video from the specified URL.
 14 |  *
 15 |  * @param url - The URL of the video to download
 16 |  * @param config - Configuration object for download settings
 17 |  * @param resolution - Preferred video resolution ('480p', '720p', '1080p', 'best')
 18 |  * @param startTime - Optional start time for trimming (format: HH:MM:SS[.ms])
 19 |  * @param endTime - Optional end time for trimming (format: HH:MM:SS[.ms])
 20 |  * @returns Promise resolving to a success message with the downloaded file path
 21 |  * @throws {Error} When URL is invalid or download fails
 22 |  *
 23 |  * @example
 24 |  * ```typescript
 25 |  * // Download with default settings
 26 |  * const result = await downloadVideo('https://youtube.com/watch?v=...');
 27 |  * console.log(result);
 28 |  *
 29 |  * // Download with specific resolution
 30 |  * const hdResult = await downloadVideo(
 31 |  *   'https://youtube.com/watch?v=...',
 32 |  *   undefined,
 33 |  *   '1080p'
 34 |  * );
 35 |  * console.log(hdResult);
 36 |  *
 37 |  * // Download with trimming
 38 |  * const trimmedResult = await downloadVideo(
 39 |  *   'https://youtube.com/watch?v=...',
 40 |  *   undefined,
 41 |  *   '720p',
 42 |  *   '00:01:30',
 43 |  *   '00:02:45'
 44 |  * );
 45 |  * console.log(trimmedResult);
 46 |  * ```
 47 |  */
 48 | export async function downloadVideo(
 49 |   url: string,
 50 |   config: Config,
 51 |   resolution: "480p" | "720p" | "1080p" | "best" = "720p",
 52 |   startTime?: string,
 53 |   endTime?: string
 54 | ): Promise<string> {
 55 |   const userDownloadsDir = config.file.downloadsDir;
 56 | 
 57 |   try {
 58 |     validateUrl(url);
 59 |     const timestamp = getFormattedTimestamp();
 60 | 
 61 |     let format: string;
 62 |     if (isYouTubeUrl(url)) {
 63 |       // YouTube-specific format selection
 64 |       switch (resolution) {
 65 |         case "480p":
 66 |           format = "bestvideo[height<=480]+bestaudio/best[height<=480]/best";
 67 |           break;
 68 |         case "720p":
 69 |           format = "bestvideo[height<=720]+bestaudio/best[height<=720]/best";
 70 |           break;
 71 |         case "1080p":
 72 |           format = "bestvideo[height<=1080]+bestaudio/best[height<=1080]/best";
 73 |           break;
 74 |         case "best":
 75 |           format = "bestvideo+bestaudio/best";
 76 |           break;
 77 |         default:
 78 |           format = "bestvideo[height<=720]+bestaudio/best[height<=720]/best";
 79 |       }
 80 |     } else {
 81 |       // For other platforms, use quality labels that are more generic
 82 |       switch (resolution) {
 83 |         case "480p":
 84 |           format = "worst[height>=480]/best[height<=480]/worst";
 85 |           break;
 86 |         case "best":
 87 |           format = "bestvideo+bestaudio/best";
 88 |           break;
 89 |         default: // Including 720p and 1080p cases
 90 |           // Prefer HD quality but fallback to best available
 91 |           format = "bestvideo[height>=720]+bestaudio/best[height>=720]/best";
 92 |       }
 93 |     }
 94 | 
 95 |     let outputTemplate: string;
 96 |     let expectedFilename: string;
 97 | 
 98 |     try {
 99 |       // 嘗試獲取檔案名稱
100 |       outputTemplate = path.join(
101 |         userDownloadsDir,
102 |         sanitizeFilename(`%(title)s [%(id)s] ${timestamp}`, config.file) + '.%(ext)s'
103 |       );
104 | 
105 |       expectedFilename = await _spawnPromise("yt-dlp", [
106 |         "--ignore-config",
107 |         "--get-filename",
108 |         "-f", format,
109 |         "--output", outputTemplate,
110 |         url
111 |       ]);
112 |       expectedFilename = expectedFilename.trim();
113 |     } catch (error) {
114 |       // 如果無法獲取檔案名稱,使用隨機檔案名
115 |       const randomFilename = generateRandomFilename('mp4');
116 |       outputTemplate = path.join(userDownloadsDir, randomFilename);
117 |       expectedFilename = randomFilename;
118 |     }
119 | 
120 |     // Build download arguments
121 |     const downloadArgs = [
122 |       "--ignore-config",
123 |       "--progress",
124 |       "--newline",
125 |       "--no-mtime",
126 |       "-f", format,
127 |       "--output", outputTemplate
128 |     ];
129 | 
130 |     // Add trimming parameters if provided
131 |     if (startTime || endTime) {
132 |       let downloadSection = "*";
133 |       
134 |       if (startTime && endTime) {
135 |         downloadSection = `*${startTime}-${endTime}`;
136 |       } else if (startTime) {
137 |         downloadSection = `*${startTime}-`;
138 |       } else if (endTime) {
139 |         downloadSection = `*-${endTime}`;
140 |       }
141 | 
142 |       downloadArgs.push("--download-sections", downloadSection, "--force-keyframes-at-cuts");
143 |     }
144 | 
145 |     downloadArgs.push(url);
146 | 
147 |     // Download with progress info
148 |     try {
149 |       await _spawnPromise("yt-dlp", downloadArgs);
150 |     } catch (error) {
151 |       if (error instanceof Error) {
152 |         if (error.message.includes("Unsupported URL") || error.message.includes("extractor")) {
153 |           throw new Error(`Unsupported platform or video URL: ${url}. Ensure the URL is from a supported platform.`);
154 |         }
155 |         if (error.message.includes("Video unavailable") || error.message.includes("private")) {
156 |           throw new Error(`Video is unavailable or private: ${url}. Check the URL and video privacy settings.`);
157 |         }
158 |         if (error.message.includes("network") || error.message.includes("Connection")) {
159 |           throw new Error("Network error during download. Check your internet connection and retry.");
160 |         }
161 |         throw new Error(`Download failed: ${error.message}. Check URL and try again.`);
162 |       }
163 |       throw new Error(`Download failed: ${String(error)}`);
164 |     }
165 | 
166 |     return `Video successfully downloaded as "${path.basename(expectedFilename)}" to ${userDownloadsDir}`;
167 |   } catch (error) {
168 |     throw error;
169 |   }
170 | } 
171 | 
```

--------------------------------------------------------------------------------
/src/modules/search.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { _spawnPromise } from "./utils.js";
  2 | import type { Config } from "../config.js";
  3 | 
  4 | /**
  5 |  * YouTube search result interface
  6 |  */
  7 | export interface SearchResult {
  8 |   title: string;
  9 |   id: string;
 10 |   url: string;
 11 |   uploader?: string;
 12 |   duration?: string;
 13 |   viewCount?: string;
 14 |   uploadDate?: string;
 15 | }
 16 | 
 17 | /**
 18 |  * Search YouTube videos
 19 |  * @param query Search keywords
 20 |  * @param maxResults Maximum number of results (1-50)
 21 |  * @param offset Number of results to skip for pagination
 22 |  * @param responseFormat Output format ('json' or 'markdown')
 23 |  * @param config Configuration object
 24 |  * @returns Search results formatted as string
 25 |  */
 26 | export async function searchVideos(
 27 |   query: string,
 28 |   maxResults: number = 10,
 29 |   offset: number = 0,
 30 |   responseFormat: "json" | "markdown" = "markdown",
 31 |   config: Config
 32 | ): Promise<string> {
 33 |   // Validate parameters
 34 |   if (!query || query.trim().length === 0) {
 35 |     throw new Error("Search query cannot be empty");
 36 |   }
 37 | 
 38 |   if (maxResults < 1 || maxResults > 50) {
 39 |     throw new Error("Number of results must be between 1 and 50");
 40 |   }
 41 | 
 42 |   if (offset < 0) {
 43 |     throw new Error("Offset cannot be negative");
 44 |   }
 45 | 
 46 |   const cleanQuery = query.trim();
 47 |   // Request more results to support offset
 48 |   const totalToFetch = maxResults + offset;
 49 |   const searchQuery = `ytsearch${totalToFetch}:${cleanQuery}`;
 50 | 
 51 |   try {
 52 |     // Use yt-dlp to search and get video information
 53 |     const args = [
 54 |       searchQuery,
 55 |       "--print", "title",
 56 |       "--print", "id", 
 57 |       "--print", "uploader",
 58 |       "--print", "duration",
 59 |       "--no-download",
 60 |       "--quiet"
 61 |     ];
 62 | 
 63 |     const result = await _spawnPromise(config.tools.required[0], args);
 64 |     
 65 |     if (!result || result.trim().length === 0) {
 66 |       return "No videos found";
 67 |     }
 68 | 
 69 |     // Parse results
 70 |     const lines = result.trim().split('\n');
 71 |     const allResults: SearchResult[] = [];
 72 | 
 73 |     // Each video has 4 lines of data: title, id, uploader, duration
 74 |     for (let i = 0; i < lines.length; i += 4) {
 75 |       if (i + 3 < lines.length) {
 76 |         const title = lines[i]?.trim();
 77 |         const id = lines[i + 1]?.trim();
 78 |         const uploader = lines[i + 2]?.trim();
 79 |         const duration = lines[i + 3]?.trim();
 80 | 
 81 |         if (title && id) {
 82 |           const url = `https://www.youtube.com/watch?v=${id}`;
 83 |           allResults.push({
 84 |             title,
 85 |             id,
 86 |             url,
 87 |             uploader: uploader || "Unknown",
 88 |             duration: duration || "Unknown"
 89 |           });
 90 |         }
 91 |       }
 92 |     }
 93 | 
 94 |     // Apply offset and limit
 95 |     const paginatedResults = allResults.slice(offset, offset + maxResults);
 96 |     const hasMore = allResults.length > offset + maxResults;
 97 | 
 98 |     if (paginatedResults.length === 0) {
 99 |       return "No videos found";
100 |     }
101 | 
102 |     // Format output based on response format
103 |     if (responseFormat === "json") {
104 |       const response = {
105 |         total: allResults.length,
106 |         count: paginatedResults.length,
107 |         offset: offset,
108 |         videos: paginatedResults,
109 |         has_more: hasMore,
110 |         ...(hasMore && { next_offset: offset + maxResults })
111 |       };
112 | 
113 |       let output = JSON.stringify(response, null, 2);
114 | 
115 |       // Check character limit
116 |       if (output.length > config.limits.characterLimit) {
117 |         // Truncate videos array
118 |         const truncatedCount = Math.ceil(paginatedResults.length / 2);
119 |         const truncatedResponse = {
120 |           ...response,
121 |           count: truncatedCount,
122 |           videos: paginatedResults.slice(0, truncatedCount),
123 |           truncated: true,
124 |           truncation_message: `Response truncated from ${paginatedResults.length} to ${truncatedCount} results. Use offset parameter or reduce maxResults to see more.`
125 |         };
126 |         output = JSON.stringify(truncatedResponse, null, 2);
127 |       }
128 | 
129 |       return output;
130 |     } else {
131 |       // Markdown format
132 |       let output = `Found ${allResults.length} video${allResults.length > 1 ? 's' : ''} (showing ${paginatedResults.length}):\n\n`;
133 | 
134 |       paginatedResults.forEach((video, index) => {
135 |         output += `${offset + index + 1}. **${video.title}**\n`;
136 |         output += `   📺 Channel: ${video.uploader}\n`;
137 |         output += `   ⏱️  Duration: ${video.duration}\n`;
138 |         output += `   🔗 URL: ${video.url}\n`;
139 |         output += `   🆔 ID: ${video.id}\n\n`;
140 |       });
141 | 
142 |       // Add pagination info
143 |       if (offset > 0 || hasMore) {
144 |         output += `\n📊 Pagination: Showing results ${offset + 1}-${offset + paginatedResults.length} of ${allResults.length}`;
145 |         if (hasMore) {
146 |           output += ` (${allResults.length - offset - paginatedResults.length} more available)`;
147 |         }
148 |         output += '\n';
149 |       }
150 | 
151 |       output += "\n💡 You can use any URL to download videos, audio, or subtitles!";
152 | 
153 |       // Check character limit
154 |       if (output.length > config.limits.characterLimit) {
155 |         output = output.substring(0, config.limits.characterLimit);
156 |         output += "\n\n⚠️ Response truncated. Use offset parameter or reduce maxResults to see more results.";
157 |       }
158 | 
159 |       return output;
160 |     }
161 | 
162 |   } catch (error) {
163 |     if (error instanceof Error) {
164 |       // Provide more actionable error messages
165 |       if (error.message.includes("network") || error.message.includes("Network")) {
166 |         throw new Error("Network error while searching. Check your internet connection and retry.");
167 |       }
168 |       if (error.message.includes("429") || error.message.includes("rate limit")) {
169 |         throw new Error("YouTube rate limit exceeded. Wait 60 seconds before searching again.");
170 |       }
171 |       throw new Error(`Search failed: ${error.message}. Try a different query or reduce maxResults.`);
172 |     }
173 |     throw new Error(`Error searching videos: ${String(error)}`);
174 |   }
175 | }
176 | 
177 | /**
178 |  * Search videos on specific platform (future expansion feature)
179 |  * @param query Search keywords
180 |  * @param platform Platform name ('youtube', 'bilibili', etc.)
181 |  * @param maxResults Maximum number of results
182 |  * @param offset Number of results to skip
183 |  * @param responseFormat Output format
184 |  * @param config Configuration object
185 |  */
186 | export async function searchByPlatform(
187 |   query: string,
188 |   platform: string = 'youtube',
189 |   maxResults: number = 10,
190 |   offset: number = 0,
191 |   responseFormat: "json" | "markdown" = "markdown",
192 |   config: Config
193 | ): Promise<string> {
194 |   // Currently only supports YouTube, can be expanded to other platforms in the future
195 |   if (platform.toLowerCase() !== 'youtube') {
196 |     throw new Error(`Currently only supports YouTube search, ${platform} is not supported`);
197 |   }
198 | 
199 |   return searchVideos(query, maxResults, offset, responseFormat, config);
200 | } 
```

--------------------------------------------------------------------------------
/test-real-video.mjs:
--------------------------------------------------------------------------------

```
  1 | #!/usr/bin/env node
  2 | /**
  3 |  * Real-world MCP server test with actual YouTube video
  4 |  * Tests multiple tools with https://www.youtube.com/watch?v=dQw4w9WgXcQ
  5 |  */
  6 | 
  7 | import { spawn } from 'child_process';
  8 | import { fileURLToPath } from 'url';
  9 | import { dirname, join } from 'path';
 10 | 
 11 | const __filename = fileURLToPath(import.meta.url);
 12 | const __dirname = dirname(__filename);
 13 | 
 14 | const serverPath = join(__dirname, 'lib', 'index.mjs');
 15 | const TEST_VIDEO = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ';
 16 | 
 17 | console.log('🎬 Testing yt-dlp MCP Server with Real Video\n');
 18 | console.log('Video:', TEST_VIDEO);
 19 | console.log('Starting server from:', serverPath, '\n');
 20 | 
 21 | const server = spawn('node', [serverPath]);
 22 | 
 23 | let testsPassed = 0;
 24 | let testsFailed = 0;
 25 | let responseBuffer = '';
 26 | let requestId = 0;
 27 | let currentTest = '';
 28 | 
 29 | // Timeout to ensure tests complete
 30 | const timeout = setTimeout(() => {
 31 |   console.log('\n⏱️  Test timeout - killing server');
 32 |   server.kill();
 33 |   printResults();
 34 | }, 60000); // 60 seconds for real API calls
 35 | 
 36 | function printResults() {
 37 |   clearTimeout(timeout);
 38 |   console.log(`\n${'='.repeat(60)}`);
 39 |   console.log(`📊 Final Test Results:`);
 40 |   console.log(`   ✅ Passed: ${testsPassed}`);
 41 |   console.log(`   ❌ Failed: ${testsFailed}`);
 42 |   console.log(`${'='.repeat(60)}`);
 43 |   process.exit(testsFailed > 0 ? 1 : 0);
 44 | }
 45 | 
 46 | server.stdout.on('data', (data) => {
 47 |   responseBuffer += data.toString();
 48 | 
 49 |   // Try to parse JSON-RPC responses
 50 |   const lines = responseBuffer.split('\n');
 51 |   responseBuffer = lines.pop() || '';
 52 | 
 53 |   lines.forEach(line => {
 54 |     if (line.trim()) {
 55 |       try {
 56 |         const response = JSON.parse(line);
 57 | 
 58 |         if (response.error) {
 59 |           console.log(`❌ ${currentTest} - ERROR`);
 60 |           console.log('   Error:', response.error.message);
 61 |           testsFailed++;
 62 |         } else if (response.result) {
 63 |           handleTestResult(response);
 64 |         }
 65 |       } catch (e) {
 66 |         // Not JSON, might be regular output
 67 |       }
 68 |     }
 69 |   });
 70 | });
 71 | 
 72 | server.stderr.on('data', (data) => {
 73 |   const output = data.toString().trim();
 74 |   if (output && !output.includes('ExperimentalWarning')) {
 75 |     console.log('🔧 Server:', output);
 76 |   }
 77 | });
 78 | 
 79 | server.on('close', (code) => {
 80 |   printResults();
 81 | });
 82 | 
 83 | function handleTestResult(response) {
 84 |   const content = response.result.content?.[0]?.text || JSON.stringify(response.result);
 85 | 
 86 |   if (currentTest === 'Initialize') {
 87 |     console.log('✅ Initialize - PASSED');
 88 |     console.log(`   Protocol: ${response.result.protocolVersion}`);
 89 |     console.log(`   Server: ${response.result.serverInfo.name} v${response.result.serverInfo.version}\n`);
 90 |     testsPassed++;
 91 |   }
 92 |   else if (currentTest === 'Get Metadata Summary') {
 93 |     if (content.includes('Rick Astley') || content.includes('Never Gonna Give You Up')) {
 94 |       console.log('✅ Get Metadata Summary - PASSED');
 95 |       console.log('   Response preview:');
 96 |       const lines = content.split('\n').slice(0, 5);
 97 |       lines.forEach(line => console.log(`   ${line}`));
 98 |       console.log('   ...\n');
 99 |       testsPassed++;
100 |     } else {
101 |       console.log('❌ Get Metadata Summary - FAILED');
102 |       console.log('   Expected Rick Astley content, got:', content.substring(0, 100));
103 |       testsFailed++;
104 |     }
105 |   }
106 |   else if (currentTest === 'List Subtitle Languages') {
107 |     if (content.includes('en') || content.includes('English')) {
108 |       console.log('✅ List Subtitle Languages - PASSED');
109 |       console.log('   Found subtitle languages\n');
110 |       testsPassed++;
111 |     } else {
112 |       console.log('❌ List Subtitle Languages - FAILED');
113 |       console.log('   Response:', content.substring(0, 200));
114 |       testsFailed++;
115 |     }
116 |   }
117 |   else if (currentTest === 'Get Metadata (Filtered)') {
118 |     try {
119 |       const metadata = JSON.parse(content);
120 |       if (metadata.title && metadata.channel) {
121 |         console.log('✅ Get Metadata (Filtered) - PASSED');
122 |         console.log(`   Title: ${metadata.title}`);
123 |         console.log(`   Channel: ${metadata.channel}`);
124 |         console.log(`   Duration: ${metadata.duration || 'N/A'}\n`);
125 |         testsPassed++;
126 |       } else {
127 |         console.log('❌ Get Metadata (Filtered) - FAILED');
128 |         console.log('   Missing expected fields');
129 |         testsFailed++;
130 |       }
131 |     } catch (e) {
132 |       console.log('❌ Get Metadata (Filtered) - FAILED');
133 |       console.log('   Invalid JSON response');
134 |       testsFailed++;
135 |     }
136 |   }
137 |   else if (currentTest === 'Download Transcript (first 500 chars)') {
138 |     if (content.length > 100) {
139 |       console.log('✅ Download Transcript - PASSED');
140 |       console.log('   Transcript length:', content.length, 'characters');
141 |       console.log('   Preview:', content.substring(0, 150).replace(/\n/g, ' ') + '...\n');
142 |       testsPassed++;
143 |     } else {
144 |       console.log('❌ Download Transcript - FAILED');
145 |       console.log('   Response too short:', content.substring(0, 100));
146 |       testsFailed++;
147 |     }
148 |   }
149 | }
150 | 
151 | function sendRequest(method, params, testName) {
152 |   requestId++;
153 |   currentTest = testName;
154 |   console.log(`🔍 Test ${requestId}: ${testName}`);
155 | 
156 |   const request = {
157 |     jsonrpc: '2.0',
158 |     id: requestId,
159 |     method: method,
160 |     params: params
161 |   };
162 | 
163 |   server.stdin.write(JSON.stringify(request) + '\n');
164 | }
165 | 
166 | // Run tests sequentially with delays
167 | setTimeout(() => {
168 |   // Test 1: Initialize
169 |   sendRequest('initialize', {
170 |     protocolVersion: '2024-11-05',
171 |     capabilities: {},
172 |     clientInfo: { name: 'test-client', version: '1.0.0' }
173 |   }, 'Initialize');
174 | 
175 |   setTimeout(() => {
176 |     // Test 2: Get video metadata summary
177 |     sendRequest('tools/call', {
178 |       name: 'ytdlp_get_video_metadata_summary',
179 |       arguments: { url: TEST_VIDEO }
180 |     }, 'Get Metadata Summary');
181 | 
182 |     setTimeout(() => {
183 |       // Test 3: List subtitle languages
184 |       sendRequest('tools/call', {
185 |         name: 'ytdlp_list_subtitle_languages',
186 |         arguments: { url: TEST_VIDEO }
187 |       }, 'List Subtitle Languages');
188 | 
189 |       setTimeout(() => {
190 |         // Test 4: Get specific metadata fields
191 |         sendRequest('tools/call', {
192 |           name: 'ytdlp_get_video_metadata',
193 |           arguments: {
194 |             url: TEST_VIDEO,
195 |             fields: ['id', 'title', 'channel', 'duration', 'view_count']
196 |           }
197 |         }, 'Get Metadata (Filtered)');
198 | 
199 |         setTimeout(() => {
200 |           // Test 5: Download transcript (might take longer)
201 |           console.log('   (This may take 10-20 seconds...)\n');
202 |           sendRequest('tools/call', {
203 |             name: 'ytdlp_download_transcript',
204 |             arguments: { url: TEST_VIDEO, language: 'en' }
205 |           }, 'Download Transcript (first 500 chars)');
206 | 
207 |           setTimeout(() => {
208 |             console.log('\n✅ All tests completed!');
209 |             server.kill();
210 |           }, 25000); // Wait 25 seconds for transcript
211 |         }, 3000);
212 |       }, 5000);
213 |     }, 5000);
214 |   }, 2000);
215 | }, 1000);
216 | 
```

--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------

```markdown
  1 | # Changelog
  2 | 
  3 | All notable changes to this project will be documented in this file.
  4 | 
  5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
  6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
  7 | 
  8 | ## [0.7.0] - 2025-10-19
  9 | 
 10 | ### 🎉 Major Release - MCP Best Practices & Quality Improvements
 11 | 
 12 | This release represents a significant upgrade with comprehensive MCP best practices implementation, following the official MCP server development guidelines.
 13 | 
 14 | ### ✨ Added
 15 | 
 16 | #### Tool Naming & Organization
 17 | - **Tool Name Prefixes**: All tools now have `ytdlp_` prefix to avoid naming conflicts with other MCP servers
 18 |   - `search_videos` → `ytdlp_search_videos`
 19 |   - `download_video` → `ytdlp_download_video`
 20 |   - `get_video_metadata` → `ytdlp_get_video_metadata`
 21 |   - And all other tools similarly prefixed
 22 | 
 23 | #### Input Validation
 24 | - **Zod Schema Validation**: Implemented runtime input validation for all 8 tools
 25 |   - URL validation with proper format checking
 26 |   - String length constraints (min/max)
 27 |   - Number range validation
 28 |   - Regex patterns for language codes and time formats
 29 |   - Enum validation for resolution and format options
 30 |   - `.strict()` mode to prevent unexpected fields
 31 | 
 32 | #### Tool Annotations
 33 | - **MCP Tool Hints**: Added comprehensive annotations to all tools
 34 |   - `readOnlyHint: true` for read-only operations (search, list, get)
 35 |   - `readOnlyHint: false` for file-creating operations (downloads)
 36 |   - `destructiveHint: false` for all tools (no destructive updates)
 37 |   - `idempotentHint: true/false` based on operation type
 38 |   - `openWorldHint: true` for all tools (external API interactions)
 39 | 
 40 | #### Response Formats
 41 | - **Flexible Output Formats**: Added `response_format` parameter to search tools
 42 |   - JSON format: Structured data for programmatic processing
 43 |   - Markdown format: Human-readable display (default)
 44 |   - Both formats include pagination metadata
 45 | 
 46 | #### Pagination Support
 47 | - **Search Pagination**: Added offset parameter to `ytdlp_search_videos`
 48 |   - `offset` parameter for skipping results
 49 |   - `has_more` indicator in responses
 50 |   - `next_offset` for easy pagination
 51 |   - Works with both JSON and Markdown formats
 52 | 
 53 | #### Character Limits & Truncation
 54 | - **Response Size Protection**: Implemented character limits to prevent context overflow
 55 |   - Standard limit: 25,000 characters
 56 |   - Transcript limit: 50,000 characters (larger for text content)
 57 |   - Automatic truncation with clear messages
 58 |   - Smart truncation that preserves JSON validity
 59 | 
 60 | #### Error Messages
 61 | - **Actionable Error Guidance**: Improved error messages across all modules
 62 |   - Platform-specific errors (Unsupported URL, Video unavailable, etc.)
 63 |   - Network error guidance with retry suggestions
 64 |   - Language availability hints (e.g., "Use ytdlp_list_subtitle_languages to check options")
 65 |   - Rate limit handling with wait time suggestions
 66 | 
 67 | ### 🔧 Improved
 68 | 
 69 | #### Tool Descriptions
 70 | - **Comprehensive Documentation**: Enhanced all tool descriptions with:
 71 |   - Clear purpose statements
 72 |   - Detailed parameter descriptions with examples
 73 |   - Complete return value schemas
 74 |   - "Use when" / "Don't use when" guidance
 75 |   - Error handling documentation
 76 |   - Example use cases
 77 | 
 78 | #### Configuration
 79 | - **Enhanced Config System**: Added new configuration options
 80 |   - `limits.characterLimit`: Maximum response size (25,000)
 81 |   - `limits.maxTranscriptLength`: Maximum transcript size (50,000)
 82 |   - Environment variable support for all settings
 83 | 
 84 | #### Code Quality
 85 | - **Better Type Safety**: Improved TypeScript types throughout
 86 |   - Proper type definitions for metadata with truncation fields
 87 |   - Explicit Promise return types
 88 |   - Better error type handling
 89 | 
 90 | ### 🐛 Fixed
 91 | 
 92 | - **JSON Parsing Issue**: Fixed metadata truncation that was breaking JSON format
 93 |   - Truncation messages now inside JSON objects instead of appended
 94 |   - Prevents "Unexpected non-whitespace character" errors
 95 |   - Maintains valid JSON structure even when truncated
 96 | 
 97 | ### 🧪 Testing
 98 | 
 99 | - **Real-World Validation**: Comprehensive testing with actual videos
100 |   - ✅ YouTube platform fully tested (Rick Astley - Never Gonna Give You Up)
101 |   - ✅ Bilibili platform fully tested (Chinese content)
102 |   - ✅ Multi-language support verified (English, Chinese)
103 |   - ✅ All 8 tools tested with real API calls
104 |   - ✅ MCP protocol compatibility verified
105 | 
106 | ### 📖 Documentation
107 | 
108 | - **Enhanced README**: Completely redesigned README.md with:
109 |   - Professional badges and visual formatting
110 |   - Comprehensive feature tables
111 |   - Detailed tool documentation
112 |   - Usage examples by category
113 |   - Configuration guide
114 |   - Architecture overview
115 |   - Multi-language support demonstration
116 | 
117 | ### 🌍 Platform Support
118 | 
119 | - **Verified Platforms**:
120 |   - ✅ YouTube (fully tested)
121 |   - ✅ Bilibili (哔哩哔哩) (fully tested)
122 |   - 🎯 1000+ other platforms supported via yt-dlp
123 | 
124 | ### 📊 Statistics
125 | 
126 | - 8 tools with complete Zod validation
127 | - 8 tools with proper annotations
128 | - 8 tools with comprehensive descriptions
129 | - 2 platforms tested and verified
130 | - 5/5 YouTube tests passing
131 | - 3/3 Bilibili tests passing
132 | - 0 critical bugs remaining
133 | 
134 | ### 🔄 Migration Guide
135 | 
136 | If upgrading from 0.6.x:
137 | 
138 | 1. **Tool Names**: Update all tool names to include `ytdlp_` prefix
139 |    ```diff
140 |    - "search_videos"
141 |    + "ytdlp_search_videos"
142 |    ```
143 | 
144 | 2. **Search Parameters**: New optional parameters available
145 |    ```javascript
146 |    {
147 |      query: "tutorial",
148 |      maxResults: 10,
149 |      offset: 0,              // NEW: pagination support
150 |      response_format: "json" // NEW: format control
151 |    }
152 |    ```
153 | 
154 | 3. **Error Handling**: Error messages are more descriptive now
155 |    - Update any error parsing logic to handle new formats
156 | 
157 | ### 🙏 Acknowledgments
158 | 
159 | This release follows the [MCP Server Development Best Practices](https://modelcontextprotocol.io) and incorporates feedback from the MCP community.
160 | 
161 | ---
162 | 
163 | ## [0.6.28] - 2025-08-13
164 | 
165 | ### Added
166 | - Video metadata extraction with `get_video_metadata` and `get_video_metadata_summary`
167 | - Comprehensive test suite
168 | - API documentation
169 | 
170 | ### Changed
171 | - Improved metadata extraction performance
172 | - Updated dependencies
173 | 
174 | ### Fixed
175 | - Various bug fixes and stability improvements
176 | 
177 | ---
178 | 
179 | ## [0.6.0] - 2025-08-01
180 | 
181 | ### Added
182 | - Initial MCP server implementation
183 | - YouTube video search functionality
184 | - Video download with resolution control
185 | - Audio extraction
186 | - Subtitle download and transcript generation
187 | - Integration with yt-dlp
188 | 
189 | ### Features
190 | - 8 core tools for video content management
191 | - Support for multiple video platforms
192 | - Configurable downloads directory
193 | - Automatic filename sanitization
194 | - Cross-platform compatibility (Windows, macOS, Linux)
195 | 
196 | ---
197 | 
198 | [0.7.0]: https://github.com/kevinwatt/yt-dlp-mcp/compare/v0.6.28...v0.7.0
199 | [0.6.28]: https://github.com/kevinwatt/yt-dlp-mcp/compare/v0.6.0...v0.6.28
200 | [0.6.0]: https://github.com/kevinwatt/yt-dlp-mcp/releases/tag/v0.6.0
201 | 
```

--------------------------------------------------------------------------------
/test-bilibili.mjs:
--------------------------------------------------------------------------------

```
  1 | #!/usr/bin/env node
  2 | /**
  3 |  * Test MCP server with Bilibili video
  4 |  * Tests cross-platform support with https://www.bilibili.com/video/BV17YdXY4Ewj/
  5 |  */
  6 | 
  7 | import { spawn } from 'child_process';
  8 | import { fileURLToPath } from 'url';
  9 | import { dirname, join } from 'path';
 10 | 
 11 | const __filename = fileURLToPath(import.meta.url);
 12 | const __dirname = dirname(__filename);
 13 | 
 14 | const serverPath = join(__dirname, 'lib', 'index.mjs');
 15 | const TEST_VIDEO = 'https://www.bilibili.com/video/BV17YdXY4Ewj/?spm_id_from=333.1387.homepage.video_card.click&vd_source=bc7bf10259efd682c452b5ce8426b945';
 16 | 
 17 | console.log('🎬 Testing yt-dlp MCP Server with Bilibili Video\n');
 18 | console.log('Video:', TEST_VIDEO);
 19 | console.log('Platform: Bilibili (哔哩哔哩)\n');
 20 | 
 21 | const server = spawn('node', [serverPath]);
 22 | 
 23 | let testsPassed = 0;
 24 | let testsFailed = 0;
 25 | let responseBuffer = '';
 26 | let requestId = 0;
 27 | let currentTest = '';
 28 | 
 29 | const timeout = setTimeout(() => {
 30 |   console.log('\n⏱️  Test timeout - killing server');
 31 |   server.kill();
 32 |   printResults();
 33 | }, 60000);
 34 | 
 35 | function printResults() {
 36 |   clearTimeout(timeout);
 37 |   console.log(`\n${'='.repeat(60)}`);
 38 |   console.log(`📊 Bilibili Test Results:`);
 39 |   console.log(`   ✅ Passed: ${testsPassed}`);
 40 |   console.log(`   ❌ Failed: ${testsFailed}`);
 41 |   console.log(`${'='.repeat(60)}`);
 42 | 
 43 |   if (testsPassed > 0) {
 44 |     console.log('\n✨ Bilibili platform is supported!');
 45 |   } else {
 46 |     console.log('\n⚠️  Bilibili support may be limited');
 47 |   }
 48 | 
 49 |   process.exit(testsFailed > 0 ? 1 : 0);
 50 | }
 51 | 
 52 | server.stdout.on('data', (data) => {
 53 |   responseBuffer += data.toString();
 54 | 
 55 |   const lines = responseBuffer.split('\n');
 56 |   responseBuffer = lines.pop() || '';
 57 | 
 58 |   lines.forEach(line => {
 59 |     if (line.trim()) {
 60 |       try {
 61 |         const response = JSON.parse(line);
 62 | 
 63 |         if (response.error) {
 64 |           console.log(`❌ ${currentTest} - ERROR`);
 65 |           console.log('   Error:', response.error.message);
 66 |           console.log('   This may indicate limited Bilibili support\n');
 67 |           testsFailed++;
 68 |         } else if (response.result) {
 69 |           handleTestResult(response);
 70 |         }
 71 |       } catch (e) {
 72 |         // Not JSON
 73 |       }
 74 |     }
 75 |   });
 76 | });
 77 | 
 78 | server.stderr.on('data', (data) => {
 79 |   const output = data.toString().trim();
 80 |   if (output && !output.includes('ExperimentalWarning')) {
 81 |     console.log('🔧 Server:', output);
 82 |   }
 83 | });
 84 | 
 85 | server.on('close', (code) => {
 86 |   printResults();
 87 | });
 88 | 
 89 | function handleTestResult(response) {
 90 |   const content = response.result.content?.[0]?.text || JSON.stringify(response.result);
 91 | 
 92 |   if (currentTest === 'Initialize') {
 93 |     console.log('✅ Initialize - PASSED\n');
 94 |     testsPassed++;
 95 |   }
 96 |   else if (currentTest === 'Get Bilibili Metadata Summary') {
 97 |     // Check if we got any content
 98 |     if (content && content.length > 50 && !content.includes('Error')) {
 99 |       console.log('✅ Get Bilibili Metadata Summary - PASSED');
100 |       console.log('   Response preview:');
101 |       const lines = content.split('\n').slice(0, 8);
102 |       lines.forEach(line => console.log(`   ${line}`));
103 |       if (content.split('\n').length > 8) {
104 |         console.log('   ...');
105 |       }
106 |       console.log();
107 |       testsPassed++;
108 |     } else if (content.includes('Error') || content.includes('Unsupported')) {
109 |       console.log('⚠️  Get Bilibili Metadata Summary - PARTIAL');
110 |       console.log('   Platform may have limited support');
111 |       console.log('   Response:', content.substring(0, 150));
112 |       console.log();
113 |       testsFailed++;
114 |     } else {
115 |       console.log('❌ Get Bilibili Metadata Summary - FAILED');
116 |       console.log('   Response too short or invalid');
117 |       console.log();
118 |       testsFailed++;
119 |     }
120 |   }
121 |   else if (currentTest === 'List Bilibili Subtitle Languages') {
122 |     if (content.length > 50 && !content.includes('Error')) {
123 |       console.log('✅ List Bilibili Subtitle Languages - PASSED');
124 |       console.log('   Subtitle info retrieved\n');
125 |       testsPassed++;
126 |     } else if (content.includes('No subtitle') || content.includes('not found')) {
127 |       console.log('⚠️  List Bilibili Subtitle Languages - NO SUBTITLES');
128 |       console.log('   Video may not have subtitles available\n');
129 |       testsPassed++; // Not an error, just no subs
130 |     } else {
131 |       console.log('❌ List Bilibili Subtitle Languages - FAILED');
132 |       console.log('   Response:', content.substring(0, 200));
133 |       console.log();
134 |       testsFailed++;
135 |     }
136 |   }
137 |   else if (currentTest === 'Get Bilibili Metadata (Filtered)') {
138 |     try {
139 |       const metadata = JSON.parse(content);
140 |       if (metadata.id || metadata.title) {
141 |         console.log('✅ Get Bilibili Metadata (Filtered) - PASSED');
142 |         if (metadata.title) console.log(`   Title: ${metadata.title}`);
143 |         if (metadata.uploader) console.log(`   Uploader: ${metadata.uploader}`);
144 |         if (metadata.duration) console.log(`   Duration: ${metadata.duration}s`);
145 |         console.log();
146 |         testsPassed++;
147 |       } else {
148 |         console.log('❌ Get Bilibili Metadata (Filtered) - FAILED');
149 |         console.log('   Missing expected fields');
150 |         console.log();
151 |         testsFailed++;
152 |       }
153 |     } catch (e) {
154 |       // Maybe it's an error message
155 |       if (content.includes('Error') || content.includes('Unsupported')) {
156 |         console.log('⚠️  Get Bilibili Metadata (Filtered) - PLATFORM ISSUE');
157 |         console.log('   Response:', content.substring(0, 200));
158 |         console.log();
159 |         testsFailed++;
160 |       } else {
161 |         console.log('❌ Get Bilibili Metadata (Filtered) - FAILED');
162 |         console.log('   Invalid response format');
163 |         console.log();
164 |         testsFailed++;
165 |       }
166 |     }
167 |   }
168 | }
169 | 
170 | function sendRequest(method, params, testName) {
171 |   requestId++;
172 |   currentTest = testName;
173 |   console.log(`🔍 Test ${requestId}: ${testName}`);
174 |   if (testName.includes('Metadata') || testName.includes('Subtitle')) {
175 |     console.log('   (Testing Bilibili platform support...)\n');
176 |   }
177 | 
178 |   const request = {
179 |     jsonrpc: '2.0',
180 |     id: requestId,
181 |     method: method,
182 |     params: params
183 |   };
184 | 
185 |   server.stdin.write(JSON.stringify(request) + '\n');
186 | }
187 | 
188 | // Run tests
189 | setTimeout(() => {
190 |   sendRequest('initialize', {
191 |     protocolVersion: '2024-11-05',
192 |     capabilities: {},
193 |     clientInfo: { name: 'bilibili-test', version: '1.0.0' }
194 |   }, 'Initialize');
195 | 
196 |   setTimeout(() => {
197 |     sendRequest('tools/call', {
198 |       name: 'ytdlp_get_video_metadata_summary',
199 |       arguments: { url: TEST_VIDEO }
200 |     }, 'Get Bilibili Metadata Summary');
201 | 
202 |     setTimeout(() => {
203 |       sendRequest('tools/call', {
204 |         name: 'ytdlp_list_subtitle_languages',
205 |         arguments: { url: TEST_VIDEO }
206 |       }, 'List Bilibili Subtitle Languages');
207 | 
208 |       setTimeout(() => {
209 |         sendRequest('tools/call', {
210 |           name: 'ytdlp_get_video_metadata',
211 |           arguments: {
212 |             url: TEST_VIDEO,
213 |             fields: ['id', 'title', 'uploader', 'duration', 'description']
214 |           }
215 |         }, 'Get Bilibili Metadata (Filtered)');
216 | 
217 |         setTimeout(() => {
218 |           console.log('\n✅ All Bilibili tests completed!');
219 |           server.kill();
220 |         }, 8000);
221 |       }, 5000);
222 |     }, 5000);
223 |   }, 2000);
224 | }, 1000);
225 | 
```

--------------------------------------------------------------------------------
/src/config.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import * as os from "os";
  2 | import * as path from "path";
  3 | 
  4 | type DeepPartial<T> = {
  5 |   [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
  6 | };
  7 | 
  8 | /**
  9 |  * Configuration type definitions
 10 |  */
 11 | export interface Config {
 12 |   // File-related configuration
 13 |   file: {
 14 |     maxFilenameLength: number;
 15 |     downloadsDir: string;
 16 |     tempDirPrefix: string;
 17 |     // Filename processing configuration
 18 |     sanitize: {
 19 |       // Character to replace illegal characters
 20 |       replaceChar: string;
 21 |       // Suffix when truncating filenames
 22 |       truncateSuffix: string;
 23 |       // Regular expression for illegal characters
 24 |       illegalChars: RegExp;
 25 |       // List of reserved names
 26 |       reservedNames: readonly string[];
 27 |     };
 28 |   };
 29 |   // Tool-related configuration
 30 |   tools: {
 31 |     required: readonly string[];
 32 |   };
 33 |   // Download-related configuration
 34 |   download: {
 35 |     defaultResolution: "480p" | "720p" | "1080p" | "best";
 36 |     defaultAudioFormat: "m4a" | "mp3";
 37 |     defaultSubtitleLanguage: string;
 38 |   };
 39 |   // Response limits
 40 |   limits: {
 41 |     characterLimit: number;
 42 |     maxTranscriptLength: number;
 43 |   };
 44 | }
 45 | 
 46 | /**
 47 |  * Default configuration
 48 |  */
 49 | const defaultConfig: Config = {
 50 |   file: {
 51 |     maxFilenameLength: 50,
 52 |     downloadsDir: path.join(os.homedir(), "Downloads"),
 53 |     tempDirPrefix: "ytdlp-",
 54 |     sanitize: {
 55 |       replaceChar: '_',
 56 |       truncateSuffix: '...',
 57 |       illegalChars: /[<>:"/\\|?*\x00-\x1F]/g,  // Windows illegal characters
 58 |       reservedNames: [
 59 |         'CON', 'PRN', 'AUX', 'NUL', 'COM1', 'COM2', 'COM3', 'COM4',
 60 |         'COM5', 'COM6', 'COM7', 'COM8', 'COM9', 'LPT1', 'LPT2',
 61 |         'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9'
 62 |       ]
 63 |     }
 64 |   },
 65 |   tools: {
 66 |     required: ['yt-dlp']
 67 |   },
 68 |   download: {
 69 |     defaultResolution: "720p",
 70 |     defaultAudioFormat: "m4a",
 71 |     defaultSubtitleLanguage: "en"
 72 |   },
 73 |   limits: {
 74 |     characterLimit: 25000,      // Standard MCP character limit
 75 |     maxTranscriptLength: 50000  // Transcripts can be larger
 76 |   }
 77 | };
 78 | 
 79 | /**
 80 |  * Load configuration from environment variables
 81 |  */
 82 | function loadEnvConfig(): DeepPartial<Config> {
 83 |   const envConfig: DeepPartial<Config> = {};
 84 | 
 85 |   // File configuration
 86 |   const fileConfig: DeepPartial<Config['file']> = {
 87 |     sanitize: {
 88 |       replaceChar: process.env.YTDLP_SANITIZE_REPLACE_CHAR,
 89 |       truncateSuffix: process.env.YTDLP_SANITIZE_TRUNCATE_SUFFIX,
 90 |       illegalChars: process.env.YTDLP_SANITIZE_ILLEGAL_CHARS ? new RegExp(process.env.YTDLP_SANITIZE_ILLEGAL_CHARS) : undefined,
 91 |       reservedNames: process.env.YTDLP_SANITIZE_RESERVED_NAMES?.split(',')
 92 |     }
 93 |   };
 94 |   
 95 |   if (process.env.YTDLP_MAX_FILENAME_LENGTH) {
 96 |     fileConfig.maxFilenameLength = parseInt(process.env.YTDLP_MAX_FILENAME_LENGTH);
 97 |   }
 98 |   if (process.env.YTDLP_DOWNLOADS_DIR) {
 99 |     fileConfig.downloadsDir = process.env.YTDLP_DOWNLOADS_DIR;
100 |   }
101 |   if (process.env.YTDLP_TEMP_DIR_PREFIX) {
102 |     fileConfig.tempDirPrefix = process.env.YTDLP_TEMP_DIR_PREFIX;
103 |   }
104 | 
105 |   if (Object.keys(fileConfig).length > 0) {
106 |     envConfig.file = fileConfig;
107 |   }
108 | 
109 |   // Download configuration
110 |   const downloadConfig: Partial<Config['download']> = {};
111 |   if (process.env.YTDLP_DEFAULT_RESOLUTION && 
112 |       ['480p', '720p', '1080p', 'best'].includes(process.env.YTDLP_DEFAULT_RESOLUTION)) {
113 |     downloadConfig.defaultResolution = process.env.YTDLP_DEFAULT_RESOLUTION as Config['download']['defaultResolution'];
114 |   }
115 |   if (process.env.YTDLP_DEFAULT_AUDIO_FORMAT && 
116 |       ['m4a', 'mp3'].includes(process.env.YTDLP_DEFAULT_AUDIO_FORMAT)) {
117 |     downloadConfig.defaultAudioFormat = process.env.YTDLP_DEFAULT_AUDIO_FORMAT as Config['download']['defaultAudioFormat'];
118 |   }
119 |   if (process.env.YTDLP_DEFAULT_SUBTITLE_LANG) {
120 |     downloadConfig.defaultSubtitleLanguage = process.env.YTDLP_DEFAULT_SUBTITLE_LANG;
121 |   }
122 |   if (Object.keys(downloadConfig).length > 0) {
123 |     envConfig.download = downloadConfig;
124 |   }
125 | 
126 |   return envConfig;
127 | }
128 | 
129 | /**
130 |  * Validate configuration
131 |  */
132 | function validateConfig(config: Config): void {
133 |   // Validate filename length
134 |   if (config.file.maxFilenameLength < 5) {
135 |     throw new Error('maxFilenameLength must be at least 5');
136 |   }
137 | 
138 |   // Validate downloads directory
139 |   if (!config.file.downloadsDir) {
140 |     throw new Error('downloadsDir must be specified');
141 |   }
142 | 
143 |   // Validate temporary directory prefix
144 |   if (!config.file.tempDirPrefix) {
145 |     throw new Error('tempDirPrefix must be specified');
146 |   }
147 | 
148 |   // Validate default resolution
149 |   if (!['480p', '720p', '1080p', 'best'].includes(config.download.defaultResolution)) {
150 |     throw new Error('Invalid defaultResolution');
151 |   }
152 | 
153 |   // Validate default audio format
154 |   if (!['m4a', 'mp3'].includes(config.download.defaultAudioFormat)) {
155 |     throw new Error('Invalid defaultAudioFormat');
156 |   }
157 | 
158 |   // Validate default subtitle language
159 |   if (!/^[a-z]{2,3}(-[A-Z][a-z]{3})?(-[A-Z]{2})?$/i.test(config.download.defaultSubtitleLanguage)) {
160 |     throw new Error('Invalid defaultSubtitleLanguage');
161 |   }
162 | }
163 | 
164 | /**
165 |  * Merge configuration
166 |  */
167 | function mergeConfig(base: Config, override: DeepPartial<Config>): Config {
168 |   return {
169 |     file: {
170 |       maxFilenameLength: override.file?.maxFilenameLength || base.file.maxFilenameLength,
171 |       downloadsDir: override.file?.downloadsDir || base.file.downloadsDir,
172 |       tempDirPrefix: override.file?.tempDirPrefix || base.file.tempDirPrefix,
173 |       sanitize: {
174 |         replaceChar: override.file?.sanitize?.replaceChar || base.file.sanitize.replaceChar,
175 |         truncateSuffix: override.file?.sanitize?.truncateSuffix || base.file.sanitize.truncateSuffix,
176 |         illegalChars: (override.file?.sanitize?.illegalChars || base.file.sanitize.illegalChars) as RegExp,
177 |         reservedNames: (override.file?.sanitize?.reservedNames || base.file.sanitize.reservedNames) as readonly string[]
178 |       }
179 |     },
180 |     tools: {
181 |       required: (override.tools?.required || base.tools.required) as readonly string[]
182 |     },
183 |     download: {
184 |       defaultResolution: override.download?.defaultResolution || base.download.defaultResolution,
185 |       defaultAudioFormat: override.download?.defaultAudioFormat || base.download.defaultAudioFormat,
186 |       defaultSubtitleLanguage: override.download?.defaultSubtitleLanguage || base.download.defaultSubtitleLanguage
187 |     },
188 |     limits: {
189 |       characterLimit: override.limits?.characterLimit || base.limits.characterLimit,
190 |       maxTranscriptLength: override.limits?.maxTranscriptLength || base.limits.maxTranscriptLength
191 |     }
192 |   };
193 | }
194 | 
195 | /**
196 |  * Load configuration
197 |  */
198 | export function loadConfig(): Config {
199 |   const envConfig = loadEnvConfig();
200 |   const config = mergeConfig(defaultConfig, envConfig);
201 |   validateConfig(config);
202 |   return config;
203 | }
204 | 
205 | /**
206 |  * Safe filename processing function
207 |  */
208 | export function sanitizeFilename(filename: string, config: Config['file']): string {
209 |   // Remove illegal characters
210 |   let safe = filename.replace(config.sanitize.illegalChars, config.sanitize.replaceChar);
211 |   
212 |   // Check reserved names
213 |   const basename = path.parse(safe).name.toUpperCase();
214 |   if (config.sanitize.reservedNames.includes(basename)) {
215 |     safe = `_${safe}`;
216 |   }
217 |   
218 |   // Handle length limitation
219 |   if (safe.length > config.maxFilenameLength) {
220 |     const ext = path.extname(safe);
221 |     const name = safe.slice(0, config.maxFilenameLength - ext.length - config.sanitize.truncateSuffix.length);
222 |     safe = `${name}${config.sanitize.truncateSuffix}${ext}`;
223 |   }
224 |   
225 |   return safe;
226 | }
227 | 
228 | // Export current configuration instance
229 | export const CONFIG = loadConfig(); 
```

--------------------------------------------------------------------------------
/src/__tests__/metadata.test.ts:
--------------------------------------------------------------------------------

```typescript
  1 | // @ts-nocheck
  2 | // @jest-environment node
  3 | import { describe, test, expect, beforeAll } from '@jest/globals';
  4 | import { getVideoMetadata, getVideoMetadataSummary } from '../modules/metadata.js';
  5 | import type { VideoMetadata } from '../modules/metadata.js';
  6 | import { CONFIG } from '../config.js';
  7 | 
  8 | // 設置 Python 環境
  9 | process.env.PYTHONPATH = '';
 10 | process.env.PYTHONHOME = '';
 11 | 
 12 | describe('Video Metadata Extraction', () => {
 13 |   const testUrl = 'https://www.youtube.com/watch?v=jNQXAC9IVRw';
 14 | 
 15 |   describe('getVideoMetadata', () => {
 16 |     test('should extract basic metadata from YouTube video', async () => {
 17 |       const metadataJson = await getVideoMetadata(testUrl);
 18 |       const metadata: VideoMetadata = JSON.parse(metadataJson);
 19 | 
 20 |       // 驗證基本字段存在
 21 |       expect(metadata).toHaveProperty('id');
 22 |       expect(metadata).toHaveProperty('title');
 23 |       expect(metadata).toHaveProperty('uploader');
 24 |       expect(metadata).toHaveProperty('duration');
 25 |       expect(metadata.id).toBe('jNQXAC9IVRw');
 26 |       expect(typeof metadata.title).toBe('string');
 27 |       expect(typeof metadata.uploader).toBe('string');
 28 |       expect(typeof metadata.duration).toBe('number');
 29 |     });
 30 | 
 31 |     test('should extract specific fields when requested', async () => {
 32 |       const fields = ['id', 'title', 'description', 'channel', 'timestamp'];
 33 |       const metadataJson = await getVideoMetadata(testUrl, fields);
 34 |       const metadata = JSON.parse(metadataJson);
 35 | 
 36 |       // 應該只包含請求的字段
 37 |       expect(Object.keys(metadata)).toEqual(expect.arrayContaining(fields.filter(f => metadata[f] !== undefined)));
 38 |       
 39 |       // 不應該包含其他字段(如果它們存在於原始數據中)
 40 |       expect(metadata).not.toHaveProperty('formats');
 41 |       expect(metadata).not.toHaveProperty('thumbnails');
 42 |     });
 43 | 
 44 |     test('should handle empty fields array gracefully', async () => {
 45 |       const metadataJson = await getVideoMetadata(testUrl, []);
 46 |       const metadata = JSON.parse(metadataJson);
 47 |       
 48 |       // 空數組應該返回空對象
 49 |       expect(metadata).toEqual({});
 50 |     });
 51 | 
 52 |     test('should handle non-existent fields gracefully', async () => {
 53 |       const fields = ['id', 'title', 'non_existent_field', 'another_fake_field'];
 54 |       const metadataJson = await getVideoMetadata(testUrl, fields);
 55 |       const metadata = JSON.parse(metadataJson);
 56 | 
 57 |       // 應該包含存在的字段
 58 |       expect(metadata).toHaveProperty('id');
 59 |       expect(metadata).toHaveProperty('title');
 60 |       
 61 |       // 不應該包含不存在的字段
 62 |       expect(metadata).not.toHaveProperty('non_existent_field');
 63 |       expect(metadata).not.toHaveProperty('another_fake_field');
 64 |     });
 65 | 
 66 |     test('should throw error for invalid URL', async () => {
 67 |       await expect(getVideoMetadata('invalid-url')).rejects.toThrow();
 68 |       await expect(getVideoMetadata('https://invalid-domain.com/video')).rejects.toThrow();
 69 |     });
 70 | 
 71 |     test('should include requested metadata fields from issue #16', async () => {
 72 |       const fields = ['id', 'title', 'description', 'creators', 'timestamp', 'channel', 'channel_id', 'channel_url'];
 73 |       const metadataJson = await getVideoMetadata(testUrl, fields);
 74 |       const metadata = JSON.parse(metadataJson);
 75 | 
 76 |       // 驗證 issue #16 中請求的字段
 77 |       expect(metadata).toHaveProperty('id');
 78 |       expect(metadata).toHaveProperty('title');
 79 |       expect(metadata.id).toBe('jNQXAC9IVRw');
 80 |       expect(typeof metadata.title).toBe('string');
 81 | 
 82 |       // 這些字段可能存在也可能不存在,取決於視頻
 83 |       if (metadata.description !== undefined) {
 84 |         expect(typeof metadata.description).toBe('string');
 85 |       }
 86 |       if (metadata.creators !== undefined) {
 87 |         expect(Array.isArray(metadata.creators)).toBe(true);
 88 |       }
 89 |       if (metadata.timestamp !== undefined) {
 90 |         expect(typeof metadata.timestamp).toBe('number');
 91 |       }
 92 |       if (metadata.channel !== undefined) {
 93 |         expect(typeof metadata.channel).toBe('string');
 94 |       }
 95 |       if (metadata.channel_id !== undefined) {
 96 |         expect(typeof metadata.channel_id).toBe('string');
 97 |       }
 98 |       if (metadata.channel_url !== undefined) {
 99 |         expect(typeof metadata.channel_url).toBe('string');
100 |       }
101 |     });
102 |   });
103 | 
104 |   describe('getVideoMetadataSummary', () => {
105 |     test('should generate human-readable summary', async () => {
106 |       const summary = await getVideoMetadataSummary(testUrl);
107 |       
108 |       expect(typeof summary).toBe('string');
109 |       expect(summary.length).toBeGreaterThan(0);
110 |       
111 |       // 應該包含基本信息
112 |       expect(summary).toMatch(/Title:/);
113 |       
114 |       // 可能包含的其他字段
115 |       const commonFields = ['Channel:', 'Duration:', 'Views:', 'Upload Date:'];
116 |       const hasAtLeastOneField = commonFields.some(field => summary.includes(field));
117 |       expect(hasAtLeastOneField).toBe(true);
118 |     });
119 | 
120 |     test('should handle videos with different metadata availability', async () => {
121 |       const summary = await getVideoMetadataSummary(testUrl);
122 |       
123 |       // 摘要應該是有效的字符串
124 |       expect(typeof summary).toBe('string');
125 |       expect(summary.trim().length).toBeGreaterThan(0);
126 |       
127 |       // 每行應該有意義的格式 (字段: 值) - 但要注意有些標題可能包含特殊字符
128 |       const lines = summary.split('\n').filter(line => line.trim());
129 |       expect(lines.length).toBeGreaterThan(0);
130 |       
131 |       // 至少應該有一行包含冒號(格式為 "字段: 值")
132 |       const hasFormattedLines = lines.some(line => line.includes(':'));
133 |       expect(hasFormattedLines).toBe(true);
134 |     }, 30000);
135 | 
136 |     test('should throw error for invalid URL', async () => {
137 |       await expect(getVideoMetadataSummary('invalid-url')).rejects.toThrow();
138 |     }, 30000);
139 |   });
140 | 
141 |   describe('Error Handling', () => {
142 |     test('should provide helpful error message for unavailable video', async () => {
143 |       const unavailableUrl = 'https://www.youtube.com/watch?v=invalid_video_id_123456789';
144 |       
145 |       await expect(getVideoMetadata(unavailableUrl)).rejects.toThrow(/unavailable|private|not available/i);
146 |     });
147 | 
148 |     test('should handle network errors gracefully', async () => {
149 |       // 使用一個應該引起網路錯誤的 URL
150 |       const badNetworkUrl = 'https://httpstat.us/500';
151 |       
152 |       await expect(getVideoMetadata(badNetworkUrl)).rejects.toThrow();
153 |     });
154 | 
155 |     test('should handle unsupported URLs', async () => {
156 |       const unsupportedUrl = 'https://example.com/not-a-video';
157 |       
158 |       await expect(getVideoMetadata(unsupportedUrl)).rejects.toThrow();
159 |     }, 10000);
160 |   });
161 | 
162 |   describe('Real-world Integration', () => {
163 |     test('should work with different video platforms supported by yt-dlp', async () => {
164 |       // 只測試 YouTube,因為其他平台的可用性可能會變化
165 |       const youtubeUrl = 'https://www.youtube.com/watch?v=jNQXAC9IVRw';
166 |       
167 |       const metadataJson = await getVideoMetadata(youtubeUrl, ['id', 'title', 'extractor']);
168 |       const metadata = JSON.parse(metadataJson);
169 |       
170 |       expect(metadata.extractor).toMatch(/youtube/i);
171 |       expect(metadata.id).toBe('jNQXAC9IVRw');
172 |     });
173 | 
174 |     test('should extract metadata that matches issue #16 requirements', async () => {
175 |       const requiredFields = ['id', 'title', 'description', 'creators', 'timestamp', 'channel', 'channel_id', 'channel_url'];
176 |       const metadataJson = await getVideoMetadata(testUrl, requiredFields);
177 |       const metadata = JSON.parse(metadataJson);
178 |       
179 |       // 驗證至少有基本字段
180 |       expect(metadata).toHaveProperty('id');
181 |       expect(metadata).toHaveProperty('title');
182 |       
183 |       // 記錄實際返回的字段以便調試
184 |       console.log('Available metadata fields for issue #16:', Object.keys(metadata));
185 |       
186 |       // 檢查每個請求的字段是否存在或者有合理的替代
187 |       const availableFields = Object.keys(metadata);
188 |       const hasRequiredBasics = availableFields.includes('id') && availableFields.includes('title');
189 |       expect(hasRequiredBasics).toBe(true);
190 |     });
191 |   });
192 | });
```

--------------------------------------------------------------------------------
/src/modules/subtitle.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import * as fs from "fs";
  2 | import * as path from "path";
  3 | import * as os from "os";
  4 | import type { Config } from '../config.js';
  5 | import { _spawnPromise, validateUrl, cleanSubtitleToTranscript } from "./utils.js";
  6 | 
  7 | /**
  8 |  * Lists all available subtitles for a video.
  9 |  * 
 10 |  * @param url - The URL of the video
 11 |  * @returns Promise resolving to a string containing the list of available subtitles
 12 |  * @throws {Error} When URL is invalid or subtitle listing fails
 13 |  * 
 14 |  * @example
 15 |  * ```typescript
 16 |  * try {
 17 |  *   const subtitles = await listSubtitles('https://youtube.com/watch?v=...');
 18 |  *   console.log('Available subtitles:', subtitles);
 19 |  * } catch (error) {
 20 |  *   console.error('Failed to list subtitles:', error);
 21 |  * }
 22 |  * ```
 23 |  */
 24 | export async function listSubtitles(url: string): Promise<string> {
 25 |   if (!validateUrl(url)) {
 26 |     throw new Error('Invalid or unsupported URL format. Please provide a valid video URL (e.g., https://youtube.com/watch?v=...)');
 27 |   }
 28 | 
 29 |   try {
 30 |     const output = await _spawnPromise('yt-dlp', [
 31 |       '--ignore-config',
 32 |       '--list-subs',
 33 |       '--write-auto-sub',
 34 |       '--skip-download',
 35 |       '--verbose',
 36 |       url
 37 |     ]);
 38 |     return output;
 39 |   } catch (error) {
 40 |     if (error instanceof Error) {
 41 |       if (error.message.includes("Unsupported URL") || error.message.includes("not supported")) {
 42 |         throw new Error(`Unsupported platform or video URL: ${url}. Ensure the URL is from a supported platform like YouTube.`);
 43 |       }
 44 |       if (error.message.includes("Video unavailable") || error.message.includes("private")) {
 45 |         throw new Error(`Video is unavailable or private: ${url}. Check the URL and video privacy settings.`);
 46 |       }
 47 |       if (error.message.includes("network") || error.message.includes("Connection")) {
 48 |         throw new Error("Network error while fetching subtitles. Check your internet connection and retry.");
 49 |       }
 50 |     }
 51 |     throw error;
 52 |   }
 53 | }
 54 | 
 55 | /**
 56 |  * Downloads subtitles for a video in the specified language.
 57 |  * 
 58 |  * @param url - The URL of the video
 59 |  * @param language - Language code (e.g., 'en', 'zh-Hant', 'ja')
 60 |  * @param config - Configuration object
 61 |  * @returns Promise resolving to the subtitle content
 62 |  * @throws {Error} When URL is invalid, language is not available, or download fails
 63 |  * 
 64 |  * @example
 65 |  * ```typescript
 66 |  * try {
 67 |  *   // Download English subtitles
 68 |  *   const enSubs = await downloadSubtitles('https://youtube.com/watch?v=...', 'en', config);
 69 |  *   console.log('English subtitles:', enSubs);
 70 |  * 
 71 |  *   // Download Traditional Chinese subtitles
 72 |  *   const zhSubs = await downloadSubtitles('https://youtube.com/watch?v=...', 'zh-Hant', config);
 73 |  *   console.log('Chinese subtitles:', zhSubs);
 74 |  * } catch (error) {
 75 |  *   if (error.message.includes('No subtitle files found')) {
 76 |  *     console.warn('No subtitles available in the requested language');
 77 |  *   } else {
 78 |  *     console.error('Failed to download subtitles:', error);
 79 |  *   }
 80 |  * }
 81 |  * ```
 82 |  */
 83 | export async function downloadSubtitles(
 84 |   url: string,
 85 |   language: string,
 86 |   config: Config
 87 | ): Promise<string> {
 88 |   if (!validateUrl(url)) {
 89 |     throw new Error('Invalid or unsupported URL format. Please provide a valid video URL (e.g., https://youtube.com/watch?v=...)');
 90 |   }
 91 | 
 92 |   const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), config.file.tempDirPrefix));
 93 | 
 94 |   try {
 95 |     await _spawnPromise('yt-dlp', [
 96 |       '--ignore-config',
 97 |       '--write-sub',
 98 |       '--write-auto-sub',
 99 |       '--sub-lang', language,
100 |       '--skip-download',
101 |       '--output', path.join(tempDir, '%(title)s.%(ext)s'),
102 |       url
103 |     ]);
104 | 
105 |     const subtitleFiles = fs.readdirSync(tempDir)
106 |       .filter(file => file.endsWith('.vtt'));
107 | 
108 |     if (subtitleFiles.length === 0) {
109 |       throw new Error(`No subtitle files found for language '${language}'. Use ytdlp_list_subtitle_languages to check available options.`);
110 |     }
111 | 
112 |     let output = '';
113 |     for (const file of subtitleFiles) {
114 |       output += fs.readFileSync(path.join(tempDir, file), 'utf8');
115 |     }
116 | 
117 |     // Check character limit
118 |     if (output.length > config.limits.characterLimit) {
119 |       output = output.substring(0, config.limits.characterLimit);
120 |       output += "\n\n⚠️ Subtitle content truncated due to size. Consider using ytdlp_download_transcript for plain text.";
121 |     }
122 | 
123 |     return output;
124 |   } catch (error) {
125 |     if (error instanceof Error) {
126 |       if (error.message.includes("Unsupported URL") || error.message.includes("not supported")) {
127 |         throw new Error(`Unsupported platform or video URL: ${url}. Ensure the URL is from a supported platform like YouTube.`);
128 |       }
129 |       if (error.message.includes("Video unavailable") || error.message.includes("private")) {
130 |         throw new Error(`Video is unavailable or private: ${url}. Check the URL and video privacy settings.`);
131 |       }
132 |       if (error.message.includes("network") || error.message.includes("Connection")) {
133 |         throw new Error("Network error while downloading subtitles. Check your internet connection and retry.");
134 |       }
135 |     }
136 |     throw error;
137 |   } finally {
138 |     fs.rmSync(tempDir, { recursive: true, force: true });
139 |   }
140 | }
141 | 
142 | /**
143 |  * Downloads and cleans subtitles to produce a plain text transcript.
144 |  * 
145 |  * @param url - The URL of the video
146 |  * @param language - Language code (e.g., 'en', 'zh-Hant', 'ja')
147 |  * @param config - Configuration object
148 |  * @returns Promise resolving to the cleaned transcript text
149 |  * @throws {Error} When URL is invalid, language is not available, or download fails
150 |  * 
151 |  * @example
152 |  * ```typescript
153 |  * try {
154 |  *   const transcript = await downloadTranscript('https://youtube.com/watch?v=...', 'en', config);
155 |  *   console.log('Transcript:', transcript);
156 |  * } catch (error) {
157 |  *   console.error('Failed to download transcript:', error);
158 |  * }
159 |  * ```
160 |  */
161 | export async function downloadTranscript(
162 |   url: string,
163 |   language: string,
164 |   config: Config
165 | ): Promise<string> {
166 |   if (!validateUrl(url)) {
167 |     throw new Error('Invalid or unsupported URL format. Please provide a valid video URL (e.g., https://youtube.com/watch?v=...)');
168 |   }
169 | 
170 |   const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), config.file.tempDirPrefix));
171 | 
172 |   try {
173 |     await _spawnPromise('yt-dlp', [
174 |       '--ignore-config',
175 |       '--skip-download',
176 |       '--write-subs',
177 |       '--write-auto-subs',
178 |       '--sub-lang', language,
179 |       '--sub-format', 'ttml',
180 |       '--convert-subs', 'srt',
181 |       '--output', path.join(tempDir, 'transcript.%(ext)s'),
182 |       url
183 |     ]);
184 | 
185 |     const srtFiles = fs.readdirSync(tempDir)
186 |       .filter(file => file.endsWith('.srt'));
187 | 
188 |     if (srtFiles.length === 0) {
189 |       throw new Error(`No subtitle files found for transcript generation in language '${language}'. Use ytdlp_list_subtitle_languages to check available options.`);
190 |     }
191 | 
192 |     let transcriptContent = '';
193 |     for (const file of srtFiles) {
194 |       const srtContent = fs.readFileSync(path.join(tempDir, file), 'utf8');
195 |       transcriptContent += cleanSubtitleToTranscript(srtContent) + ' ';
196 |     }
197 | 
198 |     transcriptContent = transcriptContent.trim();
199 | 
200 |     // Transcripts can be larger than standard limit
201 |     if (transcriptContent.length > config.limits.maxTranscriptLength) {
202 |       const truncated = transcriptContent.substring(0, config.limits.maxTranscriptLength);
203 |       transcriptContent = truncated + "\n\n⚠️ Transcript truncated due to length. This is a partial transcript.";
204 |     }
205 | 
206 |     return transcriptContent;
207 |   } catch (error) {
208 |     if (error instanceof Error) {
209 |       if (error.message.includes("Unsupported URL") || error.message.includes("not supported")) {
210 |         throw new Error(`Unsupported platform or video URL: ${url}. Ensure the URL is from a supported platform like YouTube.`);
211 |       }
212 |       if (error.message.includes("Video unavailable") || error.message.includes("private")) {
213 |         throw new Error(`Video is unavailable or private: ${url}. Check the URL and video privacy settings.`);
214 |       }
215 |       if (error.message.includes("network") || error.message.includes("Connection")) {
216 |         throw new Error("Network error while downloading transcript. Check your internet connection and retry.");
217 |       }
218 |     }
219 |     throw error;
220 |   } finally {
221 |     fs.rmSync(tempDir, { recursive: true, force: true });
222 |   }
223 | } 
224 | 
```

--------------------------------------------------------------------------------
/src/modules/metadata.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import type { Config } from "../config.js";
  2 | import {
  3 |   _spawnPromise,
  4 |   validateUrl
  5 | } from "./utils.js";
  6 | 
  7 | /**
  8 |  * Video metadata interface containing all fields that can be extracted
  9 |  */
 10 | export interface VideoMetadata {
 11 |   // Basic video information
 12 |   id?: string;
 13 |   title?: string;
 14 |   fulltitle?: string;
 15 |   description?: string;
 16 |   alt_title?: string;
 17 |   display_id?: string;
 18 |   
 19 |   // Creator/uploader information
 20 |   uploader?: string;
 21 |   uploader_id?: string;
 22 |   uploader_url?: string;
 23 |   creators?: string[];
 24 |   creator?: string;
 25 |   
 26 |   // Channel information
 27 |   channel?: string;
 28 |   channel_id?: string;
 29 |   channel_url?: string;
 30 |   channel_follower_count?: number;
 31 |   channel_is_verified?: boolean;
 32 |   
 33 |   // Timestamps and dates
 34 |   timestamp?: number;
 35 |   upload_date?: string;
 36 |   release_timestamp?: number;
 37 |   release_date?: string;
 38 |   release_year?: number;
 39 |   modified_timestamp?: number;
 40 |   modified_date?: string;
 41 |   
 42 |   // Video properties
 43 |   duration?: number;
 44 |   duration_string?: string;
 45 |   view_count?: number;
 46 |   concurrent_view_count?: number;
 47 |   like_count?: number;
 48 |   dislike_count?: number;
 49 |   repost_count?: number;
 50 |   average_rating?: number;
 51 |   comment_count?: number;
 52 |   age_limit?: number;
 53 |   
 54 |   // Content classification
 55 |   live_status?: string;
 56 |   is_live?: boolean;
 57 |   was_live?: boolean;
 58 |   playable_in_embed?: string;
 59 |   availability?: string;
 60 |   media_type?: string;
 61 |   
 62 |   // Playlist information
 63 |   playlist_id?: string;
 64 |   playlist_title?: string;
 65 |   playlist?: string;
 66 |   playlist_count?: number;
 67 |   playlist_index?: number;
 68 |   playlist_autonumber?: number;
 69 |   playlist_uploader?: string;
 70 |   playlist_uploader_id?: string;
 71 |   playlist_channel?: string;
 72 |   playlist_channel_id?: string;
 73 |   
 74 |   // URLs and technical info
 75 |   webpage_url?: string;
 76 |   webpage_url_domain?: string;
 77 |   webpage_url_basename?: string;
 78 |   original_url?: string;
 79 |   filename?: string;
 80 |   ext?: string;
 81 |   
 82 |   // Content metadata
 83 |   categories?: string[];
 84 |   tags?: string[];
 85 |   cast?: string[];
 86 |   location?: string;
 87 |   license?: string;
 88 |   
 89 |   // Series/episode information
 90 |   series?: string;
 91 |   series_id?: string;
 92 |   season?: string;
 93 |   season_number?: number;
 94 |   season_id?: string;
 95 |   episode?: string;
 96 |   episode_number?: number;
 97 |   episode_id?: string;
 98 |   
 99 |   // Music/track information
100 |   track?: string;
101 |   track_number?: number;
102 |   track_id?: string;
103 |   artists?: string[];
104 |   artist?: string;
105 |   genres?: string[];
106 |   genre?: string;
107 |   composers?: string[];
108 |   composer?: string;
109 |   album?: string;
110 |   album_type?: string;
111 |   album_artists?: string[];
112 |   album_artist?: string;
113 |   disc_number?: number;
114 |   
115 |   // Technical metadata
116 |   extractor?: string;
117 |   epoch?: number;
118 |   
119 |   // Additional fields that might be present
120 |   [key: string]: unknown;
121 | }
122 | 
123 | /**
124 |  * Extract video metadata without downloading the actual video content.
125 |  * Uses yt-dlp's --dump-json flag to get comprehensive metadata.
126 |  *
127 |  * @param url - The URL of the video to extract metadata from
128 |  * @param fields - Optional array of specific fields to extract. If not provided, returns all available metadata
129 |  * @param config - Configuration object (currently unused but kept for consistency)
130 |  * @returns Promise resolving to formatted metadata string or JSON object
131 |  * @throws {Error} When URL is invalid or metadata extraction fails
132 |  *
133 |  * @example
134 |  * ```typescript
135 |  * // Get all metadata
136 |  * const metadata = await getVideoMetadata('https://youtube.com/watch?v=...');
137 |  * console.log(metadata);
138 |  *
139 |  * // Get specific fields only
140 |  * const specificData = await getVideoMetadata(
141 |  *   'https://youtube.com/watch?v=...',
142 |  *   ['id', 'title', 'description', 'channel']
143 |  * );
144 |  * console.log(specificData);
145 |  * ```
146 |  */
147 | export async function getVideoMetadata(
148 |   url: string,
149 |   fields?: string[],
150 |   _config?: Config
151 | ): Promise<string> {
152 |   // Validate the URL
153 |   validateUrl(url);
154 | 
155 |   const args = [
156 |     "--dump-json",
157 |     "--no-warnings",
158 |     "--no-check-certificate",
159 |     url
160 |   ];
161 | 
162 |   try {
163 |     // Execute yt-dlp to get metadata
164 |     const output = await _spawnPromise("yt-dlp", args);
165 | 
166 |     // Parse the JSON output
167 |     const metadata: VideoMetadata = JSON.parse(output);
168 | 
169 |     // If specific fields are requested, filter the metadata
170 |     if (fields !== undefined && fields.length >= 0) {
171 |       const filteredMetadata: Partial<VideoMetadata> & { _truncated?: boolean; _message?: string } = {};
172 | 
173 |       for (const field of fields) {
174 |         if (metadata.hasOwnProperty(field)) {
175 |           filteredMetadata[field as keyof VideoMetadata] = metadata[field as keyof VideoMetadata];
176 |         }
177 |       }
178 | 
179 |       let result = JSON.stringify(filteredMetadata, null, 2);
180 | 
181 |       // Check character limit
182 |       if (_config && result.length > _config.limits.characterLimit) {
183 |         // Add truncation info inside JSON before truncating
184 |         filteredMetadata._truncated = true;
185 |         filteredMetadata._message = "Response truncated. Specify fewer fields to see complete data.";
186 |         result = JSON.stringify(filteredMetadata, null, 2);
187 | 
188 |         // If still too long, truncate the string content
189 |         if (result.length > _config.limits.characterLimit) {
190 |           result = result.substring(0, _config.limits.characterLimit) + '\n... }';
191 |         }
192 |       }
193 | 
194 |       return result;
195 |     }
196 | 
197 |     // Return formatted JSON string with all metadata
198 |     let result = JSON.stringify(metadata, null, 2);
199 | 
200 |     // Check character limit for full metadata
201 |     if (_config && result.length > _config.limits.characterLimit) {
202 |       // Try to return essential fields only
203 |       const essentialFields = ['id', 'title', 'description', 'channel', 'channel_id', 'uploader',
204 |                                'duration', 'duration_string', 'view_count', 'like_count',
205 |                                'upload_date', 'tags', 'categories', 'webpage_url'];
206 |       const essentialMetadata: Partial<VideoMetadata> & { _truncated?: boolean; _message?: string } = {};
207 | 
208 |       for (const field of essentialFields) {
209 |         if (metadata.hasOwnProperty(field)) {
210 |           essentialMetadata[field as keyof VideoMetadata] = metadata[field as keyof VideoMetadata];
211 |         }
212 |       }
213 | 
214 |       // Add truncation info inside the JSON object
215 |       essentialMetadata._truncated = true;
216 |       essentialMetadata._message = 'Full metadata truncated to essential fields. Use the "fields" parameter to request specific fields.';
217 | 
218 |       result = JSON.stringify(essentialMetadata, null, 2);
219 |     }
220 | 
221 |     return result;
222 | 
223 |   } catch (error) {
224 |     if (error instanceof Error) {
225 |       // Handle common yt-dlp errors with actionable messages
226 |       if (error.message.includes("Video unavailable") || error.message.includes("private")) {
227 |         throw new Error(`Video is unavailable or private: ${url}. Check the URL and video privacy settings.`);
228 |       } else if (error.message.includes("Unsupported URL") || error.message.includes("extractor")) {
229 |         throw new Error(`Unsupported platform or video URL: ${url}. Ensure the URL is from a supported platform like YouTube.`);
230 |       } else if (error.message.includes("network") || error.message.includes("Connection")) {
231 |         throw new Error("Network error while extracting metadata. Check your internet connection and retry.");
232 |       } else {
233 |         throw new Error(`Failed to extract video metadata: ${error.message}. Verify the URL is correct.`);
234 |       }
235 |     }
236 |     throw new Error(`Failed to extract video metadata from ${url}`);
237 |   }
238 | }
239 | 
240 | /**
241 |  * Get a human-readable summary of key video metadata fields.
242 |  * This is useful for quick overview without overwhelming JSON output.
243 |  *
244 |  * @param url - The URL of the video to extract metadata from
245 |  * @param config - Configuration object (currently unused but kept for consistency)
246 |  * @returns Promise resolving to a formatted summary string
247 |  * @throws {Error} When URL is invalid or metadata extraction fails
248 |  *
249 |  * @example
250 |  * ```typescript
251 |  * const summary = await getVideoMetadataSummary('https://youtube.com/watch?v=...');
252 |  * console.log(summary);
253 |  * // Output:
254 |  * // Title: Example Video Title
255 |  * // Channel: Example Channel
256 |  * // Duration: 10:30
257 |  * // Views: 1,234,567
258 |  * // Upload Date: 2023-12-01
259 |  * // Description: This is an example video...
260 |  * ```
261 |  */
262 | export async function getVideoMetadataSummary(
263 |   url: string,
264 |   _config?: Config
265 | ): Promise<string> {
266 |   try {
267 |     // Get the full metadata first
268 |     const metadataJson = await getVideoMetadata(url, undefined, _config);
269 |     const metadata: VideoMetadata = JSON.parse(metadataJson);
270 | 
271 |     // Format key fields into a readable summary
272 |     const lines: string[] = [];
273 |   
274 |   if (metadata.title) {
275 |     lines.push(`Title: ${metadata.title}`);
276 |   }
277 |   
278 |   if (metadata.channel) {
279 |     lines.push(`Channel: ${metadata.channel}`);
280 |   }
281 |   
282 |   if (metadata.uploader && metadata.uploader !== metadata.channel) {
283 |     lines.push(`Uploader: ${metadata.uploader}`);
284 |   }
285 |   
286 |   if (metadata.duration_string) {
287 |     lines.push(`Duration: ${metadata.duration_string}`);
288 |   } else if (metadata.duration) {
289 |     const hours = Math.floor(metadata.duration / 3600);
290 |     const minutes = Math.floor((metadata.duration % 3600) / 60);
291 |     const seconds = metadata.duration % 60;
292 |     const durationStr = hours > 0 
293 |       ? `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
294 |       : `${minutes}:${seconds.toString().padStart(2, '0')}`;
295 |     lines.push(`Duration: ${durationStr}`);
296 |   }
297 |   
298 |   if (metadata.view_count !== undefined) {
299 |     lines.push(`Views: ${metadata.view_count.toLocaleString()}`);
300 |   }
301 |   
302 |   if (metadata.like_count !== undefined) {
303 |     lines.push(`Likes: ${metadata.like_count.toLocaleString()}`);
304 |   }
305 |   
306 |   if (metadata.upload_date) {
307 |     // Format YYYYMMDD to YYYY-MM-DD
308 |     const dateStr = metadata.upload_date;
309 |     if (dateStr.length === 8) {
310 |       const formatted = `${dateStr.substring(0, 4)}-${dateStr.substring(4, 6)}-${dateStr.substring(6, 8)}`;
311 |       lines.push(`Upload Date: ${formatted}`);
312 |     } else {
313 |       lines.push(`Upload Date: ${dateStr}`);
314 |     }
315 |   }
316 |   
317 |   if (metadata.live_status && metadata.live_status !== 'not_live') {
318 |     lines.push(`Status: ${metadata.live_status.replace('_', ' ')}`);
319 |   }
320 |   
321 |   if (metadata.tags && metadata.tags.length > 0) {
322 |     lines.push(`Tags: ${metadata.tags.slice(0, 5).join(', ')}${metadata.tags.length > 5 ? '...' : ''}`);
323 |   }
324 |   
325 |   if (metadata.description) {
326 |     // Truncate description to first 200 characters
327 |     const desc = metadata.description.length > 200
328 |       ? metadata.description.substring(0, 200) + '...'
329 |       : metadata.description;
330 |     lines.push(`Description: ${desc}`);
331 |   }
332 | 
333 |   return lines.join('\n');
334 |   } catch (error) {
335 |     // Re-throw errors from getVideoMetadata with context
336 |     if (error instanceof Error) {
337 |       throw error;
338 |     }
339 |     throw new Error(`Failed to generate metadata summary for ${url}`);
340 |   }
341 | }
```

--------------------------------------------------------------------------------
/.claude/skills/mcp-builder/LICENSE.txt:
--------------------------------------------------------------------------------

```
  1 | 
  2 |                                  Apache License
  3 |                            Version 2.0, January 2004
  4 |                         http://www.apache.org/licenses/
  5 | 
  6 |    TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
  7 | 
  8 |    1. Definitions.
  9 | 
 10 |       "License" shall mean the terms and conditions for use, reproduction,
 11 |       and distribution as defined by Sections 1 through 9 of this document.
 12 | 
 13 |       "Licensor" shall mean the copyright owner or entity authorized by
 14 |       the copyright owner that is granting the License.
 15 | 
 16 |       "Legal Entity" shall mean the union of the acting entity and all
 17 |       other entities that control, are controlled by, or are under common
 18 |       control with that entity. For the purposes of this definition,
 19 |       "control" means (i) the power, direct or indirect, to cause the
 20 |       direction or management of such entity, whether by contract or
 21 |       otherwise, or (ii) ownership of fifty percent (50%) or more of the
 22 |       outstanding shares, or (iii) beneficial ownership of such entity.
 23 | 
 24 |       "You" (or "Your") shall mean an individual or Legal Entity
 25 |       exercising permissions granted by this License.
 26 | 
 27 |       "Source" form shall mean the preferred form for making modifications,
 28 |       including but not limited to software source code, documentation
 29 |       source, and configuration files.
 30 | 
 31 |       "Object" form shall mean any form resulting from mechanical
 32 |       transformation or translation of a Source form, including but
 33 |       not limited to compiled object code, generated documentation,
 34 |       and conversions to other media types.
 35 | 
 36 |       "Work" shall mean the work of authorship, whether in Source or
 37 |       Object form, made available under the License, as indicated by a
 38 |       copyright notice that is included in or attached to the work
 39 |       (an example is provided in the Appendix below).
 40 | 
 41 |       "Derivative Works" shall mean any work, whether in Source or Object
 42 |       form, that is based on (or derived from) the Work and for which the
 43 |       editorial revisions, annotations, elaborations, or other modifications
 44 |       represent, as a whole, an original work of authorship. For the purposes
 45 |       of this License, Derivative Works shall not include works that remain
 46 |       separable from, or merely link (or bind by name) to the interfaces of,
 47 |       the Work and Derivative Works thereof.
 48 | 
 49 |       "Contribution" shall mean any work of authorship, including
 50 |       the original version of the Work and any modifications or additions
 51 |       to that Work or Derivative Works thereof, that is intentionally
 52 |       submitted to Licensor for inclusion in the Work by the copyright owner
 53 |       or by an individual or Legal Entity authorized to submit on behalf of
 54 |       the copyright owner. For the purposes of this definition, "submitted"
 55 |       means any form of electronic, verbal, or written communication sent
 56 |       to the Licensor or its representatives, including but not limited to
 57 |       communication on electronic mailing lists, source code control systems,
 58 |       and issue tracking systems that are managed by, or on behalf of, the
 59 |       Licensor for the purpose of discussing and improving the Work, but
 60 |       excluding communication that is conspicuously marked or otherwise
 61 |       designated in writing by the copyright owner as "Not a Contribution."
 62 | 
 63 |       "Contributor" shall mean Licensor and any individual or Legal Entity
 64 |       on behalf of whom a Contribution has been received by Licensor and
 65 |       subsequently incorporated within the Work.
 66 | 
 67 |    2. Grant of Copyright License. Subject to the terms and conditions of
 68 |       this License, each Contributor hereby grants to You a perpetual,
 69 |       worldwide, non-exclusive, no-charge, royalty-free, irrevocable
 70 |       copyright license to reproduce, prepare Derivative Works of,
 71 |       publicly display, publicly perform, sublicense, and distribute the
 72 |       Work and such Derivative Works in Source or Object form.
 73 | 
 74 |    3. Grant of Patent License. Subject to the terms and conditions of
 75 |       this License, each Contributor hereby grants to You a perpetual,
 76 |       worldwide, non-exclusive, no-charge, royalty-free, irrevocable
 77 |       (except as stated in this section) patent license to make, have made,
 78 |       use, offer to sell, sell, import, and otherwise transfer the Work,
 79 |       where such license applies only to those patent claims licensable
 80 |       by such Contributor that are necessarily infringed by their
 81 |       Contribution(s) alone or by combination of their Contribution(s)
 82 |       with the Work to which such Contribution(s) was submitted. If You
 83 |       institute patent litigation against any entity (including a
 84 |       cross-claim or counterclaim in a lawsuit) alleging that the Work
 85 |       or a Contribution incorporated within the Work constitutes direct
 86 |       or contributory patent infringement, then any patent licenses
 87 |       granted to You under this License for that Work shall terminate
 88 |       as of the date such litigation is filed.
 89 | 
 90 |    4. Redistribution. You may reproduce and distribute copies of the
 91 |       Work or Derivative Works thereof in any medium, with or without
 92 |       modifications, and in Source or Object form, provided that You
 93 |       meet the following conditions:
 94 | 
 95 |       (a) You must give any other recipients of the Work or
 96 |           Derivative Works a copy of this License; and
 97 | 
 98 |       (b) You must cause any modified files to carry prominent notices
 99 |           stating that You changed the files; and
100 | 
101 |       (c) You must retain, in the Source form of any Derivative Works
102 |           that You distribute, all copyright, patent, trademark, and
103 |           attribution notices from the Source form of the Work,
104 |           excluding those notices that do not pertain to any part of
105 |           the Derivative Works; and
106 | 
107 |       (d) If the Work includes a "NOTICE" text file as part of its
108 |           distribution, then any Derivative Works that You distribute must
109 |           include a readable copy of the attribution notices contained
110 |           within such NOTICE file, excluding those notices that do not
111 |           pertain to any part of the Derivative Works, in at least one
112 |           of the following places: within a NOTICE text file distributed
113 |           as part of the Derivative Works; within the Source form or
114 |           documentation, if provided along with the Derivative Works; or,
115 |           within a display generated by the Derivative Works, if and
116 |           wherever such third-party notices normally appear. The contents
117 |           of the NOTICE file are for informational purposes only and
118 |           do not modify the License. You may add Your own attribution
119 |           notices within Derivative Works that You distribute, alongside
120 |           or as an addendum to the NOTICE text from the Work, provided
121 |           that such additional attribution notices cannot be construed
122 |           as modifying the License.
123 | 
124 |       You may add Your own copyright statement to Your modifications and
125 |       may provide additional or different license terms and conditions
126 |       for use, reproduction, or distribution of Your modifications, or
127 |       for any such Derivative Works as a whole, provided Your use,
128 |       reproduction, and distribution of the Work otherwise complies with
129 |       the conditions stated in this License.
130 | 
131 |    5. Submission of Contributions. Unless You explicitly state otherwise,
132 |       any Contribution intentionally submitted for inclusion in the Work
133 |       by You to the Licensor shall be under the terms and conditions of
134 |       this License, without any additional terms or conditions.
135 |       Notwithstanding the above, nothing herein shall supersede or modify
136 |       the terms of any separate license agreement you may have executed
137 |       with Licensor regarding such Contributions.
138 | 
139 |    6. Trademarks. This License does not grant permission to use the trade
140 |       names, trademarks, service marks, or product names of the Licensor,
141 |       except as required for reasonable and customary use in describing the
142 |       origin of the Work and reproducing the content of the NOTICE file.
143 | 
144 |    7. Disclaimer of Warranty. Unless required by applicable law or
145 |       agreed to in writing, Licensor provides the Work (and each
146 |       Contributor provides its Contributions) on an "AS IS" BASIS,
147 |       WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148 |       implied, including, without limitation, any warranties or conditions
149 |       of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150 |       PARTICULAR PURPOSE. You are solely responsible for determining the
151 |       appropriateness of using or redistributing the Work and assume any
152 |       risks associated with Your exercise of permissions under this License.
153 | 
154 |    8. Limitation of Liability. In no event and under no legal theory,
155 |       whether in tort (including negligence), contract, or otherwise,
156 |       unless required by applicable law (such as deliberate and grossly
157 |       negligent acts) or agreed to in writing, shall any Contributor be
158 |       liable to You for damages, including any direct, indirect, special,
159 |       incidental, or consequential damages of any character arising as a
160 |       result of this License or out of the use or inability to use the
161 |       Work (including but not limited to damages for loss of goodwill,
162 |       work stoppage, computer failure or malfunction, or any and all
163 |       other commercial damages or losses), even if such Contributor
164 |       has been advised of the possibility of such damages.
165 | 
166 |    9. Accepting Warranty or Additional Liability. While redistributing
167 |       the Work or Derivative Works thereof, You may choose to offer,
168 |       and charge a fee for, acceptance of support, warranty, indemnity,
169 |       or other liability obligations and/or rights consistent with this
170 |       License. However, in accepting such obligations, You may act only
171 |       on Your own behalf and on Your sole responsibility, not on behalf
172 |       of any other Contributor, and only if You agree to indemnify,
173 |       defend, and hold each Contributor harmless for any liability
174 |       incurred by, or claims asserted against, such Contributor by reason
175 |       of your accepting any such warranty or additional liability.
176 | 
177 |    END OF TERMS AND CONDITIONS
178 | 
179 |    APPENDIX: How to apply the Apache License to your work.
180 | 
181 |       To apply the Apache License to your work, attach the following
182 |       boilerplate notice, with the fields enclosed by brackets "[]"
183 |       replaced with your own identifying information. (Don't include
184 |       the brackets!)  The text should be enclosed in the appropriate
185 |       comment syntax for the file format. We also recommend that a
186 |       file or class name and description of purpose be included on the
187 |       same "printed page" as the copyright notice for easier
188 |       identification within third-party archives.
189 | 
190 |    Copyright [yyyy] [name of copyright owner]
191 | 
192 |    Licensed under the Apache License, Version 2.0 (the "License");
193 |    you may not use this file except in compliance with the License.
194 |    You may obtain a copy of the License at
195 | 
196 |        http://www.apache.org/licenses/LICENSE-2.0
197 | 
198 |    Unless required by applicable law or agreed to in writing, software
199 |    distributed under the License is distributed on an "AS IS" BASIS,
200 |    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201 |    See the License for the specific language governing permissions and
202 |    limitations under the License.
```
Page 1/2FirstPrevNextLast