Files
NewTube/server/oauth.mjs
T
bruno 050cea3a92 fix(sync): WL diagnostic, import-all paginé + totaux, sync auto historique InnerTube
- Watch Later: youtube_no_channel / watchlater_not_found avec hint
  actionnable + log cles relatedPlaylists (sans PII)
- Preview Google: totaux compte (subscriptionTotal/likeTotal), UI
  'X / N, l'import prend tout'; import-all paginé (subs<=1000,
  likes<=500) au lieu de 50/25
- Sync auto historique: GET /oauth/google/yt-history (browse FEhistory
  authentifie, continuations, <=500), bouton Sync auto dans carte
  Takeout, import via lots takeout existants
2026-09-26 22:25:50 -04:00

580 lines
21 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',
].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);
}
/** Contenu de "Regarder plus tard" (lecture seule OK). */
export async function fetchGoogleWatchLater(accessToken, max = 50) {
const playlistId = await fetchGoogleWatchLaterId(accessToken);
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 { playlistId, items: 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 };
}
// -------------------- 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;
}
export async function fetchInnerTubeHistory(accessToken, apiKey, max = 200) {
const key = String(apiKey || '').trim();
if (!key) throw Object.assign(new Error('youtube_api_key_unavailable'), { status: 503 });
const out = [];
const seen = new Set();
let continuation = null;
let pages = 0;
while (out.length < max && pages < 10) {
pages++;
const body = continuation
? { context: { client: { clientName: 'WEB', clientVersion: '2.20260101.00.00', hl: 'fr', gl: 'FR' } }, continuation }
: { context: { client: { clientName: 'WEB', clientVersion: '2.20260101.00.00', hl: 'fr', gl: 'FR' } }, browseId: 'FEhistory' };
const resp = await fetch(`https://www.youtube.com/youtubei/v1/browse?key=${encodeURIComponent(key)}&prettyPrint=false`, {
method: 'POST',
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: AbortSignal.timeout(20000),
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok) {
const err = new Error(`innertube_history_failed_${resp.status}`);
err.status = resp.status === 401 ? 401 : 502;
throw err;
}
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) };
}
// -------------------- 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;
}