feat(search): add uploadDateFilter parameter for date-based filtering

Add optional uploadDateFilter parameter to ytdlp_search_videos tool
that allows filtering search results by upload date using YouTube's
native date filtering (sp parameter).

Options: hour, today, week, month, year
Default: no filter (all dates)

Closes #21
This commit is contained in:
kevinwatt 2025-12-25 03:18:32 +08:00
parent 020c57d40c
commit af0137b2bd
3 changed files with 92 additions and 20 deletions

View File

@ -15,6 +15,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Configurable comment limit (1-100 comments)
- Includes author verification status, pinned comments, and uploader replies
- Comprehensive test suite for comments functionality
- **Upload Date Filter**: New `uploadDateFilter` parameter for `ytdlp_search_videos` tool ([#21](https://github.com/kevinwatt/yt-dlp-mcp/issues/21))
- Filter search results by upload date: `hour`, `today`, `week`, `month`, `year`
- Uses YouTube's native date filtering for efficient searches
- Optional parameter - defaults to no filtering (all dates)
### Changed
- Add Claude Code settings (.claude/, CLAUDE.md) to .gitignore
@ -23,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Comments integration tests are now opt-in via `RUN_INTEGRATION_TESTS=1` env var for CI stability
### Fixed
- Fixed `validateUrl()` return value not being checked in `audio.ts`, `metadata.ts`, and `video.ts`
- Fixed comments test Python environment handling (use `delete` instead of empty string assignment)
- Fixed regex null coalescing in comments test for author matching

View File

@ -29,6 +29,15 @@ enum ResponseFormat {
MARKDOWN = "markdown"
}
// Upload date filter enum for YouTube search
enum UploadDateFilter {
HOUR = "hour",
TODAY = "today",
WEEK = "week",
MONTH = "month",
YEAR = "year"
}
// Zod Schemas for Input Validation
const SearchVideosSchema = z.object({
query: z.string()
@ -49,6 +58,9 @@ const SearchVideosSchema = z.object({
response_format: z.nativeEnum(ResponseFormat)
.default(ResponseFormat.MARKDOWN)
.describe("Output format: 'json' for structured data, 'markdown' for human-readable"),
uploadDateFilter: z.nativeEnum(UploadDateFilter)
.optional()
.describe("Optional filter by upload date: 'hour', 'today', 'week', 'month', 'year'. If omitted, returns videos from all dates."),
}).strict();
const ListSubtitleLanguagesSchema = z.object({
@ -222,21 +234,22 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
tools: [
{
name: "ytdlp_search_videos",
description: `Search for videos on YouTube using keywords with pagination support.
description: `Search for videos on YouTube using keywords with pagination and date filtering support.
This tool queries YouTube's search API and returns matching videos with titles, uploaders, durations, and URLs. Supports pagination for browsing through large result sets.
This tool queries YouTube's search API and returns matching videos with titles, uploaders, durations, and URLs. Supports pagination for browsing through large result sets and filtering by upload date.
Args:
- query (string): Search keywords (e.g., "machine learning tutorial", "beethoven symphony")
- maxResults (number): Number of results to return (1-50, default: 10)
- offset (number): Skip first N results for pagination (default: 0)
- response_format (enum): 'json' for structured data, 'markdown' for human-readable (default: 'markdown')
- uploadDateFilter (enum, optional): Filter by upload date - 'hour' (last hour), 'today', 'week' (this week), 'month' (this month), 'year' (this year). Default: no filter (all dates)
Returns:
Markdown format: Formatted list with video details and pagination info
JSON format: { total, count, offset, videos: [{title, id, url, uploader, duration}], has_more, next_offset }
JSON format: { total, count, offset, videos: [{title, id, url, uploader, duration}], has_more, next_offset, upload_date_filter }
Use when: Finding videos by topic, creator name, or keywords
Use when: Finding videos by topic, creator name, or keywords; filtering recent uploads
Don't use when: You already have the video URL (use ytdlp_get_video_metadata instead)
Error Handling:
@ -603,6 +616,7 @@ server.setRequestHandler(
maxComments?: number;
sortOrder?: "top" | "new";
fields?: string[];
uploadDateFilter?: string;
};
// Validate inputs with Zod schemas
@ -610,7 +624,7 @@ server.setRequestHandler(
if (toolName === "ytdlp_search_videos") {
const validated = SearchVideosSchema.parse(args);
return handleToolExecution(
() => searchVideos(validated.query, validated.maxResults, validated.offset, validated.response_format, CONFIG),
() => searchVideos(validated.query, validated.maxResults, validated.offset, validated.response_format, CONFIG, validated.uploadDateFilter),
"Error searching videos"
);
} else if (toolName === "ytdlp_list_subtitle_languages") {

View File

@ -2,6 +2,11 @@ import { _spawnPromise } from "./utils.js";
import type { Config } from "../config.js";
import { getCookieArgs } from "../config.js";
/**
* Upload date filter type
*/
export type UploadDateFilter = "hour" | "today" | "week" | "month" | "year";
/**
* YouTube search result interface
*/
@ -15,6 +20,18 @@ export interface SearchResult {
uploadDate?: string;
}
/**
* Map upload date filter to YouTube's sp parameter
* These are base64-encoded protobuf parameters
*/
const UPLOAD_DATE_FILTER_MAP: Record<UploadDateFilter, string> = {
hour: "EgIIAQ%3D%3D", // Last hour
today: "EgIIAg%3D%3D", // Today
week: "EgIIAw%3D%3D", // This week
month: "EgIIBA%3D%3D", // This month
year: "EgIIBQ%3D%3D", // This year
};
/**
* Search YouTube videos
* @param query Search keywords
@ -22,6 +39,7 @@ export interface SearchResult {
* @param offset Number of results to skip for pagination
* @param responseFormat Output format ('json' or 'markdown')
* @param config Configuration object
* @param uploadDateFilter Optional filter by upload date
* @returns Search results formatted as string
*/
export async function searchVideos(
@ -29,7 +47,8 @@ export async function searchVideos(
maxResults: number = 10,
offset: number = 0,
responseFormat: "json" | "markdown" = "markdown",
config: Config
config: Config,
uploadDateFilter?: UploadDateFilter
): Promise<string> {
// Validate parameters
if (!query || query.trim().length === 0) {
@ -47,11 +66,32 @@ export async function searchVideos(
const cleanQuery = query.trim();
// Request more results to support offset
const totalToFetch = maxResults + offset;
const searchQuery = `ytsearch${totalToFetch}:${cleanQuery}`;
try {
// Use yt-dlp to search and get video information
const args = [
let args: string[];
if (uploadDateFilter && UPLOAD_DATE_FILTER_MAP[uploadDateFilter]) {
// Use YouTube URL with sp parameter for date filtering
const encodedQuery = encodeURIComponent(cleanQuery);
const spParam = UPLOAD_DATE_FILTER_MAP[uploadDateFilter];
const searchUrl = `https://www.youtube.com/results?search_query=${encodedQuery}&sp=${spParam}`;
args = [
searchUrl,
"--flat-playlist",
"--print", "title",
"--print", "id",
"--print", "uploader",
"--print", "duration",
"--no-download",
"--quiet",
"--playlist-end", String(totalToFetch),
...getCookieArgs(config)
];
} else {
// Use ytsearch prefix for regular search
const searchQuery = `ytsearch${totalToFetch}:${cleanQuery}`;
args = [
searchQuery,
"--print", "title",
"--print", "id",
@ -61,6 +101,7 @@ export async function searchVideos(
"--quiet",
...getCookieArgs(config)
];
}
const result = await _spawnPromise(config.tools.required[0], args);
@ -109,7 +150,8 @@ export async function searchVideos(
offset: offset,
videos: paginatedResults,
has_more: hasMore,
...(hasMore && { next_offset: offset + maxResults })
...(hasMore && { next_offset: offset + maxResults }),
...(uploadDateFilter && { upload_date_filter: uploadDateFilter })
};
let output = JSON.stringify(response, null, 2);
@ -131,7 +173,18 @@ export async function searchVideos(
return output;
} else {
// Markdown format
let output = `Found ${allResults.length} video${allResults.length > 1 ? 's' : ''} (showing ${paginatedResults.length}):\n\n`;
let output = `Found ${allResults.length} video${allResults.length > 1 ? 's' : ''} (showing ${paginatedResults.length})`;
if (uploadDateFilter) {
const filterLabels: Record<UploadDateFilter, string> = {
hour: "last hour",
today: "today",
week: "this week",
month: "this month",
year: "this year"
};
output += ` from ${filterLabels[uploadDateFilter]}`;
}
output += `:\n\n`;
paginatedResults.forEach((video, index) => {
output += `${offset + index + 1}. **${video.title}**\n`;