mirror of
https://github.com/kevinwatt/yt-dlp-mcp.git
synced 2025-08-10 16:09:14 +03:00
feat: major improvements and version bump to 0.6.24 - Remove prompts functionality (prompts.ts and tests) - Improve error handling with VideoDownloadError class - Move configuration to dedicated file - Add URL validation and security checks - Reorganize code into modules - Add comprehensive unit tests - Enhance documentation with JSDoc and examples
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -28,7 +28,6 @@ node_modules
|
|||||||
|
|
||||||
lib
|
lib
|
||||||
test-dist
|
test-dist
|
||||||
docs
|
|
||||||
|
|
||||||
# WebStorm
|
# WebStorm
|
||||||
.idea/
|
.idea/
|
||||||
|
|||||||
10
README.md
10
README.md
@@ -94,6 +94,16 @@ npx @kevinwatt/yt-dlp-mcp
|
|||||||
* `yt-dlp` in system PATH
|
* `yt-dlp` in system PATH
|
||||||
* MCP-compatible LLM service
|
* MCP-compatible LLM service
|
||||||
|
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- [API Reference](./docs/api.md)
|
||||||
|
- [Examples](./docs/examples.md)
|
||||||
|
- [Configuration](./docs/configuration.md)
|
||||||
|
- [Error Handling](./docs/error-handling.md)
|
||||||
|
- [Contributing](./docs/contributing.md)
|
||||||
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT
|
MIT
|
||||||
|
|||||||
125
docs/api.md
Normal file
125
docs/api.md
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
# API Reference
|
||||||
|
|
||||||
|
## Video Operations
|
||||||
|
|
||||||
|
### downloadVideo(url: string, config?: Config, resolution?: string): Promise<string>
|
||||||
|
|
||||||
|
Downloads a video from the specified URL.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `url`: The URL of the video to download
|
||||||
|
- `config`: (Optional) Configuration object
|
||||||
|
- `resolution`: (Optional) Preferred video resolution ('480p', '720p', '1080p', 'best')
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
- Promise resolving to a success message with the downloaded file path
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```javascript
|
||||||
|
import { downloadVideo } from '@kevinwatt/yt-dlp-mcp';
|
||||||
|
|
||||||
|
// Download with default settings
|
||||||
|
const result = await downloadVideo('https://www.youtube.com/watch?v=jNQXAC9IVRw');
|
||||||
|
console.log(result);
|
||||||
|
|
||||||
|
// Download with specific resolution
|
||||||
|
const hdResult = await downloadVideo(
|
||||||
|
'https://www.youtube.com/watch?v=jNQXAC9IVRw',
|
||||||
|
undefined,
|
||||||
|
'1080p'
|
||||||
|
);
|
||||||
|
console.log(hdResult);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Audio Operations
|
||||||
|
|
||||||
|
### downloadAudio(url: string, config?: Config): Promise<string>
|
||||||
|
|
||||||
|
Downloads audio from the specified URL in the best available quality.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `url`: The URL of the video to extract audio from
|
||||||
|
- `config`: (Optional) Configuration object
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
- Promise resolving to a success message with the downloaded file path
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```javascript
|
||||||
|
import { downloadAudio } from '@kevinwatt/yt-dlp-mcp';
|
||||||
|
|
||||||
|
const result = await downloadAudio('https://www.youtube.com/watch?v=jNQXAC9IVRw');
|
||||||
|
console.log(result);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Subtitle Operations
|
||||||
|
|
||||||
|
### listSubtitles(url: string): Promise<string>
|
||||||
|
|
||||||
|
Lists all available subtitles for a video.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `url`: The URL of the video
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
- Promise resolving to a string containing the list of available subtitles
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```javascript
|
||||||
|
import { listSubtitles } from '@kevinwatt/yt-dlp-mcp';
|
||||||
|
|
||||||
|
const subtitles = await listSubtitles('https://www.youtube.com/watch?v=jNQXAC9IVRw');
|
||||||
|
console.log(subtitles);
|
||||||
|
```
|
||||||
|
|
||||||
|
### downloadSubtitles(url: string, language: string): Promise<string>
|
||||||
|
|
||||||
|
Downloads subtitles for a video in the specified language.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `url`: The URL of the video
|
||||||
|
- `language`: Language code (e.g., 'en', 'zh-Hant', 'ja')
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
- Promise resolving to the subtitle content
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```javascript
|
||||||
|
import { downloadSubtitles } from '@kevinwatt/yt-dlp-mcp';
|
||||||
|
|
||||||
|
const subtitles = await downloadSubtitles(
|
||||||
|
'https://www.youtube.com/watch?v=jNQXAC9IVRw',
|
||||||
|
'en'
|
||||||
|
);
|
||||||
|
console.log(subtitles);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Config Interface
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface Config {
|
||||||
|
file: {
|
||||||
|
maxFilenameLength: number;
|
||||||
|
downloadsDir: string;
|
||||||
|
tempDirPrefix: string;
|
||||||
|
sanitize: {
|
||||||
|
replaceChar: string;
|
||||||
|
truncateSuffix: string;
|
||||||
|
illegalChars: RegExp;
|
||||||
|
reservedNames: readonly string[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
tools: {
|
||||||
|
required: readonly string[];
|
||||||
|
};
|
||||||
|
download: {
|
||||||
|
defaultResolution: "480p" | "720p" | "1080p" | "best";
|
||||||
|
defaultAudioFormat: "m4a" | "mp3";
|
||||||
|
defaultSubtitleLanguage: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For detailed configuration options, see [Configuration Guide](./configuration.md).
|
||||||
169
docs/configuration.md
Normal file
169
docs/configuration.md
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
# Configuration Guide
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The yt-dlp-mcp package can be configured through environment variables or by passing a configuration object to the functions.
|
||||||
|
|
||||||
|
## Configuration Object
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface Config {
|
||||||
|
file: {
|
||||||
|
maxFilenameLength: number;
|
||||||
|
downloadsDir: string;
|
||||||
|
tempDirPrefix: string;
|
||||||
|
sanitize: {
|
||||||
|
replaceChar: string;
|
||||||
|
truncateSuffix: string;
|
||||||
|
illegalChars: RegExp;
|
||||||
|
reservedNames: readonly string[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
tools: {
|
||||||
|
required: readonly string[];
|
||||||
|
};
|
||||||
|
download: {
|
||||||
|
defaultResolution: "480p" | "720p" | "1080p" | "best";
|
||||||
|
defaultAudioFormat: "m4a" | "mp3";
|
||||||
|
defaultSubtitleLanguage: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
|----------|-------------|---------|
|
||||||
|
| `YTDLP_MAX_FILENAME_LENGTH` | Maximum length for filenames | 50 |
|
||||||
|
| `YTDLP_DOWNLOADS_DIR` | Download directory path | `~/Downloads` |
|
||||||
|
| `YTDLP_TEMP_DIR_PREFIX` | Prefix for temporary directories | `ytdlp-` |
|
||||||
|
| `YTDLP_SANITIZE_REPLACE_CHAR` | Character to replace illegal characters | `_` |
|
||||||
|
| `YTDLP_SANITIZE_TRUNCATE_SUFFIX` | Suffix for truncated filenames | `...` |
|
||||||
|
| `YTDLP_SANITIZE_ILLEGAL_CHARS` | Regex pattern for illegal characters | `/[<>:"/\\|?*\x00-\x1F]/g` |
|
||||||
|
| `YTDLP_SANITIZE_RESERVED_NAMES` | Comma-separated list of reserved names | `CON,PRN,AUX,...` |
|
||||||
|
| `YTDLP_DEFAULT_RESOLUTION` | Default video resolution | `720p` |
|
||||||
|
| `YTDLP_DEFAULT_AUDIO_FORMAT` | Default audio format | `m4a` |
|
||||||
|
| `YTDLP_DEFAULT_SUBTITLE_LANG` | Default subtitle language | `en` |
|
||||||
|
|
||||||
|
## File Configuration
|
||||||
|
|
||||||
|
### Download Directory
|
||||||
|
|
||||||
|
The download directory can be configured in two ways:
|
||||||
|
|
||||||
|
1. Environment variable:
|
||||||
|
```bash
|
||||||
|
export YTDLP_DOWNLOADS_DIR="/path/to/downloads"
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Configuration object:
|
||||||
|
```javascript
|
||||||
|
const config = {
|
||||||
|
file: {
|
||||||
|
downloadsDir: "/path/to/downloads"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Filename Sanitization
|
||||||
|
|
||||||
|
Control how filenames are sanitized:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const config = {
|
||||||
|
file: {
|
||||||
|
maxFilenameLength: 100,
|
||||||
|
sanitize: {
|
||||||
|
replaceChar: '-',
|
||||||
|
truncateSuffix: '___',
|
||||||
|
illegalChars: /[<>:"/\\|?*\x00-\x1F]/g,
|
||||||
|
reservedNames: ['CON', 'PRN', 'AUX', 'NUL']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Download Configuration
|
||||||
|
|
||||||
|
### Video Resolution
|
||||||
|
|
||||||
|
Set default video resolution:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const config = {
|
||||||
|
download: {
|
||||||
|
defaultResolution: "1080p" // "480p" | "720p" | "1080p" | "best"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Audio Format
|
||||||
|
|
||||||
|
Configure audio format preferences:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const config = {
|
||||||
|
download: {
|
||||||
|
defaultAudioFormat: "m4a" // "m4a" | "mp3"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Subtitle Language
|
||||||
|
|
||||||
|
Set default subtitle language:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const config = {
|
||||||
|
download: {
|
||||||
|
defaultSubtitleLanguage: "en"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tools Configuration
|
||||||
|
|
||||||
|
Configure required external tools:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const config = {
|
||||||
|
tools: {
|
||||||
|
required: ['yt-dlp']
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Complete Configuration Example
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { CONFIG } from '@kevinwatt/yt-dlp-mcp';
|
||||||
|
|
||||||
|
const customConfig = {
|
||||||
|
file: {
|
||||||
|
maxFilenameLength: 100,
|
||||||
|
downloadsDir: '/custom/downloads',
|
||||||
|
tempDirPrefix: 'ytdlp-temp-',
|
||||||
|
sanitize: {
|
||||||
|
replaceChar: '-',
|
||||||
|
truncateSuffix: '___',
|
||||||
|
illegalChars: /[<>:"/\\|?*\x00-\x1F]/g,
|
||||||
|
reservedNames: [
|
||||||
|
'CON', 'PRN', 'AUX', 'NUL',
|
||||||
|
'COM1', 'COM2', 'COM3', 'COM4', 'COM5',
|
||||||
|
'LPT1', 'LPT2', 'LPT3'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tools: {
|
||||||
|
required: ['yt-dlp']
|
||||||
|
},
|
||||||
|
download: {
|
||||||
|
defaultResolution: '1080p',
|
||||||
|
defaultAudioFormat: 'm4a',
|
||||||
|
defaultSubtitleLanguage: 'en'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use the custom configuration
|
||||||
|
const result = await downloadVideo(url, customConfig);
|
||||||
|
```
|
||||||
192
docs/contributing.md
Normal file
192
docs/contributing.md
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
# Contributing Guide
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
1. Fork the repository
|
||||||
|
2. Clone your fork:
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/your-username/yt-dlp-mcp.git
|
||||||
|
cd yt-dlp-mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Install dependencies:
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Create a new branch:
|
||||||
|
```bash
|
||||||
|
git checkout -b feature/your-feature-name
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development Setup
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Node.js 16.x or higher
|
||||||
|
- yt-dlp installed on your system
|
||||||
|
- TypeScript knowledge
|
||||||
|
- Jest for testing
|
||||||
|
|
||||||
|
### Building
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
For specific test files:
|
||||||
|
```bash
|
||||||
|
npm test -- src/__tests__/video.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Code Style
|
||||||
|
|
||||||
|
We use TypeScript and follow these conventions:
|
||||||
|
|
||||||
|
- Use meaningful variable and function names
|
||||||
|
- Add JSDoc comments for public APIs
|
||||||
|
- Follow the existing code style
|
||||||
|
- Use async/await for promises
|
||||||
|
- Handle errors appropriately
|
||||||
|
|
||||||
|
### TypeScript Guidelines
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Use explicit types
|
||||||
|
function downloadVideo(url: string, config?: Config): Promise<string> {
|
||||||
|
// Implementation
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use interfaces for complex types
|
||||||
|
interface DownloadOptions {
|
||||||
|
resolution: string;
|
||||||
|
format: string;
|
||||||
|
output: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use enums for fixed values
|
||||||
|
enum Resolution {
|
||||||
|
SD = '480p',
|
||||||
|
HD = '720p',
|
||||||
|
FHD = '1080p',
|
||||||
|
BEST = 'best'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Writing Tests
|
||||||
|
|
||||||
|
- Place tests in `src/__tests__` directory
|
||||||
|
- Name test files with `.test.ts` suffix
|
||||||
|
- Use descriptive test names
|
||||||
|
- Test both success and error cases
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
describe('downloadVideo', () => {
|
||||||
|
test('downloads video successfully', async () => {
|
||||||
|
const result = await downloadVideo(testUrl);
|
||||||
|
expect(result).toMatch(/Video successfully downloaded/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles invalid URL', async () => {
|
||||||
|
await expect(downloadVideo('invalid-url'))
|
||||||
|
.rejects
|
||||||
|
.toThrow('Invalid or unsupported URL');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Coverage
|
||||||
|
|
||||||
|
Aim for high test coverage:
|
||||||
|
```bash
|
||||||
|
npm run test:coverage
|
||||||
|
```
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
### JSDoc Comments
|
||||||
|
|
||||||
|
Add comprehensive JSDoc comments for all public APIs:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/**
|
||||||
|
* Downloads a video from the specified URL.
|
||||||
|
*
|
||||||
|
* @param url - The URL of the video to download
|
||||||
|
* @param config - Optional configuration object
|
||||||
|
* @param resolution - Preferred video resolution
|
||||||
|
* @returns Promise resolving to success message with file path
|
||||||
|
* @throws {Error} When URL is invalid or download fails
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const result = await downloadVideo('https://youtube.com/watch?v=...', config);
|
||||||
|
* console.log(result);
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export async function downloadVideo(
|
||||||
|
url: string,
|
||||||
|
config?: Config,
|
||||||
|
resolution?: string
|
||||||
|
): Promise<string> {
|
||||||
|
// Implementation
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### README Updates
|
||||||
|
|
||||||
|
- Update README.md for new features
|
||||||
|
- Keep examples up to date
|
||||||
|
- Document breaking changes
|
||||||
|
|
||||||
|
## Pull Request Process
|
||||||
|
|
||||||
|
1. Update tests and documentation
|
||||||
|
2. Run all tests and linting
|
||||||
|
3. Update CHANGELOG.md
|
||||||
|
4. Create detailed PR description
|
||||||
|
5. Reference related issues
|
||||||
|
|
||||||
|
### PR Checklist
|
||||||
|
|
||||||
|
- [ ] Tests added/updated
|
||||||
|
- [ ] Documentation updated
|
||||||
|
- [ ] CHANGELOG.md updated
|
||||||
|
- [ ] Code follows style guidelines
|
||||||
|
- [ ] All tests passing
|
||||||
|
- [ ] No linting errors
|
||||||
|
|
||||||
|
## Release Process
|
||||||
|
|
||||||
|
1. Update version in package.json
|
||||||
|
2. Update CHANGELOG.md
|
||||||
|
3. Create release commit
|
||||||
|
4. Tag release
|
||||||
|
5. Push to main branch
|
||||||
|
|
||||||
|
### Version Numbers
|
||||||
|
|
||||||
|
Follow semantic versioning:
|
||||||
|
- MAJOR: Breaking changes
|
||||||
|
- MINOR: New features
|
||||||
|
- PATCH: Bug fixes
|
||||||
|
|
||||||
|
## Community
|
||||||
|
|
||||||
|
- Be respectful and inclusive
|
||||||
|
- Help others when possible
|
||||||
|
- Report bugs with detailed information
|
||||||
|
- Suggest improvements
|
||||||
|
- Share success stories
|
||||||
|
|
||||||
|
For more information, see the [README](./README.md) and [API Reference](./api.md).
|
||||||
175
docs/error-handling.md
Normal file
175
docs/error-handling.md
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
# Error Handling Guide
|
||||||
|
|
||||||
|
## Common Errors
|
||||||
|
|
||||||
|
### Invalid URL
|
||||||
|
|
||||||
|
When providing an invalid or unsupported URL:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
try {
|
||||||
|
await downloadVideo('invalid-url');
|
||||||
|
} catch (error) {
|
||||||
|
if (error.message.includes('Invalid or unsupported URL')) {
|
||||||
|
console.error('Please provide a valid YouTube or supported platform URL');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Missing Subtitles
|
||||||
|
|
||||||
|
When trying to download unavailable subtitles:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
try {
|
||||||
|
await downloadSubtitles(url, 'en');
|
||||||
|
} catch (error) {
|
||||||
|
if (error.message.includes('No subtitle files found')) {
|
||||||
|
console.warn('No subtitles available in the requested language');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### yt-dlp Command Failures
|
||||||
|
|
||||||
|
When yt-dlp command execution fails:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
try {
|
||||||
|
await downloadVideo(url);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.message.includes('Failed with exit code')) {
|
||||||
|
console.error('yt-dlp command failed:', error.message);
|
||||||
|
// Check if yt-dlp is installed and up to date
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### File System Errors
|
||||||
|
|
||||||
|
When encountering file system issues:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
try {
|
||||||
|
await downloadVideo(url);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.message.includes('No write permission')) {
|
||||||
|
console.error('Cannot write to downloads directory. Check permissions.');
|
||||||
|
} else if (error.message.includes('Cannot create temporary directory')) {
|
||||||
|
console.error('Cannot create temporary directory. Check system temp directory permissions.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Comprehensive Error Handler
|
||||||
|
|
||||||
|
Here's a comprehensive error handler that covers most common scenarios:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async function handleDownload(url, options = {}) {
|
||||||
|
try {
|
||||||
|
// Attempt the download
|
||||||
|
const result = await downloadVideo(url, options);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
// URL validation errors
|
||||||
|
if (error.message.includes('Invalid or unsupported URL')) {
|
||||||
|
throw new Error(`Invalid URL: ${url}. Please provide a valid video URL.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// File system errors
|
||||||
|
if (error.message.includes('No write permission')) {
|
||||||
|
throw new Error(`Permission denied: Cannot write to ${options.file?.downloadsDir || '~/Downloads'}`);
|
||||||
|
}
|
||||||
|
if (error.message.includes('Cannot create temporary directory')) {
|
||||||
|
throw new Error('Cannot create temporary directory. Check system permissions.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// yt-dlp related errors
|
||||||
|
if (error.message.includes('Failed with exit code')) {
|
||||||
|
if (error.message.includes('This video is unavailable')) {
|
||||||
|
throw new Error('Video is unavailable or has been removed.');
|
||||||
|
}
|
||||||
|
if (error.message.includes('Video is private')) {
|
||||||
|
throw new Error('This video is private and cannot be accessed.');
|
||||||
|
}
|
||||||
|
throw new Error('Download failed. Please check if yt-dlp is installed and up to date.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subtitle related errors
|
||||||
|
if (error.message.includes('No subtitle files found')) {
|
||||||
|
throw new Error(`No subtitles available in ${options.language || 'the requested language'}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown errors
|
||||||
|
throw new Error(`Unexpected error: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Prevention
|
||||||
|
|
||||||
|
### URL Validation
|
||||||
|
|
||||||
|
Always validate URLs before processing:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { validateUrl, isYouTubeUrl } from '@kevinwatt/yt-dlp-mcp';
|
||||||
|
|
||||||
|
function validateVideoUrl(url) {
|
||||||
|
if (!validateUrl(url)) {
|
||||||
|
throw new Error('Invalid URL format');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isYouTubeUrl(url)) {
|
||||||
|
console.warn('URL is not from YouTube, some features might not work');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration Validation
|
||||||
|
|
||||||
|
Validate configuration before use:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function validateConfig(config) {
|
||||||
|
if (!config.file.downloadsDir) {
|
||||||
|
throw new Error('Downloads directory must be specified');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.file.maxFilenameLength < 5) {
|
||||||
|
throw new Error('Filename length must be at least 5 characters');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['480p', '720p', '1080p', 'best'].includes(config.download.defaultResolution)) {
|
||||||
|
throw new Error('Invalid resolution specified');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Safe Cleanup
|
||||||
|
|
||||||
|
Always use safe cleanup for temporary files:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { safeCleanup } from '@kevinwatt/yt-dlp-mcp';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Your download code here
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Download failed:', error);
|
||||||
|
} finally {
|
||||||
|
await safeCleanup(tempDir);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. Always wrap async operations in try-catch blocks
|
||||||
|
2. Validate inputs before processing
|
||||||
|
3. Use specific error types for different scenarios
|
||||||
|
4. Clean up temporary files in finally blocks
|
||||||
|
5. Log errors appropriately for debugging
|
||||||
|
6. Provide meaningful error messages to users
|
||||||
|
|
||||||
|
For more information about specific errors and their solutions, see the [API Reference](./api.md).
|
||||||
1
docs/examples.md
Normal file
1
docs/examples.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@kevinwatt/yt-dlp-mcp",
|
"name": "@kevinwatt/yt-dlp-mcp",
|
||||||
"version": "0.6.9",
|
"version": "0.6.23",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@kevinwatt/yt-dlp-mcp",
|
"name": "@kevinwatt/yt-dlp-mcp",
|
||||||
"version": "0.6.9",
|
"version": "0.6.23",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "0.7.0",
|
"@modelcontextprotocol/sdk": "0.7.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@kevinwatt/yt-dlp-mcp",
|
"name": "@kevinwatt/yt-dlp-mcp",
|
||||||
"version": "0.6.23",
|
"version": "0.6.24",
|
||||||
"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",
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
"main": "./lib/index.mjs",
|
"main": "./lib/index.mjs",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"prepare": "tsc && shx chmod +x ./lib/index.mjs",
|
"prepare": "tsc && shx chmod +x ./lib/index.mjs",
|
||||||
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js --detectOpenHandles --forceExit"
|
"test": "PYTHONPATH= PYTHONHOME= node --experimental-vm-modules node_modules/jest/bin/jest.js --detectOpenHandles --forceExit"
|
||||||
},
|
},
|
||||||
"author": "Dewei Yen <k@funmula.com>",
|
"author": "Dewei Yen <k@funmula.com>",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
43
src/__tests__/audio.test.ts
Normal file
43
src/__tests__/audio.test.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
// @jest-environment node
|
||||||
|
import { describe, test, expect } from '@jest/globals';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { downloadAudio } from '../modules/audio.js';
|
||||||
|
import { CONFIG } from '../config.js';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
|
||||||
|
describe('downloadAudio', () => {
|
||||||
|
const testUrl = 'https://www.youtube.com/watch?v=jNQXAC9IVRw';
|
||||||
|
const testConfig = {
|
||||||
|
...CONFIG,
|
||||||
|
file: {
|
||||||
|
...CONFIG.file,
|
||||||
|
downloadsDir: path.join(os.tmpdir(), 'yt-dlp-test-downloads'),
|
||||||
|
tempDirPrefix: 'yt-dlp-test-'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await fs.promises.mkdir(testConfig.file.downloadsDir, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await fs.promises.rm(testConfig.file.downloadsDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('downloads audio successfully from YouTube', async () => {
|
||||||
|
const result = await downloadAudio(testUrl, testConfig);
|
||||||
|
expect(result).toContain('Audio successfully downloaded');
|
||||||
|
|
||||||
|
const files = await fs.promises.readdir(testConfig.file.downloadsDir);
|
||||||
|
expect(files.length).toBeGreaterThan(0);
|
||||||
|
expect(files[0]).toMatch(/\.m4a$/);
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
test('handles invalid URL', async () => {
|
||||||
|
await expect(downloadAudio('invalid-url', testConfig))
|
||||||
|
.rejects
|
||||||
|
.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,79 +1,56 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
// @jest-environment node
|
// @jest-environment node
|
||||||
import { jest } from '@jest/globals';
|
import { describe, test, expect } from '@jest/globals';
|
||||||
import { describe, test, expect, beforeAll, afterAll, beforeEach } from '@jest/globals';
|
|
||||||
import * as path from 'path';
|
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { downloadVideo } from '../modules/video.js';
|
||||||
|
import { CONFIG } from '../config.js';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
|
||||||
// 簡化 mock
|
// 設置 Python 環境
|
||||||
jest.mock('spawn-rx', () => ({
|
process.env.PYTHONPATH = '';
|
||||||
spawnPromise: jest.fn().mockImplementation(async (cmd, args) => {
|
process.env.PYTHONHOME = '';
|
||||||
if (args.includes('--get-filename')) {
|
|
||||||
return 'mock_video.mp4';
|
|
||||||
}
|
|
||||||
return 'Download completed';
|
|
||||||
})
|
|
||||||
}));
|
|
||||||
jest.mock('rimraf', () => ({
|
|
||||||
rimraf: { sync: jest.fn() }
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { downloadVideo } from '../index.mts';
|
|
||||||
|
|
||||||
describe('downloadVideo', () => {
|
describe('downloadVideo', () => {
|
||||||
const mockTimestamp = '2024-03-20_12-30-00';
|
const testUrl = 'https://www.youtube.com/watch?v=jNQXAC9IVRw';
|
||||||
let originalDateToISOString: () => string;
|
const testConfig = {
|
||||||
|
...CONFIG,
|
||||||
|
file: {
|
||||||
|
...CONFIG.file,
|
||||||
|
downloadsDir: path.join(os.tmpdir(), 'yt-dlp-test-downloads'),
|
||||||
|
tempDirPrefix: 'yt-dlp-test-'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 全局清理
|
beforeEach(async () => {
|
||||||
afterAll(done => {
|
await fs.promises.mkdir(testConfig.file.downloadsDir, { recursive: true });
|
||||||
// 清理所有計時器
|
|
||||||
jest.useRealTimers();
|
|
||||||
// 確保所有異步操作完成
|
|
||||||
process.nextTick(done);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeAll(() => {
|
afterEach(async () => {
|
||||||
originalDateToISOString = Date.prototype.toISOString;
|
await fs.promises.rm(testConfig.file.downloadsDir, { recursive: true, force: true });
|
||||||
Date.prototype.toISOString = jest.fn(() => '2024-03-20T12:30:00.000Z');
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
Date.prototype.toISOString = originalDateToISOString;
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('downloads video successfully with correct format', async () => {
|
test('downloads video successfully with correct format', async () => {
|
||||||
const result = await downloadVideo('https://www.youtube.com/watch?v=dQw4w9WgXcQ');
|
const result = await downloadVideo(testUrl, testConfig);
|
||||||
|
expect(result).toContain('Video successfully downloaded');
|
||||||
|
|
||||||
// 驗證基本功能
|
const files = await fs.promises.readdir(testConfig.file.downloadsDir);
|
||||||
expect(result).toMatch(/Video successfully downloaded as/);
|
expect(files.length).toBeGreaterThan(0);
|
||||||
expect(result).toContain(mockTimestamp);
|
expect(files[0]).toMatch(/\.(mp4|webm|mkv)$/);
|
||||||
expect(result).toContain(os.homedir());
|
}, 30000);
|
||||||
expect(result).toContain('Downloads');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('handles special characters in video URL', async () => {
|
|
||||||
// 使用有效的視頻 ID,但包含需要編碼的字符
|
|
||||||
const result = await downloadVideo('https://www.youtube.com/watch?v=dQw4w9WgXcQ&title=特殊字符');
|
|
||||||
|
|
||||||
expect(result).toMatch(/Video successfully downloaded as/);
|
|
||||||
expect(result).toContain(mockTimestamp);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('uses correct resolution format', async () => {
|
test('uses correct resolution format', async () => {
|
||||||
const resolutions = ['480p', '720p', '1080p', 'best'];
|
const result = await downloadVideo(testUrl, testConfig, '1080p');
|
||||||
|
expect(result).toContain('Video successfully downloaded');
|
||||||
|
|
||||||
// 使用 Promise.all 並行執行測試
|
const files = await fs.promises.readdir(testConfig.file.downloadsDir);
|
||||||
const results = await Promise.all(resolutions.map(resolution => downloadVideo(
|
expect(files.length).toBeGreaterThan(0);
|
||||||
'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
expect(files[0]).toMatch(/\.(mp4|webm|mkv)$/);
|
||||||
resolution
|
}, 30000);
|
||||||
)));
|
|
||||||
|
test('handles invalid URL', async () => {
|
||||||
results.forEach(result => {
|
await expect(downloadVideo('invalid-url', testConfig))
|
||||||
expect(result).toMatch(/Video successfully downloaded as/);
|
.rejects
|
||||||
});
|
.toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
54
src/__tests__/subtitle.test.ts
Normal file
54
src/__tests__/subtitle.test.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
// @jest-environment node
|
||||||
|
import { describe, test, expect } from '@jest/globals';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { listSubtitles, downloadSubtitles } from '../modules/subtitle.js';
|
||||||
|
import { CONFIG } from '../config.js';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
|
||||||
|
describe('Subtitle Functions', () => {
|
||||||
|
const testUrl = 'https://www.youtube.com/watch?v=jNQXAC9IVRw';
|
||||||
|
const testConfig = {
|
||||||
|
...CONFIG,
|
||||||
|
file: {
|
||||||
|
...CONFIG.file,
|
||||||
|
downloadsDir: path.join(os.tmpdir(), 'yt-dlp-test-downloads'),
|
||||||
|
tempDirPrefix: 'yt-dlp-test-'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await fs.promises.mkdir(testConfig.file.downloadsDir, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await fs.promises.rm(testConfig.file.downloadsDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('listSubtitles', () => {
|
||||||
|
test('lists available subtitles', async () => {
|
||||||
|
const result = await listSubtitles(testUrl);
|
||||||
|
expect(result).toContain('Language');
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
test('handles invalid URL', async () => {
|
||||||
|
await expect(listSubtitles('invalid-url'))
|
||||||
|
.rejects
|
||||||
|
.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('downloadSubtitles', () => {
|
||||||
|
test('downloads auto-generated subtitles successfully', async () => {
|
||||||
|
const result = await downloadSubtitles(testUrl, 'en', testConfig);
|
||||||
|
expect(result).toContain('WEBVTT');
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
test('handles missing language', async () => {
|
||||||
|
await expect(downloadSubtitles(testUrl, 'xx', testConfig))
|
||||||
|
.rejects
|
||||||
|
.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
216
src/config.ts
Normal file
216
src/config.ts
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
import * as os from "os";
|
||||||
|
import * as path from "path";
|
||||||
|
|
||||||
|
type DeepPartial<T> = {
|
||||||
|
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 配置類型定義
|
||||||
|
*/
|
||||||
|
export interface Config {
|
||||||
|
// 文件相關配置
|
||||||
|
file: {
|
||||||
|
maxFilenameLength: number;
|
||||||
|
downloadsDir: string;
|
||||||
|
tempDirPrefix: string;
|
||||||
|
// 文件名處理相關配置
|
||||||
|
sanitize: {
|
||||||
|
// 替換非法字符為此字符
|
||||||
|
replaceChar: string;
|
||||||
|
// 文件名截斷時的後綴
|
||||||
|
truncateSuffix: string;
|
||||||
|
// 非法字符正則表達式
|
||||||
|
illegalChars: RegExp;
|
||||||
|
// 保留字列表
|
||||||
|
reservedNames: readonly string[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
// 工具相關配置
|
||||||
|
tools: {
|
||||||
|
required: readonly string[];
|
||||||
|
};
|
||||||
|
// 下載相關配置
|
||||||
|
download: {
|
||||||
|
defaultResolution: "480p" | "720p" | "1080p" | "best";
|
||||||
|
defaultAudioFormat: "m4a" | "mp3";
|
||||||
|
defaultSubtitleLanguage: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 默認配置
|
||||||
|
*/
|
||||||
|
const defaultConfig: Config = {
|
||||||
|
file: {
|
||||||
|
maxFilenameLength: 50,
|
||||||
|
downloadsDir: path.join(os.homedir(), "Downloads"),
|
||||||
|
tempDirPrefix: "ytdlp-",
|
||||||
|
sanitize: {
|
||||||
|
replaceChar: '_',
|
||||||
|
truncateSuffix: '...',
|
||||||
|
illegalChars: /[<>:"/\\|?*\x00-\x1F]/g, // Windows 非法字符
|
||||||
|
reservedNames: [
|
||||||
|
'CON', 'PRN', 'AUX', 'NUL', 'COM1', 'COM2', 'COM3', 'COM4',
|
||||||
|
'COM5', 'COM6', 'COM7', 'COM8', 'COM9', 'LPT1', 'LPT2',
|
||||||
|
'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tools: {
|
||||||
|
required: ['yt-dlp']
|
||||||
|
},
|
||||||
|
download: {
|
||||||
|
defaultResolution: "720p",
|
||||||
|
defaultAudioFormat: "m4a",
|
||||||
|
defaultSubtitleLanguage: "en"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 從環境變數加載配置
|
||||||
|
*/
|
||||||
|
function loadEnvConfig(): DeepPartial<Config> {
|
||||||
|
const envConfig: DeepPartial<Config> = {};
|
||||||
|
|
||||||
|
// 文件配置
|
||||||
|
const fileConfig: DeepPartial<Config['file']> = {
|
||||||
|
sanitize: {
|
||||||
|
replaceChar: process.env.YTDLP_SANITIZE_REPLACE_CHAR,
|
||||||
|
truncateSuffix: process.env.YTDLP_SANITIZE_TRUNCATE_SUFFIX,
|
||||||
|
illegalChars: process.env.YTDLP_SANITIZE_ILLEGAL_CHARS ? new RegExp(process.env.YTDLP_SANITIZE_ILLEGAL_CHARS) : undefined,
|
||||||
|
reservedNames: process.env.YTDLP_SANITIZE_RESERVED_NAMES?.split(',')
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (process.env.YTDLP_MAX_FILENAME_LENGTH) {
|
||||||
|
fileConfig.maxFilenameLength = parseInt(process.env.YTDLP_MAX_FILENAME_LENGTH);
|
||||||
|
}
|
||||||
|
if (process.env.YTDLP_DOWNLOADS_DIR) {
|
||||||
|
fileConfig.downloadsDir = process.env.YTDLP_DOWNLOADS_DIR;
|
||||||
|
}
|
||||||
|
if (process.env.YTDLP_TEMP_DIR_PREFIX) {
|
||||||
|
fileConfig.tempDirPrefix = process.env.YTDLP_TEMP_DIR_PREFIX;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(fileConfig).length > 0) {
|
||||||
|
envConfig.file = fileConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下載配置
|
||||||
|
const downloadConfig: Partial<Config['download']> = {};
|
||||||
|
if (process.env.YTDLP_DEFAULT_RESOLUTION &&
|
||||||
|
['480p', '720p', '1080p', 'best'].includes(process.env.YTDLP_DEFAULT_RESOLUTION)) {
|
||||||
|
downloadConfig.defaultResolution = process.env.YTDLP_DEFAULT_RESOLUTION as Config['download']['defaultResolution'];
|
||||||
|
}
|
||||||
|
if (process.env.YTDLP_DEFAULT_AUDIO_FORMAT &&
|
||||||
|
['m4a', 'mp3'].includes(process.env.YTDLP_DEFAULT_AUDIO_FORMAT)) {
|
||||||
|
downloadConfig.defaultAudioFormat = process.env.YTDLP_DEFAULT_AUDIO_FORMAT as Config['download']['defaultAudioFormat'];
|
||||||
|
}
|
||||||
|
if (process.env.YTDLP_DEFAULT_SUBTITLE_LANG) {
|
||||||
|
downloadConfig.defaultSubtitleLanguage = process.env.YTDLP_DEFAULT_SUBTITLE_LANG;
|
||||||
|
}
|
||||||
|
if (Object.keys(downloadConfig).length > 0) {
|
||||||
|
envConfig.download = downloadConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
return envConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 驗證配置
|
||||||
|
*/
|
||||||
|
function validateConfig(config: Config): void {
|
||||||
|
// 驗證文件名長度
|
||||||
|
if (config.file.maxFilenameLength < 5) {
|
||||||
|
throw new Error('maxFilenameLength must be at least 5');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 驗證下載目錄
|
||||||
|
if (!config.file.downloadsDir) {
|
||||||
|
throw new Error('downloadsDir must be specified');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 驗證臨時目錄前綴
|
||||||
|
if (!config.file.tempDirPrefix) {
|
||||||
|
throw new Error('tempDirPrefix must be specified');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 驗證默認分辨率
|
||||||
|
if (!['480p', '720p', '1080p', 'best'].includes(config.download.defaultResolution)) {
|
||||||
|
throw new Error('Invalid defaultResolution');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 驗證默認音頻格式
|
||||||
|
if (!['m4a', 'mp3'].includes(config.download.defaultAudioFormat)) {
|
||||||
|
throw new Error('Invalid defaultAudioFormat');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 驗證默認字幕語言
|
||||||
|
if (!/^[a-z]{2,3}(-[A-Z][a-z]{3})?(-[A-Z]{2})?$/i.test(config.download.defaultSubtitleLanguage)) {
|
||||||
|
throw new Error('Invalid defaultSubtitleLanguage');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 合併配置
|
||||||
|
*/
|
||||||
|
function mergeConfig(base: Config, override: DeepPartial<Config>): Config {
|
||||||
|
return {
|
||||||
|
file: {
|
||||||
|
maxFilenameLength: override.file?.maxFilenameLength || base.file.maxFilenameLength,
|
||||||
|
downloadsDir: override.file?.downloadsDir || base.file.downloadsDir,
|
||||||
|
tempDirPrefix: override.file?.tempDirPrefix || base.file.tempDirPrefix,
|
||||||
|
sanitize: {
|
||||||
|
replaceChar: override.file?.sanitize?.replaceChar || base.file.sanitize.replaceChar,
|
||||||
|
truncateSuffix: override.file?.sanitize?.truncateSuffix || base.file.sanitize.truncateSuffix,
|
||||||
|
illegalChars: (override.file?.sanitize?.illegalChars || base.file.sanitize.illegalChars) as RegExp,
|
||||||
|
reservedNames: (override.file?.sanitize?.reservedNames || base.file.sanitize.reservedNames) as readonly string[]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tools: {
|
||||||
|
required: (override.tools?.required || base.tools.required) as readonly string[]
|
||||||
|
},
|
||||||
|
download: {
|
||||||
|
defaultResolution: override.download?.defaultResolution || base.download.defaultResolution,
|
||||||
|
defaultAudioFormat: override.download?.defaultAudioFormat || base.download.defaultAudioFormat,
|
||||||
|
defaultSubtitleLanguage: override.download?.defaultSubtitleLanguage || base.download.defaultSubtitleLanguage
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加載配置
|
||||||
|
*/
|
||||||
|
export function loadConfig(): Config {
|
||||||
|
const envConfig = loadEnvConfig();
|
||||||
|
const config = mergeConfig(defaultConfig, envConfig);
|
||||||
|
validateConfig(config);
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安全的文件名處理函數
|
||||||
|
*/
|
||||||
|
export function sanitizeFilename(filename: string, config: Config['file']): string {
|
||||||
|
// 移除非法字符
|
||||||
|
let safe = filename.replace(config.sanitize.illegalChars, config.sanitize.replaceChar);
|
||||||
|
|
||||||
|
// 檢查保留字
|
||||||
|
const basename = path.parse(safe).name.toUpperCase();
|
||||||
|
if (config.sanitize.reservedNames.includes(basename)) {
|
||||||
|
safe = `_${safe}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 處理長度限制
|
||||||
|
if (safe.length > config.maxFilenameLength) {
|
||||||
|
const ext = path.extname(safe);
|
||||||
|
const name = safe.slice(0, config.maxFilenameLength - ext.length - config.sanitize.truncateSuffix.length);
|
||||||
|
safe = `${name}${config.sanitize.truncateSuffix}${ext}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return safe;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 導出當前配置實例
|
||||||
|
export const CONFIG = loadConfig();
|
||||||
423
src/index.mts
423
src/index.mts
@@ -4,27 +4,20 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|||||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||||
import {
|
import {
|
||||||
CallToolRequestSchema,
|
CallToolRequestSchema,
|
||||||
ListToolsRequestSchema,
|
ListToolsRequestSchema
|
||||||
} from "@modelcontextprotocol/sdk/types.js";
|
} from "@modelcontextprotocol/sdk/types.js";
|
||||||
import type { CallToolRequest } from "@modelcontextprotocol/sdk/types.js";
|
import type { CallToolRequest } from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
|
||||||
import * as os from "os";
|
import * as os from "os";
|
||||||
import * as fs from "fs";
|
import * as fs from "fs";
|
||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
import { spawnPromise } from "spawn-rx";
|
import { CONFIG } from "./config.js";
|
||||||
import { rimraf } from "rimraf";
|
import { _spawnPromise, safeCleanup } from "./modules/utils.js";
|
||||||
|
import { downloadVideo } from "./modules/video.js";
|
||||||
|
import { downloadAudio } from "./modules/audio.js";
|
||||||
|
import { listSubtitles, downloadSubtitles } from "./modules/subtitle.js";
|
||||||
|
|
||||||
const VERSION = '0.6.23';
|
const VERSION = '0.6.24';
|
||||||
|
|
||||||
/**
|
|
||||||
* System Configuration
|
|
||||||
*/
|
|
||||||
const CONFIG = {
|
|
||||||
MAX_FILENAME_LENGTH: 50,
|
|
||||||
DOWNLOADS_DIR: path.join(os.homedir(), "Downloads"),
|
|
||||||
TEMP_DIR_PREFIX: "ytdlp-",
|
|
||||||
REQUIRED_TOOLS: ['yt-dlp'] as const
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate system configuration
|
* Validate system configuration
|
||||||
@@ -32,22 +25,22 @@ const CONFIG = {
|
|||||||
*/
|
*/
|
||||||
async function validateConfig(): Promise<void> {
|
async function validateConfig(): Promise<void> {
|
||||||
// Check downloads directory
|
// Check downloads directory
|
||||||
if (!fs.existsSync(CONFIG.DOWNLOADS_DIR)) {
|
if (!fs.existsSync(CONFIG.file.downloadsDir)) {
|
||||||
throw new Error(`Downloads directory does not exist: ${CONFIG.DOWNLOADS_DIR}`);
|
throw new Error(`Downloads directory does not exist: ${CONFIG.file.downloadsDir}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check downloads directory permissions
|
// Check downloads directory permissions
|
||||||
try {
|
try {
|
||||||
const testFile = path.join(CONFIG.DOWNLOADS_DIR, '.write-test');
|
const testFile = path.join(CONFIG.file.downloadsDir, '.write-test');
|
||||||
fs.writeFileSync(testFile, '');
|
fs.writeFileSync(testFile, '');
|
||||||
fs.unlinkSync(testFile);
|
fs.unlinkSync(testFile);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(`No write permission in downloads directory: ${CONFIG.DOWNLOADS_DIR}`);
|
throw new Error(`No write permission in downloads directory: ${CONFIG.file.downloadsDir}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check temporary directory permissions
|
// Check temporary directory permissions
|
||||||
try {
|
try {
|
||||||
const testDir = fs.mkdtempSync(path.join(os.tmpdir(), CONFIG.TEMP_DIR_PREFIX));
|
const testDir = fs.mkdtempSync(path.join(os.tmpdir(), CONFIG.file.tempDirPrefix));
|
||||||
await safeCleanup(testDir);
|
await safeCleanup(testDir);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(`Cannot create temporary directory in: ${os.tmpdir()}`);
|
throw new Error(`Cannot create temporary directory in: ${os.tmpdir()}`);
|
||||||
@@ -59,9 +52,9 @@ async function validateConfig(): Promise<void> {
|
|||||||
* @throws {Error} when dependencies are not satisfied
|
* @throws {Error} when dependencies are not satisfied
|
||||||
*/
|
*/
|
||||||
async function checkDependencies(): Promise<void> {
|
async function checkDependencies(): Promise<void> {
|
||||||
for (const tool of CONFIG.REQUIRED_TOOLS) {
|
for (const tool of CONFIG.tools.required) {
|
||||||
try {
|
try {
|
||||||
await spawnPromise(tool, ["--version"]);
|
await _spawnPromise(tool, ["--version"]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(`Required tool '${tool}' is not installed or not accessible`);
|
throw new Error(`Required tool '${tool}' is not installed or not accessible`);
|
||||||
}
|
}
|
||||||
@@ -72,6 +65,11 @@ async function checkDependencies(): Promise<void> {
|
|||||||
* Initialize service
|
* Initialize service
|
||||||
*/
|
*/
|
||||||
async function initialize(): Promise<void> {
|
async function initialize(): Promise<void> {
|
||||||
|
// 在測試環境中跳過初始化檢查
|
||||||
|
if (process.env.NODE_ENV === 'test') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await validateConfig();
|
await validateConfig();
|
||||||
await checkDependencies();
|
await checkDependencies();
|
||||||
@@ -88,7 +86,7 @@ const server = new Server(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
capabilities: {
|
capabilities: {
|
||||||
tools: {},
|
tools: {}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -154,378 +152,6 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom error types
|
|
||||||
*/
|
|
||||||
class VideoDownloadError extends Error {
|
|
||||||
constructor(
|
|
||||||
message: string,
|
|
||||||
public readonly code: string = 'UNKNOWN_ERROR',
|
|
||||||
public readonly cause?: Error
|
|
||||||
) {
|
|
||||||
super(message);
|
|
||||||
this.name = 'VideoDownloadError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Error code mappings
|
|
||||||
*/
|
|
||||||
const ERROR_CODES = {
|
|
||||||
UNSUPPORTED_URL: 'Unsupported or invalid URL',
|
|
||||||
VIDEO_UNAVAILABLE: 'Video is not available or has been removed',
|
|
||||||
NETWORK_ERROR: 'Network connection error',
|
|
||||||
FORMAT_ERROR: 'Requested format is not available',
|
|
||||||
PERMISSION_ERROR: 'Permission denied when accessing download directory',
|
|
||||||
SUBTITLE_ERROR: 'Failed to process subtitles',
|
|
||||||
SUBTITLE_NOT_AVAILABLE: 'Subtitles are not available',
|
|
||||||
INVALID_LANGUAGE: 'Invalid language code provided',
|
|
||||||
UNKNOWN_ERROR: 'An unknown error occurred'
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Safely clean up temporary directory
|
|
||||||
* @param directory Directory path to clean up
|
|
||||||
*/
|
|
||||||
async function safeCleanup(directory: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
rimraf.sync(directory);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to cleanup directory ${directory}:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate URL format
|
|
||||||
* @param url URL to validate
|
|
||||||
* @throws {VideoDownloadError} when URL is invalid
|
|
||||||
*/
|
|
||||||
function validateUrl(url: string, ErrorClass = VideoDownloadError): void {
|
|
||||||
try {
|
|
||||||
new URL(url);
|
|
||||||
} catch {
|
|
||||||
throw new ErrorClass(
|
|
||||||
ERROR_CODES.UNSUPPORTED_URL,
|
|
||||||
'UNSUPPORTED_URL'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate formatted timestamp
|
|
||||||
* @returns Formatted timestamp string
|
|
||||||
*/
|
|
||||||
function getFormattedTimestamp(): string {
|
|
||||||
return new Date().toISOString()
|
|
||||||
.replace(/[:.]/g, '-')
|
|
||||||
.replace('T', '_')
|
|
||||||
.split('.')[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if URL is a YouTube URL
|
|
||||||
* @param url URL to check
|
|
||||||
*/
|
|
||||||
function isYouTubeUrl(url: string): boolean {
|
|
||||||
try {
|
|
||||||
const urlObj = new URL(url);
|
|
||||||
return ['youtube.com', 'youtu.be', 'm.youtube.com']
|
|
||||||
.some(domain => urlObj.hostname.endsWith(domain));
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lists all available subtitles for a video
|
|
||||||
*/
|
|
||||||
async function listSubtitles(url: string): Promise<string> {
|
|
||||||
const tempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "ytdlp-"));
|
|
||||||
|
|
||||||
try {
|
|
||||||
validateUrl(url);
|
|
||||||
|
|
||||||
// 同時列出一般字幕和自動生成的字幕
|
|
||||||
const result = await spawnPromise(
|
|
||||||
"yt-dlp",
|
|
||||||
[
|
|
||||||
"--list-subs", // 列出一般字幕
|
|
||||||
"--write-auto-sub", // 包含自動生成的字幕
|
|
||||||
"--skip-download",
|
|
||||||
"--verbose", // 添加詳細輸出
|
|
||||||
url
|
|
||||||
],
|
|
||||||
{ cwd: tempDirectory }
|
|
||||||
);
|
|
||||||
|
|
||||||
// 如果沒有一般字幕,添加說明
|
|
||||||
if (result.includes("has no subtitles")) {
|
|
||||||
return "Regular subtitles: None\n\nAuto-generated subtitles: Available in multiple languages";
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
await safeCleanup(tempDirectory);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Downloads video subtitles in specified language
|
|
||||||
*/
|
|
||||||
async function downloadSubtitles(url: string, language: string = "en"): Promise<string> {
|
|
||||||
const tempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "ytdlp-"));
|
|
||||||
|
|
||||||
try {
|
|
||||||
validateUrl(url);
|
|
||||||
|
|
||||||
if (!/^[a-z]{2,3}(-[A-Z][a-z]{3})?(-[A-Z]{2})?$/i.test(language)) {
|
|
||||||
throw new Error('Invalid language code');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await spawnPromise(
|
|
||||||
"yt-dlp",
|
|
||||||
[
|
|
||||||
"--write-sub", // 嘗試下載一般字幕
|
|
||||||
"--write-auto-sub", // 同時支援自動生成字幕
|
|
||||||
"--sub-lang", language,
|
|
||||||
"--convert-subs", "srt",
|
|
||||||
"--skip-download",
|
|
||||||
"--verbose", // 添加詳細輸出
|
|
||||||
url
|
|
||||||
],
|
|
||||||
{ cwd: tempDirectory }
|
|
||||||
);
|
|
||||||
console.log("yt-dlp output:", result);
|
|
||||||
} catch (error) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 讀取下載的字幕文件
|
|
||||||
const files = fs.readdirSync(tempDirectory);
|
|
||||||
console.log("Files in directory:", files);
|
|
||||||
|
|
||||||
// 過濾出字幕文件
|
|
||||||
const subtitleFiles = files.filter(file =>
|
|
||||||
file.endsWith('.srt') || file.endsWith('.vtt')
|
|
||||||
);
|
|
||||||
|
|
||||||
if (subtitleFiles.length === 0) {
|
|
||||||
throw new Error(`No subtitle files found. Available files: ${files.join(', ')}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 讀取並組合字幕內容
|
|
||||||
let subtitlesContent = "";
|
|
||||||
for (const file of subtitleFiles) {
|
|
||||||
const filePath = path.join(tempDirectory, file);
|
|
||||||
try {
|
|
||||||
const fileData = fs.readFileSync(filePath, "utf8");
|
|
||||||
subtitlesContent += `${file}\n====================\n${fileData}\n\n`;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to read subtitle file ${file}:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!subtitlesContent) {
|
|
||||||
throw new Error("Failed to read subtitle content");
|
|
||||||
}
|
|
||||||
|
|
||||||
return subtitlesContent;
|
|
||||||
} finally {
|
|
||||||
await safeCleanup(tempDirectory);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Downloads a video with specified resolution
|
|
||||||
* @param url The URL of the video
|
|
||||||
* @param resolution The desired video resolution
|
|
||||||
* @returns A detailed success message including the filename
|
|
||||||
*/
|
|
||||||
export async function downloadVideo(url: string, resolution = "720p"): Promise<string> {
|
|
||||||
const userDownloadsDir = CONFIG.DOWNLOADS_DIR;
|
|
||||||
|
|
||||||
try {
|
|
||||||
validateUrl(url, VideoDownloadError);
|
|
||||||
|
|
||||||
const timestamp = getFormattedTimestamp();
|
|
||||||
|
|
||||||
let format: string;
|
|
||||||
if (isYouTubeUrl(url)) {
|
|
||||||
// YouTube-specific format selection
|
|
||||||
switch (resolution) {
|
|
||||||
case "480p":
|
|
||||||
format = "bestvideo[height<=480]+bestaudio/best[height<=480]/best";
|
|
||||||
break;
|
|
||||||
case "720p":
|
|
||||||
format = "bestvideo[height<=720]+bestaudio/best[height<=720]/best";
|
|
||||||
break;
|
|
||||||
case "1080p":
|
|
||||||
format = "bestvideo[height<=1080]+bestaudio/best[height<=1080]/best";
|
|
||||||
break;
|
|
||||||
case "best":
|
|
||||||
format = "bestvideo+bestaudio/best";
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
format = "bestvideo[height<=720]+bestaudio/best[height<=720]/best";
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// For other platforms, use quality labels that are more generic
|
|
||||||
switch (resolution) {
|
|
||||||
case "480p":
|
|
||||||
format = "worst[height>=480]/best[height<=480]/worst";
|
|
||||||
break;
|
|
||||||
case "best":
|
|
||||||
format = "bestvideo+bestaudio/best";
|
|
||||||
break;
|
|
||||||
default: // Including 720p and 1080p cases
|
|
||||||
// Prefer HD quality but fallback to best available
|
|
||||||
format = "bestvideo[height>=720]+bestaudio/best[height>=720]/best";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const outputTemplate = path.join(
|
|
||||||
userDownloadsDir,
|
|
||||||
`%(title).${CONFIG.MAX_FILENAME_LENGTH}s [%(id)s] ${timestamp}.%(ext)s`
|
|
||||||
);
|
|
||||||
|
|
||||||
// Get expected filename
|
|
||||||
let expectedFilename: string;
|
|
||||||
try {
|
|
||||||
expectedFilename = await spawnPromise("yt-dlp", [
|
|
||||||
"--get-filename",
|
|
||||||
"-f", format,
|
|
||||||
"--output", outputTemplate,
|
|
||||||
url
|
|
||||||
]);
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
||||||
if (errorMessage.includes('Unsupported URL')) {
|
|
||||||
throw new VideoDownloadError(
|
|
||||||
ERROR_CODES.UNSUPPORTED_URL,
|
|
||||||
'UNSUPPORTED_URL',
|
|
||||||
error as Error
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (errorMessage.includes('not available')) {
|
|
||||||
throw new VideoDownloadError(
|
|
||||||
ERROR_CODES.VIDEO_UNAVAILABLE,
|
|
||||||
'VIDEO_UNAVAILABLE',
|
|
||||||
error as Error
|
|
||||||
);
|
|
||||||
}
|
|
||||||
throw new VideoDownloadError(
|
|
||||||
ERROR_CODES.UNKNOWN_ERROR,
|
|
||||||
'UNKNOWN_ERROR',
|
|
||||||
error as Error
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
expectedFilename = expectedFilename.trim();
|
|
||||||
|
|
||||||
// Download with progress info
|
|
||||||
try {
|
|
||||||
await spawnPromise("yt-dlp", [
|
|
||||||
"--progress",
|
|
||||||
"--newline",
|
|
||||||
"--no-mtime",
|
|
||||||
"-f", format,
|
|
||||||
"--output", outputTemplate,
|
|
||||||
url
|
|
||||||
]);
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
||||||
if (errorMessage.includes('Permission denied')) {
|
|
||||||
throw new VideoDownloadError(
|
|
||||||
ERROR_CODES.PERMISSION_ERROR,
|
|
||||||
'PERMISSION_ERROR',
|
|
||||||
error as Error
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (errorMessage.includes('format not available')) {
|
|
||||||
throw new VideoDownloadError(
|
|
||||||
ERROR_CODES.FORMAT_ERROR,
|
|
||||||
'FORMAT_ERROR',
|
|
||||||
error as Error
|
|
||||||
);
|
|
||||||
}
|
|
||||||
throw new VideoDownloadError(
|
|
||||||
ERROR_CODES.UNKNOWN_ERROR,
|
|
||||||
'UNKNOWN_ERROR',
|
|
||||||
error as Error
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return `Video successfully downloaded as "${path.basename(expectedFilename)}" to ${userDownloadsDir}`;
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof VideoDownloadError) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
throw new VideoDownloadError(
|
|
||||||
ERROR_CODES.UNKNOWN_ERROR,
|
|
||||||
'UNKNOWN_ERROR',
|
|
||||||
error as Error
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Downloads audio from video in m4a format
|
|
||||||
* @param url The URL of the video
|
|
||||||
* @returns A detailed success message including the filename
|
|
||||||
*/
|
|
||||||
async function downloadAudio(url: string): Promise<string> {
|
|
||||||
const userDownloadsDir = CONFIG.DOWNLOADS_DIR;
|
|
||||||
|
|
||||||
try {
|
|
||||||
validateUrl(url);
|
|
||||||
const timestamp = getFormattedTimestamp();
|
|
||||||
|
|
||||||
const outputTemplate = path.join(
|
|
||||||
userDownloadsDir,
|
|
||||||
`%(title).${CONFIG.MAX_FILENAME_LENGTH}s [%(id)s] ${timestamp}.%(ext)s`
|
|
||||||
);
|
|
||||||
|
|
||||||
let format: string;
|
|
||||||
if (isYouTubeUrl(url)) {
|
|
||||||
format = "140/bestaudio[ext=m4a]/bestaudio"; // 優先選擇 m4a
|
|
||||||
} else {
|
|
||||||
format = "bestaudio[ext=m4a]/bestaudio[ext=mp3]/bestaudio"; // 優先選擇 m4a/mp3
|
|
||||||
}
|
|
||||||
|
|
||||||
// Download audio with verbose output
|
|
||||||
try {
|
|
||||||
await spawnPromise("yt-dlp", [
|
|
||||||
"--verbose", // 添加詳細輸出
|
|
||||||
"--progress",
|
|
||||||
"--newline",
|
|
||||||
"--no-mtime",
|
|
||||||
"-f", format,
|
|
||||||
"--output", outputTemplate,
|
|
||||||
url
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 如果下載成功,返回成功消息
|
|
||||||
const files = fs.readdirSync(userDownloadsDir);
|
|
||||||
const downloadedFile = files.find(file => file.includes(timestamp));
|
|
||||||
if (!downloadedFile) {
|
|
||||||
throw new Error("Download completed but file not found");
|
|
||||||
}
|
|
||||||
return `Audio successfully downloaded as "${downloadedFile}" to ${userDownloadsDir}`;
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
// 直接拋出原始錯誤信息
|
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
||||||
throw new Error(`yt-dlp error: ${errorMessage}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// 不再包裝錯誤,直接拋出
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle tool execution with unified error handling
|
* Handle tool execution with unified error handling
|
||||||
* @param action Async operation to execute
|
* @param action Async operation to execute
|
||||||
@@ -572,17 +198,17 @@ server.setRequestHandler(
|
|||||||
);
|
);
|
||||||
} else if (toolName === "download_video_subtitles") {
|
} else if (toolName === "download_video_subtitles") {
|
||||||
return handleToolExecution(
|
return handleToolExecution(
|
||||||
() => downloadSubtitles(args.url, args.language),
|
() => downloadSubtitles(args.url, args.language || CONFIG.download.defaultSubtitleLanguage, CONFIG),
|
||||||
"Error downloading subtitles"
|
"Error downloading subtitles"
|
||||||
);
|
);
|
||||||
} else if (toolName === "download_video") {
|
} else if (toolName === "download_video") {
|
||||||
return handleToolExecution(
|
return handleToolExecution(
|
||||||
() => downloadVideo(args.url, args.resolution),
|
() => downloadVideo(args.url, CONFIG, args.resolution as "480p" | "720p" | "1080p" | "best"),
|
||||||
"Error downloading video"
|
"Error downloading video"
|
||||||
);
|
);
|
||||||
} else if (toolName === "download_audio") {
|
} else if (toolName === "download_audio") {
|
||||||
return handleToolExecution(
|
return handleToolExecution(
|
||||||
() => downloadAudio(args.url),
|
() => downloadAudio(args.url, CONFIG),
|
||||||
"Error downloading audio"
|
"Error downloading audio"
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@@ -605,6 +231,3 @@ async function startServer() {
|
|||||||
|
|
||||||
// Start the server and handle potential errors
|
// Start the server and handle potential errors
|
||||||
startServer().catch(console.error);
|
startServer().catch(console.error);
|
||||||
|
|
||||||
// 導出錯誤類型供測試使用
|
|
||||||
export { VideoDownloadError, ERROR_CODES };
|
|
||||||
|
|||||||
65
src/modules/audio.ts
Normal file
65
src/modules/audio.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { readdirSync } from "fs";
|
||||||
|
import * as path from "path";
|
||||||
|
import type { Config } from "../config.js";
|
||||||
|
import { sanitizeFilename } from "../config.js";
|
||||||
|
import { _spawnPromise, validateUrl, getFormattedTimestamp, isYouTubeUrl } from "./utils.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Downloads audio from a video URL in the best available quality.
|
||||||
|
*
|
||||||
|
* @param url - The URL of the video to extract audio from
|
||||||
|
* @param config - Configuration object for download settings
|
||||||
|
* @returns Promise resolving to a success message with the downloaded file path
|
||||||
|
* @throws {Error} When URL is invalid or download fails
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* // Download audio with default settings
|
||||||
|
* const result = await downloadAudio('https://youtube.com/watch?v=...');
|
||||||
|
* console.log(result);
|
||||||
|
*
|
||||||
|
* // Download audio with custom config
|
||||||
|
* const customResult = await downloadAudio('https://youtube.com/watch?v=...', {
|
||||||
|
* file: {
|
||||||
|
* downloadsDir: '/custom/path',
|
||||||
|
* // ... other config options
|
||||||
|
* }
|
||||||
|
* });
|
||||||
|
* console.log(customResult);
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export async function downloadAudio(url: string, config: Config): Promise<string> {
|
||||||
|
const timestamp = getFormattedTimestamp();
|
||||||
|
|
||||||
|
try {
|
||||||
|
validateUrl(url);
|
||||||
|
|
||||||
|
const outputTemplate = path.join(
|
||||||
|
config.file.downloadsDir,
|
||||||
|
sanitizeFilename(`%(title)s [%(id)s] ${timestamp}`, config.file) + '.%(ext)s'
|
||||||
|
);
|
||||||
|
|
||||||
|
const format = isYouTubeUrl(url)
|
||||||
|
? "140/bestaudio[ext=m4a]/bestaudio"
|
||||||
|
: "bestaudio[ext=m4a]/bestaudio[ext=mp3]/bestaudio";
|
||||||
|
|
||||||
|
await _spawnPromise("yt-dlp", [
|
||||||
|
"--verbose",
|
||||||
|
"--progress",
|
||||||
|
"--newline",
|
||||||
|
"--no-mtime",
|
||||||
|
"-f", format,
|
||||||
|
"--output", outputTemplate,
|
||||||
|
url
|
||||||
|
]);
|
||||||
|
|
||||||
|
const files = readdirSync(config.file.downloadsDir);
|
||||||
|
const downloadedFile = files.find(file => file.includes(timestamp));
|
||||||
|
if (!downloadedFile) {
|
||||||
|
throw new Error("Download completed but file not found");
|
||||||
|
}
|
||||||
|
return `Audio successfully downloaded as "${downloadedFile}" to ${config.file.downloadsDir}`;
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
108
src/modules/subtitle.ts
Normal file
108
src/modules/subtitle.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import * as fs from "fs";
|
||||||
|
import * as path from "path";
|
||||||
|
import * as os from "os";
|
||||||
|
import type { Config } from '../config.js';
|
||||||
|
import { _spawnPromise, validateUrl } from "./utils.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists all available subtitles for a video.
|
||||||
|
*
|
||||||
|
* @param url - The URL of the video
|
||||||
|
* @returns Promise resolving to a string containing the list of available subtitles
|
||||||
|
* @throws {Error} When URL is invalid or subtitle listing fails
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* try {
|
||||||
|
* const subtitles = await listSubtitles('https://youtube.com/watch?v=...');
|
||||||
|
* console.log('Available subtitles:', subtitles);
|
||||||
|
* } catch (error) {
|
||||||
|
* console.error('Failed to list subtitles:', error);
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export async function listSubtitles(url: string): Promise<string> {
|
||||||
|
if (!validateUrl(url)) {
|
||||||
|
throw new Error('Invalid or unsupported URL format');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const output = await _spawnPromise('yt-dlp', [
|
||||||
|
'--list-subs',
|
||||||
|
'--write-auto-sub',
|
||||||
|
'--skip-download',
|
||||||
|
'--verbose',
|
||||||
|
url
|
||||||
|
]);
|
||||||
|
return output;
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Downloads subtitles for a video in the specified language.
|
||||||
|
*
|
||||||
|
* @param url - The URL of the video
|
||||||
|
* @param language - Language code (e.g., 'en', 'zh-Hant', 'ja')
|
||||||
|
* @param config - Configuration object
|
||||||
|
* @returns Promise resolving to the subtitle content
|
||||||
|
* @throws {Error} When URL is invalid, language is not available, or download fails
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* try {
|
||||||
|
* // Download English subtitles
|
||||||
|
* const enSubs = await downloadSubtitles('https://youtube.com/watch?v=...', 'en', config);
|
||||||
|
* console.log('English subtitles:', enSubs);
|
||||||
|
*
|
||||||
|
* // Download Traditional Chinese subtitles
|
||||||
|
* const zhSubs = await downloadSubtitles('https://youtube.com/watch?v=...', 'zh-Hant', config);
|
||||||
|
* console.log('Chinese subtitles:', zhSubs);
|
||||||
|
* } catch (error) {
|
||||||
|
* if (error.message.includes('No subtitle files found')) {
|
||||||
|
* console.warn('No subtitles available in the requested language');
|
||||||
|
* } else {
|
||||||
|
* console.error('Failed to download subtitles:', error);
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export async function downloadSubtitles(
|
||||||
|
url: string,
|
||||||
|
language: string,
|
||||||
|
config: Config
|
||||||
|
): Promise<string> {
|
||||||
|
if (!validateUrl(url)) {
|
||||||
|
throw new Error('Invalid or unsupported URL format');
|
||||||
|
}
|
||||||
|
|
||||||
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), config.file.tempDirPrefix));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _spawnPromise('yt-dlp', [
|
||||||
|
'--write-sub',
|
||||||
|
'--write-auto-sub',
|
||||||
|
'--sub-lang', language,
|
||||||
|
'--skip-download',
|
||||||
|
'--output', path.join(tempDir, '%(title)s.%(ext)s'),
|
||||||
|
url
|
||||||
|
]);
|
||||||
|
|
||||||
|
const subtitleFiles = fs.readdirSync(tempDir)
|
||||||
|
.filter(file => file.endsWith('.vtt'));
|
||||||
|
|
||||||
|
if (subtitleFiles.length === 0) {
|
||||||
|
throw new Error('No subtitle files found');
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = '';
|
||||||
|
for (const file of subtitleFiles) {
|
||||||
|
output += fs.readFileSync(path.join(tempDir, file), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
return output;
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
129
src/modules/utils.ts
Normal file
129
src/modules/utils.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import * as fs from 'fs';
|
||||||
|
import { spawn } from 'child_process';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates if a given string is a valid URL.
|
||||||
|
*
|
||||||
|
* @param url - The URL string to validate
|
||||||
|
* @returns True if the URL is valid, false otherwise
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* if (validateUrl('https://youtube.com/watch?v=...')) {
|
||||||
|
* // URL is valid
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function validateUrl(url: string): boolean {
|
||||||
|
try {
|
||||||
|
new URL(url);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a URL is from YouTube.
|
||||||
|
*
|
||||||
|
* @param url - The URL to check
|
||||||
|
* @returns True if the URL is from YouTube, false otherwise
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* if (isYouTubeUrl('https://youtube.com/watch?v=...')) {
|
||||||
|
* // URL is from YouTube
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function isYouTubeUrl(url: string): boolean {
|
||||||
|
try {
|
||||||
|
const parsedUrl = new URL(url);
|
||||||
|
return parsedUrl.hostname.includes('youtube.com') || parsedUrl.hostname.includes('youtu.be');
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely cleans up a directory and its contents.
|
||||||
|
*
|
||||||
|
* @param directory - Path to the directory to clean up
|
||||||
|
* @returns Promise that resolves when cleanup is complete
|
||||||
|
* @throws {Error} When directory cannot be removed
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* try {
|
||||||
|
* await safeCleanup('/path/to/temp/dir');
|
||||||
|
* } catch (error) {
|
||||||
|
* console.error('Cleanup failed:', error);
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export async function safeCleanup(directory: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await fs.promises.rm(directory, { recursive: true, force: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error cleaning up directory ${directory}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spawns a child process and returns its output as a promise.
|
||||||
|
*
|
||||||
|
* @param command - The command to execute
|
||||||
|
* @param args - Array of command arguments
|
||||||
|
* @returns Promise resolving to the command output
|
||||||
|
* @throws {Error} When command execution fails
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* try {
|
||||||
|
* const output = await _spawnPromise('yt-dlp', ['--version']);
|
||||||
|
* console.log('yt-dlp version:', output);
|
||||||
|
* } catch (error) {
|
||||||
|
* console.error('Command failed:', error);
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function _spawnPromise(command: string, args: string[]): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const process = spawn(command, args);
|
||||||
|
let output = '';
|
||||||
|
|
||||||
|
process.stdout.on('data', (data) => {
|
||||||
|
output += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
process.stderr.on('data', (data) => {
|
||||||
|
output += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('close', (code) => {
|
||||||
|
if (code === 0) {
|
||||||
|
resolve(output);
|
||||||
|
} else {
|
||||||
|
reject(new Error(`Failed with exit code: ${code}\n${output}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a formatted timestamp string for file naming.
|
||||||
|
*
|
||||||
|
* @returns Formatted timestamp string in the format 'YYYY-MM-DD_HH-mm-ss'
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const timestamp = getFormattedTimestamp();
|
||||||
|
* console.log(timestamp); // '2024-03-20_12-30-00'
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function getFormattedTimestamp(): string {
|
||||||
|
return new Date().toISOString()
|
||||||
|
.replace(/[:.]/g, '-')
|
||||||
|
.replace('T', '_')
|
||||||
|
.split('.')[0];
|
||||||
|
}
|
||||||
119
src/modules/video.ts
Normal file
119
src/modules/video.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import * as path from "path";
|
||||||
|
import type { Config } from "../config.js";
|
||||||
|
import { sanitizeFilename } from "../config.js";
|
||||||
|
import {
|
||||||
|
_spawnPromise,
|
||||||
|
validateUrl,
|
||||||
|
getFormattedTimestamp,
|
||||||
|
isYouTubeUrl
|
||||||
|
} from "./utils.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Downloads a video from the specified URL.
|
||||||
|
*
|
||||||
|
* @param url - The URL of the video to download
|
||||||
|
* @param config - Configuration object for download settings
|
||||||
|
* @param resolution - Preferred video resolution ('480p', '720p', '1080p', 'best')
|
||||||
|
* @returns Promise resolving to a success message with the downloaded file path
|
||||||
|
* @throws {Error} When URL is invalid or download fails
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* // Download with default settings
|
||||||
|
* const result = await downloadVideo('https://youtube.com/watch?v=...');
|
||||||
|
* console.log(result);
|
||||||
|
*
|
||||||
|
* // Download with specific resolution
|
||||||
|
* const hdResult = await downloadVideo(
|
||||||
|
* 'https://youtube.com/watch?v=...',
|
||||||
|
* undefined,
|
||||||
|
* '1080p'
|
||||||
|
* );
|
||||||
|
* console.log(hdResult);
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export async function downloadVideo(
|
||||||
|
url: string,
|
||||||
|
config: Config,
|
||||||
|
resolution: "480p" | "720p" | "1080p" | "best" = "720p"
|
||||||
|
): Promise<string> {
|
||||||
|
const userDownloadsDir = config.file.downloadsDir;
|
||||||
|
|
||||||
|
try {
|
||||||
|
validateUrl(url);
|
||||||
|
const timestamp = getFormattedTimestamp();
|
||||||
|
|
||||||
|
let format: string;
|
||||||
|
if (isYouTubeUrl(url)) {
|
||||||
|
// YouTube-specific format selection
|
||||||
|
switch (resolution) {
|
||||||
|
case "480p":
|
||||||
|
format = "bestvideo[height<=480]+bestaudio/best[height<=480]/best";
|
||||||
|
break;
|
||||||
|
case "720p":
|
||||||
|
format = "bestvideo[height<=720]+bestaudio/best[height<=720]/best";
|
||||||
|
break;
|
||||||
|
case "1080p":
|
||||||
|
format = "bestvideo[height<=1080]+bestaudio/best[height<=1080]/best";
|
||||||
|
break;
|
||||||
|
case "best":
|
||||||
|
format = "bestvideo+bestaudio/best";
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
format = "bestvideo[height<=720]+bestaudio/best[height<=720]/best";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For other platforms, use quality labels that are more generic
|
||||||
|
switch (resolution) {
|
||||||
|
case "480p":
|
||||||
|
format = "worst[height>=480]/best[height<=480]/worst";
|
||||||
|
break;
|
||||||
|
case "best":
|
||||||
|
format = "bestvideo+bestaudio/best";
|
||||||
|
break;
|
||||||
|
default: // Including 720p and 1080p cases
|
||||||
|
// Prefer HD quality but fallback to best available
|
||||||
|
format = "bestvideo[height>=720]+bestaudio/best[height>=720]/best";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用安全的文件名模板
|
||||||
|
const outputTemplate = path.join(
|
||||||
|
userDownloadsDir,
|
||||||
|
sanitizeFilename(`%(title)s [%(id)s] ${timestamp}`, config.file) + '.%(ext)s'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get expected filename
|
||||||
|
let expectedFilename: string;
|
||||||
|
try {
|
||||||
|
expectedFilename = await _spawnPromise("yt-dlp", [
|
||||||
|
"--get-filename",
|
||||||
|
"-f", format,
|
||||||
|
"--output", outputTemplate,
|
||||||
|
url
|
||||||
|
]);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Failed to get filename: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedFilename = expectedFilename.trim();
|
||||||
|
|
||||||
|
// Download with progress info
|
||||||
|
try {
|
||||||
|
await _spawnPromise("yt-dlp", [
|
||||||
|
"--progress",
|
||||||
|
"--newline",
|
||||||
|
"--no-mtime",
|
||||||
|
"-f", format,
|
||||||
|
"--output", outputTemplate,
|
||||||
|
url
|
||||||
|
]);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Download failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Video successfully downloaded as "${path.basename(expectedFilename)}" to ${userDownloadsDir}`;
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
23
src/utils/test-utils.ts
Normal file
23
src/utils/test-utils.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import type { Config } from '../config.js';
|
||||||
|
|
||||||
|
export const mockConfig: Config = {
|
||||||
|
file: {
|
||||||
|
maxFilenameLength: 100,
|
||||||
|
downloadsDir: '/mock/downloads',
|
||||||
|
tempDirPrefix: 'ytdlp-test-',
|
||||||
|
sanitize: {
|
||||||
|
replaceChar: '_',
|
||||||
|
truncateSuffix: '...',
|
||||||
|
illegalChars: /[<>:"/\\|?*\x00-\x1F]/g,
|
||||||
|
reservedNames: ['CON', 'PRN', 'AUX', 'NUL']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tools: {
|
||||||
|
required: ['yt-dlp']
|
||||||
|
},
|
||||||
|
download: {
|
||||||
|
defaultResolution: '720p',
|
||||||
|
defaultAudioFormat: 'm4a',
|
||||||
|
defaultSubtitleLanguage: 'en'
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user