diff --git a/.gitignore b/.gitignore index 06cfcc8..90f0d2a 100644 --- a/.gitignore +++ b/.gitignore @@ -28,7 +28,6 @@ node_modules lib test-dist -docs # WebStorm .idea/ diff --git a/README.md b/README.md index 1c302ad..19cec4a 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,16 @@ npx @kevinwatt/yt-dlp-mcp * `yt-dlp` in system PATH * 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 MIT diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..47a267a --- /dev/null +++ b/docs/api.md @@ -0,0 +1,125 @@ +# API Reference + +## Video Operations + +### downloadVideo(url: string, config?: Config, resolution?: string): Promise + +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 + +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 + +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 + +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). \ No newline at end of file diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..071aff2 --- /dev/null +++ b/docs/configuration.md @@ -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); +``` \ No newline at end of file diff --git a/docs/contributing.md b/docs/contributing.md new file mode 100644 index 0000000..67f15ff --- /dev/null +++ b/docs/contributing.md @@ -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 { + // 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 { + // 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). \ No newline at end of file diff --git a/docs/error-handling.md b/docs/error-handling.md new file mode 100644 index 0000000..a597a87 --- /dev/null +++ b/docs/error-handling.md @@ -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). \ No newline at end of file diff --git a/docs/examples.md b/docs/examples.md new file mode 100644 index 0000000..0519ecb --- /dev/null +++ b/docs/examples.md @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 21e239f..67b5590 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@kevinwatt/yt-dlp-mcp", - "version": "0.6.9", + "version": "0.6.23", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@kevinwatt/yt-dlp-mcp", - "version": "0.6.9", + "version": "0.6.23", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "0.7.0", diff --git a/package.json b/package.json index 5f9e74e..63531f0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "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.", "keywords": [ "mcp", @@ -27,7 +27,7 @@ "main": "./lib/index.mjs", "scripts": { "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 ", "license": "MIT", diff --git a/src/__tests__/audio.test.ts b/src/__tests__/audio.test.ts new file mode 100644 index 0000000..eefe937 --- /dev/null +++ b/src/__tests__/audio.test.ts @@ -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(); + }); +}); \ No newline at end of file diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts index 04052fd..665852d 100644 --- a/src/__tests__/index.test.ts +++ b/src/__tests__/index.test.ts @@ -1,79 +1,56 @@ // @ts-nocheck // @jest-environment node -import { jest } from '@jest/globals'; -import { describe, test, expect, beforeAll, afterAll, beforeEach } from '@jest/globals'; -import * as path from 'path'; +import { describe, test, expect } from '@jest/globals'; import * as os from 'os'; +import * as path from 'path'; +import { downloadVideo } from '../modules/video.js'; +import { CONFIG } from '../config.js'; +import * as fs from 'fs'; -// 簡化 mock -jest.mock('spawn-rx', () => ({ - spawnPromise: jest.fn().mockImplementation(async (cmd, args) => { - if (args.includes('--get-filename')) { - return 'mock_video.mp4'; - } - return 'Download completed'; - }) -})); -jest.mock('rimraf', () => ({ - rimraf: { sync: jest.fn() } -})); - -import { downloadVideo } from '../index.mts'; +// 設置 Python 環境 +process.env.PYTHONPATH = ''; +process.env.PYTHONHOME = ''; describe('downloadVideo', () => { - const mockTimestamp = '2024-03-20_12-30-00'; - let originalDateToISOString: () => string; + 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-' + } + }; - // 全局清理 - afterAll(done => { - // 清理所有計時器 - jest.useRealTimers(); - // 確保所有異步操作完成 - process.nextTick(done); + beforeEach(async () => { + await fs.promises.mkdir(testConfig.file.downloadsDir, { recursive: true }); }); - beforeAll(() => { - originalDateToISOString = Date.prototype.toISOString; - Date.prototype.toISOString = jest.fn(() => '2024-03-20T12:30:00.000Z'); - }); - - afterAll(() => { - Date.prototype.toISOString = originalDateToISOString; - }); - - beforeEach(() => { - jest.clearAllMocks(); + afterEach(async () => { + await fs.promises.rm(testConfig.file.downloadsDir, { recursive: true, force: true }); }); 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'); - // 驗證基本功能 - expect(result).toMatch(/Video successfully downloaded as/); - expect(result).toContain(mockTimestamp); - expect(result).toContain(os.homedir()); - 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); - }); + const files = await fs.promises.readdir(testConfig.file.downloadsDir); + expect(files.length).toBeGreaterThan(0); + expect(files[0]).toMatch(/\.(mp4|webm|mkv)$/); + }, 30000); 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 results = await Promise.all(resolutions.map(resolution => downloadVideo( - 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', - resolution - ))); - - results.forEach(result => { - expect(result).toMatch(/Video successfully downloaded as/); - }); + const files = await fs.promises.readdir(testConfig.file.downloadsDir); + expect(files.length).toBeGreaterThan(0); + expect(files[0]).toMatch(/\.(mp4|webm|mkv)$/); + }, 30000); + + test('handles invalid URL', async () => { + await expect(downloadVideo('invalid-url', testConfig)) + .rejects + .toThrow(); }); }); \ No newline at end of file diff --git a/src/__tests__/subtitle.test.ts b/src/__tests__/subtitle.test.ts new file mode 100644 index 0000000..19e360b --- /dev/null +++ b/src/__tests__/subtitle.test.ts @@ -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(); + }); + }); +}); \ No newline at end of file diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..7a22deb --- /dev/null +++ b/src/config.ts @@ -0,0 +1,216 @@ +import * as os from "os"; +import * as path from "path"; + +type DeepPartial = { + [P in keyof T]?: T[P] extends object ? DeepPartial : 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 { + const envConfig: DeepPartial = {}; + + // 文件配置 + const fileConfig: DeepPartial = { + 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 = {}; + 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 { + 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(); \ No newline at end of file diff --git a/src/index.mts b/src/index.mts index a16945f..5b25605 100644 --- a/src/index.mts +++ b/src/index.mts @@ -4,27 +4,20 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, - ListToolsRequestSchema, + ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; import type { CallToolRequest } from "@modelcontextprotocol/sdk/types.js"; import * as os from "os"; import * as fs from "fs"; import * as path from "path"; -import { spawnPromise } from "spawn-rx"; -import { rimraf } from "rimraf"; +import { CONFIG } from "./config.js"; +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'; - -/** - * 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; +const VERSION = '0.6.24'; /** * Validate system configuration @@ -32,22 +25,22 @@ const CONFIG = { */ async function validateConfig(): Promise { // Check downloads directory - if (!fs.existsSync(CONFIG.DOWNLOADS_DIR)) { - throw new Error(`Downloads directory does not exist: ${CONFIG.DOWNLOADS_DIR}`); + if (!fs.existsSync(CONFIG.file.downloadsDir)) { + throw new Error(`Downloads directory does not exist: ${CONFIG.file.downloadsDir}`); } // Check downloads directory permissions try { - const testFile = path.join(CONFIG.DOWNLOADS_DIR, '.write-test'); + const testFile = path.join(CONFIG.file.downloadsDir, '.write-test'); fs.writeFileSync(testFile, ''); fs.unlinkSync(testFile); } 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 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); } catch (error) { throw new Error(`Cannot create temporary directory in: ${os.tmpdir()}`); @@ -59,9 +52,9 @@ async function validateConfig(): Promise { * @throws {Error} when dependencies are not satisfied */ async function checkDependencies(): Promise { - for (const tool of CONFIG.REQUIRED_TOOLS) { + for (const tool of CONFIG.tools.required) { try { - await spawnPromise(tool, ["--version"]); + await _spawnPromise(tool, ["--version"]); } catch (error) { throw new Error(`Required tool '${tool}' is not installed or not accessible`); } @@ -72,6 +65,11 @@ async function checkDependencies(): Promise { * Initialize service */ async function initialize(): Promise { + // 在測試環境中跳過初始化檢查 + if (process.env.NODE_ENV === 'test') { + return; + } + try { await validateConfig(); await checkDependencies(); @@ -88,7 +86,7 @@ const server = new Server( }, { 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 { - 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 { - 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 { - 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 { - 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 { - 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 * @param action Async operation to execute @@ -572,17 +198,17 @@ server.setRequestHandler( ); } else if (toolName === "download_video_subtitles") { return handleToolExecution( - () => downloadSubtitles(args.url, args.language), + () => downloadSubtitles(args.url, args.language || CONFIG.download.defaultSubtitleLanguage, CONFIG), "Error downloading subtitles" ); } else if (toolName === "download_video") { return handleToolExecution( - () => downloadVideo(args.url, args.resolution), + () => downloadVideo(args.url, CONFIG, args.resolution as "480p" | "720p" | "1080p" | "best"), "Error downloading video" ); } else if (toolName === "download_audio") { return handleToolExecution( - () => downloadAudio(args.url), + () => downloadAudio(args.url, CONFIG), "Error downloading audio" ); } else { @@ -605,6 +231,3 @@ async function startServer() { // Start the server and handle potential errors startServer().catch(console.error); - -// 導出錯誤類型供測試使用 -export { VideoDownloadError, ERROR_CODES }; diff --git a/src/modules/audio.ts b/src/modules/audio.ts new file mode 100644 index 0000000..cb2927d --- /dev/null +++ b/src/modules/audio.ts @@ -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 { + 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; + } +} \ No newline at end of file diff --git a/src/modules/subtitle.ts b/src/modules/subtitle.ts new file mode 100644 index 0000000..206378b --- /dev/null +++ b/src/modules/subtitle.ts @@ -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 { + 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 { + 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 }); + } +} \ No newline at end of file diff --git a/src/modules/utils.ts b/src/modules/utils.ts new file mode 100644 index 0000000..b177e06 --- /dev/null +++ b/src/modules/utils.ts @@ -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 { + 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 { + 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]; +} \ No newline at end of file diff --git a/src/modules/video.ts b/src/modules/video.ts new file mode 100644 index 0000000..5e43b78 --- /dev/null +++ b/src/modules/video.ts @@ -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 { + 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; + } +} \ No newline at end of file diff --git a/src/utils/test-utils.ts b/src/utils/test-utils.ts new file mode 100644 index 0000000..a485b99 --- /dev/null +++ b/src/utils/test-utils.ts @@ -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' + } +}; \ No newline at end of file