mirror of
				https://github.com/kevinwatt/yt-dlp-mcp.git
				synced 2025-08-10 16:09:14 +03:00 
			
		
		
		
	Compare commits
	
		
			18 Commits
		
	
	
		
			v0.6.20
			...
			revert-8-r
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					f27d22eb81 | ||
| 
						 | 
					0de9308a41 | ||
| 
						 | 
					c79766c241 | ||
| 
						 | 
					4171abc6d0 | ||
| 
						 | 
					7900a9b4e1 | ||
| 
						 | 
					944b0211c6 | ||
| 
						 | 
					c39fd8785c | ||
| 
						 | 
					e9a0e55762 | ||
| 
						 | 
					21689391bd | ||
| 
						 | 
					5152ad4d17 | ||
| 
						 | 
					c4dcc0eda2 | ||
| 
						 | 
					12fa5dbffe | ||
| 
						 | 
					b3e8ed5f58 | ||
| 
						 | 
					576549bc2c | ||
| 
						 | 
					9c25179fab | ||
| 
						 | 
					7710184faf | ||
| 
						 | 
					adf1b7178c | ||
| 
						 | 
					58384bb1a2 | 
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							@@ -28,7 +28,6 @@ node_modules
 | 
			
		||||
 | 
			
		||||
lib
 | 
			
		||||
test-dist
 | 
			
		||||
docs
 | 
			
		||||
 | 
			
		||||
# WebStorm
 | 
			
		||||
.idea/
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										35
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										35
									
								
								README.md
									
									
									
									
									
								
							@@ -1,11 +1,12 @@
 | 
			
		||||
# yt-dlp-mcp
 | 
			
		||||
 | 
			
		||||
An MCP server implementation that integrates with yt-dlp, providing video content download capabilities (e.g. YouTube, Facebook, etc.) for LLMs.
 | 
			
		||||
An MCP server implementation that integrates with yt-dlp, providing video and audio content download capabilities (e.g. YouTube, Facebook, Tiktok, etc.) for LLMs.
 | 
			
		||||
 | 
			
		||||
## Features
 | 
			
		||||
 | 
			
		||||
* **Subtitles**: Download subtitles in SRT format for LLMs to read
 | 
			
		||||
* **Video Download**: Save videos to your Downloads folder with resolution control
 | 
			
		||||
* **Audio Download**: Save audios to your Downloads folder
 | 
			
		||||
* **Privacy-Focused**: Direct download without tracking
 | 
			
		||||
* **MCP Integration**: Works with Dive and other MCP-compatible LLMs
 | 
			
		||||
 | 
			
		||||
@@ -48,13 +49,13 @@ pip install yt-dlp
 | 
			
		||||
 | 
			
		||||
## Tool Documentation
 | 
			
		||||
 | 
			
		||||
* **list_video_subtitles**
 | 
			
		||||
  * List all available subtitles for a video
 | 
			
		||||
* **list_subtitle_languages**
 | 
			
		||||
  * List all available subtitle languages and their formats for a video (including auto-generated captions)
 | 
			
		||||
  * Inputs:
 | 
			
		||||
    * `url` (string, required): URL of the video
 | 
			
		||||
 | 
			
		||||
* **download_video_srt**
 | 
			
		||||
  * Download subtitles in SRT format
 | 
			
		||||
* **download_video_subtitles**
 | 
			
		||||
  * Download video subtitles in any available format. Supports both regular and auto-generated subtitles
 | 
			
		||||
  * Inputs:
 | 
			
		||||
    * `url` (string, required): URL of the video
 | 
			
		||||
    * `language` (string, optional): Language code (e.g., 'en', 'zh-Hant', 'ja'). Defaults to 'en'
 | 
			
		||||
@@ -65,6 +66,17 @@ pip install yt-dlp
 | 
			
		||||
    * `url` (string, required): URL of the video
 | 
			
		||||
    * `resolution` (string, optional): Video resolution ('480p', '720p', '1080p', 'best'). Defaults to '720p'
 | 
			
		||||
 | 
			
		||||
* **download_audio**
 | 
			
		||||
  * Download audio in best available quality (usually m4a/mp3 format) to user's Downloads folder
 | 
			
		||||
  * Inputs:
 | 
			
		||||
    * `url` (string, required): URL of the video
 | 
			
		||||
 | 
			
		||||
* **download_transcript**
 | 
			
		||||
  * Download and clean video subtitles to produce a plain text transcript without timestamps or formatting
 | 
			
		||||
  * Inputs:
 | 
			
		||||
    * `url` (string, required): URL of the video
 | 
			
		||||
    * `language` (string, optional): Language code (e.g., 'en', 'zh-Hant', 'ja'). Defaults to 'en'
 | 
			
		||||
 | 
			
		||||
## Usage Examples
 | 
			
		||||
 | 
			
		||||
Ask your LLM to:
 | 
			
		||||
@@ -73,6 +85,9 @@ Ask your LLM to:
 | 
			
		||||
"Download a video from facebook: https://facebook.com/..."
 | 
			
		||||
"Download Chinese subtitles from this video: https://youtube.com/watch?v=..."
 | 
			
		||||
"Download this video in 1080p: https://youtube.com/watch?v=..."
 | 
			
		||||
"Download audio from this YouTube video: https://youtube.com/watch?v=..."
 | 
			
		||||
"Get a clean transcript of this video: https://youtube.com/watch?v=..."
 | 
			
		||||
"Download Spanish transcript from this video: https://youtube.com/watch?v=..."
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
## Manual Start
 | 
			
		||||
@@ -88,6 +103,15 @@ npx @kevinwatt/yt-dlp-mcp
 | 
			
		||||
* `yt-dlp` in system PATH
 | 
			
		||||
* MCP-compatible LLM service
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
## Documentation
 | 
			
		||||
 | 
			
		||||
- [API Reference](./docs/api.md)
 | 
			
		||||
- [Configuration](./docs/configuration.md)
 | 
			
		||||
- [Error Handling](./docs/error-handling.md)
 | 
			
		||||
- [Contributing](./docs/contributing.md)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
## License
 | 
			
		||||
 | 
			
		||||
MIT
 | 
			
		||||
@@ -96,3 +120,4 @@ MIT
 | 
			
		||||
 | 
			
		||||
Dewei Yen
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										125
									
								
								docs/api.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										125
									
								
								docs/api.md
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,125 @@
 | 
			
		||||
# API Reference
 | 
			
		||||
 | 
			
		||||
## Video Operations
 | 
			
		||||
 | 
			
		||||
### downloadVideo(url: string, config?: Config, resolution?: string): Promise<string>
 | 
			
		||||
 | 
			
		||||
Downloads a video from the specified URL.
 | 
			
		||||
 | 
			
		||||
**Parameters:**
 | 
			
		||||
- `url`: The URL of the video to download
 | 
			
		||||
- `config`: (Optional) Configuration object
 | 
			
		||||
- `resolution`: (Optional) Preferred video resolution ('480p', '720p', '1080p', 'best')
 | 
			
		||||
 | 
			
		||||
**Returns:**
 | 
			
		||||
- Promise resolving to a success message with the downloaded file path
 | 
			
		||||
 | 
			
		||||
**Example:**
 | 
			
		||||
```javascript
 | 
			
		||||
import { downloadVideo } from '@kevinwatt/yt-dlp-mcp';
 | 
			
		||||
 | 
			
		||||
// Download with default settings
 | 
			
		||||
const result = await downloadVideo('https://www.youtube.com/watch?v=jNQXAC9IVRw');
 | 
			
		||||
console.log(result);
 | 
			
		||||
 | 
			
		||||
// Download with specific resolution
 | 
			
		||||
const hdResult = await downloadVideo(
 | 
			
		||||
  'https://www.youtube.com/watch?v=jNQXAC9IVRw',
 | 
			
		||||
  undefined,
 | 
			
		||||
  '1080p'
 | 
			
		||||
);
 | 
			
		||||
console.log(hdResult);
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
## Audio Operations
 | 
			
		||||
 | 
			
		||||
### downloadAudio(url: string, config?: Config): Promise<string>
 | 
			
		||||
 | 
			
		||||
Downloads audio from the specified URL in the best available quality.
 | 
			
		||||
 | 
			
		||||
**Parameters:**
 | 
			
		||||
- `url`: The URL of the video to extract audio from
 | 
			
		||||
- `config`: (Optional) Configuration object
 | 
			
		||||
 | 
			
		||||
**Returns:**
 | 
			
		||||
- Promise resolving to a success message with the downloaded file path
 | 
			
		||||
 | 
			
		||||
**Example:**
 | 
			
		||||
```javascript
 | 
			
		||||
import { downloadAudio } from '@kevinwatt/yt-dlp-mcp';
 | 
			
		||||
 | 
			
		||||
const result = await downloadAudio('https://www.youtube.com/watch?v=jNQXAC9IVRw');
 | 
			
		||||
console.log(result);
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
## Subtitle Operations
 | 
			
		||||
 | 
			
		||||
### listSubtitles(url: string): Promise<string>
 | 
			
		||||
 | 
			
		||||
Lists all available subtitles for a video.
 | 
			
		||||
 | 
			
		||||
**Parameters:**
 | 
			
		||||
- `url`: The URL of the video
 | 
			
		||||
 | 
			
		||||
**Returns:**
 | 
			
		||||
- Promise resolving to a string containing the list of available subtitles
 | 
			
		||||
 | 
			
		||||
**Example:**
 | 
			
		||||
```javascript
 | 
			
		||||
import { listSubtitles } from '@kevinwatt/yt-dlp-mcp';
 | 
			
		||||
 | 
			
		||||
const subtitles = await listSubtitles('https://www.youtube.com/watch?v=jNQXAC9IVRw');
 | 
			
		||||
console.log(subtitles);
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### downloadSubtitles(url: string, language: string): Promise<string>
 | 
			
		||||
 | 
			
		||||
Downloads subtitles for a video in the specified language.
 | 
			
		||||
 | 
			
		||||
**Parameters:**
 | 
			
		||||
- `url`: The URL of the video
 | 
			
		||||
- `language`: Language code (e.g., 'en', 'zh-Hant', 'ja')
 | 
			
		||||
 | 
			
		||||
**Returns:**
 | 
			
		||||
- Promise resolving to the subtitle content
 | 
			
		||||
 | 
			
		||||
**Example:**
 | 
			
		||||
```javascript
 | 
			
		||||
import { downloadSubtitles } from '@kevinwatt/yt-dlp-mcp';
 | 
			
		||||
 | 
			
		||||
const subtitles = await downloadSubtitles(
 | 
			
		||||
  'https://www.youtube.com/watch?v=jNQXAC9IVRw',
 | 
			
		||||
  'en'
 | 
			
		||||
);
 | 
			
		||||
console.log(subtitles);
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
## Configuration
 | 
			
		||||
 | 
			
		||||
### Config Interface
 | 
			
		||||
 | 
			
		||||
```typescript
 | 
			
		||||
interface Config {
 | 
			
		||||
  file: {
 | 
			
		||||
    maxFilenameLength: number;
 | 
			
		||||
    downloadsDir: string;
 | 
			
		||||
    tempDirPrefix: string;
 | 
			
		||||
    sanitize: {
 | 
			
		||||
      replaceChar: string;
 | 
			
		||||
      truncateSuffix: string;
 | 
			
		||||
      illegalChars: RegExp;
 | 
			
		||||
      reservedNames: readonly string[];
 | 
			
		||||
    };
 | 
			
		||||
  };
 | 
			
		||||
  tools: {
 | 
			
		||||
    required: readonly string[];
 | 
			
		||||
  };
 | 
			
		||||
  download: {
 | 
			
		||||
    defaultResolution: "480p" | "720p" | "1080p" | "best";
 | 
			
		||||
    defaultAudioFormat: "m4a" | "mp3";
 | 
			
		||||
    defaultSubtitleLanguage: string;
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
For detailed configuration options, see [Configuration Guide](./configuration.md). 
 | 
			
		||||
							
								
								
									
										169
									
								
								docs/configuration.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										169
									
								
								docs/configuration.md
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,169 @@
 | 
			
		||||
# Configuration Guide
 | 
			
		||||
 | 
			
		||||
## Overview
 | 
			
		||||
 | 
			
		||||
The yt-dlp-mcp package can be configured through environment variables or by passing a configuration object to the functions.
 | 
			
		||||
 | 
			
		||||
## Configuration Object
 | 
			
		||||
 | 
			
		||||
```typescript
 | 
			
		||||
interface Config {
 | 
			
		||||
  file: {
 | 
			
		||||
    maxFilenameLength: number;
 | 
			
		||||
    downloadsDir: string;
 | 
			
		||||
    tempDirPrefix: string;
 | 
			
		||||
    sanitize: {
 | 
			
		||||
      replaceChar: string;
 | 
			
		||||
      truncateSuffix: string;
 | 
			
		||||
      illegalChars: RegExp;
 | 
			
		||||
      reservedNames: readonly string[];
 | 
			
		||||
    };
 | 
			
		||||
  };
 | 
			
		||||
  tools: {
 | 
			
		||||
    required: readonly string[];
 | 
			
		||||
  };
 | 
			
