/** * @typedef {Object} Suggestion * @property {string} title * @property {string} id * @property {number=} duration * @property {boolean=} isShort * @property {string=} url * @property {string=} thumbnail * @property {string=} uploaderName * @property {string=} type */ function parseISODurationToSeconds(iso) { if (typeof iso !== 'string' || !iso) return 0; const match = iso.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/); if (!match) return 0; const hours = Number(match[1] || 0); const minutes = Number(match[2] || 0); const seconds = Number(match[3] || 0); return (hours * 3600) + (minutes * 60) + seconds; } /** @type {{ id: 'yt', label: string, search: (q: string, opts: { limit: number, page?: number, sort?: 'relevance'|'date'|'views' }) => Promise }} */ const handler = { id: 'yt', label: 'YouTube', async search(q, opts) { const { limit = 10, page = 1, sort = 'relevance' } = opts || {}; try { const API_KEY = process.env.YOUTUBE_API_KEY; if (!API_KEY) { throw new Error('YOUTUBE_API_KEY not configured'); } let order = 'relevance'; if (sort === 'date') order = 'date'; else if (sort === 'views') order = 'viewCount'; // Iterate nextPageToken to reach the requested page (1-based) const perPage = Math.min(Math.max(1, Number(limit || 10)), 50); const targetPage = Math.max(1, Number(page || 1)); let pageToken = ''; let currentPage = 1; let lastItems = []; while (currentPage <= targetPage) { const params = new URLSearchParams({ part: 'snippet', q: q, type: 'video', maxResults: String(perPage), key: API_KEY, order }); if (pageToken) params.set('pageToken', pageToken); const response = await fetch(`https://www.googleapis.com/youtube/v3/search?` + params.toString()); if (!response.ok) { throw new Error(`YouTube API error: ${response.status}`); } const data = await response.json(); if (currentPage === targetPage) { lastItems = Array.isArray(data.items) ? data.items : []; break; } // Prepare for next iteration const next = data.nextPageToken; if (!next) { // No more pages; requested page beyond available results lastItems = []; break; } pageToken = String(next); currentPage++; } const videoIds = (lastItems || []) .map(item => item?.id?.videoId) .filter(Boolean); const detailsMap = new Map(); if (videoIds.length > 0) { const detailsParams = new URLSearchParams({ part: 'contentDetails,statistics', id: videoIds.join(','), key: API_KEY }); const detailsResp = await fetch(`https://www.googleapis.com/youtube/v3/videos?${detailsParams.toString()}`); if (detailsResp.ok) { const detailsData = await detailsResp.json(); for (const vid of detailsData?.items || []) { if (vid?.id) detailsMap.set(vid.id, vid); } } } return (lastItems || []).map(item => { const videoId = item?.id?.videoId; const snippet = item?.snippet || {}; const thumb = snippet.thumbnails?.high?.url || snippet.thumbnails?.medium?.url || snippet.thumbnails?.default?.url || undefined; const details = videoId ? detailsMap.get(videoId) : null; const isoDuration = details?.contentDetails?.duration || ''; const duration = parseISODurationToSeconds(isoDuration); const views = details?.statistics?.viewCount != null ? Number(details.statistics.viewCount) : undefined; return { title: snippet.title || '', id: videoId, url: videoId ? `https://www.youtube.com/watch?v=${videoId}` : undefined, thumbnail: thumb, uploaderName: snippet.channelTitle || undefined, type: 'video', duration: duration > 0 ? duration : undefined, views, publishedAt: snippet.publishedAt || undefined }; }); } catch (error) { console.error('YouTube search error:', error); return []; } } }; export default handler;