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
|
# WebStorm
|
||||||
.idea/
|
.idea/
|
||||||
|
plan/
|
||||||
dist/
|
dist/
|
||||||
|
|||||||
33
README.md
33
README.md
@ -287,6 +287,7 @@ Get human-readable metadata summary
|
|||||||
|
|
||||||
- **[API Reference](./docs/api.md)** - Detailed tool documentation
|
- **[API Reference](./docs/api.md)** - Detailed tool documentation
|
||||||
- **[Configuration](./docs/configuration.md)** - Environment variables and settings
|
- **[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
|
- **[Error Handling](./docs/error-handling.md)** - Common errors and solutions
|
||||||
- **[Contributing](./docs/contributing.md)** - How to contribute
|
- **[Contributing](./docs/contributing.md)** - How to contribute
|
||||||
|
|
||||||
@ -313,6 +314,38 @@ YTDLP_CHARACTER_LIMIT=25000
|
|||||||
YTDLP_MAX_TRANSCRIPT_LENGTH=50000
|
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
|
## 🏗️ 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",
|
"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.",
|
"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": [
|
"keywords": [
|
||||||
"mcp",
|
"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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -8,7 +8,7 @@ describe('Search functionality tests', () => {
|
|||||||
|
|
||||||
describe('searchVideos', () => {
|
describe('searchVideos', () => {
|
||||||
test('should successfully search for JavaScript tutorials', async () => {
|
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('Found 3 videos');
|
||||||
expect(result).toContain('Channel:');
|
expect(result).toContain('Channel:');
|
||||||
@ -20,18 +20,18 @@ describe('Search functionality tests', () => {
|
|||||||
}, 30000); // Increase timeout for real network calls
|
}, 30000); // Increase timeout for real network calls
|
||||||
|
|
||||||
test('should reject empty search queries', async () => {
|
test('should reject empty search queries', async () => {
|
||||||
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, 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 () => {
|
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', 0, 0, 'markdown', 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', 51, 0, 'markdown', CONFIG)).rejects.toThrow('Number of results must be between 1 and 50');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should handle search with different result counts', async () => {
|
test('should handle search with different result counts', async () => {
|
||||||
const result1 = await searchVideos('python programming', 1, CONFIG);
|
const result1 = await searchVideos('python programming', 1, 0, 'markdown', CONFIG);
|
||||||
const result5 = await searchVideos('python programming', 5, CONFIG);
|
const result5 = await searchVideos('python programming', 5, 0, 'markdown', CONFIG);
|
||||||
|
|
||||||
expect(result1).toContain('Found 1 video');
|
expect(result1).toContain('Found 1 video');
|
||||||
expect(result5).toContain('Found 5 videos');
|
expect(result5).toContain('Found 5 videos');
|
||||||
@ -45,7 +45,7 @@ describe('Search functionality tests', () => {
|
|||||||
}, 30000);
|
}, 30000);
|
||||||
|
|
||||||
test('should return properly formatted results', async () => {
|
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
|
// Check for proper formatting
|
||||||
expect(result).toMatch(/Found \d+ videos?:/);
|
expect(result).toMatch(/Found \d+ videos?:/);
|
||||||
@ -58,7 +58,7 @@ describe('Search functionality tests', () => {
|
|||||||
|
|
||||||
test('should handle obscure search terms gracefully', async () => {
|
test('should handle obscure search terms gracefully', async () => {
|
||||||
// Using a very specific and unlikely search term
|
// 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
|
// Even obscure terms should return some results, as YouTube's search is quite broad
|
||||||
// But if no results, it should be handled gracefully
|
// But if no results, it should be handled gracefully
|
||||||
|
|||||||
@ -1,10 +1,21 @@
|
|||||||
import * as os from "os";
|
import * as os from "os";
|
||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
|
import * as fs from "fs";
|
||||||
|
|
||||||
type DeepPartial<T> = {
|
type DeepPartial<T> = {
|
||||||
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
|
[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
|
* Configuration type definitions
|
||||||
*/
|
*/
|
||||||
@ -41,6 +52,13 @@ export interface Config {
|
|||||||
characterLimit: number;
|
characterLimit: number;
|
||||||
maxTranscriptLength: 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: {
|
limits: {
|
||||||
characterLimit: 25000, // Standard MCP character limit
|
characterLimit: 25000, // Standard MCP character limit
|
||||||
maxTranscriptLength: 50000 // Transcripts can be larger
|
maxTranscriptLength: 50000 // Transcripts can be larger
|
||||||
|
},
|
||||||
|
cookies: {
|
||||||
|
file: undefined,
|
||||||
|
fromBrowser: undefined
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -123,6 +145,18 @@ function loadEnvConfig(): DeepPartial<Config> {
|
|||||||
envConfig.download = downloadConfig;
|
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;
|
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)) {
|
if (!/^[a-z]{2,3}(-[A-Z][a-z]{3})?(-[A-Z]{2})?$/i.test(config.download.defaultSubtitleLanguage)) {
|
||||||
throw new Error('Invalid 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: {
|
limits: {
|
||||||
characterLimit: override.limits?.characterLimit || base.limits.characterLimit,
|
characterLimit: override.limits?.characterLimit || base.limits.characterLimit,
|
||||||
maxTranscriptLength: override.limits?.maxTranscriptLength || base.limits.maxTranscriptLength
|
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;
|
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 current configuration instance
|
||||||
export const CONFIG = loadConfig();
|
export const CONFIG = loadConfig();
|
||||||
@ -507,7 +507,7 @@ server.setRequestHandler(
|
|||||||
} else if (toolName === "ytdlp_list_subtitle_languages") {
|
} else if (toolName === "ytdlp_list_subtitle_languages") {
|
||||||
const validated = ListSubtitleLanguagesSchema.parse(args);
|
const validated = ListSubtitleLanguagesSchema.parse(args);
|
||||||
return handleToolExecution(
|
return handleToolExecution(
|
||||||
() => listSubtitles(validated.url),
|
() => listSubtitles(validated.url, CONFIG),
|
||||||
"Error listing subtitle languages"
|
"Error listing subtitle languages"
|
||||||
);
|
);
|
||||||
} else if (toolName === "ytdlp_download_video_subtitles") {
|
} else if (toolName === "ytdlp_download_video_subtitles") {
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { readdirSync } from "fs";
|
import { readdirSync } from "fs";
|
||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
import type { Config } from "../config.js";
|
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";
|
import { _spawnPromise, validateUrl, getFormattedTimestamp, isYouTubeUrl } from "./utils.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -52,6 +52,7 @@ export async function downloadAudio(url: string, config: Config): Promise<string
|
|||||||
"--no-mtime",
|
"--no-mtime",
|
||||||
"-f", format,
|
"-f", format,
|
||||||
"--output", outputTemplate,
|
"--output", outputTemplate,
|
||||||
|
...getCookieArgs(config),
|
||||||
url
|
url
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import type { Config } from "../config.js";
|
import type { Config } from "../config.js";
|
||||||
|
import { getCookieArgs } from "../config.js";
|
||||||
import {
|
import {
|
||||||
_spawnPromise,
|
_spawnPromise,
|
||||||
validateUrl
|
validateUrl
|
||||||
@ -156,6 +157,7 @@ export async function getVideoMetadata(
|
|||||||
"--dump-json",
|
"--dump-json",
|
||||||
"--no-warnings",
|
"--no-warnings",
|
||||||
"--no-check-certificate",
|
"--no-check-certificate",
|
||||||
|
...(_config ? getCookieArgs(_config) : []),
|
||||||
url
|
url
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { _spawnPromise } from "./utils.js";
|
import { _spawnPromise } from "./utils.js";
|
||||||
import type { Config } from "../config.js";
|
import type { Config } from "../config.js";
|
||||||
|
import { getCookieArgs } from "../config.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* YouTube search result interface
|
* YouTube search result interface
|
||||||
@ -57,7 +58,8 @@ export async function searchVideos(
|
|||||||
"--print", "uploader",
|
"--print", "uploader",
|
||||||
"--print", "duration",
|
"--print", "duration",
|
||||||
"--no-download",
|
"--no-download",
|
||||||
"--quiet"
|
"--quiet",
|
||||||
|
...getCookieArgs(config)
|
||||||
];
|
];
|
||||||
|
|
||||||
const result = await _spawnPromise(config.tools.required[0], args);
|
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 path from "path";
|
||||||
import * as os from "os";
|
import * as os from "os";
|
||||||
import type { Config } from '../config.js';
|
import type { Config } from '../config.js';
|
||||||
|
import { getCookieArgs } from '../config.js';
|
||||||
import { _spawnPromise, validateUrl, cleanSubtitleToTranscript } from "./utils.js";
|
import { _spawnPromise, validateUrl, cleanSubtitleToTranscript } from "./utils.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lists all available subtitles for a video.
|
* Lists all available subtitles for a video.
|
||||||
*
|
*
|
||||||
* @param url - The URL of the 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
|
* @returns Promise resolving to a string containing the list of available subtitles
|
||||||
* @throws {Error} When URL is invalid or subtitle listing fails
|
* @throws {Error} When URL is invalid or subtitle listing fails
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```typescript
|
* ```typescript
|
||||||
* try {
|
* 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);
|
* console.log('Available subtitles:', subtitles);
|
||||||
* } catch (error) {
|
* } catch (error) {
|
||||||
* console.error('Failed to list subtitles:', 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)) {
|
if (!validateUrl(url)) {
|
||||||
throw new Error('Invalid or unsupported URL format. Please provide a valid video URL (e.g., https://youtube.com/watch?v=...)');
|
throw new Error('Invalid or unsupported URL format. Please provide a valid video URL (e.g., https://youtube.com/watch?v=...)');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const output = await _spawnPromise('yt-dlp', [
|
const args = [
|
||||||
'--ignore-config',
|
'--ignore-config',
|
||||||
'--list-subs',
|
'--list-subs',
|
||||||
'--write-auto-sub',
|
'--write-auto-sub',
|
||||||
'--skip-download',
|
'--skip-download',
|
||||||
'--verbose',
|
'--verbose',
|
||||||
|
...(config ? getCookieArgs(config) : []),
|
||||||
url
|
url
|
||||||
]);
|
];
|
||||||
|
const output = await _spawnPromise('yt-dlp', args);
|
||||||
return output;
|
return output;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
@ -99,6 +103,7 @@ export async function downloadSubtitles(
|
|||||||
'--sub-lang', language,
|
'--sub-lang', language,
|
||||||
'--skip-download',
|
'--skip-download',
|
||||||
'--output', path.join(tempDir, '%(title)s.%(ext)s'),
|
'--output', path.join(tempDir, '%(title)s.%(ext)s'),
|
||||||
|
...getCookieArgs(config),
|
||||||
url
|
url
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -179,6 +184,7 @@ export async function downloadTranscript(
|
|||||||
'--sub-format', 'ttml',
|
'--sub-format', 'ttml',
|
||||||
'--convert-subs', 'srt',
|
'--convert-subs', 'srt',
|
||||||
'--output', path.join(tempDir, 'transcript.%(ext)s'),
|
'--output', path.join(tempDir, 'transcript.%(ext)s'),
|
||||||
|
...getCookieArgs(config),
|
||||||
url
|
url
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
import type { Config } from "../config.js";
|
import type { Config } from "../config.js";
|
||||||
import { sanitizeFilename } from "../config.js";
|
import { sanitizeFilename, getCookieArgs } from "../config.js";
|
||||||
import {
|
import {
|
||||||
_spawnPromise,
|
_spawnPromise,
|
||||||
validateUrl,
|
validateUrl,
|
||||||
@ -102,13 +102,15 @@ export async function downloadVideo(
|
|||||||
sanitizeFilename(`%(title)s [%(id)s] ${timestamp}`, config.file) + '.%(ext)s'
|
sanitizeFilename(`%(title)s [%(id)s] ${timestamp}`, config.file) + '.%(ext)s'
|
||||||
);
|
);
|
||||||
|
|
||||||
expectedFilename = await _spawnPromise("yt-dlp", [
|
const getFilenameArgs = [
|
||||||
"--ignore-config",
|
"--ignore-config",
|
||||||
"--get-filename",
|
"--get-filename",
|
||||||
"-f", format,
|
"-f", format,
|
||||||
"--output", outputTemplate,
|
"--output", outputTemplate,
|
||||||
|
...getCookieArgs(config),
|
||||||
url
|
url
|
||||||
]);
|
];
|
||||||
|
expectedFilename = await _spawnPromise("yt-dlp", getFilenameArgs);
|
||||||
expectedFilename = expectedFilename.trim();
|
expectedFilename = expectedFilename.trim();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// 如果無法獲取檔案名稱,使用隨機檔案名
|
// 如果無法獲取檔案名稱,使用隨機檔案名
|
||||||
@ -124,7 +126,8 @@ export async function downloadVideo(
|
|||||||
"--newline",
|
"--newline",
|
||||||
"--no-mtime",
|
"--no-mtime",
|
||||||
"-f", format,
|
"-f", format,
|
||||||
"--output", outputTemplate
|
"--output", outputTemplate,
|
||||||
|
...getCookieArgs(config)
|
||||||
];
|
];
|
||||||
|
|
||||||
// Add trimming parameters if provided
|
// Add trimming parameters if provided
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user