18 Commits

Author SHA1 Message Date
Kevin Watt
f27d22eb81 Revert "Revert "feat: add transcript download functionality"" 2025-05-30 12:03:04 +08:00
Kevin Watt
0de9308a41 Merge pull request #8 from kevinwatt/revert-7-feature/transcript-download
Revert "feat: add transcript download functionality"
2025-05-30 11:59:39 +08:00
Kevin Watt
c79766c241 Revert "feat: add transcript download functionality" 2025-05-30 11:57:52 +08:00
Kevin Watt
4171abc6d0 Merge pull request #7 from msuch/feature/transcript-download
Looks great to me. Thanks for the commit.

feat: add transcript download functionality
2025-05-30 11:56:47 +08:00
m
7900a9b4e1 feat: add transcript download functionality
- Add cleanSubtitleToTranscript utility to strip SRT formatting, timestamps, and HTML tags
- Implement downloadTranscript function using yt-dlp with subtitle cleaning
- Add download_transcript MCP tool with language support (defaults to English)
- Include comprehensive tests for both utility and download functionality
- Update README documentation with tool description and usage examples

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-05-27 12:00:31 +02:00
kevinwatt
944b0211c6 feat: add random filename fallback when filename cannot be retrieved - Add generateRandomFilename utility function - Modify downloadVideo to use random filename when yt-dlp fails to get filename - Update version to 0.6.26 2025-02-23 05:53:58 +08:00
kevinwatt
c39fd8785c update README.md 2025-02-22 03:24:44 +08:00
kevinwatt
e9a0e55762 feat: major improvements and version bump to 0.6.24 - Remove prompts functionality (prompts.ts and tests) - Improve error handling with VideoDownloadError class - Move configuration to dedicated file - Add URL validation and security checks - Reorganize code into modules - Add comprehensive unit tests - Enhance documentation with JSDoc and examples 2025-02-22 00:43:15 +08:00
kevinwatt
21689391bd add download_audio tool to README.md 2025-02-21 17:43:33 +08:00
kevinwatt
5152ad4d17 fix yt-dlp error handling for audio download 2025-02-21 17:40:35 +08:00
kevinwatt
c4dcc0eda2 v0.6.23 2025-02-21 17:22:47 +08:00
kevin
12fa5dbffe v0.6.22 2025-02-21 17:19:13 +08:00
kevin
b3e8ed5f58 feat: improve audio download support
- Add support for various audio formats (m4a/mp3)
- Update audio download format selection logic
- Improve error handling and filename display
- Bump version to 0.6.22
2025-02-21 17:14:28 +08:00
kevin
576549bc2c update description in README.md 2025-02-21 16:56:45 +08:00
kevin
9c25179fab more descriptive description 2025-02-21 16:55:31 +08:00
kevin
7710184faf fix: update description 2025-02-21 16:54:25 +08:00
kevin
adf1b7178c new description for package.json 2025-02-21 16:52:56 +08:00
kevin
58384bb1a2 fix: improve subtitle handling and tool names
- Rename list_video_subtitles to list_subtitle_languages for clarity
- Update tool descriptions to better reflect functionality
- Improve subtitle listing output format
- Simplify subtitle download parameters
- Add verbose logging for better debugging
- Bump version to 0.6.21
2025-02-21 16:42:12 +08:00
18 changed files with 1726 additions and 415 deletions

1
.gitignore vendored
View File

@@ -28,7 +28,6 @@ node_modules
lib
test-dist
docs
# WebStorm
.idea/

View File

@@ -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
View 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
View 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
View 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
View 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
View File

@@ -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",

View File

@@ -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",

View 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();
});
});

View File

@@ -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();
});
});

View 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
View 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();

View File

@@ -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
View 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
View 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
View 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
View 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
View 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'
}
};