From 186294f518a40680e8bdbb5613167cb966f087ed Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Sat, 26 Sep 2026 23:45:29 -0400 Subject: [PATCH] =?UTF-8?q?fix(sync):=20historique=20via=20youtubei.js=20a?= =?UTF-8?q?uthentifi=C3=A9=20(contexte=20officiel)=20+=20WL=20alias=20WL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/index.mjs | 9 +-- server/oauth.mjs | 153 +++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 139 insertions(+), 23 deletions(-) diff --git a/server/index.mjs b/server/index.mjs index f77ef45..694cb2c 100644 --- a/server/index.mjs +++ b/server/index.mjs @@ -716,14 +716,15 @@ r.get('/oauth/google/yt-history', authMiddlewareCookieAware, oauthLimiter, async try { const conn = await freshOAuthToken(req.user.id, 'google'); const max = Math.min(500, Math.max(1, Number(req.query.max || 200))); - const { items, hasMore } = await fetchInnerTubeHistory(conn.accessToken, innertubeApiKey(), max); + const { items, hasMore } = await fetchInnerTubeHistory(conn, max); return res.json({ items, count: items.length, hasMore }); } catch (error) { const out = { error: error?.message || 'ythistory_fetch_failed' }; if (error?.ytReason) out.ytReason = error.ytReason; - if (error?.status === 401) { + if (error?.hint) out.hint = error.hint; + if (!out.hint && error?.status === 401) { out.hint = 'Session Google expirée : déconnectez puis reconnectez Google.'; - } else if (String(error?.ytReason || '').toLowerCase().includes('insufficientpermissions')) { + } else if (!out.hint && String(error?.ytReason || '').toLowerCase().includes('insufficientpermissions')) { out.hint = 'Scope manquant : déconnectez puis reconnectez Google pour autoriser l’accès complet YouTube.'; } return res.status(error?.status || 502).json(out); @@ -752,7 +753,7 @@ r.post('/oauth/google/watchlater', authMiddlewareCookieAware, oauthLimiter, asyn for (const videoId of clean) { try { if (useInnertube) await pushInnerTubeWatchLater(conn.accessToken, innertubeApiKey(), videoId); - else await pushGoogleWatchLater(conn.accessToken, videoId); + else await pushGoogleWatchLater(conn.accessToken, videoId, innertubeApiKey()); added++; } catch { skipped++; } } diff --git a/server/oauth.mjs b/server/oauth.mjs index 44a96b5..53af1c4 100644 --- a/server/oauth.mjs +++ b/server/oauth.mjs @@ -371,9 +371,7 @@ export async function fetchGoogleWatchLaterId(accessToken) { return String(id); } -/** Contenu de "Regarder plus tard" (lecture seule OK). */ -export async function fetchGoogleWatchLater(accessToken, max = 50) { - const playlistId = await fetchGoogleWatchLaterId(accessToken); +async function fetchWatchLaterItems(accessToken, playlistId, max) { const out = []; let pageToken = ''; while (out.length < max) { @@ -398,19 +396,53 @@ export async function fetchGoogleWatchLater(accessToken, max = 50) { pageToken = data?.nextPageToken || ''; if (!pageToken) break; } - return { playlistId, items: out }; + return out; } -/** Ajoute une vidéo à "Regarder plus tard" (exige le scope force-ssl). */ -export async function pushGoogleWatchLater(accessToken, videoId) { - const playlistId = await fetchGoogleWatchLaterId(accessToken); - const data = await googleApiPost(accessToken, 'playlistItems?part=snippet', { - snippet: { - playlistId, - resourceId: { kind: 'youtube#video', videoId: String(videoId) }, - }, - }); - return { playlistItemId: data?.id || null }; +/** + * Contenu de "Regarder plus tard" (lecture seule OK). + * 1) ID résolu via channels.list, 2) alias officiel "WL", 3) InnerTube. + */ +export async function fetchGoogleWatchLater(accessToken, max = 50) { + try { + const playlistId = await fetchGoogleWatchLaterId(accessToken); + return { playlistId, items: await fetchWatchLaterItems(accessToken, playlistId, max), via: 'api' }; + } catch (e) { + if (e?.message !== 'watchlater_not_found' && e?.message !== 'youtube_no_channel') throw e; + } + try { + return { playlistId: 'WL', items: await fetchWatchLaterItems(accessToken, 'WL', max), via: 'api-alias' }; + } catch (e) { + if (e?.status === 401 || e?.status === 403) throw e; + } + const { items } = await fetchInnerTubeWatchLater(accessToken, process.env.YOUTUBE_API_KEY || '', max); + return { playlistId: 'WL', items, via: 'innertube' }; +} + +/** + * Ajoute une vidéo à "Regarder plus tard" (exige le scope force-ssl). + * ID résolu, sinon alias "WL", sinon InnerTube. + */ +export async function pushGoogleWatchLater(accessToken, videoId, apiKey = '') { + let playlistId = 'WL'; + try { + playlistId = await fetchGoogleWatchLaterId(accessToken); + } catch (e) { + if (e?.message !== 'watchlater_not_found' && e?.message !== 'youtube_no_channel') throw e; + } + try { + const data = await googleApiPost(accessToken, 'playlistItems?part=snippet', { + snippet: { + playlistId, + resourceId: { kind: 'youtube#video', videoId: String(videoId) }, + }, + }); + return { playlistItemId: data?.id || null, via: playlistId === 'WL' ? 'api-alias' : 'api' }; + } catch (e) { + if (e?.status === 401) throw e; + await pushInnerTubeWatchLater(accessToken, apiKey, videoId); + return { playlistItemId: null, via: 'innertube' }; + } } // -------------------- Historique YouTube via InnerTube authentifié -------------------- @@ -565,13 +597,96 @@ async function innertubeBrowseAll(accessToken, apiKey, browseId, max, label) { return { items: out.slice(0, max), hasMore: Boolean(continuation) }; } -export async function fetchInnerTubeHistory(accessToken, apiKey, max = 200) { +let ytLibNoiseSilenced = false; +async function silenceYoutubeiNoise() { + if (ytLibNoiseSilenced) return; + ytLibNoiseSilenced = true; try { - return await innertubeBrowseAll(accessToken, apiKey, 'FEhistory', max, 'innertube_history'); + const { Log } = await import('youtubei.js'); + if (Log?.set_level && Log?.Level) Log.set_level(Log.Level.ERROR ?? 3); + } catch {} +} + +/** + * Session youtubei.js authentifiée avec les tokens OAuth stockés + * (même Bearer que l'app, mais contexte client officiel complet). + */ +async function getAuthedInnertube(conn) { + const accessToken = String(conn?.accessToken || ''); + const refreshToken = String(conn?.refreshToken || ''); + if (!accessToken) throw Object.assign(new Error('oauth_not_connected'), { status: 404 }); + if (!refreshToken) { + throw Object.assign(new Error('google_reconnect_required'), { + status: 403, + hint: 'Jeton sans refresh_token : déconnectez puis reconnectez Google (cochez toutes les cases du consentement).', + }); + } + await silenceYoutubeiNoise(); + const { Innertube } = await import('youtubei.js'); + const innertube = await Innertube.create({ lang: 'fr', location: 'FR' }); + const expiry = Number(conn?.expiresAt || 0); + await innertube.session.oauth.init({ + access_token: accessToken, + refresh_token: refreshToken, + expiry_date: new Date(expiry > 0 ? expiry : Date.now() + 3600_000).toISOString(), + client: { + client_id: String(process.env.GOOGLE_CLIENT_ID || ''), + client_secret: String(process.env.GOOGLE_CLIENT_SECRET || ''), + }, + }); + // oauth.init n'active pas logged_in tout seul (vérifié) : sans lui le + // Bearer n'est jamais joint aux requêtes InnerTube ("You must be signed in"). + innertube.session.logged_in = true; + return innertube; +} + +/** + * Historique YouTube via youtubei.js authentifié (getHistory + continuations). + * `conn` = { accessToken, refreshToken, expiresAt } (jeton déjà rafraîchi). + */ +export async function fetchInnerTubeHistory(conn, max = 200) { + try { + const { mapVideoNode } = await import('./providers/youtube-innertube.mjs'); + const innertube = await getAuthedInnertube(conn); + const out = []; + const seen = new Set(); + let feed = await innertube.getHistory(); + let guard = 0; + while (feed && out.length < max && guard < 10) { + guard++; + const sections = Array.isArray(feed.sections) ? feed.sections : []; + for (const section of sections) { + const contents = Array.isArray(section?.contents) ? section.contents : []; + for (const node of contents) { + const mapped = mapVideoNode(node); + if (!mapped?.id || seen.has(mapped.id)) continue; + seen.add(mapped.id); + out.push({ + provider: 'youtube', + videoId: mapped.id, + title: mapped.title || mapped.id, + thumbnail: mapped.thumbnail || '', + watchedAt: new Date().toISOString(), + }); + if (out.length >= max) break; + } + if (out.length >= max) break; + } + if (out.length >= max) break; + if (feed.has_continuation) feed = await feed.getContinuation(); + else break; + } + return { items: out.slice(0, max), hasMore: Boolean(feed?.has_continuation) }; } 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; + if (e?.status === 404 || e?.status === 403 || e?.status === 401) throw e; + const msg = String(e?.message || ''); + if (/reconnect/i.test(msg) || e?.message === 'google_reconnect_required') throw e; + // youtubei.js encode le statut HTTP dans le message ("...status code 401"). + const m = /status code (\d{3})/.exec(msg); + const status = m ? Number(m[1]) : e?.status; + const err = new Error(`innertube_history_failed_${status || 'error'}`); + err.status = status === 401 ? 401 : 502; + if (e?.ytReason) err.ytReason = e.ytReason; throw err; } }