fix(sync): historique/WL via youtubei.js authentifié + erreurs détaillées

This commit is contained in:
2026-09-27 07:36:16 -04:00
parent 186294f518
commit 8edf9de0c6
4 changed files with 108 additions and 35 deletions
+5 -4
View File
@@ -106,7 +106,7 @@ import {
refreshAccessToken, redirectUriFor, fetchGoogleProfile, fetchGoogleSubscriptions, refreshAccessToken, redirectUriFor, fetchGoogleProfile, fetchGoogleSubscriptions,
fetchGoogleLiked, fetchTwitchProfile, fetchTwitchFollows, hasGoogleWriteScope, fetchGoogleLiked, fetchTwitchProfile, fetchTwitchFollows, hasGoogleWriteScope,
fetchGoogleWatchLater, pushGoogleWatchLater, fetchInnerTubeHistory, fetchGoogleWatchLater, pushGoogleWatchLater, fetchInnerTubeHistory,
fetchInnerTubeWatchLater, pushInnerTubeWatchLater, fetchGoogleWatchLaterId, fetchInnerTubeWatchLaterViaLib, pushInnerTubeWatchLater, fetchGoogleWatchLaterId,
} from './oauth.mjs'; } from './oauth.mjs';
import { getSearchMode, getYtDlpBin, hasCookiesFile, metricsSnapshot } from './providers/youtube-common.mjs'; import { getSearchMode, getYtDlpBin, hasCookiesFile, metricsSnapshot } from './providers/youtube-common.mjs';
import { ytScrapeCacheStats } from './providers/youtube.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' }); return res.json({ items, count: items.length, writeGranted, via: 'api' });
} catch (apiError) { } catch (apiError) {
// L'API Data ne renvoie pas d'ID WL pour certains comptes : repli // 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; if (apiError?.message !== 'watchlater_not_found' && apiError?.message !== 'youtube_no_channel') throw apiError;
try { 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 {} } catch {}
const { items } = await fetchInnerTubeWatchLater(conn.accessToken, innertubeApiKey(), 100); const { items } = await fetchInnerTubeWatchLaterViaLib(conn, 100);
return res.json({ return res.json({
items, count: items.length, writeGranted, via: 'innertube', items, count: items.length, writeGranted, via: 'innertube',
note: 'Liste lue via InnerTube (l’API YouTube ne l’expose pas pour ce compte).', 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' }; const out = { error: error?.message || 'watchlater_fetch_failed' };
if (error?.hint) out.hint = error.hint; if (error?.hint) out.hint = error.hint;
if (error?.ytReason) out.ytReason = error.ytReason; if (error?.ytReason) out.ytReason = error.ytReason;
if (error?.detail) out.detail = error.detail;
if (!out.hint && String(error?.ytReason || '').toLowerCase().includes('insufficientpermissions')) { 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.'; out.hint = 'Scope manquant : déconnectez puis reconnectez Google pour autoriser l’accès complet YouTube.';
} }
+94 -29
View File
@@ -400,8 +400,9 @@ async function fetchWatchLaterItems(accessToken, playlistId, max) {
} }
/** /**
* Contenu de "Regarder plus tard" (lecture seule OK). * Contenu de "Regarder plus tard" via Data API (lecture seule OK).
* 1) ID résolu via channels.list, 2) alias officiel "WL", 3) InnerTube. * 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) { export async function fetchGoogleWatchLater(accessToken, max = 50) {
try { try {
@@ -410,13 +411,7 @@ export async function fetchGoogleWatchLater(accessToken, max = 50) {
} catch (e) { } catch (e) {
if (e?.message !== 'watchlater_not_found' && e?.message !== 'youtube_no_channel') throw 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' };
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' };
} }
/** /**
@@ -640,6 +635,58 @@ async function getAuthedInnertube(conn) {
return innertube; 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). * Historique YouTube via youtubei.js authentifié (getHistory + continuations).
* `conn` = { accessToken, refreshToken, expiresAt } (jeton déjà rafraîchi). * `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) { for (const section of sections) {
const contents = Array.isArray(section?.contents) ? section.contents : []; const contents = Array.isArray(section?.contents) ? section.contents : [];
for (const node of contents) { for (const node of contents) {
const mapped = mapVideoNode(node); const v = mapHistoryNode(node, mapVideoNode);
if (!mapped?.id || seen.has(mapped.id)) continue; if (!v || seen.has(v.videoId)) continue;
seen.add(mapped.id); seen.add(v.videoId);
out.push({ out.push(v);
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 (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) }; return { items: out.slice(0, max), hasMore: Boolean(feed?.has_continuation) };
} catch (e) { } catch (e) {
if (e?.status === 404 || e?.status === 403 || e?.status === 401) throw e; throwLibHistoryError(e, 'innertube_history');
const msg = String(e?.message || ''); throw e;
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'}`); * "Regarder plus tard" via youtubei.js authentifié (getPlaylist WL + continuations).
err.status = status === 401 ? 401 : 502; */
if (e?.ytReason) err.ytReason = e.ytReason; export async function fetchInnerTubeWatchLaterViaLib(conn, max = 100) {
throw err; 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;
} }
} }
@@ -145,6 +145,7 @@
</div> </div>
<div *ngIf="wlError()" class="mt-2 rounded-lg border border-red-700 bg-red-900/30 p-2 text-xs text-red-200">{{ wlError() }}</div> <div *ngIf="wlError()" class="mt-2 rounded-lg border border-red-700 bg-red-900/30 p-2 text-xs text-red-200">{{ wlError() }}</div>
<div *ngIf="wlNote()" class="mt-2 rounded-lg border border-blue-700 bg-blue-900/20 p-2 text-xs text-blue-200">{{ wlNote() }}</div> <div *ngIf="wlNote()" class="mt-2 rounded-lg border border-blue-700 bg-blue-900/20 p-2 text-xs text-blue-200">{{ wlNote() }}</div>
<div *ngIf="wlLoaded() && !wlLoading() && !wlItems().length && !wlError()" class="mt-2 text-sm text-slate-400">Liste vide : aucune vidéo dans « Regarder plus tard » YouTube.</div>
<div *ngIf="wlItems().length" class="mt-2 text-sm text-slate-300"> <div *ngIf="wlItems().length" class="mt-2 text-sm text-slate-300">
{{ wlItems().length }} vidéo(s) dans « Regarder plus tard » YouTube. {{ wlItems().length }} vidéo(s) dans « Regarder plus tard » YouTube.
<span *ngIf="!wlWriteGranted()" class="text-amber-200">Écriture non autorisée : déconnectez puis reconnectez Google pour l’activer.</span> <span *ngIf="!wlWriteGranted()" class="text-amber-200">Écriture non autorisée : déconnectez puis reconnectez Google pour l’activer.</span>
@@ -158,7 +158,8 @@ export class ImportComponent implements OnInit {
const code = e?.error?.error || e?.message || 'ythistory_fetch_failed'; const code = e?.error?.error || e?.message || 'ythistory_fetch_failed';
const hint = e?.error?.hint; const hint = e?.error?.hint;
const ytReason = e?.error?.ytReason; 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); this.ytAutoError.set(extra ? `${code} — ${extra}` : code);
} finally { } finally {
this.ytAutoLoading.set(false); this.ytAutoLoading.set(false);
@@ -230,6 +231,7 @@ export class ImportComponent implements OnInit {
wlItems = signal<Array<{ videoId: string; title: string; thumbnail?: string }>>([]); wlItems = signal<Array<{ videoId: string; title: string; thumbnail?: string }>>([]);
wlNote = signal<string | null>(null); wlNote = signal<string | null>(null);
wlLoading = signal(false); wlLoading = signal(false);
wlLoaded = signal(false);
wlWriteGranted = signal(false); wlWriteGranted = signal(false);
wlError = signal<string | null>(null); wlError = signal<string | null>(null);
wlImporting = signal(false); wlImporting = signal(false);
@@ -242,20 +244,24 @@ export class ImportComponent implements OnInit {
loadWatchLater(): void { loadWatchLater(): void {
this.wlLoading.set(true); this.wlLoading.set(true);
this.wlError.set(null); this.wlError.set(null);
this.wlLoaded.set(false);
this.api.watchLater().subscribe({ this.api.watchLater().subscribe({
next: (res) => { next: (res) => {
this.wlItems.set(res?.items || []); this.wlItems.set(res?.items || []);
this.wlWriteGranted.set(!!res?.writeGranted); this.wlWriteGranted.set(!!res?.writeGranted);
this.wlNote.set(res?.note || (res?.via === 'innertube' ? 'Liste lue via InnerTube.' : null)); this.wlNote.set(res?.note || (res?.via === 'innertube' ? 'Liste lue via InnerTube.' : null));
this.wlLoading.set(false); this.wlLoading.set(false);
this.wlLoaded.set(true);
}, },
error: (e) => { error: (e) => {
const code = e?.error?.error || e?.message || 'watchlater_fetch_failed'; const code = e?.error?.error || e?.message || 'watchlater_fetch_failed';
const hint = e?.error?.hint; const hint = e?.error?.hint;
const ytReason = e?.error?.ytReason; 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.wlError.set(extra ? `${code} — ${extra}` : code);
this.wlLoading.set(false); this.wlLoading.set(false);
this.wlLoaded.set(true);
}, },
}); });
} }