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 | [](https://www.npmjs.com/package/@kevinwatt/yt-dlp-mcp)
8 | [](https://opensource.org/licenses/MIT)
9 | [](https://nodejs.org/)
10 | [](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.
```