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 third‑party thumbnails/CDNs used by providers contentSecurityPolicy: false, // Disable COEP to avoid blocking cross‑origin resources (e.g., images/videos) crossOriginEmbedderPolicy: false, // Allow loading cross‑origin images crossOriginResourcePolicy: { policy: 'cross-origin' }, })); app.use(express.json()); app.use(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} */ 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} */ 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); }); // 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); // 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/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) -------------------- 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}`); });