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,
|
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
@@ -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);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user