Files
NewTube/server/index.mjs
T
bruno 97e5de74f5
CI / build-and-test (push) Successful in 13m34s
feat(channel): page details chaine multi-providers avec onglets et infinite scroll
- Strategy Pattern front: ChannelProviderInterface + 6 providers (yt/dm/tw/pt/od/ru) + factory (ids longs/courts)
- ChannelContentService: cache par onglet/tri/recherche (TTL 5min), pagination nextPage
- ChannelPage: header (banniere/avatar/subs/description ...plus), tabs dynamiques par capabilities, grilles videos/shorts 9:16/playlists/live, recherche debounced, tri recent/populaire, infinite scroll IntersectionObserver, skeletons/empty/error
- Backend: GET /api/channels/:provider/:id/content (videos/shorts/playlists/live) natif par provider
2026-09-25 10:15:12 -04:00

2378 lines
98 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 youtubedl from 'youtube-dl-exec';
import ffmpegPath from 'ffmpeg-static';
import * as cheerio from 'cheerio';
import axios from 'axios';
import rumbleRouter from './rumble.mjs';
import { providerRegistry, validateProviders } from './providers/registry.mjs';
import {
getUserByUsername,
getUserById,
insertUser,
insertSession,
getSessionById,
updateSessionToken,
revokeSession,
revokeAllUserSessions,
listUserSessions,
setUserLastLogin,
insertLoginAudit,
getPreferences,
getPreferencesForApi,
upsertPreferences,
insertTelemetryEvent,
listTelemetryEvents,
countTelemetryEvents,
cryptoRandomId,
cryptoRandomUUID,
insertSearchHistory,
listSearchHistory,
deleteSearchHistoryById,
deleteAllSearchHistory,
upsertWatchHistory,
listWatchHistory,
updateWatchHistoryById,
deleteWatchHistoryById,
deleteAllWatchHistory,
likeVideo,
unlikeVideo,
listLikedVideos,
isVideoLiked,
createPlaylist,
listPlaylists,
listPublicPlaylists,
getPlaylistRaw,
getPlaylistWithItemsIfAllowed,
updatePlaylist,
deletePlaylist,
listPlaylistItems,
addPlaylistVideo,
removePlaylistVideo,
reorderPlaylistVideos,
ensureChannelFresh,
listSubscriptionsByUser,
subscribeChannel,
unsubscribeChannel,
isSubscribed,
insertDownloadJob,
getDownloadJob,
listDownloadJobs,
updateDownloadJob,
deleteDownloadJob,
resetActiveDownloadJobs,
countActiveDownloadJobs,
sumCompletedDownloadBytes,
} from './db.mjs';
import { getChannelAdapter, setTwitchTokenProvider } from './providers/channel-registry.mjs';
import { fetchChannelContent } from './providers/channel-content.mjs';
const app = express();
const PORT = Number(process.env.PORT || 4000);
const IS_PROD = String(process.env.NODE_ENV || '').toLowerCase() === 'production';
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-me';
if (!process.env.JWT_SECRET) {
const msg = '[config] JWT_SECRET non défini — utilisation du secret de développement. NE PAS UTILISER EN PRODUCTION.';
if (IS_PROD) console.error(msg);
else console.warn(msg);
}
const ACCESS_TTL_MIN = Number(process.env.ACCESS_TTL_MIN || 15);
// Garde-fou : une erreur non capturée dans une route ne doit jamais tuer tout le serveur.
process.on('uncaughtException', (err) => {
try { console.error('[fatal] uncaughtException:', err?.stack || err); } catch {}
});
process.on('unhandledRejection', (reason) => {
try { console.error('[fatal] unhandledRejection:', reason); } catch {}
});
const REFRESH_TTL_DAYS = Number(process.env.REFRESH_TTL_DAYS || 2);
const REMEMBER_TTL_DAYS = Number(process.env.REMEMBER_TTL_DAYS || 30);
const CHANNEL_TTL_MS = Number(process.env.CHANNEL_TTL_MS || (6 * 60 * 60 * 1000));
const corsOptions = {
origin: ['http://localhost:4200', 'http://localhost:4000', 'http://localhost:3000'],
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true,
maxAge: 86400 // 24h
};
// Middleware de logging — verbeux uniquement hors production, sinon une ligne sobre.
// Jamais de headers complets (tokens) ni de body brut (mots de passe) en prod.
const SENSITIVE_FIELDS = new Set(['password', 'currentPassword', 'newPassword', 'token', 'refreshToken', 'accessToken']);
function sanitizeBody(body) {
if (!body || typeof body !== 'object') return body;
const out = Array.isArray(body) ? [...body] : { ...body };
for (const k of Object.keys(out)) {
if (SENSITIVE_FIELDS.has(k)) out[k] = '[redacted]';
}
return out;
}
const requestLogger = (req, res, next) => {
if (IS_PROD) {
console.log(`[${new Date().toISOString()}] ${req.method} ${req.originalUrl}`);
return next();
}
console.log(`[${new Date().toISOString()}] ${req.method} ${req.originalUrl}`);
console.log('Headers:', JSON.stringify({ ...req.headers, authorization: req.headers.authorization ? '[redacted]' : undefined }, null, 2));
console.log('Query:', JSON.stringify(req.query, null, 2));
console.log('Body:', JSON.stringify(sanitizeBody(req.body), null, 2));
next();
};
const subscriptionsLimiter = rateLimit({
windowMs: 60 * 1000,
max: 30,
standardHeaders: true,
legacyHeaders: false,
});
const channelsLimiter = rateLimit({
windowMs: 60 * 1000,
max: 60,
standardHeaders: true,
legacyHeaders: false,
});
const r = express.Router();
// Public: list public playlists (no auth required)
r.get('/playlists/public', (req, res) => {
try {
const limit = Math.min(200, Math.max(1, Number(req.query.limit || 50)));
const offset = Math.max(0, Number(req.query.offset || 0));
const q = typeof req.query.q === 'string' ? req.query.q : undefined;
const rows = listPublicPlaylists({ limit, offset, q });
return res.json(rows);
} catch (e) {
return res.status(500).json({ error: 'list_public_failed', details: String(e?.message || e) });
}
});
// -------------------- Channels APIs --------------------
function requireProviderId(value) {
const allowed = ['yt','dm','tw','pt','od','ru'];
if (!allowed.includes(String(value))) {
throw Object.assign(new Error('invalid_provider'), { status: 400 });
}
return /** @type {'yt'|'dm'|'tw'|'pt'|'od'|'ru'} */(value);
}
async function resolveChannel(provider, externalId, { forceRefresh = false } = {}) {
const adapterEntry = getChannelAdapter(provider) || providerRegistry[provider];
if (!adapterEntry || typeof adapterEntry.fetchChannelById !== 'function') {
throw Object.assign(new Error('provider_not_supported'), { status: 501 });
}
return ensureChannelFresh(provider, externalId, () => adapterEntry.fetchChannelById(externalId), { force: forceRefresh });
}
r.post('/channels/resolve', authMiddlewareCookieAware, channelsLimiter, async (req, res) => {
try {
const provider = requireProviderId(req.body?.provider);
const externalId = String(req.body?.externalId || '').trim();
if (!externalId) return res.status(400).json({ error: 'external_id_required' });
const meta = await resolveChannel(provider, externalId, { forceRefresh: Boolean(req.body?.refresh) });
return res.json(meta);
} catch (error) {
const status = error?.status || 500;
return res.status(status).json({ error: error?.message || 'channel_resolve_failed' });
}
});
// Lecture publique (logo chaîne sur /watch même déconnecté) — ne crée pas d'abonnement.
r.get('/channels/:provider/:externalId', channelsLimiter, async (req, res) => {
try {
const provider = requireProviderId(req.params.provider);
const externalId = String(req.params.externalId || '').trim();
if (!externalId) return res.status(400).json({ error: 'external_id_required' });
const refresh = req.query.refresh === '1' || req.query.refresh === 'true';
const meta = await resolveChannel(provider, externalId, { forceRefresh: refresh });
return res.json(meta);
} catch (error) {
const status = error?.status || 500;
return res.status(status).json({ error: error?.message || 'channel_fetch_failed' });
}
});
// Contenu d'une chaîne : ?type=videos|shorts|playlists|live&page=&limit=&sort=recent|popular&q=
r.get('/channels/:provider/:externalId/content', channelsLimiter, async (req, res) => {
try {
const provider = requireProviderId(req.params.provider);
const externalId = String(req.params.externalId || '').trim();
if (!externalId) return res.status(400).json({ error: 'external_id_required' });
const type = String(req.query.type || 'videos');
if (!['videos', 'shorts', 'playlists', 'live'].includes(type)) {
return res.status(400).json({ error: 'invalid_type' });
}
const page = Math.max(1, Number(req.query.page || 1));
const limit = Math.min(50, Math.max(1, Number(req.query.limit || 24)));
const sort = req.query.sort === 'popular' ? 'popular' : req.query.sort === 'relevance' ? 'relevance' : 'recent';
const q = typeof req.query.q === 'string' ? req.query.q.slice(0, 200) : '';
const data = await fetchChannelContent(provider, externalId, { type, page, limit, sort, q }, { searchRegistry: providerRegistry });
return res.json({ ...data, page, limit, sort, type });
} catch (error) {
const status = error?.status || 500;
return res.status(status).json({ error: error?.message || 'channel_content_failed', items: [], nextPage: null });
}
});
// -------------------- Subscriptions APIs --------------------
r.get('/subscriptions', authMiddlewareCookieAware, subscriptionsLimiter, (req, res) => {
try {
const items = listSubscriptionsByUser(req.user.id);
return res.json({ items, ttl: CHANNEL_TTL_MS });
} catch (error) {
return res.status(500).json({ error: 'subscriptions_list_failed', details: String(error?.message || error) });
}
});
r.post('/subscriptions', authMiddlewareCookieAware, subscriptionsLimiter, async (req, res) => {
try {
const provider = requireProviderId(req.body?.provider);
const externalId = String(req.body?.externalId || '').trim();
if (!externalId) return res.status(400).json({ error: 'external_id_required' });
const entity = await resolveChannel(provider, externalId, { forceRefresh: Boolean(req.body?.refresh) });
const sub = subscribeChannel({ userId: req.user.id, provider, externalId, channelId: entity?.id });
return res.status(201).json(sub);
} catch (error) {
const status = error?.status || 500;
return res.status(status).json({ error: error?.message || 'subscription_create_failed' });
}
});
r.delete('/subscriptions/:subscriptionId', authMiddlewareCookieAware, subscriptionsLimiter, (req, res) => {
try {
const info = unsubscribeChannel({ userId: req.user.id, subscriptionId: req.params.subscriptionId });
if ((info?.changes || 0) === 0) return res.status(404).json({ error: 'not_found' });
return res.status(204).end();
} catch (error) {
return res.status(500).json({ error: 'subscription_delete_failed', details: String(error?.message || error) });
}
});
// 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,
});
const downloadLimiter = rateLimit({
windowMs: 60 * 1000, // 1 min
max: 15,
standardHeaders: true,
legacyHeaders: false,
});
// Rate limiter for Rumble scraping to prevent being blocked
const rumbleLimiter = rateLimit({
windowMs: 60 * 1000, // 1 min
max: 10, // Limit to 10 requests per minute per IP
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many requests to Rumble API. Please try again later.' }
});
function makeAccessToken(userId, sessionId) {
const payload = { sub: userId, sid: sessionId };
return jwt.sign(payload, JWT_SECRET, { expiresIn: `${ACCESS_TTL_MIN}m` });
}
function isSecureRequest(req) {
try {
if (process.env.COOKIE_SECURE === 'true') return true;
if (process.env.COOKIE_SECURE === 'false') return false;
if (req?.secure) return true;
const proto = String(req?.headers?.['x-forwarded-proto'] || req?.protocol || '').split(',')[0].trim().toLowerCase();
return proto === 'https';
} catch {
return false;
}
}
function setRefreshCookies(res, { sessionId, token, days }, req) {
const maxAgeMs = days * 24 * 60 * 60 * 1000;
const cookieOpts = {
httpOnly: true,
// 'lax' : envoyé sur les navigations top-level (liens directs, <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');
}
}
async function scrapeRumbleVideo(videoId) {
const url = `https://rumble.com/${videoId}`;
try {
const response = await axios.get(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.5',
'Accept-Encoding': 'gzip, deflate, br',
'DNT': '1',
'Connection': 'keep-alive',
'Upgrade-Insecure-Requests': '1',
'Cache-Control': 'no-cache'
},
timeout: 15000,
maxRedirects: 5,
validateStatus: function (status) {
return status >= 200 && status < 400; // Accept redirects
}
});
const $ = cheerio.load(response.data);
const html = response.data;
// Extract basic video information
let title = '';
let thumbnail = '';
let uploaderName = '';
let uploaderAvatar = '';
let views = 0;
let duration = 0;
let uploadedDate = '';
let description = '';
// Try multiple selectors for title (Rumble's HTML structure can vary)
title = $('h1.video-title, .video-title h1, [data-video-title]').first().text().trim() ||
$('meta[property="og:title"]').attr('content') ||
$('title').text().trim() ||
$('h1').first().text().trim() || '';
// Clean up title (remove site name if present)
title = title.replace(/\s*\|\s*Rumble$/i, '').trim();
// Extract thumbnail with fallbacks
thumbnail = $('meta[property="og:image"], meta[name="twitter:image"]').attr('content') ||
$('meta[property="og:image:secure_url"]').attr('content') ||
$('.video-thumbnail img, .thumbnail img').attr('src') || '';
// Make thumbnail URL absolute if relative
if (thumbnail && !thumbnail.startsWith('http')) {
thumbnail = thumbnail.startsWith('//') ? 'https:' + thumbnail : 'https://rumble.com' + thumbnail;
}
// Extract uploader information with multiple selectors
uploaderName = $('.media-by--a, .channel-name, .uploader-name').first().text().trim() ||
$('meta[property="article:author"]').attr('content') ||
$('.author-name, .channel-link').first().text().trim() || '';
uploaderAvatar = $('.channel-avatar img, .uploader-avatar img').attr('src') ||
$('.author-avatar img').attr('src') || '';
// Make uploader avatar URL absolute
if (uploaderAvatar && !uploaderAvatar.startsWith('http')) {
uploaderAvatar = uploaderAvatar.startsWith('//') ? 'https:' + uploaderAvatar : 'https://rumble.com' + uploaderAvatar;
}
// Extract views with better parsing
const viewsText = $('.rumbles-views, .video-views, .views-count, .video-info .views').first().text().trim();
if (viewsText) {
const viewsMatch = viewsText.match(/([\d,]+(?:\.\d+)?)\s*(K|M|B)?/i);
if (viewsMatch) {
let num = parseFloat(viewsMatch[1].replace(/,/g, ''));
const multiplier = viewsMatch[2]?.toUpperCase();
if (multiplier === 'K') num *= 1000;
else if (multiplier === 'M') num *= 1000000;
else if (multiplier === 'B') num *= 1000000000;
views = Math.floor(num);
} else {
// Try direct number parsing
const directMatch = viewsText.match(/(\d+(?:,\d+)*)/);
if (directMatch) {
views = parseInt(directMatch[1].replace(/,/g, ''));
}
}
}
// Extract duration with improved parsing
const durationText = $('meta[property="video:duration"]').attr('content') ||
$('video').attr('duration') ||
$('.video-duration, .duration, .video-time').first().text().trim() ||
$('.time-duration').text().trim();
if (durationText) {
if (!isNaN(durationText)) {
duration = parseInt(durationText);
} else {
// Parse various duration formats
const timeMatch = durationText.match(/(\d+):(\d+)(?::(\d+))?/);
if (timeMatch) {
const hours = parseInt(timeMatch[3] || '0');
const minutes = parseInt(timeMatch[1]);
const seconds = parseInt(timeMatch[2]);
duration = hours * 3600 + minutes * 60 + seconds;
} else {
// Try HH:MM:SS format or MM:SS
const parts = durationText.split(':').map(p => parseInt(p.trim()) || 0);
if (parts.length === 3) {
duration = parts[0] * 3600 + parts[1] * 60 + parts[2];
} else if (parts.length === 2) {
duration = parts[0] * 60 + parts[1];
}
}
}
}
// Extract upload date
uploadedDate = $('meta[property="article:published_time"]').attr('content') ||
$('.upload-date, .published-date, .video-date').first().text().trim() || '';
// Try to parse relative dates
if (!uploadedDate || uploadedDate.includes('ago')) {
const relativeDate = $('.upload-date, .published-date').first().text().trim();
if (relativeDate && relativeDate.includes('ago')) {
// Convert relative date to ISO string (simple conversion)
const now = new Date();
if (relativeDate.includes('hour')) {
const hours = parseInt(relativeDate.match(/(\d+)/)?.[1] || '1');
now.setHours(now.getHours() - hours);
uploadedDate = now.toISOString();
} else if (relativeDate.includes('day')) {
const days = parseInt(relativeDate.match(/(\d+)/)?.[1] || '1');
now.setDate(now.getDate() - days);
uploadedDate = now.toISOString();
}
}
}
// Extract description
description = $('meta[property="og:description"]').attr('content') ||
$('.video-description, .description, .video-summary').first().text().trim() || '';
// Extract video ID from various sources
let extractedVideoId = videoId;
const videoIdMatch = html.match(/"video_id"\s*:\s*"([^"]+)"/) ||
html.match(/video[_-]id["\s:]+([^"\s]+)/) ||
html.match(/embed\/([^/?]+)/);
if (videoIdMatch && videoIdMatch[1]) {
extractedVideoId = videoIdMatch[1];
}
// Validate extracted data
const isValidVideo = title || thumbnail || uploaderName;
return {
videoId: extractedVideoId,
title: title || 'Untitled Video',
thumbnail,
uploaderName: uploaderName || 'Unknown Uploader',
uploaderAvatar: uploaderAvatar || thumbnail,
views: Math.max(0, views),
duration: Math.max(0, duration),
uploadedDate: uploadedDate || new Date().toISOString(),
description,
url,
type: 'video',
scraped: true,
confidence: isValidVideo ? 'high' : 'low'
};
} catch (error) {
console.error('Erreur scraping Rumble:', error.message);
// Return minimal data for fallback with error info
return {
videoId,
title: 'Video unavailable',
thumbnail: '',
uploaderName: 'Unknown',
uploaderAvatar: '',
views: 0,
duration: 0,
uploadedDate: '',
description: '',
url,
type: 'video',
error: error.message,
scraped: false,
confidence: 'none'
};
}
}
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) --------------------
// 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 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 = (typeof getChannelAdapter === 'function') ? getChannelAdapter(regProvider) : null;
if (adapter && typeof adapter.fetchChannelById === 'function') {
const ch = await adapter.fetchChannelById(String(channelExternalId));
if (ch?.avatarUrl) uploaderAvatar = ch.avatarUrl;
if (typeof ch?.subsCount === 'number' && !subscribers) subscribers = ch.subsCount;
}
}
} catch {}
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: typeof meta.view_count === 'number' ? meta.view_count : (typeof meta.viewCount === 'number' ? meta.viewCount : 0),
duration: typeof meta.duration === 'number' ? meta.duration : 0,
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',
};
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)
r.use('/download', authMiddlewareCookieAware, downloadLimiter);
// List available formats for a given video
r.get('/download/:provider/:videoId/formats', 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 url = providerUrlFrom(provider, videoId, { instance, slug, sourceUrl });
const raw = await youtubedl(url, { dumpSingleJson: true, noWarnings: true, noCheckCertificates: true, skipDownload: true });
const meta = (typeof raw === 'string') ? JSON.parse(raw || '{}') : (raw || {});
const formats = formatListFromMeta(meta);
return res.json({ url, formats, title: meta?.title || '', duration: meta?.duration || 0 });
} 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) });
}
});
// Start a download job
r.post('/download/:provider/:videoId', 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', (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', 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', (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', (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', (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 });
});
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']);
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']) {
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);
});
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 rows = listSearchHistory({ userId: req.user.id, limit, before, q });
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 rows = listWatchHistory({ userId: req.user.id, limit, before, q });
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();
});
// --- Likes ---
r.get('/user/likes', authMiddleware, (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 });
return res.json(rows);
});
r.post('/user/likes', authMiddleware, async (req, res) => {
let { provider, videoId, title, thumbnail } = req.body || {};
try {
console.log('[POST /user/likes] payload:', {
provider,
videoId,
titlePreview: typeof title === 'string' ? title.slice(0, 80) : title,
hasThumbnail: Boolean(thumbnail)
});
} catch {}
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 });
return res.status(201).json(row);
});
r.delete('/user/likes', authMiddleware, (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 });
return res.json(result);
});
// Like status for a specific video
r.get('/user/likes/status', authMiddleware, (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 });
return res.json({ liked });
});
// 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
app.use('/api', r);
// Health endpoint for container checks
app.get('/api/health', (_req, res) => res.json({ status: 'ok' }));
// 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)
app.use('/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' });
}
});
// -------------------- 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 {
const pathPart = req.originalUrl.replace(/^\/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'));
// -------------------- Unified search endpoint (GET) --------------------
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)));
// 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';
// Validate and normalize providers list (default to all supported when none/invalid)
const requested = typeof providers === 'string' ? String(providers) : '';
const validProviders = validateProviders(requested);
// Execute search for each provider in parallel
const results = await Promise.allSettled(
validProviders.map((providerId) => {
const mod = providerRegistry[/** @type {any} */(providerId)];
if (!mod || typeof mod.search !== 'function') return Promise.resolve([]);
// Basic options include pagination and sort hints
return Promise.resolve().then(() => mod.search(q, { limit: pageSize, page: pageNum, sort }));
})
);
// Group results by provider id (+ per-provider errors for diagnosable UI)
const groups = /** @type {Record<string, any[]>} */ ({});
const errors = /** @type {Record<string, { message: string, status?: number, code?: string }>} */ ({});
results.forEach((result, index) => {
const providerId = validProviders[index];
if (result.status === 'fulfilled') {
groups[providerId] = Array.isArray(result.value) ? result.value : [];
} else {
console.warn(`Search failed for provider ${providerId}:`, result.reason?.message || result.reason);
groups[providerId] = [];
try {
const r = /** @type {any} */ (result.reason);
errors[providerId] = {
message: String(r?.message || r || 'search_failed'),
...(typeof r?.ytStatus === 'number' ? { status: r.ytStatus } : {}),
...(r?.code ? { code: String(r.code) } : {}),
};
} catch {}
}
});
return res.json({ q, providers: validProviders, groups, errors, page: pageNum, pageSize, sort });
} catch (e) {
return res.status(500).json({ error: 'search_failed', details: String(e?.message || e) });
}
});
// -------------------- 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
app.use(express.static(staticDir, { maxAge: '1h', index: 'index.html' }));
// SPA fallback: any non-API GET should serve index.html
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)) 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}`);
});
// --- Playlists ---
// Create a new playlist
r.post('/playlists', authMiddleware, (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', authMiddleware, (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', authMiddleware, (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', authMiddleware, (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', authMiddleware, (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', authMiddleware, 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', authMiddleware, (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', authMiddleware, (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 (minimal for playlists) ---
r.get('/openapi.json', (_req, res) => {
const doc = {
openapi: '3.0.0',
info: { title: 'NewTube API', version: '1.0.0' },
paths: {
'/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);
});