fix(sync): historique via youtubei.js authentifié (contexte officiel) + WL alias WL
This commit is contained in:
+5
-4
@@ -716,14 +716,15 @@ r.get('/oauth/google/yt-history', authMiddlewareCookieAware, oauthLimiter, async
|
||||
try {
|
||||
const conn = await freshOAuthToken(req.user.id, 'google');
|
||||
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 });
|
||||
} catch (error) {
|
||||
const out = { error: error?.message || 'ythistory_fetch_failed' };
|
||||
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.';
|
||||
} 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.';
|
||||
}
|
||||
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) {
|
||||
try {
|
||||
if (useInnertube) await pushInnerTubeWatchLater(conn.accessToken, innertubeApiKey(), videoId);
|
||||
else await pushGoogleWatchLater(conn.accessToken, videoId);
|
||||
else await pushGoogleWatchLater(conn.accessToken, videoId, innertubeApiKey());
|
||||
added++;
|
||||
} catch { skipped++; }
|
||||
}
|
||||
|
||||
+134
-19
@@ -371,9 +371,7 @@ export async function fetchGoogleWatchLaterId(accessToken) {
|
||||
return String(id);
|
||||
}
|
||||
|
||||
/** Contenu de "Regarder plus tard" (lecture seule OK). */
|
||||
export async function fetchGoogleWatchLater(accessToken, max = 50) {
|
||||
const playlistId = await fetchGoogleWatchLaterId(accessToken);
|
||||
async function fetchWatchLaterItems(accessToken, playlistId, max) {
|
||||
const out = [];
|
||||
let pageToken = '';
|
||||
while (out.length < max) {
|
||||
@@ -398,19 +396,53 @@ export async function fetchGoogleWatchLater(accessToken, max = 50) {
|
||||
pageToken = data?.nextPageToken || '';
|
||||
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) {
|
||||
const playlistId = await fetchGoogleWatchLaterId(accessToken);
|
||||
const data = await googleApiPost(accessToken, 'playlistItems?part=snippet', {
|
||||
snippet: {
|
||||
playlistId,
|
||||
resourceId: { kind: 'youtube#video', videoId: String(videoId) },
|
||||
},
|
||||
});
|
||||
return { playlistItemId: data?.id || null };
|
||||
/**
|
||||
* Contenu de "Regarder plus tard" (lecture seule OK).
|
||||
* 1) ID résolu via channels.list, 2) alias officiel "WL", 3) InnerTube.
|
||||
*/
|
||||
export async function fetchGoogleWatchLater(accessToken, max = 50) {
|
||||
try {
|
||||
const playlistId = await fetchGoogleWatchLaterId(accessToken);
|
||||
return { playlistId, items: await fetchWatchLaterItems(accessToken, playlistId, max), via: 'api' };
|
||||
} 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' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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é --------------------
|
||||
@@ -565,13 +597,96 @@ async function innertubeBrowseAll(accessToken, apiKey, browseId, max, label) {
|
||||
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 {
|
||||
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) {
|
||||
if (e?.message?.startsWith('innertube_history_failed_')) throw e;
|
||||
const err = new Error(`innertube_history_failed_${e?.status || 'error'}`);
|
||||
err.status = e?.status === 401 ? 401 : 502;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user