983 lines
36 KiB
JavaScript
983 lines
36 KiB
JavaScript
/**
|
||
* OAuth Google / Twitch pour l'import des favoris et abonnements.
|
||
*
|
||
* - Google (YouTube) : scopes `youtube.readonly` (+ profil). Importe les
|
||
* abonnements (`subscriptions.list?mine=true`) et les favoris
|
||
* (`videos.list?myRating=like`).
|
||
* - Twitch : scopes `user:read:follows user:read:subscriptions`. Importe les
|
||
* chaînes suivies (`/helix/users/follows?from_id=`). Twitch n'a pas de
|
||
* notion de "like" vidéo : seul l'import abonnements est proposé.
|
||
*
|
||
* Les tokens sont stockés en SQLite (instance locale / auto-hébergée).
|
||
* Ne jamais logger access_token / refresh_token / client_secret.
|
||
*/
|
||
|
||
const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
|
||
const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
|
||
const TWITCH_AUTH_URL = 'https://id.twitch.tv/oauth2/authorize';
|
||
const TWITCH_TOKEN_URL = 'https://id.twitch.tv/oauth2/token';
|
||
|
||
const GOOGLE_SCOPES = [
|
||
'openid',
|
||
'https://www.googleapis.com/auth/userinfo.profile',
|
||
'https://www.googleapis.com/auth/youtube.readonly',
|
||
// Écriture Watch Later (playlistItems.insert/delete). Lecture seule
|
||
// (abos, likes, WL) fonctionne sans lui ; le push exige un re-consentement.
|
||
'https://www.googleapis.com/auth/youtube.force-ssl',
|
||
// InnerTube authentifié (historique FEhistory, playlist WL) exige le scope
|
||
// complet : avec readonly seul, YouTube répond 403 insufficientPermissions.
|
||
// Les connexions existantes doivent se reconnecter pour l'obtenir.
|
||
'https://www.googleapis.com/auth/youtube',
|
||
].join(' ');
|
||
|
||
/** Le jeton stocké permet-il l'écriture (push Watch Later) ? */
|
||
export function hasGoogleWriteScope(scopes) {
|
||
const tokens = String(scopes || '').split(/\s+/).filter(Boolean);
|
||
return tokens.some((t) => t === 'https://www.googleapis.com/auth/youtube.force-ssl' || t === 'https://www.googleapis.com/auth/youtube');
|
||
}
|
||
|
||
const TWITCH_SCOPES = ['user:read:follows', 'user:read:subscriptions'].join(' ');
|
||
|
||
// state -> { userId, provider, createdAt } (mémoire, 10 min).
|
||
const pendingStates = new Map();
|
||
|
||
function randomState() {
|
||
try {
|
||
const { randomBytes } = require('node:crypto');
|
||
return randomBytes(16).toString('hex');
|
||
} catch {}
|
||
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`;
|
||
}
|
||
|
||
export function createOAuthState(userId, provider, purpose = 'link') {
|
||
const state = randomState();
|
||
pendingStates.set(state, {
|
||
userId: userId == null ? null : String(userId),
|
||
provider: String(provider),
|
||
purpose: String(purpose || 'link'),
|
||
createdAt: Date.now(),
|
||
});
|
||
// Purge opportuniste.
|
||
try {
|
||
const now = Date.now();
|
||
for (const [k, v] of pendingStates) {
|
||
if (now - v.createdAt > 10 * 60 * 1000) pendingStates.delete(k);
|
||
}
|
||
} catch {}
|
||
return state;
|
||
}
|
||
|
||
export function consumeOAuthState(state) {
|
||
const entry = pendingStates.get(String(state || ''));
|
||
if (!entry) return null;
|
||
pendingStates.delete(String(state));
|
||
if (Date.now() - entry.createdAt > 10 * 60 * 1000) return null;
|
||
return entry;
|
||
}
|
||
|
||
export function oauthStatus() {
|
||
const googleId = String(process.env.GOOGLE_CLIENT_ID || '').trim();
|
||
const googleSecret = String(process.env.GOOGLE_CLIENT_SECRET || '').trim();
|
||
const twitchId = String(process.env.TWITCH_CLIENT_ID || '').trim();
|
||
const twitchSecret = String(process.env.TWITCH_CLIENT_SECRET || '').trim();
|
||
return {
|
||
google: {
|
||
configured: Boolean(googleId && googleSecret),
|
||
missing: [...(!googleId ? ['GOOGLE_CLIENT_ID'] : []), ...(!googleSecret ? ['GOOGLE_CLIENT_SECRET'] : [])],
|
||
},
|
||
twitch: {
|
||
configured: Boolean(twitchId && twitchSecret),
|
||
missing: [...(!twitchId ? ['TWITCH_CLIENT_ID'] : []), ...(!twitchSecret ? ['TWITCH_CLIENT_SECRET'] : [])],
|
||
},
|
||
};
|
||
}
|
||
|
||
/** Base publique de l'app (pour la redirect_uri). Priorité au .env explicite. */
|
||
export function appBaseUrl(req) {
|
||
const explicit = String(process.env.OAUTH_APP_BASE_URL || '').trim().replace(/\/+$/, '');
|
||
if (explicit) return explicit;
|
||
try {
|
||
const proto = String(req?.headers?.['x-forwarded-proto'] || req?.protocol || 'http').split(',')[0].trim() || 'http';
|
||
const host = String(req?.headers?.['x-forwarded-host'] || req?.headers?.host || req?.get?.('host') || '').trim();
|
||
if (host) return `${proto}://${host}`;
|
||
} catch {}
|
||
return 'http://localhost:4200';
|
||
}
|
||
|
||
export function redirectUriFor(provider, req) {
|
||
const p = String(provider);
|
||
if (p === 'google') {
|
||
const explicit = String(process.env.GOOGLE_REDIRECT_URI || '').trim();
|
||
if (explicit) return explicit;
|
||
}
|
||
if (p === 'twitch') {
|
||
const explicit = String(process.env.TWITCH_REDIRECT_URI || '').trim();
|
||
if (explicit) return explicit;
|
||
}
|
||
return `${appBaseUrl(req).replace(/\/+$/, '')}/api/oauth/${p}/callback`;
|
||
}
|
||
|
||
export function buildAuthUrl(provider, state, req) {
|
||
const p = String(provider);
|
||
if (p === 'google') {
|
||
const clientId = String(process.env.GOOGLE_CLIENT_ID || '').trim();
|
||
if (!clientId) throw Object.assign(new Error('google_oauth_not_configured'), { status: 503 });
|
||
const qs = new URLSearchParams({
|
||
client_id: clientId,
|
||
redirect_uri: redirectUriFor('google', req),
|
||
response_type: 'code',
|
||
scope: GOOGLE_SCOPES,
|
||
access_type: 'offline',
|
||
prompt: 'consent',
|
||
state,
|
||
});
|
||
return `${GOOGLE_AUTH_URL}?${qs.toString()}`;
|
||
}
|
||
if (p === 'twitch') {
|
||
const clientId = String(process.env.TWITCH_CLIENT_ID || '').trim();
|
||
if (!clientId) throw Object.assign(new Error('twitch_oauth_not_configured'), { status: 503 });
|
||
const qs = new URLSearchParams({
|
||
client_id: clientId,
|
||
redirect_uri: redirectUriFor('twitch', req),
|
||
response_type: 'code',
|
||
scope: TWITCH_SCOPES,
|
||
state,
|
||
});
|
||
return `${TWITCH_AUTH_URL}?${qs.toString()}`;
|
||
}
|
||
throw Object.assign(new Error('invalid_oauth_provider'), { status: 400 });
|
||
}
|
||
|
||
async function postForm(url, params) {
|
||
const body = new URLSearchParams(params);
|
||
const resp = await fetch(url, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body: body.toString(),
|
||
});
|
||
const data = await resp.json().catch(() => ({}));
|
||
if (!resp.ok) {
|
||
const err = new Error(`oauth_token_failed_${resp.status}`);
|
||
err.status = 502;
|
||
err.details = data;
|
||
throw err;
|
||
}
|
||
return data;
|
||
}
|
||
|
||
export async function exchangeCode(provider, code, req) {
|
||
const p = String(provider);
|
||
if (p === 'google') {
|
||
const data = await postForm(GOOGLE_TOKEN_URL, {
|
||
code: String(code),
|
||
client_id: String(process.env.GOOGLE_CLIENT_ID || ''),
|
||
client_secret: String(process.env.GOOGLE_CLIENT_SECRET || ''),
|
||
redirect_uri: redirectUriFor('google', req),
|
||
grant_type: 'authorization_code',
|
||
});
|
||
return {
|
||
accessToken: data.access_token,
|
||
refreshToken: data.refresh_token || null,
|
||
expiresAt: data.expires_in ? Date.now() + Number(data.expires_in) * 1000 : null,
|
||
scopes: data.scope || GOOGLE_SCOPES,
|
||
};
|
||
}
|
||
if (p === 'twitch') {
|
||
const data = await postForm(TWITCH_TOKEN_URL, {
|
||
code: String(code),
|
||
client_id: String(process.env.TWITCH_CLIENT_ID || ''),
|
||
client_secret: String(process.env.TWITCH_CLIENT_SECRET || ''),
|
||
redirect_uri: redirectUriFor('twitch', req),
|
||
grant_type: 'authorization_code',
|
||
});
|
||
return {
|
||
accessToken: data.access_token,
|
||
refreshToken: data.refresh_token || null,
|
||
expiresAt: data.expires_in ? Date.now() + Number(data.expires_in) * 1000 : null,
|
||
scopes: Array.isArray(data.scope) ? data.scope.join(' ') : TWITCH_SCOPES,
|
||
};
|
||
}
|
||
throw Object.assign(new Error('invalid_oauth_provider'), { status: 400 });
|
||
}
|
||
|
||
export async function refreshAccessToken(provider, refreshToken) {
|
||
const p = String(provider);
|
||
if (p === 'google') {
|
||
const data = await postForm(GOOGLE_TOKEN_URL, {
|
||
refresh_token: String(refreshToken),
|
||
client_id: String(process.env.GOOGLE_CLIENT_ID || ''),
|
||
client_secret: String(process.env.GOOGLE_CLIENT_SECRET || ''),
|
||
grant_type: 'refresh_token',
|
||
});
|
||
return {
|
||
accessToken: data.access_token,
|
||
refreshToken: data.refresh_token || refreshToken,
|
||
expiresAt: data.expires_in ? Date.now() + Number(data.expires_in) * 1000 : null,
|
||
};
|
||
}
|
||
if (p === 'twitch') {
|
||
const data = await postForm(TWITCH_TOKEN_URL, {
|
||
refresh_token: String(refreshToken),
|
||
client_id: String(process.env.TWITCH_CLIENT_ID || ''),
|
||
client_secret: String(process.env.TWITCH_CLIENT_SECRET || ''),
|
||
grant_type: 'refresh_token',
|
||
});
|
||
return {
|
||
accessToken: data.access_token,
|
||
refreshToken: data.refresh_token || refreshToken,
|
||
expiresAt: data.expires_in ? Date.now() + Number(data.expires_in) * 1000 : null,
|
||
};
|
||
}
|
||
throw Object.assign(new Error('invalid_oauth_provider'), { status: 400 });
|
||
}
|
||
|
||
async function getJson(url, accessToken, extraHeaders = {}) {
|
||
const resp = await fetch(url, {
|
||
headers: { Authorization: `Bearer ${accessToken}`, ...extraHeaders },
|
||
});
|
||
const data = await resp.json().catch(() => ({}));
|
||
if (!resp.ok) {
|
||
const err = new Error(`oauth_api_failed_${resp.status}`);
|
||
err.status = resp.status === 401 ? 401 : 502;
|
||
err.details = data;
|
||
throw err;
|
||
}
|
||
return data;
|
||
}
|
||
|
||
// -------------------- Google (YouTube) --------------------
|
||
|
||
export async function fetchGoogleProfile(accessToken) {
|
||
const data = await getJson('https://www.googleapis.com/oauth2/v2/userinfo', accessToken);
|
||
return {
|
||
id: String(data.id || ''),
|
||
email: String(data.email || ''),
|
||
verifiedEmail: data.verified_email !== false,
|
||
displayName: String(data.name || data.email || 'Google'),
|
||
avatarUrl: String(data.picture || ''),
|
||
};
|
||
}
|
||
|
||
export async function fetchGoogleSubscriptions(accessToken, max = 1000) {
|
||
const out = [];
|
||
let total = 0;
|
||
let pageToken = '';
|
||
while (out.length < max) {
|
||
const qs = new URLSearchParams({
|
||
part: 'snippet',
|
||
mine: 'true',
|
||
maxResults: String(Math.min(50, max - out.length)),
|
||
order: 'alphabetical',
|
||
});
|
||
if (pageToken) qs.set('pageToken', pageToken);
|
||
const data = await getJson(`https://www.googleapis.com/youtube/v3/subscriptions?${qs.toString()}`, accessToken);
|
||
if (!total && typeof data?.pageInfo?.totalResults === 'number') total = data.pageInfo.totalResults;
|
||
for (const item of Array.isArray(data?.items) ? data.items : []) {
|
||
const sn = item?.snippet || {};
|
||
const channelId = sn?.resourceId?.channelId || '';
|
||
if (!channelId) continue;
|
||
out.push({
|
||
provider: 'youtube',
|
||
externalId: channelId,
|
||
title: sn?.title || channelId,
|
||
handle: null,
|
||
avatarUrl: sn?.thumbnails?.default?.url || sn?.thumbnails?.medium?.url || null,
|
||
url: `https://www.youtube.com/channel/${channelId}`,
|
||
});
|
||
}
|
||
pageToken = data?.nextPageToken || '';
|
||
if (!pageToken) break;
|
||
}
|
||
return { items: out, total: total || out.length };
|
||
}
|
||
|
||
export async function fetchGoogleLiked(accessToken, max = 500) {
|
||
const out = [];
|
||
let total = 0;
|
||
let pageToken = '';
|
||
while (out.length < max) {
|
||
const qs = new URLSearchParams({
|
||
part: 'snippet,contentDetails',
|
||
myRating: 'like',
|
||
maxResults: String(Math.min(50, max - out.length)),
|
||
});
|
||
if (pageToken) qs.set('pageToken', pageToken);
|
||
const data = await getJson(`https://www.googleapis.com/youtube/v3/videos?${qs.toString()}`, accessToken);
|
||
if (!total && typeof data?.pageInfo?.totalResults === 'number') total = data.pageInfo.totalResults;
|
||
for (const item of Array.isArray(data?.items) ? data.items : []) {
|
||
const id = item?.id || '';
|
||
const sn = item?.snippet || {};
|
||
if (!id) continue;
|
||
out.push({
|
||
provider: 'youtube',
|
||
videoId: String(id),
|
||
title: sn?.title || '',
|
||
thumbnail: sn?.thumbnails?.medium?.url || sn?.thumbnails?.default?.url || '',
|
||
});
|
||
}
|
||
pageToken = data?.nextPageToken || '';
|
||
if (!pageToken) break;
|
||
}
|
||
return { items: out, total: total || out.length };
|
||
}
|
||
|
||
// -------------------- Google : Watch Later --------------------
|
||
|
||
async function googleApiGet(accessToken, path, params = {}) {
|
||
const qs = new URLSearchParams();
|
||
for (const [k, v] of Object.entries(params)) {
|
||
if (v !== undefined && v !== null && v !== '') qs.set(k, String(v));
|
||
}
|
||
return getJson(`https://www.googleapis.com/youtube/v3/${path}?${qs.toString()}`, accessToken);
|
||
}
|
||
|
||
async function googleApiPost(accessToken, path, body) {
|
||
const resp = await fetch(`https://www.googleapis.com/youtube/v3/${path}`, {
|
||
method: 'POST',
|
||
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body || {}),
|
||
});
|
||
const data = await resp.json().catch(() => ({}));
|
||
if (!resp.ok) {
|
||
const err = new Error(`oauth_api_failed_${resp.status}`);
|
||
err.status = resp.status === 401 ? 401 : resp.status === 403 ? 403 : 502;
|
||
err.details = data;
|
||
throw err;
|
||
}
|
||
return data;
|
||
}
|
||
|
||
/** Chaîne résolue par `mine=true` pour ce token (id + titre, pour comparer). */
|
||
export async function fetchMineChannel(accessToken) {
|
||
const data = await googleApiGet(accessToken, 'channels', { part: 'id,snippet,contentDetails', mine: 'true' });
|
||
const item = Array.isArray(data?.items) ? data.items[0] : null;
|
||
if (!item) return { id: null, title: null, relatedKeys: [] };
|
||
return {
|
||
id: item.id || null,
|
||
title: item.snippet?.title || null,
|
||
relatedKeys: Object.keys(item?.contentDetails?.relatedPlaylists || {}),
|
||
};
|
||
}
|
||
|
||
/** ID de la playlist "Regarder plus tard" du compte (lecture seule OK). */
|
||
export async function fetchGoogleWatchLaterId(accessToken) {
|
||
const data = await googleApiGet(accessToken, 'channels', { part: 'contentDetails', mine: 'true' });
|
||
const item = Array.isArray(data?.items) ? data.items[0] : null;
|
||
if (!item) {
|
||
throw Object.assign(new Error('youtube_no_channel'), {
|
||
status: 502,
|
||
hint: 'Ce compte Google n’a pas de chaîne YouTube : créez-en une sur youtube.com pour utiliser Regarder plus tard.',
|
||
});
|
||
}
|
||
const id = item?.contentDetails?.relatedPlaylists?.watchLater || '';
|
||
if (!id) {
|
||
// Diagnostic sans PII : quelles playlists liées YouTube expose-t-il ?
|
||
try {
|
||
console.warn('[oauth] watchLater absent, relatedPlaylists =', Object.keys(item?.contentDetails?.relatedPlaylists || {}).join(',') || '(vide)');
|
||
} catch {}
|
||
throw Object.assign(new Error('watchlater_not_found'), {
|
||
status: 502,
|
||
hint: 'YouTube ne renvoie pas de playlist « Regarder plus tard » pour ce compte (compte de marque sans chaîne principale ?). Les abonnements et favoris restent importables.',
|
||
});
|
||
}
|
||
return String(id);
|
||
}
|
||
|
||
async function fetchWatchLaterItems(accessToken, playlistId, max) {
|
||
const out = [];
|
||
let pageToken = '';
|
||
while (out.length < max) {
|
||
const data = await googleApiGet(accessToken, 'playlistItems', {
|
||
part: 'snippet,contentDetails',
|
||
playlistId,
|
||
maxResults: Math.min(50, max - out.length),
|
||
...(pageToken ? { pageToken } : {}),
|
||
});
|
||
for (const item of Array.isArray(data?.items) ? data.items : []) {
|
||
const vid = item?.contentDetails?.videoId || item?.snippet?.resourceId?.videoId || '';
|
||
if (!vid) continue;
|
||
const sn = item?.snippet || {};
|
||
out.push({
|
||
provider: 'youtube',
|
||
videoId: String(vid),
|
||
title: sn?.title || '',
|
||
thumbnail: sn?.thumbnails?.medium?.url || sn?.thumbnails?.default?.url || '',
|
||
playlistItemId: item?.id || null,
|
||
});
|
||
}
|
||
pageToken = data?.nextPageToken || '';
|
||
if (!pageToken) break;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* 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 {
|
||
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;
|
||
}
|
||
return { playlistId: 'WL', items: await fetchWatchLaterItems(accessToken, 'WL', max), via: 'api-alias' };
|
||
}
|
||
|
||
/**
|
||
* 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é --------------------
|
||
// L'API Data v3 n'expose pas l'historique : on passe par youtubei/v1/browse
|
||
// (browseId FEhistory) avec le Bearer OAuth de l'utilisateur — même mécanisme
|
||
// que SmartTube. Lecture seule, pas de scope supplémentaire.
|
||
|
||
function pickThumb(thumbnails) {
|
||
const list = Array.isArray(thumbnails) ? thumbnails : [];
|
||
let best = null;
|
||
for (const t of list) {
|
||
if (!t?.url) continue;
|
||
if (!best || Number(t.width || 0) > Number(best.width || 0)) best = t;
|
||
}
|
||
return best?.url || '';
|
||
}
|
||
|
||
function runsText(runs) {
|
||
try {
|
||
return (Array.isArray(runs) ? runs : []).map((r) => r?.text || '').join('');
|
||
} catch { return ''; }
|
||
}
|
||
|
||
function extractHistoryEntries(node, out) {
|
||
if (!node || typeof node !== 'object') return;
|
||
if (Array.isArray(node)) {
|
||
for (const e of node) extractHistoryEntries(e, out);
|
||
return;
|
||
}
|
||
if (node.videoRenderer?.videoId) {
|
||
const vr = node.videoRenderer;
|
||
out.push({
|
||
provider: 'youtube',
|
||
videoId: String(vr.videoId),
|
||
title: runsText(vr.title?.runs) || String(vr.title?.simpleText || vr.videoId),
|
||
thumbnail: pickThumb(vr.thumbnail?.thumbnails),
|
||
watchedAt: new Date().toISOString(),
|
||
});
|
||
return;
|
||
}
|
||
for (const v of Object.values(node)) {
|
||
if (v && typeof v === 'object') extractHistoryEntries(v, out);
|
||
}
|
||
}
|
||
|
||
function findHistoryContinuation(data) {
|
||
try {
|
||
const tabs = data?.contents?.singleColumnBrowseResultsRenderer?.tabs || [];
|
||
const stack = [...tabs];
|
||
while (stack.length) {
|
||
const n = stack.pop();
|
||
if (!n || typeof n !== 'object') continue;
|
||
if (Array.isArray(n)) { stack.push(...n); continue; }
|
||
const token = n?.continuationItemRenderer?.continuationEndpoint?.continuationCommand?.token;
|
||
if (token) return String(token);
|
||
for (const v of Object.values(n)) {
|
||
if (v && typeof v === 'object') stack.push(v);
|
||
}
|
||
}
|
||
} catch {}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Appel youtubei/v1/* authentifié (Bearer OAuth utilisateur).
|
||
* La clé YOUTUBE_API_KEY est souvent restreinte par referrer HTTP : l'appel
|
||
* serveur (sans Referer) est alors rejeté en 403. On envoie un Referer
|
||
* navigateur et on bascule sur la clé publique du client web en repli.
|
||
*/
|
||
const PUBLIC_INNERTUBE_KEY = 'AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8';
|
||
|
||
function innertubeKeys(apiKey) {
|
||
const keys = [String(apiKey || '').trim(), PUBLIC_INNERTUBE_KEY].filter(Boolean);
|
||
return [...new Set(keys)];
|
||
}
|
||
|
||
async function innertubePost(path, apiKey, accessToken, body, label = 'innertube') {
|
||
const keys = innertubeKeys(apiKey);
|
||
if (!keys.length) throw Object.assign(new Error('youtube_api_key_unavailable'), { status: 503 });
|
||
let lastErr = null;
|
||
for (const key of keys) {
|
||
try {
|
||
const resp = await fetch(`https://www.youtube.com/youtubei/v1/${path}?key=${encodeURIComponent(key)}&prettyPrint=false`, {
|
||
method: 'POST',
|
||
headers: {
|
||
Authorization: `Bearer ${accessToken}`,
|
||
'Content-Type': 'application/json',
|
||
// Sans Referer/UA navigateur, les clés restreintes et l'anti-bot répondent 403.
|
||
Referer: 'https://www.youtube.com/',
|
||
Origin: 'https://www.youtube.com',
|
||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36',
|
||
},
|
||
body: JSON.stringify(body),
|
||
signal: AbortSignal.timeout(20000),
|
||
});
|
||
const data = await resp.json().catch(() => ({}));
|
||
if (!resp.ok) {
|
||
const err = new Error(`${label}_failed_${resp.status}`);
|
||
err.status = resp.status === 401 ? 401 : resp.status;
|
||
// Motif YouTube (sans PII) pour diagnostiquer : ex. rateLimitExceeded,
|
||
// botGuard, authError... remonte jusqu'à l'UI.
|
||
try {
|
||
const e0 = data?.error?.errors?.[0] || {};
|
||
err.ytReason = String(e0.reason || data?.error?.message || '').slice(0, 160) || undefined;
|
||
} catch {}
|
||
throw err;
|
||
}
|
||
return data;
|
||
} catch (e) {
|
||
lastErr = e;
|
||
// 403 = clé rejetée (restriction referrer) ou IP filtrée : on essaie la clé suivante.
|
||
// 401 = token invalide : inutile de réessayer.
|
||
if (e?.status === 401) throw e;
|
||
if (e?.status !== 403) throw e;
|
||
}
|
||
}
|
||
throw lastErr || new Error(`${label}_failed`);
|
||
}
|
||
|
||
function innertubeContext() {
|
||
// Version du client WEB alignée sur youtubei.js (dépendance du projet) :
|
||
// une version inconnue/inventée est rejetée en 403 par l'anti-bot.
|
||
return { client: { clientName: 'WEB', clientVersion: '2.20260623.01.00', hl: 'fr', gl: 'FR' } };
|
||
}
|
||
|
||
/** Boucle browse + continuations générique (FEhistory, WL, ...). */
|
||
async function innertubeBrowseAll(accessToken, apiKey, browseId, max, label) {
|
||
const out = [];
|
||
const seen = new Set();
|
||
let continuation = null;
|
||
let pages = 0;
|
||
while (out.length < max && pages < 10) {
|
||
pages++;
|
||
const body = continuation
|
||
? { context: innertubeContext(), continuation }
|
||
: { context: innertubeContext(), browseId };
|
||
const data = await innertubePost('browse', apiKey, accessToken, body, label);
|
||
const batch = [];
|
||
const root = continuation
|
||
? (data?.onResponseReceivedActions || data?.continuationContents)
|
||
: data;
|
||
extractHistoryEntries(root, batch);
|
||
for (const v of batch) {
|
||
if (seen.has(v.videoId)) continue;
|
||
seen.add(v.videoId);
|
||
out.push(v);
|
||
if (out.length >= max) break;
|
||
}
|
||
continuation = findHistoryContinuation(data);
|
||
if (!continuation) break;
|
||
}
|
||
return { items: out.slice(0, max), hasMore: Boolean(continuation) };
|
||
}
|
||
|
||
let ytLibNoiseSilenced = false;
|
||
async function silenceYoutubeiNoise() {
|
||
if (ytLibNoiseSilenced) return;
|
||
ytLibNoiseSilenced = true;
|
||
try {
|
||
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, { pageId } = {}) {
|
||
const accessToken = String(conn?.accessToken || '');
|
||
const refreshToken = String(conn?.refreshToken || '');
|
||
const targetPageId = String(pageId || conn?.ytPageId || '').trim() || null;
|
||
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');
|
||
// targetPageId = chaîne/brand secondaire : on_behalf_of_user fait envoyer
|
||
// X-Goog-PageId par youtubei.js (sélecteur de chaîne façon SmartTube).
|
||
const innertube = await Innertube.create({
|
||
lang: 'fr',
|
||
location: 'FR',
|
||
...(targetPageId ? { on_behalf_of_user: targetPageId } : {}),
|
||
});
|
||
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;
|
||
}
|
||
|
||
/** Détail d'erreur YouTube exposable (clé/token masqués, 500 car. max). */
|
||
function libErrorDetail(e) {
|
||
try {
|
||
// youtubei.js met le corps de réponse dans e.info (JSON d'erreur Google).
|
||
let raw = '';
|
||
if (e?.info && typeof e.info === 'object') {
|
||
try { raw = JSON.stringify(e.info); } catch { raw = String(e.info); }
|
||
} else if (typeof e?.info === 'string') {
|
||
raw = e.info;
|
||
}
|
||
const combined = [raw, String(e?.message || '')].filter(Boolean).join(' | ');
|
||
// Extrait le motif Google s'il existe : {"error":{"errors":[{"reason":"X"}]}}.
|
||
let reason = '';
|
||
try {
|
||
const parsed = JSON.parse(raw);
|
||
const first = parsed?.error?.errors?.[0];
|
||
if (first?.reason) reason = `reason=${first.reason}; msg=${String(first.message || parsed?.error?.message || '').slice(0, 200)}`;
|
||
} catch {}
|
||
const out = (reason || combined)
|
||
.replace(/key=[^&\s]*/gi, 'key=[redacted]')
|
||
.replace(/Bearer\s+[A-Za-z0-9._~-]+/gi, 'Bearer [redacted]')
|
||
.slice(0, 500);
|
||
return out || 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(),
|
||
};
|
||
}
|
||
|
||
function textOf(t) {
|
||
if (!t) return '';
|
||
if (typeof t === 'string') return t;
|
||
if (Array.isArray(t?.runs)) return t.runs.map((r) => r?.text || '').join('');
|
||
return String(t?.simpleText || '');
|
||
}
|
||
|
||
/**
|
||
* Diagnostic InnerTube authentifié : teste plusieurs endpoints et rapporte
|
||
* pour chacun ok/count ou le motif YouTube. But : isoler un rejet global
|
||
* du compte d'un problème spécifique à l'historique.
|
||
*/
|
||
export async function diagnoseInnertube(conn) {
|
||
const out = {};
|
||
let innertube = null;
|
||
try {
|
||
innertube = await getAuthedInnertube(conn);
|
||
out.session = { ok: true };
|
||
} catch (e) {
|
||
out.session = { ok: false, reason: String(e?.message || e).slice(0, 200) };
|
||
return out;
|
||
}
|
||
const probe = async (name, fn) => {
|
||
try {
|
||
const r = await fn();
|
||
out[name] = { ok: true, ...r };
|
||
} catch (e) {
|
||
out[name] = { ok: false, reason: libErrorDetail(e) };
|
||
}
|
||
};
|
||
await probe('subscriptionsFeed', async () => {
|
||
const feed = await innertube.getSubscriptionsFeed();
|
||
return { count: Array.isArray(feed?.contents) ? feed.contents.length : 0 };
|
||
});
|
||
await probe('library', async () => {
|
||
await innertube.getLibrary();
|
||
return {};
|
||
});
|
||
await probe('accountMenu', async () => {
|
||
const data = await innertube.session.actions.execute('/account/account_menu', {});
|
||
const blob = JSON.stringify(data?.data || data || '').slice(0, 200);
|
||
return { sample: blob.slice(0, 120) };
|
||
});
|
||
await probe('history', async () => {
|
||
const feed = await innertube.getHistory();
|
||
const n = Array.isArray(feed?.sections) ? feed.sections.reduce((a, s) => a + (s?.contents?.length || 0), 0) : 0;
|
||
return { count: n };
|
||
});
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* Chaînes YouTube du compte (sélecteur façon SmartTube) via account_menu.
|
||
* La chaîne par défaut peut être une coquille vide (relatedPlaylists sans
|
||
* watchLater/history) alors que l'activité est sur une chaîne secondaire.
|
||
*/
|
||
export async function fetchAccountChannels(conn) {
|
||
try {
|
||
const innertube = await getAuthedInnertube(conn);
|
||
const data = await innertube.session.actions.execute('/account/account_menu', {});
|
||
const found = new Map();
|
||
const visit = (node, depth = 0) => {
|
||
if (!node || typeof node !== 'object' || depth > 12) return;
|
||
if (Array.isArray(node)) {
|
||
for (const e of node) visit(e, depth + 1);
|
||
return;
|
||
}
|
||
// accountItem : nom + avatar (+ handle/canal à proximité).
|
||
const name = textOf(node.accountName).trim();
|
||
if (name) {
|
||
const blob = JSON.stringify(node).slice(0, 4000);
|
||
const ch = /UC[A-Za-z0-9_-]{20,}/.exec(blob)?.[0] || '';
|
||
const handle = /@[A-Za-z0-9._-]{3,}/.exec(textOf(node.accountByline) + ' ' + blob)?.[0] || '';
|
||
const key = ch || name.toLowerCase();
|
||
if (!found.has(key)) {
|
||
found.set(key, {
|
||
channelId: ch || null,
|
||
title: name,
|
||
handle: handle || null,
|
||
selected: Boolean(node.isSelected || node.isDefault),
|
||
});
|
||
}
|
||
}
|
||
for (const v of Object.values(node)) {
|
||
if (v && typeof v === 'object') visit(v, depth + 1);
|
||
}
|
||
};
|
||
visit(data?.data || data);
|
||
return { channels: [...found.values()].slice(0, 10) };
|
||
} catch (e) {
|
||
throwLibHistoryError(e, 'yt_channels');
|
||
throw e;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Historique YouTube via youtubei.js authentifié (getHistory + continuations).
|
||
* `conn` = { accessToken, refreshToken, expiresAt, ytPageId? } (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 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 (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) {
|
||
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;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* "Regarder plus tard" via InnerTube (browseId WL logique) : repli quand
|
||
* l'API Data ne renvoie pas d'ID (comptes de marque...). Pas d'ID requis.
|
||
*/
|
||
export async function fetchInnerTubeWatchLater(accessToken, apiKey, max = 100) {
|
||
try {
|
||
const { items, hasMore } = await innertubeBrowseAll(accessToken, apiKey, 'WL', max, 'innertube_watchlater');
|
||
return { items, hasMore, via: 'innertube' };
|
||
} catch (e) {
|
||
if (e?.message?.startsWith('innertube_watchlater_failed_')) throw e;
|
||
const err = new Error(`innertube_watchlater_failed_${e?.status || 'error'}`);
|
||
err.status = e?.status === 401 ? 401 : 502;
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
/** Ajout à "Regarder plus tard" via InnerTube (playlistId logique WL). */
|
||
export async function pushInnerTubeWatchLater(accessToken, apiKey, videoId) {
|
||
const data = await innertubePost('browse/editPlaylist', apiKey, accessToken, {
|
||
context: innertubeContext(),
|
||
actions: [{ action: 'ACTION_ADD_VIDEO', addedVideoId: String(videoId), playlistId: 'WL' }],
|
||
}, 'innertube_watchlater_push');
|
||
if (data?.status && String(data.status).toUpperCase() === 'FAIL') {
|
||
throw Object.assign(new Error('innertube_watchlater_push_rejected'), { status: 502 });
|
||
}
|
||
return { ok: true };
|
||
}
|
||
|
||
// -------------------- Twitch --------------------
|
||
|
||
function twitchClientId() {
|
||
return String(process.env.TWITCH_CLIENT_ID || '').trim();
|
||
}
|
||
|
||
export async function fetchTwitchProfile(accessToken) {
|
||
const data = await getJson('https://api.twitch.tv/helix/users', accessToken, { 'Client-Id': twitchClientId() });
|
||
const u = Array.isArray(data?.data) ? data.data[0] : null;
|
||
if (!u) throw Object.assign(new Error('twitch_profile_failed'), { status: 502 });
|
||
return {
|
||
id: String(u.id || ''),
|
||
displayName: String(u.display_name || u.login || 'Twitch'),
|
||
avatarUrl: String(u.profile_image_url || ''),
|
||
login: String(u.login || ''),
|
||
};
|
||
}
|
||
|
||
export async function fetchTwitchFollows(accessToken, twitchUserId, max = 100) {
|
||
const out = [];
|
||
let cursor = '';
|
||
while (out.length < max) {
|
||
const qs = new URLSearchParams({
|
||
from_id: String(twitchUserId),
|
||
first: String(Math.min(100, max - out.length)),
|
||
});
|
||
if (cursor) qs.set('after', cursor);
|
||
const data = await getJson(`https://api.twitch.tv/helix/users/follows?${qs.toString()}`, accessToken, {
|
||
'Client-Id': twitchClientId(),
|
||
});
|
||
const list = Array.isArray(data?.data) ? data.data : [];
|
||
// Enrichit les logins via /helix/users?id= (display_name + avatar).
|
||
const ids = list.map((f) => f?.to_id).filter(Boolean).slice(0, 100);
|
||
let usersById = new Map();
|
||
if (ids.length) {
|
||
try {
|
||
const uqs = new URLSearchParams();
|
||
ids.forEach((id) => uqs.append('id', String(id)));
|
||
const udata = await getJson(`https://api.twitch.tv/helix/users?${uqs.toString()}`, accessToken, {
|
||
'Client-Id': twitchClientId(),
|
||
});
|
||
for (const u of Array.isArray(udata?.data) ? udata.data : []) {
|
||
usersById.set(String(u.id), u);
|
||
}
|
||
} catch {}
|
||
}
|
||
for (const f of list) {
|
||
const toId = String(f?.to_id || '');
|
||
if (!toId) continue;
|
||
const u = usersById.get(toId);
|
||
const login = String(u?.login || f?.to_name || '');
|
||
out.push({
|
||
provider: 'twitch',
|
||
externalId: login || toId,
|
||
twitchUserId: toId,
|
||
title: String(u?.display_name || f?.to_name || login || toId),
|
||
handle: login || null,
|
||
avatarUrl: String(u?.profile_image_url || ''),
|
||
url: login ? `https://www.twitch.tv/${login}` : '',
|
||
});
|
||
}
|
||
cursor = data?.pagination?.cursor || '';
|
||
if (!cursor || !list.length) break;
|
||
}
|
||
return out;
|
||
}
|