CI / build-and-test (push) Successful in 14m43s
7.3: capturedAt/source au registre + 6 adaptateurs + module provenance.ts + ?debug=1 (search-transport.mjs). 7.4: ProviderHealthService + badge source degradee. 7.6: squelettes par provider + snapshots progressifs + transport NDJSON /api/search. 8.1: ProviderAdapter unifie (search enveloppe + channelContent/channelMeta/capabilities) via getProviderAdapter + test de contrat offline.
253 lines
9.3 KiB
JavaScript
253 lines
9.3 KiB
JavaScript
// 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';
|
|
}
|
|
|
|
// --- Phase 4.4 : bascule automatique InnerTube -> scrape --------------------
|
|
/**
|
|
* InnerTube casse plus souvent que la Data API (changement de format, bot-check).
|
|
* On mesure le taux d'echec InnerTube sur une fenetre glissante et on bascule
|
|
* temporairement sur `scrape-first`, avec retour automatique et cooldown.
|
|
*
|
|
* Pourquoi cet etat est dans le process et pas en base : une bascule doit
|
|
* survivre a un redemarrage (les bases survivent au redemarrage, l'etat non),
|
|
* et `provider_metrics` est de toute facon purge au-dela de 24 h.
|
|
*/
|
|
const failover = {
|
|
/** null = bascule inactive, sinon { until, previousMode, reason }. */
|
|
active: null,
|
|
lastToggleAt: 0,
|
|
};
|
|
|
|
/** Seuil de declenchement, 0.2 = 20 % d'echecs InnerTube sur 1 h. */
|
|
function failoverThreshold() {
|
|
const v = Number(process.env.YT_INNERTUBE_AUTO_FAILOVER);
|
|
return Number.isFinite(v) && v > 0 && v < 1 ? v : 0.2;
|
|
}
|
|
|
|
/** Cooldown apres un retour au mode normal, pour eviter l'oscillation. */
|
|
function failoverCooldownMs() {
|
|
const v = Number(process.env.YT_INNERTUBE_FAILOVER_COOLDOWN_MS);
|
|
return Number.isFinite(v) && v > 0 ? v : 15 * 60 * 1000;
|
|
}
|
|
|
|
/**
|
|
* A appeler apres chaque tentative InnerTube.
|
|
* @param {boolean} ok
|
|
* @param {object} snapshot `providerMetricsSnapshot({ hours: 1 })`
|
|
*/
|
|
export function recordInnerTubeOutcome(ok, snapshot) {
|
|
try {
|
|
if (!ok) ytMetrics.innertubeErrors++;
|
|
const now = Date.now();
|
|
// Retour au mode normal des que le taux repasse sous le seuil, en respectant
|
|
// le cooldown anti-oscillation.
|
|
if (failover.active && now < failover.active.until) return failover.active;
|
|
if (failover.active && now - failover.lastToggleAt < failoverCooldownMs()) return failover.active;
|
|
if (!Array.isArray(snapshot) || snapshot.length === 0) return failover.active;
|
|
const row = snapshot.find((r) => r.provider === 'yt');
|
|
if (!row || !row.calls || row.calls < 3) return failover.active; // pas assez d'echantillons
|
|
if (row.errorRate >= failoverThreshold()) {
|
|
const previousMode = getSearchMode();
|
|
failover.active = { until: now + failoverCooldownMs(), previousMode, reason: `errorRate=${row.errorRate.toFixed(2)} calls=${row.calls}` };
|
|
failover.lastToggleAt = now;
|
|
console.warn(`[YT failover] InnerTube dégradé (${failover.active.reason}) -> scrape-first pendant ${Math.round(failoverCooldownMs() / 60000)} min`);
|
|
return failover.active;
|
|
}
|
|
if (failover.active) {
|
|
console.log(`[YT failover] InnerTube rétabli (errorRate=${(row.errorRate || 0).toFixed(2)}) -> retour ${failover.active.previousMode}`);
|
|
failover.active = null;
|
|
failover.lastToggleAt = now;
|
|
}
|
|
return failover.active;
|
|
} catch { return failover.active; }
|
|
}
|
|
|
|
export function getFailoverState() {
|
|
return failover.active ? { ...failover.active, mode: 'scrape-first' } : null;
|
|
}
|
|
|
|
/**
|
|
* Mode **effectif** : le mode configure, force sur `scrape-first` pendant un
|
|
* failover actif. `getSearchMode()` reste la lecture de l'intention (env) ;
|
|
* les appelants doivent utiliser cette fonction.
|
|
*/
|
|
export function getEffectiveSearchMode() {
|
|
if (failover.active && Date.now() < failover.active.until) {
|
|
const base = getSearchMode();
|
|
// On n'ecrase que les modes qui dependent d'InnerTube.
|
|
return base.startsWith('innertube') ? 'scrape-first' : base;
|
|
}
|
|
return getSearchMode();
|
|
}
|
|
|
|
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 };
|
|
}
|