Merge pull request #15 from seszele64/implement-trimmed-download

Implement trimmed download

Thanks for the great work on this PR! 🙌

The trimmed download feature looks solid - good implementation with proper tests and documentation. This will be really useful for users who need to download video segments.

Appreciate the contribution!

LGTM 👍
This commit is contained in:
Kevin Watt 2025-07-28 04:21:22 +08:00 committed by GitHub
commit 9ba39128aa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 160 additions and 19 deletions

View File

@ -65,6 +65,8 @@ pip install yt-dlp
* Inputs: * Inputs:
* `url` (string, required): URL of the video * `url` (string, required): URL of the video
* `resolution` (string, optional): Video resolution ('480p', '720p', '1080p', 'best'). Defaults to '720p' * `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**
* Download audio in best available quality (usually m4a/mp3 format) to user's Downloads folder * Download audio in best available quality (usually m4a/mp3 format) to user's Downloads folder

View File

@ -2,14 +2,16 @@
## Video Operations ## Video Operations
### downloadVideo(url: string, config?: Config, resolution?: string): Promise<string> ### downloadVideo(url: string, config?: Config, resolution?: string, startTime?: string, endTime?: string): Promise<string>
Downloads a video from the specified URL. Downloads a video from the specified URL with optional trimming.
**Parameters:** **Parameters:**
- `url`: The URL of the video to download - `url`: The URL of the video to download
- `config`: (Optional) Configuration object - `config`: (Optional) Configuration object
- `resolution`: (Optional) Preferred video resolution ('480p', '720p', '1080p', 'best') - `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:** **Returns:**
- Promise resolving to a success message with the downloaded file path - Promise resolving to a success message with the downloaded file path
@ -29,6 +31,26 @@ const hdResult = await downloadVideo(
'1080p' '1080p'
); );
console.log(hdResult); 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 ## Audio Operations

View File

@ -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);
});

View File

@ -128,11 +128,19 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
type: "object", type: "object",
properties: { properties: {
url: { type: "string", description: "URL of the video" }, url: { type: "string", description: "URL of the video" },
resolution: { resolution: {
type: "string", 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'", 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"] 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"], required: ["url"],
}, },
@ -197,10 +205,12 @@ server.setRequestHandler(
CallToolRequestSchema, CallToolRequestSchema,
async (request: CallToolRequest) => { async (request: CallToolRequest) => {
const toolName = request.params.name; const toolName = request.params.name;
const args = request.params.arguments as { const args = request.params.arguments as {
url: string; url: string;
language?: string; language?: string;
resolution?: string; resolution?: string;
startTime?: string;
endTime?: string;
}; };
if (toolName === "list_subtitle_languages") { if (toolName === "list_subtitle_languages") {
@ -215,7 +225,13 @@ server.setRequestHandler(
); );
} else if (toolName === "download_video") { } else if (toolName === "download_video") {
return handleToolExecution( 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" "Error downloading video"
); );
} else if (toolName === "download_audio") { } else if (toolName === "download_audio") {

View File

@ -11,19 +11,21 @@ import {
/** /**
* Downloads a video from the specified URL. * Downloads a video from the specified URL.
* *
* @param url - The URL of the video to download * @param url - The URL of the video to download
* @param config - Configuration object for download settings * @param config - Configuration object for download settings
* @param resolution - Preferred video resolution ('480p', '720p', '1080p', 'best') * @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 * @returns Promise resolving to a success message with the downloaded file path
* @throws {Error} When URL is invalid or download fails * @throws {Error} When URL is invalid or download fails
* *
* @example * @example
* ```typescript * ```typescript
* // Download with default settings * // Download with default settings
* const result = await downloadVideo('https://youtube.com/watch?v=...'); * const result = await downloadVideo('https://youtube.com/watch?v=...');
* console.log(result); * console.log(result);
* *
* // Download with specific resolution * // Download with specific resolution
* const hdResult = await downloadVideo( * const hdResult = await downloadVideo(
* 'https://youtube.com/watch?v=...', * 'https://youtube.com/watch?v=...',
@ -31,12 +33,24 @@ import {
* '1080p' * '1080p'
* ); * );
* console.log(hdResult); * 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( export async function downloadVideo(
url: string, url: string,
config: Config, config: Config,
resolution: "480p" | "720p" | "1080p" | "best" = "720p" resolution: "480p" | "720p" | "1080p" | "best" = "720p",
startTime?: string,
endTime?: string
): Promise<string> { ): Promise<string> {
const userDownloadsDir = config.file.downloadsDir; const userDownloadsDir = config.file.downloadsDir;
@ -103,17 +117,36 @@ export async function downloadVideo(
expectedFilename = randomFilename; 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 // Download with progress info
try { try {
await _spawnPromise("yt-dlp", [ await _spawnPromise("yt-dlp", downloadArgs);
"--ignore-config",
"--progress",
"--newline",
"--no-mtime",
"-f", format,
"--output", outputTemplate,
url
]);
} catch (error) { } catch (error) {
throw new Error(`Download failed: ${error instanceof Error ? error.message : String(error)}`); throw new Error(`Download failed: ${error instanceof Error ? error.message : String(error)}`);
} }