fix(sync): historique/WL via youtubei.js authentifié + erreurs détaillées
This commit is contained in:
+5
-4
@@ -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.';
|
||||
}
|
||||
|
||||
+94
-29
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -145,6 +145,7 @@
|
||||
</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="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">
|
||||
{{ 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>
|
||||
|
||||
@@ -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<Array<{ videoId: string; title: string; thumbnail?: string }>>([]);
|
||||
wlNote = signal<string | null>(null);
|
||||
wlLoading = signal(false);
|
||||
wlLoaded = signal(false);
|
||||
wlWriteGranted = signal(false);
|
||||
wlError = signal<string | null>(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);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user