Files
NewTube/server/oauth.mjs
T

927 lines
34 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;
}
/** 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 || '');
}
/**
* 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;
}