fix(sync): 403 InnerTube + WL manquant via fallbacks

- InnerTube 403: la YOUTUBE_API_KEY restreinte par referrer etait
  rejetee sans Referer envoye; ajout headers navigateur + repli cle
  publique du client web (probe live: 401 propre = transport OK)
- Watch Later: repli lecture via InnerTube (browseId WL, sans ID) quand
  l'API Data ne renvoie pas d'ID; repli push via editPlaylist; note
  'via' affichee dans l'UI; debug cles relatedPlaylists conserve
This commit is contained in:
2026-09-26 22:50:53 -04:00
parent 050cea3a92
commit 5a19f8f11f
5 changed files with 132 additions and 25 deletions
+99 -17
View File
@@ -470,9 +470,63 @@ function findHistoryContinuation(data) {
return null;
}
export async function fetchInnerTubeHistory(accessToken, apiKey, max = 200) {
const key = String(apiKey || '').trim();
if (!key) throw Object.assign(new Error('youtube_api_key_unavailable'), { status: 503 });
/**
* Appel youtubei/v1/* authentifié (Bearer OAuth utilisateur).
* La clé YOUTUBE_API_KEY est souvent restreinte par referrer HTTP : l'appel
* serveur (sans Referer) est alors rejeté en 403. On envoie un Referer
* navigateur et on bascule sur la clé publique du client web en repli.
*/
const PUBLIC_INNERTUBE_KEY = 'AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8';
function innertubeKeys(apiKey) {
const keys = [String(apiKey || '').trim(), PUBLIC_INNERTUBE_KEY].filter(Boolean);
return [...new Set(keys)];
}
async function innertubePost(path, apiKey, accessToken, body, label = 'innertube') {
const keys = innertubeKeys(apiKey);
if (!keys.length) throw Object.assign(new Error('youtube_api_key_unavailable'), { status: 503 });
let lastErr = null;
for (const key of keys) {
try {
const resp = await fetch(`https://www.youtube.com/youtubei/v1/${path}?key=${encodeURIComponent(key)}&prettyPrint=false`, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
// Sans Referer/UA navigateur, les clés restreintes et l'anti-bot répondent 403.
Referer: 'https://www.youtube.com/',
Origin: 'https://www.youtube.com',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36',
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(20000),
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok) {
const err = new Error(`${label}_failed_${resp.status}`);
err.status = resp.status === 401 ? 401 : resp.status;
err.details = data?.error || undefined;
throw err;
}
return data;
} catch (e) {
lastErr = e;
// 403 = clé rejetée (restriction referrer) ou IP filtrée : on essaie la clé suivante.
// 401 = token invalide : inutile de réessayer.
if (e?.status === 401) throw e;
if (e?.status !== 403) throw e;
}
}
throw lastErr || new Error(`${label}_failed`);
}
function innertubeContext() {
return { client: { clientName: 'WEB', clientVersion: '2.20260101.00.00', hl: 'fr', gl: 'FR' } };
}
/** Boucle browse + continuations générique (FEhistory, WL, ...). */
async function innertubeBrowseAll(accessToken, apiKey, browseId, max, label) {
const out = [];
const seen = new Set();
let continuation = null;
@@ -480,20 +534,9 @@ export async function fetchInnerTubeHistory(accessToken, apiKey, max = 200) {
while (out.length < max && pages < 10) {
pages++;
const body = continuation
? { context: { client: { clientName: 'WEB', clientVersion: '2.20260101.00.00', hl: 'fr', gl: 'FR' } }, continuation }
: { context: { client: { clientName: 'WEB', clientVersion: '2.20260101.00.00', hl: 'fr', gl: 'FR' } }, browseId: 'FEhistory' };
const resp = await fetch(`https://www.youtube.com/youtubei/v1/browse?key=${encodeURIComponent(key)}&prettyPrint=false`, {
method: 'POST',
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: AbortSignal.timeout(20000),
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok) {
const err = new Error(`innertube_history_failed_${resp.status}`);
err.status = resp.status === 401 ? 401 : 502;
throw err;
}
? { context: innertubeContext(), continuation }
: { context: innertubeContext(), browseId };
const data = await innertubePost('browse', apiKey, accessToken, body, label);
const batch = [];
const root = continuation
? (data?.onResponseReceivedActions || data?.continuationContents)
@@ -511,6 +554,45 @@ export async function fetchInnerTubeHistory(accessToken, apiKey, max = 200) {
return { items: out.slice(0, max), hasMore: Boolean(continuation) };
}
export async function fetchInnerTubeHistory(accessToken, apiKey, max = 200) {
try {
return await innertubeBrowseAll(accessToken, apiKey, 'FEhistory', max, 'innertube_history');
} catch (e) {
if (e?.message?.startsWith('innertube_history_failed_')) throw e;
const err = new Error(`innertube_history_failed_${e?.status || 'error'}`);
err.status = e?.status === 401 ? 401 : 502;
throw err;
}
}
/**
* "Regarder plus tard" via InnerTube (browseId WL logique) : repli quand
* l'API Data ne renvoie pas d'ID (comptes de marque...). Pas d'ID requis.
*/
export async function fetchInnerTubeWatchLater(accessToken, apiKey, max = 100) {
try {
const { items, hasMore } = await innertubeBrowseAll(accessToken, apiKey, 'WL', max, 'innertube_watchlater');
return { items, hasMore, via: 'innertube' };
} catch (e) {
if (e?.message?.startsWith('innertube_watchlater_failed_')) throw e;
const err = new Error(`innertube_watchlater_failed_${e?.status || 'error'}`);
err.status = e?.status === 401 ? 401 : 502;
throw err;
}
}
/** Ajout à "Regarder plus tard" via InnerTube (playlistId logique WL). */
export async function pushInnerTubeWatchLater(accessToken, apiKey, videoId) {
const data = await innertubePost('browse/editPlaylist', apiKey, accessToken, {
context: innertubeContext(),
actions: [{ action: 'ACTION_ADD_VIDEO', addedVideoId: String(videoId), playlistId: 'WL' }],
}, 'innertube_watchlater_push');
if (data?.status && String(data.status).toUpperCase() === 'FAIL') {
throw Object.assign(new Error('innertube_watchlater_push_rejected'), { status: 502 });
}
return { ok: true };
}
// -------------------- Twitch --------------------
function twitchClientId() {