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