mirror of
https://github.com/kevinwatt/yt-dlp-mcp.git
synced 2025-08-10 16:09:14 +03:00
Compare commits
12 Commits
da7e4666ed
...
9d14f6bc01
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d14f6bc01 | ||
|
|
fa879ab9ab | ||
|
|
5aecaa3b20 | ||
|
|
9ba39128aa | ||
|
|
353bc8fd22 | ||
|
|
53437dc472 | ||
|
|
cc2b9ec8b6 | ||
|
|
7278b672f4 | ||
|
|
83a2eb9bb8 | ||
|
|
bbc0e6aa93 | ||
|
|
8cf7b3f5dc | ||
|
|
01709a778b |
11
README.md
11
README.md
@@ -49,6 +49,12 @@ pip install yt-dlp
|
||||
|
||||
## Tool Documentation
|
||||
|
||||
* **search_videos**
|
||||
* Search for videos on YouTube using keywords
|
||||
* Inputs:
|
||||
* `query` (string, required): Search keywords or phrase
|
||||
* `maxResults` (number, optional): Maximum number of results to return (1-50, default: 10)
|
||||
|
||||
* **list_subtitle_languages**
|
||||
* List all available subtitle languages and their formats for a video (including auto-generated captions)
|
||||
* Inputs:
|
||||
@@ -65,6 +71,8 @@ pip install yt-dlp
|
||||
* Inputs:
|
||||
* `url` (string, required): URL of the video
|
||||
* `resolution` (string, optional): Video resolution ('480p', '720p', '1080p', 'best'). Defaults to '720p'
|
||||
* `startTime` (string, optional): Start time for trimming (format: HH:MM:SS[.ms]) - e.g., '00:01:30' or '00:01:30.500'
|
||||
* `endTime` (string, optional): End time for trimming (format: HH:MM:SS[.ms]) - e.g., '00:02:45' or '00:02:45.500'
|
||||
|
||||
* **download_audio**
|
||||
* Download audio in best available quality (usually m4a/mp3 format) to user's Downloads folder
|
||||
@@ -81,6 +89,9 @@ pip install yt-dlp
|
||||
|
||||
Ask your LLM to:
|
||||
```
|
||||
"Search for Python tutorial videos"
|
||||
"Find JavaScript courses and show me the top 5 results"
|
||||
"Search for machine learning tutorials with 15 results"
|
||||
"List available subtitles for this video: https://youtube.com/watch?v=..."
|
||||
"Download a video from facebook: https://facebook.com/..."
|
||||
"Download Chinese subtitles from this video: https://youtube.com/watch?v=..."
|
||||
|
||||
26
docs/api.md
26
docs/api.md
@@ -2,14 +2,16 @@
|
||||
|
||||
## Video Operations
|
||||
|
||||
### downloadVideo(url: string, config?: Config, resolution?: string): Promise<string>
|
||||
### downloadVideo(url: string, config?: Config, resolution?: string, startTime?: string, endTime?: string): Promise<string>
|
||||
|
||||
Downloads a video from the specified URL.
|
||||
Downloads a video from the specified URL with optional trimming.
|
||||
|
||||
**Parameters:**
|
||||
- `url`: The URL of the video to download
|
||||
- `config`: (Optional) Configuration object
|
||||
- `resolution`: (Optional) Preferred video resolution ('480p', '720p', '1080p', 'best')
|
||||
- `startTime`: (Optional) Start time for trimming (format: HH:MM:SS[.ms])
|
||||
- `endTime`: (Optional) End time for trimming (format: HH:MM:SS[.ms])
|
||||
|
||||
**Returns:**
|
||||
- Promise resolving to a success message with the downloaded file path
|
||||
@@ -29,6 +31,26 @@ const hdResult = await downloadVideo(
|
||||
'1080p'
|
||||
);
|
||||
console.log(hdResult);
|
||||
|
||||
// Download with trimming
|
||||
const trimmedResult = await downloadVideo(
|
||||
'https://www.youtube.com/watch?v=jNQXAC9IVRw',
|
||||
undefined,
|
||||
'720p',
|
||||
'00:01:30',
|
||||
'00:02:45'
|
||||
);
|
||||
console.log(trimmedResult);
|
||||
|
||||
// Download with fractional seconds
|
||||
const preciseTrim = await downloadVideo(
|
||||
'https://www.youtube.com/watch?v=jNQXAC9IVRw',
|
||||
undefined,
|
||||
'720p',
|
||||
'00:01:30.500',
|
||||
'00:02:45.250'
|
||||
);
|
||||
console.log(preciseTrim);
|
||||
```
|
||||
|
||||
## Audio Operations
|
||||
|
||||
@@ -4,17 +4,20 @@
|
||||
|
||||
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
|
||||
```
|
||||
@@ -31,7 +34,7 @@ git checkout -b feature/your-feature-name
|
||||
### Building
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm run prepare
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
@@ -41,6 +44,7 @@ npm test
|
||||
```
|
||||
|
||||
For specific test files:
|
||||
|
||||
```bash
|
||||
npm test -- src/__tests__/video.test.ts
|
||||
```
|
||||
@@ -72,10 +76,10 @@ interface DownloadOptions {
|
||||
|
||||
// Use enums for fixed values
|
||||
enum Resolution {
|
||||
SD = '480p',
|
||||
HD = '720p',
|
||||
FHD = '1080p',
|
||||
BEST = 'best'
|
||||
SD = "480p",
|
||||
HD = "720p",
|
||||
FHD = "1080p",
|
||||
BEST = "best",
|
||||
}
|
||||
```
|
||||
|
||||
@@ -91,16 +95,16 @@ enum Resolution {
|
||||
Example:
|
||||
|
||||
```typescript
|
||||
describe('downloadVideo', () => {
|
||||
test('downloads video successfully', async () => {
|
||||
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("handles invalid URL", async () => {
|
||||
await expect(downloadVideo("invalid-url")).rejects.toThrow(
|
||||
"Invalid or unsupported URL"
|
||||
);
|
||||
});
|
||||
});
|
||||
```
|
||||
@@ -108,6 +112,7 @@ describe('downloadVideo', () => {
|
||||
### Test Coverage
|
||||
|
||||
Aim for high test coverage:
|
||||
|
||||
```bash
|
||||
npm run test:coverage
|
||||
```
|
||||
@@ -118,16 +123,16 @@ npm run test:coverage
|
||||
|
||||
Add comprehensive JSDoc comments for all public APIs:
|
||||
|
||||
```typescript
|
||||
````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);
|
||||
@@ -141,7 +146,7 @@ export async function downloadVideo(
|
||||
): Promise<string> {
|
||||
// Implementation
|
||||
}
|
||||
```
|
||||
````
|
||||
|
||||
### README Updates
|
||||
|
||||
@@ -177,6 +182,7 @@ export async function downloadVideo(
|
||||
### Version Numbers
|
||||
|
||||
Follow semantic versioning:
|
||||
|
||||
- MAJOR: Breaking changes
|
||||
- MINOR: New features
|
||||
- PATCH: Bug fixes
|
||||
@@ -189,4 +195,4 @@ Follow semantic versioning:
|
||||
- Suggest improvements
|
||||
- Share success stories
|
||||
|
||||
For more information, see the [README](./README.md) and [API Reference](./api.md).
|
||||
For more information, see the [README](./README.md) and [API Reference](./api.md).
|
||||
|
||||
77
docs/search-feature-demo.md
Normal file
77
docs/search-feature-demo.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# Search Feature Demo
|
||||
|
||||
The search functionality has been successfully added to yt-dlp-mcp! This feature allows you to search for videos on YouTube using keywords and get formatted results with video information.
|
||||
|
||||
## New Tool: `search_videos`
|
||||
|
||||
### Description
|
||||
Search for videos on YouTube using keywords. Returns title, uploader, duration, and URL for each result.
|
||||
|
||||
### Parameters
|
||||
- `query` (string, required): Search keywords or phrase
|
||||
- `maxResults` (number, optional): Maximum number of results to return (1-50, default: 10)
|
||||
|
||||
### Example Usage
|
||||
|
||||
Ask your LLM to:
|
||||
```
|
||||
"Search for Python tutorial videos"
|
||||
"Find JavaScript courses and show me the top 5 results"
|
||||
"Search for machine learning tutorials with 15 results"
|
||||
```
|
||||
|
||||
### Example Output
|
||||
|
||||
When searching for "javascript tutorial" with 3 results, you'll get:
|
||||
|
||||
```
|
||||
Found 3 videos:
|
||||
|
||||
1. **JavaScript Tutorial Full Course - Beginner to Pro**
|
||||
📺 Channel: Traversy Media
|
||||
⏱️ Duration: 15663
|
||||
🔗 URL: https://www.youtube.com/watch?v=EerdGm-ehJQ
|
||||
🆔 ID: EerdGm-ehJQ
|
||||
|
||||
2. **JavaScript Course for Beginners**
|
||||
📺 Channel: FreeCodeCamp.org
|
||||
⏱️ Duration: 12402
|
||||
🔗 URL: https://www.youtube.com/watch?v=W6NZfCO5SIk
|
||||
🆔 ID: W6NZfCO5SIk
|
||||
|
||||
3. **JavaScript Full Course for free 🌐 (2024)**
|
||||
📺 Channel: Bro Code
|
||||
⏱️ Duration: 43200
|
||||
🔗 URL: https://www.youtube.com/watch?v=lfmg-EJ8gm4
|
||||
🆔 ID: lfmg-EJ8gm4
|
||||
|
||||
💡 You can use any URL to download videos, audio, or subtitles!
|
||||
```
|
||||
|
||||
## Integration with Existing Features
|
||||
|
||||
After searching for videos, you can directly use the returned URLs with other tools:
|
||||
|
||||
1. **Download video**: Use the URL with `download_video`
|
||||
2. **Download audio**: Use the URL with `download_audio`
|
||||
3. **Get subtitles**: Use the URL with `list_subtitle_languages` or `download_video_subtitles`
|
||||
4. **Get transcript**: Use the URL with `download_transcript`
|
||||
|
||||
## Test Results
|
||||
|
||||
All search functionality tests pass:
|
||||
- ✅ Successfully search and format results
|
||||
- ✅ Reject empty search queries
|
||||
- ✅ Validate maxResults parameter range
|
||||
- ✅ Handle search with different result counts
|
||||
- ✅ Return properly formatted results
|
||||
- ✅ Handle obscure search terms gracefully
|
||||
|
||||
## Implementation Details
|
||||
|
||||
The search feature uses yt-dlp's built-in search capability with the syntax:
|
||||
- `ytsearch[N]:[query]` where N is the number of results
|
||||
- Uses `--print` options to extract: title, id, uploader, duration
|
||||
- Results are formatted in a user-friendly way with emojis and clear structure
|
||||
|
||||
This addresses the feature request from [Issue #14](https://github.com/kevinwatt/yt-dlp-mcp/issues/14) and provides a seamless search experience for users.
|
||||
1398
package-lock.json
generated
1398
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@kevinwatt/yt-dlp-mcp",
|
||||
"version": "0.6.26",
|
||||
"version": "0.6.27",
|
||||
"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",
|
||||
@@ -26,7 +26,7 @@
|
||||
],
|
||||
"main": "./lib/index.mjs",
|
||||
"scripts": {
|
||||
"prepare": "tsc && shx chmod +x ./lib/index.mjs",
|
||||
"prepare": "tsc --skipLibCheck && chmod +x ./lib/index.mjs",
|
||||
"test": "PYTHONPATH= PYTHONHOME= node --experimental-vm-modules node_modules/jest/bin/jest.js --detectOpenHandles --forceExit"
|
||||
},
|
||||
"author": "Dewei Yen <k@funmula.com>",
|
||||
|
||||
69
src/__tests__/search.test.ts
Normal file
69
src/__tests__/search.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
// @ts-nocheck
|
||||
// @jest-environment node
|
||||
import { describe, test, expect } from '@jest/globals';
|
||||
import { searchVideos } from '../modules/search.js';
|
||||
import { CONFIG } from '../config.js';
|
||||
|
||||
describe('Search functionality tests', () => {
|
||||
|
||||
describe('searchVideos', () => {
|
||||
test('should successfully search for JavaScript tutorials', async () => {
|
||||
const result = await searchVideos('javascript tutorial', 3, CONFIG);
|
||||
|
||||
expect(result).toContain('Found 3 videos');
|
||||
expect(result).toContain('Channel:');
|
||||
expect(result).toContain('Duration:');
|
||||
expect(result).toContain('URL:');
|
||||
expect(result).toContain('ID:');
|
||||
expect(result).toContain('https://www.youtube.com/watch?v=');
|
||||
expect(result).toContain('You can use any URL to download videos, audio, or subtitles!');
|
||||
}, 30000); // Increase timeout for real network calls
|
||||
|
||||
test('should reject empty search queries', async () => {
|
||||
await expect(searchVideos('', 10, CONFIG)).rejects.toThrow('Search query cannot be empty');
|
||||
await expect(searchVideos(' ', 10, CONFIG)).rejects.toThrow('Search query cannot be empty');
|
||||
});
|
||||
|
||||
test('should validate maxResults parameter range', async () => {
|
||||
await expect(searchVideos('test', 0, CONFIG)).rejects.toThrow('Number of results must be between 1 and 50');
|
||||
await expect(searchVideos('test', 51, CONFIG)).rejects.toThrow('Number of results must be between 1 and 50');
|
||||
});
|
||||
|
||||
test('should handle search with different result counts', async () => {
|
||||
const result1 = await searchVideos('python programming', 1, CONFIG);
|
||||
const result5 = await searchVideos('python programming', 5, CONFIG);
|
||||
|
||||
expect(result1).toContain('Found 1 video');
|
||||
expect(result5).toContain('Found 5 videos');
|
||||
|
||||
// Count number of video entries (each video has a numbered entry)
|
||||
const count1 = (result1.match(/^\d+\./gm) || []).length;
|
||||
const count5 = (result5.match(/^\d+\./gm) || []).length;
|
||||
|
||||
expect(count1).toBe(1);
|
||||
expect(count5).toBe(5);
|
||||
}, 30000);
|
||||
|
||||
test('should return properly formatted results', async () => {
|
||||
const result = await searchVideos('react tutorial', 2, CONFIG);
|
||||
|
||||
// Check for proper formatting
|
||||
expect(result).toMatch(/Found \d+ videos?:/);
|
||||
expect(result).toMatch(/\d+\. \*\*.*\*\*/); // Numbered list with bold titles
|
||||
expect(result).toMatch(/📺 Channel: .+/);
|
||||
expect(result).toMatch(/⏱️ Duration: .+/);
|
||||
expect(result).toMatch(/🔗 URL: https:\/\/www\.youtube\.com\/watch\?v=.+/);
|
||||
expect(result).toMatch(/🆔 ID: .+/);
|
||||
}, 30000);
|
||||
|
||||
test('should handle obscure search terms gracefully', async () => {
|
||||
// Using a very specific and unlikely search term
|
||||
const result = await searchVideos('asdfghjklqwertyuiopzxcvbnm12345', 1, CONFIG);
|
||||
|
||||
// Even obscure terms should return some results, as YouTube's search is quite broad
|
||||
// But if no results, it should be handled gracefully
|
||||
expect(typeof result).toBe('string');
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
}, 30000);
|
||||
});
|
||||
});
|
||||
68
src/__tests__/video.test.ts
Normal file
68
src/__tests__/video.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
// @ts-nocheck
|
||||
// @jest-environment node
|
||||
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';
|
||||
|
||||
// 設置 Python 環境
|
||||
process.env.PYTHONPATH = '';
|
||||
process.env.PYTHONHOME = '';
|
||||
|
||||
describe('downloadVideo with trimming', () => {
|
||||
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 });
|
||||
});
|
||||
|
||||
test('downloads video with start time trimming', async () => {
|
||||
const result = await downloadVideo(testUrl, testConfig, '720p', '00:00:10');
|
||||
expect(result).toContain('Video successfully downloaded');
|
||||
|
||||
const files = await fs.promises.readdir(testConfig.file.downloadsDir);
|
||||
expect(files.length).toBeGreaterThan(0);
|
||||
expect(files[0]).toMatch(/\.(mp4|webm|mkv)$/);
|
||||
}, 30000);
|
||||
|
||||
test('downloads video with end time trimming', async () => {
|
||||
const result = await downloadVideo(testUrl, testConfig, '720p', undefined, '00:00:20');
|
||||
expect(result).toContain('Video successfully downloaded');
|
||||
|
||||
const files = await fs.promises.readdir(testConfig.file.downloadsDir);
|
||||
expect(files.length).toBeGreaterThan(0);
|
||||
expect(files[0]).toMatch(/\.(mp4|webm|mkv)$/);
|
||||
}, 30000);
|
||||
|
||||
test('downloads video with both start and end time trimming', async () => {
|
||||
const result = await downloadVideo(testUrl, testConfig, '720p', '00:00:10', '00:00:20');
|
||||
expect(result).toContain('Video successfully downloaded');
|
||||
|
||||
const files = await fs.promises.readdir(testConfig.file.downloadsDir);
|
||||
expect(files.length).toBeGreaterThan(0);
|
||||
expect(files[0]).toMatch(/\.(mp4|webm|mkv)$/);
|
||||
}, 30000);
|
||||
|
||||
test('downloads video without trimming when no times provided', async () => {
|
||||
const result = await downloadVideo(testUrl, testConfig, '720p');
|
||||
expect(result).toContain('Video successfully downloaded');
|
||||
|
||||
const files = await fs.promises.readdir(testConfig.file.downloadsDir);
|
||||
expect(files.length).toBeGreaterThan(0);
|
||||
expect(files[0]).toMatch(/\.(mp4|webm|mkv)$/);
|
||||
}, 30000);
|
||||
});
|
||||
@@ -6,31 +6,31 @@ type DeepPartial<T> = {
|
||||
};
|
||||
|
||||
/**
|
||||
* 配置類型定義
|
||||
* Configuration type definitions
|
||||
*/
|
||||
export interface Config {
|
||||
// 文件相關配置
|
||||
// File-related configuration
|
||||
file: {
|
||||
maxFilenameLength: number;
|
||||
downloadsDir: string;
|
||||
tempDirPrefix: string;
|
||||
// 文件名處理相關配置
|
||||
// Filename processing configuration
|
||||
sanitize: {
|
||||
// 替換非法字符為此字符
|
||||
// Character to replace illegal characters
|
||||
replaceChar: string;
|
||||
// 文件名截斷時的後綴
|
||||
// Suffix when truncating filenames
|
||||
truncateSuffix: string;
|
||||
// 非法字符正則表達式
|
||||
// Regular expression for illegal characters
|
||||
illegalChars: RegExp;
|
||||
// 保留字列表
|
||||
// List of reserved names
|
||||
reservedNames: readonly string[];
|
||||
};
|
||||
};
|
||||
// 工具相關配置
|
||||
// Tool-related configuration
|
||||
tools: {
|
||||
required: readonly string[];
|
||||
};
|
||||
// 下載相關配置
|
||||
// Download-related configuration
|
||||
download: {
|
||||
defaultResolution: "480p" | "720p" | "1080p" | "best";
|
||||
defaultAudioFormat: "m4a" | "mp3";
|
||||
@@ -39,7 +39,7 @@ export interface Config {
|
||||
}
|
||||
|
||||
/**
|
||||
* 默認配置
|
||||
* Default configuration
|
||||
*/
|
||||
const defaultConfig: Config = {
|
||||
file: {
|
||||
@@ -49,7 +49,7 @@ const defaultConfig: Config = {
|
||||
sanitize: {
|
||||
replaceChar: '_',
|
||||
truncateSuffix: '...',
|
||||
illegalChars: /[<>:"/\\|?*\x00-\x1F]/g, // Windows 非法字符
|
||||
illegalChars: /[<>:"/\\|?*\x00-\x1F]/g, // Windows illegal characters
|
||||
reservedNames: [
|
||||
'CON', 'PRN', 'AUX', 'NUL', 'COM1', 'COM2', 'COM3', 'COM4',
|
||||
'COM5', 'COM6', 'COM7', 'COM8', 'COM9', 'LPT1', 'LPT2',
|
||||
@@ -68,12 +68,12 @@ const defaultConfig: Config = {
|
||||
};
|
||||
|
||||
/**
|
||||
* 從環境變數加載配置
|
||||
* Load configuration from environment variables
|
||||
*/
|
||||
function loadEnvConfig(): DeepPartial<Config> {
|
||||
const envConfig: DeepPartial<Config> = {};
|
||||
|
||||
// 文件配置
|
||||
// File configuration
|
||||
const fileConfig: DeepPartial<Config['file']> = {
|
||||
sanitize: {
|
||||
replaceChar: process.env.YTDLP_SANITIZE_REPLACE_CHAR,
|
||||
@@ -97,7 +97,7 @@ function loadEnvConfig(): DeepPartial<Config> {
|
||||
envConfig.file = fileConfig;
|
||||
}
|
||||
|
||||
// 下載配置
|
||||
// Download configuration
|
||||
const downloadConfig: Partial<Config['download']> = {};
|
||||
if (process.env.YTDLP_DEFAULT_RESOLUTION &&
|
||||
['480p', '720p', '1080p', 'best'].includes(process.env.YTDLP_DEFAULT_RESOLUTION)) {
|
||||
@@ -118,42 +118,42 @@ function loadEnvConfig(): DeepPartial<Config> {
|
||||
}
|
||||
|
||||
/**
|
||||
* 驗證配置
|
||||
* Validate configuration
|
||||
*/
|
||||
function validateConfig(config: Config): void {
|
||||
// 驗證文件名長度
|
||||
// Validate filename length
|
||||
if (config.file.maxFilenameLength < 5) {
|
||||
throw new Error('maxFilenameLength must be at least 5');
|
||||
}
|
||||
|
||||
// 驗證下載目錄
|
||||
// Validate downloads directory
|
||||
if (!config.file.downloadsDir) {
|
||||
throw new Error('downloadsDir must be specified');
|
||||
}
|
||||
|
||||
// 驗證臨時目錄前綴
|
||||
// Validate temporary directory prefix
|
||||
if (!config.file.tempDirPrefix) {
|
||||
throw new Error('tempDirPrefix must be specified');
|
||||
}
|
||||
|
||||
// 驗證默認分辨率
|
||||
// Validate default resolution
|
||||
if (!['480p', '720p', '1080p', 'best'].includes(config.download.defaultResolution)) {
|
||||
throw new Error('Invalid defaultResolution');
|
||||
}
|
||||
|
||||
// 驗證默認音頻格式
|
||||
// Validate default audio format
|
||||
if (!['m4a', 'mp3'].includes(config.download.defaultAudioFormat)) {
|
||||
throw new Error('Invalid defaultAudioFormat');
|
||||
}
|
||||
|
||||
// 驗證默認字幕語言
|
||||
// Validate default subtitle language
|
||||
if (!/^[a-z]{2,3}(-[A-Z][a-z]{3})?(-[A-Z]{2})?$/i.test(config.download.defaultSubtitleLanguage)) {
|
||||
throw new Error('Invalid defaultSubtitleLanguage');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 合併配置
|
||||
* Merge configuration
|
||||
*/
|
||||
function mergeConfig(base: Config, override: DeepPartial<Config>): Config {
|
||||
return {
|
||||
@@ -180,7 +180,7 @@ function mergeConfig(base: Config, override: DeepPartial<Config>): Config {
|
||||
}
|
||||
|
||||
/**
|
||||
* 加載配置
|
||||
* Load configuration
|
||||
*/
|
||||
export function loadConfig(): Config {
|
||||
const envConfig = loadEnvConfig();
|
||||
@@ -190,19 +190,19 @@ export function loadConfig(): Config {
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全的文件名處理函數
|
||||
* Safe filename processing function
|
||||
*/
|
||||
export function sanitizeFilename(filename: string, config: Config['file']): string {
|
||||
// 移除非法字符
|
||||
// Remove illegal characters
|
||||
let safe = filename.replace(config.sanitize.illegalChars, config.sanitize.replaceChar);
|
||||
|
||||
// 檢查保留字
|
||||
// Check reserved names
|
||||
const basename = path.parse(safe).name.toUpperCase();
|
||||
if (config.sanitize.reservedNames.includes(basename)) {
|
||||
safe = `_${safe}`;
|
||||
}
|
||||
|
||||
// 處理長度限制
|
||||
// Handle length limitation
|
||||
if (safe.length > config.maxFilenameLength) {
|
||||
const ext = path.extname(safe);
|
||||
const name = safe.slice(0, config.maxFilenameLength - ext.length - config.sanitize.truncateSuffix.length);
|
||||
@@ -212,5 +212,5 @@ export function sanitizeFilename(filename: string, config: Config['file']): stri
|
||||
return safe;
|
||||
}
|
||||
|
||||
// 導出當前配置實例
|
||||
// Export current configuration instance
|
||||
export const CONFIG = loadConfig();
|
||||
@@ -16,8 +16,9 @@ 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";
|
||||
import { searchVideos } from "./modules/search.js";
|
||||
|
||||
const VERSION = '0.6.26';
|
||||
const VERSION = '0.6.27';
|
||||
|
||||
/**
|
||||
* Validate system configuration
|
||||
@@ -97,6 +98,23 @@ const server = new Server(
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
return {
|
||||
tools: [
|
||||
{
|
||||
name: "search_videos",
|
||||
description: "Search for videos on YouTube using keywords. Returns title, uploader, duration, and URL for each result.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "Search keywords or phrase" },
|
||||
maxResults: {
|
||||
type: "number",
|
||||
description: "Maximum number of results to return (1-50, default: 10)",
|
||||
minimum: 1,
|
||||
maximum: 50
|
||||
},
|
||||
},
|
||||
required: ["query"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_subtitle_languages",
|
||||
description: "List all available subtitle languages and their formats for a video (including auto-generated captions)",
|
||||
@@ -128,11 +146,19 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
type: "object",
|
||||
properties: {
|
||||
url: { type: "string", description: "URL of the video" },
|
||||
resolution: {
|
||||
type: "string",
|
||||
resolution: {
|
||||
type: "string",
|
||||
description: "Preferred video resolution. For YouTube: '480p', '720p', '1080p', 'best'. For other platforms: '480p' for low quality, '720p'/'1080p' for HD, 'best' for highest quality. Defaults to '720p'",
|
||||
enum: ["480p", "720p", "1080p", "best"]
|
||||
},
|
||||
startTime: {
|
||||
type: "string",
|
||||
description: "Start time for trimming (format: HH:MM:SS[.ms]) - e.g., '00:01:30' or '00:01:30.500'"
|
||||
},
|
||||
endTime: {
|
||||
type: "string",
|
||||
description: "End time for trimming (format: HH:MM:SS[.ms]) - e.g., '00:02:45' or '00:02:45.500'"
|
||||
},
|
||||
},
|
||||
required: ["url"],
|
||||
},
|
||||
@@ -197,13 +223,22 @@ server.setRequestHandler(
|
||||
CallToolRequestSchema,
|
||||
async (request: CallToolRequest) => {
|
||||
const toolName = request.params.name;
|
||||
const args = request.params.arguments as {
|
||||
const args = request.params.arguments as {
|
||||
url: string;
|
||||
language?: string;
|
||||
resolution?: string;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
query?: string;
|
||||
maxResults?: number;
|
||||
};
|
||||
|
||||
if (toolName === "list_subtitle_languages") {
|
||||
if (toolName === "search_videos") {
|
||||
return handleToolExecution(
|
||||
() => searchVideos(args.query!, args.maxResults || 10, CONFIG),
|
||||
"Error searching videos"
|
||||
);
|
||||
} else if (toolName === "list_subtitle_languages") {
|
||||
return handleToolExecution(
|
||||
() => listSubtitles(args.url),
|
||||
"Error listing subtitle languages"
|
||||
@@ -215,7 +250,13 @@ server.setRequestHandler(
|
||||
);
|
||||
} else if (toolName === "download_video") {
|
||||
return handleToolExecution(
|
||||
() => downloadVideo(args.url, CONFIG, args.resolution as "480p" | "720p" | "1080p" | "best"),
|
||||
() => downloadVideo(
|
||||
args.url,
|
||||
CONFIG,
|
||||
args.resolution as "480p" | "720p" | "1080p" | "best",
|
||||
args.startTime,
|
||||
args.endTime
|
||||
),
|
||||
"Error downloading video"
|
||||
);
|
||||
} else if (toolName === "download_audio") {
|
||||
|
||||
@@ -30,20 +30,22 @@ import { _spawnPromise, validateUrl, getFormattedTimestamp, isYouTubeUrl } from
|
||||
*/
|
||||
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)
|
||||
const format = isYouTubeUrl(url)
|
||||
? "140/bestaudio[ext=m4a]/bestaudio"
|
||||
: "bestaudio[ext=m4a]/bestaudio[ext=mp3]/bestaudio";
|
||||
|
||||
await _spawnPromise("yt-dlp", [
|
||||
"--ignore-config",
|
||||
"--no-check-certificate",
|
||||
"--verbose",
|
||||
"--progress",
|
||||
"--newline",
|
||||
@@ -62,4 +64,4 @@ export async function downloadAudio(url: string, config: Config): Promise<string
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
127
src/modules/search.ts
Normal file
127
src/modules/search.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { _spawnPromise } from "./utils.js";
|
||||
import type { Config } from "../config.js";
|
||||
|
||||
/**
|
||||
* YouTube search result interface
|
||||
*/
|
||||
export interface SearchResult {
|
||||
title: string;
|
||||
id: string;
|
||||
url: string;
|
||||
uploader?: string;
|
||||
duration?: string;
|
||||
viewCount?: string;
|
||||
uploadDate?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search YouTube videos
|
||||
* @param query Search keywords
|
||||
* @param maxResults Maximum number of results (1-50)
|
||||
* @param config Configuration object
|
||||
* @returns Search results formatted as string
|
||||
*/
|
||||
export async function searchVideos(
|
||||
query: string,
|
||||
maxResults: number = 10,
|
||||
config: Config
|
||||
): Promise<string> {
|
||||
// Validate parameters
|
||||
if (!query || query.trim().length === 0) {
|
||||
throw new Error("Search query cannot be empty");
|
||||
}
|
||||
|
||||
if (maxResults < 1 || maxResults > 50) {
|
||||
throw new Error("Number of results must be between 1 and 50");
|
||||
}
|
||||
|
||||
const cleanQuery = query.trim();
|
||||
const searchQuery = `ytsearch${maxResults}:${cleanQuery}`;
|
||||
|
||||
try {
|
||||
// Use yt-dlp to search and get video information
|
||||
const args = [
|
||||
searchQuery,
|
||||
"--print", "title",
|
||||
"--print", "id",
|
||||
"--print", "uploader",
|
||||
"--print", "duration",
|
||||
"--no-download",
|
||||
"--quiet"
|
||||
];
|
||||
|
||||
const result = await _spawnPromise(config.tools.required[0], args);
|
||||
|
||||
if (!result || result.trim().length === 0) {
|
||||
return "No videos found";
|
||||
}
|
||||
|
||||
// Parse results
|
||||
const lines = result.trim().split('\n');
|
||||
const results: SearchResult[] = [];
|
||||
|
||||
// Each video has 4 lines of data: title, id, uploader, duration
|
||||
for (let i = 0; i < lines.length; i += 4) {
|
||||
if (i + 3 < lines.length) {
|
||||
const title = lines[i]?.trim();
|
||||
const id = lines[i + 1]?.trim();
|
||||
const uploader = lines[i + 2]?.trim();
|
||||
const duration = lines[i + 3]?.trim();
|
||||
|
||||
if (title && id) {
|
||||
const url = `https://www.youtube.com/watch?v=${id}`;
|
||||
results.push({
|
||||
title,
|
||||
id,
|
||||
url,
|
||||
uploader: uploader || "Unknown",
|
||||
duration: duration || "Unknown"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
return "No videos found";
|
||||
}
|
||||
|
||||
// Format output
|
||||
let output = `Found ${results.length} video${results.length > 1 ? 's' : ''}:\n\n`;
|
||||
|
||||
results.forEach((video, index) => {
|
||||
output += `${index + 1}. **${video.title}**\n`;
|
||||
output += ` 📺 Channel: ${video.uploader}\n`;
|
||||
output += ` ⏱️ Duration: ${video.duration}\n`;
|
||||
output += ` 🔗 URL: ${video.url}\n`;
|
||||
output += ` 🆔 ID: ${video.id}\n\n`;
|
||||
});
|
||||
|
||||
output += "💡 You can use any URL to download videos, audio, or subtitles!";
|
||||
|
||||
return output;
|
||||
|
||||
} catch (error) {
|
||||
throw new Error(`Error searching videos: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search videos on specific platform (future expansion feature)
|
||||
* @param query Search keywords
|
||||
* @param platform Platform name ('youtube', 'bilibili', etc.)
|
||||
* @param maxResults Maximum number of results
|
||||
* @param config Configuration object
|
||||
*/
|
||||
export async function searchByPlatform(
|
||||
query: string,
|
||||
platform: string = 'youtube',
|
||||
maxResults: number = 10,
|
||||
config: Config
|
||||
): Promise<string> {
|
||||
// Currently only supports YouTube, can be expanded to other platforms in the future
|
||||
if (platform.toLowerCase() !== 'youtube') {
|
||||
throw new Error(`Currently only supports YouTube search, ${platform} is not supported`);
|
||||
}
|
||||
|
||||
return searchVideos(query, maxResults, config);
|
||||
}
|
||||
@@ -28,6 +28,7 @@ export async function listSubtitles(url: string): Promise<string> {
|
||||
|
||||
try {
|
||||
const output = await _spawnPromise('yt-dlp', [
|
||||
'--ignore-config',
|
||||
'--list-subs',
|
||||
'--write-auto-sub',
|
||||
'--skip-download',
|
||||
@@ -81,6 +82,7 @@ export async function downloadSubtitles(
|
||||
|
||||
try {
|
||||
await _spawnPromise('yt-dlp', [
|
||||
'--ignore-config',
|
||||
'--write-sub',
|
||||
'--write-auto-sub',
|
||||
'--sub-lang', language,
|
||||
@@ -139,6 +141,7 @@ export async function downloadTranscript(
|
||||
|
||||
try {
|
||||
await _spawnPromise('yt-dlp', [
|
||||
'--ignore-config',
|
||||
'--skip-download',
|
||||
'--write-subs',
|
||||
'--write-auto-subs',
|
||||
@@ -166,4 +169,4 @@ export async function downloadTranscript(
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +1,31 @@
|
||||
import * as path from "path";
|
||||
import type { Config } from "../config.js";
|
||||
import { sanitizeFilename } from "../config.js";
|
||||
import {
|
||||
_spawnPromise,
|
||||
validateUrl,
|
||||
getFormattedTimestamp,
|
||||
import {
|
||||
_spawnPromise,
|
||||
validateUrl,
|
||||
getFormattedTimestamp,
|
||||
isYouTubeUrl,
|
||||
generateRandomFilename
|
||||
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')
|
||||
* @param startTime - Optional start time for trimming (format: HH:MM:SS[.ms])
|
||||
* @param endTime - Optional end time for trimming (format: HH:MM:SS[.ms])
|
||||
* @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=...',
|
||||
@@ -31,19 +33,31 @@ import {
|
||||
* '1080p'
|
||||
* );
|
||||
* console.log(hdResult);
|
||||
*
|
||||
* // Download with trimming
|
||||
* const trimmedResult = await downloadVideo(
|
||||
* 'https://youtube.com/watch?v=...',
|
||||
* undefined,
|
||||
* '720p',
|
||||
* '00:01:30',
|
||||
* '00:02:45'
|
||||
* );
|
||||
* console.log(trimmedResult);
|
||||
* ```
|
||||
*/
|
||||
export async function downloadVideo(
|
||||
url: string,
|
||||
config: Config,
|
||||
resolution: "480p" | "720p" | "1080p" | "best" = "720p"
|
||||
resolution: "480p" | "720p" | "1080p" | "best" = "720p",
|
||||
startTime?: string,
|
||||
endTime?: string
|
||||
): Promise<string> {
|
||||
const userDownloadsDir = config.file.downloadsDir;
|
||||
|
||||
|
||||
try {
|
||||
validateUrl(url);
|
||||
const timestamp = getFormattedTimestamp();
|
||||
|
||||
|
||||
let format: string;
|
||||
if (isYouTubeUrl(url)) {
|
||||
// YouTube-specific format selection
|
||||
@@ -80,15 +94,16 @@ export async function downloadVideo(
|
||||
|
||||
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", [
|
||||
"--ignore-config",
|
||||
"--get-filename",
|
||||
"-f", format,
|
||||
"--output", outputTemplate,
|
||||
@@ -101,17 +116,37 @@ export async function downloadVideo(
|
||||
outputTemplate = path.join(userDownloadsDir, randomFilename);
|
||||
expectedFilename = randomFilename;
|
||||
}
|
||||
|
||||
|
||||
// Build download arguments
|
||||
const downloadArgs = [
|
||||
"--ignore-config",
|
||||
"--progress",
|
||||
"--newline",
|
||||
"--no-mtime",
|
||||
"-f", format,
|
||||
"--output", outputTemplate
|
||||
];
|
||||
|
||||
// Add trimming parameters if provided
|
||||
if (startTime || endTime) {
|
||||
let downloadSection = "*";
|
||||
|
||||
if (startTime && endTime) {
|
||||
downloadSection = `*${startTime}-${endTime}`;
|
||||
} else if (startTime) {
|
||||
downloadSection = `*${startTime}-`;
|
||||
} else if (endTime) {
|
||||
downloadSection = `*-${endTime}`;
|
||||
}
|
||||
|
||||
downloadArgs.push("--download-sections", downloadSection, "--force-keyframes-at-cuts");
|
||||
}
|
||||
|
||||
downloadArgs.push(url);
|
||||
|
||||
// Download with progress info
|
||||
try {
|
||||
await _spawnPromise("yt-dlp", [
|
||||
"--progress",
|
||||
"--newline",
|
||||
"--no-mtime",
|
||||
"-f", format,
|
||||
"--output", outputTemplate,
|
||||
url
|
||||
]);
|
||||
await _spawnPromise("yt-dlp", downloadArgs);
|
||||
} catch (error) {
|
||||
throw new Error(`Download failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
@@ -120,4 +155,4 @@ export async function downloadVideo(
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
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