/** * @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; } /** * Resolve configured YouTube API keys. * Accepts YOUTUBE_API_KEYS as JSON array ('["k1","k2"]') or CSV ('k1,k2'), * plus the legacy single YOUTUBE_API_KEY as fallback. De-duplicated. * @returns {string[]} */ function getYouTubeKeys() { const keys = []; try { const raw = process.env.YOUTUBE_API_KEYS; if (raw && String(raw).trim() && String(raw).trim() !== 'undefined' && String(raw).trim() !== 'null') { const s = String(raw).trim(); if (s.startsWith('[')) { try { const arr = JSON.parse(s); if (Array.isArray(arr)) keys.push(...arr.map(v => String(v || '').trim()).filter(Boolean)); } catch {} } else { keys.push(...s.split(',').map(v => String(v || '').trim()).filter(Boolean)); } } } catch {} try { const single = process.env.YOUTUBE_API_KEY; if (single && String(single).trim()) keys.push(String(single).trim()); } catch {} return Array.from(new Set(keys.filter(Boolean))); } /** * Classify a YouTube API failure as retryable with another key. * - 400 API_KEY_INVALID / "API key expired" -> key is dead, try next. * - 403 quotaExceeded / rateLimitExceeded / dailyLimitExceeded -> quota, try next. */ function isKeyFailure(status, data) { try { const reason = data?.error?.errors?.[0]?.reason || ''; const message = String(data?.error?.message || ''); if (status === 400 && (reason === 'API_KEY_INVALID' || /api key (expired|invalid)/i.test(message))) return true; if (status === 403 && /quota|rateLimit|dailyLimit|userRateLimit/i.test(reason + ' ' + message)) return true; } catch {} return false; } /** GET JSON from YouTube, rotating through keys on key failures. */ async function ytFetchJson(base, paramsWithoutKey) { const keys = getYouTubeKeys(); if (!keys.length) { throw Object.assign(new Error('YOUTUBE_API_KEY not configured'), { ytStatus: 503, code: 'youtube_api_key_unavailable' }); } let lastError = null; let lastStatus = 500; let lastData = null; for (const key of keys) { const params = new URLSearchParams(paramsWithoutKey); params.set('key', key); const resp = await fetch(`${base}?${params.toString()}`); const data = await resp.json().catch(() => ({})); if (resp.ok) return data; lastStatus = resp.status; lastData = data; lastError = new Error(`YouTube API error: ${resp.status} ${data?.error?.message || ''}`.trim()); // Only rotate to the next key on key-attributable failures; otherwise fail fast. if (!isKeyFailure(resp.status, data)) break; console.warn(`[YouTube] key ...${String(key).slice(-4)} failed (${resp.status}), trying next key`); } const err = lastError || new Error(`YouTube API error: ${lastStatus}`); err.ytStatus = lastStatus; err.ytData = lastData; throw err; } /** @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 keys = getYouTubeKeys(); if (!keys.length) { throw Object.assign(new Error('YOUTUBE_API_KEY not configured'), { ytStatus: 503, code: 'youtube_api_key_unavailable' }); } 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 = { part: 'snippet', q: q, type: 'video', maxResults: String(perPage), order, // Ne retourner que des vidéos lisibles en embed (évite l'erreur 153 côté player). // Les vidéos non-embeddables sont filtrées via le champ status ci-dessous pour /videos. videoEmbeddable: 'true', safeSearch: 'moderate', }; if (pageToken) params.pageToken = pageToken; const data = await ytFetchJson('https://www.googleapis.com/youtube/v3/search', params); 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) { try { const detailsData = await ytFetchJson('https://www.googleapis.com/youtube/v3/videos', { part: 'contentDetails,statistics,status', id: videoIds.join(','), }); for (const vid of detailsData?.items || []) { if (vid?.id) detailsMap.set(vid.id, vid); } } catch (e) { console.warn('[YouTube] details fetch failed, continuing without durations:', e?.message || e); } } 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; const channelId = snippet.channelId || undefined; const channelHandle = snippet.channelTitle || undefined; // status.embeddable === false -> le player renvoie l'erreur 153 ("Video configuration error"). const embeddable = details?.status ? details.status.embeddable !== false : 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, channelId, channelHandle, channelExternalId: channelId, channelUrl: channelId ? `https://www.youtube.com/channel/${channelId}` : undefined, embeddable, }; }); } catch (error) { // Log explicite (clé expirée / quota) puis remonte l'erreur pour que // /api/search la reporte dans `errors.yt` au lieu d'un groupe vide silencieux. const status = error?.ytStatus || 500; console.error(`YouTube search error (status ${status}):`, error?.message || error); throw error; } } }; export default handler;