fix(sync): historique via youtubei.js authentifié (contexte officiel) + WL alias WL

This commit is contained in:
2026-09-26 23:45:29 -04:00
parent 464d6f1b80
commit 186294f518
2 changed files with 139 additions and 23 deletions
+5 -4
View File
@@ -716,14 +716,15 @@ r.get('/oauth/google/yt-history', authMiddlewareCookieAware, oauthLimiter, async
try { try {
const conn = await freshOAuthToken(req.user.id, 'google'); const conn = await freshOAuthToken(req.user.id, 'google');
const max = Math.min(500, Math.max(1, Number(req.query.max || 200))); 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 }); return res.json({ items, count: items.length, hasMore });
} catch (error) { } catch (error) {
const out = { error: error?.message || 'ythistory_fetch_failed' }; const out = { error: error?.message || 'ythistory_fetch_failed' };
if (error?.ytReason) out.ytReason = error.ytReason; 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.'; 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.'; out.hint = 'Scope manquant : déconnectez puis reconnectez Google pour autoriser l’accès complet YouTube.';
} }
return res.status(error?.status || 502).json(out); 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) { for (const videoId of clean) {
try { try {
if (useInnertube) await pushInnerTubeWatchLater(conn.accessToken, innertubeApiKey(), videoId); if (useInnertube) await pushInnerTubeWatchLater(conn.accessToken, innertubeApiKey(), videoId);
else await pushGoogleWatchLater(conn.accessToken, videoId); else await pushGoogleWatchLater(conn.accessToken, videoId, innertubeApiKey());
added++; added++;
} catch { skipped++; } } catch { skipped++; }
} }
+134 -19
View File
@@ -371,9 +371,7 @@ export async function fetchGoogleWatchLaterId(accessToken) {
return String(id); return String(id);
} }
/** Contenu de "Regarder plus tard" (lecture seule OK). */ async function fetchWatchLaterItems(accessToken, playlistId, max) {
export async function fetchGoogleWatchLater(accessToken, max = 50) {
const playlistId = await fetchGoogleWatchLaterId(accessToken);
const out = []; const out = [];
let pageToken = ''; let pageToken = '';
while (out.length < max) { while (out.length < max) {
@@ -398,19 +396,53 @@ export async function fetchGoogleWatchLater(accessToken, max = 50) {
pageToken = data?.nextPageToken || ''; pageToken = data?.nextPageToken || '';
if (!pageToken) break; 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) { * Contenu de "Regarder plus tard" (lecture seule OK).
const playlistId = await fetchGoogleWatchLaterId(accessToken); * 1) ID résolu via channels.list, 2) alias officiel "WL", 3) InnerTube.
const data = await googleApiPost(accessToken, 'playlistItems?part=snippet', { */
snippet: { export async function fetchGoogleWatchLater(accessToken, max = 50) {
playlistId, try {
resourceId: { kind: 'youtube#video', videoId: String(videoId) }, const playlistId = await fetchGoogleWatchLaterId(accessToken);
}, return { playlistId, items: await fetchWatchLaterItems(accessToken, playlistId, max), via: 'api' };
}); } catch (e) {
return { playlistItemId: data?.id || null }; 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é -------------------- // -------------------- 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) }; 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 { 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) { } catch (e) {
if (e?.message?.startsWith('innertube_history_failed_')) throw e; if (e?.status === 404 || e?.status === 403 || e?.status === 401) throw e;
const err = new Error(`innertube_history_failed_${e?.status || 'error'}`); const msg = String(e?.message || '');
err.status = e?.status === 401 ? 401 : 502; 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; throw err;
} }
} }