diff --git a/CHANGELOG.md b/CHANGELOG.md index 81b3079..ef2553d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/index.mts b/src/index.mts index 63f7e05..f403aaf 100644 --- a/src/index.mts +++ b/src/index.mts @@ -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") { diff --git a/src/modules/search.ts b/src/modules/search.ts index 4cd34d6..aef78b7 100644 --- a/src/modules/search.ts +++ b/src/modules/search.ts @@ -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 = { + 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 { // Validate parameters if (!query || query.trim().length === 0) { @@ -47,20 +66,42 @@ 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 = [ - searchQuery, - "--print", "title", - "--print", "id", - "--print", "uploader", - "--print", "duration", - "--no-download", - "--quiet", - ...getCookieArgs(config) - ]; + 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", + "--print", "uploader", + "--print", "duration", + "--no-download", + "--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 = { + 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`;