From 8edf9de0c601a7aae1119db3047504fcb0a9a38f Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Sun, 27 Sep 2026 07:36:16 -0400 Subject: [PATCH] =?UTF-8?q?fix(sync):=20historique/WL=20via=20youtubei.js?= =?UTF-8?q?=20authentifi=C3=A9=20+=20erreurs=20d=C3=A9taill=C3=A9es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/index.mjs | 9 +- server/oauth.mjs | 123 +++++++++++++----- .../library/import/import.component.html | 1 + .../library/import/import.component.ts | 10 +- 4 files changed, 108 insertions(+), 35 deletions(-) diff --git a/server/index.mjs b/server/index.mjs index 694cb2c..614cd07 100644 --- a/server/index.mjs +++ b/server/index.mjs @@ -106,7 +106,7 @@ import { refreshAccessToken, redirectUriFor, fetchGoogleProfile, fetchGoogleSubscriptions, fetchGoogleLiked, fetchTwitchProfile, fetchTwitchFollows, hasGoogleWriteScope, fetchGoogleWatchLater, pushGoogleWatchLater, fetchInnerTubeHistory, - fetchInnerTubeWatchLater, pushInnerTubeWatchLater, fetchGoogleWatchLaterId, + fetchInnerTubeWatchLaterViaLib, pushInnerTubeWatchLater, fetchGoogleWatchLaterId, } from './oauth.mjs'; import { getSearchMode, getYtDlpBin, hasCookiesFile, metricsSnapshot } from './providers/youtube-common.mjs'; import { ytScrapeCacheStats } from './providers/youtube.mjs'; @@ -667,12 +667,12 @@ r.get('/oauth/google/watchlater', authMiddlewareCookieAware, oauthLimiter, async 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). + // youtubei.js authentifié (getPlaylist 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`); + console.warn(`[oauth] WL Data API indisponible (${apiError.message}), repli youtubei.js`); } catch {} - const { items } = await fetchInnerTubeWatchLater(conn.accessToken, innertubeApiKey(), 100); + const { items } = await fetchInnerTubeWatchLaterViaLib(conn, 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).', @@ -682,6 +682,7 @@ r.get('/oauth/google/watchlater', authMiddlewareCookieAware, oauthLimiter, async const out = { error: error?.message || 'watchlater_fetch_failed' }; if (error?.hint) out.hint = error.hint; if (error?.ytReason) out.ytReason = error.ytReason; + if (error?.detail) out.detail = error.detail; 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.'; } diff --git a/server/oauth.mjs b/server/oauth.mjs index 53af1c4..353a683 100644 --- a/server/oauth.mjs +++ b/server/oauth.mjs @@ -400,8 +400,9 @@ async function fetchWatchLaterItems(accessToken, playlistId, max) { } /** - * Contenu de "Regarder plus tard" (lecture seule OK). - * 1) ID résolu via channels.list, 2) alias officiel "WL", 3) InnerTube. + * Contenu de "Regarder plus tard" via Data API (lecture seule OK). + * 1) ID résolu via channels.list, 2) alias officiel "WL". + * Le repli youtubei.js est géré par la route (jeton complet requis). */ export async function fetchGoogleWatchLater(accessToken, max = 50) { try { @@ -410,13 +411,7 @@ export async function fetchGoogleWatchLater(accessToken, max = 50) { } 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' }; + return { playlistId: 'WL', items: await fetchWatchLaterItems(accessToken, 'WL', max), via: 'api-alias' }; } /** @@ -640,6 +635,58 @@ async function getAuthedInnertube(conn) { return innertube; } +/** Détail d'erreur YouTube exposable (clé API masquée, 300 car. max, sans token). */ +function libErrorDetail(e) { + try { + return String(e?.message || e || '') + .replace(/key=[^&\s]*/gi, 'key=[redacted]') + .replace(/Bearer\s+[A-Za-z0-9._~-]+/gi, 'Bearer [redacted]') + .slice(0, 300) || undefined; + } catch { return undefined; } +} + +function throwLibHistoryError(e, prefix) { + 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(`${prefix}_failed_${status || 'error'}`); + err.status = status === 401 ? 401 : 502; + const detail = libErrorDetail(e); + if (detail) err.detail = detail; + if (e?.ytReason) err.ytReason = e.ytReason; + throw err; +} + +/** Node History/Playlist (Video, LockupView, PlaylistVideo...) -> entrée. */ +function mapHistoryNode(node, mapVideoNode) { + if (!node || typeof node !== 'object') return null; + if (String(node.type || '') === 'PlaylistVideo') { + const id = node.video_id ? String(node.video_id) : null; + const title = node.title?.text ?? (typeof node.title === 'string' ? node.title : ''); + if (!id || !title) return null; + const thumbs = Array.isArray(node.thumbnails) ? node.thumbnails.filter((t) => t?.url) : []; + return { + provider: 'youtube', + videoId: id, + title: String(title), + thumbnail: thumbs.length ? thumbs[thumbs.length - 1].url : '', + watchedAt: new Date().toISOString(), + }; + } + const mapped = mapVideoNode(node); + if (!mapped?.id) return null; + return { + provider: 'youtube', + videoId: mapped.id, + title: mapped.title || mapped.id, + thumbnail: mapped.thumbnail || '', + watchedAt: new Date().toISOString(), + }; +} + /** * Historique YouTube via youtubei.js authentifié (getHistory + continuations). * `conn` = { accessToken, refreshToken, expiresAt } (jeton déjà rafraîchi). @@ -658,16 +705,10 @@ export async function fetchInnerTubeHistory(conn, max = 200) { 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(), - }); + const v = mapHistoryNode(node, mapVideoNode); + if (!v || seen.has(v.videoId)) continue; + seen.add(v.videoId); + out.push(v); if (out.length >= max) break; } if (out.length >= max) break; @@ -678,16 +719,40 @@ export async function fetchInnerTubeHistory(conn, max = 200) { } return { items: out.slice(0, max), hasMore: Boolean(feed?.has_continuation) }; } catch (e) { - 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; + throwLibHistoryError(e, 'innertube_history'); + throw e; + } +} + +/** + * "Regarder plus tard" via youtubei.js authentifié (getPlaylist WL + continuations). + */ +export async function fetchInnerTubeWatchLaterViaLib(conn, max = 100) { + try { + const { mapVideoNode } = await import('./providers/youtube-innertube.mjs'); + const innertube = await getAuthedInnertube(conn); + const out = []; + const seen = new Set(); + let feed = await innertube.getPlaylist('WL'); + let guard = 0; + while (feed && out.length < max && guard < 10) { + guard++; + const items = Array.isArray(feed.items) ? feed.items : []; + for (const node of items) { + const v = mapHistoryNode(node, mapVideoNode); + if (!v || seen.has(v.videoId)) continue; + seen.add(v.videoId); + out.push(v); + 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), via: 'innertube' }; + } catch (e) { + throwLibHistoryError(e, 'innertube_watchlater'); + throw e; } } diff --git a/src/components/library/import/import.component.html b/src/components/library/import/import.component.html index fb2ba83..64517de 100644 --- a/src/components/library/import/import.component.html +++ b/src/components/library/import/import.component.html @@ -145,6 +145,7 @@
{{ wlError() }}
{{ wlNote() }}
+
Liste vide : aucune vidéo dans « Regarder plus tard » YouTube.
{{ 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 0211ef5..e65510f 100644 --- a/src/components/library/import/import.component.ts +++ b/src/components/library/import/import.component.ts @@ -158,7 +158,8 @@ export class ImportComponent implements OnInit { const code = e?.error?.error || e?.message || 'ythistory_fetch_failed'; const hint = e?.error?.hint; const ytReason = e?.error?.ytReason; - const extra = [hint, ytReason ? `YouTube: ${ytReason}` : ''].filter(Boolean).join(' — '); + const detail = e?.error?.detail; + const extra = [hint, ytReason ? `YouTube: ${ytReason}` : '', detail || ''].filter(Boolean).join(' — '); this.ytAutoError.set(extra ? `${code} — ${extra}` : code); } finally { this.ytAutoLoading.set(false); @@ -230,6 +231,7 @@ export class ImportComponent implements OnInit { wlItems = signal>([]); wlNote = signal(null); wlLoading = signal(false); + wlLoaded = signal(false); wlWriteGranted = signal(false); wlError = signal(null); wlImporting = signal(false); @@ -242,20 +244,24 @@ export class ImportComponent implements OnInit { loadWatchLater(): void { this.wlLoading.set(true); this.wlError.set(null); + this.wlLoaded.set(false); this.api.watchLater().subscribe({ 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); + this.wlLoaded.set(true); }, error: (e) => { const code = e?.error?.error || e?.message || 'watchlater_fetch_failed'; const hint = e?.error?.hint; const ytReason = e?.error?.ytReason; - const extra = [hint, ytReason ? `YouTube: ${ytReason}` : ''].filter(Boolean).join(' — '); + const detail = e?.error?.detail; + const extra = [hint, ytReason ? `YouTube: ${ytReason}` : '', detail || ''].filter(Boolean).join(' — '); this.wlError.set(extra ? `${code} — ${extra}` : code); this.wlLoading.set(false); + this.wlLoaded.set(true); }, }); }