		||||
  download: {
 | 
			
		||||
    defaultResolution: "480p" | "720p" | "1080p" | "best";
 | 
			
		||||
    defaultAudioFormat: "m4a" | "mp3";
 | 
			
		||||
    defaultSubtitleLanguage: string;
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
## Environment Variables
 | 
			
		||||
 | 
			
		||||
| Variable | Description | Default |
 | 
			
		||||
|----------|-------------|---------|
 | 
			
		||||
| `YTDLP_MAX_FILENAME_LENGTH` | Maximum length for filenames | 50 |
 | 
			
		||||
| `YTDLP_DOWNLOADS_DIR` | Download directory path | `~/Downloads` |
 | 
			
		||||
| `YTDLP_TEMP_DIR_PREFIX` | Prefix for temporary directories | `ytdlp-` |
 | 
			
		||||
| `YTDLP_SANITIZE_REPLACE_CHAR` | Character to replace illegal characters | `_` |
 | 
			
		||||
| `YTDLP_SANITIZE_TRUNCATE_SUFFIX` | Suffix for truncated filenames | `...` |
 | 
			
		||||
| `YTDLP_SANITIZE_ILLEGAL_CHARS` | Regex pattern for illegal characters | `/[<>:"/\\|?*\x00-\x1F]/g` |
 | 
			
		||||
| `YTDLP_SANITIZE_RESERVED_NAMES` | Comma-separated list of reserved names | `CON,PRN,AUX,...` |
 | 
			
		||||
| `YTDLP_DEFAULT_RESOLUTION` | Default video resolution | `720p` |
 | 
			
		||||
| `YTDLP_DEFAULT_AUDIO_FORMAT` | Default audio format | `m4a` |
 | 
			
		||||
| `YTDLP_DEFAULT_SUBTITLE_LANG` | Default subtitle language | `en` |
 | 
			
		||||
 | 
			
		||||
## File Configuration
 | 
			
		||||
 | 
			
		||||
### Download Directory
 | 
			
		||||
 | 
			
		||||
The download directory can be configured in two ways:
 | 
			
		||||
 | 
			
		||||
1. Environment variable:
 | 
			
		||||
```bash
 | 
			
		||||
export YTDLP_DOWNLOADS_DIR="/path/to/downloads"
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
2. Configuration object:
 | 
			
		||||
```javascript
 | 
			
		||||
const config = {
 | 
			
		||||
  file: {
 | 
			
		||||
    downloadsDir: "/path/to/downloads"
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### Filename Sanitization
 | 
			
		||||
 | 
			
		||||
Control how filenames are sanitized:
 | 
			
		||||
 | 
			
		||||
```javascript
 | 
			
		||||
const config = {
 | 
			
		||||
  file: {
 | 
			
		||||
    maxFilenameLength: 100,
 | 
			
		||||
    sanitize: {
 | 
			
		||||
      replaceChar: '-',
 | 
			
		||||
      truncateSuffix: '___',
 | 
			
		||||
      illegalChars: /[<>:"/\\|?*\x00-\x1F]/g,
 | 
			
		||||
      reservedNames: ['CON', 'PRN', 'AUX', 'NUL']
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
## Download Configuration
 | 
			
		||||
 | 
			
		||||
### Video Resolution
 | 
			
		||||
 | 
			
		||||
Set default video resolution:
 | 
			
		||||
 | 
			
		||||
```javascript
 | 
			
		||||
const config = {
 | 
			
		||||
  download: {
 | 
			
		||||
    defaultResolution: "1080p" // "480p" | "720p" | "1080p" | "best"
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### Audio Format
 | 
			
		||||
 | 
			
		||||
Configure audio format preferences:
 | 
			
		||||
 | 
			
		||||
```javascript
 | 
			
		||||
const config = {
 | 
			
		||||
  download: {
 | 
			
		||||
    defaultAudioFormat: "m4a" // "m4a" | "mp3"
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### Subtitle Language
 | 
			
		||||
 | 
			
		||||
Set default subtitle language:
 | 
			
		||||
 | 
			
		||||
```javascript
 | 
			
		||||
const config = {
 | 
			
		||||
  download: {
 | 
			
		||||
    defaultSubtitleLanguage: "en"
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
## Tools Configuration
 | 
			
		||||
 | 
			
		||||
Configure required external tools:
 | 
			
		||||
 | 
			
		||||
```javascript
 | 
			
		||||
const config = {
 | 
			
		||||
  tools: {
 | 
			
		||||
    required: ['yt-dlp']
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
## Complete Configuration Example
 | 
			
		||||
 | 
			
		||||
```javascript
 | 
			
		||||
import { CONFIG } from '@kevinwatt/yt-dlp-mcp';
 | 
			
		||||
 | 
			
		||||
const customConfig = {
 | 
			
		||||
  file: {
 | 
			
		||||
    maxFilenameLength: 100,
 | 
			
		||||
    downloadsDir: '/custom/downloads',
 | 
			
		||||
    tempDirPrefix: 'ytdlp-temp-',
 | 
			
		||||
    sanitize: {
 | 
			
		||||
      replaceChar: '-',
 | 
			
		||||
      truncateSuffix: '___',
 | 
			
		||||
      illegalChars: /[<>:"/\\|?*\x00-\x1F]/g,
 | 
			
		||||
      reservedNames: [
 | 
			
		||||
        'CON', 'PRN', 'AUX', 'NUL',
 | 
			
		||||
        'COM1', 'COM2', 'COM3', 'COM4', 'COM5',
 | 
			
		||||
        'LPT1', 'LPT2', 'LPT3'
 | 
			
		||||
      ]
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  tools: {
 | 
			
		||||
    required: ['yt-dlp']
 | 
			
		||||
  },
 | 
			
		||||
  download: {
 | 
			
		||||
    defaultResolution: '1080p',
 | 
			
		||||
    defaultAudioFormat: 'm4a',
 | 
			
		||||
    defaultSubtitleLanguage: 'en'
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// Use the custom configuration
 | 
			
		||||
const result = await downloadVideo(url, customConfig);
 | 
			
		||||
``` 
 | 
			
		||||
							
								
								
									
										192
									
								
								docs/contributing.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										192
									
								
								docs/contributing.md
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,192 @@
 | 
			
		||||
# Contributing Guide
 | 
			
		||||
 | 
			
		||||
## Getting Started
 | 
			
		||||
 | 
			
		||||
1. Fork the repository
 | 
			
		||||
2. Clone your fork:
 | 
			
		||||
```bash
 | 
			
		||||
git clone https://github.com/your-username/yt-dlp-mcp.git
 | 
			
		||||
cd yt-dlp-mcp
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
3. Install dependencies:
 | 
			
		||||
```bash
 | 
			
		||||
npm install
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
4. Create a new branch:
 | 
			
		||||
```bash
 | 
			
		||||
git checkout -b feature/your-feature-name
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
## Development Setup
 | 
			
		||||
 | 
			
		||||
### Prerequisites
 | 
			
		||||
 | 
			
		||||
- Node.js 16.x or higher
 | 
			
		||||
- yt-dlp installed on your system
 | 
			
		||||
- TypeScript knowledge
 | 
			
		||||
- Jest for testing
 | 
			
		||||
 | 
			
		||||
### Building
 | 
			
		||||
 | 
			
		||||
```bash
 | 
			
		||||
npm run build
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### Running Tests
 | 
			
		||||
 | 
			
		||||
```bash
 | 
			
		||||
npm test
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
For specific test files:
 | 
			
		||||
```bash
 | 
			
		||||
npm test -- src/__tests__/video.test.ts
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
## Code Style
 | 
			
		||||
 | 
			
		||||
We use TypeScript and follow these conventions:
 | 
			
		||||
 | 
			
		||||
- Use meaningful variable and function names
 | 
			
		||||
- Add JSDoc comments for public APIs
 | 
			
		||||
- Follow the existing code style
 | 
			
		||||
- Use async/await for promises
 | 
			
		||||
- Handle errors appropriately
 | 
			
		||||
 | 
			
		||||
### TypeScript Guidelines
 | 
			
		||||
 | 
			
		||||
```typescript
 | 
			
		||||
// Use explicit types
 | 
			
		||||
function downloadVideo(url: string, config?: Config): Promise<string> {
 | 
			
		||||
  // Implementation
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Use interfaces for complex types
 | 
			
		||||
interface DownloadOptions {
 | 
			
		||||
  resolution: string;
 | 
			
		||||
  format: string;
 | 
			
		||||
  output: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Use enums for fixed values
 | 
			
		||||
enum Resolution {
 | 
			
		||||
  SD = '480p',
 | 
			
		||||
  HD = '720p',
 | 
			
		||||
  FHD = '1080p',
 | 
			
		||||
  BEST = 'best'
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
## Testing
 | 
			
		||||
 | 
			
		||||
### Writing Tests
 | 
			
		||||
 | 
			
		||||
- Place tests in `src/__tests__` directory
 | 
			
		||||
- Name test files with `.test.ts` suffix
 | 
			
		||||
- Use descriptive test names
 | 
			
		||||
- Test both success and error cases
 | 
			
		||||
 | 
			
		||||
Example:
 | 
			
		||||
 | 
			
		||||
```typescript
 | 
			
		||||
describe('downloadVideo', () => {
 | 
			
		||||
  test('downloads video successfully', async () => {
 | 
			
		||||
    const result = await downloadVideo(testUrl);
 | 
			
		||||
    expect(result).toMatch(/Video successfully downloaded/);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  test('handles invalid URL', async () => {
 | 
			
		||||
    await expect(downloadVideo('invalid-url'))
 | 
			
		||||
      .rejects
 | 
			
		||||
      .toThrow('Invalid or unsupported URL');
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### Test Coverage
 | 
			
		||||
 | 
			
		||||
Aim for high test coverage:
 | 
			
		||||
```bash
 | 
			
		||||
npm run test:coverage
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
## Documentation
 | 
			
		||||
 | 
			
		||||
### JSDoc Comments
 | 
			
		||||
 | 
			
		||||
Add comprehensive JSDoc comments for all public APIs:
 | 
			
		||||
 | 
			
		||||
```typescript
 | 
			
		||||
/**
 | 
			
		||||
 * Downloads a video from the specified URL.
 | 
			
		||||
 * 
 | 
			
		||||
 * @param url - The URL of the video to download
 | 
			
		||||
 * @param config - Optional configuration object
 | 
			
		||||
 * @param resolution - Preferred video resolution
 | 
			
		||||
 * @returns Promise resolving to success message with file path
 | 
			
		||||
 * @throws {Error} When URL is invalid or download fails
 | 
			
		||||
 * 
 | 
			
		||||
 * @example
 | 
			
		||||
 * ```typescript
 | 
			
		||||
 * const result = await downloadVideo('https://youtube.com/watch?v=...', config);
 | 
			
		||||
 * console.log(result);
 | 
			
		||||
 * ```
 | 
			
		||||
 */
 | 
			
		||||
export async function downloadVideo(
 | 
			
		||||
  url: string,
 | 
			
		||||
  config?: Config,
 | 
			
		||||
  resolution?: string
 | 
			
		||||
): Promise<string> {
 | 
			
		||||
  // Implementation
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### README Updates
 | 
			
		||||
 | 
			
		||||
- Update README.md for new features
 | 
			
		||||
- Keep examples up to date
 | 
			
		||||
- Document breaking changes
 | 
			
		||||
 | 
			
		||||
## Pull Request Process
 | 
			
		||||
 | 
			
		||||
1. Update tests and documentation
 | 
			
		||||
2. Run all tests and linting
 | 
			
		||||
3. Update CHANGELOG.md
 | 
			
		||||
4. Create detailed PR description
 | 
			
		||||
5. Reference related issues
 | 
			
		||||
 | 
			
		||||
### PR Checklist
 | 
			
		||||
 | 
			
		||||
- [ ] Tests added/updated
 | 
			
		||||
- [ ] Documentation updated
 | 
			
		||||
- [ ] CHANGELOG.md updated
 | 
			
		||||
- [ ] Code follows style guidelines
 | 
			
		||||
- [ ] All tests passing
 | 
			
		||||
- [ ] No linting errors
 | 
			
		||||
 | 
			
		||||
## Release Process
 | 
			
		||||
 | 
			
		||||
1. Update version in package.json
 | 
			
		||||
2. Update CHANGELOG.md
 | 
			
		||||
3. Create release commit
 | 
			
		||||
4. Tag release
 | 
			
		||||
5. Push to main branch
 | 
			
		||||
 | 
			
		||||
### Version Numbers
 | 
			
		||||
 | 
			
		||||
Follow semantic versioning:
 | 
			
		||||
- MAJOR: Breaking changes
 | 
			
		||||
- MINOR: New features
 | 
			
		||||
- PATCH: Bug fixes
 | 
			
		||||
 | 
			
		||||
## Community
 | 
			
		||||
 | 
			
		||||
- Be respectful and inclusive
 | 
			
		||||
- Help others when possible
 | 
			
		||||
- Report bugs with detailed information
 | 
			
		||||
- Suggest improvements
 | 
			
		||||
- Share success stories
 | 
			
		||||
 | 
			
		||||
For more information, see the [README](./README.md) and [API Reference](./api.md). 
 | 
			
		||||
							
								
								
									
										175
									
								
								docs/error-handling.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										175
									
								
								docs/error-handling.md
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,175 @@
 | 
			
		||||
# Error Handling Guide
 | 
			
		||||
 | 
			
		||||
## Common Errors
 | 
			
		||||
 | 
			
		||||
### Invalid URL
 | 
			
		||||
 | 
			
		||||
When providing an invalid or unsupported URL:
 | 
			
		||||
 | 
			
		||||
```javascript
 | 
			
		||||
try {
 | 
			
		||||
  await downloadVideo('invalid-url');
 | 
			
		||||
} catch (error) {
 | 
			
		||||
  if (error.message.includes('Invalid or unsupported URL')) {
 | 
			
		||||
    console.error('Please provide a valid YouTube or supported platform URL');
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### Missing Subtitles
 | 
			
		||||
 | 
			
		||||
When trying to download unavailable subtitles:
 | 
			
		||||
 | 
			
		||||
```javascript
 | 
			
		||||
try {
 | 
			
		||||
  await downloadSubtitles(url, 'en');
 | 
			
		||||
} catch (error) {
 | 
			
		||||
  if (error.message.includes('No subtitle files found')) {
 | 
			
		||||
    console.warn('No subtitles available in the requested language');
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### yt-dlp Command Failures
 | 
			
		||||
 | 
			
		||||
When yt-dlp command execution fails:
 | 
			
		||||
 | 
			
		||||
```javascript
 | 
			
		||||
try {
 | 
			
		||||
  await downloadVideo(url);
 | 
			
		||||
} catch (error) {
 | 
			
		||||
  if (error.message.includes('Failed with exit code')) {
 | 
			
		||||
    console.error('yt-dlp command failed:', error.message);
 | 
			
		||||
    // Check if yt-dlp is installed and up to date
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### File System Errors
 | 
			
		||||
 | 
			
		||||
When encountering file system issues:
 | 
			
		||||
 | 
			
		||||
```javascript
 | 
			
		||||
try {
 | 
			
		||||
  await downloadVideo(url);
 | 
			
		||||
} catch (error) {
 | 
			
		||||
  if (error.message.includes('No write permission')) {
 | 
			
		||||
    console.error('Cannot write to downloads directory. Check permissions.');
 | 
			
		||||
  } else if (error.message.includes('Cannot create temporary directory')) {
 | 
			
		||||
    console.error('Cannot create temporary directory. Check system temp directory permissions.');
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
## Comprehensive Error Handler
 | 
			
		||||
 | 
			
		||||
Here's a comprehensive error handler that covers most common scenarios:
 | 
			
		||||
 | 
			
		||||
```javascript
 | 
			
		||||
async function handleDownload(url, options = {}) {
 | 
			
		||||
  try {
 | 
			
		||||
    // Attempt the download
 | 
			
		||||
    const result = await downloadVideo(url, options);
 | 
			
		||||
    return result;
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    // URL validation errors
 | 
			
		||||
    if (error.message.includes('Invalid or unsupported URL')) {
 | 
			
		||||
      throw new Error(`Invalid URL: ${url}. Please provide a valid video URL.`);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // File system errors
 | 
			
		||||
    if (error.message.includes('No write permission')) {
 | 
			
		||||
      throw new Error(`Permission denied: Cannot write to ${options.file?.downloadsDir || '~/Downloads'}`);
 | 
			
		||||
    }
 | 
			
		||||
    if (error.message.includes('Cannot create temporary directory')) {
 | 
			
		||||
      throw new Error('Cannot create temporary directory. Check system permissions.');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // yt-dlp related errors
 | 
			
		||||
    if (error.message.includes('Failed with exit code')) {
 | 
			
		||||
      if (error.message.includes('This video is unavailable')) {
 | 
			
		||||
        throw new Error('Video is unavailable or has been removed.');
 | 
			
		||||
      }
 | 
			
		||||
      if (error.message.includes('Video is private')) {
 | 
			
		||||
        throw new Error('This video is private and cannot be accessed.');
 | 
			
		||||
      }
 | 
			
		||||
      throw new Error('Download failed. Please check if yt-dlp is installed and up to date.');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Subtitle related errors
 | 
			
		||||
    if (error.message.includes('No subtitle files found')) {
 | 
			
		||||
      throw new Error(`No subtitles available in ${options.language || 'the requested language'}.`);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Unknown errors
 | 
			
		||||
    throw new Error(`Unexpected error: ${error.message}`);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
## Error Prevention
 | 
			
		||||
 | 
			
		||||
### URL Validation
 | 
			
		||||
 | 
			
		||||
Always validate URLs before processing:
 | 
			
		||||
 | 
			
		||||
```javascript
 | 
			
		||||
import { validateUrl, isYouTubeUrl } from '@kevinwatt/yt-dlp-mcp';
 | 
			
		||||
 | 
			
		||||
function validateVideoUrl(url) {
 | 
			
		||||
  if (!validateUrl(url)) {
 | 
			
		||||
    throw new Error('Invalid URL format');
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  if (!isYouTubeUrl(url)) {
 | 
			
		||||
    console.warn('URL is not from YouTube, some features might not work');
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### Configuration Validation
 | 
			
		||||
 | 
			
		||||
Validate configuration before use:
 | 
			
		||||
 | 
			
		||||
```javascript
 | 
			
		||||
function validateConfig(config) {
 | 
			
		||||
  if (!config.file.downloadsDir) {
 | 
			
		||||
    throw new Error('Downloads directory must be specified');
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (config.file.maxFilenameLength < 5) {
 | 
			
		||||
    throw new Error('Filename length must be at least 5 characters');
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (!['480p', '720p', '1080p', 'best'].includes(config.download.defaultResolution)) {
 | 
			
		||||
    throw new Error('Invalid resolution specified');
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### Safe Cleanup
 | 
			
		||||
 | 
			
		||||
Always use safe cleanup for temporary files:
 | 
			
		||||
 | 
			
		||||
```javascript
 | 
			
		||||
import { safeCleanup } from '@kevinwatt/yt-dlp-mcp';
 | 
			
		||||
 | 
			
		||||
try {
 | 
			
		||||
  // Your download code here
 | 
			
		||||
} catch (error) {
 | 
			
		||||
  console.error('Download failed:', error);
 | 
			
		||||
} finally {
 | 
			
		||||
  await safeCleanup(tempDir);
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
## Best Practices
 | 
			
		||||
 | 
			
		||||
1. Always wrap async operations in try-catch blocks
 | 
			
		||||
2. Validate inputs before processing
 | 
			
		||||
3. Use specific error types for different scenarios
 | 
			
		||||
4. Clean up temporary files in finally blocks
 | 
			
		||||
5. Log errors appropriately for debugging
 | 
			
		||||
6. Provide meaningful error messages to users
 | 
			
		||||
 | 
			
		||||
For more information about specific errors and their solutions, see the [API Reference](./api.md). 
 | 
			
		||||
							
								
								
									
										4
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										4
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							@@ -1,12 +1,12 @@
 | 
			
		||||
{
 | 
			
		||||
  "name": "@kevinwatt/yt-dlp-mcp",
 | 
			
		||||
  "version": "0.6.9",
 | 
			
		||||
  "version": "0.6.23",
 | 
			
		||||
  "lockfileVersion": 3,
 | 
			
		||||
  "requires": true,
 | 
			
		||||
  "packages": {
 | 
			
		||||
    "": {
 | 
			
		||||
      "name": "@kevinwatt/yt-dlp-mcp",
 | 
			
		||||
      "version": "0.6.9",
 | 
			
		||||
      "version": "0.6.23",
 | 
			
		||||
      "license": "MIT",
 | 
			
		||||
      "dependencies": {
 | 
			
		||||
        "@modelcontextprotocol/sdk": "0.7.0",
 | 
			
		||||
 
 | 
			
		||||
@@ -1,7 +1,7 @@
 | 
			
		||||
{
 | 
			
		||||
  "name": "@kevinwatt/yt-dlp-mcp",
 | 
			
		||||
  "version": "0.6.20",
 | 
			
		||||
  "description": "yt-dlp MCP Server - Download video content via Model Context Protocol",
 | 
			
		||||
  "version": "0.6.26",
 | 
			
		||||
  "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.",
 | 
			
		||||
  "keywords": [
 | 
			
		||||
    "mcp",
 | 
			
		||||
    "youtube",
 | 
			
		||||
@@ -27,7 +27,7 @@
 | 
			
		||||
  "main": "./lib/index.mjs",
 | 
			
		||||
  "scripts": {
 | 
			
		||||
    "prepare": "tsc && shx chmod +x ./lib/index.mjs",
 | 
			
		||||
    "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js --detectOpenHandles --forceExit"
 | 
			
		||||
    "test": "PYTHONPATH= PYTHONHOME= node --experimental-vm-modules node_modules/jest/bin/jest.js --detectOpenHandles --forceExit"
 | 
			
		||||
  },
 | 
			
		||||
  "author": "Dewei Yen <k@funmula.com>",
 | 
			
		||||
  "license": "MIT",
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										43
									
								
								src/__tests__/audio.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								src/__tests__/audio.test.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,43 @@
 | 
			
		||||
// @ts-nocheck
 | 
			
		||||
// @jest-environment node
 | 
			
		||||
import { describe, test, expect } from '@jest/globals';
 | 
			
		||||
import * as os from 'os';
 | 
			
		||||
import * as path from 'path';
 | 
			
		||||
import { downloadAudio } from '../modules/audio.js';
 | 
			
		||||
import { CONFIG } from '../config.js';
 | 
			
		||||
import * as fs from 'fs';
 | 
			
		||||
 | 
			
		||||
describe('downloadAudio', () => {
 | 
			
		||||
  const testUrl = 'https://www.youtube.com/watch?v=jNQXAC9IVRw';
 | 
			
		||||
  const testConfig = {
 | 
			
		||||
    ...CONFIG,
 | 
			
		||||
    file: {
 | 
			
		||||
      ...CONFIG.file,
 | 
			
		||||
      downloadsDir: path.join(os.tmpdir(), 'yt-dlp-test-downloads'),
 | 
			
		||||
      tempDirPrefix: 'yt-dlp-test-'
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  beforeAll(async () => {
 | 
			
		||||
    await fs.promises.mkdir(testConfig.file.downloadsDir, { recursive: true });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  afterAll(async () => {
 | 
			
		||||
    await fs.promises.rm(testConfig.file.downloadsDir, { recursive: true, force: true });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  test('downloads audio successfully from YouTube', async () => {
 | 
			
		||||
    const result = await downloadAudio(testUrl, testConfig);
 | 
			
		||||
    expect(result).toContain('Audio successfully downloaded');
 | 
			
		||||
    
 | 
			
		||||
    const files = await fs.promises.readdir(testConfig.file.downloadsDir);
 | 
			
		||||
    expect(files.length).toBeGreaterThan(0);
 | 
			
		||||
    expect(files[0]).toMatch(/\.m4a$/);
 | 
			
		||||
  }, 30000);
 | 
			
		||||
 | 
			
		||||
  test('handles invalid URL', async () => {
 | 
			
		||||
    await expect(downloadAudio('invalid-url', testConfig))
 | 
			
		||||
      .rejects
 | 
			
		||||
      .toThrow();
 | 
			
		||||
  });
 | 
			
		||||
}); 
 | 
			
		||||
@@ -1,79 +1,56 @@
 | 
			
		||||
// @ts-nocheck
 | 
			
		||||
// @jest-environment node
 | 
			
		||||
import { jest } from '@jest/globals';
 | 
			
		||||
import { describe, test, expect, beforeAll, afterAll, beforeEach } from '@jest/globals';
 | 
			
		||||
import * as path from 'path';
 | 
			
		||||
import { describe, test, expect } from '@jest/globals';
 | 
			
		||||
import * as os from 'os';
 | 
			
		||||
import * as path from 'path';
 | 
			
		||||
import { downloadVideo } from '../modules/video.js';
 | 
			
		||||
import { CONFIG } from '../config.js';
 | 
			
		||||
import * as fs from 'fs';
 | 
			
		||||
 | 
			
		||||
// 簡化 mock
 | 
			
		||||
jest.mock('spawn-rx', () => ({
 | 
			
		||||
  spawnPromise: jest.fn().mockImplementation(async (cmd, args) => {
 | 
			
		||||
    if (args.includes('--get-filename')) {
 | 
			
		||||
      return 'mock_video.mp4';
 | 
			
		||||
    }
 | 
			
		||||
    return 'Download completed';
 | 
			
		||||
  })
 | 
			
		||||
}));
 | 
			
		||||
jest.mock('rimraf', () => ({
 | 
			
		||||
  rimraf: { sync: jest.fn() }
 | 
			
		||||
}));
 | 
			
		||||
 | 
			
		||||
import { downloadVideo } from '../index.mts';
 | 
			
		||||
// 設置 Python 環境
 | 
			
		||||
process.env.PYTHONPATH = '';
 | 
			
		||||
process.env.PYTHONHOME = '';
 | 
			
		||||
 | 
			
		||||
describe('downloadVideo', () => {
 | 
			
		||||
  const mockTimestamp = '2024-03-20_12-30-00';
 | 
			
		||||
  let originalDateToISOString: () => string;
 | 
			
		||||
  const testUrl = 'https://www.youtube.com/watch?v=jNQXAC9IVRw';
 | 
			
		||||
  const testConfig = {
 | 
			
		||||
    ...CONFIG,
 | 
			
		||||
    file: {
 | 
			
		||||
      ...CONFIG.file,
 | 
			
		||||
      downloadsDir: path.join(os.tmpdir(), 'yt-dlp-test-downloads'),
 | 
			
		||||
      tempDirPrefix: 'yt-dlp-test-'
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  // 全局清理
 | 
			
		||||
  afterAll(done => {
 | 
			
		||||
    // 清理所有計時器
 | 
			
		||||
    jest.useRealTimers();
 | 
			
		||||
    // 確保所有異步操作完成
 | 
			
		||||
    process.nextTick(done);
 | 
			
		||||
  beforeEach(async () => {
 | 
			
		||||
    await fs.promises.mkdir(testConfig.file.downloadsDir, { recursive: true });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  beforeAll(() => {
 | 
			
		||||
    originalDateToISOString = Date.prototype.toISOString;
 | 
			
		||||
    Date.prototype.toISOString = jest.fn(() => '2024-03-20T12:30:00.000Z');
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  afterAll(() => {
 | 
			
		||||
    Date.prototype.toISOString = originalDateToISOString;
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  beforeEach(() => {
 | 
			
		||||
    jest.clearAllMocks();
 | 
			
		||||
  afterEach(async () => {
 | 
			
		||||
    await fs.promises.rm(testConfig.file.downloadsDir, { recursive: true, force: true });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  test('downloads video successfully with correct format', async () => {
 | 
			
		||||
    const result = await downloadVideo('https://www.youtube.com/watch?v=dQw4w9WgXcQ');
 | 
			
		||||
    const result = await downloadVideo(testUrl, testConfig);
 | 
			
		||||
    expect(result).toContain('Video successfully downloaded');
 | 
			
		||||
    
 | 
			
		||||
    // 驗證基本功能
 | 
			
		||||
    expect(result).toMatch(/Video successfully downloaded as/);
 | 
			
		||||
    expect(result).toContain(mockTimestamp);
 | 
			
		||||
    expect(result).toContain(os.homedir());
 | 
			
		||||
    expect(result).toContain('Downloads');
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  test('handles special characters in video URL', async () => {
 | 
			
		||||
    // 使用有效的視頻 ID,但包含需要編碼的字符
 | 
			
		||||
    const result = await downloadVideo('https://www.youtube.com/watch?v=dQw4w9WgXcQ&title=特殊字符');
 | 
			
		||||
    
 | 
			
		||||
    expect(result).toMatch(/Video successfully downloaded as/);
 | 
			
		||||
    expect(result).toContain(mockTimestamp);
 | 
			
		||||
  });
 | 
			
		||||
    const files = await fs.promises.readdir(testConfig.file.downloadsDir);
 | 
			
		||||
    expect(files.length).toBeGreaterThan(0);
 | 
			
		||||
    expect(files[0]).toMatch(/\.(mp4|webm|mkv)$/);
 | 
			
		||||
  }, 30000);
 | 
			
		||||
 | 
			
		||||
  test('uses correct resolution format', async () => {
 | 
			
		||||
    const resolutions = ['480p', '720p', '1080p', 'best'];
 | 
			
		||||
    const result = await downloadVideo(testUrl, testConfig, '1080p');
 | 
			
		||||
    expect(result).toContain('Video successfully downloaded');
 | 
			
		||||
    
 | 
			
		||||
    // 使用 Promise.all 並行執行測試
 | 
			
		||||
    const results = await Promise.all(resolutions.map(resolution => downloadVideo(
 | 
			
		||||
      'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
 | 
			
		||||
      resolution
 | 
			
		||||
    )));
 | 
			
		||||
    
 | 
			
		||||
    results.forEach(result => {
 | 
			
		||||
      expect(result).toMatch(/Video successfully downloaded as/);
 | 
			
		||||
    });
 | 
			
		||||
    const files = await fs.promises.readdir(testConfig.file.downloadsDir);
 | 
			
		||||
    expect(files.length).toBeGreaterThan(0);
 | 
			
		||||
    expect(files[0]).toMatch(/\.(mp4|webm|mkv)$/);
 | 
			
		||||
  }, 30000);
 | 
			
		||||
 | 
			
		||||
  test('handles invalid URL', async () => {
 | 
			
		||||
    await expect(downloadVideo('invalid-url', testConfig))
 | 
			
		||||
      .rejects
 | 
			
		||||
      .toThrow();
 | 
			
		||||
  });
 | 
			
		||||
}); 
 | 
			
		||||
							
								
								
									
										111
									
								
								src/__tests__/subtitle.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										111
									
								
								src/__tests__/subtitle.test.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,111 @@
 | 
			
		||||
// @ts-nocheck
 | 
			
		||||
// @jest-environment node
 | 
			
		||||
import { describe, test, expect } from '@jest/globals';
 | 
			
		||||
import * as os from 'os';
 | 
			
		||||
import * as path from 'path';
 | 
			
		||||
import { listSubtitles, downloadSubtitles, downloadTranscript } from '../modules/subtitle.js';
 | 
			
		||||
import { cleanSubtitleToTranscript } from '../modules/utils.js';
 | 
			
		||||
import { CONFIG } from '../config.js';
 | 
			
		||||
import * as fs from 'fs';
 | 
			
		||||
 | 
			
		||||
describe('Subtitle Functions', () => {
 | 
			
		||||
  const testUrl = 'https://www.youtube.com/watch?v=jNQXAC9IVRw';
 | 
			
		||||
  const testConfig = {
 | 
			
		||||
    ...CONFIG,
 | 
			
		||||
    file: {
 | 
			
		||||
      ...CONFIG.file,
 | 
			
		||||
      downloadsDir: path.join(os.tmpdir(), 'yt-dlp-test-downloads'),
 | 
			
		||||
      tempDirPrefix: 'yt-dlp-test-'
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  beforeEach(async () => {
 | 
			
		||||
    await fs.promises.mkdir(testConfig.file.downloadsDir, { recursive: true });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  afterEach(async () => {
 | 
			
		||||
    await fs.promises.rm(testConfig.file.downloadsDir, { recursive: true, force: true });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  describe('listSubtitles', () => {
 | 
			
		||||
    test('lists available subtitles', async () => {
 | 
			
		||||
      const result = await listSubtitles(testUrl);
 | 
			
		||||
      expect(result).toContain('Language');
 | 
			
		||||
    }, 30000);
 | 
			
		||||
 | 
			
		||||
    test('handles invalid URL', async () => {
 | 
			
		||||
      await expect(listSubtitles('invalid-url'))
 | 
			
		||||
        .rejects
 | 
			
		||||
        .toThrow();
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  describe('downloadSubtitles', () => {
 | 
			
		||||
    test('downloads auto-generated subtitles successfully', async () => {
 | 
			
		||||
      const result = await downloadSubtitles(testUrl, 'en', testConfig);
 | 
			
		||||
      expect(result).toContain('WEBVTT');
 | 
			
		||||
    }, 30000);
 | 
			
		||||
 | 
			
		||||
    test('handles missing language', async () => {
 | 
			
		||||
      await expect(downloadSubtitles(testUrl, 'xx', testConfig))
 | 
			
		||||
        .rejects
 | 
			
		||||
        .toThrow();
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  describe('downloadTranscript', () => {
 | 
			
		||||
    test('downloads and cleans transcript successfully', async () => {
 | 
			
		||||
      const result = await downloadTranscript(testUrl, 'en', testConfig);
 | 
			
		||||
      expect(typeof result).toBe('string');
 | 
			
		||||
      expect(result.length).toBeGreaterThan(0);
 | 
			
		||||
      expect(result).not.toContain('WEBVTT');
 | 
			
		||||
      expect(result).not.toContain('-->');
 | 
			
		||||
      expect(result).not.toMatch(/^\d+$/m);
 | 
			
		||||
    }, 30000);
 | 
			
		||||
 | 
			
		||||
    test('handles invalid URL', async () => {
 | 
			
		||||
      await expect(downloadTranscript('invalid-url', 'en', testConfig))
 | 
			
		||||
        .rejects
 | 
			
		||||
        .toThrow();
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  describe('cleanSubtitleToTranscript', () => {
 | 
			
		||||
    test('cleans SRT content correctly', () => {
 | 
			
		||||
      const srtContent = `1
 | 
			
		||||
00:00:01,000 --> 00:00:03,000
 | 
			
		||||
Hello <i>world</i>
 | 
			
		||||
 | 
			
		||||
2
 | 
			
		||||
00:00:04,000 --> 00:00:06,000
 | 
			
		||||
This is a test
 | 
			
		||||
 | 
			
		||||
3
 | 
			
		||||
00:00:07,000 --> 00:00:09,000
 | 
			
		||||
<b>Bold text</b> here`;
 | 
			
		||||
 | 
			
		||||
      const result = cleanSubtitleToTranscript(srtContent);
 | 
			
		||||
      expect(result).toBe('Hello world This is a test Bold text here');
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    test('handles empty content', () => {
 | 
			
		||||
      const result = cleanSubtitleToTranscript('');
 | 
			
		||||
      expect(result).toBe('');
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    test('removes timestamps and sequence numbers', () => {
 | 
			
		||||
      const srtContent = `1
 | 
			
		||||
00:00:01,000 --> 00:00:03,000
 | 
			
		||||
First line
 | 
			
		||||
 | 
			
		||||
2
 | 
			
		||||
00:00:04,000 --> 00:00:06,000
 | 
			
		||||
Second line`;
 | 
			
		||||
 | 
			
		||||
      const result = cleanSubtitleToTranscript(srtContent);
 | 
			
		||||
      expect(result).not.toContain('00:00');
 | 
			
		||||
      expect(result).not.toMatch(/^\d+$/);
 | 
			
		||||
      expect(result).toBe('First line Second line');
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
}); 
 | 
			
		||||
							
								
								
									
										216
									
								
								src/config.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										216
									
								
								src/config.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,216 @@
 | 
			
		||||
import * as os from "os";
 | 
			
		||||
import * as path from "path";
 | 
			
		||||
 | 
			
		||||
type DeepPartial<T> = {
 | 
			
		||||
  [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * 配置類型定義
 | 
			
		||||
 */
 | 
			
		||||
export interface Config {
 | 
			
		||||
  // 文件相關配置
 | 
			
		||||
  file: {
 | 
			
		||||
    maxFilenameLength: number;
 | 
			
		||||
    downloadsDir: string;
 | 
			
		||||
    tempDirPrefix: string;
 | 
			
		||||
    // 文件名處理相關配置
 | 
			
		||||
    sanitize: {
 | 
			
		||||
      // 替換非法字符為此字符
 | 
			
		||||
      replaceChar: string;
 | 
			
		||||
      // 文件名截斷時的後綴
 | 
			
		||||
      truncateSuffix: string;
 | 
			
		||||
      // 非法字符正則表達式
 | 
			
		||||
      illegalChars: RegExp;
 | 
			
		||||
      // 保留字列表
 | 
			
		||||
      reservedNames: readonly string[];
 | 
			
		||||
    };
 | 
			
		||||
  };
 | 
			
		||||
  // 工具相關配置
 | 
			
		||||
  tools: {
 | 
			
		||||
    required: readonly string[];
 | 
			
		||||
  };
 | 
			
		||||
  // 下載相關配置
 | 
			
		||||
  download: {
 | 
			
		||||
    defaultResolution: "480p" | "720p" | "1080p" | "best";
 | 
			
		||||
    defaultAudioFormat: "m4a" | "mp3";
 | 
			
		||||
    defaultSubtitleLanguage: string;
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * 默認配置
 | 
			
		||||
 */
 | 
			
		||||
const defaultConfig: Config = {
 | 
			
		||||
  file: {
 | 
			
		||||
    maxFilenameLength: 50,
 | 
			
		||||
    downloadsDir: path.join(os.homedir(), "Downloads"),
 | 
			
		||||
    tempDirPrefix: "ytdlp-",
 | 
			
		||||
    sanitize: {
 | 
			
		||||
      replaceChar: '_',
 | 
			
		||||
      truncateSuffix: '...',
 | 
			
		||||
      illegalChars: /[<>:"/\\|?*\x00-\x1F]/g,  // Windows 非法字符
 | 
			
		||||
      reservedNames: [
 | 
			
		||||
        'CON', 'PRN', 'AUX', 'NUL', 'COM1', 'COM2', 'COM3', 'COM4',
 | 
			
		||||
        'COM5', 'COM6', 'COM7', 'COM8', 'COM9', 'LPT1', 'LPT2',
 | 
			
		||||
        'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9'
 | 
			
		||||
      ]
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  tools: {
 | 
			
		||||
    required: ['yt-dlp']
 | 
			
		||||
  },
 | 
			
		||||
  download: {
 | 
			
		||||
    defaultResolution: "720p",
 | 
			
		||||
    defaultAudioFormat: "m4a",
 | 
			
		||||
    defaultSubtitleLanguage: "en"
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * 從環境變數加載配置
 | 
			
		||||
 */
 | 
			
		||||
function loadEnvConfig(): DeepPartial<Config> {
 | 
			
		||||
  const envConfig: DeepPartial<Config> = {};
 | 
			
		||||
 | 
			
		||||
  // 文件配置
 | 
			
		||||
  const fileConfig: DeepPartial<Config['file']> = {
 | 
			
		||||
    sanitize: {
 | 
			
		||||
      replaceChar: process.env.YTDLP_SANITIZE_REPLACE_CHAR,
 | 
			
		||||
      truncateSuffix: process.env.YTDLP_SANITIZE_TRUNCATE_SUFFIX,
 | 
			
		||||
      illegalChars: process.env.YTDLP_SANITIZE_ILLEGAL_CHARS ? new RegExp(process.env.YTDLP_SANITIZE_ILLEGAL_CHARS) : undefined,
 | 
			
		||||
      reservedNames: process.env.YTDLP_SANITIZE_RESERVED_NAMES?.split(',')
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
  
 | 
			
		||||
  if (process.env.YTDLP_MAX_FILENAME_LENGTH) {
 | 
			
		||||
    fileConfig.maxFilenameLength = parseInt(process.env.YTDLP_MAX_FILENAME_LENGTH);
 | 
			
		||||
  }
 | 
			
		||||
  if (process.env.YTDLP_DOWNLOADS_DIR) {
 | 
			
		||||
    fileConfig.downloadsDir = process.env.YTDLP_DOWNLOADS_DIR;
 | 
			
		||||
  }
 | 
			
		||||
  if (process.env.YTDLP_TEMP_DIR_PREFIX) {
 | 
			
		||||
    fileConfig.tempDirPrefix = process.env.YTDLP_TEMP_DIR_PREFIX;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (Object.keys(fileConfig).length > 0) {
 | 
			
		||||
    envConfig.file = fileConfig;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // 下載配置
 | 
			
		||||
  const downloadConfig: Partial<Config['download']> = {};
 | 
			
		||||
  if (process.env.YTDLP_DEFAULT_RESOLUTION && 
 | 
			
		||||
      ['480p', '720p', '1080p', 'best'].includes(process.env.YTDLP_DEFAULT_RESOLUTION)) {
 | 
			
		||||
    downloadConfig.defaultResolution = process.env.YTDLP_DEFAULT_RESOLUTION as Config['download']['defaultResolution'];
 | 
			
		||||
  }
 | 
			
		||||
  if (process.env.YTDLP_DEFAULT_AUDIO_FORMAT && 
 | 
			
		||||
      ['m4a', 'mp3'].includes(process.env.YTDLP_DEFAULT_AUDIO_FORMAT)) {
 | 
			
		||||
    downloadConfig.defaultAudioFormat = process.env.YTDLP_DEFAULT_AUDIO_FORMAT as Config['download']['defaultAudioFormat'];
 | 
			
		||||
  }
 | 
			
		||||
  if (process.env.YTDLP_DEFAULT_SUBTITLE_LANG) {
 | 
			
		||||
    downloadConfig.defaultSubtitleLanguage = process.env.YTDLP_DEFAULT_SUBTITLE_LANG;
 | 
			
		||||
  }
 | 
			
		||||
  if (Object.keys(downloadConfig).length > 0) {
 | 
			
		||||
    envConfig.download = downloadConfig;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return envConfig;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * 驗證配置
 | 
			
		||||
 */
 | 
			
		||||
function validateConfig(config: Config): void {
 | 
			
		||||
  // 驗證文件名長度
 | 
			
		||||
  if (config.file.maxFilenameLength < 5) {
 | 
			
		||||
    throw new Error('maxFilenameLength must be at least 5');
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // 驗證下載目錄
 | 
			
		||||
  if (!config.file.downloadsDir) {
 | 
			
		||||
    throw new Error('downloadsDir must be specified');
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // 驗證臨時目錄前綴
 | 
			
		||||
  if (!config.file.tempDirPrefix) {
 | 
			
		||||
    throw new Error('tempDirPrefix must be specified');
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // 驗證默認分辨率
 | 
			
		||||
  if (!['480p', '720p', '1080p', 'best'].includes(config.download.defaultResolution)) {
 | 
			
		||||
    throw new Error('Invalid defaultResolution');
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // 驗證默認音頻格式
 | 
			
		||||
  if (!['m4a', 'mp3'].includes(config.download.defaultAudioFormat)) {
 | 
			
		||||
    throw new Error('Invalid defaultAudioFormat');
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // 驗證默認字幕語言
 | 
			
		||||
  if (!/^[a-z]{2,3}(-[A-Z][a-z]{3})?(-[A-Z]{2})?$/i.test(config.download.defaultSubtitleLanguage)) {
 | 
			
		||||
    throw new Error('Invalid defaultSubtitleLanguage');
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * 合併配置
 | 
			
		||||
 */
 | 
			
		||||
function mergeConfig(base: Config, override: DeepPartial<Config>): Config {
 | 
			
		||||
  return {
 | 
			
		||||
    file: {
 | 
			
		||||
      maxFilenameLength: override.file?.maxFilenameLength || base.file.maxFilenameLength,
 | 
			
		||||
      downloadsDir: override.file?.downloadsDir || base.file.downloadsDir,
 | 
			
		||||
      tempDirPrefix: override.file?.tempDirPrefix || base.file.tempDirPrefix,
 | 
			
		||||
      sanitize: {
 | 
			
		||||
        replaceChar: override.file?.sanitize?.replaceChar || base.file.sanitize.replaceChar,
 | 
			
		||||
        truncateSuffix: override.file?.sanitize?.truncateSuffix || base.file.sanitize.truncateSuffix,
 | 
			
		||||
        illegalChars: (override.file?.sanitize?.illegalChars || base.file.sanitize.illegalChars) as RegExp,
 | 
			
		||||
        reservedNames: (override.file?.sanitize?.reservedNames || base.file.sanitize.reservedNames) as readonly string[]
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    tools: {
 | 
			
		||||
      required: (override.tools?.required || base.tools.required) as readonly string[]
 | 
			
		||||
    },
 | 
			
		||||
    download: {
 | 
			
		||||
      defaultResolution: override.download?.defaultResolution || base.download.defaultResolution,
 | 
			
		||||
      defaultAudioFormat: override.download?.defaultAudioFormat || base.download.defaultAudioFormat,
 | 
			
		||||
      defaultSubtitleLanguage: override.download?.defaultSubtitleLanguage || base.download.defaultSubtitleLanguage
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * 加載配置
 | 
			
		||||
 */
 | 
			
		||||
export function loadConfig(): Config {
 | 
			
		||||
  const envConfig = loadEnvConfig();
 | 
			
		||||
  const config = mergeConfig(defaultConfig, envConfig);
 | 
			
		||||
  validateConfig(config);
 | 
			
		||||
  return config;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * 安全的文件名處理函數
 | 
			
		||||
 */
 | 
			
		||||
export function sanitizeFilename(filename: string, config: Config['file']): string {
 | 
			
		||||
  // 移除非法字符
 | 
			
		||||
  let safe = filename.replace(config.sanitize.illegalChars, config.sanitize.replaceChar);
 | 
			
		||||
  
 | 
			
		||||
  // 檢查保留字
 | 
			
		||||
  const basename = path.parse(safe).name.toUpperCase();
 | 
			
		||||
  if (config.sanitize.reservedNames.includes(basename)) {
 | 
			
		||||
    safe = `_${safe}`;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  // 處理長度限制
 | 
			
		||||
  if (safe.length > config.maxFilenameLength) {
 | 
			
		||||
    const ext = path.extname(safe);
 | 
			
		||||
    const name = safe.slice(0, config.maxFilenameLength - ext.length - config.sanitize.truncateSuffix.length);
 | 
			
		||||
    safe = `${name}${config.sanitize.truncateSuffix}${ext}`;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  return safe;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// 導出當前配置實例
 | 
			
		||||
export const CONFIG = loadConfig(); 
 | 
			
		||||
							
								
								
									
										402
									
								
								src/index.mts
									
									
									
									
									
								
							
							
						
						
									
										402
									
								
								src/index.mts
									
									
									
									
									
								
							@@ -4,27 +4,20 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
 | 
			
		||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
 | 
			
		||||
import {
 | 
			
		||||
  CallToolRequestSchema,
 | 
			
		||||
  ListToolsRequestSchema,
 | 
			
		||||
  ListToolsRequestSchema
 | 
			
		||||
} from "@modelcontextprotocol/sdk/types.js";
 | 
			
		||||
import type { CallToolRequest } from "@modelcontextprotocol/sdk/types.js";
 | 
			
		||||
 | 
			
		||||
import * as os from "os";
 | 
			
		||||
import * as fs from "fs";
 | 
			
		||||
import * as path from "path";
 | 
			
		||||
import { spawnPromise } from "spawn-rx";
 | 
			
		||||
import { rimraf } from "rimraf";
 | 
			
		||||
import { CONFIG } from "./config.js";
 | 
			
		||||
import { _spawnPromise, safeCleanup } from "./modules/utils.js";
 | 
			
		||||
import { downloadVideo } from "./modules/video.js";
 | 
			
		||||
import { downloadAudio } from "./modules/audio.js";
 | 
			
		||||
import { listSubtitles, downloadSubtitles, downloadTranscript } from "./modules/subtitle.js";
 | 
			
		||||
 | 
			
		||||
const VERSION = '0.6.20';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * System Configuration
 | 
			
		||||
 */
 | 
			
		||||
const CONFIG = {
 | 
			
		||||
  MAX_FILENAME_LENGTH: 50,
 | 
			
		||||
  DOWNLOADS_DIR: path.join(os.homedir(), "Downloads"),
 | 
			
		||||
  TEMP_DIR_PREFIX: "ytdlp-",
 | 
			
		||||
  REQUIRED_TOOLS: ['yt-dlp'] as const
 | 
			
		||||
} as const;
 | 
			
		||||
const VERSION = '0.6.26';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Validate system configuration
 | 
			
		||||
@@ -32,22 +25,22 @@ const CONFIG = {
 | 
			
		||||
 */
 | 
			
		||||
async function validateConfig(): Promise<void> {
 | 
			
		||||
  // Check downloads directory
 | 
			
		||||
  if (!fs.existsSync(CONFIG.DOWNLOADS_DIR)) {
 | 
			
		||||
    throw new Error(`Downloads directory does not exist: ${CONFIG.DOWNLOADS_DIR}`);
 | 
			
		||||
  if (!fs.existsSync(CONFIG.file.downloadsDir)) {
 | 
			
		||||
    throw new Error(`Downloads directory does not exist: ${CONFIG.file.downloadsDir}`);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Check downloads directory permissions
 | 
			
		||||
  try {
 | 
			
		||||
    const testFile = path.join(CONFIG.DOWNLOADS_DIR, '.write-test');
 | 
			
		||||
    const testFile = path.join(CONFIG.file.downloadsDir, '.write-test');
 | 
			
		||||
    fs.writeFileSync(testFile, '');
 | 
			
		||||
    fs.unlinkSync(testFile);
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    throw new Error(`No write permission in downloads directory: ${CONFIG.DOWNLOADS_DIR}`);
 | 
			
		||||
    throw new Error(`No write permission in downloads directory: ${CONFIG.file.downloadsDir}`);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Check temporary directory permissions
 | 
			
		||||
  try {
 | 
			
		||||
    const testDir = fs.mkdtempSync(path.join(os.tmpdir(), CONFIG.TEMP_DIR_PREFIX));
 | 
			
		||||
    const testDir = fs.mkdtempSync(path.join(os.tmpdir(), CONFIG.file.tempDirPrefix));
 | 
			
		||||
    await safeCleanup(testDir);
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    throw new Error(`Cannot create temporary directory in: ${os.tmpdir()}`);
 | 
			
		||||
@@ -59,9 +52,9 @@ async function validateConfig(): Promise<void> {
 | 
			
		||||
 * @throws {Error} when dependencies are not satisfied
 | 
			
		||||
 */
 | 
			
		||||
async function checkDependencies(): Promise<void> {
 | 
			
		||||
  for (const tool of CONFIG.REQUIRED_TOOLS) {
 | 
			
		||||
  for (const tool of CONFIG.tools.required) {
 | 
			
		||||
    try {
 | 
			
		||||
      await spawnPromise(tool, ["--version"]);
 | 
			
		||||
      await _spawnPromise(tool, ["--version"]);
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      throw new Error(`Required tool '${tool}' is not installed or not accessible`);
 | 
			
		||||
    }
 | 
			
		||||
@@ -72,6 +65,11 @@ async function checkDependencies(): Promise<void> {
 | 
			
		||||
 * Initialize service
 | 
			
		||||
 */
 | 
			
		||||
async function initialize(): Promise<void> {
 | 
			
		||||
  // 在測試環境中跳過初始化檢查
 | 
			
		||||
  if (process.env.NODE_ENV === 'test') {
 | 
			
		||||
    return;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  try {
 | 
			
		||||
    await validateConfig();
 | 
			
		||||
    await checkDependencies();
 | 
			
		||||
@@ -88,7 +86,7 @@ const server = new Server(
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    capabilities: {
 | 
			
		||||
      tools: {},
 | 
			
		||||
      tools: {}
 | 
			
		||||
    },
 | 
			
		||||
  }
 | 
			
		||||
);
 | 
			
		||||
@@ -100,8 +98,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
 | 
			
		||||
  return {
 | 
			
		||||
    tools: [
 | 
			
		||||
      {
 | 
			
		||||
        name: "list_video_subtitles",
 | 
			
		||||
        description: "List all available subtitles for a video, including auto-generated captions",
 | 
			
		||||
        name: "list_subtitle_languages",
 | 
			
		||||
        description: "List all available subtitle languages and their formats for a video (including auto-generated captions)",
 | 
			
		||||
        inputSchema: {
 | 
			
		||||
          type: "object",
 | 
			
		||||
          properties: {
 | 
			
		||||
@@ -139,322 +137,33 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
 | 
			
		||||
          required: ["url"],
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        name: "download_audio",
 | 
			
		||||
        description: "Download audio in best available quality (usually m4a/mp3 format) to the user's default Downloads folder (usually ~/Downloads).",
 | 
			
		||||
        inputSchema: {
 | 
			
		||||
          type: "object",
 | 
			
		||||
          properties: {
 | 
			
		||||
            url: { type: "string", description: "URL of the video" },
 | 
			
		||||
          },
 | 
			
		||||
          required: ["url"],
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        name: "download_transcript",
 | 
			
		||||
        description: "Download and clean video subtitles to produce a plain text transcript without timestamps or formatting.",
 | 
			
		||||
        inputSchema: {
 | 
			
		||||
          type: "object",
 | 
			
		||||
          properties: {
 | 
			
		||||
            url: { type: "string", description: "URL of the video" },
 | 
			
		||||
            language: { type: "string", description: "Language code (e.g., 'en', 'zh-Hant', 'ja'). Defaults to 'en'" },
 | 
			
		||||
          },
 | 
			
		||||
          required: ["url"],
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
    ],
 | 
			
		||||
  };
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Custom error types
 | 
			
		||||
 */
 | 
			
		||||
class VideoDownloadError extends Error {
 | 
			
		||||
  constructor(
 | 
			
		||||
    message: string,
 | 
			
		||||
    public readonly code: string = 'UNKNOWN_ERROR',
 | 
			
		||||
    public readonly cause?: Error
 | 
			
		||||
  ) {
 | 
			
		||||
    super(message);
 | 
			
		||||
    this.name = 'VideoDownloadError';
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Error code mappings
 | 
			
		||||
 */
 | 
			
		||||
const ERROR_CODES = {
 | 
			
		||||
  UNSUPPORTED_URL: 'Unsupported or invalid URL',
 | 
			
		||||
  VIDEO_UNAVAILABLE: 'Video is not available or has been removed',
 | 
			
		||||
  NETWORK_ERROR: 'Network connection error',
 | 
			
		||||
  FORMAT_ERROR: 'Requested format is not available',
 | 
			
		||||
  PERMISSION_ERROR: 'Permission denied when accessing download directory',
 | 
			
		||||
  SUBTITLE_ERROR: 'Failed to process subtitles',
 | 
			
		||||
  SUBTITLE_NOT_AVAILABLE: 'Subtitles are not available',
 | 
			
		||||
  INVALID_LANGUAGE: 'Invalid language code provided',
 | 
			
		||||
  UNKNOWN_ERROR: 'An unknown error occurred'
 | 
			
		||||
} as const;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Safely clean up temporary directory
 | 
			
		||||
 * @param directory Directory path to clean up
 | 
			
		||||
 */
 | 
			
		||||
async function safeCleanup(directory: string): Promise<void> {
 | 
			
		||||
  try {
 | 
			
		||||
    rimraf.sync(directory);
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error(`Failed to cleanup directory ${directory}:`, error);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Validate URL format
 | 
			
		||||
 * @param url URL to validate
 | 
			
		||||
 * @throws {VideoDownloadError} when URL is invalid
 | 
			
		||||
 */
 | 
			
		||||
function validateUrl(url: string, ErrorClass = VideoDownloadError): void {
 | 
			
		||||
  try {
 | 
			
		||||
    new URL(url);
 | 
			
		||||
  } catch {
 | 
			
		||||
    throw new ErrorClass(
 | 
			
		||||
      ERROR_CODES.UNSUPPORTED_URL,
 | 
			
		||||
      'UNSUPPORTED_URL'
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Generate formatted timestamp
 | 
			
		||||
 * @returns Formatted timestamp string
 | 
			
		||||
 */
 | 
			
		||||
function getFormattedTimestamp(): string {
 | 
			
		||||
  return new Date().toISOString()
 | 
			
		||||
    .replace(/[:.]/g, '-')
 | 
			
		||||
    .replace('T', '_')
 | 
			
		||||
    .split('.')[0];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Check if URL is a YouTube URL
 | 
			
		||||
 * @param url URL to check
 | 
			
		||||
 */
 | 
			
		||||
function isYouTubeUrl(url: string): boolean {
 | 
			
		||||
  try {
 | 
			
		||||
    const urlObj = new URL(url);
 | 
			
		||||
    return ['youtube.com', 'youtu.be', 'm.youtube.com']
 | 
			
		||||
      .some(domain => urlObj.hostname.endsWith(domain));
 | 
			
		||||
  } catch {
 | 
			
		||||
    return false;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Lists all available subtitles for a video
 | 
			
		||||
 */
 | 
			
		||||
async function listSubtitles(url: string): Promise<string> {
 | 
			
		||||
  const tempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "ytdlp-"));
 | 
			
		||||
  
 | 
			
		||||
  try {
 | 
			
		||||
    validateUrl(url);
 | 
			
		||||
 | 
			
		||||
    // 同時列出一般字幕和自動生成的字幕
 | 
			
		||||
    const result = await spawnPromise(
 | 
			
		||||
      "yt-dlp",
 | 
			
		||||
      [
 | 
			
		||||
        "--list-subs",         // 列出一般字幕
 | 
			
		||||
        "--write-auto-sub",    // 包含自動生成的字幕
 | 
			
		||||
        "--skip-download",
 | 
			
		||||
        url
 | 
			
		||||
      ],
 | 
			
		||||
      { cwd: tempDirectory }
 | 
			
		||||
    );
 | 
			
		||||
    return result;
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    // 直接傳遞 yt-dlp 的錯誤訊息
 | 
			
		||||
    throw error;
 | 
			
		||||
  } finally {
 | 
			
		||||
    await safeCleanup(tempDirectory);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Downloads video subtitles in specified language
 | 
			
		||||
 */
 | 
			
		||||
async function downloadSubtitles(url: string, language: string = "en"): Promise<string> {
 | 
			
		||||
  const tempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "ytdlp-"));
 | 
			
		||||
 | 
			
		||||
  try {
 | 
			
		||||
    validateUrl(url);
 | 
			
		||||
 | 
			
		||||
    if (!/^[a-z]{2,3}(-[A-Z][a-z]{3})?(-[A-Z]{2})?$/i.test(language)) {
 | 
			
		||||
      throw new Error('Invalid language code');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // 下載字幕,同時支援一般字幕和自動生成的字幕
 | 
			
		||||
    try {
 | 
			
		||||
      const result = await spawnPromise(
 | 
			
		||||
        "yt-dlp",
 | 
			
		||||
        [
 | 
			
		||||
          "--write-sub",         // 嘗試下載一般字幕
 | 
			
		||||
          "--write-auto-sub",    // 同時支援自動生成字幕
 | 
			
		||||
          "--sub-lang", language,
 | 
			
		||||
          "--convert-subs", "srt",
 | 
			
		||||
          "--skip-download",
 | 
			
		||||
          url
 | 
			
		||||
        ],
 | 
			
		||||
        { cwd: tempDirectory }
 | 
			
		||||
      );
 | 
			
		||||
      console.log("yt-dlp output:", result);
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      throw error;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // 讀取下載的字幕文件
 | 
			
		||||
    const files = fs.readdirSync(tempDirectory);
 | 
			
		||||
    console.log("Files in directory:", files);
 | 
			
		||||
    
 | 
			
		||||
    // 過濾出字幕文件
 | 
			
		||||
    const subtitleFiles = files.filter(file => 
 | 
			
		||||
      file.endsWith('.srt')
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    if (subtitleFiles.length === 0) {
 | 
			
		||||
      throw new Error(`No subtitle files found. Available files: ${files.join(', ')}`);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // 讀取並組合字幕內容
 | 
			
		||||
    let subtitlesContent = "";
 | 
			
		||||
    for (const file of subtitleFiles) {
 | 
			
		||||
      const filePath = path.join(tempDirectory, file);
 | 
			
		||||
      try {
 | 
			
		||||
        const fileData = fs.readFileSync(filePath, "utf8");
 | 
			
		||||
        subtitlesContent += `${file}\n====================\n${fileData}\n\n`;
 | 
			
		||||
      } catch (error) {
 | 
			
		||||
        console.error(`Failed to read subtitle file ${file}:`, error);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (!subtitlesContent) {
 | 
			
		||||
      throw new Error("Failed to read subtitle content");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return subtitlesContent;
 | 
			
		||||
  } finally {
 | 
			
		||||
    await safeCleanup(tempDirectory);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Downloads a video with specified resolution
 | 
			
		||||
 * @param url The URL of the video
 | 
			
		||||
 * @param resolution The desired video resolution
 | 
			
		||||
 * @returns A detailed success message including the filename
 | 
			
		||||
 */
 | 
			
		||||
export async function downloadVideo(url: string, resolution = "720p"): Promise<string> {
 | 
			
		||||
  const userDownloadsDir = CONFIG.DOWNLOADS_DIR;
 | 
			
		||||
  
 | 
			
		||||
  try {
 | 
			
		||||
    validateUrl(url, VideoDownloadError);
 | 
			
		||||
 | 
			
		||||
    const timestamp = getFormattedTimestamp();
 | 
			
		||||
      
 | 
			
		||||
    let format: string;
 | 
			
		||||
    if (isYouTubeUrl(url)) {
 | 
			
		||||
      // YouTube-specific format selection
 | 
			
		||||
      switch (resolution) {
 | 
			
		||||
        case "480p":
 | 
			
		||||
          format = "bestvideo[height<=480]+bestaudio/best[height<=480]/best";
 | 
			
		||||
          break;
 | 
			
		||||
        case "720p":
 | 
			
		||||
          format = "bestvideo[height<=720]+bestaudio/best[height<=720]/best";
 | 
			
		||||
          break;
 | 
			
		||||
        case "1080p":
 | 
			
		||||
          format = "bestvideo[height<=1080]+bestaudio/best[height<=1080]/best";
 | 
			
		||||
          break;
 | 
			
		||||
        case "best":
 | 
			
		||||
          format = "bestvideo+bestaudio/best";
 | 
			
		||||
          break;
 | 
			
		||||
        default:
 | 
			
		||||
          format = "bestvideo[height<=720]+bestaudio/best[height<=720]/best";
 | 
			
		||||
      }
 | 
			
		||||
    } else {
 | 
			
		||||
      // For other platforms, use quality labels that are more generic
 | 
			
		||||
      switch (resolution) {
 | 
			
		||||
        case "480p":
 | 
			
		||||
          format = "worst[height>=480]/best[height<=480]/worst";
 | 
			
		||||
          break;
 | 
			
		||||
        case "best":
 | 
			
		||||
          format = "bestvideo+bestaudio/best";
 | 
			
		||||
          break;
 | 
			
		||||
        default: // Including 720p and 1080p cases
 | 
			
		||||
          // Prefer HD quality but fallback to best available
 | 
			
		||||
          format = "bestvideo[height>=720]+bestaudio/best[height>=720]/best";
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    const outputTemplate = path.join(
 | 
			
		||||
      userDownloadsDir,
 | 
			
		||||
      `%(title).${CONFIG.MAX_FILENAME_LENGTH}s [%(id)s] ${timestamp}.%(ext)s`
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    // Get expected filename
 | 
			
		||||
    let expectedFilename: string;
 | 
			
		||||
    try {
 | 
			
		||||
      expectedFilename = await spawnPromise("yt-dlp", [
 | 
			
		||||
        "--get-filename",
 | 
			
		||||
        "-f", format,
 | 
			
		||||
        "--output", outputTemplate,
 | 
			
		||||
        url
 | 
			
		||||
      ]);
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      const errorMessage = error instanceof Error ? error.message : String(error);
 | 
			
		||||
      if (errorMessage.includes('Unsupported URL')) {
 | 
			
		||||
        throw new VideoDownloadError(
 | 
			
		||||
          ERROR_CODES.UNSUPPORTED_URL,
 | 
			
		||||
          'UNSUPPORTED_URL',
 | 
			
		||||
          error as Error
 | 
			
		||||
        );
 | 
			
		||||
      }
 | 
			
		||||
      if (errorMessage.includes('not available')) {
 | 
			
		||||
        throw new VideoDownloadError(
 | 
			
		||||
          ERROR_CODES.VIDEO_UNAVAILABLE,
 | 
			
		||||
          'VIDEO_UNAVAILABLE',
 | 
			
		||||
          error as Error
 | 
			
		||||
        );
 | 
			
		||||
      }
 | 
			
		||||
      throw new VideoDownloadError(
 | 
			
		||||
        ERROR_CODES.UNKNOWN_ERROR,
 | 
			
		||||
        'UNKNOWN_ERROR',
 | 
			
		||||
        error as Error
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    expectedFilename = expectedFilename.trim();
 | 
			
		||||
    
 | 
			
		||||
    // Download with progress info
 | 
			
		||||
    try {
 | 
			
		||||
      await spawnPromise("yt-dlp", [
 | 
			
		||||
        "--progress",
 | 
			
		||||
        "--newline",
 | 
			
		||||
        "--no-mtime",
 | 
			
		||||
        "-f", format,
 | 
			
		||||
        "--output", outputTemplate,
 | 
			
		||||
        url
 | 
			
		||||
      ]);
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      const errorMessage = error instanceof Error ? error.message : String(error);
 | 
			
		||||
      if (errorMessage.includes('Permission denied')) {
 | 
			
		||||
        throw new VideoDownloadError(
 | 
			
		||||
          ERROR_CODES.PERMISSION_ERROR,
 | 
			
		||||
          'PERMISSION_ERROR',
 | 
			
		||||
          error as Error
 | 
			
		||||
        );
 | 
			
		||||
      }
 | 
			
		||||
      if (errorMessage.includes('format not available')) {
 | 
			
		||||
        throw new VideoDownloadError(
 | 
			
		||||
          ERROR_CODES.FORMAT_ERROR,
 | 
			
		||||
          'FORMAT_ERROR',
 | 
			
		||||
          error as Error
 | 
			
		||||
        );
 | 
			
		||||
      }
 | 
			
		||||
      throw new VideoDownloadError(
 | 
			
		||||
        ERROR_CODES.UNKNOWN_ERROR,
 | 
			
		||||
        'UNKNOWN_ERROR',
 | 
			
		||||
        error as Error
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return `Video successfully downloaded as "${path.basename(expectedFilename)}" to ${userDownloadsDir}`;
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    if (error instanceof VideoDownloadError) {
 | 
			
		||||
      throw error;
 | 
			
		||||
    }
 | 
			
		||||
    throw new VideoDownloadError(
 | 
			
		||||
      ERROR_CODES.UNKNOWN_ERROR,
 | 
			
		||||
      'UNKNOWN_ERROR',
 | 
			
		||||
      error as Error
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Handle tool execution with unified error handling
 | 
			
		||||
 * @param action Async operation to execute
 | 
			
		||||
@@ -494,21 +203,31 @@ server.setRequestHandler(
 | 
			
		||||
      resolution?: string;
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    if (toolName === "list_video_subtitles") {
 | 
			
		||||
    if (toolName === "list_subtitle_languages") {
 | 
			
		||||
      return handleToolExecution(
 | 
			
		||||
        () => listSubtitles(args.url),
 | 
			
		||||
        "Error listing subtitles"
 | 
			
		||||
        "Error listing subtitle languages"
 | 
			
		||||
      );
 | 
			
		||||
    } else if (toolName === "download_video_subtitles") {
 | 
			
		||||
      return handleToolExecution(
 | 
			
		||||
        () => downloadSubtitles(args.url, args.language),
 | 
			
		||||
        () => downloadSubtitles(args.url, args.language || CONFIG.download.defaultSubtitleLanguage, CONFIG),
 | 
			
		||||
        "Error downloading subtitles"
 | 
			
		||||
      );
 | 
			
		||||
    } else if (toolName === "download_video") {
 | 
			
		||||
      return handleToolExecution(
 | 
			
		||||
        () => downloadVideo(args.url, args.resolution),
 | 
			
		||||
        () => downloadVideo(args.url, CONFIG, args.resolution as "480p" | "720p" | "1080p" | "best"),
 | 
			
		||||
        "Error downloading video"
 | 
			
		||||
      );
 | 
			
		||||
    } else if (toolName === "download_audio") {
 | 
			
		||||
      return handleToolExecution(
 | 
			
		||||
        () => downloadAudio(args.url, CONFIG),
 | 
			
		||||
        "Error downloading audio"
 | 
			
		||||
      );
 | 
			
		||||
    } else if (toolName === "download_transcript") {
 | 
			
		||||
      return handleToolExecution(
 | 
			
		||||
        () => downloadTranscript(args.url, args.language || CONFIG.download.defaultSubtitleLanguage, CONFIG),
 | 
			
		||||
        "Error downloading transcript"
 | 
			
		||||
      );
 | 
			
		||||
    } else {
 | 
			
		||||
      return {
 | 
			
		||||
        content: [{ type: "text", text: `Unknown tool: ${toolName}` }],
 | 
			
		||||
@@ -529,6 +248,3 @@ async function startServer() {
 | 
			
		||||
 | 
			
		||||
// Start the server and handle potential errors
 | 
			
		||||
startServer().catch(console.error);
 | 
			
		||||
 | 
			
		||||
// 導出錯誤類型供測試使用
 | 
			
		||||
export { VideoDownloadError, ERROR_CODES };
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										65
									
								
								src/modules/audio.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										65
									
								
								src/modules/audio.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,65 @@
 | 
			
		||||
import { readdirSync } from "fs";
 | 
			
		||||
import * as path from "path";
 | 
			
		||||
import type { Config } from "../config.js";
 | 
			
		||||
import { sanitizeFilename } from "../config.js";
 | 
			
		||||
import { _spawnPromise, validateUrl, getFormattedTimestamp, isYouTubeUrl } from "./utils.js";
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Downloads audio from a video URL in the best available quality.
 | 
			
		||||
 * 
 | 
			
		||||
 * @param url - The URL of the video to extract audio from
 | 
			
		||||
 * @param config - Configuration object for download settings
 | 
			
		||||
 * @returns Promise resolving to a success message with the downloaded file path
 | 
			
		||||
 * @throws {Error} When URL is invalid or download fails
 | 
			
		||||
 * 
 | 
			
		||||
 * @example
 | 
			
		||||
 * ```typescript
 | 
			
		||||
 * // Download audio with default settings
 | 
			
		||||
 * const result = await downloadAudio('https://youtube.com/watch?v=...');
 | 
			
		||||
 * console.log(result);
 | 
			
		||||
 * 
 | 
			
		||||
 * // Download audio with custom config
 | 
			
		||||
 * const customResult = await downloadAudio('https://youtube.com/watch?v=...', {
 | 
			
		||||
 *   file: {
 | 
			
		||||
 *     downloadsDir: '/custom/path',
 | 
			
		||||
 *     // ... other config options
 | 
			
		||||
 *   }
 | 
			
		||||
 * });
 | 
			
		||||
 * console.log(customResult);
 | 
			
		||||
 * ```
 | 
			
		||||
 */
 | 
			
		||||
export async function downloadAudio(url: string, config: Config): Promise<string> {
 | 
			
		||||
  const timestamp = getFormattedTimestamp();
 | 
			
		||||
  
 | 
			
		||||
  try {
 | 
			
		||||
    validateUrl(url);
 | 
			
		||||
    
 | 
			
		||||
    const outputTemplate = path.join(
 | 
			
		||||
      config.file.downloadsDir,
 | 
			
		||||
      sanitizeFilename(`%(title)s [%(id)s] ${timestamp}`, config.file) + '.%(ext)s'
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    const format = isYouTubeUrl(url) 
 | 
			
		||||
      ? "140/bestaudio[ext=m4a]/bestaudio"
 | 
			
		||||
      : "bestaudio[ext=m4a]/bestaudio[ext=mp3]/bestaudio";
 | 
			
		||||
 | 
			
		||||
    await _spawnPromise("yt-dlp", [
 | 
			
		||||
      "--verbose",
 | 
			
		||||
      "--progress",
 | 
			
		||||
      "--newline",
 | 
			
		||||
      "--no-mtime",
 | 
			
		||||
      "-f", format,
 | 
			
		||||
      "--output", outputTemplate,
 | 
			
		||||
      url
 | 
			
		||||
    ]);
 | 
			
		||||
 | 
			
		||||
    const files = readdirSync(config.file.downloadsDir);
 | 
			
		||||
    const downloadedFile = files.find(file => file.includes(timestamp));
 | 
			
		||||
    if (!downloadedFile) {
 | 
			
		||||
      throw new Error("Download completed but file not found");
 | 
			
		||||
    }
 | 
			
		||||
    return `Audio successfully downloaded as "${downloadedFile}" to ${config.file.downloadsDir}`;
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    throw error;
 | 
			
		||||
  }
 | 
			
		||||
} 
 | 
			
		||||
							
								
								
									
										169
									
								
								src/modules/subtitle.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										169
									
								
								src/modules/subtitle.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,169 @@
 | 
			
		||||
import * as fs from "fs";
 | 
			
		||||
import * as path from "path";
 | 
			
		||||
import * as os from "os";
 | 
			
		||||
import type { Config } from '../config.js';
 | 
			
		||||
import { _spawnPromise, validateUrl, cleanSubtitleToTranscript } from "./utils.js";
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Lists all available subtitles for a video.
 | 
			
		||||
 * 
 | 
			
		||||
 * @param url - The URL of the video
 | 
			
		||||
 * @returns Promise resolving to a string containing the list of available subtitles
 | 
			
		||||
 * @throws {Error} When URL is invalid or subtitle listing fails
 | 
			
		||||
 * 
 | 
			
		||||
 * @example
 | 
			
		||||
 * ```typescript
 | 
			
		||||
 * try {
 | 
			
		||||
 *   const subtitles = await listSubtitles('https://youtube.com/watch?v=...');
 | 
			
		||||
 *   console.log('Available subtitles:', subtitles);
 | 
			
		||||
 * } catch (error) {
 | 
			
		||||
 *   console.error('Failed to list subtitles:', error);
 | 
			
		||||
 * }
 | 
			
		||||
 * ```
 | 
			
		||||
 */
 | 
			
		||||
export async function listSubtitles(url: string): Promise<string> {
 | 
			
		||||
  if (!validateUrl(url)) {
 | 
			
		||||
    throw new Error('Invalid or unsupported URL format');
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  try {
 | 
			
		||||
    const output = await _spawnPromise('yt-dlp', [
 | 
			
		||||
      '--list-subs',
 | 
			
		||||
      '--write-auto-sub',
 | 
			
		||||
      '--skip-download',
 | 
			
		||||
      '--verbose',
 | 
			
		||||
      url
 | 
			
		||||
    ]);
 | 
			
		||||
    return output;
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    throw error;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Downloads subtitles for a video in the specified language.
 | 
			
		||||
 * 
 | 
			
		||||
 * @param url - The URL of the video
 | 
			
		||||
 * @param language - Language code (e.g., 'en', 'zh-Hant', 'ja')
 | 
			
		||||
 * @param config - Configuration object
 | 
			
		||||
 * @returns Promise resolving to the subtitle content
 | 
			
		||||
 * @throws {Error} When URL is invalid, language is not available, or download fails
 | 
			
		||||
 * 
 | 
			
		||||
 * @example
 | 
			
		||||
 * ```typescript
 | 
			
		||||
 * try {
 | 
			
		||||
 *   // Download English subtitles
 | 
			
		||||
 *   const enSubs = await downloadSubtitles('https://youtube.com/watch?v=...', 'en', config);
 | 
			
		||||
 *   console.log('English subtitles:', enSubs);
 | 
			
		||||
 * 
 | 
			
		||||
 *   // Download Traditional Chinese subtitles
 | 
			
		||||
 *   const zhSubs = await downloadSubtitles('https://youtube.com/watch?v=...', 'zh-Hant', config);
 | 
			
		||||
 *   console.log('Chinese subtitles:', zhSubs);
 | 
			
		||||
 * } catch (error) {
 | 
			
		||||
 *   if (error.message.includes('No subtitle files found')) {
 | 
			
		||||
 *     console.warn('No subtitles available in the requested language');
 | 
			
		||||
 *   } else {
 | 
			
		||||
 *     console.error('Failed to download subtitles:', error);
 | 
			
		||||
 *   }
 | 
			
		||||
 * }
 | 
			
		||||
 * ```
 | 
			
		||||
 */
 | 
			
		||||
export async function downloadSubtitles(
 | 
			
		||||
  url: string,
 | 
			
		||||
  language: string,
 | 
			
		||||
  config: Config
 | 
			
		||||
): Promise<string> {
 | 
			
		||||
  if (!validateUrl(url)) {
 | 
			
		||||
    throw new Error('Invalid or unsupported URL format');
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), config.file.tempDirPrefix));
 | 
			
		||||
 | 
			
		||||
  try {
 | 
			
		||||
    await _spawnPromise('yt-dlp', [
 | 
			
		||||
      '--write-sub',
 | 
			
		||||
      '--write-auto-sub',
 | 
			
		||||
      '--sub-lang', language,
 | 
			
		||||
      '--skip-download',
 | 
			
		||||
      '--output', path.join(tempDir, '%(title)s.%(ext)s'),
 | 
			
		||||
      url
 | 
			
		||||
    ]);
 | 
			
		||||
 | 
			
		||||
    const subtitleFiles = fs.readdirSync(tempDir)
 | 
			
		||||
      .filter(file => file.endsWith('.vtt'));
 | 
			
		||||
 | 
			
		||||
    if (subtitleFiles.length === 0) {
 | 
			
		||||
      throw new Error('No subtitle files found');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    let output = '';
 | 
			
		||||
    for (const file of subtitleFiles) {
 | 
			
		||||
      output += fs.readFileSync(path.join(tempDir, file), 'utf8');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return output;
 | 
			
		||||
  } finally {
 | 
			
		||||
    fs.rmSync(tempDir, { recursive: true, force: true });
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Downloads and cleans subtitles to produce a plain text transcript.
 | 
			
		||||
 * 
 | 
			
		||||
 * @param url - The URL of the video
 | 
			
		||||
 * @param language - Language code (e.g., 'en', 'zh-Hant', 'ja')
 | 
			
		||||
 * @param config - Configuration object
 | 
			
		||||
 * @returns Promise resolving to the cleaned transcript text
 | 
			
		||||
 * @throws {Error} When URL is invalid, language is not available, or download fails
 | 
			
		||||
 * 
 | 
			
		||||
 * @example
 | 
			
		||||
 * ```typescript
 | 
			
		||||
 * try {
 | 
			
		||||
 *   const transcript = await downloadTranscript('https://youtube.com/watch?v=...', 'en', config);
 | 
			
		||||
 *   console.log('Transcript:', transcript);
 | 
			
		||||
 * } catch (error) {
 | 
			
		||||
 *   console.error('Failed to download transcript:', error);
 | 
			
		||||
 * }
 | 
			
		||||
 * ```
 | 
			
		||||
 */
 | 
			
		||||
export async function downloadTranscript(
 | 
			
		||||
  url: string,
 | 
			
		||||
  language: string,
 | 
			
		||||
  config: Config
 | 
			
		||||
): Promise<string> {
 | 
			
		||||
  if (!validateUrl(url)) {
 | 
			
		||||
    throw new Error('Invalid or unsupported URL format');
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), config.file.tempDirPrefix));
 | 
			
		||||
 | 
			
		||||
  try {
 | 
			
		||||
    await _spawnPromise('yt-dlp', [
 | 
			
		||||
      '--skip-download',
 | 
			
		||||
      '--write-subs',
 | 
			
		||||
      '--write-auto-subs',
 | 
			
		||||
      '--sub-lang', language,
 | 
			
		||||
      '--sub-format', 'ttml',
 | 
			
		||||
      '--convert-subs', 'srt',
 | 
			
		||||
      '--output', path.join(tempDir, 'transcript.%(ext)s'),
 | 
			
		||||
      url
 | 
			
		||||
    ]);
 | 
			
		||||
 | 
			
		||||
    const srtFiles = fs.readdirSync(tempDir)
 | 
			
		||||
      .filter(file => file.endsWith('.srt'));
 | 
			
		||||
 | 
			
		||||
    if (srtFiles.length === 0) {
 | 
			
		||||
      throw new Error('No subtitle files found for transcript generation');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    let transcriptContent = '';
 | 
			
		||||
    for (const file of srtFiles) {
 | 
			
		||||
      const srtContent = fs.readFileSync(path.join(tempDir, file), 'utf8');
 | 
			
		||||
      transcriptContent += cleanSubtitleToTranscript(srtContent) + ' ';
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return transcriptContent.trim();
 | 
			
		||||
  } finally {
 | 
			
		||||
    fs.rmSync(tempDir, { recursive: true, force: true });
 | 
			
		||||
  }
 | 
			
		||||
} 
 | 
			
		||||
							
								
								
									
										183
									
								
								src/modules/utils.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										183
									
								
								src/modules/utils.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,183 @@
 | 
			
		||||
import * as fs from 'fs';
 | 
			
		||||
import { spawn } from 'child_process';
 | 
			
		||||
import { randomBytes } from 'crypto';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Validates if a given string is a valid URL.
 | 
			
		||||
 * 
 | 
			
		||||
 * @param url - The URL string to validate
 | 
			
		||||
 * @returns True if the URL is valid, false otherwise
 | 
			
		||||
 * 
 | 
			
		||||
 * @example
 | 
			
		||||
 * ```typescript
 | 
			
		||||
 * if (validateUrl('https://youtube.com/watch?v=...')) {
 | 
			
		||||
 *   // URL is valid
 | 
			
		||||
 * }
 | 
			
		||||
 * ```
 | 
			
		||||
 */
 | 
			
		||||
export function validateUrl(url: string): boolean {
 | 
			
		||||
  try {
 | 
			
		||||
    new URL(url);
 | 
			
		||||
    return true;
 | 
			
		||||
  } catch {
 | 
			
		||||
    return false;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Checks if a URL is from YouTube.
 | 
			
		||||
 * 
 | 
			
		||||
 * @param url - The URL to check
 | 
			
		||||
 * @returns True if the URL is from YouTube, false otherwise
 | 
			
		||||
 * 
 | 
			
		||||
 * @example
 | 
			
		||||
 * ```typescript
 | 
			
		||||
 * if (isYouTubeUrl('https://youtube.com/watch?v=...')) {
 | 
			
		||||
 *   // URL is from YouTube
 | 
			
		||||
 * }
 | 
			
		||||
 * ```
 | 
			
		||||
 */
 | 
			
		||||
export function isYouTubeUrl(url: string): boolean {
 | 
			
		||||
  try {
 | 
			
		||||
    const parsedUrl = new URL(url);
 | 
			
		||||
    return parsedUrl.hostname.includes('youtube.com') || parsedUrl.hostname.includes('youtu.be');
 | 
			
		||||
  } catch {
 | 
			
		||||
    return false;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Safely cleans up a directory and its contents.
 | 
			
		||||
 * 
 | 
			
		||||
 * @param directory - Path to the directory to clean up
 | 
			
		||||
 * @returns Promise that resolves when cleanup is complete
 | 
			
		||||
 * @throws {Error} When directory cannot be removed
 | 
			
		||||
 * 
 | 
			
		||||
 * @example
 | 
			
		||||
 * ```typescript
 | 
			
		||||
 * try {
 | 
			
		||||
 *   await safeCleanup('/path/to/temp/dir');
 | 
			
		||||
 * } catch (error) {
 | 
			
		||||
 *   console.error('Cleanup failed:', error);
 | 
			
		||||
 * }
 | 
			
		||||
 * ```
 | 
			
		||||
 */
 | 
			
		||||
export async function safeCleanup(directory: string): Promise<void> {
 | 
			
		||||
  try {
 | 
			
		||||
    await fs.promises.rm(directory, { recursive: true, force: true });
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error(`Error cleaning up directory ${directory}:`, error);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Spawns a child process and returns its output as a promise.
 | 
			
		||||
 * 
 | 
			
		||||
 * @param command - The command to execute
 | 
			
		||||
 * @param args - Array of command arguments
 | 
			
		||||
 * @returns Promise resolving to the command output
 | 
			
		||||
 * @throws {Error} When command execution fails
 | 
			
		||||
 * 
 | 
			
		||||
 * @example
 | 
			
		||||
 * ```typescript
 | 
			
		||||
 * try {
 | 
			
		||||
 *   const output = await _spawnPromise('yt-dlp', ['--version']);
 | 
			
		||||
 *   console.log('yt-dlp version:', output);
 | 
			
		||||
 * } catch (error) {
 | 
			
		||||
 *   console.error('Command failed:', error);
 | 
			
		||||
 * }
 | 
			
		||||
 * ```
 | 
			
		||||
 */
 | 
			
		||||
export function _spawnPromise(command: string, args: string[]): Promise<string> {
 | 
			
		||||
  return new Promise((resolve, reject) => {
 | 
			
		||||
    const process = spawn(command, args);
 | 
			
		||||
    let output = '';
 | 
			
		||||
 | 
			
		||||
    process.stdout.on('data', (data) => {
 | 
			
		||||
      output += data.toString();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    process.stderr.on('data', (data) => {
 | 
			
		||||
      output += data.toString();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    process.on('close', (code) => {
 | 
			
		||||
      if (code === 0) {
 | 
			
		||||
        resolve(output);
 | 
			
		||||
      } else {
 | 
			
		||||
        reject(new Error(`Failed with exit code: ${code}\n${output}`));
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Generates a formatted timestamp string for file naming.
 | 
			
		||||
 * 
 | 
			
		||||
 * @returns Formatted timestamp string in the format 'YYYY-MM-DD_HH-mm-ss'
 | 
			
		||||
 * 
 | 
			
		||||
 * @example
 | 
			
		||||
 * ```typescript
 | 
			
		||||
 * const timestamp = getFormattedTimestamp();
 | 
			
		||||
 * console.log(timestamp); // '2024-03-20_12-30-00'
 | 
			
		||||
 * ```
 | 
			
		||||
 */
 | 
			
		||||
export function getFormattedTimestamp(): string {
 | 
			
		||||
  return new Date().toISOString()
 | 
			
		||||
    .replace(/[:.]/g, '-')
 | 
			
		||||
    .replace('T', '_')
 | 
			
		||||
    .split('.')[0];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Generates a random filename with timestamp prefix.
 | 
			
		||||
 * 
 | 
			
		||||
 * @param extension - Optional file extension (default: 'mp4')
 | 
			
		||||
 * @returns A random filename with timestamp
 | 
			
		||||
 * 
 | 
			
		||||
 * @example
 | 
			
		||||
 * ```typescript
 | 
			
		||||
 * const filename = generateRandomFilename('mp3');
 | 
			
		||||
 * console.log(filename); // '2024-03-20_12-30-00_a1b2c3d4.mp3'
 | 
			
		||||
 * ```
 | 
			
		||||
 */
 | 
			
		||||
export function generateRandomFilename(extension: string = 'mp4'): string {
 | 
			
		||||
  const timestamp = getFormattedTimestamp();
 | 
			
		||||
  const randomId = randomBytes(4).toString('hex');
 | 
			
		||||
  return `${timestamp}_${randomId}.${extension}`;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Cleans SRT subtitle content to produce a plain text transcript.
 | 
			
		||||
 * Removes timestamps, sequence numbers, and HTML tags.
 | 
			
		||||
 * 
 | 
			
		||||
 * @param srtContent - Raw SRT subtitle content
 | 
			
		||||
 * @returns Cleaned transcript text
 | 
			
		||||
 * 
 | 
			
		||||
 * @example
 | 
			
		||||
 * ```typescript
 | 
			
		||||
 * const cleanedText = cleanSubtitleToTranscript(srtContent);
 | 
			
		||||
 * console.log(cleanedText); // 'Hello world this is a transcript...'
 | 
			
		||||
 * ```
 | 
			
		||||
 */
 | 
			
		||||
export function cleanSubtitleToTranscript(srtContent: string): string {
 | 
			
		||||
  return srtContent
 | 
			
		||||
    .split('\n')
 | 
			
		||||
    .filter(line => {
 | 
			
		||||
      const trimmed = line.trim();
 | 
			
		||||
      // Remove empty lines
 | 
			
		||||
      if (!trimmed) return false;
 | 
			
		||||
      // Remove sequence numbers (lines that are just digits)
 | 
			
		||||
      if (/^\d+$/.test(trimmed)) return false;
 | 
			
		||||
      // Remove timestamp lines
 | 
			
		||||
      if (/^\d{2}:\d{2}:\d{2}[.,]\d{3}\s*-->\s*\d{2}:\d{2}:\d{2}[.,]\d{3}$/.test(trimmed)) return false;
 | 
			
		||||
      return true;
 | 
			
		||||
    })
 | 
			
		||||
    .map(line => {
 | 
			
		||||
      // Remove HTML tags
 | 
			
		||||
      return line.replace(/<[^>]*>/g, '');
 | 
			
		||||
    })
 | 
			
		||||
    .join(' ')
 | 
			
		||||
    .replace(/\s+/g, ' ')
 | 
			
		||||
    .trim();
 | 
			
		||||
} 
 | 
			
		||||
							
								
								
									
										123
									
								
								src/modules/video.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										123
									
								
								src/modules/video.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,123 @@
 | 
			
		||||
import * as path from "path";
 | 
			
		||||
import type { Config } from "../config.js";
 | 
			
		||||
import { sanitizeFilename } from "../config.js";
 | 
			
		||||
import { 
 | 
			
		||||
  _spawnPromise, 
 | 
			
		||||
  validateUrl, 
 | 
			
		||||
  getFormattedTimestamp, 
 | 
			
		||||
  isYouTubeUrl,
 | 
			
		||||
  generateRandomFilename 
 | 
			
		||||
} from "./utils.js";
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Downloads a video from the specified URL.
 | 
			
		||||
 * 
 | 
			
		||||
 * @param url - The URL of the video to download
 | 
			
		||||
 * @param config - Configuration object for download settings
 | 
			
		||||
 * @param resolution - Preferred video resolution ('480p', '720p', '1080p', 'best')
 | 
			
		||||
 * @returns Promise resolving to a success message with the downloaded file path
 | 
			
		||||
 * @throws {Error} When URL is invalid or download fails
 | 
			
		||||
 * 
 | 
			
		||||
 * @example
 | 
			
		||||
 * ```typescript
 | 
			
		||||
 * // Download with default settings
 | 
			
		||||
 * const result = await downloadVideo('https://youtube.com/watch?v=...');
 | 
			
		||||
 * console.log(result);
 | 
			
		||||
 * 
 | 
			
		||||
 * // Download with specific resolution
 | 
			
		||||
 * const hdResult = await downloadVideo(
 | 
			
		||||
 *   'https://youtube.com/watch?v=...',
 | 
			
		||||
 *   undefined,
 | 
			
		||||
 *   '1080p'
 | 
			
		||||
 * );
 | 
			
		||||
 * console.log(hdResult);
 | 
			
		||||
 * ```
 | 
			
		||||
 */
 | 
			
		||||
export async function downloadVideo(
 | 
			
		||||
  url: string,
 | 
			
		||||
  config: Config,
 | 
			
		||||
  resolution: "480p" | "720p" | "1080p" | "best" = "720p"
 | 
			
		||||
): Promise<string> {
 | 
			
		||||
  const userDownloadsDir = config.file.downloadsDir;
 | 
			
		||||
  
 | 
			
		||||
  try {
 | 
			
		||||
    validateUrl(url);
 | 
			
		||||
    const timestamp = getFormattedTimestamp();
 | 
			
		||||
      
 | 
			
		||||
    let format: string;
 | 
			
		||||
    if (isYouTubeUrl(url)) {
 | 
			
		||||
      // YouTube-specific format selection
 | 
			
		||||
      switch (resolution) {
 | 
			
		||||
        case "480p":
 | 
			
		||||
          format = "bestvideo[height<=480]+bestaudio/best[height<=480]/best";
 | 
			
		||||
          break;
 | 
			
		||||
        case "720p":
 | 
			
		||||
          format = "bestvideo[height<=720]+bestaudio/best[height<=720]/best";
 | 
			
		||||
          break;
 | 
			
		||||
        case "1080p":
 | 
			
		||||
          format = "bestvideo[height<=1080]+bestaudio/best[height<=1080]/best";
 | 
			
		||||
          break;
 | 
			
		||||
        case "best":
 | 
			
		||||
          format = "bestvideo+bestaudio/best";
 | 
			
		||||
          break;
 | 
			
		||||
        default:
 | 
			
		||||
          format = "bestvideo[height<=720]+bestaudio/best[height<=720]/best";
 | 
			
		||||
      }
 | 
			
		||||
    } else {
 | 
			
		||||
      // For other platforms, use quality labels that are more generic
 | 
			
		||||
      switch (resolution) {
 | 
			
		||||
        case "480p":
 | 
			
		||||
          format = "worst[height>=480]/best[height<=480]/worst";
 | 
			
		||||
          break;
 | 
			
		||||
        case "best":
 | 
			
		||||
          format = "bestvideo+bestaudio/best";
 | 
			
		||||
          break;
 | 
			
		||||
        default: // Including 720p and 1080p cases
 | 
			
		||||
          // Prefer HD quality but fallback to best available
 | 
			
		||||
          format = "bestvideo[height>=720]+bestaudio/best[height>=720]/best";
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    let outputTemplate: string;
 | 
			
		||||
    let expectedFilename: string;
 | 
			
		||||
    
 | 
			
		||||
    try {
 | 
			
		||||
      // 嘗試獲取檔案名稱
 | 
			
		||||
      outputTemplate = path.join(
 | 
			
		||||
        userDownloadsDir,
 | 
			
		||||
        sanitizeFilename(`%(title)s [%(id)s] ${timestamp}`, config.file) + '.%(ext)s'
 | 
			
		||||
      );
 | 
			
		||||
      
 | 
			
		||||
      expectedFilename = await _spawnPromise("yt-dlp", [
 | 
			
		||||
        "--get-filename",
 | 
			
		||||
        "-f", format,
 | 
			
		||||
        "--output", outputTemplate,
 | 
			
		||||
        url
 | 
			
		||||
      ]);
 | 
			
		||||
      expectedFilename = expectedFilename.trim();
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      // 如果無法獲取檔案名稱,使用隨機檔案名
 | 
			
		||||
      const randomFilename = generateRandomFilename('mp4');
 | 
			
		||||
      outputTemplate = path.join(userDownloadsDir, randomFilename);
 | 
			
		||||
      expectedFilename = randomFilename;
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // Download with progress info
 | 
			
		||||
    try {
 | 
			
		||||
      await _spawnPromise("yt-dlp", [
 | 
			
		||||
        "--progress",
 | 
			
		||||
        "--newline",
 | 
			
		||||
        "--no-mtime",
 | 
			
		||||
        "-f", format,
 | 
			
		||||
        "--output", outputTemplate,
 | 
			
		||||
        url
 | 
			
		||||
      ]);
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      throw new Error(`Download failed: ${error instanceof Error ? error.message : String(error)}`);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return `Video successfully downloaded as "${path.basename(expectedFilename)}" to ${userDownloadsDir}`;
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    throw error;
 | 
			
		||||
  }
 | 
			
		||||
} 
 | 
			
		||||
							
								
								
									
										23
									
								
								src/utils/test-utils.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								src/utils/test-utils.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,23 @@
 | 
			
		||||
import type { Config } from '../config.js';
 | 
			
		||||
 | 
			
		||||
export const mockConfig: Config = {
 | 
			
		||||
  file: {
 | 
			
		||||
    maxFilenameLength: 100,
 | 
			
		||||
    downloadsDir: '/mock/downloads',
 | 
			
		||||
    tempDirPrefix: 'ytdlp-test-',
 | 
			
		||||
    sanitize: {
 | 
			
		||||
      replaceChar: '_',
 | 
			
		||||
      truncateSuffix: '...',
 | 
			
		||||
      illegalChars: /[<>:"/\\|?*\x00-\x1F]/g,
 | 
			
		||||
      reservedNames: ['CON', 'PRN', 'AUX', 'NUL']
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  tools: {
 | 
			
		||||
    required: ['yt-dlp']
 | 
			
		||||
  },
 | 
			
		||||
  download: {
 | 
			
		||||
    defaultResolution: '720p',
 | 
			
		||||
    defaultAudioFormat: 'm4a',
 | 
			
		||||
    defaultSubtitleLanguage: 'en'
 | 
			
		||||
  }
 | 
			
		||||
}; 
 | 
			
		||||
		Reference in New Issue
	
	Block a user