NewTube/server/index.mjs

1594 lines
63 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,
// Playlists
createPlaylist,
listPlaylists,
listPublicPlaylists,
getPlaylistRaw,
getPlaylistWithItemsIfAllowed,
updatePlaylist,
deletePlaylist,
listPlaylistItems,
addPlaylistVideo,
removePlaylistVideo,
reorderPlaylistVideos,
} 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);
// Create router
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) });
}
});
// 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 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
// -------------------- YouTube simple cache (GET) --------------------
// YouTube API key rotation and error handling (similar to Angular service)
const ytKeys = (() => {
try {
const keys = process.env.YOUTUBE_API_KEYS;
if (keys && keys !== 'undefined' && keys !== 'null') {
return JSON.parse(keys);
}
} catch {}
const single = process.env.YOUTUBE_API_KEY;
return single ? [single] : [];
})();
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);
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}`);
}
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);
// Check if we have cached data and it's still valid
if (cached && (now - cached.ts) < YT_CACHE_TTL_MS) {
return res.json(cached.data);
}
const key = getActiveYouTubeKey();
if (!key) {
console.warn('[YouTube API] No API key available');
return res.status(503).json({ error: 'youtube_api_key_unavailable' });
}
// Add API key to the URL
const url = new URL(targetUrl);
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);
// Cache the result
ytCache.set(targetUrl, {
ts: now,
data: data,
status: status,
isError: status >= 400
});
return res.status(status).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.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 });
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, 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 --------------------
function jsVal(v) { return JSON.stringify(v == null ? '' : v); }
app.get(['/assets/config.local.js', '/assets/config.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/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'));
// -------------------- 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);
});