import express from 'express'; import helmet from 'helmet'; import cors from 'cors'; import cookieParser from 'cookie-parser'; import rateLimit from 'express-rate-limit'; import bcrypt from 'bcryptjs'; import jwt from 'jsonwebtoken'; import fs from 'node:fs'; import path from 'node:path'; import youtubedlPkg, { create as createYtDlp } from 'youtube-dl-exec'; import { execFile as execFileCb } from 'node:child_process'; import { promisify } from 'node:util'; import { fileURLToPath as serverFileURLToPath } from 'node:url'; import ffmpegPath from 'ffmpeg-static'; import * as cheerio from 'cheerio'; import axios from 'axios'; import rumbleRouter from './rumble.mjs'; import { providerRegistry, validateProviders } from './providers/registry.mjs'; import { dedupeSuggestGroups } from './suggest.mjs'; import { fetchWebSuggest, fetchOdyseeLighthouseSuggest } from './suggest-web.mjs'; import { pickTrack, parseTrackText, parseVtt, dedupeTranscriptLines, orderedTracks, translatedFallbacks, firstPerLanguage, normalizeTranscriptProvider, transcriptTrackExt, looksLikeHtmlError } from './transcript.mjs'; import { getUserByUsername, getUserById, insertUser, insertSession, getSessionById, updateSessionToken, revokeSession, revokeAllUserSessions, listUserSessions, setUserLastLogin, insertLoginAudit, getPreferences, getPreferencesForApi, upsertPreferences, insertTelemetryEvent, listTelemetryEvents, countTelemetryEvents, cryptoRandomId, cryptoRandomUUID, insertSearchHistory, listSearchHistory, deleteSearchHistoryById, deleteAllSearchHistory, upsertWatchHistory, listWatchHistory, updateWatchHistoryById, deleteWatchHistoryById, deleteAllWatchHistory, likeVideo, unlikeVideo, listLikedVideos, isVideoLiked, createPlaylist, listPlaylists, listPublicPlaylists, getPlaylistRaw, getPlaylistWithItemsIfAllowed, updatePlaylist, deletePlaylist, listPlaylistItems, addPlaylistVideo, removePlaylistVideo, reorderPlaylistVideos, ensureChannelFresh, listSubscriptionsByUser, subscribeChannel, unsubscribeChannel, isSubscribed, listSubscriptionGroups, createSubscriptionGroup, updateSubscriptionGroup, deleteSubscriptionGroup, setSubscriptionGroupMembers, setSubscriptionGroups, listSubscriptionGroupMembersByUser, getUserByEmail, getUserByOAuth, upsertOAuthConnection, listOAuthConnections, getOAuthConnection, updateOAuthTokens, deleteOAuthConnection, setOAuthChannel, upsertChannelRow, insertDownloadJob, getDownloadJob, listDownloadJobs, updateDownloadJob, deleteDownloadJob, resetActiveDownloadJobs, countActiveDownloadJobs, sumCompletedDownloadBytes, SUPPORTED_DOWNLOAD_LANGUAGES as SUPPORTED_DL_LANGS, DEFAULT_DOWNLOAD_LANGUAGES as DEFAULT_DL_LANGS, upsertTranscriptHistory, getTranscriptHistoryItem, listTranscriptHistory, deleteTranscriptHistoryById, deleteAllTranscriptHistory, } from './db.mjs'; import { getChannelAdapter, setTwitchTokenProvider } from './providers/channel-registry.mjs'; import { fetchChannelContent } from './providers/channel-content.mjs'; import { oauthStatus, buildAuthUrl, createOAuthState, consumeOAuthState, exchangeCode, refreshAccessToken, redirectUriFor, fetchGoogleProfile, fetchGoogleSubscriptions, fetchGoogleLiked, fetchTwitchProfile, fetchTwitchFollows, hasGoogleWriteScope, fetchGoogleWatchLater, pushGoogleWatchLater, fetchInnerTubeHistory, fetchInnerTubeWatchLaterViaLib, pushInnerTubeWatchLater, fetchGoogleWatchLaterId, fetchAccountChannels, } from './oauth.mjs'; import { getSearchMode, getYtDlpBin, hasCookiesFile, metricsSnapshot } from './providers/youtube-common.mjs'; import { ytScrapeCacheStats } from './providers/youtube.mjs'; /** * Options réseau communes pour les appels yt-dlp : cookies YouTube exportés * (session résidentielle de confiance) + proxy sortant. Sans eux, les IPs * de datacenter se voient servir du vide/429 sur timedtext et parfois sur * les dumps. Absents par défaut -> comportement inchangé. */ function ytdlpNetOpts() { const opts = {}; try { const cookies = String(process.env.YT_COOKIES_FILE || '').trim(); if (cookies) { try { if (fs.existsSync(cookies)) opts.cookies = cookies; else console.warn(`[transcript] YT_COOKIES_FILE introuvable : ${cookies}`); } catch {} } } catch {} try { const proxy = String(process.env.YT_EGRESS_PROXY || '').trim(); if (proxy) opts.proxy = proxy; } catch {} return opts; } const app = express(); const PORT = Number(process.env.PORT || 4000); // yt-dlp: prefer the newest binary available. The copy bundled with // youtube-dl-exec goes stale (YouTube then answers "The page needs to be // reloaded" to dump-single-json), while a system install is usually fresher. // `YT_DLP_PATH` wins, then `yt-dlp` on PATH, then the bundled binary. const execFileAsync = promisify(execFileCb); async function binaryVersion(bin) { try { const { stdout } = await execFileAsync(bin, ['--version'], { timeout: 15000 }); return String(stdout || '').trim().split('\n')[0].trim(); } catch { return null; } } let youtubedl = youtubedlPkg; let ytDlpInfo = 'bundled'; try { let bundledBin = null; try { const u = new URL('../node_modules/youtube-dl-exec/bin/', import.meta.url); const cand = path.join(String(serverFileURLToPath(u)), process.platform === 'win32' ? 'yt-dlp.exe' : 'yt-dlp'); if (fs.existsSync(cand)) bundledBin = cand; } catch {} const bundledVer = bundledBin ? await binaryVersion(bundledBin) : null; const candidates = []; if (process.env.YT_DLP_PATH) candidates.push(process.env.YT_DLP_PATH); candidates.push(process.platform === 'win32' ? 'yt-dlp.exe' : 'yt-dlp', 'yt-dlp'); let best = null; for (const cand of candidates) { if (!cand) continue; const ver = await binaryVersion(cand); if (ver && (!best || ver > best.ver)) best = { bin: cand, ver }; } if (best && (!bundledVer || best.ver >= bundledVer)) { youtubedl = createYtDlp(best.bin); ytDlpInfo = `${best.bin} (${best.ver})`; } else if (bundledVer) { ytDlpInfo = `bundled (${bundledVer})`; } } catch (e) { console.warn('[config] yt-dlp binary selection failed, using bundled:', e?.message || e); } console.log(`[config] yt-dlp: ${ytDlpInfo}`); const IS_PROD = String(process.env.NODE_ENV || '').toLowerCase() === 'production'; const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-me'; if (!process.env.JWT_SECRET) { const msg = '[config] JWT_SECRET non défini — utilisation du secret de développement. NE PAS UTILISER EN PRODUCTION.'; if (IS_PROD) console.error(msg); else console.warn(msg); } const ACCESS_TTL_MIN = Number(process.env.ACCESS_TTL_MIN || 15); // Garde-fou : une erreur non capturée dans une route ne doit jamais tuer tout le serveur. process.on('uncaughtException', (err) => { try { console.error('[fatal] uncaughtException:', err?.stack || err); } catch {} }); process.on('unhandledRejection', (reason) => { try { console.error('[fatal] unhandledRejection:', reason); } catch {} }); const REFRESH_TTL_DAYS = Number(process.env.REFRESH_TTL_DAYS || 2); const REMEMBER_TTL_DAYS = Number(process.env.REMEMBER_TTL_DAYS || 30); const CHANNEL_TTL_MS = Number(process.env.CHANNEL_TTL_MS || (6 * 60 * 60 * 1000)); const corsOptions = { origin: ['http://localhost:4200', 'http://localhost:4000', 'http://localhost:3000'], methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], allowedHeaders: ['Content-Type', 'Authorization'], credentials: true, maxAge: 86400 // 24h }; // Middleware de logging — verbeux uniquement hors production, sinon une ligne sobre. // Jamais de headers complets (tokens) ni de body brut (mots de passe) en prod. const SENSITIVE_FIELDS = new Set(['password', 'currentPassword', 'newPassword', 'token', 'refreshToken', 'accessToken']); function sanitizeBody(body) { if (!body || typeof body !== 'object') return body; const out = Array.isArray(body) ? [...body] : { ...body }; for (const k of Object.keys(out)) { if (SENSITIVE_FIELDS.has(k)) out[k] = '[redacted]'; } return out; } const requestLogger = (req, res, next) => { if (IS_PROD) { console.log(`[${new Date().toISOString()}] ${req.method} ${req.originalUrl}`); return next(); } console.log(`[${new Date().toISOString()}] ${req.method} ${req.originalUrl}`); console.log('Headers:', JSON.stringify({ ...req.headers, authorization: req.headers.authorization ? '[redacted]' : undefined }, null, 2)); console.log('Query:', JSON.stringify(req.query, null, 2)); console.log('Body:', JSON.stringify(sanitizeBody(req.body), null, 2)); next(); }; const subscriptionsLimiter = rateLimit({ windowMs: 60 * 1000, max: 30, standardHeaders: true, legacyHeaders: false, }); const channelsLimiter = rateLimit({ windowMs: 60 * 1000, max: 60, standardHeaders: true, legacyHeaders: false, }); const r = express.Router(); // Public: list public playlists (no auth required) r.get('/playlists/public', (req, res) => { try { const limit = Math.min(200, Math.max(1, Number(req.query.limit || 50))); const offset = Math.max(0, Number(req.query.offset || 0)); const q = typeof req.query.q === 'string' ? req.query.q : undefined; const rows = listPublicPlaylists({ limit, offset, q }); return res.json(rows); } catch (e) { return res.status(500).json({ error: 'list_public_failed', details: String(e?.message || e) }); } }); // -------------------- Channels APIs -------------------- function requireProviderId(value) { const allowed = ['yt','dm','tw','pt','od','ru']; if (!allowed.includes(String(value))) { throw Object.assign(new Error('invalid_provider'), { status: 400 }); } return /** @type {'yt'|'dm'|'tw'|'pt'|'od'|'ru'} */(value); } async function resolveChannel(provider, externalId, { forceRefresh = false } = {}) { const adapterEntry = getChannelAdapter(provider) || providerRegistry[provider]; if (!adapterEntry || typeof adapterEntry.fetchChannelById !== 'function') { throw Object.assign(new Error('provider_not_supported'), { status: 501 }); } return ensureChannelFresh(provider, externalId, () => adapterEntry.fetchChannelById(externalId), { force: forceRefresh }); } r.post('/channels/resolve', authMiddlewareCookieAware, channelsLimiter, async (req, res) => { try { const provider = requireProviderId(req.body?.provider); const externalId = String(req.body?.externalId || '').trim(); if (!externalId) return res.status(400).json({ error: 'external_id_required' }); const meta = await resolveChannel(provider, externalId, { forceRefresh: Boolean(req.body?.refresh) }); return res.json(meta); } catch (error) { const status = error?.status || 500; return res.status(status).json({ error: error?.message || 'channel_resolve_failed' }); } }); // Lecture publique (logo chaîne sur /watch même déconnecté) — ne crée pas d'abonnement. r.get('/channels/:provider/:externalId', channelsLimiter, async (req, res) => { try { const provider = requireProviderId(req.params.provider); const externalId = String(req.params.externalId || '').trim(); if (!externalId) return res.status(400).json({ error: 'external_id_required' }); const refresh = req.query.refresh === '1' || req.query.refresh === 'true'; const meta = await resolveChannel(provider, externalId, { forceRefresh: refresh }); return res.json(meta); } catch (error) { const status = error?.status || 500; return res.status(status).json({ error: error?.message || 'channel_fetch_failed' }); } }); // Contenu d'une chaîne : ?type=videos|shorts|playlists|live&page=&limit=&sort=recent|popular&q= r.get('/channels/:provider/:externalId/content', channelsLimiter, async (req, res) => { try { const provider = requireProviderId(req.params.provider); const externalId = String(req.params.externalId || '').trim(); if (!externalId) return res.status(400).json({ error: 'external_id_required' }); const type = String(req.query.type || 'videos'); if (!['videos', 'shorts', 'playlists', 'live'].includes(type)) { return res.status(400).json({ error: 'invalid_type' }); } const page = Math.max(1, Number(req.query.page || 1)); const limit = Math.min(50, Math.max(1, Number(req.query.limit || 24))); const sort = req.query.sort === 'popular' ? 'popular' : req.query.sort === 'relevance' ? 'relevance' : 'recent'; const q = typeof req.query.q === 'string' ? req.query.q.slice(0, 200) : ''; const data = await fetchChannelContent(provider, externalId, { type, page, limit, sort, q }, { searchRegistry: providerRegistry }); return res.json({ ...data, page, limit, sort, type }); } catch (error) { const status = error?.status || 500; return res.status(status).json({ error: error?.message || 'channel_content_failed', items: [], nextPage: null }); } }); // -------------------- Subscriptions APIs -------------------- r.get('/subscriptions', authMiddlewareCookieAware, subscriptionsLimiter, (req, res) => { try { const items = listSubscriptionsByUser(req.user.id); return res.json({ items, ttl: CHANNEL_TTL_MS }); } catch (error) { return res.status(500).json({ error: 'subscriptions_list_failed', details: String(error?.message || error) }); } }); r.post('/subscriptions', authMiddlewareCookieAware, subscriptionsLimiter, async (req, res) => { try { const provider = requireProviderId(req.body?.provider); const externalId = String(req.body?.externalId || '').trim(); if (!externalId) return res.status(400).json({ error: 'external_id_required' }); const entity = await resolveChannel(provider, externalId, { forceRefresh: Boolean(req.body?.refresh) }); const sub = subscribeChannel({ userId: req.user.id, provider, externalId, channelId: entity?.id }); return res.status(201).json(sub); } catch (error) { const status = error?.status || 500; return res.status(status).json({ error: error?.message || 'subscription_create_failed' }); } }); r.delete('/subscriptions/:subscriptionId', authMiddlewareCookieAware, subscriptionsLimiter, (req, res) => { try { const info = unsubscribeChannel({ userId: req.user.id, subscriptionId: req.params.subscriptionId }); if ((info?.changes || 0) === 0) return res.status(404).json({ error: 'not_found' }); return res.status(204).end(); } catch (error) { return res.status(500).json({ error: 'subscription_delete_failed', details: String(error?.message || error) }); } }); // -------------------- Subscription groups APIs (façon PocketTube) -------------------- r.get('/subscription-groups', authMiddlewareCookieAware, subscriptionsLimiter, (req, res) => { try { const groups = listSubscriptionGroups(req.user.id); const members = listSubscriptionGroupMembersByUser(req.user.id); return res.json({ groups, members }); } catch (error) { return res.status(500).json({ error: 'groups_list_failed', details: String(error?.message || error) }); } }); r.post('/subscription-groups', authMiddlewareCookieAware, subscriptionsLimiter, (req, res) => { try { const group = createSubscriptionGroup({ userId: req.user.id, name: req.body?.name, color: req.body?.color, icon: req.body?.icon, }); return res.status(201).json(group); } catch (error) { const msg = error?.message || 'group_create_failed'; if (msg === 'group_name_required') return res.status(400).json({ error: msg }); if (msg === 'group_name_taken') return res.status(409).json({ error: msg }); return res.status(500).json({ error: 'group_create_failed' }); } }); r.patch('/subscription-groups/:groupId', authMiddlewareCookieAware, subscriptionsLimiter, (req, res) => { try { const group = updateSubscriptionGroup({ userId: req.user.id, groupId: req.params.groupId, name: req.body?.name, color: req.body?.color, icon: req.body?.icon, }); if (!group) return res.status(404).json({ error: 'not_found' }); return res.json(group); } catch (error) { const msg = error?.message || 'group_update_failed'; if (msg === 'group_name_required') return res.status(400).json({ error: msg }); if (msg === 'group_name_taken') return res.status(409).json({ error: msg }); return res.status(500).json({ error: 'group_update_failed' }); } }); r.delete('/subscription-groups/:groupId', authMiddlewareCookieAware, subscriptionsLimiter, (req, res) => { try { const info = deleteSubscriptionGroup({ userId: req.user.id, groupId: req.params.groupId }); if ((info?.changes || 0) === 0) return res.status(404).json({ error: 'not_found' }); return res.status(204).end(); } catch (error) { return res.status(500).json({ error: 'group_delete_failed' }); } }); // Remplace les chaînes d'un groupe : { subscriptionIds: number[] } r.put('/subscription-groups/:groupId/members', authMiddlewareCookieAware, subscriptionsLimiter, (req, res) => { try { const group = setSubscriptionGroupMembers({ userId: req.user.id, groupId: req.params.groupId, subscriptionIds: req.body?.subscriptionIds, }); if (!group) return res.status(404).json({ error: 'not_found' }); return res.json(group); } catch (error) { return res.status(500).json({ error: 'group_members_failed' }); } }); // Remplace les groupes d'un abonnement : { groupIds: string[] } r.put('/subscriptions/:subscriptionId/groups', authMiddlewareCookieAware, subscriptionsLimiter, (req, res) => { try { const groupIds = setSubscriptionGroups({ userId: req.user.id, subscriptionId: Number(req.params.subscriptionId), groupIds: req.body?.groupIds, }); if (!groupIds) return res.status(404).json({ error: 'not_found' }); return res.json({ subscriptionId: Number(req.params.subscriptionId), groupIds }); } catch (error) { return res.status(500).json({ error: 'subscription_groups_failed' }); } }); // -------------------- OAuth Google / Twitch (import favoris + abonnements) -------------------- const oauthLimiter = rateLimit({ windowMs: 60 * 1000, max: 30, standardHeaders: true, legacyHeaders: false }); function requireOAuthProvider(value) { const p = String(value || '').toLowerCase(); if (p !== 'google' && p !== 'twitch') throw Object.assign(new Error('invalid_oauth_provider'), { status: 400 }); return p; } /** Token frais (refresh si expiré dans < 60 s et refresh_token dispo). */ async function freshOAuthToken(userId, provider) { const conn = getOAuthConnection(userId, provider, true); if (!conn?.accessToken) throw Object.assign(new Error('oauth_not_connected'), { status: 404 }); const expired = typeof conn.expiresAt === 'number' && conn.expiresAt - Date.now() < 60_000; if (!expired || !conn.refreshToken) return conn; const refreshed = await refreshAccessToken(provider, conn.refreshToken); updateOAuthTokens(userId, provider, refreshed); return { ...conn, accessToken: refreshed.accessToken, refreshToken: refreshed.refreshToken, expiresAt: refreshed.expiresAt }; } // Public : l'UI affiche "Connecter" seulement si configuré côté serveur. r.get('/oauth/status', (req, res) => res.json(oauthStatus())); // URL d'autorisation (connecté requis : le state mémorise l'utilisateur). r.get('/oauth/:provider/url', authMiddlewareCookieAware, oauthLimiter, (req, res) => { try { const provider = requireOAuthProvider(req.params.provider); const status = oauthStatus()[provider]; if (!status?.configured) return res.status(503).json({ error: `${provider}_oauth_not_configured`, missing: status?.missing || [] }); const state = createOAuthState(req.user.id, provider); return res.json({ url: buildAuthUrl(provider, state, req) }); } catch (error) { return res.status(error?.status || 500).json({ error: error?.message || 'oauth_url_failed' }); } }); // Callback OAuth (public : retrouvé via le state). Redirige vers le front. r.get('/oauth/:provider/callback', oauthLimiter, async (req, res) => { const provider = String(req.params.provider || '').toLowerCase(); const frontBase = (() => { try { const explicit = String(process.env.OAUTH_APP_BASE_URL || '').trim().replace(/\/+$/, ''); if (explicit) return explicit; 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 || '').trim(); // En prod Docker le callback arrive sur :4000 mais l'UI est sur :4200 (hôte). // Si l'hôte pointe le port API interne, on rebascule vers le port public. const publicPort = String(process.env.OAUTH_PUBLIC_PORT || process.env.HOST_PORT || '').trim(); if (host && publicPort) { const bare = host.split(':')[0]; return `${proto}://${bare}:${publicPort}`; } if (host) return `${proto}://${host}`; } catch {} return 'http://localhost:4200'; })(); const fail = (code) => res.redirect(302, `${frontBase}/library/import?provider=${provider}&error=${encodeURIComponent(code)}`); try { if (provider !== 'google' && provider !== 'twitch') return fail('invalid_oauth_provider'); if (req.query?.error) return fail(String(req.query.error_description || req.query.error)); const { code, state } = req.query || {}; if (!code || !state) return fail('oauth_missing_code_or_state'); const entry = consumeOAuthState(String(state)); if (!entry || entry.provider !== provider) return fail('oauth_invalid_state'); // Le login Google revient ici : buildAuthUrl utilise toujours la redirect URI // /api/oauth/google/callback (une seule URI à enregistrer côté Google). if (provider === 'google' && entry.purpose === 'login') { return completeGoogleLogin(req, res, frontBaseForOAuth(req), entry, String(code)); } const tokens = await exchangeCode(provider, String(code), req); if (!tokens?.accessToken) return fail('oauth_token_failed'); let profile = { id: '', displayName: provider, avatarUrl: '' }; try { profile = provider === 'google' ? await fetchGoogleProfile(tokens.accessToken) : await fetchTwitchProfile(tokens.accessToken); } catch {} upsertOAuthConnection({ userId: entry.userId, provider, externalUserId: profile.id || null, displayName: profile.displayName || null, avatarUrl: profile.avatarUrl || null, accessToken: tokens.accessToken, refreshToken: tokens.refreshToken, expiresAt: tokens.expiresAt, scopes: tokens.scopes, }); return res.redirect(302, `${frontBase}/library/import?provider=${provider}&connected=1`); } catch (error) { return fail(error?.message || 'oauth_callback_failed'); } }); r.get('/oauth/connections', authMiddlewareCookieAware, oauthLimiter, (req, res) => { try { return res.json({ connections: listOAuthConnections(req.user.id), status: oauthStatus() }); } catch { return res.status(500).json({ error: 'oauth_connections_failed' }); } }); r.delete('/oauth/:provider', authMiddlewareCookieAware, oauthLimiter, (req, res) => { try { const provider = requireOAuthProvider(req.params.provider); deleteOAuthConnection(req.user.id, provider); return res.status(204).end(); } catch (error) { return res.status(error?.status || 500).json({ error: error?.message || 'oauth_disconnect_failed' }); } }); // -------------------- Google : choix de la chaîne (multi-chaînes / marque) -------------------- // La chaîne par défaut peut être une coquille vide (pas d'historique ni WL) // alors que l'activité est sur une chaîne secondaire : l'utilisateur la // choisit ici, et les appels InnerTube la ciblent via X-Goog-PageId. r.get('/oauth/google/yt-channels', authMiddlewareCookieAware, oauthLimiter, async (req, res) => { try { const conn = await freshOAuthToken(req.user.id, 'google'); const { channels } = await fetchAccountChannels(conn); try { console.log(`[oauth] yt-channels user=${req.user?.id} count=${channels.length}`); } catch {} return res.json({ channels, selected: { channelId: conn.ytChannelId || null, pageId: conn.ytPageId || null } }); } catch (error) { const out = { error: error?.message || 'yt_channels_failed' }; if (error?.detail) out.detail = error.detail; if (error?.hint) out.hint = error.hint; try { console.warn(`[oauth] yt-channels échec user=${req.user?.id} code=${out.error} detail=${String(error?.detail || '').slice(0, 300)}`); } catch {} return res.status(error?.status || 502).json(out); } }); r.put('/oauth/google/yt-channel', authMiddlewareCookieAware, oauthLimiter, (req, res) => { try { const channelId = String(req.body?.channelId || '').trim().slice(0, 64) || null; const pageId = String(req.body?.pageId || '').trim().slice(0, 64) || null; const conn = setOAuthChannel(req.user.id, 'google', { channelId, pageId }); if (!conn) return res.status(404).json({ error: 'oauth_not_connected' }); return res.json({ selected: { channelId: conn.ytChannelId || null, pageId: conn.ytPageId || null } }); } catch { return res.status(500).json({ error: 'yt_channel_save_failed' }); } }); // Aperçu distant (sans rien importer) : abonnements + favoris. r.get('/oauth/:provider/preview', authMiddlewareCookieAware, oauthLimiter, async (req, res) => { try { const provider = requireOAuthProvider(req.params.provider); const conn = await freshOAuthToken(req.user.id, provider); if (provider === 'google') { const [subsRes, likesRes] = await Promise.all([ fetchGoogleSubscriptions(conn.accessToken, 50).catch(() => ({ items: [], total: 0 })), fetchGoogleLiked(conn.accessToken, 25).catch(() => ({ items: [], total: 0 })), ]); return res.json({ provider, subscriptions: subsRes.items, likes: likesRes.items, subscriptionCount: subsRes.items.length, likeCount: likesRes.items.length, // Totaux du compte (l'aperçu n'affiche que les 50/25 premiers, l'import prend tout). subscriptionTotal: subsRes.total, likeTotal: likesRes.total, }); } const profile = await fetchTwitchProfile(conn.accessToken).catch(() => ({ id: conn.externalUserId || '' })); const follows = await fetchTwitchFollows(conn.accessToken, profile?.id || conn.externalUserId || '', 100); return res.json({ provider, subscriptions: follows, likes: [], subscriptionCount: follows.length, likeCount: 0 }); } catch (error) { const status = error?.status || 502; return res.status(status).json({ error: error?.message || 'oauth_preview_failed' }); } }); // Import : { types: ['subscriptions','likes'] } (likes = Google uniquement). r.post('/oauth/:provider/import', authMiddlewareCookieAware, oauthLimiter, async (req, res) => { try { const provider = requireOAuthProvider(req.params.provider); const rawTypes = Array.isArray(req.body?.types) ? req.body.types : ['subscriptions', 'likes']; const wantSubs = rawTypes.includes('subscriptions'); const wantLikes = rawTypes.includes('likes'); const conn = await freshOAuthToken(req.user.id, provider); let importedSubscriptions = 0; let importedLikes = 0; let skippedSubscriptions = 0; let skippedLikes = 0; if (provider === 'google') { if (wantSubs) { // Import complet paginé (l'aperçu n'en montre que 50). const { items: subs } = await fetchGoogleSubscriptions(conn.accessToken, 1000); for (const s of subs) { try { const row = upsertChannelRow({ provider: 'youtube', externalId: s.externalId, title: s.title, handle: s.handle || null, avatarUrl: s.avatarUrl || null, url: s.url || null, lastRefreshedAt: Date.now(), }); subscribeChannel({ userId: req.user.id, provider: 'youtube', externalId: s.externalId, channelId: row?.id }); importedSubscriptions++; } catch { skippedSubscriptions++; } } } if (wantLikes) { // Import complet paginé (l'aperçu n'en montre que 25). const { items: likes } = await fetchGoogleLiked(conn.accessToken, 500); for (const v of likes) { try { likeVideo({ userId: req.user.id, provider: 'youtube', videoId: v.videoId, title: v.title, thumbnail: v.thumbnail }); importedLikes++; } catch { skippedLikes++; } } } } else { if (wantSubs) { const profile = await fetchTwitchProfile(conn.accessToken).catch(() => ({ id: conn.externalUserId || '' })); const follows = await fetchTwitchFollows(conn.accessToken, profile?.id || conn.externalUserId || '', 100); for (const f of follows) { try { const row = upsertChannelRow({ provider: 'twitch', externalId: f.externalId, title: f.title, handle: f.handle || null, avatarUrl: f.avatarUrl || null, url: f.url || null, lastRefreshedAt: Date.now(), }); subscribeChannel({ userId: req.user.id, provider: 'twitch', externalId: f.externalId, channelId: row?.id }); importedSubscriptions++; } catch { skippedSubscriptions++; } } } // Twitch : pas de likes vidéo → on l'indique au lieu d'échouer silencieusement. if (wantLikes) skippedLikes = 0; } return res.json({ provider, importedSubscriptions, importedLikes, skippedSubscriptions, skippedLikes }); } catch (error) { const status = error?.status || 502; return res.status(status).json({ error: error?.message || 'oauth_import_failed' }); } }); // -------------------- Google : Watch Later (lecture + écriture) -------------------- // Lecture (youtube.readonly OK). `writeGranted` = le jeton stocké a le scope // force-ssl ; sinon le push répond 403 et l'UI propose de reconnecter. r.get('/oauth/google/watchlater', authMiddlewareCookieAware, oauthLimiter, async (req, res) => { try { const conn = await freshOAuthToken(req.user.id, 'google'); const writeGranted = hasGoogleWriteScope(conn.scopes); try { const { items } = await fetchGoogleWatchLater(conn.accessToken, 50); return res.json({ items, count: items.length, writeGranted, via: 'api' }); } catch (apiError) { // L'API Data ne renvoie pas d'ID WL pour certains comptes : repli // youtubei.js authentifié (getPlaylist WL, aucun ID requis). if (apiError?.message !== 'watchlater_not_found' && apiError?.message !== 'youtube_no_channel') throw apiError; try { console.warn(`[oauth] WL Data API indisponible (${apiError.message}), repli youtubei.js`); } catch {} const { items } = await fetchInnerTubeWatchLaterViaLib(conn, 100); try { console.log(`[oauth] WL via innertube user=${req.user?.id} count=${items.length}`); } catch {} return res.json({ items, count: items.length, writeGranted, via: 'innertube', note: 'Liste lue via InnerTube (l’API YouTube ne l’expose pas pour ce compte).', }); } } catch (error) { const out = { error: error?.message || 'watchlater_fetch_failed' }; if (error?.hint) out.hint = error.hint; if (error?.ytReason) out.ytReason = error.ytReason; if (error?.detail) out.detail = error.detail; if (!out.hint && String(error?.ytReason || '').toLowerCase().includes('insufficientpermissions')) { out.hint = 'Scope manquant : déconnectez puis reconnectez Google pour autoriser l’accès complet YouTube.'; } return res.status(error?.status || 502).json(out); } }); // -------------------- Google : historique auto via InnerTube authentifié -------------------- // L'API Data v3 n'expose pas l'historique : on lit youtubei/v1/browse // (FEhistory) avec le Bearer OAuth de l'utilisateur (mécanisme SmartTube). // Le client réutilise ensuite POST /user/history/takeout pour l'importer. function innertubeApiKey() { const single = String(process.env.YOUTUBE_API_KEY || '').trim(); if (single && !single.includes(',')) return single; const csv = String(process.env.YOUTUBE_API_KEYS || '').trim(); try { if (csv.startsWith('[')) { const arr = JSON.parse(csv); if (Array.isArray(arr) && arr[0]) return String(arr[0]); } } catch {} if (csv) { const first = csv.split(',').map((s) => s.trim()).filter(Boolean)[0]; if (first) return first; } if (single) return single.split(',').map((s) => s.trim()).filter(Boolean)[0] || ''; return ''; } r.get('/oauth/google/yt-history', authMiddlewareCookieAware, oauthLimiter, async (req, res) => { try { const conn = await freshOAuthToken(req.user.id, 'google'); const max = Math.min(500, Math.max(1, Number(req.query.max || 200))); const { items, hasMore } = await fetchInnerTubeHistory(conn, max); try { console.log(`[oauth] yt-history user=${req.user?.id} count=${items.length} hasMore=${hasMore}`); } catch {} return res.json({ items, count: items.length, hasMore }); } catch (error) { const out = { error: error?.message || 'ythistory_fetch_failed' }; if (error?.ytReason) out.ytReason = error.ytReason; if (error?.detail) out.detail = error.detail; if (error?.hint) out.hint = error.hint; try { console.warn(`[oauth] yt-history échec user=${req.user?.id} code=${out.error} detail=${String(error?.detail || error?.ytReason || '').slice(0, 300)}`); } catch {} if (!out.hint && error?.status === 401) { out.hint = 'Session Google expirée : déconnectez puis reconnectez Google.'; } else if (!out.hint && String(error?.ytReason || '').toLowerCase().includes('insufficientpermissions')) { out.hint = 'Scope manquant : déconnectez puis reconnectez Google pour autoriser l’accès complet YouTube.'; } return res.status(error?.status || 502).json(out); } }); r.post('/oauth/google/watchlater', authMiddlewareCookieAware, oauthLimiter, async (req, res) => { try { const conn = await freshOAuthToken(req.user.id, 'google'); if (!hasGoogleWriteScope(conn.scopes)) { return res.status(403).json({ error: 'youtube_write_scope_missing', hint: 'Reconnectez Google pour autoriser l’écriture (Watch Later).' }); } const ids = Array.isArray(req.body?.videoIds) ? req.body.videoIds : []; const clean = [...new Set(ids.map((v) => String(v || '').trim()).filter(Boolean))].slice(0, 50); if (!clean.length) return res.status(400).json({ error: 'videoIds_required' }); // ID WL via Data API, sinon repli InnerTube (playlist logique "WL"). let useInnertube = false; try { await fetchGoogleWatchLaterId(conn.accessToken); } catch (e) { if (e?.message === 'watchlater_not_found' || e?.message === 'youtube_no_channel') useInnertube = true; else throw e; } let added = 0; let skipped = 0; for (const videoId of clean) { try { if (useInnertube) await pushInnerTubeWatchLater(conn.accessToken, innertubeApiKey(), videoId); else await pushGoogleWatchLater(conn.accessToken, videoId, innertubeApiKey()); added++; } catch { skipped++; } } return res.json({ added, skipped, total: clean.length, via: useInnertube ? 'innertube' : 'api' }); } catch (error) { const status = error?.status || 502; if (status === 403) { return res.status(403).json({ error: 'youtube_write_scope_missing', hint: 'Reconnectez Google pour autoriser l’écriture (Watch Later).' }); } return res.status(status).json({ error: error?.message || 'watchlater_push_failed' }); } }); // -------------------- Import historique Takeout (Google) -------------------- // Le client parse le fichier `watch-history.json` localement (confidentialité, // pas de limite d'upload) et envoie des lots { videoId, title, watchedAt }. // L'historique YouTube n'est pas lisible par API (limite Google), d'où Takeout. r.post('/user/history/takeout', authMiddleware, oauthLimiter, (req, res) => { try { const items = Array.isArray(req.body?.items) ? req.body.items : []; if (!items.length) return res.status(400).json({ error: 'items_required' }); if (items.length > 1000) return res.status(400).json({ error: 'batch_too_large' }); let imported = 0; let skipped = 0; for (const it of items) { const videoId = String(it?.videoId || '').trim().slice(0, 64); if (!/^[A-Za-z0-9_-]{6,64}$/.test(videoId)) { skipped++; continue; } const title = String(it?.title || '').slice(0, 300) || videoId; let watchedAt = new Date().toISOString(); if (it?.watchedAt) { const d = new Date(String(it.watchedAt)); if (!Number.isNaN(d.getTime())) watchedAt = d.toISOString(); } try { upsertWatchHistory({ userId: req.user.id, provider: 'youtube', videoId, title, watchedAt }); imported++; } catch { skipped++; } } return res.json({ imported, skipped, total: items.length }); } catch { return res.status(500).json({ error: 'takeout_import_failed' }); } }); // Public: view a playlist if allowed (owner or public). Authorization header is optional. r.get('/playlists/:id/view', (req, res) => { try { const id = String(req.params.id || ''); let viewerUserId = undefined; try { const auth = req.headers['authorization'] || ''; const [, token] = String(auth).split(' '); if (token) { const payload = jwt.verify(token, JWT_SECRET); viewerUserId = payload?.sub; } } catch {} const limit = Math.min(2000, Math.max(1, Number(req.query.limit || 500))); const offset = Math.max(0, Number(req.query.offset || 0)); const result = getPlaylistWithItemsIfAllowed({ viewerUserId, id, limit, offset }); if (result === 'forbidden') return res.status(404).json({ error: 'not_found' }); if (!result) return res.status(404).json({ error: 'not_found' }); return res.json(result); } catch (e) { return res.status(500).json({ error: 'view_failed', details: String(e?.message || e) }); } }); // Servir les fichiers statiques du dossier dist app.use(express.static(path.join(process.cwd(), 'dist'))); app.use('/assets', express.static(path.join(process.cwd(), 'assets'))); app.set('trust proxy', 1); app.use(helmet({ // Disable strict CSP for now to allow third‑party thumbnails/CDNs used by providers contentSecurityPolicy: false, // Disable COEP to avoid blocking cross‑origin resources (e.g., images/videos) crossOriginEmbedderPolicy: false, // Allow loading cross‑origin images crossOriginResourcePolicy: { policy: 'cross-origin' }, })); app.use(express.json()); app.use(express.urlencoded({ extended: true })); app.use(cookieParser()); app.use(cors(corsOptions)); app.options('*', cors(corsOptions)); // Pré-vol CORS // Logging des requêtes app.use(requestLogger); // Routes API app.use('/api', r); // -------------------- Downloads configuration -------------------- // Downloads directory (per-user sub-directories) const downloadsRoot = path.join(process.cwd(), 'tmp', 'downloads'); if (!fs.existsSync(downloadsRoot)) { fs.mkdirSync(downloadsRoot, { recursive: true }); } // Storage quota (bytes) per user for completed downloads in the retention window. // Default: 5 GiB. Set to 0 to disable. const DOWNLOAD_STORAGE_QUOTA_BYTES = Number(process.env.DOWNLOAD_STORAGE_QUOTA_BYTES ?? (5 * 1024 * 1024 * 1024)); // Retention window (ms) for quota accounting. Default: 30 days. Set to 0 for no window. const DOWNLOAD_QUOTA_WINDOW_MS = Number(process.env.DOWNLOAD_QUOTA_WINDOW_MS ?? (30 * 24 * 60 * 60 * 1000)); function userDownloadsDir(userId) { const safeId = String(userId || 'anonymous').replace(/[^a-zA-Z0-9_-]+/g, '_').slice(0, 64) || 'anonymous'; const dir = path.join(downloadsRoot, safeId); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); return dir; } // On boot: mark jobs that were active when the API stopped as 'interrupted' // so users can retry them, and clean orphan files left by jobs that never completed. resetActiveDownloadJobs(); (function cleanupOrphanDownloadFiles() { try { const known = new Set( listDownloadJobs({ userId: '', limit: 100000 }) .filter(j => j.state === 'completed' && j.filePath) .map(j => path.resolve(j.filePath)) ); if (!fs.existsSync(downloadsRoot)) return; for (const entry of fs.readdirSync(downloadsRoot, { withFileTypes: true })) { const full = path.join(downloadsRoot, entry.name); if (entry.isDirectory()) { for (const f of fs.readdirSync(full)) { const fp = path.join(full, f); if (!known.has(path.resolve(fp))) { try { fs.unlinkSync(fp); } catch {} } } } else if (entry.isFile() && !known.has(path.resolve(full))) { // Legacy layout: files directly under downloadsRoot try { fs.unlinkSync(full); } catch {} } } } catch (e) { console.warn('[downloads] orphan cleanup failed:', e?.message || e); } })(); function providerLabel(provider) { switch (String(provider)) { case 'youtube': return 'YouTube'; case 'dailymotion': return 'Dailymotion'; case 'twitch': return 'Twitch'; case 'peertube': return 'PeerTube'; case 'odysee': return 'Odysee'; case 'rumble': return 'Rumble'; default: return String(provider || '').charAt(0).toUpperCase() + String(provider || '').slice(1); } } function normalizeResolutionLabel(label) { const s = String(label || '').trim(); // Prefer forms like "480p", falling back to numeric height const m = /(\d{3,4})\b/.exec(s); if (/\d{3,4}p/.test(s)) return s.replace(/[^0-9p]/g, ''); if (m) return `${m[1]}p`; return s || 'best'; } function uniquePath(baseDir, baseName, ext) { let candidate = `${baseName}.${ext}`; let full = path.join(baseDir, candidate); let i = 1; while (fs.existsSync(full)) { candidate = `${baseName} (${i}).${ext}`; full = path.join(baseDir, candidate); i++; } return { fileName: candidate, filePath: full }; } // Pick the best progressive (video+audio) format from metadata function pickBestProgressiveFormat(meta) { const items = Array.isArray(meta?.formats) ? meta.formats : []; let best = null; for (const f of items) { if (!f) continue; const hasVideo = f.vcodec && f.vcodec !== 'none'; const hasAudio = f.acodec && f.acodec !== 'none'; if (!hasVideo || !hasAudio) continue; const height = Number(f.height || 0); const fps = Number(f.fps || 0); if (!best) { best = f; continue; } const bh = Number(best.height || 0); const bf = Number(best.fps || 0); if (height > bh || (height === bh && fps > bf)) best = f; } return best; } const loginLimiter = rateLimit({ windowMs: 60 * 1000, // 1 min max: 5, standardHeaders: true, legacyHeaders: false, }); /** * Réponse JSON (et non texte brut) quand un rate-limiter se déclenche, pour * que le front puisse afficher un message FR précis avec compte à rebours. */ function jsonLimitHandler(req, res, _next, options) { let retryAfterSec = 60; try { const resetMs = req?.rateLimit?.resetTime ? new Date(req.rateLimit.resetTime).getTime() : 0; if (resetMs > Date.now()) retryAfterSec = Math.max(1, Math.ceil((resetMs - Date.now()) / 1000)); else if (options?.windowMs) retryAfterSec = Math.max(1, Math.ceil(options.windowMs / 1000)); } catch {} try { res.set('Retry-After', String(retryAfterSec)); } catch {} return res.status(options?.statusCode || 429).json({ error: 'rate_limited', retryAfterSec }); } const downloadReadLimiter = rateLimit({ windowMs: 60 * 1000, // polling légitime des jobs (2-5 s) : seau large max: 120, standardHeaders: true, legacyHeaders: false, handler: jsonLimitHandler, }); const downloadWriteLimiter = rateLimit({ windowMs: 60 * 1000, max: 30, standardHeaders: true, legacyHeaders: false, handler: jsonLimitHandler, }); const downloadFormatsLimiter = rateLimit({ windowMs: 60 * 1000, max: 30, standardHeaders: true, legacyHeaders: false, handler: jsonLimitHandler, }); /** Cache mémoire des listes de formats (un dump yt-dlp = 5-15 s + quota YouTube). */ const formatsCache = new Map(); // key -> { ts, data } const FORMATS_CACHE_TTL_MS = 10 * 60 * 1000; const FORMATS_CACHE_MAX = 200; function formatsCacheGet(key) { const hit = formatsCache.get(key); if (!hit) return null; if (Date.now() - hit.ts > FORMATS_CACHE_TTL_MS) { formatsCache.delete(key); return null; } // LRU : rejoue l'entrée en fin de Map formatsCache.delete(key); formatsCache.set(key, hit); return hit.data; } function formatsCacheSet(key, data) { if (formatsCache.has(key)) formatsCache.delete(key); formatsCache.set(key, { ts: Date.now(), data }); while (formatsCache.size > FORMATS_CACHE_MAX) { const oldest = formatsCache.keys().next().value; formatsCache.delete(oldest); } } // Rate limiter for Rumble scraping to prevent being blocked const rumbleLimiter = rateLimit({ windowMs: 60 * 1000, // 1 min max: 10, // Limit to 10 requests per minute per IP standardHeaders: true, legacyHeaders: false, message: { error: 'Too many requests to Rumble API. Please try again later.' } }); function makeAccessToken(userId, sessionId) { const payload = { sub: userId, sid: sessionId }; return jwt.sign(payload, JWT_SECRET, { expiresIn: `${ACCESS_TTL_MIN}m` }); } function isSecureRequest(req) { try { if (process.env.COOKIE_SECURE === 'true') return true; if (process.env.COOKIE_SECURE === 'false') return false; if (req?.secure) return true; const proto = String(req?.headers?.['x-forwarded-proto'] || req?.protocol || '').split(',')[0].trim().toLowerCase(); return proto === 'https'; } catch { return false; } } function setRefreshCookies(res, { sessionId, token, days }, req) { const maxAgeMs = days * 24 * 60 * 60 * 1000; const cookieOpts = { httpOnly: true, // 'lax' : envoyé sur les navigations top-level (liens directs,