/** * 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; }