// Helpers partagés YouTube : clés API, mode de recherche, anti-ban yt-dlp, métriques. // Extrait de youtube.mjs + index.mjs pour éviter la duplication (Step 17 P0). import fs from 'node:fs'; import path from 'node:path'; import crypto from 'node:crypto'; import { execFile as execFileCb } from 'node:child_process'; import { promisify } from 'node:util'; export function getYouTubeKeys() { const keys = []; try { const raw = process.env.YOUTUBE_API_KEYS; if (raw && String(raw).trim() && !['undefined', 'null'].includes(String(raw).trim())) { 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))); } export 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; } /** Mode de recherche YT : innertube-first (défaut) | innertube-only | scrape-first | api-first | scrape-only | api-only */ export const YT_SEARCH_MODES = ['innertube-first', 'innertube-only', 'scrape-first', 'api-first', 'scrape-only', 'api-only']; export function getSearchMode() { const m = String(process.env.YT_SEARCH_MODE || 'innertube-first').trim().toLowerCase(); if (YT_SEARCH_MODES.includes(m)) return m; return 'innertube-first'; } export function getScrapeTtlMs() { const v = Number(process.env.YT_SCRAPE_TTL_MS || 30 * 60 * 1000); return Number.isFinite(v) && v > 0 ? v : 30 * 60 * 1000; } export function getYtDlpTimeoutMs() { const v = Number(process.env.YT_DLP_TIMEOUT_MS || 20000); return Number.isFinite(v) && v >= 5000 ? v : 20000; } export function getYtDlpBin() { const p = String(process.env.YT_DLP_PATH || '').trim(); if (p) return p; return process.platform === 'win32' ? 'yt-dlp.exe' : 'yt-dlp'; } /** * Résolution robuste du binaire yt-dlp (même ordre que server/index.mjs) : * 1. YT_DLP_PATH (si le fichier existe) * 2. `yt-dlp[.exe]` sur le PATH (probé via --version) * 3. binaire bundled de `youtube-dl-exec` (node_modules/.../bin/, utilisé en Docker) * Résultat mis en cache process-wide. Throw avec code `yt_scrape_no_binary` si introuvable. */ let _resolvedBin = null; export function resetYtDlpBinCache() { _resolvedBin = null; } function bundledYtDlpPath() { try { const exe = process.platform === 'win32' ? 'yt-dlp.exe' : 'yt-dlp'; // server/providers/ -> server/../node_modules/youtube-dl-exec/bin/ const cand = path.resolve(path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1')), '..', '..', 'node_modules', 'youtube-dl-exec', 'bin', exe); if (fs.existsSync(cand)) return cand; } catch {} return null; } export function getBundledYtDlpPath() { return bundledYtDlpPath(); } async function probeBin(bin) { try { const execFileAsync = promisify(execFileCb); await execFileAsync(bin, ['--version'], { timeout: 10000 }); return true; } catch (e) { // ENOENT = binaire absent ; autre erreur (timeout...) = présent mais KO -> on le garde quand même return e?.code !== 'ENOENT' && e?.errno !== 'ENOENT'; } } export async function resolveYtDlpBin() { if (_resolvedBin) return _resolvedBin; // 1) YT_DLP_PATH explicite try { const p = String(process.env.YT_DLP_PATH || '').trim(); if (p && fs.existsSync(p)) { if (await probeBin(p)) { _resolvedBin = p; return p; } } else if (p) { console.warn(`[YT] YT_DLP_PATH introuvable : ${p} (fallback PATH/bundled)`); } } catch {} // 2) PATH const exe = process.platform === 'win32' ? 'yt-dlp.exe' : 'yt-dlp'; for (const cand of [exe, 'yt-dlp']) { try { if (await probeBin(cand)) { _resolvedBin = cand; return cand; } } catch {} } // 3) bundled youtube-dl-exec const bundled = bundledYtDlpPath(); if (bundled && await probeBin(bundled)) { _resolvedBin = bundled; return bundled; } throw Object.assign( new Error('yt-dlp introuvable (PATH, YT_DLP_PATH ni binaire bundled). Installez yt-dlp ou définissez YT_DLP_PATH.'), { ytStatus: 503, code: 'yt_scrape_no_binary' }, ); } /** * Args anti-ban communs pour yt-dlp (cookies, PO-Token, proxy). * Ne jamais logger les valeurs (cookies path exclu des logs verbeux). */ export function buildYtDlpExtraArgs() { const args = []; try { const cookies = String(process.env.YT_COOKIES_FILE || '').trim(); if (cookies && fs.existsSync(cookies)) { args.push('--cookies', cookies); } } catch {} try { const po = String(process.env.YT_PO_TOKEN || '').trim(); if (po) args.push('--extractor-args', `youtube:po_token=${po}`); } catch {} try { const proxy = String(process.env.YT_EGRESS_PROXY || '').trim(); if (proxy) args.push('--proxy', proxy); } catch {} return args; } export function hasCookiesFile() { try { const c = String(process.env.YT_COOKIES_FILE || '').trim(); return Boolean(c && fs.existsSync(c)); } catch { return false; } } export function hashSearchKey(parts) { return crypto.createHash('sha256').update(String(parts)).digest('hex').slice(0, 32); } // Métriques process-wide (remises à zéro au restart, persistées jour par jour en SQLite via db.mjs) export const ytMetrics = { scrapeCalls: 0, scrapeHits: 0, // hit cache (mémoire ou SQLite) apiCalls: 0, quotaUnits: 0, // estimation search*100 + videos*1 fallbacks: 0, scrapeErrors: 0, innertubeCalls: 0, innertubeErrors: 0, }; export function metricsSnapshot() { return { ...ytMetrics }; }