NewTube/server/index.mjs

1192 lines
46 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 {
getUserByUsername,
getUserById,
insertUser,
insertSession,
getSessionById,
updateSessionToken,
revokeSession,
revokeAllUserSessions,
listUserSessions,
setUserLastLogin,
insertLoginAudit,
getPreferences,
upsertPreferences,
cryptoRandomId,
cryptoRandomUUID,
insertSearchHistory,
listSearchHistory,
deleteSearchHistoryById,
deleteAllSearchHistory,
upsertWatchHistory,
listWatchHistory,
updateWatchHistoryById,
deleteWatchHistoryById,
deleteAllWatchHistory,
likeVideo,
unlikeVideo,
listLikedVideos,
isVideoLiked,
} from './db.mjs';
const app = express();
const PORT = Number(process.env.PORT || 4000);
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-me';
const ACCESS_TTL_MIN = Number(process.env.ACCESS_TTL_MIN || 15);
const REFRESH_TTL_DAYS = Number(process.env.REFRESH_TTL_DAYS || 2);
const REMEMBER_TTL_DAYS = Number(process.env.REMEMBER_TTL_DAYS || 30);
// 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 thirdparty thumbnails/CDNs used by providers
contentSecurityPolicy: false,
// Disable COEP to avoid blocking crossorigin resources (e.g., images/videos)
crossOriginEmbedderPolicy: false,
// Allow loading crossorigin images
crossOriginResourcePolicy: { policy: 'cross-origin' },
}));
app.use(express.json());
app.use(cookieParser());
app.use(cors({
origin: true,
credentials: true,
}));
// Downloads directory
const downloadsRoot = path.join(process.cwd(), 'tmp', 'downloads');
if (!fs.existsSync(downloadsRoot)) {
fs.mkdirSync(downloadsRoot, { recursive: true });
}
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 setRefreshCookies(res, { sessionId, token, days }) {
const maxAgeMs = days * 24 * 60 * 60 * 1000;
const cookieOpts = {
httpOnly: true,
sameSite: 'strict',
secure: process.env.NODE_ENV === 'production',
// In dev the Angular proxy uses /proxy/api; in prod use '/api'
path: process.env.NODE_ENV === 'production' ? '/api' : '/proxy/api',
maxAge: maxAgeMs,
};
res.cookie('sid', sessionId, cookieOpts);
res.cookie('refreshToken', token, cookieOpts);
}
function clearRefreshCookies(res) {
const base = {
httpOnly: true,
sameSite: 'strict',
secure: process.env.NODE_ENV === 'production',
path: process.env.NODE_ENV === 'production' ? '/api' : '/proxy/api',
};
res.clearCookie('sid', base);
res.clearCookie('refreshToken', base);
}
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 --------------------
const DOWNLOAD_ALLOWED_PROVIDERS = (process.env.DOWNLOAD_PROVIDERS || 'peertube,odysee').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
const r = express.Router();
// -------------------- YouTube simple cache (GET) --------------------
// Cache TTL defaults to 15 minutes, configurable via env YT_CACHE_TTL_MS
const YT_CACHE_TTL_MS = Number(process.env.YT_CACHE_TTL_MS || 15 * 60 * 1000);
/** @type {Map<string, { ts: number, data: any }>} */
const ytCache = new Map();
// Example: /api/yt/youtube/v3/videos?... -> https://www.googleapis.com/youtube/v3/videos?...
r.get('/yt/*', async (req, res) => {
try {
const googlePath = req.originalUrl.replace(/^\/api\/yt/, '');
const targetUrl = `https://www.googleapis.com${googlePath}`;
const now = Date.now();
const cached = ytCache.get(targetUrl);
if (cached && (now - cached.ts) < YT_CACHE_TTL_MS) {
return res.json(cached.data);
}
const response = await axios.get(targetUrl, { timeout: 15000, validateStatus: s => s >= 200 && s < 400 });
const data = response.data;
ytCache.set(targetUrl, { ts: now, data });
return res.status(response.status || 200).json(data);
} 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 out = {
videoId,
title: meta.title || '',
thumbnail: meta.thumbnail || (Array.isArray(meta.thumbnails) && meta.thumbnails.length ? meta.thumbnails[0].url : ''),
uploaderName: meta.uploader || meta.channel || '',
uploaderAvatar: '',
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';
// Limit concurrent jobs per user
const activeCount = Array.from(jobs.values()).filter(j => j.userId === userId && (j.state === 'queued' || j.state === 'running' || j.state === 'merging')).length;
if (activeCount >= Number(process.env.DOWNLOAD_MAX_CONCURRENT || 2)) {
return res.status(429).json({ error: 'too_many_downloads' });
}
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();
// Keep the produced filename simple and rename after completion
const tmpOutTpl = path.join(downloadsRoot, `${jobId}.%(ext)s`);
// Fetch metadata to build the final filename (title & resolution)
let expectedBaseName = '';
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 || {});
const title = sanitizeFileName(meta?.title || `${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);
// 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;
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();
}
if (/\[Merger]/.test(s)) {
job.state = 'merging';
}
};
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();
});
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();
return;
}
// Find produced file
const files = fs.readdirSync(downloadsRoot).filter(f => f.startsWith(`${jobId}.`));
if (files.length > 0) {
const f = files[0];
const p = path.join(downloadsRoot, 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(downloadsRoot, 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();
} else {
job.state = 'failed';
job.error = 'file_not_found_after_download';
job.updatedAt = new Date().toISOString();
}
} catch (err) {
job.state = 'failed';
job.error = String(err?.message || err);
job.updatedAt = new Date().toISOString();
}
});
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) });
}
});
// Job status
r.get('/download/jobs/:id', (req, res) => {
const { id } = req.params;
const job = jobs.get(id);
if (!job) return res.status(404).json({ error: 'not_found' });
// Hide internal Proc from response
const { proc, ...safe } = job;
return res.json(safe);
});
// Stream the file (supports Range)
r.get('/download/jobs/:id/file', (req, res) => {
const { id } = req.params;
const job = jobs.get(id);
if (!job || job.state !== 'completed' || !job.filePath) return res.status(404).json({ error: 'not_found' });
const filePath = job.filePath;
const stat = fs.statSync(filePath);
const total = stat.size;
const ext = job.fileExt || 'mp4';
const ctype = guessContentTypeByExt(ext);
const fileName = sanitizeFileName(job.fileName || `${job.provider}-${job.videoId}.${ext}`);
res.setHeader('Content-Type', ctype);
res.setHeader('Accept-Ranges', 'bytes');
res.setHeader('Content-Disposition', `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
r.delete('/download/jobs/:id', (req, res) => {
const { id } = req.params;
const job = jobs.get(id);
if (!job) return res.status(404).json({ error: 'not_found' });
try {
if (job.proc && typeof job.proc.kill === 'function') {
job.proc.kill('SIGKILL');
}
} catch {}
try {
if (job.filePath && fs.existsSync(job.filePath)) fs.unlinkSync(job.filePath);
} catch {}
jobs.delete(id);
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();
insertSession({ id: sessionId, userId: id, refreshTokenHash, isRemember: false, userAgent: req.headers['user-agent'] || '', deviceInfo: '', ip: getClientIp(req), expiresAt });
setUserLastLogin(id);
insertLoginAudit({ userId: id, username, ip: getClientIp(req), userAgent: req.headers['user-agent'] || '', success: true });
setRefreshCookies(res, { sessionId, token: refreshToken, days });
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 });
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.is_remember ? 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 });
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 = getPreferences(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 = getPreferences(req.user.id);
return res.json(prefs || {});
});
// --- 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();
});
// Delete a single search history item
r.delete('/user/history/search/:id', authMiddleware, (req, res) => {
const { id } = req.params;
if (!id) return res.status(400).json({ error: 'id is required' });
try {
deleteSearchHistoryById(req.user.id, id);
return res.status(204).end();
} catch (e) {
return res.status(500).json({ error: 'delete_failed', details: String(e?.message || e) });
}
});
// --- 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, (req, res) => {
const { provider, videoId } = req.body || {};
if (!provider || !videoId) return res.status(400).json({ error: 'provider and videoId are required' });
const row = likeVideo({ userId: req.user.id, provider, videoId });
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);
});
// Odysee image proxy to avoid CORS/ORB issues
r.get('/img/odysee', async (req, res) => {
try {
const imageUrl = req.query.u ? String(req.query.u) : '';
if (!imageUrl) return res.status(400).json({ error: 'Missing image URL' });
// Only allow specific Odysee domains for security
const allowedHosts = [
'thumbnails.odycdn.com',
'thumbs.odycdn.com',
'od.lk',
'cdn.lbryplayer.xyz',
'spee.ch',
'cdn.lbryplayer.xyz',
'thumbnails.lbry.com',
'static.odycdn.com',
'thumbnails.odycdn.com'
];
const url = new URL(imageUrl);
if (!allowedHosts.includes(url.hostname)) {
return res.status(403).json({ error: 'Forbidden domain' });
}
// Fetch the image
const response = await fetch(imageUrl, {
headers: { 'User-Agent': 'NewTube/1.0' },
redirect: 'follow'
});
if (!response.ok) {
return res.status(response.status).send(`Error fetching image: ${response.statusText}`);
}
// Forward the image with appropriate caching
res.setHeader('Cache-Control', 'public, max-age=600');
res.setHeader('Content-Type', response.headers.get('content-type') || 'image/jpeg');
const arrayBuffer = await response.arrayBuffer();
res.send(Buffer.from(arrayBuffer));
} catch (error) {
console.error('Error proxying Odysee image:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
app.use('/api', r);
// 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 --------------------
function jsVal(v) { return JSON.stringify(v == null ? '' : v); }
app.get(['/assets/config.local.js', '/config.js'], (_req, res) => {
// WARNING: Values served here are exposed to the browser. Do not put secrets here unless you accept this.
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 {
const arr = String(env.YOUTUBE_API_KEYS).split(',').map(s => s.trim()).filter(Boolean);
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)};`);
if (env.TWITCH_CLIENT_SECRET) lines.push(`window.TWITCH_CLIENT_SECRET = ${jsVal(env.TWITCH_CLIENT_SECRET)};`);
if (env.GEMINI_API_KEY) lines.push(`window.GEMINI_API_KEY = ${jsVal(env.GEMINI_API_KEY)};`);
if (env.RUMBLE_API_KEY) lines.push(`window.RUMBLE_API_KEY = ${jsVal(env.RUMBLE_API_KEY)};`);
res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
res.send(lines.join('\n'));
});
// -------------------- 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();
const headers = { ...req.headers };
delete headers['host'];
// Avoid sending cookie headers to third-parties
delete headers['cookie'];
const resp = await axios({ url: targetUrl, method, headers, 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/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'));
// -------------------- Static Frontend (Angular build) --------------------
try {
const staticDir = path.join(process.cwd(), 'dist');
if (fs.existsSync(staticDir)) {
app.use(express.static(staticDir, { maxAge: '1h', index: 'index.html' }));
// SPA fallback: route non-API GETs to index.html
app.get('*', (req, res, next) => {
const url = req.originalUrl || req.url || '';
if (url.startsWith('/api/') || url.startsWith('/health')) return next();
const indexPath = path.join(staticDir, 'index.html');
if (fs.existsSync(indexPath)) return res.sendFile(indexPath);
return next();
});
}
} catch {}
app.listen(PORT, () => {
console.log(`[newtube-api] listening on http://localhost:${PORT}`);
});