diff --git a/README.md b/README.md index a465b20..10742a5 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,8 @@ pip install yt-dlp * Inputs: * `url` (string, required): URL of the video * `resolution` (string, optional): Video resolution ('480p', '720p', '1080p', 'best'). Defaults to '720p' + * `startTime` (string, optional): Start time for trimming (format: HH:MM:SS[.ms]) - e.g., '00:01:30' or '00:01:30.500' + * `endTime` (string, optional): End time for trimming (format: HH:MM:SS[.ms]) - e.g., '00:02:45' or '00:02:45.500' * **download_audio** * Download audio in best available quality (usually m4a/mp3 format) to user's Downloads folder diff --git a/docs/api.md b/docs/api.md index 47a267a..25028ef 100644 --- a/docs/api.md +++ b/docs/api.md @@ -2,14 +2,16 @@ ## Video Operations -### downloadVideo(url: string, config?: Config, resolution?: string): Promise +### downloadVideo(url: string, config?: Config, resolution?: string, startTime?: string, endTime?: string): Promise -Downloads a video from the specified URL. +Downloads a video from the specified URL with optional trimming. **Parameters:** - `url`: The URL of the video to download - `config`: (Optional) Configuration object - `resolution`: (Optional) Preferred video resolution ('480p', '720p', '1080p', 'best') +- `startTime`: (Optional) Start time for trimming (format: HH:MM:SS[.ms]) +- `endTime`: (Optional) End time for trimming (format: HH:MM:SS[.ms]) **Returns:** - Promise resolving to a success message with the downloaded file path @@ -29,6 +31,26 @@ const hdResult = await downloadVideo( '1080p' ); console.log(hdResult); + +// Download with trimming +const trimmedResult = await downloadVideo( + 'https://www.youtube.com/watch?v=jNQXAC9IVRw', + undefined, + '720p', + '00:01:30', + '00:02:45' +); +console.log(trimmedResult); + +// Download with fractional seconds +const preciseTrim = await downloadVideo( + 'https://www.youtube.com/watch?v=jNQXAC9IVRw', + undefined, + '720p', + '00:01:30.500', + '00:02:45.250' +); +console.log(preciseTrim); ``` ## Audio Operations diff --git a/src/__tests__/video.test.ts b/src/__tests__/video.test.ts new file mode 100644 index 0000000..9a5badd --- /dev/null +++ b/src/__tests__/video.test.ts @@ -0,0 +1,68 @@ +// @ts-nocheck +// @jest-environment node +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'; + +// 設置 Python 環境 +process.env.PYTHONPATH = ''; +process.env.PYTHONHOME = ''; + +describe('downloadVideo with trimming', () => { + 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 }); + }); + + test('downloads video with start time trimming', async () => { + const result = await downloadVideo(testUrl, testConfig, '720p', '00:00:10'); + expect(result).toContain('Video successfully downloaded'); + + const files = await fs.promises.readdir(testConfig.file.downloadsDir); + expect(files.length).toBeGreaterThan(0); + expect(files[0]).toMatch(/\.(mp4|webm|mkv)$/); + }, 30000); + + test('downloads video with end time trimming', async () => { + const result = await downloadVideo(testUrl, testConfig, '720p', undefined, '00:00:20'); + expect(result).toContain('Video successfully downloaded'); + + const files = await fs.promises.readdir(testConfig.file.downloadsDir); + expect(files.length).toBeGreaterThan(0); + expect(files[0]).toMatch(/\.(mp4|webm|mkv)$/); + }, 30000); + + test('downloads video with both start and end time trimming', async () => { + const result = await downloadVideo(testUrl, testConfig, '720p', '00:00:10', '00:00:20'); + expect(result).toContain('Video successfully downloaded'); + + const files = await fs.promises.readdir(testConfig.file.downloadsDir); + expect(files.length).toBeGreaterThan(0); + expect(files[0]).toMatch(/\.(mp4|webm|mkv)$/); + }, 30000); + + test('downloads video without trimming when no times provided', async () => { + const result = await downloadVideo(testUrl, testConfig, '720p'); + expect(result).toContain('Video successfully downloaded'); + + const files = await fs.promises.readdir(testConfig.file.downloadsDir); + expect(files.length).toBeGreaterThan(0); + expect(files[0]).toMatch(/\.(mp4|webm|mkv)$/); + }, 30000); +}); \ No newline at end of file diff --git a/src/index.mts b/src/index.mts index 6dbe2c6..1fa8c30 100644 --- a/src/index.mts +++ b/src/index.mts @@ -128,11 +128,19 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { type: "object", properties: { url: { type: "string", description: "URL of the video" }, - resolution: { - type: "string", + resolution: { + type: "string", description: "Preferred video resolution. For YouTube: '480p', '720p', '1080p', 'best'. For other platforms: '480p' for low quality, '720p'/'1080p' for HD, 'best' for highest quality. Defaults to '720p'", enum: ["480p", "720p", "1080p", "best"] }, + startTime: { + type: "string", + description: "Start time for trimming (format: HH:MM:SS[.ms]) - e.g., '00:01:30' or '00:01:30.500'" + }, + endTime: { + type: "string", + description: "End time for trimming (format: HH:MM:SS[.ms]) - e.g., '00:02:45' or '00:02:45.500'" + }, }, required: ["url"], }, @@ -197,10 +205,12 @@ server.setRequestHandler( CallToolRequestSchema, async (request: CallToolRequest) => { const toolName = request.params.name; - const args = request.params.arguments as { + const args = request.params.arguments as { url: string; language?: string; resolution?: string; + startTime?: string; + endTime?: string; }; if (toolName === "list_subtitle_languages") { @@ -215,7 +225,13 @@ server.setRequestHandler( ); } else if (toolName === "download_video") { return handleToolExecution( - () => downloadVideo(args.url, CONFIG, args.resolution as "480p" | "720p" | "1080p" | "best"), + () => downloadVideo( + args.url, + CONFIG, + args.resolution as "480p" | "720p" | "1080p" | "best", + args.startTime, + args.endTime + ), "Error downloading video" ); } else if (toolName === "download_audio") { diff --git a/src/modules/video.ts b/src/modules/video.ts index a50db6b..3007deb 100644 --- a/src/modules/video.ts +++ b/src/modules/video.ts @@ -11,19 +11,21 @@ import { /** * 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') + * @param startTime - Optional start time for trimming (format: HH:MM:SS[.ms]) + * @param endTime - Optional end time for trimming (format: HH:MM:SS[.ms]) * @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=...', @@ -31,12 +33,24 @@ import { * '1080p' * ); * console.log(hdResult); + * + * // Download with trimming + * const trimmedResult = await downloadVideo( + * 'https://youtube.com/watch?v=...', + * undefined, + * '720p', + * '00:01:30', + * '00:02:45' + * ); + * console.log(trimmedResult); * ``` */ export async function downloadVideo( url: string, config: Config, - resolution: "480p" | "720p" | "1080p" | "best" = "720p" + resolution: "480p" | "720p" | "1080p" | "best" = "720p", + startTime?: string, + endTime?: string ): Promise { const userDownloadsDir = config.file.downloadsDir; @@ -103,17 +117,36 @@ export async function downloadVideo( expectedFilename = randomFilename; } + // Build download arguments + const downloadArgs = [ + "--ignore-config", + "--progress", + "--newline", + "--no-mtime", + "-f", format, + "--output", outputTemplate + ]; + + // Add trimming parameters if provided + if (startTime || endTime) { + let downloadSection = "*"; + + if (startTime && endTime) { + downloadSection = `*${startTime}-${endTime}`; + } else if (startTime) { + downloadSection = `*${startTime}-`; + } else if (endTime) { + downloadSection = `*-${endTime}`; + } + + downloadArgs.push("--download-sections", downloadSection, "--force-keyframes-at-cuts"); + } + + downloadArgs.push(url); + // Download with progress info try { - await _spawnPromise("yt-dlp", [ - "--ignore-config", - "--progress", - "--newline", - "--no-mtime", - "-f", format, - "--output", outputTemplate, - url - ]); + await _spawnPromise("yt-dlp", downloadArgs); } catch (error) { throw new Error(`Download failed: ${error instanceof Error ? error.message : String(error)}`); }