feat: add video search functionality

- Add new search_videos tool for YouTube video search
- Support configurable search result count (1-50)
- Return formatted results with title, channel, duration, and URL
- Add comprehensive test coverage with real yt-dlp integration
- Update documentation with search examples
- Fix dependency security vulnerabilities
- Bump version to 0.6.27

Resolves: kevinwatt/yt-dlp-mcp#14
This commit is contained in:
kevinwatt
2025-07-28 04:45:37 +08:00
parent 9ba39128aa
commit 5aecaa3b20
8 changed files with 796 additions and 971 deletions

View File

@@ -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:
@@ -83,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=..."

View 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

File diff suppressed because it is too large Load Diff

View File

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

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

View File

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

View File

@@ -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)",
@@ -211,9 +229,16 @@ server.setRequestHandler(
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"

127
src/modules/search.ts Normal file
View 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);
}