import express from 'express'; import helmet from 'helmet'; import cors from 'cors'; import cookieParser from 'cookie-parser'; import rateLimit from 'express-rate-limit'; import bcrypt from 'bcryptjs'; import jwt from 'jsonwebtoken'; import fs from 'node:fs'; import path from 'node:path'; import youtubedl from 'youtube-dl-exec'; import ffmpegPath from 'ffmpeg-static'; import * as cheerio from 'cheerio'; import axios from 'axios'; import rumbleRouter from './rumble.mjs'; import { providerRegistry, validateProviders } from './providers/registry.mjs'; import { getUserByUsername, getUserById, insertUser, insertSession, getSessionById, updateSessionToken, revokeSession, revokeAllUserSessions, listUserSessions, setUserLastLogin, insertLoginAudit, getPreferences, 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 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 // -------------------- 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')); // -------------------- Unified search endpoint (GET) -------------------- app.get('/api/search', async (req, res) => { try { const { q, providers } = req.query; if (!q || typeof q !== 'string' || q.trim().length === 0) { return res.status(400).json({ error: 'missing_query' }); } // Validate and normalize providers list (default to all supported when none/invalid) const requested = typeof providers === 'string' ? String(providers) : ''; const validProviders = validateProviders(requested); // Execute search for each provider in parallel const results = await Promise.allSettled( validProviders.map((providerId) => { const mod = providerRegistry[/** @type {any} */(providerId)]; if (!mod || typeof mod.search !== 'function') return Promise.resolve([]); // Basic options: limit per provider; could be extended with page, etc. return Promise.resolve().then(() => mod.search(q, { limit: 10 })); }) ); // Group results by provider id const groups = /** @type {Record} */ ({}); results.forEach((result, index) => { const providerId = validProviders[index]; if (result.status === 'fulfilled') { groups[providerId] = Array.isArray(result.value) ? result.value : []; } else { console.warn(`Search failed for provider ${providerId}:`, result.reason); groups[providerId] = []; } }); return res.json({ q, providers: validProviders, groups }); } catch (e) { return res.status(500).json({ error: 'search_failed', details: String(e?.message || e) }); } }); // -------------------- Static Frontend (Angular build) -------------------- const distRoot = path.join(process.cwd(), 'dist'); const distBrowser = path.join(distRoot, 'browser'); const staticDir = fs.existsSync(distBrowser) ? distBrowser : distRoot; // Mount static files unconditionally; if path missing, it will just not serve anything app.use(express.static(staticDir, { maxAge: '1h', index: 'index.html' })); // SPA fallback: any non-API GET should serve index.html app.get('*', (req, res, next) => { try { const url = req.originalUrl || req.url || ''; if (url.startsWith('/api/')) return next(); const indexPath = path.join(staticDir, 'index.html'); if (fs.existsSync(indexPath)) return res.sendFile(indexPath); return next(); } catch { return next(); } }); app.listen(PORT, () => { const cwd = process.cwd(); const hasDistRoot = fs.existsSync(distRoot); const hasDistBrowser = fs.existsSync(distBrowser); const hasIndex = fs.existsSync(path.join(staticDir, 'index.html')); console.log(`[newtube-api] listening on http://localhost:${PORT}`); console.log(`[newtube-api] cwd=${cwd}`); console.log(`[newtube-api] distRoot=${distRoot} exists=${hasDistRoot}`); console.log(`[newtube-api] distBrowser=${distBrowser} exists=${hasDistBrowser}`); console.log(`[newtube-api] staticDir=${staticDir} indexExists=${hasIndex}`); }); // --- Playlists --- // Create a new playlist r.post('/playlists', authMiddleware, (req, res) => { try { const { title, description, thumbnail, isPrivate } = req.body || {}; if (!title || String(title).trim().length === 0) { return res.status(400).json({ error: 'title_required' }); } const pl = createPlaylist({ userId: req.user.id, title: String(title).trim(), description, thumbnail, isPrivate: !!isPrivate }); return res.status(201).json(pl); } catch (e) { const msg = String(e?.message || e); if (msg === 'title_required') return res.status(400).json({ error: msg }); return res.status(500).json({ error: 'create_failed', details: msg }); } }); // List current user's playlists (pagination + search) r.get('/playlists', authMiddleware, (req, res) => { try { const limit = Math.min(200, Math.max(1, Number(req.query.limit || 50))); const offset = Math.max(0, Number(req.query.offset || 0)); const q = typeof req.query.q === 'string' ? req.query.q : undefined; const rows = listPlaylists({ userId: req.user.id, limit, offset, q }); return res.json(rows); } catch (e) { return res.status(500).json({ error: 'list_failed', details: String(e?.message || e) }); } }); // Get playlist details (owner only for now) r.get('/playlists/:id', authMiddleware, (req, res) => { try { const id = String(req.params.id || ''); const pl = getPlaylistRaw(id); if (!pl) return res.status(404).json({ error: 'not_found' }); if (pl.userId !== req.user.id) return res.status(404).json({ error: 'not_found' }); const limit = Math.min(2000, Math.max(1, Number(req.query.limit || 500))); const offset = Math.max(0, Number(req.query.offset || 0)); const items = listPlaylistItems({ playlistId: id, limit, offset }); return res.json({ ...pl, items }); } catch (e) { return res.status(500).json({ error: 'get_failed', details: String(e?.message || e) }); } }); // Update a playlist (title/description/thumbnail/isPrivate) r.put('/playlists/:id', authMiddleware, (req, res) => { try { const id = String(req.params.id || ''); const patch = req.body || {}; const result = updatePlaylist({ userId: req.user.id, id, patch }); if (result === 'forbidden') return res.status(403).json({ error: 'forbidden' }); if (!result) return res.status(404).json({ error: 'not_found' }); return res.json(result); } catch (e) { return res.status(500).json({ error: 'update_failed', details: String(e?.message || e) }); } }); // Delete a playlist r.delete('/playlists/:id', authMiddleware, (req, res) => { try { const id = String(req.params.id || ''); const result = deletePlaylist({ userId: req.user.id, id }); if (result === 'forbidden') return res.status(403).json({ error: 'forbidden' }); if (!result || !result.removed) return res.status(404).json({ error: 'not_found' }); return res.status(204).end(); } catch (e) { return res.status(500).json({ error: 'delete_failed', details: String(e?.message || e) }); } }); // Add a video to a playlist (enrich title/thumbnail if missing) r.post('/playlists/:id/videos', authMiddleware, async (req, res) => { try { const playlistId = String(req.params.id || ''); let { provider, videoId, title, thumbnail, sourceUrl, slug, instance } = req.body || {}; provider = String(provider || '').trim(); videoId = String(videoId || '').trim(); if (!provider || !videoId) return res.status(400).json({ error: 'provider_and_videoId_required' }); // Optional enrichment like likes route try { const needTitle = !(typeof title === 'string' && title.trim().length > 0); const needThumb = !(typeof thumbnail === 'string' && thumbnail.trim().length > 0); if (needTitle || needThumb) { const url = providerUrlFrom(provider, videoId, { instance, slug, sourceUrl }); try { const raw = await youtubedl(url, { dumpSingleJson: true, noWarnings: true, noCheckCertificates: true, skipDownload: true }); const meta = (typeof raw === 'string') ? JSON.parse(raw || '{}') : (raw || {}); if (needTitle) title = meta?.title || title || ''; if (needThumb) thumbnail = meta?.thumbnail || (Array.isArray(meta?.thumbnails) && meta.thumbnails.length ? meta.thumbnails[0].url : thumbnail || ''); } catch {} } } catch {} const row = addPlaylistVideo({ userId: req.user.id, playlistId, provider, videoId, title, thumbnail }); if (row === 'not_found') return res.status(404).json({ error: 'playlist_not_found' }); if (row === 'forbidden') return res.status(403).json({ error: 'forbidden' }); return res.status(201).json(row); } catch (e) { return res.status(500).json({ error: 'add_video_failed', details: String(e?.message || e) }); } }); // Remove a video from a playlist (provider required via query) r.delete('/playlists/:id/videos/:videoId', authMiddleware, (req, res) => { try { const playlistId = String(req.params.id || ''); const videoId = String(req.params.videoId || ''); const provider = req.query.provider ? String(req.query.provider) : ''; if (!provider || !videoId) return res.status(400).json({ error: 'provider_and_videoId_required' }); const result = removePlaylistVideo({ userId: req.user.id, playlistId, provider, videoId }); if (result === 'not_found') return res.status(404).json({ error: 'playlist_not_found' }); if (result === 'forbidden') return res.status(403).json({ error: 'forbidden' }); return res.json(result); } catch (e) { return res.status(500).json({ error: 'remove_video_failed', details: String(e?.message || e) }); } }); // Reorder playlist items r.put('/playlists/:id/reorder', authMiddleware, (req, res) => { try { const playlistId = String(req.params.id || ''); const order = Array.isArray(req.body?.order) ? req.body.order : []; if (!order.length) return res.status(400).json({ error: 'order_required' }); const result = reorderPlaylistVideos({ userId: req.user.id, playlistId, order }); if (result === 'not_found') return res.status(404).json({ error: 'playlist_not_found' }); if (result === 'forbidden') return res.status(403).json({ error: 'forbidden' }); return res.json(result); } catch (e) { return res.status(500).json({ error: 'reorder_failed', details: String(e?.message || e) }); } }); // --- OpenAPI (minimal for playlists) --- r.get('/openapi.json', (_req, res) => { const doc = { openapi: '3.0.0', info: { title: 'NewTube API', version: '1.0.0' }, paths: { '/playlists': { get: { summary: 'List user playlists', security: [{ bearerAuth: [] }], parameters: [ { name: 'limit', in: 'query', schema: { type: 'integer' } }, { name: 'offset', in: 'query', schema: { type: 'integer' } }, { name: 'q', in: 'query', schema: { type: 'string' } }, ] }, post: { summary: 'Create playlist', security: [{ bearerAuth: [] }], requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', properties: { title: { type: 'string' }, description: { type: 'string' }, thumbnail: { type: 'string' }, isPrivate: { type: 'boolean' } }, required: ['title'] } } } } } }, '/playlists/{id}': { get: { summary: 'Get playlist details', security: [{ bearerAuth: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }] }, put: { summary: 'Update playlist', security: [{ bearerAuth: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }] }, delete: { summary: 'Delete playlist', security: [{ bearerAuth: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }] } }, '/playlists/{id}/videos': { post: { summary: 'Add video to playlist', security: [{ bearerAuth: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }] } }, '/playlists/{id}/videos/{videoId}': { delete: { summary: 'Remove video from playlist', security: [{ bearerAuth: [] }], parameters: [ { name: 'id', in: 'path', required: true, schema: { type: 'string' } }, { name: 'videoId', in: 'path', required: true, schema: { type: 'string' } }, { name: 'provider', in: 'query', required: true, schema: { type: 'string' } } ] } }, '/playlists/{id}/reorder': { put: { summary: 'Reorder playlist items', security: [{ bearerAuth: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }] } } }, components: { securitySchemes: { bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' } } } }; res.json(doc); });