feat(cookies): add cookie support for authenticated access
- Add YTDLP_COOKIES_FILE and YTDLP_COOKIES_FROM_BROWSER env vars - Support all yt-dlp cookie methods (file, browser extraction) - Validate browser names (brave, chrome, chromium, edge, firefox, opera, safari, vivaldi, whale) - Cookie file takes precedence over browser extraction - Add getCookieArgs() helper function - Integrate cookie args into all modules (video, audio, subtitle, search, metadata) - Add comprehensive cookie documentation (docs/cookies.md) - Add 12 unit tests for cookie configuration - Fix search.test.ts function signature issue Closes #19
This commit is contained in:
parent
26b2137751
commit
87ba2f8494
2
.gitignore
vendored
2
.gitignore
vendored
@ -31,5 +31,5 @@ test-dist
|
||||
|
||||
# WebStorm
|
||||
.idea/
|
||||
|
||||
plan/
|
||||
dist/
|
||||
|
||||
33
README.md
33
README.md
@ -287,6 +287,7 @@ Get human-readable metadata summary
|
||||
|
||||
- **[API Reference](./docs/api.md)** - Detailed tool documentation
|
||||
- **[Configuration](./docs/configuration.md)** - Environment variables and settings
|
||||
- **[Cookie Configuration](./docs/cookies.md)** - Authentication and private video access
|
||||
- **[Error Handling](./docs/error-handling.md)** - Common errors and solutions
|
||||
- **[Contributing](./docs/contributing.md)** - How to contribute
|
||||
|
||||
@ -313,6 +314,38 @@ YTDLP_CHARACTER_LIMIT=25000
|
||||
YTDLP_MAX_TRANSCRIPT_LENGTH=50000
|
||||
```
|
||||
|
||||
### Cookie Configuration
|
||||
|
||||
To access private videos, age-restricted content, or avoid rate limits, configure cookies:
|
||||
|
||||
```bash
|
||||
# Extract cookies from browser (recommended)
|
||||
YTDLP_COOKIES_FROM_BROWSER=chrome
|
||||
|
||||
# Or use a cookie file
|
||||
YTDLP_COOKIES_FILE=/path/to/cookies.txt
|
||||
```
|
||||
|
||||
**MCP Configuration with cookies:**
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"yt-dlp": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@kevinwatt/yt-dlp-mcp"],
|
||||
"env": {
|
||||
"YTDLP_COOKIES_FROM_BROWSER": "chrome"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Supported browsers: `brave`, `chrome`, `chromium`, `edge`, `firefox`, `opera`, `safari`, `vivaldi`, `whale`
|
||||
|
||||
See [Cookie Configuration Guide](./docs/cookies.md) for detailed setup instructions.
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
302
docs/cookies.md
Normal file
302
docs/cookies.md
Normal file
@ -0,0 +1,302 @@
|
||||
# Cookies Configuration Guide
|
||||
|
||||
## Why Do You Need Cookies?
|
||||
|
||||
You need to configure cookies for yt-dlp-mcp in the following situations:
|
||||
|
||||
- **Access private videos**: Videos that require login to view
|
||||
- **Age-restricted content**: Content requiring account age verification
|
||||
- **Bypass CAPTCHA**: Some websites require verification
|
||||
- **Avoid rate limiting**: Reduce HTTP 429 (Too Many Requests) errors
|
||||
- **YouTube Premium features**: Access premium-exclusive content and quality
|
||||
|
||||
## Configuration Methods
|
||||
|
||||
yt-dlp-mcp supports two cookie configuration methods via environment variables.
|
||||
|
||||
### Method 1: Extract from Browser (Recommended)
|
||||
|
||||
This is the simplest approach. yt-dlp reads cookies directly from your browser.
|
||||
|
||||
```bash
|
||||
YTDLP_COOKIES_FROM_BROWSER=chrome
|
||||
```
|
||||
|
||||
#### Supported Browsers
|
||||
|
||||
| Browser | Value |
|
||||
|---------|-------|
|
||||
| Google Chrome | `chrome` |
|
||||
| Chromium | `chromium` |
|
||||
| Microsoft Edge | `edge` |
|
||||
| Mozilla Firefox | `firefox` |
|
||||
| Brave | `brave` |
|
||||
| Opera | `opera` |
|
||||
| Safari (macOS) | `safari` |
|
||||
| Vivaldi | `vivaldi` |
|
||||
| Whale | `whale` |
|
||||
|
||||
#### Advanced Configuration
|
||||
|
||||
```bash
|
||||
# Specify Chrome Profile
|
||||
YTDLP_COOKIES_FROM_BROWSER=chrome:Profile 1
|
||||
|
||||
# Specify Firefox Container
|
||||
YTDLP_COOKIES_FROM_BROWSER=firefox::work
|
||||
|
||||
# Flatpak-installed Chrome (Linux)
|
||||
YTDLP_COOKIES_FROM_BROWSER=chrome:~/.var/app/com.google.Chrome/
|
||||
|
||||
# Full format: BROWSER:PROFILE::CONTAINER
|
||||
YTDLP_COOKIES_FROM_BROWSER=chrome:Profile 1::personal
|
||||
```
|
||||
|
||||
### Method 2: Use Cookie File
|
||||
|
||||
If you prefer using a fixed cookie file, or automatic extraction doesn't work:
|
||||
|
||||
```bash
|
||||
YTDLP_COOKIES_FILE=/path/to/cookies.txt
|
||||
```
|
||||
|
||||
The cookie file must be in Netscape/Mozilla format with the first line:
|
||||
```
|
||||
# Netscape HTTP Cookie File
|
||||
```
|
||||
|
||||
## Exporting Cookies
|
||||
|
||||
### Using yt-dlp (Recommended)
|
||||
|
||||
This is the most reliable method, ensuring correct format:
|
||||
|
||||
```bash
|
||||
# Export from Chrome
|
||||
yt-dlp --cookies-from-browser chrome --cookies cookies.txt "https://www.youtube.com"
|
||||
|
||||
# Export from Firefox
|
||||
yt-dlp --cookies-from-browser firefox --cookies cookies.txt "https://www.youtube.com"
|
||||
```
|
||||
|
||||
> **Note**: This command exports ALL website cookies from your browser. Keep this file secure.
|
||||
|
||||
### Using Browser Extensions
|
||||
|
||||
| Browser | Extension |
|
||||
|---------|-----------|
|
||||
| Chrome | [Get cookies.txt LOCALLY](https://chrome.google.com/webstore/detail/get-cookiestxt-locally/cclelndahbckbenkjhflpdbgdldlbecc) |
|
||||
| Firefox | [cookies.txt](https://addons.mozilla.org/firefox/addon/cookies-txt/) |
|
||||
|
||||
> **Warning**: Only use the recommended extensions above. Some cookie export extensions may be malware.
|
||||
|
||||
## MCP Configuration Examples
|
||||
|
||||
### Claude Desktop
|
||||
|
||||
Edit `claude_desktop_config.json`:
|
||||
|
||||
**Using Browser Cookies:**
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"yt-dlp": {
|
||||
"command": "npx",
|
||||
"args": ["@kevinwatt/yt-dlp-mcp"],
|
||||
"env": {
|
||||
"YTDLP_COOKIES_FROM_BROWSER": "chrome"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Using Cookie File:**
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"yt-dlp": {
|
||||
"command": "npx",
|
||||
"args": ["@kevinwatt/yt-dlp-mcp"],
|
||||
"env": {
|
||||
"YTDLP_COOKIES_FILE": "/Users/username/.config/yt-dlp/cookies.txt"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration File Locations
|
||||
|
||||
| OS | Claude Desktop Config Location |
|
||||
|----|-------------------------------|
|
||||
| macOS | `~/Library/Application Support/Claude/claude_desktop_config.json` |
|
||||
| Windows | `%APPDATA%\Claude\claude_desktop_config.json` |
|
||||
| Linux | `~/.config/Claude/claude_desktop_config.json` |
|
||||
|
||||
## Priority Order
|
||||
|
||||
When both `YTDLP_COOKIES_FILE` and `YTDLP_COOKIES_FROM_BROWSER` are set:
|
||||
|
||||
1. `YTDLP_COOKIES_FILE` is used first
|
||||
2. If no file is set, `YTDLP_COOKIES_FROM_BROWSER` is used
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
### Cookie File Security
|
||||
|
||||
1. **Keep it safe**: Cookie files contain your login credentials; leakage may lead to account compromise
|
||||
2. **Never share**: Never share cookie files with others or upload to public locations
|
||||
3. **Version control**: Add `cookies.txt` to `.gitignore`
|
||||
4. **File permissions**:
|
||||
```bash
|
||||
chmod 600 cookies.txt # Owner read/write only
|
||||
```
|
||||
|
||||
### Browser Cookie Extraction
|
||||
|
||||
- Ensure your browser is up to date
|
||||
- You may need to close the browser temporarily during extraction
|
||||
- Some browser security features may block cookie extraction
|
||||
|
||||
### Regular Updates
|
||||
|
||||
- Browser cookies expire
|
||||
- Re-export cookies periodically
|
||||
- If you encounter authentication errors, try updating cookies
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Error: Cookie file not found
|
||||
|
||||
```
|
||||
Error: Cookie file not found: /path/to/cookies.txt
|
||||
```
|
||||
|
||||
**Solutions:**
|
||||
1. Verify the file path is correct
|
||||
2. Confirm the file exists
|
||||
3. Ensure the MCP service has permission to read the file
|
||||
|
||||
### Error: Browser cookies could not be loaded
|
||||
|
||||
```
|
||||
Error: Could not load cookies from chrome
|
||||
```
|
||||
|
||||
**Solutions:**
|
||||
1. Verify browser name spelling is correct
|
||||
2. Try closing the browser and retry
|
||||
3. Ensure no multiple browser instances are running
|
||||
4. Check if browser has password-protected cookie storage
|
||||
|
||||
### Error: Invalid cookie file format
|
||||
|
||||
```
|
||||
Error: Invalid cookie file format
|
||||
```
|
||||
|
||||
**Solutions:**
|
||||
1. Ensure first line is `# Netscape HTTP Cookie File` or `# HTTP Cookie File`
|
||||
2. Check line ending format (Unix uses LF, Windows uses CRLF)
|
||||
3. Re-export cookies using yt-dlp
|
||||
|
||||
### Still Cannot Access Private Videos
|
||||
|
||||
1. **Confirm login**: Verify you're logged in to the video platform in your browser
|
||||
2. **Refresh page**: Refresh the video page in browser before exporting
|
||||
3. **Re-export**: Re-export cookies
|
||||
4. **Check permissions**: Confirm your account has permission to access the video
|
||||
|
||||
### HTTP 400: Bad Request
|
||||
|
||||
This usually indicates incorrect line ending format in the cookie file.
|
||||
|
||||
**Linux/macOS:**
|
||||
```bash
|
||||
# Convert to Unix line endings
|
||||
sed -i 's/\r$//' cookies.txt
|
||||
```
|
||||
|
||||
**Windows:**
|
||||
Use Notepad++ or VS Code to convert line endings to LF.
|
||||
|
||||
## YouTube JavaScript Runtime Requirement
|
||||
|
||||
YouTube requires a JavaScript runtime for yt-dlp to function properly. Without it, you may see errors like:
|
||||
|
||||
```
|
||||
WARNING: [youtube] Signature solving failed: Some formats may be missing
|
||||
ERROR: Requested format is not available
|
||||
```
|
||||
|
||||
### Installing EJS (Recommended)
|
||||
|
||||
EJS is a lightweight JavaScript runtime specifically designed for yt-dlp.
|
||||
|
||||
**Linux (Debian/Ubuntu):**
|
||||
```bash
|
||||
# Install Node.js if not already installed
|
||||
sudo apt install nodejs
|
||||
|
||||
# Install EJS globally
|
||||
sudo npm install -g @aspect-build/ejs
|
||||
```
|
||||
|
||||
**Linux (Arch):**
|
||||
```bash
|
||||
sudo pacman -S nodejs npm
|
||||
sudo npm install -g @aspect-build/ejs
|
||||
```
|
||||
|
||||
**macOS:**
|
||||
```bash
|
||||
brew install node
|
||||
npm install -g @aspect-build/ejs
|
||||
```
|
||||
|
||||
**Windows:**
|
||||
```powershell
|
||||
# Install Node.js from https://nodejs.org/
|
||||
npm install -g @aspect-build/ejs
|
||||
```
|
||||
|
||||
### Alternative: PhantomJS
|
||||
|
||||
If EJS doesn't work, you can try PhantomJS:
|
||||
|
||||
**Linux:**
|
||||
```bash
|
||||
sudo apt install phantomjs
|
||||
```
|
||||
|
||||
**macOS:**
|
||||
```bash
|
||||
brew install phantomjs
|
||||
```
|
||||
|
||||
### Verifying Installation
|
||||
|
||||
Test that yt-dlp can use the JavaScript runtime:
|
||||
|
||||
```bash
|
||||
yt-dlp --dump-json "https://www.youtube.com/watch?v=dQw4w9WgXcQ" 2>&1 | head -1
|
||||
```
|
||||
|
||||
If successful, you should see JSON output starting with `{`.
|
||||
|
||||
### Additional Dependencies for Cookie Extraction (Linux)
|
||||
|
||||
On Linux, cookie extraction from browsers requires the `secretstorage` module:
|
||||
|
||||
```bash
|
||||
python3 -m pip install secretstorage
|
||||
```
|
||||
|
||||
This is needed to decrypt cookies stored by Chromium-based browsers.
|
||||
|
||||
## Related Links
|
||||
|
||||
- [yt-dlp Cookie FAQ](https://github.com/yt-dlp/yt-dlp/wiki/FAQ#how-do-i-pass-cookies-to-yt-dlp)
|
||||
- [yt-dlp EJS Wiki](https://github.com/yt-dlp/yt-dlp/wiki/EJS)
|
||||
- [yt-dlp Documentation](https://github.com/yt-dlp/yt-dlp#readme)
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@kevinwatt/yt-dlp-mcp",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.0",
|
||||
"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",
|
||||
|
||||
179
src/__tests__/config.test.ts
Normal file
179
src/__tests__/config.test.ts
Normal file
@ -0,0 +1,179 @@
|
||||
// @ts-nocheck
|
||||
// @jest-environment node
|
||||
import { describe, test, expect, beforeEach, afterEach, jest } from '@jest/globals';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
|
||||
// Store original env
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
describe('Cookie Configuration', () => {
|
||||
beforeEach(() => {
|
||||
// Reset environment before each test
|
||||
process.env = { ...originalEnv };
|
||||
// Clear module cache to reload config
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
describe('getCookieArgs', () => {
|
||||
test('returns empty array when no cookies configured', async () => {
|
||||
const { getCookieArgs, loadConfig } = await import('../config.js');
|
||||
const config = loadConfig();
|
||||
const args = getCookieArgs(config);
|
||||
expect(args).toEqual([]);
|
||||
});
|
||||
|
||||
test('returns --cookies args when file is configured', async () => {
|
||||
// Create a temporary cookie file
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cookie-test-'));
|
||||
const cookieFile = path.join(tempDir, 'cookies.txt');
|
||||
fs.writeFileSync(cookieFile, '# Netscape HTTP Cookie File\n');
|
||||
|
||||
process.env.YTDLP_COOKIES_FILE = cookieFile;
|
||||
|
||||
const { getCookieArgs, loadConfig } = await import('../config.js');
|
||||
const config = loadConfig();
|
||||
const args = getCookieArgs(config);
|
||||
|
||||
expect(args).toEqual(['--cookies', cookieFile]);
|
||||
|
||||
// Cleanup
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('returns --cookies-from-browser args when browser is configured', async () => {
|
||||
process.env.YTDLP_COOKIES_FROM_BROWSER = 'chrome';
|
||||
|
||||
const { getCookieArgs, loadConfig } = await import('../config.js');
|
||||
const config = loadConfig();
|
||||
const args = getCookieArgs(config);
|
||||
|
||||
expect(args).toEqual(['--cookies-from-browser', 'chrome']);
|
||||
});
|
||||
|
||||
test('file takes precedence over browser', async () => {
|
||||
// Create a temporary cookie file
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cookie-test-'));
|
||||
const cookieFile = path.join(tempDir, 'cookies.txt');
|
||||
fs.writeFileSync(cookieFile, '# Netscape HTTP Cookie File\n');
|
||||
|
||||
process.env.YTDLP_COOKIES_FILE = cookieFile;
|
||||
process.env.YTDLP_COOKIES_FROM_BROWSER = 'chrome';
|
||||
|
||||
const { getCookieArgs, loadConfig } = await import('../config.js');
|
||||
const config = loadConfig();
|
||||
const args = getCookieArgs(config);
|
||||
|
||||
expect(args).toEqual(['--cookies', cookieFile]);
|
||||
|
||||
// Cleanup
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('supports browser with profile', async () => {
|
||||
process.env.YTDLP_COOKIES_FROM_BROWSER = 'chrome:Profile 1';
|
||||
|
||||
const { getCookieArgs, loadConfig } = await import('../config.js');
|
||||
const config = loadConfig();
|
||||
const args = getCookieArgs(config);
|
||||
|
||||
expect(args).toEqual(['--cookies-from-browser', 'chrome:Profile 1']);
|
||||
});
|
||||
|
||||
test('supports browser with container', async () => {
|
||||
process.env.YTDLP_COOKIES_FROM_BROWSER = 'firefox::work';
|
||||
|
||||
const { getCookieArgs, loadConfig } = await import('../config.js');
|
||||
const config = loadConfig();
|
||||
const args = getCookieArgs(config);
|
||||
|
||||
expect(args).toEqual(['--cookies-from-browser', 'firefox::work']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cookie Validation', () => {
|
||||
test('clears invalid cookie file path with warning', async () => {
|
||||
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
process.env.YTDLP_COOKIES_FILE = '/nonexistent/path/cookies.txt';
|
||||
|
||||
const { loadConfig } = await import('../config.js');
|
||||
const config = loadConfig();
|
||||
|
||||
expect(config.cookies.file).toBeUndefined();
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Cookie file not found')
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('accepts valid browser names', async () => {
|
||||
const validBrowsers = ['brave', 'chrome', 'chromium', 'edge', 'firefox', 'opera', 'safari', 'vivaldi', 'whale'];
|
||||
|
||||
for (const browser of validBrowsers) {
|
||||
jest.resetModules();
|
||||
process.env = { ...originalEnv };
|
||||
process.env.YTDLP_COOKIES_FROM_BROWSER = browser;
|
||||
|
||||
const { loadConfig } = await import('../config.js');
|
||||
const config = loadConfig();
|
||||
|
||||
expect(config.cookies.fromBrowser).toBe(browser);
|
||||
}
|
||||
});
|
||||
|
||||
test('clears invalid browser name with warning', async () => {
|
||||
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
process.env.YTDLP_COOKIES_FROM_BROWSER = 'invalidbrowser';
|
||||
|
||||
const { loadConfig } = await import('../config.js');
|
||||
const config = loadConfig();
|
||||
|
||||
expect(config.cookies.fromBrowser).toBeUndefined();
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Invalid browser name')
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('accepts valid browser with custom path (Flatpak style)', async () => {
|
||||
// Path format is valid for Flatpak installations
|
||||
process.env.YTDLP_COOKIES_FROM_BROWSER = 'chrome:~/.var/app/com.google.Chrome/';
|
||||
|
||||
const { loadConfig } = await import('../config.js');
|
||||
const config = loadConfig();
|
||||
|
||||
expect(config.cookies.fromBrowser).toBe('chrome:~/.var/app/com.google.Chrome/');
|
||||
});
|
||||
|
||||
test('accepts valid browser with empty profile', async () => {
|
||||
// chrome: is valid (empty profile means default)
|
||||
process.env.YTDLP_COOKIES_FROM_BROWSER = 'chrome:';
|
||||
|
||||
const { loadConfig } = await import('../config.js');
|
||||
const config = loadConfig();
|
||||
|
||||
expect(config.cookies.fromBrowser).toBe('chrome:');
|
||||
});
|
||||
});
|
||||
|
||||
describe('VALID_BROWSERS constant', () => {
|
||||
test('exports valid browsers list', async () => {
|
||||
const { VALID_BROWSERS } = await import('../config.js');
|
||||
|
||||
expect(VALID_BROWSERS).toContain('chrome');
|
||||
expect(VALID_BROWSERS).toContain('firefox');
|
||||
expect(VALID_BROWSERS).toContain('edge');
|
||||
expect(VALID_BROWSERS).toContain('safari');
|
||||
expect(VALID_BROWSERS.length).toBe(9);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -5,11 +5,11 @@ 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);
|
||||
|
||||
const result = await searchVideos('javascript tutorial', 3, 0, 'markdown', CONFIG);
|
||||
|
||||
expect(result).toContain('Found 3 videos');
|
||||
expect(result).toContain('Channel:');
|
||||
expect(result).toContain('Duration:');
|
||||
@ -20,33 +20,33 @@ describe('Search functionality tests', () => {
|
||||
}, 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');
|
||||
await expect(searchVideos('', 10, 0, 'markdown', CONFIG)).rejects.toThrow('Search query cannot be empty');
|
||||
await expect(searchVideos(' ', 10, 0, 'markdown', 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');
|
||||
await expect(searchVideos('test', 0, 0, 'markdown', CONFIG)).rejects.toThrow('Number of results must be between 1 and 50');
|
||||
await expect(searchVideos('test', 51, 0, 'markdown', 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);
|
||||
const result1 = await searchVideos('python programming', 1, 0, 'markdown', CONFIG);
|
||||
const result5 = await searchVideos('python programming', 5, 0, 'markdown', 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);
|
||||
|
||||
const result = await searchVideos('react tutorial', 2, 0, 'markdown', CONFIG);
|
||||
|
||||
// Check for proper formatting
|
||||
expect(result).toMatch(/Found \d+ videos?:/);
|
||||
expect(result).toMatch(/\d+\. \*\*.*\*\*/); // Numbered list with bold titles
|
||||
@ -58,8 +58,8 @@ describe('Search functionality tests', () => {
|
||||
|
||||
test('should handle obscure search terms gracefully', async () => {
|
||||
// Using a very specific and unlikely search term
|
||||
const result = await searchVideos('asdfghjklqwertyuiopzxcvbnm12345', 1, CONFIG);
|
||||
|
||||
const result = await searchVideos('asdfghjklqwertyuiopzxcvbnm12345', 1, 0, 'markdown', 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');
|
||||
|
||||
@ -1,10 +1,21 @@
|
||||
import * as os from "os";
|
||||
import * as path from "path";
|
||||
import * as fs from "fs";
|
||||
|
||||
type DeepPartial<T> = {
|
||||
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
|
||||
};
|
||||
|
||||
/**
|
||||
* Valid browser names for cookie extraction
|
||||
*/
|
||||
export const VALID_BROWSERS = [
|
||||
'brave', 'chrome', 'chromium', 'edge',
|
||||
'firefox', 'opera', 'safari', 'vivaldi', 'whale'
|
||||
] as const;
|
||||
|
||||
export type ValidBrowser = typeof VALID_BROWSERS[number];
|
||||
|
||||
/**
|
||||
* Configuration type definitions
|
||||
*/
|
||||
@ -41,6 +52,13 @@ export interface Config {
|
||||
characterLimit: number;
|
||||
maxTranscriptLength: number;
|
||||
};
|
||||
// Cookie configuration for authenticated access
|
||||
cookies: {
|
||||
// Path to Netscape format cookie file
|
||||
file?: string;
|
||||
// Browser name and settings (format: BROWSER[:PROFILE][::CONTAINER])
|
||||
fromBrowser?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@ -73,6 +91,10 @@ const defaultConfig: Config = {
|
||||
limits: {
|
||||
characterLimit: 25000, // Standard MCP character limit
|
||||
maxTranscriptLength: 50000 // Transcripts can be larger
|
||||
},
|
||||
cookies: {
|
||||
file: undefined,
|
||||
fromBrowser: undefined
|
||||
}
|
||||
};
|
||||
|
||||
@ -123,6 +145,18 @@ function loadEnvConfig(): DeepPartial<Config> {
|
||||
envConfig.download = downloadConfig;
|
||||
}
|
||||
|
||||
// Cookie configuration
|
||||
const cookiesConfig: Partial<Config['cookies']> = {};
|
||||
if (process.env.YTDLP_COOKIES_FILE) {
|
||||
cookiesConfig.file = process.env.YTDLP_COOKIES_FILE;
|
||||
}
|
||||
if (process.env.YTDLP_COOKIES_FROM_BROWSER) {
|
||||
cookiesConfig.fromBrowser = process.env.YTDLP_COOKIES_FROM_BROWSER;
|
||||
}
|
||||
if (Object.keys(cookiesConfig).length > 0) {
|
||||
envConfig.cookies = cookiesConfig;
|
||||
}
|
||||
|
||||
return envConfig;
|
||||
}
|
||||
|
||||
@ -159,6 +193,34 @@ function validateConfig(config: Config): void {
|
||||
if (!/^[a-z]{2,3}(-[A-Z][a-z]{3})?(-[A-Z]{2})?$/i.test(config.download.defaultSubtitleLanguage)) {
|
||||
throw new Error('Invalid defaultSubtitleLanguage');
|
||||
}
|
||||
|
||||
// Validate cookies (lenient - warnings only)
|
||||
validateCookiesConfig(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate cookie configuration (lenient - logs warnings but doesn't throw)
|
||||
*/
|
||||
function validateCookiesConfig(config: Config): void {
|
||||
// Validate cookie file path
|
||||
if (config.cookies.file) {
|
||||
if (!fs.existsSync(config.cookies.file)) {
|
||||
console.warn(`[yt-dlp-mcp] Cookie file not found: ${config.cookies.file}, continuing without cookies`);
|
||||
config.cookies.file = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate browser name only
|
||||
// Format: BROWSER[:PROFILE_OR_PATH][::CONTAINER]
|
||||
// We only validate browser name; yt-dlp will validate path/container
|
||||
if (config.cookies.fromBrowser) {
|
||||
const browserName = config.cookies.fromBrowser.split(':')[0].toLowerCase();
|
||||
|
||||
if (!VALID_BROWSERS.includes(browserName as ValidBrowser)) {
|
||||
console.warn(`[yt-dlp-mcp] Invalid browser name: ${browserName}. Valid browsers: ${VALID_BROWSERS.join(', ')}`);
|
||||
config.cookies.fromBrowser = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -188,6 +250,10 @@ function mergeConfig(base: Config, override: DeepPartial<Config>): Config {
|
||||
limits: {
|
||||
characterLimit: override.limits?.characterLimit || base.limits.characterLimit,
|
||||
maxTranscriptLength: override.limits?.maxTranscriptLength || base.limits.maxTranscriptLength
|
||||
},
|
||||
cookies: {
|
||||
file: override.cookies?.file ?? base.cookies.file,
|
||||
fromBrowser: override.cookies?.fromBrowser ?? base.cookies.fromBrowser
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -225,5 +291,26 @@ export function sanitizeFilename(filename: string, config: Config['file']): stri
|
||||
return safe;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cookie-related yt-dlp arguments
|
||||
* Priority: file > fromBrowser
|
||||
* @param config Configuration object
|
||||
* @returns Array of yt-dlp arguments for cookie handling
|
||||
*/
|
||||
export function getCookieArgs(config: Config): string[] {
|
||||
// Guard against missing cookies config
|
||||
if (!config.cookies) {
|
||||
return [];
|
||||
}
|
||||
// Cookie file takes precedence over browser extraction
|
||||
if (config.cookies.file) {
|
||||
return ['--cookies', config.cookies.file];
|
||||
}
|
||||
if (config.cookies.fromBrowser) {
|
||||
return ['--cookies-from-browser', config.cookies.fromBrowser];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// Export current configuration instance
|
||||
export const CONFIG = loadConfig();
|
||||
@ -507,7 +507,7 @@ server.setRequestHandler(
|
||||
} else if (toolName === "ytdlp_list_subtitle_languages") {
|
||||
const validated = ListSubtitleLanguagesSchema.parse(args);
|
||||
return handleToolExecution(
|
||||
() => listSubtitles(validated.url),
|
||||
() => listSubtitles(validated.url, CONFIG),
|
||||
"Error listing subtitle languages"
|
||||
);
|
||||
} else if (toolName === "ytdlp_download_video_subtitles") {
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { readdirSync } from "fs";
|
||||
import * as path from "path";
|
||||
import type { Config } from "../config.js";
|
||||
import { sanitizeFilename } from "../config.js";
|
||||
import { sanitizeFilename, getCookieArgs } from "../config.js";
|
||||
import { _spawnPromise, validateUrl, getFormattedTimestamp, isYouTubeUrl } from "./utils.js";
|
||||
|
||||
/**
|
||||
@ -52,6 +52,7 @@ export async function downloadAudio(url: string, config: Config): Promise<string
|
||||
"--no-mtime",
|
||||
"-f", format,
|
||||
"--output", outputTemplate,
|
||||
...getCookieArgs(config),
|
||||
url
|
||||
]);
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import type { Config } from "../config.js";
|
||||
import { getCookieArgs } from "../config.js";
|
||||
import {
|
||||
_spawnPromise,
|
||||
validateUrl
|
||||
@ -156,6 +157,7 @@ export async function getVideoMetadata(
|
||||
"--dump-json",
|
||||
"--no-warnings",
|
||||
"--no-check-certificate",
|
||||
...(_config ? getCookieArgs(_config) : []),
|
||||
url
|
||||
];
|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { _spawnPromise } from "./utils.js";
|
||||
import type { Config } from "../config.js";
|
||||
import { getCookieArgs } from "../config.js";
|
||||
|
||||
/**
|
||||
* YouTube search result interface
|
||||
@ -53,11 +54,12 @@ export async function searchVideos(
|
||||
const args = [
|
||||
searchQuery,
|
||||
"--print", "title",
|
||||
"--print", "id",
|
||||
"--print", "id",
|
||||
"--print", "uploader",
|
||||
"--print", "duration",
|
||||
"--no-download",
|
||||
"--quiet"
|
||||
"--quiet",
|
||||
...getCookieArgs(config)
|
||||
];
|
||||
|
||||
const result = await _spawnPromise(config.tools.required[0], args);
|
||||
|
||||
@ -2,39 +2,43 @@ import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import * as os from "os";
|
||||
import type { Config } from '../config.js';
|
||||
import { getCookieArgs } from '../config.js';
|
||||
import { _spawnPromise, validateUrl, cleanSubtitleToTranscript } from "./utils.js";
|
||||
|
||||
/**
|
||||
* Lists all available subtitles for a video.
|
||||
*
|
||||
*
|
||||
* @param url - The URL of the video
|
||||
* @param config - Configuration object (optional, for cookie support)
|
||||
* @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=...');
|
||||
* const subtitles = await listSubtitles('https://youtube.com/watch?v=...', config);
|
||||
* console.log('Available subtitles:', subtitles);
|
||||
* } catch (error) {
|
||||
* console.error('Failed to list subtitles:', error);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export async function listSubtitles(url: string): Promise<string> {
|
||||
export async function listSubtitles(url: string, config?: Config): Promise<string> {
|
||||
if (!validateUrl(url)) {
|
||||
throw new Error('Invalid or unsupported URL format. Please provide a valid video URL (e.g., https://youtube.com/watch?v=...)');
|
||||
}
|
||||
|
||||
try {
|
||||
const output = await _spawnPromise('yt-dlp', [
|
||||
const args = [
|
||||
'--ignore-config',
|
||||
'--list-subs',
|
||||
'--write-auto-sub',
|
||||
'--skip-download',
|
||||
'--verbose',
|
||||
...(config ? getCookieArgs(config) : []),
|
||||
url
|
||||
]);
|
||||
];
|
||||
const output = await _spawnPromise('yt-dlp', args);
|
||||
return output;
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
@ -99,6 +103,7 @@ export async function downloadSubtitles(
|
||||
'--sub-lang', language,
|
||||
'--skip-download',
|
||||
'--output', path.join(tempDir, '%(title)s.%(ext)s'),
|
||||
...getCookieArgs(config),
|
||||
url
|
||||
]);
|
||||
|
||||
@ -179,6 +184,7 @@ export async function downloadTranscript(
|
||||
'--sub-format', 'ttml',
|
||||
'--convert-subs', 'srt',
|
||||
'--output', path.join(tempDir, 'transcript.%(ext)s'),
|
||||
...getCookieArgs(config),
|
||||
url
|
||||
]);
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import * as path from "path";
|
||||
import type { Config } from "../config.js";
|
||||
import { sanitizeFilename } from "../config.js";
|
||||
import { sanitizeFilename, getCookieArgs } from "../config.js";
|
||||
import {
|
||||
_spawnPromise,
|
||||
validateUrl,
|
||||
@ -102,13 +102,15 @@ export async function downloadVideo(
|
||||
sanitizeFilename(`%(title)s [%(id)s] ${timestamp}`, config.file) + '.%(ext)s'
|
||||
);
|
||||
|
||||
expectedFilename = await _spawnPromise("yt-dlp", [
|
||||
const getFilenameArgs = [
|
||||
"--ignore-config",
|
||||
"--get-filename",
|
||||
"-f", format,
|
||||
"--output", outputTemplate,
|
||||
...getCookieArgs(config),
|
||||
url
|
||||
]);
|
||||
];
|
||||
expectedFilename = await _spawnPromise("yt-dlp", getFilenameArgs);
|
||||
expectedFilename = expectedFilename.trim();
|
||||
} catch (error) {
|
||||
// 如果無法獲取檔案名稱,使用隨機檔案名
|
||||
@ -124,7 +126,8 @@ export async function downloadVideo(
|
||||
"--newline",
|
||||
"--no-mtime",
|
||||
"-f", format,
|
||||
"--output", outputTemplate
|
||||
"--output", outputTemplate,
|
||||
...getCookieArgs(config)
|
||||
];
|
||||
|
||||
// Add trimming parameters if provided
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user