Files
NewTube/server/index.mjs
T
bruno c45e04af42 perf(details): Odysee par l'API LBRY + cache des métadonnées (45 s → 0,2 s)
Mesuré avant : `/api/details/odysee/…` = 44,7 s puis « No video formats
found! » — la page Watch restait une minute sans titre, vues, description ni
vignette. L'extracteur `lbry` de yt-dlp n'est pas utilisable ici.

- Nouvelle branche Odysee dans /api/details : `resolve` de l'API LBRY
  (`server/providers/odysee.mjs` → `resolveOdyseeVideo`) = 229 ms mesurés,
  titre/description/vignette/durée/date/canal + avatar. Claim introuvable →
  404 `odysee_claim_not_found` (le client garde les données du flux), panne →
  502 `odysee_details_failed`. Jamais de repli yt-dlp (45 s pour échouer).
- `views` volontairement OMIS pour Odysee : ni LBRY ni lighthouse n'exposent
  de compteur pour un claim isolé (l'`effective_amount` est un montant LBC).
- Cache mémoire de /api/details : 6 h, LRU 500 (`DETAILS_CACHE_TTL_MS`,
  `DETAILS_CACHE_MAX_ENTRIES`) — métadonnées immuables, chaque /watch
  relançait un dump complet.
- `views`/`duration` ne sont plus émis quand ils valent 0 : un 0 écrasait la
  valeur du flux côté client (merge sur `typeof === 'number'`), d'où les
  « 0 vue » sur les fournisseurs muets — même doctrine que
  `parseRumbleViews` (« jamais de compteur à 0 »).
- Mapping pur `odyseeClaimToVideo` testé hors ligne : 21 assertions,
  `npm run test:odysee`.
2026-10-02 14:48:53 -04:00

4073 lines
181 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.
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 axios from 'axios';
import rumbleRouter from './rumble.mjs';
import { providerRegistry, getProviderAdapter, validateProviders, SUGGESTION_CONTRACT_VERSION } from './providers/registry.mjs';
import { applyProviderFlags, providerFlag } from './providers/feature-flags.mjs';
import { dedupeSuggestGroups } from './suggest.mjs';
import { buildDebugPayload, APPLICATION_NDJSON } from './search-transport.mjs';
import { parseSearchFilters, activeFilters as activeSearchFilters, applySearchFilters, filtersCacheKey } from './search-filters.mjs';
import { fetchWebSuggest, fetchOdyseeLighthouseSuggest } from './suggest-web.mjs';
import { pickTrack, parseTrackText, parseVtt, dedupeTranscriptLines, orderedTracks, translatedFallbacks, firstPerLanguage, normalizeTranscriptProvider, transcriptTrackExt, looksLikeHtmlError, ensureFmtParam, isEmptyTimedTextBody, isBlobTranscript, mergeTranscriptCandidates } from './transcript.mjs';
import {
getUserByUsername,
getUserById,
insertUser,
insertSession,
getSessionById,
updateSessionToken,
revokeSession,
revokeAllUserSessions,
listUserSessions,
setUserLastLogin,
insertLoginAudit,
getPreferences,
getPreferencesForApi,
upsertPreferences,
insertTelemetryEvent,
listTelemetryEvents,
countTelemetryEvents,
cryptoRandomId,
cryptoRandomUUID,
insertSearchHistory,
insertSearchHistoryAt,
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 { 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, diagnoseInnertube, fetchMineChannel,
} 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 = getProviderAdapter(provider);
if (!adapterEntry || typeof adapterEntry.channelMeta !== 'function') {
throw Object.assign(new Error('provider_not_supported'), { status: 501 });
}
return ensureChannelFresh(provider, externalId, () => adapterEntry.channelMeta(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) : '';
// Phase 3.1 - curseur opaque pour les API paginées par curseur (Twitch Helix).
// Sans lui, `page=2` re-demandait la page 1 et la pagination bouclait à
// l'infini. Le front le renvoie tel quel dans `?cursor=`.
const cursor = typeof req.query.cursor === 'string' ? req.query.cursor.slice(0, 512) : '';
const data = await getProviderAdapter(provider).channelContent(externalId, { type, page, limit, sort, q, cursor }, { searchRegistry: providerRegistry });
return res.json({ ...data, page, limit, sort, type, ...(data?.nextCursor ? { nextCursor: data.nextCursor } : {}) });
} 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' });
}
});
// Import en lot (ex. abonnements.csv de Takeout) : upsert direct, sans
// résolution externe (les IDs Takeout sont déjà fiables).
r.post('/subscriptions/batch', authMiddlewareCookieAware, subscriptionsLimiter, (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' });
const allowed = new Set(['youtube', 'twitch', 'dailymotion', 'peertube', 'odysee', 'rumble', 'yt', 'dm', 'tw', 'pt', 'od', 'ru']);
let imported = 0;
let skipped = 0;
for (const it of items) {
const provider = String(it?.provider || 'youtube').trim().toLowerCase();
const externalId = String(it?.externalId || '').trim().slice(0, 128);
if (!allowed.has(provider) || !externalId) { skipped++; continue; }
try {
const row = upsertChannelRow({
provider, externalId,
title: String(it?.title || externalId).slice(0, 200) || null,
handle: String(it?.handle || '').slice(0, 128) || null,
avatarUrl: String(it?.avatarUrl || '').slice(0, 500) || null,
url: String(it?.url || '').slice(0, 500) || null,
lastRefreshedAt: Date.now(),
});
subscribeChannel({ userId: req.user.id, provider, externalId, channelId: row?.id });
imported++;
} catch { skipped++; }
}
return res.json({ imported, skipped, total: items.length });
} catch {
return res.status(500).json({ error: 'subscriptions_batch_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);
}
});
// Diagnostic : état de chaque endpoint InnerTube authentifié + Data API.
r.get('/oauth/google/diag', authMiddlewareCookieAware, oauthLimiter, async (req, res) => {
try {
const conn = await freshOAuthToken(req.user.id, 'google');
const dataApi = {};
try {
dataApi.mineChannel = await fetchMineChannel(conn.accessToken);
} catch (e) {
dataApi.mineChannel = { error: String(e?.message || '').slice(0, 80) };
}
try {
await fetchGoogleWatchLaterId(conn.accessToken);
dataApi.watchLaterId = true;
} catch (e) {
dataApi.watchLaterId = false;
dataApi.watchLaterError = String(e?.message || '').slice(0, 80);
}
const innertube = await diagnoseInnertube(conn);
try {
console.log(`[oauth] diag user=${req.user?.id} ${JSON.stringify({ dataApi, innertube }).slice(0, 800)}`);
} catch {}
return res.json({ dataApi, innertube });
} catch (error) {
return res.status(error?.status || 502).json({ error: error?.message || 'diag_failed' });
}
});
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;
// Miniature YouTube déterministe quand Takeout n'en fournit pas.
let thumbnail = String(it?.thumbnail || '').slice(0, 500);
if (!thumbnail && /^[A-Za-z0-9_-]{11}$/.test(videoId)) {
thumbnail = `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`;
}
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, thumbnail, 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);
}
}
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, <video>) tout en
// bloquant l'envoi cross-site sur POST (CSRF). 'strict' cassait les téléchargements directs.
sameSite: 'lax',
// Ne jamais forcer Secure sur du http local (sinon le navigateur n'envoie jamais
// les cookies et les liens directs /proxy/api/.../file répondent 401 Unauthorized).
secure: isSecureRequest(req),
// '/' : les cookies doivent partir aussi bien sur /api/* que sur /proxy/api/*.
path: '/',
maxAge: maxAgeMs,
};
res.cookie('sid', sessionId, cookieOpts);
res.cookie('refreshToken', token, cookieOpts);
}
function clearRefreshCookies(res) {
// Supprime les cookies actuels + les variantes historiques (anciens Path/Secure).
const variants = [
{ httpOnly: true, sameSite: 'lax', secure: false, path: '/' },
{ httpOnly: true, sameSite: 'strict', secure: false, path: '/api' },
{ httpOnly: true, sameSite: 'strict', secure: false, path: '/proxy/api' },
{ httpOnly: true, sameSite: 'strict', secure: true, path: '/api' },
{ httpOnly: true, sameSite: 'lax', secure: true, path: '/' },
];
for (const base of variants) {
try { res.clearCookie('sid', base); } catch {}
try { res.clearCookie('refreshToken', base); } catch {}
}
}
function getClientIp(req) {
const xf = req.headers['x-forwarded-for'];
if (typeof xf === 'string') return xf.split(',')[0].trim();
if (Array.isArray(xf) && xf.length > 0) return xf[0];
return req.ip || '';
}
async function hashPassword(password) {
const salt = await bcrypt.genSalt(12);
return bcrypt.hash(password, salt);
}
async function verifyPassword(password, hash) {
return bcrypt.compare(password, hash);
}
async function hashToken(token) {
// Using bcrypt to hash refresh token
const salt = await bcrypt.genSalt(12);
return bcrypt.hash(token, salt);
}
function authMiddleware(req, res, next) {
const hdr = req.headers['authorization'] || '';
const [, token] = hdr.split(' ');
if (!token) return res.status(401).json({ error: 'Unauthorized' });
try {
const payload = jwt.verify(token, JWT_SECRET);
req.user = { id: payload.sub, sessionId: payload.sid };
next();
} catch {
return res.status(401).json({ error: 'Unauthorized' });
}
}
// For direct browser downloads (anchor tag), Authorization header is not attached.
// Allow authentication using the httpOnly session cookies as a fallback for the file route.
function authMiddlewareCookieAware(req, res, next) {
const hdr = req.headers['authorization'] || '';
const [, token] = hdr.split(' ');
if (token) {
try {
const payload = jwt.verify(token, JWT_SECRET);
req.user = { id: payload.sub, sessionId: payload.sid };
return next();
} catch {}
}
// Fallback to session cookies
const { sid, refreshToken } = req.cookies || {};
if (!sid || !refreshToken) return res.status(401).json({ error: 'Unauthorized' });
const session = getSessionById(sid);
if (!session || session.revoked_at) return res.status(401).json({ error: 'Unauthorized' });
bcrypt.compare(refreshToken, session.refresh_token_hash).then((ok) => {
if (!ok) return res.status(401).json({ error: 'Unauthorized' });
req.user = { id: session.user_id, sessionId: session.id };
next();
}).catch(() => res.status(401).json({ error: 'Unauthorized' }));
}
// -------------------- Download Orchestrator --------------------
// Par défaut tous les providers gérés par yt-dlp sont autorisés (youtube inclus).
// Restreindre via DOWNLOAD_PROVIDERS="peertube,odysee" si besoin.
const DOWNLOAD_ALLOWED_PROVIDERS = (process.env.DOWNLOAD_PROVIDERS || 'youtube,dailymotion,twitch,peertube,odysee,rumble').split(',').map(s => s.trim()).filter(Boolean);
/** @type {Map<string, any>} */
const jobs = new Map();
function sanitizeFileName(name) {
return String(name || 'video')
.replace(/[^a-zA-Z0-9-_\. ]+/g, '_')
.replace(/[\s]+/g, ' ')
.trim()
.slice(0, 140);
}
function providerUrlFrom(provider, videoId, { instance, slug, sourceUrl }) {
if (sourceUrl && /^https?:\/\//i.test(sourceUrl)) return sourceUrl;
const id = String(videoId);
switch (String(provider)) {
case 'youtube':
return `https://www.youtube.com/watch?v=${encodeURIComponent(id)}`;
case 'dailymotion':
return `https://www.dailymotion.com/video/${encodeURIComponent(id)}`;
case 'twitch':
return `https://www.twitch.tv/videos/${encodeURIComponent(id)}`;
case 'peertube': {
const inst = String(instance || '').trim();
if (!inst) throw new Error('peertube_instance_required');
return `https://${inst}/w/${encodeURIComponent(id)}`;
}
case 'odysee': {
const s = String(slug || id);
return `https://odysee.com/${s.replace(/^\//, '')}`;
}
case 'rumble':
return `https://rumble.com/${encodeURIComponent(id)}`;
default:
throw new Error('unsupported_provider');
}
}
function guessContentTypeByExt(ext) {
const e = String(ext || '').toLowerCase();
if (e === 'mp4' || e === 'm4v') return 'video/mp4';
if (e === 'webm') return 'video/webm';
if (e === 'mkv') return 'video/x-matroska';
if (e === 'mp3') return 'audio/mpeg';
if (e === 'm4a' || e === 'aac') return 'audio/mp4';
if (e === 'opus' || e === 'ogg') return 'audio/ogg';
return 'application/octet-stream';
}
function formatListFromMeta(meta) {
const items = Array.isArray(meta?.formats) ? meta.formats : [];
const mapped = items.map(f => {
const height = f.height || 0;
const fps = f.fps || 0;
const resolution = height ? `${height}p${fps && fps >= 50 ? fps : ''}` : (f.format_note || '');
const sizeEstimate = f.filesize || f.filesize_approx || null;
const labelParts = [];
if (resolution) labelParts.push(resolution);
if (f.ext) labelParts.push(f.ext);
if (f.vcodec && f.vcodec !== 'none') labelParts.push(f.vcodec);
if (f.acodec && f.acodec !== 'none') labelParts.push(`+${f.acodec}`);
return {
id: f.format_id,
resolution,
fps: fps || undefined,
ext: f.ext || '',
vcodec: f.vcodec || '',
acodec: f.acodec || '',
sizeEstimate,
label: labelParts.filter(Boolean).join(' '),
};
});
// Deduplicate by id
const seen = new Set();
const out = [];
for (const m of mapped) {
if (!m.id || seen.has(m.id)) continue;
seen.add(m.id);
out.push(m);
}
return out;
}
// Routes under /api
// -------------------- YouTube simple cache (GET) --------------------
// YouTube API key rotation and error handling (similar to Angular service)
const ytKeys = (() => {
const out = [];
try {
const raw = process.env.YOUTUBE_API_KEYS;
if (raw && String(raw).trim() && String(raw).trim() !== 'undefined' && String(raw).trim() !== 'null') {
const s = String(raw).trim();
if (s.startsWith('[')) {
try {
const arr = JSON.parse(s);
if (Array.isArray(arr)) out.push(...arr.map(v => String(v || '').trim()).filter(Boolean));
} catch {}
} else {
out.push(...s.split(',').map(v => String(v || '').trim()).filter(Boolean));
}
}
} catch {}
const single = process.env.YOUTUBE_API_KEY;
if (single && String(single).trim()) out.push(String(single).trim());
return Array.from(new Set(out.filter(Boolean)));
})();
let ytKeyIndex = 0;
const ytKeyBans = new Map(); // key -> bannedUntil epoch ms
// Ban duration (default 6h) can be overridden via env YT_KEY_BAN_MS
const YT_KEY_BAN_MS = Number(process.env.YT_KEY_BAN_MS || 6 * 60 * 60 * 1000);
// Simple in-memory response cache for the YouTube proxy (URL -> { ts, data })
// TTL configurable via YT_CACHE_TTL_MS (default 5 min). Cap to avoid unbounded growth.
const YT_CACHE_TTL_MS = Number(process.env.YT_CACHE_TTL_MS || 5 * 60 * 1000);
const YT_CACHE_MAX_ENTRIES = Number(process.env.YT_CACHE_MAX_ENTRIES || 500);
const ytCache = new Map();
function ytCacheGet(key) {
const hit = ytCache.get(key);
if (!hit) return null;
// Refresh recency for a naive LRU behavior
ytCache.delete(key);
ytCache.set(key, hit);
if ((Date.now() - hit.ts) >= YT_CACHE_TTL_MS) {
ytCache.delete(key);
return null;
}
return hit;
}
function ytCacheSet(key, value) {
if (ytCache.has(key)) ytCache.delete(key);
ytCache.set(key, value);
while (ytCache.size > YT_CACHE_MAX_ENTRIES) {
const oldest = ytCache.keys().next().value;
if (oldest === undefined) break;
ytCache.delete(oldest);
}
}
function getActiveYouTubeKey() {
if (!ytKeys || ytKeys.length === 0) return null;
const now = Date.now();
// Find a non-banned key
for (let i = 0; i < ytKeys.length; i++) {
const key = ytKeys[ytKeyIndex % ytKeys.length];
ytKeyIndex = (ytKeyIndex + 1) % ytKeys.length;
const bannedUntil = ytKeyBans.get(key);
if (!bannedUntil || now > bannedUntil) {
return key;
}
}
return ytKeys[0]; // fallback to first key
}
function banYouTubeKey(key) {
if (!key) return;
const bannedUntil = Date.now() + YT_KEY_BAN_MS;
ytKeyBans.set(key, bannedUntil);
console.warn(`[YouTube API] Banned key ending with ...${key.slice(-4)} until ${new Date(bannedUntil).toISOString()}`);
}
function logYouTubeApiUsage(key, status, path) {
const shortKey = key ? `...${key.slice(-4)}` : 'none';
const logLevel = status >= 400 ? 'warn' : 'info';
console[logLevel](`[YouTube API] Key ${shortKey} - ${status} - ${path}`);
}
function isYouTubeKeyFailure(status, data) {
try {
const reason = data?.error?.errors?.[0]?.reason || '';
const message = String(data?.error?.message || '');
if (status === 400 && (reason === 'API_KEY_INVALID' || /api key (expired|invalid)/i.test(message))) return true;
if (status === 403 && /quota|rateLimit|dailyLimit|userRateLimit/i.test(reason + ' ' + message)) return true;
} catch {}
return false;
}
r.get('/yt/*', async (req, res) => {
try {
const googlePath = req.originalUrl.replace(/^\/api\/yt/, '');
// Cache key WITHOUT any client-supplied key (évite la fragmentation + fuite de clé en cache)
const cacheUrl = new URL(`https://www.googleapis.com${googlePath}`);
cacheUrl.searchParams.delete('key');
const cacheKey = cacheUrl.toString();
const now = Date.now();
const cached = ytCacheGet(cacheKey);
// Check if we have cached data and it's still valid (only success is cached)
if (cached) {
return res.status(cached.status || 200).json(cached.data);
}
let lastStatus = 503;
let lastData = { error: 'youtube_api_key_unavailable' };
const tried = new Set();
// Try each configured server key in turn (rotation on expired/quota keys)
const keysToTry = [...ytKeys];
if (keysToTry.length === 0) {
console.warn('[YouTube API] No API key available');
return res.status(503).json(lastData);
}
for (let i = 0; i < keysToTry.length; i++) {
const key = getActiveYouTubeKey();
if (!key || tried.has(key)) continue;
tried.add(key);
// Add API key to the URL (server key is source of truth; ignore client key)
const url = new URL(`https://www.googleapis.com${googlePath}`);
url.searchParams.set('key', key);
const finalUrl = url.toString();
const response = await axios.get(finalUrl, { timeout: 15000, validateStatus: s => s >= 200 && s < 500 });
const status = response.status;
const data = response.data;
// Log the usage
logYouTubeApiUsage(key, status, googlePath);
if (status < 400) {
// Only cache successful responses (jamais d'erreurs : une clé expirée
// ne doit pas polluer le cache pour les autres clés)
ytCacheSet(cacheKey, { ts: now, data, status, isError: false });
return res.status(status).json(data);
}
lastStatus = status;
lastData = data;
if (isYouTubeKeyFailure(status, data)) {
banYouTubeKey(key);
continue; // try next key
}
return res.status(status).json(data);
}
return res.status(lastStatus).json(lastData);
} catch (e) {
const status = e?.response?.status || 500;
const data = e?.response?.data || { error: 'yt_cache_upstream_error', details: String(e?.message || e) };
return res.status(status).json(data);
}
});
// -------------------- PeerTube proxy (GET) --------------------
// Usage example: /api/peertube/video.manu.quebec/api/v1/videos?sort=-trending&count=24&start=0
r.get('/peertube/:instance/*', async (req, res) => {
try {
const instance = String(req.params.instance || '').replace(/[^a-zA-Z0-9.-]/g, '');
if (!instance) return res.status(400).json({ error: 'missing_instance' });
const rest = req.params[0] ? '/' + req.params[0] : '';
const qs = req.url.includes('?') ? req.url.substring(req.url.indexOf('?')) : '';
const targetUrl = `https://${instance}${rest}${qs}`;
const response = await axios.get(targetUrl, { timeout: 15000, validateStatus: s => s >= 200 && s < 400 });
return res.status(response.status || 200).json(response.data);
} catch (e) {
const status = e?.response?.status || 500;
const data = e?.response?.data || { error: 'peertube_upstream_error', details: String(e?.message || e) };
return res.status(status).json(data);
}
});
// -------------------- Generic video details (GET) --------------------
// Cache mémoire pour les vidéos connexes InnerTube (watch-next) : TTL 1h, LRU 200.
const YT_RELATED_TTL_MS = Number(process.env.YT_RELATED_TTL_MS || 60 * 60 * 1000);
const ytRelatedCache = new Map();
function ytRelatedCacheSet(key, items) {
if (ytRelatedCache.has(key)) ytRelatedCache.delete(key);
ytRelatedCache.set(key, { ts: Date.now(), items });
while (ytRelatedCache.size > 200) { const o = ytRelatedCache.keys().next().value; if (o === undefined) break; ytRelatedCache.delete(o); }
}
// Cache des métadonnées de /api/details : titre, description, vignette, durée et
// date de publication d'une vidéo sont immuables → TTL long (6 h par défaut).
// Sans lui, chaque ouverture de /watch relançait un dump yt-dlp complet (2-5 s,
// et ~45 s pour Odysee qui finissait en erreur).
const DETAILS_CACHE_TTL_MS = Number(process.env.DETAILS_CACHE_TTL_MS || 6 * 60 * 60 * 1000);
const DETAILS_CACHE_MAX_ENTRIES = Number(process.env.DETAILS_CACHE_MAX_ENTRIES || 500);
const detailsCache = new Map();
function detailsCacheGet(key) {
const hit = detailsCache.get(key);
if (!hit) return null;
// Récence = LRU naïf (même modèle que `ytCache`).
detailsCache.delete(key);
detailsCache.set(key, hit);
if ((Date.now() - hit.ts) >= DETAILS_CACHE_TTL_MS) { detailsCache.delete(key); return null; }
return hit.data;
}
function detailsCacheSet(key, data) {
if (detailsCache.has(key)) detailsCache.delete(key);
detailsCache.set(key, { ts: Date.now(), data });
while (detailsCache.size > DETAILS_CACHE_MAX_ENTRIES) {
const oldest = detailsCache.keys().next().value;
if (oldest === undefined) break;
detailsCache.delete(oldest);
}
}
// Returns metadata such as title, description, uploader, thumbnail, duration and views for a provider/videoId
// Supports query params similar to download endpoints: instance (PeerTube), slug (Odysee), sourceUrl (direct)
r.get('/details/:provider/:videoId', async (req, res) => {
try {
const { provider, videoId } = req.params;
const instance = req.query.instance || undefined;
const slug = req.query.slug || undefined;
const sourceUrl = req.query.sourceUrl || undefined;
const url = providerUrlFrom(provider, videoId, { instance, slug, sourceUrl });
const cacheKey = `${provider}:${videoId}:${instance || ''}:${slug || ''}`;
const cached = detailsCacheGet(cacheKey);
if (cached) return res.json(cached);
// Odysee : l'API LBRY (`resolve`) répond en ~100-300 ms là où l'extracteur
// yt-dlp met ~45 s pour finir sur « No video formats found! » (mesuré). Sans
// cette branche, la page Watch restait une minute sans titre, description ni
// vignette. `views` est volontairement OMIS (aucun compteur fiable pour un
// claim isolé : l'`effective_amount` est un montant LBC, pas des vues) pour
// que le client garde la valeur du flux au lieu de l'écraser par 0.
if (String(provider) === 'odysee') {
try {
const { resolveOdyseeVideo } = await import('./providers/odysee.mjs');
const od = await resolveOdyseeVideo(slug || videoId);
if (!od) return res.status(404).json({ error: 'odysee_claim_not_found' });
const odOut = {
videoId,
url,
type: 'video',
title: od.title,
thumbnail: od.thumbnail,
description: od.description,
uploaderName: od.uploaderName,
uploaderAvatar: od.uploaderAvatar,
uploadedDate: od.releaseTime ? new Date(od.releaseTime * 1000).toISOString() : '',
...(od.duration > 0 ? { duration: od.duration } : {}),
};
detailsCacheSet(cacheKey, odOut);
return res.json(odOut);
} catch (e) {
return res.status(502).json({ error: 'odysee_details_failed', details: String(e?.message || e) });
}
}
const raw = await youtubedl(url, { dumpSingleJson: true, noWarnings: true, noCheckCertificates: true, skipDownload: true });
const meta = (typeof raw === 'string') ? JSON.parse(raw || '{}') : (raw || {});
const channelId = meta.channel_id || meta.uploader_id || meta.channel_url?.split('/').filter(Boolean).pop() || '';
const channelExternalId = channelId || meta.channel || meta.uploader || '';
const uploaderUrl = meta.channel_url || meta.uploader_url || '';
// Best-effort avatar: yt-dlp ne fournit pas l'avatar de chaîne, on tente
// l'API YouTube Data (si clé dispo) puis le registre de chaînes (cache 6h).
let uploaderAvatar = '';
let subscribers = 0;
try {
if (String(provider) === 'youtube' && channelId) {
const yKey = (typeof getActiveYouTubeKey === 'function') ? getActiveYouTubeKey() : null;
if (yKey) {
const params = new URLSearchParams({ part: 'snippet,statistics', id: String(channelId), key: String(yKey) });
const resp = await fetch(`https://www.googleapis.com/youtube/v3/channels?${params.toString()}`);
if (resp.ok) {
const data = await resp.json().catch(() => ({}));
const item = data?.items?.[0];
const thumbs = item?.snippet?.thumbnails || {};
uploaderAvatar = thumbs.high?.url || thumbs.medium?.url || thumbs.default?.url || '';
const subsRaw = item?.statistics?.subscriberCount;
if (subsRaw != null) subscribers = Number(subsRaw) || 0;
} else if (resp.status === 403 || resp.status === 400) {
try { banYouTubeKey(yKey); } catch {}
}
}
}
// Fallback générique via le registre (yt/dm/tw/pt/od/ru) pour avatar + subs.
if (!uploaderAvatar && channelExternalId) {
const shortToRegistry = { youtube: 'yt', dailymotion: 'dm', twitch: 'tw', peertube: 'pt', odysee: 'od', rumble: 'ru' };
const regProvider = shortToRegistry[String(provider)] || String(provider);
const adapter = getProviderAdapter(regProvider);
if (adapter && typeof adapter.channelMeta === 'function') {
const ch = await adapter.channelMeta(String(channelExternalId));
if (ch?.avatarUrl) uploaderAvatar = ch.avatarUrl;
if (typeof ch?.subsCount === 'number' && !subscribers) subscribers = ch.subsCount;
}
}
} catch {}
const views = typeof meta.view_count === 'number' ? meta.view_count : (typeof meta.viewCount === 'number' ? meta.viewCount : 0);
const duration = typeof meta.duration === 'number' ? meta.duration : 0;
const out = {
videoId,
title: meta.title || '',
thumbnail: meta.thumbnail || (Array.isArray(meta.thumbnails) && meta.thumbnails.length ? meta.thumbnails[meta.thumbnails.length - 1].url || meta.thumbnails[0].url : ''),
uploaderName: meta.uploader || meta.channel || '',
uploaderUrl,
uploaderAvatar,
channelId: channelId || undefined,
channelExternalId: channelExternalId || undefined,
subscribers,
// `views` / `duration` ne sont émis QUE s'ils sont connus : un 0 écraserait
// la valeur déjà affichée (le client merge sur `typeof === 'number'`), ce
// qui montrait « 0 vue » sur les fournisseurs muets sur ces champs.
...(views > 0 ? { views } : {}),
...(duration > 0 ? { duration } : {}),
uploadedDate: meta.upload_date ? new Date(meta.upload_date.replace(/(\d{4})(\d{2})(\d{2})/, '$1-$2-$3')).toISOString() : (meta.release_timestamp ? new Date(meta.release_timestamp * 1000).toISOString() : ''),
description: meta.description || meta.summary || '',
url,
type: 'video',
};
// Step 18 : vidéos connexes façon SmartTube (watch-next InnerTube, best-effort, 0 quota).
// N'implique que YouTube ; toute erreur -> `related: []`, la réponse reste 200.
if (String(provider) === 'youtube' && req.query.related !== '0') {
try {
const { getRelatedViaInnerTube } = await import('./providers/youtube-innertube.mjs');
const relKey = `related:${videoId}`;
const cached = ytRelatedCache.get(relKey);
if (cached && (Date.now() - cached.ts) < YT_RELATED_TTL_MS) {
out.related = cached.items;
} else {
const items = await getRelatedViaInnerTube(videoId, 24).catch(() => []);
out.related = items;
ytRelatedCacheSet(relKey, items);
}
} catch { out.related = []; }
}
detailsCacheSet(cacheKey, out);
return res.json(out);
} catch (e) {
return res.status(500).json({ error: 'details_failed', details: String(e?.message || e) });
}
});
// Download routes middleware (auth supports both Authorization header and cookies).
// Les lectures (polling jobs) et écritures ont des seaux séparés : le polling
// ne doit jamais affamer les suppressions ni les listes de formats.
r.use('/download', authMiddlewareCookieAware);
// List available formats for a given video (cache 10 min : un dump = 5-15 s)
r.get('/download/:provider/:videoId/formats', downloadFormatsLimiter, async (req, res) => {
try {
const { provider, videoId } = req.params;
if (!DOWNLOAD_ALLOWED_PROVIDERS.includes(String(provider))) {
return res.status(403).json({ error: 'download_disabled_for_provider' });
}
const instance = req.query.instance || undefined;
const slug = req.query.slug || undefined;
const sourceUrl = req.query.sourceUrl || undefined;
const cacheKey = `formats:${provider}:${videoId}:${instance || ''}:${slug || ''}:${sourceUrl || ''}`;
const cached = formatsCacheGet(cacheKey);
if (cached) return res.json(cached);
const url = providerUrlFrom(provider, videoId, { instance, slug, sourceUrl });
const raw = await youtubedl(url, { dumpSingleJson: true, noWarnings: true, noCheckCertificates: true, skipDownload: true, ...ytdlpNetOpts() });
const meta = (typeof raw === 'string') ? JSON.parse(raw || '{}') : (raw || {});
const formats = formatListFromMeta(meta);
const out = { url, formats, title: meta?.title || '', duration: meta?.duration || 0 };
formatsCacheSet(cacheKey, out);
return res.json(out);
} catch (e) {
const code = (e && e.message === 'peertube_instance_required') ? 400 : 500;
return res.status(code).json({ error: 'formats_failed', details: String(e?.message || e).slice(0, 300) });
}
});
// Start a download job
r.post('/download/:provider/:videoId', downloadWriteLimiter, async (req, res) => {
try {
const { provider, videoId } = req.params;
if (!DOWNLOAD_ALLOWED_PROVIDERS.includes(String(provider))) {
return res.status(403).json({ error: 'download_disabled_for_provider' });
}
const userId = req.user?.id || 'anonymous';
// Concurrency quota per user (DB-backed so it survives restarts)
const activeCount = countActiveDownloadJobs(userId);
if (activeCount >= Number(process.env.DOWNLOAD_MAX_CONCURRENT || 2)) {
return res.status(429).json({ error: 'too_many_downloads' });
}
// Storage quota: sum of completed files within the retention window
if (DOWNLOAD_STORAGE_QUOTA_BYTES > 0) {
const used = sumCompletedDownloadBytes(userId, DOWNLOAD_QUOTA_WINDOW_MS > 0 ? Date.now() - DOWNLOAD_QUOTA_WINDOW_MS : 0);
if (used >= DOWNLOAD_STORAGE_QUOTA_BYTES) {
return res.status(429).json({ error: 'storage_quota_exceeded', usedBytes: used, quotaBytes: DOWNLOAD_STORAGE_QUOTA_BYTES });
}
}
const { formatId, audioOnly, sourceUrl } = req.body || {};
const instance = req.query.instance || undefined;
const slug = req.query.slug || undefined;
const url = providerUrlFrom(provider, videoId, { instance, slug, sourceUrl });
const jobId = cryptoRandomId();
// Per-user sub-directory keeps files isolated and easy to purge
const userDir = userDownloadsDir(userId);
// Keep the produced filename simple and rename after completion
const tmpOutTpl = path.join(userDir, `${jobId}.%(ext)s`);
// Fetch metadata to build the final filename (title & resolution)
let expectedBaseName = '';
let metaTitle = '';
let chosenFormatId = formatId ? String(formatId) : '';
let chosenResolution = 'best';
try {
const rawMeta = await youtubedl(url, { dumpSingleJson: true, noWarnings: true, noCheckCertificates: true, skipDownload: true });
const meta = (typeof rawMeta === 'string') ? JSON.parse(rawMeta || '{}') : (rawMeta || {});
metaTitle = meta?.title || '';
const title = sanitizeFileName(metaTitle || `${provider}-${videoId}`);
if (audioOnly) {
chosenResolution = 'audio';
} else if (chosenFormatId) {
try {
const fmts = formatListFromMeta(meta);
const picked = fmts.find(f => f.id === String(chosenFormatId));
chosenResolution = normalizeResolutionLabel(picked?.resolution || picked?.label || 'best');
} catch {}
} else {
// No explicit selection: choose best progressive format and use its resolution
const bestProg = pickBestProgressiveFormat(meta);
if (bestProg && bestProg.format_id) {
chosenFormatId = String(bestProg.format_id);
const res = bestProg.height ? `${bestProg.height}p` : (bestProg.format_note || bestProg.ext || 'best');
chosenResolution = normalizeResolutionLabel(res);
}
}
expectedBaseName = `${providerLabel(provider)}_${title}_${chosenResolution}`;
} catch {}
const job = {
id: jobId,
userId,
provider,
videoId,
state: 'queued',
progress: 0,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
filePath: null,
fileExt: null,
fileSize: null,
fileName: null,
expectedBaseName,
error: null,
url,
};
jobs.set(jobId, job);
try {
insertDownloadJob({ id: jobId, userId, provider, videoId, title: metaTitle, formatId: chosenFormatId, audioOnly: !!audioOnly, url });
} catch (e) {
console.warn('[downloads] persist job failed:', e?.message || e);
}
// Start the process asynchronously
const args = {
output: tmpOutTpl,
ffmpegLocation: ffmpegPath || undefined,
noWarnings: true,
noCheckCertificates: true,
preferFreeFormats: true,
progress: true,
newline: true,
// Do not force mp4; let yt-dlp pick a compatible container (mkv/webm/mp4)
};
if (audioOnly) {
args.extractAudio = true;
args.audioFormat = 'm4a';
}
if (!audioOnly && chosenFormatId) args.format = String(chosenFormatId);
const cp = youtubedl.exec(url, args, { shell: false });
job.state = 'running';
job.proc = cp;
updateDownloadJob(jobId, { state: 'running' });
// Throttled DB progress sync (avoid hammering SQLite with every progress line)
let lastDbSync = 0;
const syncProgressDb = (force = false) => {
const now = Date.now();
if (!force && now - lastDbSync < 5000) return;
lastDbSync = now;
try { updateDownloadJob(jobId, { state: job.state, progress: job.progress || 0 }); } catch {}
};
const onLine = (text) => {
const s = String(text);
const m = /(\d+(?:\.\d+)?)%/.exec(s);
if (m) {
job.progress = Math.max(job.progress || 0, Math.min(100, Number(m[1])));
job.updatedAt = new Date().toISOString();
syncProgressDb();
}
if (/\[Merger]/.test(s)) {
job.state = 'merging';
syncProgressDb(true);
}
};
cp.stdout?.on('data', (chunk) => onLine(chunk.toString()));
cp.stderr?.on('data', (chunk) => onLine(chunk.toString()));
cp.on('error', (err) => {
job.state = 'failed';
job.error = String(err?.message || err);
job.updatedAt = new Date().toISOString();
updateDownloadJob(jobId, { state: 'failed', error: job.error });
});
cp.on('close', async (code) => {
try {
if (code !== 0) {
job.state = 'failed';
job.error = `yt-dlp exited with code ${code}`;
job.updatedAt = new Date().toISOString();
updateDownloadJob(jobId, { state: 'failed', error: job.error });
return;
}
// Find produced file in the user's download directory
const files = fs.readdirSync(userDir).filter(f => f.startsWith(`${jobId}.`));
if (files.length > 0) {
const f = files[0];
const p = path.join(userDir, f);
const st = fs.statSync(p);
const ext = f.split('.').pop();
let finalName = f;
// Build final friendly file name if we have enough info
try {
const base = job.expectedBaseName ? sanitizeFileName(job.expectedBaseName) : `${providerLabel(job.provider)}_${job.videoId}`;
const uniq = uniquePath(userDir, base, ext);
finalName = uniq.fileName;
const finalPath = uniq.filePath;
// Rename the temporary file to the final name
fs.renameSync(p, finalPath);
job.filePath = finalPath;
job.fileName = finalName;
} catch {
// Fallback to temporary file name
job.filePath = p;
job.fileName = f;
}
job.fileExt = ext;
job.fileSize = st.size;
job.state = 'completed';
job.progress = 100;
job.updatedAt = new Date().toISOString();
updateDownloadJob(jobId, { state: 'completed', progress: 100, fileName: job.fileName, fileExt: ext, fileSize: st.size, filePath: job.filePath, completedAt: Date.now() });
} else {
job.state = 'failed';
job.error = 'file_not_found_after_download';
job.updatedAt = new Date().toISOString();
updateDownloadJob(jobId, { state: 'failed', error: job.error });
}
} catch (err) {
job.state = 'failed';
job.error = String(err?.message || err);
updateDownloadJob(jobId, { state: 'failed', error: job.error });
}
});
return res.status(202).json({ jobId });
} catch (e) {
const code = (e && e.message === 'peertube_instance_required') ? 400 : 500;
return res.status(code).json({ error: 'start_failed', details: String(e?.message || e) });
}
});
// List current user's download jobs (persistent queue history)
r.get('/download/jobs', downloadReadLimiter, (req, res) => {
try {
const userId = req.user?.id || 'anonymous';
const limit = Math.min(200, Math.max(1, Number(req.query.limit || 50)));
const offset = Math.max(0, Number(req.query.offset || 0));
const state = typeof req.query.state === 'string' ? req.query.state : undefined;
const items = listDownloadJobs({ userId, limit, offset, state });
const quota = DOWNLOAD_STORAGE_QUOTA_BYTES > 0
? {
usedBytes: sumCompletedDownloadBytes(userId, DOWNLOAD_QUOTA_WINDOW_MS > 0 ? Date.now() - DOWNLOAD_QUOTA_WINDOW_MS : 0),
quotaBytes: DOWNLOAD_STORAGE_QUOTA_BYTES,
}
: null;
return res.json({ items, quota });
} catch (e) {
return res.status(500).json({ error: 'download_jobs_list_failed', details: String(e?.message || e) });
}
});
// Load a job row (DB) enforcing ownership; falls back gracefully
function loadOwnedJob(req) {
const { id } = req.params;
const userId = req.user?.id || 'anonymous';
const row = getDownloadJob(id);
if (!row) return { error: 'not_found' };
if (row.userId !== userId) return { error: 'forbidden' };
return { row };
}
// Retry a failed/interrupted job (reprise)
r.post('/download/jobs/:id/retry', downloadWriteLimiter, async (req, res) => {
try {
const { row, error } = loadOwnedJob(req);
if (error === 'not_found' || error === 'forbidden') return res.status(error === 'forbidden' ? 403 : 404).json({ error });
if (!['failed', 'interrupted'].includes(row.state)) {
return res.status(400).json({ error: 'job_not_retryable', state: row.state });
}
const activeCount = countActiveDownloadJobs(row.userId);
if (activeCount >= Number(process.env.DOWNLOAD_MAX_CONCURRENT || 2)) {
return res.status(429).json({ error: 'too_many_downloads' });
}
if (!row.url) return res.status(400).json({ error: 'job_url_missing' });
const userDir = userDownloadsDir(row.userId);
const jobId = row.id;
// Clean any partial file left by the previous attempt
try {
for (const f of fs.readdirSync(userDir).filter(f => f.startsWith(`${jobId}.`))) {
fs.unlinkSync(path.join(userDir, f));
}
} catch {}
const args = {
output: path.join(userDir, `${jobId}.%(ext)s`),
ffmpegLocation: ffmpegPath || undefined,
noWarnings: true,
noCheckCertificates: true,
preferFreeFormats: true,
progress: true,
newline: true,
};
if (row.audioOnly) {
args.extractAudio = true;
args.audioFormat = 'm4a';
} else if (row.formatId) {
args.format = String(row.formatId);
}
const inMemory = jobs.get(jobId) || {};
const job = {
id: jobId,
userId: row.userId,
provider: row.provider,
videoId: row.videoId,
state: 'queued',
progress: 0,
createdAt: inMemory.createdAt || new Date(row.createdAt).toISOString(),
updatedAt: new Date().toISOString(),
filePath: null,
fileExt: null,
fileSize: null,
fileName: null,
expectedBaseName: inMemory.expectedBaseName || `${providerLabel(row.provider)}_${sanitizeFileName(row.title || row.videoId)}`,
error: null,
url: row.url,
};
jobs.set(jobId, job);
updateDownloadJob(jobId, { state: 'running', progress: 0, error: null });
const cp = youtubedl.exec(row.url, args, { shell: false });
job.state = 'running';
job.proc = cp;
let lastDbSync = 0;
const syncProgressDb = (force = false) => {
const now = Date.now();
if (!force && now - lastDbSync < 5000) return;
lastDbSync = now;
try { updateDownloadJob(jobId, { state: job.state, progress: job.progress || 0 }); } catch {}
};
const onLine = (text) => {
const s = String(text);
const m = /(\d+(?:\.\d+)?)%/.exec(s);
if (m) {
job.progress = Math.max(job.progress || 0, Math.min(100, Number(m[1])));
job.updatedAt = new Date().toISOString();
syncProgressDb();
}
if (/\[Merger]/.test(s)) {
job.state = 'merging';
syncProgressDb(true);
}
};
cp.stdout?.on('data', (chunk) => onLine(chunk.toString()));
cp.stderr?.on('data', (chunk) => onLine(chunk.toString()));
cp.on('error', (err) => {
job.state = 'failed';
job.error = String(err?.message || err);
job.updatedAt = new Date().toISOString();
updateDownloadJob(jobId, { state: 'failed', error: job.error });
});
cp.on('close', (code) => {
try {
if (code !== 0) {
job.state = 'failed';
job.error = `yt-dlp exited with code ${code}`;
updateDownloadJob(jobId, { state: 'failed', error: job.error });
return;
}
const files = fs.readdirSync(userDir).filter(f => f.startsWith(`${jobId}.`));
if (files.length > 0) {
const f = files[0];
const p = path.join(userDir, f);
const st = fs.statSync(p);
const ext = f.split('.').pop();
let finalName = f;
try {
const base = job.expectedBaseName ? sanitizeFileName(job.expectedBaseName) : `${providerLabel(job.provider)}_${job.videoId}`;
const uniq = uniquePath(userDir, base, ext);
finalName = uniq.fileName;
fs.renameSync(p, uniq.filePath);
job.filePath = uniq.filePath;
job.fileName = finalName;
} catch {
job.filePath = p;
job.fileName = f;
}
job.fileExt = ext;
job.fileSize = st.size;
job.state = 'completed';
job.progress = 100;
updateDownloadJob(jobId, { state: 'completed', progress: 100, fileName: job.fileName, fileExt: ext, fileSize: st.size, filePath: job.filePath, completedAt: Date.now() });
} else {
job.state = 'failed';
job.error = 'file_not_found_after_download';
updateDownloadJob(jobId, { state: 'failed', error: job.error });
}
} catch (err) {
job.state = 'failed';
job.error = String(err?.message || err);
updateDownloadJob(jobId, { state: 'failed', error: job.error });
}
});
return res.status(202).json({ jobId });
} catch (e) {
return res.status(500).json({ error: 'retry_failed', details: String(e?.message || e) });
}
});
// Job status — DB first (persistent, survives restarts), memory fallback
r.get('/download/jobs/:id', downloadReadLimiter, (req, res) => {
const { id } = req.params;
const { row, error } = loadOwnedJob(req);
if (error === 'forbidden') return res.status(403).json({ error });
if (row) {
const { filePath, ...safe } = row; // never leak absolute server paths
return res.json(safe);
}
if (error === 'not_found') {
const job = jobs.get(id);
if (job && (job.userId === (req.user?.id || 'anonymous'))) {
const { proc, expectedBaseName, filePath, ...safe } = job;
return res.json(safe);
}
}
return res.status(404).json({ error: 'not_found' });
});
// Stream the file (supports Range) — path resolved from the DB row (owner only)
r.get('/download/jobs/:id/file', downloadReadLimiter, (req, res) => {
const { id } = req.params;
const { row, error } = loadOwnedJob(req);
if (error === 'forbidden') return res.status(403).json({ error });
if (!row || row.state !== 'completed' || !row.filePath) return res.status(404).json({ error: 'not_found' });
const filePath = row.filePath;
if (!fs.existsSync(filePath)) return res.status(410).json({ error: 'file_gone' });
const stat = fs.statSync(filePath);
const total = stat.size;
const ext = row.fileExt || 'mp4';
const ctype = guessContentTypeByExt(ext);
const fileName = sanitizeFileName(row.fileName || `${row.provider}-${row.videoId}.${ext}`);
res.setHeader('Content-Type', ctype);
res.setHeader('Accept-Ranges', 'bytes');
// ?inline=1 -> lecture dans le navigateur (page "Lire"), sinon téléchargement.
const inline = req.query.inline === '1' || req.query.inline === 'true';
res.setHeader('Content-Disposition', `${inline ? 'inline' : 'attachment'}; filename="${fileName}"`);
const range = req.headers.range;
if (range) {
const m = /bytes=(\d+)-(\d+)?/.exec(range);
if (m) {
const start = parseInt(m[1], 10);
const end = m[2] ? parseInt(m[2], 10) : total - 1;
if (start >= total || end >= total) {
return res.status(416).setHeader('Content-Range', `bytes */${total}`).end();
}
res.status(206);
res.setHeader('Content-Range', `bytes ${start}-${end}/${total}`);
res.setHeader('Content-Length', String(end - start + 1));
fs.createReadStream(filePath, { start, end }).pipe(res);
return;
}
}
res.setHeader('Content-Length', String(total));
fs.createReadStream(filePath).pipe(res);
});
// Cancel a job (owner only) — also removes the DB row
r.delete('/download/jobs/:id', downloadWriteLimiter, (req, res) => {
const { id } = req.params;
const { row, error } = loadOwnedJob(req);
if (error === 'forbidden') return res.status(403).json({ error });
const job = jobs.get(id);
if (!row && !job) return res.status(404).json({ error: 'not_found' });
try {
if (job?.proc && typeof job.proc.kill === 'function') {
job.proc.kill('SIGKILL');
}
} catch {}
const filePath = row?.filePath || job?.filePath;
try {
if (filePath && fs.existsSync(filePath)) fs.unlinkSync(filePath);
} catch {}
jobs.delete(id);
if (row) {
try { deleteDownloadJob({ userId: row.userId, id }); } catch {}
}
return res.status(204).end();
});
// Rumble routes are handled by the dedicated router in server/rumble.mjs
// Auth routes
r.post('/auth/register', loginLimiter, async (req, res) => {
const rawUsername = (req.body?.username ?? '').trim();
const email = (req.body?.email ?? '')?.trim() || null;
const password = req.body?.password ?? '';
// allow using email as username if username is missing
const username = rawUsername || (email || '');
if (!username || !password) return res.status(400).json({ error: 'username and password are required' });
const existing = getUserByUsername(username);
if (existing) return res.status(409).json({ error: 'username already exists' });
const id = cryptoRandomUUID();
const passwordHash = await hashPassword(password);
insertUser({ id, username, email, passwordHash });
const sessionId = cryptoRandomId();
const refreshToken = cryptoRandomId();
const refreshTokenHash = await hashToken(refreshToken);
const days = REFRESH_TTL_DAYS;
const expiresAt = new Date(Date.now() + days * 86400_000).toISOString();
const ua = req.headers['user-agent'] || '';
insertSession({ id: sessionId, userId: id, refreshTokenHash, isRemember: false, userAgent: ua, deviceInfo: '', ip: getClientIp(req), expiresAt });
setUserLastLogin(id);
insertLoginAudit({ userId: id, username, ip: getClientIp(req), userAgent: ua, success: true });
setRefreshCookies(res, { sessionId, token: refreshToken, days }, req);
const accessToken = makeAccessToken(id, sessionId);
return res.status(201).json({ user: { id, username, email: email || null }, accessToken, sessionId });
});
r.post('/auth/login', loginLimiter, async (req, res) => {
const rawUsername = (req.body?.username ?? '').trim();
const email = (req.body?.email ?? '')?.trim() || null;
const password = req.body?.password ?? '';
const rememberMe = !!req.body?.rememberMe;
const username = rawUsername || (email || '');
if (!username || !password) return res.status(400).json({ error: 'username and password are required' });
const user = getUserByUsername(username);
const ip = getClientIp(req);
const ua = req.headers['user-agent'] || '';
if (!user) {
insertLoginAudit({ userId: null, username, ip, userAgent: ua, success: false, reason: 'user_not_found' });
return res.status(401).json({ error: 'invalid credentials' });
}
const ok = await verifyPassword(password, user.password_hash);
if (!ok) {
insertLoginAudit({ userId: user.id, username, ip, userAgent: ua, success: false, reason: 'invalid_password' });
return res.status(401).json({ error: 'invalid credentials' });
}
const sessionId = cryptoRandomId();
const refreshToken = cryptoRandomId();
const refreshTokenHash = await hashToken(refreshToken);
const days = rememberMe ? REMEMBER_TTL_DAYS : REFRESH_TTL_DAYS;
const expiresAt = new Date(Date.now() + days * 86400_000).toISOString();
insertSession({ id: sessionId, userId: user.id, refreshTokenHash, isRemember: !!rememberMe, userAgent: ua, deviceInfo: '', ip, expiresAt });
setUserLastLogin(user.id);
insertLoginAudit({ userId: user.id, username, ip, userAgent: ua, success: true });
setRefreshCookies(res, { sessionId, token: refreshToken, days }, req);
const accessToken = makeAccessToken(user.id, sessionId);
return res.json({ user: { id: user.id, username: user.username, email: user.email }, accessToken, sessionId });
});
// -------------------- Connexion avec Google (compte Google au lieu du compte interne) --------------------
// Même OAuth que l'import : le consentement `youtube.readonly` sert ensuite
// directement à l'import abonnements + favoris (aucun 2e consentement).
// Comptes Google-only : password_hash aléatoire (login mot de passe impossible).
function frontBaseForOAuth(req) {
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();
const publicPort = String(process.env.OAUTH_PUBLIC_PORT || process.env.HOST_PORT || '').trim();
if (host && publicPort) return `${proto}://${host.split(':')[0]}:${publicPort}`;
if (host) return `${proto}://${host}`;
} catch {}
return 'http://localhost:4200';
}
r.get('/auth/google/url', loginLimiter, (req, res) => {
try {
const status = oauthStatus().google;
if (!status?.configured) return res.status(503).json({ error: 'google_oauth_not_configured', missing: status?.missing || [] });
const state = createOAuthState(null, 'google', 'login');
return res.json({ url: buildAuthUrl('google', state, req) });
} catch (error) {
return res.status(error?.status || 500).json({ error: error?.message || 'google_auth_url_failed' });
}
});
/**
* Finalise un login Google à partir d'un state `login` déjà consommé :
* échange code → profil → find-or-create → session + cookies → front.
* Partagé par /api/auth/google/callback ET /api/oauth/google/callback
* (buildAuthUrl n'enregistre qu'une seule redirect URI côté Google).
*/
async function completeGoogleLogin(req, res, frontBase, entry, code) {
const fail = (code) => res.redirect(302, `${frontBase}/auth/login?error=${encodeURIComponent(code)}`);
try {
const tokens = await exchangeCode('google', String(code), req);
if (!tokens?.accessToken) return fail('google_token_failed');
const profile = await fetchGoogleProfile(tokens.accessToken);
if (!profile?.id) return fail('google_profile_failed');
const email = String(profile.email || '').trim() || null;
// 1) Compte déjà lié à ce Google sub → lui. 2) Email identique → on lie.
// 3) Sinon création (username dérivé, suffixe si collision).
let user = getUserByOAuth('google', profile.id);
if (!user && email) user = getUserByEmail(email);
if (!user) {
const base = String(profile.displayName || (email ? email.split('@')[0] : 'google') || 'google')
.trim().replace(/\s+/g, ' ').slice(0, 40) || 'google-user';
let username = base;
let n = 0;
while (getUserByUsername(username)) {
n++;
username = `${base} ${n}`.slice(0, 40);
if (n > 50) return fail('username_taken');
}
const id = cryptoRandomUUID();
await insertUser({ id, username, email, passwordHash: `oauth-google:${cryptoRandomId()}` });
user = getUserById(id);
}
if (!user) return fail('google_login_failed');
upsertOAuthConnection({
userId: user.id, provider: 'google', externalUserId: profile.id,
displayName: profile.displayName || null, avatarUrl: profile.avatarUrl || null,
accessToken: tokens.accessToken, refreshToken: tokens.refreshToken,
expiresAt: tokens.expiresAt, scopes: tokens.scopes,
});
const sessionId = cryptoRandomId();
const refreshToken = cryptoRandomId();
const refreshTokenHash = await hashToken(refreshToken);
const days = REMEMBER_TTL_DAYS;
const expiresAt = new Date(Date.now() + days * 86400_000).toISOString();
const ua = req.headers['user-agent'] || '';
insertSession({ id: sessionId, userId: user.id, refreshTokenHash, isRemember: true, userAgent: ua, deviceInfo: '', ip: getClientIp(req), expiresAt });
setUserLastLogin(user.id);
insertLoginAudit({ userId: user.id, username: user.username, ip: getClientIp(req), userAgent: ua, success: true, reason: 'google' });
setRefreshCookies(res, { sessionId, token: refreshToken, days }, req);
return res.redirect(302, `${frontBase}/auth/google/callback?connected=1`);
} catch {
return fail('google_login_failed');
}
}
r.get('/auth/google/callback', loginLimiter, async (req, res) => {
const frontBase = frontBaseForOAuth(req);
const fail = (code) => res.redirect(302, `${frontBase}/auth/login?error=${encodeURIComponent(code)}`);
try {
if (req.query?.error) return fail(String(req.query.error_description || req.query.error));
const { code, state } = req.query || {};
if (!code || !state) return fail('google_missing_code_or_state');
const entry = consumeOAuthState(String(state));
if (!entry || entry.provider !== 'google' || entry.purpose !== 'login') return fail('google_invalid_state');
return await completeGoogleLogin(req, res, frontBase, entry, String(code));
} catch {
return fail('google_login_failed');
}
});
r.post('/auth/refresh', async (req, res) => {
const { sid, refreshToken } = req.cookies || {};
if (!sid || !refreshToken) return res.status(401).json({ error: 'Unauthorized' });
const session = getSessionById(sid);
if (!session || session.revoked_at) return res.status(401).json({ error: 'Unauthorized' });
const ok = await bcrypt.compare(refreshToken, session.refresh_token_hash);
if (!ok) return res.status(401).json({ error: 'Unauthorized' });
// rotate token
const nextToken = cryptoRandomId();
const nextHash = await hashToken(nextToken);
const days = session.isRemember ? REMEMBER_TTL_DAYS : REFRESH_TTL_DAYS;
const expiresAt = new Date(Date.now() + days * 86400_000).toISOString();
updateSessionToken(session.id, nextHash, expiresAt);
setRefreshCookies(res, { sessionId: session.id, token: nextToken, days }, req);
const accessToken = makeAccessToken(session.user_id, session.id);
return res.json({ accessToken });
});
r.post('/auth/logout', (req, res) => {
const { allDevices } = req.body || {};
const { sid } = req.cookies || {};
if (sid) {
if (allDevices) {
const session = getSessionById(sid);
if (session) revokeAllUserSessions(session.user_id);
} else {
revokeSession(sid);
}
}
clearRefreshCookies(res);
return res.status(204).end();
});
r.get('/auth/sessions', authMiddleware, (req, res) => {
const items = listUserSessions(req.user.id);
return res.json(items);
});
r.delete('/auth/sessions/:id', authMiddleware, (req, res) => {
const { id } = req.params;
const session = getSessionById(id);
if (!session || session.user_id !== req.user.id) return res.status(404).json({ error: 'not_found' });
revokeSession(id);
return res.status(204).end();
});
r.get('/user/me', authMiddleware, (req, res) => {
const u = getUserById(req.user.id);
if (!u) return res.status(404).json({ error: 'not_found' });
return res.json({ id: u.id, username: u.username, email: u.email, created_at: u.created_at, last_login_at: u.last_login_at });
});
r.get('/user/preferences', authMiddleware, (req, res) => {
const prefs = getPreferencesForApi(req.user.id);
return res.json(prefs || {});
});
r.patch('/user/preferences', authMiddleware, (req, res) => {
const patch = req.body || {};
upsertPreferences(req.user.id, patch);
const prefs = getPreferencesForApi(req.user.id);
return res.json(prefs || {});
});
// --- Telemetry: minimal anonymous product events (Step 13) ---
const telemetryLimiter = rateLimit({ windowMs: 60 * 1000, max: 120, standardHeaders: true, legacyHeaders: false });
r.post('/telemetry/events', authMiddleware, telemetryLimiter, (req, res) => {
const { event, meta } = req.body || {};
if (!event || typeof event !== 'string') return res.status(400).json({ error: 'event_required' });
// Whitelist known event names to keep the table clean
const allowed = new Set(['search_submit', 'provider_picker_open', 'provider_apply', 'at_autocomplete_use', 'quick_menu_open', 'filter_panel_open', 'filter_apply', 'suggest_shown', 'suggest_used']);
if (!allowed.has(event)) return res.status(400).json({ error: 'unknown_event' });
const row = insertTelemetryEvent({ userId: req.user.id, event, meta: (meta && typeof meta === 'object') ? meta : null });
return res.status(201).json(row || { ok: true });
});
r.get('/telemetry/events', authMiddleware, (req, res) => {
// Admin-ish introspection: only the user's own events
const rows = listTelemetryEvents({
event: typeof req.query.event === 'string' ? req.query.event : undefined,
limit: Math.min(500, Number(req.query.limit || 100)),
since: typeof req.query.since === 'string' ? req.query.since : undefined,
});
const mine = rows.filter(row => row.userId === req.user.id);
return res.json(mine);
});
r.get('/telemetry/summary', authMiddleware, (req, res) => {
const since = typeof req.query.since === 'string' ? req.query.since : undefined;
const events = {};
for (const name of ['search_submit', 'provider_picker_open', 'provider_apply', 'at_autocomplete_use', 'quick_menu_open', 'filter_panel_open', 'filter_apply', 'suggest_shown', 'suggest_used']) {
events[name] = countTelemetryEvents({ event: name, since });
}
return res.json({ events });
});
// --- History: Search ---
r.post('/user/history/search', authMiddleware, (req, res) => {
const { query, filters } = req.body || {};
if (!query || typeof query !== 'string') return res.status(400).json({ error: 'query required' });
const row = insertSearchHistory({ userId: req.user.id, query, filters });
return res.status(201).json(row);
});
// Import en lot des recherches Takeout (historique-recherches.html).
r.post('/user/history/search/batch', 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 query = String(it?.query || '').trim().slice(0, 300);
if (!query) { skipped++; continue; }
try {
insertSearchHistoryAt({ userId: req.user.id, query, createdAt: it?.createdAt });
imported++;
} catch { skipped++; }
}
return res.json({ imported, skipped, total: items.length });
} catch {
return res.status(500).json({ error: 'search_batch_failed' });
}
});
r.get('/user/history/search', authMiddleware, (req, res) => {
const limit = Math.min(200, Number(req.query.limit || 50));
const before = req.query.before ? String(req.query.before) : undefined;
const q = typeof req.query.q === 'string' ? req.query.q : undefined;
const provider = typeof req.query.provider === 'string' ? req.query.provider : undefined;
const rows = listSearchHistory({ userId: req.user.id, limit, before, q, provider });
return res.json(rows);
});
r.delete('/user/history/search/:id', authMiddleware, (req, res) => {
deleteSearchHistoryById(req.user.id, req.params.id);
return res.status(204).end();
});
r.delete('/user/history/search', authMiddleware, (req, res) => {
if (String(req.query.all || '') !== '1') return res.status(400).json({ error: 'set all=1' });
deleteAllSearchHistory(req.user.id);
return res.status(204).end();
});
// --- History: Watch ---
r.post('/user/history/watch', authMiddleware, (req, res) => {
const { provider, videoId, title, thumbnail, watchedAt, progressSeconds, durationSeconds, lastPositionSeconds } = req.body || {};
if (!provider || !videoId) return res.status(400).json({ error: 'provider and videoId are required' });
const row = upsertWatchHistory({ userId: req.user.id, provider, videoId, title, thumbnail, watchedAt, progressSeconds, durationSeconds, lastPositionSeconds });
return res.status(201).json(row);
});
r.get('/user/history/watch', authMiddleware, (req, res) => {
const limit = Math.min(200, Number(req.query.limit || 50));
const before = req.query.before ? String(req.query.before) : undefined;
const q = typeof req.query.q === 'string' ? req.query.q : undefined;
const provider = typeof req.query.provider === 'string' ? req.query.provider : undefined;
const rows = listWatchHistory({ userId: req.user.id, limit, before, q, provider });
return res.json(rows);
});
// Delete a single watch history item
r.delete('/user/history/watch/:id', authMiddleware, (req, res) => {
const { id } = req.params;
if (!id) return res.status(400).json({ error: 'id is required' });
try {
deleteWatchHistoryById(req.user.id, id);
return res.status(204).end();
} catch (e) {
return res.status(500).json({ error: 'delete_failed', details: String(e?.message || e) });
}
});
r.patch('/user/history/watch/:id', authMiddleware, (req, res) => {
const { progressSeconds, lastPositionSeconds } = req.body || {};
const row = updateWatchHistoryById(req.params.id, { progressSeconds, lastPositionSeconds });
if (!row) return res.status(404).json({ error: 'not_found' });
return res.json(row);
});
r.delete('/user/history/watch', authMiddleware, (req, res) => {
if (String(req.query.all || '') !== '1') return res.status(400).json({ error: 'set all=1' });
deleteAllWatchHistory(req.user.id);
return res.status(204).end();
});
// --- History: Transcripts (conservés, rejoués sans régénération) ---
r.post('/user/history/transcripts', authMiddleware, (req, res) => {
const { provider, videoId, title, thumbnail, lang, languages, lines } = req.body || {};
if (!provider || !videoId || !lang) {
return res.status(400).json({ error: 'provider, videoId and lang are required' });
}
if (!Array.isArray(lines) || !lines.length) {
return res.status(400).json({ error: 'lines required' });
}
try {
const row = upsertTranscriptHistory({
userId: req.user.id, provider, videoId, title, thumbnail, lang, languages, lines,
});
if (!row) return res.status(400).json({ error: 'invalid_transcript' });
return res.status(201).json(row);
} catch (e) {
return res.status(500).json({ error: 'save_failed', details: String(e?.message || e) });
}
});
r.get('/user/history/transcripts', authMiddleware, (req, res) => {
const limit = Math.min(200, Number(req.query.limit || 50));
const before = req.query.before ? String(req.query.before) : undefined;
const q = typeof req.query.q === 'string' ? req.query.q : undefined;
const provider = typeof req.query.provider === 'string' ? req.query.provider : undefined;
const lang = typeof req.query.lang === 'string' ? req.query.lang : undefined;
try {
const rows = listTranscriptHistory({ userId: req.user.id, limit, before, q, provider, lang });
return res.json(rows);
} catch (e) {
return res.status(500).json({ error: 'list_failed', details: String(e?.message || e) });
}
});
r.get('/user/history/transcripts/:provider/:videoId', authMiddleware, (req, res) => {
const { provider, videoId } = req.params;
const lang = typeof req.query.lang === 'string' ? req.query.lang : undefined;
try {
const row = getTranscriptHistoryItem({ userId: req.user.id, provider, videoId, lang });
if (!row) return res.status(404).json({ available: false, error: 'not_found' });
return res.json({ available: true, fromHistory: true, ...row });
} catch (e) {
return res.status(500).json({ available: false, error: 'lookup_failed' });
}
});
r.delete('/user/history/transcripts/:id', authMiddleware, (req, res) => {
try {
deleteTranscriptHistoryById(req.user.id, req.params.id);
return res.status(204).end();
} catch (e) {
return res.status(500).json({ error: 'delete_failed', details: String(e?.message || e) });
}
});
r.delete('/user/history/transcripts', authMiddleware, (req, res) => {
if (String(req.query.all || '') !== '1') return res.status(400).json({ error: 'set all=1' });
const provider = typeof req.query.provider === 'string' && req.query.provider ? String(req.query.provider) : undefined;
try {
deleteAllTranscriptHistory(req.user.id, provider);
return res.status(204).end();
} catch (e) {
return res.status(500).json({ error: 'delete_failed', details: String(e?.message || e) });
}
});
// --- Listes vidéo par tag : « vidéos aimées » + « à regarder plus tard » ---
// NOTE : cookie-aware (et non Bearer seul) : le front s'authentifie via
// cookies httpOnly + Bearer en mémoire (perdu au F5) ; exiger le Bearer
// seul renvoyait 401 « Unauthorized » même connecté.
// `tagName` = nom de ligne dans `tags` ; c'est le SEUL paramètre qui change
// d'une liste à l'autre, les 4 routes sont donc enregistrées par une fabrique.
function registerVideoTagRoutes(prefix, tagName) {
r.get(prefix, authMiddlewareCookieAware, (req, res) => {
const limit = Math.min(500, Number(req.query.limit || 100));
const q = typeof req.query.q === 'string' ? req.query.q : undefined;
const rows = listLikedVideos({ userId: req.user.id, limit, q, tag: tagName });
return res.json(rows);
});
r.post(prefix, authMiddlewareCookieAware, async (req, res) => {
let { provider, videoId, title, thumbnail } = req.body || {};
if (!provider || !videoId) return res.status(400).json({ error: 'provider and videoId are required' });
// Server-side enrichment: if title or thumbnail is missing, fetch minimal details via yt-dlp
try {
const needTitle = !(typeof title === 'string' && title.trim().length > 0);
const needThumb = !(typeof thumbnail === 'string' && thumbnail.trim().length > 0);
if (needTitle || needThumb) {
const url = providerUrlFrom(provider, videoId, { instance: req.query.instance, slug: req.query.slug, sourceUrl: req.query.sourceUrl });
try {
const raw = await youtubedl(url, { dumpSingleJson: true, noWarnings: true, noCheckCertificates: true, skipDownload: true });
const meta = (typeof raw === 'string') ? JSON.parse(raw || '{}') : (raw || {});
if (needTitle) title = meta?.title || title || '';
if (needThumb) thumbnail = meta?.thumbnail || (Array.isArray(meta?.thumbnails) && meta.thumbnails.length ? meta.thumbnails[0].url : thumbnail || '');
} catch {}
}
} catch {}
const row = likeVideo({ userId: req.user.id, provider, videoId, title, thumbnail, tag: tagName });
return res.status(201).json(row);
});
r.delete(prefix, authMiddlewareCookieAware, (req, res) => {
const provider = req.query.provider ? String(req.query.provider) : '';
const videoId = req.query.videoId ? String(req.query.videoId) : '';
if (!provider || !videoId) return res.status(400).json({ error: 'provider and videoId are required' });
const result = unlikeVideo({ userId: req.user.id, provider, videoId, tag: tagName });
return res.json(result);
});
// Status for a specific video
r.get(`${prefix}/status`, authMiddlewareCookieAware, (req, res) => {
const provider = req.query.provider ? String(req.query.provider) : '';
const videoId = req.query.videoId ? String(req.query.videoId) : '';
if (!provider || !videoId) return res.status(400).json({ error: 'provider and videoId are required' });
const liked = isVideoLiked({ userId: req.user.id, provider, videoId, tag: tagName });
return res.json({ liked });
});
}
registerVideoTagRoutes('/user/likes', 'like');
registerVideoTagRoutes('/user/watch-later', 'watch-later');
// Odysee image proxy to avoid CORS/ORB issues
r.get('/img/odysee', async (req, res) => {
try {
const u = String(req.query.u || '').trim();
if (!u) return res.status(400).json({ error: 'missing_url' });
let target = u;
// Ensure absolute https URL
if (target.startsWith('//')) target = 'https:' + target;
if (!/^https?:\/\//i.test(target)) target = 'https://' + target.replace(/^\/*/, '');
// Parse and validate host
let parsed;
try { parsed = new URL(target); } catch { return res.status(400).json({ error: 'invalid_url' }); }
const host = parsed.hostname.toLowerCase();
const allowed = new Set([
'thumbnails.odycdn.com',
'thumbs.odycdn.com',
'thumb.odycdn.com',
'static.odycdn.com',
'images.odycdn.com',
'cdn.lbryplayer.xyz',
'thumbnails.lbry.com',
'thumbnails.lbry.tech'
]);
const headers = {
'Accept': 'image/avif,image/webp,image/apng,image/*,*/*;q=0.8',
'Referer': 'https://odysee.com/',
'User-Agent': 'Mozilla/5.0'
};
// Build candidates: extract inner URL after '/plain/' when present, try host alternates, toggle extension, strip query
function extractPlainUrl(href) {
try {
const idx = href.indexOf('/plain/');
if (idx !== -1) {
const tail = href.substring(idx + 7); // after '/plain/'
// Tail can be absolute URL possibly percent-encoded
try { return new URL(tail).toString(); } catch {}
try { return new URL(decodeURIComponent(tail)).toString(); } catch {}
}
} catch {}
return '';
}
function toggleExt(u0) {
try {
const u1 = new URL(u0);
if (/\.webp(\?|$)/i.test(u1.pathname)) u1.pathname = u1.pathname.replace(/\.webp(\?|$)/i, '.jpg$1');
else if (/\.jpg(\?|$)/i.test(u1.pathname)) u1.pathname = u1.pathname.replace(/\.jpg(\?|$)/i, '.webp$1');
return u1.toString();
} catch { return u0; }
}
function stripQuery(u0) {
try { const u1 = new URL(u0); u1.search = ''; return u1.toString(); } catch { return u0; }
}
const hosts = ['thumbs.odycdn.com','thumbnails.lbry.com'];
// const hosts = ['thumbnails.odycdn.com','thumbnails.lbry.com','thumbs.odycdn.com','thumb.odycdn.com','static.odycdn.com','images.odycdn.com','thumbnails.lbry.tech','cdn.lbryplayer.xyz'];
function swapHost(u0, h) { try { const u1 = new URL(u0); u1.hostname = h; return u1.toString(); } catch { return u0; } }
const baseHref = parsed.toString();
const inner = extractPlainUrl(baseHref);
const seed = inner && (() => { try { const p = new URL(inner); return allowed.has(p.hostname.toLowerCase()) ? inner : ''; } catch { return ''; } })() || baseHref;
const candidates = new Set();
candidates.add(seed);
candidates.add(stripQuery(seed));
candidates.add(toggleExt(seed));
// host alternatives
for (const h of hosts) { candidates.add(swapHost(seed, h)); }
// If it contained optimize/plain, also try removing that segment entirely
try {
if (/\/optimize\//.test(seed) && /\/plain\//.test(seed)) {
const idx = seed.indexOf('/plain/');
const after = seed.substring(idx + 7);
try { const direct = new URL(after).toString(); candidates.add(direct); candidates.add(stripQuery(direct)); candidates.add(toggleExt(direct)); } catch {}
try { const dec = new URL(decodeURIComponent(after)).toString(); candidates.add(dec); candidates.add(stripQuery(dec)); candidates.add(toggleExt(dec)); } catch {}
}
} catch {}
// Try sequentially and stream the first success
let lastStatus = 0;
for (const href of candidates) {
try {
const u2 = new URL(href);
if (!allowed.has(u2.hostname.toLowerCase())) continue;
const upstream = await axios.get(u2.toString(), { responseType: 'stream', maxRedirects: 3, timeout: 15000, headers, validateStatus: s => s >= 200 && s < 400 });
const ctype = upstream.headers['content-type'] || 'image/jpeg';
const clen = upstream.headers['content-length'];
res.setHeader('Content-Type', ctype);
if (clen) res.setHeader('Content-Length', String(clen));
res.setHeader('Cache-Control', 'public, max-age=600');
upstream.data.pipe(res);
return; // success
} catch (e) {
lastStatus = e?.response?.status || lastStatus || 0;
continue;
}
}
// All attempts failed
return res.status(lastStatus || 502).json({ error: 'odysee_img_proxy_error', details: 'no_variant_succeeded' });
} catch (e) {
const status = e?.response?.status || 502;
return res.status(status).json({ error: 'odysee_img_proxy_error', details: String(e?.message || e) });
}
});
// Mount API router (prod) and alias for dev proxy
// Phase 4.5 : purge de fond du cache de recherche (toutes les 10 min).
// `unref()` est indispensable : sans lui le timer empeche le process de
// s'arreter, y compris dans les tests qui demarrent puis ferment le serveur.
const SEARCH_CACHE_JANITOR_MS = Math.max(60e3, Number(process.env.SEARCH_CACHE_PRUNE_MS || 10 * 60 * 1000));
let searchCacheJanitorStarted = false;
function startSearchCacheJanitor() {
if (searchCacheJanitorStarted) return;
searchCacheJanitorStarted = true;
const tick = async () => {
try {
const { pruneSearchCache, purgeProviderMetrics } = await import('./db.mjs');
const purged = pruneSearchCache();
const metrics = purgeProviderMetrics();
if (purged || metrics) console.log(`[cache] purge: ${purged} entrées search_cache, ${metrics} lignes provider_metrics`);
} catch (e) {
console.warn('[cache] purge échouée:', e?.message || e);
}
};
const timer = setInterval(tick, SEARCH_CACHE_JANITOR_MS);
timer.unref?.();
// Une purge immédiate au boot : une base qui a tourné des semaines hors ligne
// peut accumuler des lignes expirees en masse.
setTimeout(tick, 2000).unref?.();
}
// Phase 4.2 : migration `youtube_search_cache` -> `search_cache`, une fois au boot.
// Idempotente et non destructive : l'ancienne table reste lue en repli.
(async () => {
try {
const { migrateYoutubeCacheToSearchCache } = await import('./db.mjs');
const r = migrateYoutubeCacheToSearchCache();
if (r?.migrated > 0) console.log(`[cache] migration youtube_search_cache -> search_cache: ${r.migrated} ligne(s) copiée(s)`);
} catch (e) {
console.warn('[cache] migration ignorée:', e?.message || e);
}
})();
// Phase 4.3 : observabilité fournisseurs. Aucun secret exposé.
const PROVIDER_IDS = ['yt', 'dm', 'tw', 'pt', 'od', 'ru'];
// Sonde par provider : une recherche `limit=1`, résultat mis en cache 60 s
// (sans ce garde-fou, `/providers/health` créerait l'inverse du problème qu'il
// mesure en martelant les upstreams).
const healthProbeCache = new Map();
const HEALTH_PROBE_TTL_MS = Math.max(5e3, Number(process.env.PROVIDER_HEALTH_TTL_MS || 60e3));
async function probeProvider(pid) {
const cached = healthProbeCache.get(pid);
if (cached && Date.now() - cached.at < HEALTH_PROBE_TTL_MS) return cached.value;
const t0 = Date.now();
let value;
try {
const { providerRegistry } = await import('./providers/registry.mjs');
const adapter = providerRegistry[pid];
if (!adapter || typeof adapter.search !== 'function') throw new Error('adapter_absent');
const items = await adapter.search('test', { limit: 1, page: 1, sort: 'relevance' });
value = {
ok: true, latencyMs: Date.now() - t0, itemCount: Array.isArray(items) ? items.length : 0,
lastSuccessAt: new Date().toISOString(), lastError: null,
};
} catch (e) {
value = { ok: false, latencyMs: Date.now() - t0, itemCount: 0, lastSuccessAt: null, lastError: String(e?.message || e).slice(0, 300) };
}
healthProbeCache.set(pid, { at: Date.now(), value });
return value;
}
r.get('/providers/health', async (req, res) => {
try {
const only = String(req.query.provider || '').trim();
const ids = only ? (PROVIDER_IDS.includes(only) ? [only] : []) : PROVIDER_IDS;
if (!ids.length) return res.status(400).json({ error: 'unknown_provider' });
const snapshot = (await import('./db.mjs')).providerMetricsSnapshot({ hours: 1 });
const entries = await Promise.all(ids.map(async (pid) => {
// Phase 8.3 : on ne sonde PAS un provider désactivé par feature flag.
// Sonder un upstream volontairement éteint renverrait `ok: false`, alors
// qu'il n'est pas en panne : `disabled` distingue « éteint » de « cassé ».
const flag = providerFlag(pid);
if (!flag.enabled) {
return [pid, {
ok: false, disabled: true, flag: flag.flag, flagValue: flag.raw,
latencyMs: 0, itemCount: 0, lastSuccessAt: null, lastError: 'disabled_by_ff',
consecutiveFailures: 0, errorRate: 0,
}];
}
const probe = await probeProvider(pid);
const row = snapshot.find((x) => x.provider === pid);
return [pid, {
...probe,
disabled: false,
flag: flag.flag, flagValue: flag.raw,
consecutiveFailures: row?.errors || 0, errorRate: row?.errorRate || 0,
}];
}));
res.json({ providers: Object.fromEntries(entries) });
} catch (e) {
res.status(500).json({ error: String(e?.message || e) });
}
});
r.get('/providers/metrics', async (_req, res) => {
try {
const dbm = await import('./db.mjs');
res.json({
cache: dbm.searchCacheStats?.() || [],
providers: dbm.providerMetricsSnapshot?.({ hours: 1 }) || [],
youtube: { today: dbm.getYoutubeMetricsToday?.() || null },
});
} catch (e) {
res.status(500).json({ error: String(e?.message || e) });
}
});
app.use('/api', r);
// Health endpoint for container checks
app.get('/api/health', (_req, res) => res.json({ status: 'ok' }));
// Step 17 : observabilité YouTube (mode, yt-dlp, cache, quota). Aucun secret exposé.
app.get(['/healthz', '/api/healthz'], async (_req, res) => {
try {
const [{ getYoutubeMetricsToday, countYoutubeCacheRows, searchCacheStats, providerMetricsSnapshot, countSearchCacheRows }, common] =
await Promise.all([import('./db.mjs'), import('./providers/youtube-common.mjs')]);
let ytdlpVersion = null;
let resolvedBin = null;
try {
resolvedBin = await common.resolveYtDlpBin();
const { stdout } = await execFileAsync(resolvedBin, ['--version'], { timeout: 10000 });
ytdlpVersion = String(stdout || '').trim().split('\n')[0].trim() || null;
} catch {}
const keys = common.getYouTubeKeys();
const banned = [];
try { for (const [k, until] of ytKeyBans || []) if (Date.now() < until) banned.push(`...${String(k).slice(-4)}`); } catch {}
res.json({
status: 'ok',
youtube: {
mode: getSearchMode(),
// Phase 4.4 : le mode reel peut differer de l'intention (bascule auto).
effectiveMode: common.getEffectiveSearchMode?.() || getSearchMode(),
failover: common.getFailoverState?.() || null,
ytdlp: { bin: resolvedBin || getYtDlpBin(), version: ytdlpVersion, info: ytDlpInfo, binOk: Boolean(ytdlpVersion) },
antiban: {
cookiesFile: hasCookiesFile(),
poToken: Boolean(String(process.env.YT_PO_TOKEN || '').trim()),
egressProxy: Boolean(String(process.env.YT_EGRESS_PROXY || '').trim()),
},
cache: { ...ytScrapeCacheStats(), sqliteRows: countYoutubeCacheRows() },
metrics: { ...metricsSnapshot(), today: getYoutubeMetricsToday() },
keys: { count: keys.length, banned },
},
// Phase 4.3 : résumé multi-fournisseurs. Volontairement compact — le
// détail est sur `/api/providers/metrics`.
providers: {
cache: { rows: countSearchCacheRows?.() || 0, byProvider: searchCacheStats?.() || [] },
lastHour: providerMetricsSnapshot?.({ hours: 1 }) || [],
},
});
} catch (e) {
res.status(500).json({ status: 'error', error: String(e?.message || e) });
}
});
// Step 17 : trending YouTube sans clé (scrape) avec fallback [] propre.
app.get('/api/trending', async (req, res) => {
try {
const provider = String(req.query.provider || 'yt');
const limit = Math.min(50, Math.max(1, Number(req.query.limit || 24)));
if (provider !== 'yt') return res.status(400).json({ error: 'only yt supported in phase 1' });
const { getTrendingViaScrape } = await import('./providers/youtube-scrape.mjs');
const items = await getTrendingViaScrape(limit);
return res.json({ provider, items });
} catch (e) {
return res.json({ provider: 'yt', items: [], error: String(e?.code || e?.message || 'trending_failed') });
}
});
// Alias to support Angular dev proxy paths in both dev and production builds
app.use('/proxy/api', r);
// Mount dedicated Rumble router (browse, search, video)
// Idem transcript : exposé sous /api ET /proxy/api (fallback du front Watch).
app.use('/api/rumble', rumbleRouter);
app.use('/proxy/api/rumble', rumbleRouter);
// -------------------- Client config from environment --------------------
// WARNING: Values served here are exposed to the browser.
// Only browser-safe values belong here (e.g. referrer-restricted YouTube keys).
// Secrets like TWITCH_CLIENT_SECRET / GEMINI_API_KEY stay server-side and are
// consumed through dedicated server endpoints (/api/twitch-token, /api/ai/*).
function jsVal(v) { return JSON.stringify(v == null ? '' : v); }
app.get(['/assets/config.local.js', '/assets/config.js', '/config.js'], (_req, res) => {
const lines = [];
const env = process.env || {};
if (env.YOUTUBE_API_KEY) lines.push(`window.YOUTUBE_API_KEY = ${jsVal(env.YOUTUBE_API_KEY)};`);
if (env.YOUTUBE_API_KEYS) {
try {
// Accepte JSON array ('["k1","k2"]', fourni par ex. via compose) ou CSV ('k1,k2').
const raw = String(env.YOUTUBE_API_KEYS).trim();
let arr = [];
if (raw.startsWith('[')) {
try { arr = JSON.parse(raw); } catch { arr = []; }
if (!Array.isArray(arr)) arr = [];
} else {
arr = raw.split(',');
}
arr = arr.map(s => String(s || '').trim().replace(/^["'\[]+|["'\]]+$/g, '')).filter(Boolean);
if (arr.length) lines.push(`window.YOUTUBE_API_KEYS = ${JSON.stringify(arr)};`);
} catch {}
}
if (env.TWITCH_CLIENT_ID) lines.push(`window.TWITCH_CLIENT_ID = ${jsVal(env.TWITCH_CLIENT_ID)};`);
// Intentionally NOT exposed: TWITCH_CLIENT_SECRET, GEMINI_API_KEY — proxy via /api/twitch-token & /api/ai/*
res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
res.send(lines.join('\n'));
});
// -------------------- Twitch app token (server-side) --------------------
// Exchanges TWITCH_CLIENT_ID + TWITCH_CLIENT_SECRET server-side and returns the
// app access token to the client. The secret never leaves the server.
let twitchAppToken = null; // { token, expiresAtMs, clientId }
let twitchAppTokenPromise = null;
async function fetchTwitchAppToken() {
const clientId = process.env.TWITCH_CLIENT_ID;
const clientSecret = process.env.TWITCH_CLIENT_SECRET;
if (!clientId || !clientSecret) {
throw Object.assign(new Error('twitch_not_configured'), { status: 503 });
}
const body = new URLSearchParams({ client_id: clientId, client_secret: clientSecret, grant_type: 'client_credentials' });
const resp = await axios.post('https://id.twitch.tv/oauth2/token', body.toString(), {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
timeout: 15000,
validateStatus: s => s >= 200 && s < 500,
});
if (resp.status !== 200 || !resp.data?.access_token) {
throw Object.assign(new Error('twitch_token_failed'), { status: 502 });
}
const expiresInSec = Number(resp.data.expires_in || 0);
twitchAppToken = {
token: resp.data.access_token,
// Refresh 2 minutes before actual expiry
expiresAtMs: Date.now() + Math.max(60, expiresInSec - 120) * 1000,
clientId,
};
return twitchAppToken;
}
// Le registre de chaînes réutilise le token Twitch du serveur (cache + refresh
// centralisés) pour résoudre titres/avatars des chaînes Twitch.
try {
setTwitchTokenProvider(async () => (await fetchTwitchAppToken()).token);
} catch {}
app.get('/api/twitch-token', async (_req, res) => {
try {
if (twitchAppToken && Date.now() < twitchAppToken.expiresAtMs) {
return res.json({ accessToken: twitchAppToken.token, clientId: twitchAppToken.clientId });
}
if (!twitchAppTokenPromise) {
twitchAppTokenPromise = fetchTwitchAppToken().finally(() => { twitchAppTokenPromise = null; });
}
const t = await twitchAppTokenPromise;
return res.json({ accessToken: t.token, clientId: t.clientId });
} catch (e) {
const status = e?.status || 502;
return res.status(status).json({ error: e?.message || 'twitch_token_failed' });
}
});
// Top streams Twitch via le token serveur (repli fiable pour Home/Explore
// quand les appels Helix directs du navigateur échouent).
app.get('/api/twitch/top-streams', async (req, res) => {
try {
const first = Math.min(50, Math.max(1, parseInt(String(req.query.first || '24'), 10) || 24));
const { default: twitch } = await import('./providers/twitch.mjs');
const items = await twitch.topStreams(first);
return res.json({ items });
} catch (e) {
return res.status(502).json({ items: [], error: e?.message || 'twitch_top_streams_failed' });
}
});
// -------------------- Gemini summarize (server-side) --------------------
// Keeps GEMINI_API_KEY on the server. Client calls POST /api/ai/summarize.
app.get('/api/ai/status', (_req, res) => {
const ready = Boolean(process.env.GEMINI_API_KEY);
return res.json({ ready, reason: ready ? undefined : 'GEMINI_API_KEY is not configured on the server.' });
});
const aiLimiter = rateLimit({
windowMs: 60 * 1000,
max: 10,
standardHeaders: true,
legacyHeaders: false,
});
app.post('/api/ai/summarize', aiLimiter, async (req, res) => {
try {
if (!process.env.GEMINI_API_KEY) {
return res.status(503).json({ error: 'gemini_not_configured' });
}
const comments = Array.isArray(req.body?.comments) ? req.body.comments.filter(c => typeof c === 'string') : [];
if (comments.length === 0) return res.status(400).json({ error: 'comments_required' });
const capped = comments.slice(0, 500).map(c => String(c).slice(0, 2000));
const commentsText = capped.join('\n- ');
const prompt = `Please summarize the following YouTube comments. Provide a concise, neutral overview of the general sentiment and the main topics discussed. Do not add any preamble or sign-off. Just provide the summary. Here are the comments:\n\n- ${commentsText}`;
const resp = await axios.post(
`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${encodeURIComponent(process.env.GEMINI_API_KEY)}`,
{ contents: [{ parts: [{ text: prompt }] }] },
{ timeout: 30000, validateStatus: s => s >= 200 && s < 500 }
);
if (resp.status !== 200) {
return res.status(resp.status).json({ error: 'gemini_upstream_error', details: resp.data });
}
const text = resp.data?.candidates?.[0]?.content?.parts?.map(p => p?.text).filter(Boolean).join('') || '';
return res.json({ summary: text });
} catch (e) {
const status = e?.response?.status || 500;
return res.status(status).json({ error: 'summarize_failed', details: String(e?.message || e) });
}
});
// -------------------- Simple production proxies to avoid CORS --------------------
// Generic JSON forwarder helper
async function forwardJson(req, res, base) {
try {
// Accepte les deux préfixes : `/api/<key>` (prod canonique) ET
// `/proxy/<key>` (repli du front, défini dans proxy.conf.json pour le dev).
// Sans le second, les replis `/proxy/...` tombaient sur le fallback SPA
// (index.html) → erreur de parse JSON "status 200 / ok:false".
const pathPart = req.originalUrl.replace(/^\/(?:proxy\/)?(?:api\/)?(dm|odysee|twitch-api|twitch-auth)/, '');
const targetUrl = `${base}${pathPart}`;
const method = (req.method || 'GET').toUpperCase();
// Ne transfère que les headers utiles : tout le reste (cookies, content-length
// d'origine, UA navigateur, referer...) peut faire rejeter la requête en amont
// (ex. 403 CloudFront de api.twitch.tv).
const headers = {};
for (const [k, v] of Object.entries(req.headers || {})) {
const lk = String(k).toLowerCase();
if (['authorization', 'client-id', 'client_id', 'content-type', 'accept'].includes(lk) && v != null) {
headers[lk] = v;
}
}
const hasBody = !['GET', 'HEAD', 'OPTIONS'].includes(method) && req.body != null && !(typeof req.body === 'object' && Object.keys(req.body).length === 0);
const resp = await axios({
url: targetUrl,
method,
headers,
...(hasBody ? { data: req.body } : {}),
timeout: 20000,
validateStatus: s => s >= 200 && s < 500,
});
return res.status(resp.status).json(resp.data);
} catch (e) {
const status = e?.response?.status || 500;
const data = e?.response?.data || { error: 'proxy_error', details: String(e?.message || e) };
return res.status(status).json(data);
}
}
app.all('/api/dm/*', (req, res) => forwardJson(req, res, 'https://api.dailymotion.com'));
app.all('/api/odysee/*', (req, res) => forwardJson(req, res, 'https://api.na-backend.odysee.com'));
app.all('/api/twitch-api/*', (req, res) => forwardJson(req, res, 'https://api.twitch.tv'));
app.all('/api/twitch-auth/*', (req, res) => forwardJson(req, res, 'https://id.twitch.tv'));
// Alias `/proxy/...` : le front utilise ces chemins en repli (cf. proxy.conf.json
// pour le dev). En prod ils doivent répondre du JSON, pas le shell SPA.
app.all('/proxy/dm/*', (req, res) => forwardJson(req, res, 'https://api.dailymotion.com'));
app.all('/proxy/odysee/*', (req, res) => forwardJson(req, res, 'https://api.na-backend.odysee.com'));
app.all('/proxy/twitch-api/*', (req, res) => forwardJson(req, res, 'https://api.twitch.tv'));
app.all('/proxy/twitch-auth/*', (req, res) => forwardJson(req, res, 'https://id.twitch.tv'));
// -------------------- Unified search endpoint (GET) --------------------
// Phase 7.3 : le payload `?debug=1` (provenance + `raw` tronque et redige) est
// construit par `search-transport.mjs` : la troncature et la redaction sont une
// barriere de securite, elles meritent un module et des tests dedies.
// Phase 7.6 : deux TRANSPORTS pour un SEUL fan-out. `Accept: application/x-ndjson`
// (ou `?stream=1`) fait écrire une ligne JSON dès qu'un provider répond ; sinon
// la réponse atomique historique est renvoyée à l'identique. Les deux modes
// partagent le même code d'agrégation : impossible qu'ils divergent sur le
// filtrage ou la forme des erreurs.
app.get('/api/search', async (req, res) => {
try {
console.log('[SEARCH] Requête reçue - Query:', req.query);
const { q, providers } = req.query;
// Validation des paramètres
if (!q || typeof q !== 'string' || q.trim().length < 2) {
console.log('[SEARCH] Requête invalide - q manquant ou trop court:', q);
return res.status(400).json({ error: 'q is required and must be at least 2 characters long' });
}
const pageNum = Math.max(1, Number(req.query.page || 1));
const pageSize = Math.min(50, Math.max(1, Number(req.query.pageSize || 24)));
// Filtres de recherche (type / durée / période / tri). Un filtre inconnu est
// ignoré (jamais 400) : `normalizeFilters` retombe sur 'all'.
const filters = parseSearchFilters(req.query);
// Optional sort parameter (normalized to known set)
const allowedSort = new Set(['relevance', 'date', 'views']);
let sort = (typeof req.query.sort === 'string' ? req.query.sort.trim().toLowerCase() : 'relevance');
if (!allowedSort.has(sort)) sort = 'relevance';
// `filters.sort` fait autorité (le front envoie les deux, ils concordent)
sort = filters.sort;
// Validate and normalize providers list (default to all supported when none/invalid)
const requested = typeof providers === 'string' ? String(providers) : '';
// Phase 8.3 : on retire les providers désactivés par feature flag AVANT le
// fan-out (inutile d'appeler un upstream qu'on a choisi d'éteindre), et on
// le signale dans `errors` pour que la colonne vide soit explicable.
const { providerIds: validProviders, errors: flagErrors } = applyProviderFlags(validateProviders(requested));
for (const [pid, err] of Object.entries(flagErrors)) {
console.warn(`[search] provider ${pid} désactivé (${err.code})`);
}
// Phase 7.6 : transport incrémental. `Accept: text/event-stream` n'est pas
// utilisé volontairement — NDJSON se parse ligne à ligne sans parseur SSE, et
// reste rejouable / testable avec un simple client HTTP.
const wantsStream = String(req.headers.accept || '').includes(APPLICATION_NDJSON)
|| req.query.stream === '1';
let streamStarted = false;
if (wantsStream) {
// `X-Accel-Buffering: no` : derrière nginx (configuré par défaut), un
// buffer de réponse différerait TOUT le NDJSON jusqu'à la fin — on
// obtiendrait un streaming qui streame en un seul coup, c'est-à-dire rien.
res.setHeader('Content-Type', `${APPLICATION_NDJSON}; charset=utf-8`);
res.setHeader('Cache-Control', 'no-store');
res.setHeader('X-Accel-Buffering', 'no');
res.flushHeaders?.();
streamStarted = true;
}
/** Écrit une ligne du flux, si le client a demandé le streaming. */
const emit = (line) => {
if (!streamStarted) return;
// `res.writableEnded` : le client a pu annuler (nouvelle frappe) pendant
// qu'un provider répondait ; écrire dans le vide lèverait une erreur.
if (res.writableEnded || res.destroyed) return;
res.write(`${JSON.stringify(line)}\n`);
};
// Group results by provider id (+ per-provider errors for diagnosable UI)
const groups = /** @type {Record<string, any[]>} */ ({});
// Les providers désactivés par FF ont bien un groupe vide : le front
// affiche une pastille « aucun résultat » cohérente avec le reste.
for (const pid of Object.keys(flagErrors)) groups[pid] = [];
const errors = /** @type {Record<string, { message: string, status?: number, code?: string }>} */ ({ ...flagErrors });
// Exécution en parallèle, avec émission IMMÉDIATE de chaque groupe dès
// qu'il est résolu. L'agrégation est écrite UNE fois : le mode atomique et
// le mode NDJSON consomment exactement les mêmes objets.
await Promise.all(validProviders.map(async (providerId) => {
try {
const mod = providerRegistry[/** @type {any} */(providerId)];
const raw = (!mod || typeof mod.search !== 'function')
? []
: await mod.search(q, { limit: pageSize, page: pageNum, sort, filters });
// Filtres non supportés nativement par le provider -> affinage ici.
const items = applySearchFilters(providerId, Array.isArray(raw) ? raw : [], filters);
groups[providerId] = items;
emit({ type: 'provider', provider: providerId, ok: true, items });
} catch (r) {
console.warn(`Search failed for provider ${providerId}:`, r?.message || r);
groups[providerId] = [];
try {
const rr = /** @type {any} */ (r);
errors[providerId] = {
message: String(rr?.message || rr || 'search_failed'),
...(typeof rr?.ytStatus === 'number' ? { status: rr.ytStatus } : {}),
...(rr?.code ? { code: String(rr.code) } : {}),
};
} catch {}
emit({ type: 'provider', provider: providerId, ok: false, error: errors[providerId] || { message: 'search_failed' } });
}
}));
const debug = req.query.debug === '1' || req.query.debug === 'true';
if (streamStarted) {
// Ligne de clôture : tout le contrat (version, pagination, filtres), puis
// `end`. Le front peut donc valider l'arrivée complète sans deviner quand
// le flux se termine.
emit({ type: 'done', v: SUGGESTION_CONTRACT_VERSION, q, providers: validProviders, page: pageNum, pageSize, sort, filters: activeSearchFilters(filters) });
if (!res.writableEnded) res.end();
return undefined;
}
return res.json({
// Phase 0.4 - le contrat Suggestion est versionne : le front peut
// detecter un serveur ancien et degrader proprement au lieu de lire des
// champs `undefined` sans le savoir. 2 = champs optionnels (views,
// publishedAt, avatars, channelId, …). 1 = contrat d'origine.
v: SUGGESTION_CONTRACT_VERSION,
q, providers: validProviders, groups, errors,
page: pageNum, pageSize, sort,
filters: activeSearchFilters(filters),
// Phase 7.3 - mode debug : on expose le `raw` provider tronque. Volontairement
// HORS du contrat normal : un payload brut de 6 providers ferait exploser la
// reponse et peut contenir des cles d'API internes, donc uniquement si
// demande explicitement.
...(debug ? { debug: buildDebugPayload(groups, errors) } : {}),
});
} catch (e) {
// Une coupure du client ne doit pas logger une trace d'erreur serveur : en
// flux incrémental, abandonner la requête est le comportement NORMAL.
if (streamStarted && (res.writableEnded || res.destroyed)) return undefined;
return res.status(500).json({ error: 'search_failed', details: String(e?.message || e) });
}
});
// -------------------- Query typeahead suggestions (Step 15) --------------------
// GET /api/search/suggest?q=…&providers=yt,dm&limit=10 -> { q, groups: { yt: string[], dm: string[] } }
// Fan-out over provider `suggest()` handlers; providers without one degrade to [] (never 500).
const SUGGEST_CACHE_TTL_MS = Number(process.env.SUGGEST_CACHE_TTL_MS || 5 * 60 * 1000);
const SUGGEST_CACHE_MAX_ENTRIES = 500;
/** @type {Map<string, { ts: number, data: any }>} */
const suggestCache = new Map();
function suggestCacheGet(key) {
const hit = suggestCache.get(key);
if (!hit) return null;
if ((Date.now() - hit.ts) >= SUGGEST_CACHE_TTL_MS) {
suggestCache.delete(key);
return null;
}
// LRU refresh
suggestCache.delete(key);
suggestCache.set(key, hit);
return hit.data;
}
function suggestCacheSet(key, data) {
if (suggestCache.has(key)) suggestCache.delete(key);
suggestCache.set(key, { ts: Date.now(), data });
while (suggestCache.size > SUGGEST_CACHE_MAX_ENTRIES) {
const oldest = suggestCache.keys().next().value;
suggestCache.delete(oldest);
}
}
const suggestLimiter = rateLimit({
windowMs: 60 * 1000,
max: Number(process.env.SUGGEST_RATE_LIMIT || 60),
standardHeaders: true,
legacyHeaders: false,
});
// NOTE: montée sur le router `r` (et non `app`) pour être servie sous les
// DEUX préfixes `/api` et `/proxy/api` (cf. apiBase() côté front : en prod le
// front appelle `/proxy/api`, sinon fallback SPA -> index.html -> parse error
// et seules les suggestions locales s'affichaient). Même pattern que transcript.
r.get('/search/suggest', suggestLimiter, async (req, res) => {
try {
const rawQ = typeof req.query.q === 'string' ? req.query.q : '';
const q = rawQ.trim();
if (q.length < 2) {
return res.status(400).json({ error: 'q is required and must be at least 2 characters long' });
}
const limit = Math.min(20, Math.max(1, Number(req.query.limit || 10)));
const requested = typeof req.query.providers === 'string' ? String(req.query.providers) : '';
// Phase 8.3 : flags appliqués AVANT la clé de cache, sinon un résultat
// calculé pendant que le provider était éteint serait resservi après
// réactivation (et l'inverse). Inclus dans la clé de toute façon.
const { providerIds: validProviders, errors: flagErrors } = applyProviderFlags(validateProviders(requested));
const cacheKey = `suggest:${validProviders.join(',')}:${q.toLowerCase()}:${limit}`;
const cached = suggestCacheGet(cacheKey);
if (cached) return res.json(cached);
// Enrichissement gratuit (sans clé) lancé en parallèle du fan-out
// providers : suggestions web génériques (Bing + Google) + vrais titres
// vidéo Odysee (Lighthouse) quand `od` est demandé. Jamais bloquant.
const webEnabled = String(process.env.SUGGEST_WEB_ENABLED ?? '1') !== '0';
const wantOdysee = validProviders.includes('od');
const webPromise = webEnabled ? fetchWebSuggest(q, { limit }) : Promise.resolve([]);
const lighthousePromise = wantOdysee ? fetchOdyseeLighthouseSuggest(q, { limit }) : Promise.resolve([]);
const results = await Promise.allSettled(
validProviders.map((providerId) => {
const mod = providerRegistry[providerId];
if (!mod || typeof mod.suggest !== 'function') return Promise.resolve([]);
return Promise.resolve().then(() => mod.suggest(q, { limit }));
})
);
const [webRes, lightRes] = await Promise.allSettled([webPromise, lighthousePromise]);
const groups = {};
// Cohérence avec /api/search : un provider éteint a un groupe vide ET une
// raison, sinon le front ne peut pas distinguer « éteint » de « muet ».
for (const pid of Object.keys(flagErrors)) groups[pid] = [];
results.forEach((result, index) => {
const providerId = validProviders[index];
if (result.status === 'fulfilled' && Array.isArray(result.value)) {
groups[providerId] = result.value
.map((s) => String(s ?? '').trim())
.filter(Boolean)
.slice(0, limit);
} else {
groups[providerId] = [];
}
});
if (wantOdysee && lightRes.status === 'fulfilled' && Array.isArray(lightRes.value) && lightRes.value.length > 0) {
groups.od = [...(groups.od || []), ...lightRes.value].slice(0, limit);
}
if (webEnabled && webRes.status === 'fulfilled' && Array.isArray(webRes.value) && webRes.value.length > 0) {
groups.web = webRes.value.slice(0, limit);
}
// La même occurrence n'est renvoyée qu'une fois (providers d'abord, web en dernier).
const data = { q, groups: dedupeSuggestGroups(groups, [...validProviders, 'web']) };
if (Object.keys(flagErrors).length > 0) data.errors = flagErrors;
suggestCacheSet(cacheKey, data);
return res.json(data);
} catch (e) {
return res.status(500).json({ error: 'suggest_failed', details: String(e?.message || e) });
}
});
// -------------------- Video transcripts (Step 16, Phase 1) --------------------
// GET /api/transcript/:provider/:videoId?lang=&instance=&slug=&sourceUrl=
// -> { lang, available, languages, lines: [{ t, dur, text }] }
// One endpoint / one parser / one UI whatever the provider (yt-dlp subtitles).
const TRANSCRIPT_CACHE_TTL_MS = Number(process.env.TRANSCRIPT_CACHE_TTL || 24 * 60 * 60 * 1000);
const TRANSCRIPT_CACHE_MAX_ENTRIES = 200;
/** @type {Map<string, { ts: number, data: any }>} */
const transcriptCache = new Map();
function transcriptCacheGet(key) {
const hit = transcriptCache.get(key);
if (!hit) return null;
if ((Date.now() - hit.ts) >= TRANSCRIPT_CACHE_TTL_MS) {
transcriptCache.delete(key);
return null;
}
transcriptCache.delete(key);
transcriptCache.set(key, hit);
return hit.data;
}
function transcriptCacheSet(key, data) {
if (transcriptCache.has(key)) transcriptCache.delete(key);
transcriptCache.set(key, { ts: Date.now(), data });
while (transcriptCache.size > TRANSCRIPT_CACHE_MAX_ENTRIES) {
const oldest = transcriptCache.keys().next().value;
transcriptCache.delete(oldest);
}
}
const transcriptLimiter = rateLimit({
windowMs: 60 * 1000,
max: Number(process.env.TRANSCRIPT_RATE_LIMIT || 10),
standardHeaders: true,
legacyHeaders: false,
// Always answer JSON (default handler sends an HTML/text page, which the
// Angular HttpClient cannot parse as JSON and surfaces as a raw SyntaxError).
handler: (req, res) => {
return res.status(429).json({ available: false, error: 'rate_limited' });
},
});
/** Providers known NOT to expose subtitle tracks via yt-dlp (no transcript possible). */
const TRANSCRIPT_UNSUPPORTED_PROVIDERS = new Set(['twitch', 'odysee', 'rumble']);
/** Langues de transcripts autorisées : défaut fr+en, jamais de téléchargement hors liste. */
function sanitizeAllowedTranscriptLangs(raw) {
const supported = Array.isArray(SUPPORTED_DL_LANGS) && SUPPORTED_DL_LANGS.length
? SUPPORTED_DL_LANGS
: ['fr', 'en'];
const fallback = Array.isArray(DEFAULT_DL_LANGS) && DEFAULT_DL_LANGS.length
? DEFAULT_DL_LANGS
: ['fr', 'en'];
let arr = raw;
if (typeof arr === 'string') arr = arr.split(',');
if (!Array.isArray(arr)) return [...fallback];
const cleaned = arr
.map(v => String(v || '').trim().toLowerCase().replace(/_/g, '-').split('-')[0])
.filter(v => /^[a-z]{2,3}$/.test(v) && supported.includes(v));
return Array.from(new Set(cleaned));
}
function transcriptPrimary(lang) {
return String(lang || '').trim().toLowerCase().replace(/_/g, '-').split('-')[0];
}
/** Ne jamais exposer ni télécharger une langue non activée dans les préférences. */
function filterTranscriptLanguages(languages, allowed) {
const set = new Set((allowed || []).map(transcriptPrimary).filter(Boolean));
return (Array.isArray(languages) ? languages : []).filter(l => set.has(transcriptPrimary(l)));
}
/** Résout les langues autorisées : `?langs=` explicite > préférence user (JWT) > défaut fr,en. */
function resolveTranscriptAllowedLangs(req) {
const fromQuery = sanitizeAllowedTranscriptLangs(req.query?.langs);
const queryAsked = req.query?.langs !== undefined;
if (queryAsked) return fromQuery;
try {
const hdr = req.headers?.['authorization'] || '';
const [, token] = String(hdr).split(' ');
if (token) {
const payload = jwt.verify(token, JWT_SECRET);
const uid = payload?.sub;
if (uid) {
const prefs = getPreferencesForApi(uid);
if (prefs && Array.isArray(prefs.downloadLanguages)) {
return sanitizeAllowedTranscriptLangs(prefs.downloadLanguages);
}
}
}
} catch {}
return sanitizeAllowedTranscriptLangs(undefined);
}
/** Download subtitles via yt-dlp (handles YouTube impersonation + 429-prone
* translated tracks). Tries `langs` in order, returns the first non-empty
* parsed lines with the language that worked, or null. Bounded by a timeout
* (yt-dlp subtitle downloads can hang on .part files); partial results on
* disk are still scanned when yt-dlp exits non-zero. */
async function transcriptViaYtDlp(url, langs, netOpts = {}) {
const osMod = await import('node:os');
const dir = await fs.promises.mkdtemp(path.join(osMod.tmpdir(), 'newtube-transcript-'));
const scanDir = async (wanted) => {
let files = [];
try {
files = (await fs.promises.readdir(dir)).filter((f) => /\.vtt$/i.test(f) && !/\.part$/i.test(f));
} catch { return null; }
// Ignore stale/empty files
const nonEmpty = [];
for (const f of files) {
try {
const st = await fs.promises.stat(path.join(dir, f));
if (st.size > 0) nonEmpty.push(f);
} catch {}
}
// Prefer requested languages first
nonEmpty.sort((a, b) => {
const la = a.toLowerCase(), lb = b.toLowerCase();
const ia = wanted.findIndex((w) => la.includes(`.${w.toLowerCase()}.`) || la.endsWith(`.${w.toLowerCase()}.vtt`));
const ib = wanted.findIndex((w) => lb.includes(`.${w.toLowerCase()}.`) || lb.endsWith(`.${w.toLowerCase()}.vtt`));
return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib);
});
for (const file of nonEmpty) {
try {
const text = await fs.promises.readFile(path.join(dir, file), 'utf8');
const lines = parseVtt(text);
if (lines.length) {
const m = /\.([a-z]{2,3}(?:-[a-z]{2,4})?)\.vtt$/i.exec(file);
return { lines, lang: m ? m[1] : null };
}
} catch {}
}
return null;
};
try {
// Ne télécharger QUE les langues autorisées (aucun ajout forcé hors préférence).
const wanted = Array.from(new Set((langs || []).map((l) => String(l || '').split('-')[0]).filter(Boolean)));
if (!wanted.length) return null;
const subLangs = wanted.slice(0, 6).join(',');
const child = youtubedl(url, {
writeSub: true,
writeAutoSub: true,
subLangs,
subFormat: 'vtt/best',
skipDownload: true,
noWarnings: true,
noCheckCertificates: true,
noPlaylist: true,
// Espace les requêtes sous-titres : les rafales déclenchent des 429
// YouTube que les retries immédiats ne font qu'aggraver.
sleepRequests: 2,
sleepSubtitles: 5,
...netOpts,
output: path.join(dir, '%(id)s'),
});
const timer = setTimeout(() => { try { child.kill('SIGKILL'); } catch {} }, 90000);
try {
await child;
} catch (e) {
// Non-zero exit (e.g. one language 429'd) — partial files may still exist.
console.warn('[transcript] yt-dlp subtitle download exited non-zero:', String(e?.message || e).slice(0, 200));
} finally {
clearTimeout(timer);
}
return await scanDir(wanted);
} catch (e) {
console.warn('[transcript] yt-dlp subtitle download failed:', e?.message || e);
try {
const wanted = Array.from(new Set((langs || []).map((l) => String(l || '').split('-')[0]).filter(Boolean)));
return await scanDir(wanted);
} catch { return null; }
} finally {
try { await fs.promises.rm(dir, { recursive: true, force: true }); } catch {}
}
}
/** Build a `Cookie` header from a Netscape-format cookies file (YT_COOKIES_FILE).
* Lets plain timedtext fetches reuse the same trusted residential session as
* yt-dlp instead of going out bare (datacenter egress => 200-empty/429). */
function youtubeCookiesHeader() {
try {
const file = String(process.env.YT_COOKIES_FILE || '').trim();
if (!file || !fs.existsSync(file)) return null;
const raw = fs.readFileSync(file, 'utf8');
const pairs = [];
for (const line of raw.split('\n')) {
const l = line.trim();
if (!l || l.startsWith('#')) continue;
const parts = l.split('\t');
if (parts.length < 7) continue;
const domain = parts[0] || '';
const name = parts[5] || '';
const value = parts[6] || '';
if (!name || !/youtube\.com|googlevideo\.com|google\.com/i.test(domain)) continue;
pairs.push(`${name.trim()}=${value.trim()}`);
}
return pairs.length ? Array.from(new Set(pairs)).join('; ') : null;
} catch {
return null;
}
}
/** Fetch one timedtext track URL and parse it (any format). Throws on any failure. */
async function fetchTimedTextLines(track) {
// Les URLs signées yt-dlp portent déjà `fmt=` : ne jamais le dupliquer
// (signature invalidée => 429/Sorry).
const url = ensureFmtParam(String(track?.url || ''), transcriptTrackExt(track) === 'vtt' ? 'vtt' : 'json3');
const headers = {
'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',
Accept: 'application/json, text/vtt, text/*;q=0.9, */*;q=0.8',
'Accept-Language': 'en-US,en;q=0.9,fr;q=0.8',
};
const cookies = youtubeCookiesHeader();
if (cookies) headers.Cookie = cookies;
const resp = await fetch(url, {
headers,
signal: AbortSignal.timeout(10000),
});
if (!resp.ok) throw new Error(`track_fetch_failed:${resp.status}`);
const text = await resp.text();
// Throttle silencieux de YouTube sur les IPs de datacenter : HTTP 200 avec
// corps vide (ou page Sorry). Transitoire, comme un 429 — jamais "no subtitles".
if (isEmptyTimedTextBody(text)) throw new Error('track_fetch_failed:empty_200');
const lines = parseTrackText(text, transcriptTrackExt(track));
if (!lines.length) throw new Error('track_fetch_failed:unparsable');
return lines;
}
// NOTE: montée sur le router `r` (et non `app`) pour être servie sous les
// DEUX préfixes `/api` et `/proxy/api` (cf. apiBase() côté front + rewrite
// du proxy dev). Une route `app.get('/api/...')` ici répondrait index.html
// (fallback SPA) sous `/proxy/api/...` en prod.
r.get('/transcript/:provider/:videoId', transcriptLimiter, async (req, res) => {
try {
const { provider, videoId } = req.params;
// Langues autorisées par les préférences (défaut fr,en) : rien d'autre
// n'est affiché ni téléchargé.
const allowed = resolveTranscriptAllowedLangs(req);
let lang = String(req.query.lang || allowed[0] || 'fr').slice(0, 12) || 'fr';
if (!allowed.map(transcriptPrimary).includes(transcriptPrimary(lang))) {
lang = allowed[0] || 'fr';
}
const normalized = normalizeTranscriptProvider(provider);
if (!normalized || !videoId) {
return res.status(400).json({ available: false, error: 'invalid_provider_or_video' });
}
if (!allowed.length) {
return res.json({ lang: null, available: false, languages: [], lines: [], reason: 'no_subtitles' });
}
const cacheKey = `transcript:${normalized}:${videoId}:${lang.toLowerCase()}:${[...allowed].sort().join(',')}`;
const cached = transcriptCacheGet(cacheKey);
if (cached) return res.json(cached);
// Source de découverte des pistes : InnerTube d'abord pour YouTube
// (mêmes URLs timedtext, sans spawn yt-dlp), yt-dlp sinon/en secours.
// YT_TRANSCRIPT_SOURCE=innertube-first (défaut) | ytdlp-only | innertube-only
const transcriptSource = String(process.env.YT_TRANSCRIPT_SOURCE || 'innertube-first').trim().toLowerCase();
const transcriptUrl = providerUrlFrom(normalized, String(videoId), {
instance: req.query.instance || undefined,
slug: req.query.slug || undefined,
sourceUrl: req.query.sourceUrl || undefined,
});
let meta = null;
if (normalized === 'youtube' && transcriptSource !== 'ytdlp-only') {
try {
const { getCaptionTracksViaInnerTube } = await import('./providers/youtube-innertube.mjs');
const cap = await getCaptionTracksViaInnerTube(String(videoId));
if (cap.trackCount > 0) {
meta = { subtitles: cap.subtitles, automatic_captions: cap.automatic_captions };
console.log(`[transcript] source=innertube tracks=${cap.trackCount} langs=${cap.languages.join(',')}`);
} else {
// InnerTube fait foi (même backend que le lecteur) : 0 piste = pas
// de sous-titres, sans payer un dump yt-dlp complet.
console.log('[transcript] source=innertube tracks=0 -> no_subtitles');
const empty = { lang: null, available: false, languages: [], lines: [], reason: 'no_subtitles' };
transcriptCacheSet(cacheKey, empty);
return res.json(empty);
}
} catch (e) {
console.warn('[transcript] innertube discovery failed, fallback yt-dlp:', e?.code || String(e?.message || e).slice(0, 120));
if (transcriptSource === 'innertube-only') {
return res.status(502).json({ available: false, languages: [], lines: [], error: 'transcript_temporarily_unavailable', retryable: true, retryAfterSec: 30 });
}
}
}
if (!meta) {
try {
const raw = await youtubedl(transcriptUrl, { dumpSingleJson: true, skipDownload: true, noWarnings: true, noCheckCertificates: true, ...ytdlpNetOpts() });
meta = (typeof raw === 'string') ? JSON.parse(raw || '{}') : (raw || {});
} catch (e) {
console.error('[transcript] yt-dlp failed:', e?.message || e);
return res.status(502).json({ available: false, languages: [], lines: [], error: 'transcript_temporarily_unavailable', retryable: true, retryAfterSec: 30 });
}
}
const { track, languages, lang: chosenLang } = pickTrack(meta, lang);
const visibleLanguages = filterTranscriptLanguages(languages, allowed);
if (!track || !allowed.map(transcriptPrimary).includes(transcriptPrimary(chosenLang))) {
// Definitive absence: distinguish "provider never exposes subtitles"
// (twitch/odysee/rumble) from "this video has none" — cacheable 200s.
const reason = TRANSCRIPT_UNSUPPORTED_PROVIDERS.has(normalized) ? 'provider_unsupported' : 'no_subtitles';
const empty = { lang: null, available: false, languages: visibleLanguages, lines: [], reason };
transcriptCacheSet(cacheKey, empty);
return res.json(empty);
}
// Stratégie de récupération (résultat des tests live 2026-09-28) :
// 1. Sonde rapide des URLs InnerTube directes (max 2, sans retry) :
// elles répondent désormais 200-vide depuis les egress filtrés.
// 2. Dump yt-dlp -> URLs signées fraîches (`signature`/`expire`) fetchées
// en direct (max 3) : c'est la voie rapide qui fonctionne encore.
// 3. Traduction serveur `&tlang=` (dernier recours avant yt-dlp) : sous
// throttle, YouTube peut renvoyer la piste source en UN seul bloc au
// lieu de la traduire — le garde anti-blob refuse ces réponses.
// 4. Dernier recours : `yt-dlp --write-sub` (client visionos + retries
// internes, le plus robuste : ~6 s, 80+ Ko vérifiés live).
// On ne martèle jamais plus de 2 URLs mortes d'affilée : chaque 200-vide
// ou 429 supplémentaire aggrave le throttle YouTube à la minute.
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
let lines = [];
let workedLang = chosenLang;
let sawRateLimit = false;
let sawEmpty = false;
const allowedSet = new Set(allowed.map(transcriptPrimary));
const keepAllowed = (cands) => (cands || []).filter(c => allowedSet.has(transcriptPrimary(c.lang)));
const markTransient = (msg) => {
if (msg.includes(':429') || msg.includes('Sorry') || msg.includes('sorry')) sawRateLimit = true;
if (msg.includes(':empty_200') || msg.includes(':unparsable') || msg.includes(':blob') || msg.includes('html_error')) sawEmpty = true;
};
const tryCandidates = async (cands, { retry429 = false, label = '' } = {}) => {
for (const cand of cands) {
let attempt = 0;
for (;;) {
try {
const parsed = await fetchTimedTextLines(cand.track);
if (parsed && parsed.length && !isBlobTranscript(parsed)) {
lines = parsed;
workedLang = cand.lang || chosenLang;
} else if (isBlobTranscript(parsed)) {
console.warn(`[transcript] rejected translation blob${label ? ` (${label})` : ''}:`, cand.lang, `1 cue x ${String(parsed[0]?.text || '').length} chars`);
markTransient(':blob');
}
break;
} catch (e) {
const msg = String(e?.message || e);
console.warn(`[transcript] track fetch failed${label ? ` (${label})` : ''}:`, cand.lang, msg.slice(0, 120));
markTransient(msg);
if (msg.includes(':429') && retry429 && attempt < 1) {
sawRateLimit = true;
attempt += 1;
await sleep(2000 + Math.floor(Math.random() * 1000));
continue;
}
break;
}
}
if (lines.length) return true;
}
return false;
};
const innertubeCands = keepAllowed(normalized === 'youtube'
? firstPerLanguage(orderedTracks(meta, lang), 2)
: orderedTracks(meta, lang).slice(0, 2));
await tryCandidates(innertubeCands, { retry429: false, label: 'innertube' });
let signedBase = [];
if (!lines.length && normalized === 'youtube' && transcriptSource !== 'innertube-only') {
// Étape 2 : URLs signées via dump yt-dlp (même quand InnerTube a déjà
// découvert les pistes : ce sont les seules URLs fetchables en direct).
// Directes uniquement : le `&tlang=` attend l'étape 3 (un blob de
// traduction dégradée ne doit jamais éclipser une piste directe).
try {
const raw = await youtubedl(transcriptUrl, { dumpSingleJson: true, skipDownload: true, noWarnings: true, noCheckCertificates: true, ...ytdlpNetOpts() });
const dump = (typeof raw === 'string') ? JSON.parse(raw || '{}') : (raw || {});
signedBase = keepAllowed(firstPerLanguage(orderedTracks(dump, lang), 3));
const signed = mergeTranscriptCandidates({ signed: signedBase, innertube: [], max: 3 });
if (signed.length) {
console.log(`[transcript] retry via signed yt-dlp urls (${signed.length} candidats)`);
await tryCandidates(signed, { retry429: true, label: 'signed' });
}
} catch (e) {
console.warn('[transcript] signed-url dump failed:', String(e?.message || e).slice(0, 150));
}
}
if (!lines.length && normalized === 'youtube') {
// Étape 3 : traduction serveur `&tlang=` vers la langue demandée, à
// partir des pistes directes qui ont répondu (jamais en premier :
// voir garde anti-blob ci-dessus).
const tlangCands = keepAllowed(translatedFallbacks(innertubeCands.concat(signedBase), lang)).slice(0, 2);
if (tlangCands.length) await tryCandidates(tlangCands, { retry429: false, label: 'tlang' });
}
if (!lines || lines.length === 0) {
// Étape 4 : téléchargement via yt-dlp (gère impersonation + retries).
// Uniquement les langues autorisées ; blob refusé comme en direct.
try {
const viaDlp = await transcriptViaYtDlp(transcriptUrl, [lang, chosenLang].filter(l => allowedSet.has(transcriptPrimary(l))), ytdlpNetOpts());
if (viaDlp && viaDlp.lines && viaDlp.lines.length && !isBlobTranscript(viaDlp.lines) && allowedSet.has(transcriptPrimary(viaDlp.lang))) {
lines = viaDlp.lines;
if (viaDlp.lang) workedLang = viaDlp.lang;
} else if (isBlobTranscript(viaDlp?.lines)) {
console.warn('[transcript] rejected yt-dlp blob:', viaDlp?.lang);
sawEmpty = true;
}
} catch {}
}
if (!lines || lines.length === 0) {
// Subtitle tracks exist but their content could not be retrieved or
// parsed (YouTube 429 / 200-vide / Sorry pages) : the video HAS
// subtitles, so this is transient — 502 with languages + retryable flag,
// never cached as "no subtitles".
return res.status(502).json({
available: false,
languages: visibleLanguages,
lines: [],
error: 'transcript_temporarily_unavailable',
retryable: true,
rateLimited: (sawRateLimit || sawEmpty) || undefined,
retryAfterSec: sawRateLimit ? 60 : 30,
});
}
lines = dedupeTranscriptLines(lines);
if (isBlobTranscript(lines)) {
// Jamais de cache ni de 200 sur un blob : réponse transitoire.
console.warn('[transcript] rejected post-dedupe blob');
return res.status(502).json({
available: false,
languages: visibleLanguages,
lines: [],
error: 'transcript_temporarily_unavailable',
retryable: true,
rateLimited: true,
retryAfterSec: 60,
});
}
const result = { lang: workedLang, available: true, languages: visibleLanguages, lines };
transcriptCacheSet(cacheKey, result);
return res.json(result);
} catch (e) {
console.error('[transcript] unexpected error:', e?.message || e);
return res.status(502).json({ available: false, languages: [], lines: [], error: 'transcript_temporarily_unavailable', retryable: true, retryAfterSec: 30 });
}
});
// -------------------- Static Frontend (Angular build) --------------------
const distRoot = path.join(process.cwd(), 'dist');
const distBrowser = path.join(distRoot, 'browser');
const staticDir = fs.existsSync(distBrowser) ? distBrowser : distRoot;
// Mount static files unconditionally; if path missing, it will just not serve anything.
// Hashed bundles (.js/.css) sont immuables -> cache long ; index.html ne doit
// JAMAIS être caché (sinon le navigateur rejoue d'anciens chunks après un
// redéploiement et appelle l'API avec d'anciens formats d'URL).
app.use(express.static(staticDir, {
maxAge: '1h',
index: 'index.html',
setHeaders: (res, filePath) => {
try {
if (String(filePath || '').toLowerCase().endsWith('.html')) {
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.setHeader('Pragma', 'no-cache');
res.removeHeader('Expires');
}
} catch {}
},
}));
// SPA fallback: any non-API GET should serve index.html (toujours frais, cf. ci-dessus)
app.get('*', (req, res, next) => {
try {
const url = req.originalUrl || req.url || '';
if (url.startsWith('/api/')) return next();
const indexPath = path.join(staticDir, 'index.html');
if (fs.existsSync(indexPath)) {
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.setHeader('Pragma', 'no-cache');
return res.sendFile(indexPath);
}
return next();
} catch {
return next();
}
});
app.listen(PORT, () => {
const cwd = process.cwd();
const hasDistRoot = fs.existsSync(distRoot);
const hasDistBrowser = fs.existsSync(distBrowser);
const hasIndex = fs.existsSync(path.join(staticDir, 'index.html'));
console.log(`[newtube-api] listening on http://localhost:${PORT}`);
console.log(`[newtube-api] cwd=${cwd}`);
console.log(`[newtube-api] distRoot=${distRoot} exists=${hasDistRoot}`);
console.log(`[newtube-api] distBrowser=${distBrowser} exists=${hasDistBrowser}`);
console.log(`[newtube-api] staticDir=${staticDir} indexExists=${hasIndex}`);
startSearchCacheJanitor();
});
// --- Playlists ---
// NOTE : cookie-aware (et non Bearer seul) : voir commentaire section Likes.
// Sans cela, création/ajout renvoyait 401 « Unauthorized » même connecté.
// Create a new playlist
r.post('/playlists', authMiddlewareCookieAware, (req, res) => {
try {
const { title, description, thumbnail, isPrivate } = req.body || {};
if (!title || String(title).trim().length === 0) {
return res.status(400).json({ error: 'title_required' });
}
const pl = createPlaylist({ userId: req.user.id, title: String(title).trim(), description, thumbnail, isPrivate: !!isPrivate });
return res.status(201).json(pl);
} catch (e) {
const msg = String(e?.message || e);
if (msg === 'title_required') return res.status(400).json({ error: msg });
return res.status(500).json({ error: 'create_failed', details: msg });
}
});
// List current user's playlists (pagination + search)
r.get('/playlists', authMiddlewareCookieAware, (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 = listPlaylists({ userId: req.user.id, limit, offset, q });
return res.json(rows);
} catch (e) {
return res.status(500).json({ error: 'list_failed', details: String(e?.message || e) });
}
});
// Get playlist details (owner only for now)
r.get('/playlists/:id', authMiddlewareCookieAware, (req, res) => {
try {
const id = String(req.params.id || '');
const pl = getPlaylistRaw(id);
if (!pl) return res.status(404).json({ error: 'not_found' });
if (pl.userId !== req.user.id) return res.status(404).json({ error: 'not_found' });
const limit = Math.min(2000, Math.max(1, Number(req.query.limit || 500)));
const offset = Math.max(0, Number(req.query.offset || 0));
const items = listPlaylistItems({ playlistId: id, limit, offset });
return res.json({ ...pl, items });
} catch (e) {
return res.status(500).json({ error: 'get_failed', details: String(e?.message || e) });
}
});
// Update a playlist (title/description/thumbnail/isPrivate)
r.put('/playlists/:id', authMiddlewareCookieAware, (req, res) => {
try {
const id = String(req.params.id || '');
const patch = req.body || {};
const result = updatePlaylist({ userId: req.user.id, id, patch });
if (result === 'forbidden') return res.status(403).json({ error: 'forbidden' });
if (!result) return res.status(404).json({ error: 'not_found' });
return res.json(result);
} catch (e) {
return res.status(500).json({ error: 'update_failed', details: String(e?.message || e) });
}
});
// Delete a playlist
r.delete('/playlists/:id', authMiddlewareCookieAware, (req, res) => {
try {
const id = String(req.params.id || '');
const result = deletePlaylist({ userId: req.user.id, id });
if (result === 'forbidden') return res.status(403).json({ error: 'forbidden' });
if (!result || !result.removed) return res.status(404).json({ error: 'not_found' });
return res.status(204).end();
} catch (e) {
return res.status(500).json({ error: 'delete_failed', details: String(e?.message || e) });
}
});
// Add a video to a playlist (enrich title/thumbnail if missing)
r.post('/playlists/:id/videos', authMiddlewareCookieAware, async (req, res) => {
try {
const playlistId = String(req.params.id || '');
let { provider, videoId, title, thumbnail, sourceUrl, slug, instance } = req.body || {};
provider = String(provider || '').trim();
videoId = String(videoId || '').trim();
if (!provider || !videoId) return res.status(400).json({ error: 'provider_and_videoId_required' });
// Optional enrichment like likes route
try {
const needTitle = !(typeof title === 'string' && title.trim().length > 0);
const needThumb = !(typeof thumbnail === 'string' && thumbnail.trim().length > 0);
if (needTitle || needThumb) {
const url = providerUrlFrom(provider, videoId, { instance, slug, sourceUrl });
try {
const raw = await youtubedl(url, { dumpSingleJson: true, noWarnings: true, noCheckCertificates: true, skipDownload: true });
const meta = (typeof raw === 'string') ? JSON.parse(raw || '{}') : (raw || {});
if (needTitle) title = meta?.title || title || '';
if (needThumb) thumbnail = meta?.thumbnail || (Array.isArray(meta?.thumbnails) && meta.thumbnails.length ? meta.thumbnails[0].url : thumbnail || '');
} catch {}
}
} catch {}
const row = addPlaylistVideo({ userId: req.user.id, playlistId, provider, videoId, title, thumbnail });
if (row === 'not_found') return res.status(404).json({ error: 'playlist_not_found' });
if (row === 'forbidden') return res.status(403).json({ error: 'forbidden' });
return res.status(201).json(row);
} catch (e) {
return res.status(500).json({ error: 'add_video_failed', details: String(e?.message || e) });
}
});
// Remove a video from a playlist (provider required via query)
r.delete('/playlists/:id/videos/:videoId', authMiddlewareCookieAware, (req, res) => {
try {
const playlistId = String(req.params.id || '');
const videoId = String(req.params.videoId || '');
const provider = req.query.provider ? String(req.query.provider) : '';
if (!provider || !videoId) return res.status(400).json({ error: 'provider_and_videoId_required' });
const result = removePlaylistVideo({ userId: req.user.id, playlistId, provider, videoId });
if (result === 'not_found') return res.status(404).json({ error: 'playlist_not_found' });
if (result === 'forbidden') return res.status(403).json({ error: 'forbidden' });
return res.json(result);
} catch (e) {
return res.status(500).json({ error: 'remove_video_failed', details: String(e?.message || e) });
}
});
// Reorder playlist items
r.put('/playlists/:id/reorder', authMiddlewareCookieAware, (req, res) => {
try {
const playlistId = String(req.params.id || '');
const order = Array.isArray(req.body?.order) ? req.body.order : [];
if (!order.length) return res.status(400).json({ error: 'order_required' });
const result = reorderPlaylistVideos({ userId: req.user.id, playlistId, order });
if (result === 'not_found') return res.status(404).json({ error: 'playlist_not_found' });
if (result === 'forbidden') return res.status(403).json({ error: 'forbidden' });
return res.json(result);
} catch (e) {
return res.status(500).json({ error: 'reorder_failed', details: String(e?.message || e) });
}
});
// --- OpenAPI (référence machine : lecture publique + espace utilisateur) ---
r.get('/openapi.json', (_req, res) => {
const bearer = [{ bearerAuth: [] }];
const doc = {
openapi: '3.0.0',
info: { title: 'NewTube API', version: '1.1.0' },
paths: {
'/healthz': {
get: { summary: 'Santé API (mode YT, yt-dlp, cache, quota)', responses: { 200: { description: 'ok' } } },
},
'/search': {
get: { summary: 'Recherche unifiée multi-providers', parameters: [
{ name: 'q', in: 'query', required: true, schema: { type: 'string', minLength: 2 } },
{ name: 'providers', in: 'query', schema: { type: 'string', example: 'yt,dm' } },
{ name: 'page', in: 'query', schema: { type: 'integer', default: 1 } },
{ name: 'pageSize', in: 'query', schema: { type: 'integer', maximum: 50 } },
{ name: 'sort', in: 'query', schema: { type: 'string', enum: ['relevance', 'date', 'views'] } },
], responses: { 200: { description: '{ q, providers, groups, errors, page, pageSize, sort }' } } },
},
'/search/suggest': {
get: { summary: 'Typeahead groupé par provider', parameters: [
{ name: 'q', in: 'query', required: true, schema: { type: 'string', minLength: 2 } },
{ name: 'providers', in: 'query', schema: { type: 'string' } },
{ name: 'limit', in: 'query', schema: { type: 'integer', maximum: 20 } },
], responses: { 200: { description: '{ q, groups }' } } },
},
'/details/{provider}/{videoId}': {
get: { summary: 'Métadonnées vidéo + connexes YouTube', parameters: [
{ name: 'provider', in: 'path', required: true, schema: { type: 'string' } },
{ name: 'videoId', in: 'path', required: true, schema: { type: 'string' } },
{ name: 'instance', in: 'query', schema: { type: 'string' } },
{ name: 'related', in: 'query', schema: { type: 'string', enum: ['0'] } },
], responses: { 200: { description: 'video + related[]' } } },
},
'/trending': {
get: { summary: 'Tendances YouTube sans clé (phase 1 : yt)', parameters: [
{ name: 'provider', in: 'query', schema: { type: 'string', default: 'yt' } },
{ name: 'limit', in: 'query', schema: { type: 'integer', maximum: 50 } },
], responses: { 200: { description: '{ provider, items }' } } },
},
'/transcript/{provider}/{videoId}': {
get: { summary: 'Transcript { lang, available, languages, lines }', parameters: [
{ name: 'provider', in: 'path', required: true, schema: { type: 'string' } },
{ name: 'videoId', in: 'path', required: true, schema: { type: 'string' } },
{ name: 'lang', in: 'query', schema: { type: 'string', default: 'fr' } },
{ name: 'instance', in: 'query', schema: { type: 'string' } },
], responses: { 200: { description: '200 available:true|false ; 502 retryable:true si 429 YouTube' } } },
},
'/auth/register': {
post: { summary: 'Créer un compte', responses: { 201: { description: '{ user, accessToken }' } } },
},
'/auth/login': {
post: { summary: 'Connexion (retourne accessToken + cookies)', responses: { 200: { description: '{ user, accessToken }' } } },
},
'/user/me': {
get: { summary: 'Profil courant', security: bearer, responses: { 200: { description: 'user' } } },
},
'/user/preferences': {
get: { summary: 'Lire préférences', security: bearer, responses: { 200: { description: 'prefs' } } },
patch: { summary: 'Modifier préférences (defaultProviders…)', security: bearer, responses: { 200: { description: 'prefs' } } },
},
'/user/likes': {
get: { summary: 'Vidéos likées', security: bearer, responses: { 200: { description: 'likes[]' } } },
post: { summary: 'Liker', security: bearer, responses: { 201: { description: 'like' } } },
},
'/user/watch-later': {
get: { summary: 'Liste « à regarder plus tard »', security: bearer, responses: { 200: { description: 'videos[]' } } },
post: { summary: 'Ajouter à la liste', security: bearer, responses: { 201: { description: '{ provider, video_id }' } } },
},
'/subscriptions': {
get: { summary: 'Abonnements { items, ttl }', security: bearer, responses: { 200: { description: 'subs' } } },
post: { summary: 'S abonner (résolution chaîne)', security: bearer, responses: { 201: { description: 'sub' } } },
},
'/download/{provider}/{videoId}/formats': {
get: { summary: 'Formats téléchargeables (cache 10 min)', security: bearer, responses: { 200: { description: '{ url, formats }' } } },
},
'/oauth/status': {
get: { summary: 'Connecteurs OAuth configurés', responses: { 200: { description: '{ google, twitch }' } } },
},
'/playlists': {
get: { summary: 'List user playlists', security: [{ bearerAuth: [] }], parameters: [
{ name: 'limit', in: 'query', schema: { type: 'integer' } },
{ name: 'offset', in: 'query', schema: { type: 'integer' } },
{ name: 'q', in: 'query', schema: { type: 'string' } },
] },
post: { summary: 'Create playlist', security: [{ bearerAuth: [] }], requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', properties: { title: { type: 'string' }, description: { type: 'string' }, thumbnail: { type: 'string' }, isPrivate: { type: 'boolean' } }, required: ['title'] } } } } }
},
'/playlists/{id}': {
get: { summary: 'Get playlist details', security: [{ bearerAuth: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }] },
put: { summary: 'Update playlist', security: [{ bearerAuth: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }] },
delete: { summary: 'Delete playlist', security: [{ bearerAuth: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }] }
},
'/playlists/{id}/videos': {
post: { summary: 'Add video to playlist', security: [{ bearerAuth: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }] }
},
'/playlists/{id}/videos/{videoId}': {
delete: { summary: 'Remove video from playlist', security: [{ bearerAuth: [] }], parameters: [
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
{ name: 'videoId', in: 'path', required: true, schema: { type: 'string' } },
{ name: 'provider', in: 'query', required: true, schema: { type: 'string' } }
] }
},
'/playlists/{id}/reorder': {
put: { summary: 'Reorder playlist items', security: [{ bearerAuth: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }] }
}
},
components: { securitySchemes: { bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' } } }
};
res.json(doc);
});