From 5a19f8f11f0dd2e0a860bc93c523916667e66207 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Sat, 26 Sep 2026 22:50:53 -0400 Subject: [PATCH] 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 --- server/index.mjs | 36 ++++-- server/oauth.mjs | 116 +++++++++++++++--- .../library/import/import.component.html | 1 + .../library/import/import.component.ts | 2 + src/services/oauth-import.service.ts | 2 +- 5 files changed, 132 insertions(+), 25 deletions(-) diff --git a/server/index.mjs b/server/index.mjs index f5af064..50dc6fd 100644 --- a/server/index.mjs +++ b/server/index.mjs @@ -106,6 +106,7 @@ import { refreshAccessToken, redirectUriFor, fetchGoogleProfile, fetchGoogleSubscriptions, fetchGoogleLiked, fetchTwitchProfile, fetchTwitchFollows, hasGoogleWriteScope, fetchGoogleWatchLater, pushGoogleWatchLater, fetchInnerTubeHistory, + fetchInnerTubeWatchLater, pushInnerTubeWatchLater, fetchGoogleWatchLaterId, } from './oauth.mjs'; import { getSearchMode, getYtDlpBin, hasCookiesFile, metricsSnapshot } from './providers/youtube-common.mjs'; import { ytScrapeCacheStats } from './providers/youtube.mjs'; @@ -660,11 +661,23 @@ r.post('/oauth/:provider/import', authMiddlewareCookieAware, oauthLimiter, async r.get('/oauth/google/watchlater', authMiddlewareCookieAware, oauthLimiter, async (req, res) => { try { const conn = await freshOAuthToken(req.user.id, 'google'); - const { items } = await fetchGoogleWatchLater(conn.accessToken, 50); - return res.json({ - items, count: items.length, - writeGranted: hasGoogleWriteScope(conn.scopes), - }); + const writeGranted = hasGoogleWriteScope(conn.scopes); + try { + const { items } = await fetchGoogleWatchLater(conn.accessToken, 50); + return res.json({ items, count: items.length, writeGranted, via: 'api' }); + } catch (apiError) { + // L'API Data ne renvoie pas d'ID WL pour certains comptes : repli + // InnerTube (playlist logique "WL", aucun ID requis). + if (apiError?.message !== 'watchlater_not_found' && apiError?.message !== 'youtube_no_channel') throw apiError; + try { + console.warn(`[oauth] WL Data API indisponible (${apiError.message}), repli InnerTube`); + } catch {} + const { items } = await fetchInnerTubeWatchLater(conn.accessToken, innertubeApiKey(), 100); + return res.json({ + items, count: items.length, writeGranted, via: 'innertube', + note: 'Liste lue via InnerTube (l’API YouTube ne l’expose pas pour ce compte).', + }); + } } catch (error) { const out = { error: error?.message || 'watchlater_fetch_failed' }; if (error?.hint) out.hint = error.hint; @@ -719,15 +732,24 @@ r.post('/oauth/google/watchlater', authMiddlewareCookieAware, oauthLimiter, asyn const ids = Array.isArray(req.body?.videoIds) ? req.body.videoIds : []; const clean = [...new Set(ids.map((v) => String(v || '').trim()).filter(Boolean))].slice(0, 50); if (!clean.length) return res.status(400).json({ error: 'videoIds_required' }); + // ID WL via Data API, sinon repli InnerTube (playlist logique "WL"). + let useInnertube = false; + try { + await fetchGoogleWatchLaterId(conn.accessToken); + } catch (e) { + if (e?.message === 'watchlater_not_found' || e?.message === 'youtube_no_channel') useInnertube = true; + else throw e; + } let added = 0; let skipped = 0; for (const videoId of clean) { try { - await pushGoogleWatchLater(conn.accessToken, videoId); + if (useInnertube) await pushInnerTubeWatchLater(conn.accessToken, innertubeApiKey(), videoId); + else await pushGoogleWatchLater(conn.accessToken, videoId); added++; } catch { skipped++; } } - return res.json({ added, skipped, total: clean.length }); + return res.json({ added, skipped, total: clean.length, via: useInnertube ? 'innertube' : 'api' }); } catch (error) { const status = error?.status || 502; if (status === 403) { diff --git a/server/oauth.mjs b/server/oauth.mjs index 9108eeb..8bf6a52 100644 --- a/server/oauth.mjs +++ b/server/oauth.mjs @@ -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() { diff --git a/src/components/library/import/import.component.html b/src/components/library/import/import.component.html index 3f21214..a54fcef 100644 --- a/src/components/library/import/import.component.html +++ b/src/components/library/import/import.component.html @@ -144,6 +144,7 @@
{{ wlError() }}
+
{{ wlNote() }}
{{ wlItems().length }} vidéo(s) dans « Regarder plus tard » YouTube. Écriture non autorisée : déconnectez puis reconnectez Google pour l’activer. diff --git a/src/components/library/import/import.component.ts b/src/components/library/import/import.component.ts index 99a68c9..ba53f8d 100644 --- a/src/components/library/import/import.component.ts +++ b/src/components/library/import/import.component.ts @@ -226,6 +226,7 @@ export class ImportComponent implements OnInit { // -------------------- Watch Later YouTube -------------------- wlItems = signal>([]); + wlNote = signal(null); wlLoading = signal(false); wlWriteGranted = signal(false); wlError = signal(null); @@ -243,6 +244,7 @@ export class ImportComponent implements OnInit { next: (res) => { this.wlItems.set(res?.items || []); this.wlWriteGranted.set(!!res?.writeGranted); + this.wlNote.set(res?.note || (res?.via === 'innertube' ? 'Liste lue via InnerTube.' : null)); this.wlLoading.set(false); }, error: (e) => { diff --git a/src/services/oauth-import.service.ts b/src/services/oauth-import.service.ts index 2d92009..7fd5a3a 100644 --- a/src/services/oauth-import.service.ts +++ b/src/services/oauth-import.service.ts @@ -64,7 +64,7 @@ export class OAuthImportService { } /** Contenu "Regarder plus tard" YouTube (lecture seule OK). */ - watchLater(): Observable<{ items: Array<{ videoId: string; title: string; thumbnail?: string }>; count: number; writeGranted: boolean }> { + watchLater(): Observable<{ items: Array<{ videoId: string; title: string; thumbnail?: string }>; count: number; writeGranted: boolean; via?: string; note?: string }> { return this.http.get(`/api/oauth/google/watchlater`); }