1526 lines
62 KiB
JavaScript
1526 lines
62 KiB
JavaScript
import express from 'express';
|
||
import helmet from 'helmet';
|
||
import cors from 'cors';
|
||
import cookieParser from 'cookie-parser';
|
||
import rateLimit from 'express-rate-limit';
|
||
import bcrypt from 'bcryptjs';
|
||
import jwt from 'jsonwebtoken';
|
||
import fs from 'node:fs';
|
||
import path from 'node:path';
|
||
import youtubedl from 'youtube-dl-exec';
|
||
import ffmpegPath from 'ffmpeg-static';
|
||
import * as cheerio from 'cheerio';
|
||
import axios from 'axios';
|
||
import rumbleRouter from './rumble.mjs';
|
||
import {
|
||
getUserByUsername,
|
||
getUserById,
|
||
insertUser,
|
||
insertSession,
|
||
getSessionById,
|
||
updateSessionToken,
|
||
revokeSession,
|
||
revokeAllUserSessions,
|
||
listUserSessions,
|
||
setUserLastLogin,
|
||
insertLoginAudit,
|
||
getPreferences,
|
||
upsertPreferences,
|
||
cryptoRandomId,
|
||
cryptoRandomUUID,
|
||
insertSearchHistory,
|
||
listSearchHistory,
|
||
deleteSearchHistoryById,
|
||
deleteAllSearchHistory,
|
||
upsertWatchHistory,
|
||
listWatchHistory,
|
||
updateWatchHistoryById,
|
||
deleteWatchHistoryById,
|
||
deleteAllWatchHistory,
|
||
likeVideo,
|
||
unlikeVideo,
|
||
listLikedVideos,
|
||
isVideoLiked,
|
||
// Playlists
|
||
createPlaylist,
|
||
listPlaylists,
|
||
listPublicPlaylists,
|
||
getPlaylistRaw,
|
||
getPlaylistWithItemsIfAllowed,
|
||
updatePlaylist,
|
||
deletePlaylist,
|
||
listPlaylistItems,
|
||
addPlaylistVideo,
|
||
removePlaylistVideo,
|
||
reorderPlaylistVideos,
|
||
} from './db.mjs';
|
||
|
||
const app = express();
|
||
const PORT = Number(process.env.PORT || 4000);
|
||
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-me';
|
||
const ACCESS_TTL_MIN = Number(process.env.ACCESS_TTL_MIN || 15);
|
||
const REFRESH_TTL_DAYS = Number(process.env.REFRESH_TTL_DAYS || 2);
|
||
const REMEMBER_TTL_DAYS = Number(process.env.REMEMBER_TTL_DAYS || 30);
|
||
|
||
// Create router
|
||
const r = express.Router();
|
||
|
||
// Public: list public playlists (no auth required)
|
||
r.get('/playlists/public', (req, res) => {
|
||
try {
|
||
const limit = Math.min(200, Math.max(1, Number(req.query.limit || 50)));
|
||
const offset = Math.max(0, Number(req.query.offset || 0));
|
||
const q = typeof req.query.q === 'string' ? req.query.q : undefined;
|
||
const rows = listPublicPlaylists({ limit, offset, q });
|
||
return res.json(rows);
|
||
} catch (e) {
|
||
return res.status(500).json({ error: 'list_public_failed', details: String(e?.message || e) });
|
||
}
|
||
});
|
||
|
||
// Public: view a playlist if allowed (owner or public). Authorization header is optional.
|
||
r.get('/playlists/:id/view', (req, res) => {
|
||
try {
|
||
const id = String(req.params.id || '');
|
||
let viewerUserId = undefined;
|
||
try {
|
||
const auth = req.headers['authorization'] || '';
|
||
const [, token] = String(auth).split(' ');
|
||
if (token) {
|
||
const payload = jwt.verify(token, JWT_SECRET);
|
||
viewerUserId = payload?.sub;
|
||
}
|
||
} catch {}
|
||
const limit = Math.min(2000, Math.max(1, Number(req.query.limit || 500)));
|
||
const offset = Math.max(0, Number(req.query.offset || 0));
|
||
const result = getPlaylistWithItemsIfAllowed({ viewerUserId, id, limit, offset });
|
||
if (result === 'forbidden') return res.status(404).json({ error: 'not_found' });
|
||
if (!result) return res.status(404).json({ error: 'not_found' });
|
||
return res.json(result);
|
||
} catch (e) {
|
||
return res.status(500).json({ error: 'view_failed', details: String(e?.message || e) });
|
||
}
|
||
});
|
||
|
||
// Servir les fichiers statiques du dossier dist
|
||
app.use(express.static(path.join(process.cwd(), 'dist')));
|
||
app.use('/assets', express.static(path.join(process.cwd(), 'assets')));
|
||
|
||
app.set('trust proxy', 1);
|
||
app.use(helmet({
|
||
// Disable strict CSP for now to allow 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<string, any>} */
|
||
const jobs = new Map();
|
||
|
||
function sanitizeFileName(name) {
|
||
return String(name || 'video')
|
||
.replace(/[^a-zA-Z0-9-_\. ]+/g, '_')
|
||
.replace(/[\s]+/g, ' ')
|
||
.trim()
|
||
.slice(0, 140);
|
||
}
|
||
|
||
function providerUrlFrom(provider, videoId, { instance, slug, sourceUrl }) {
|
||
if (sourceUrl && /^https?:\/\//i.test(sourceUrl)) return sourceUrl;
|
||
const id = String(videoId);
|
||
switch (String(provider)) {
|
||
case 'youtube':
|
||
return `https://www.youtube.com/watch?v=${encodeURIComponent(id)}`;
|
||
case 'dailymotion':
|
||
return `https://www.dailymotion.com/video/${encodeURIComponent(id)}`;
|
||
case 'twitch':
|
||
return `https://www.twitch.tv/videos/${encodeURIComponent(id)}`;
|
||
case 'peertube': {
|
||
const inst = String(instance || '').trim();
|
||
if (!inst) throw new Error('peertube_instance_required');
|
||
return `https://${inst}/w/${encodeURIComponent(id)}`;
|
||
}
|
||
case 'odysee': {
|
||
const s = String(slug || id);
|
||
return `https://odysee.com/${s.replace(/^\//, '')}`;
|
||
}
|
||
case 'rumble':
|
||
return `https://rumble.com/${encodeURIComponent(id)}`;
|
||
default:
|
||
throw new Error('unsupported_provider');
|
||
}
|
||
}
|
||
|
||
async function scrapeRumbleVideo(videoId) {
|
||
const url = `https://rumble.com/${videoId}`;
|
||
|
||
try {
|
||
const response = await axios.get(url, {
|
||
headers: {
|
||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
|
||
'Accept-Language': 'en-US,en;q=0.5',
|
||
'Accept-Encoding': 'gzip, deflate, br',
|
||
'DNT': '1',
|
||
'Connection': 'keep-alive',
|
||
'Upgrade-Insecure-Requests': '1',
|
||
'Cache-Control': 'no-cache'
|
||
},
|
||
timeout: 15000,
|
||
maxRedirects: 5,
|
||
validateStatus: function (status) {
|
||
return status >= 200 && status < 400; // Accept redirects
|
||
}
|
||
});
|
||
|
||
const $ = cheerio.load(response.data);
|
||
const html = response.data;
|
||
|
||
// Extract basic video information
|
||
let title = '';
|
||
let thumbnail = '';
|
||
let uploaderName = '';
|
||
let uploaderAvatar = '';
|
||
let views = 0;
|
||
let duration = 0;
|
||
let uploadedDate = '';
|
||
let description = '';
|
||
|
||
// Try multiple selectors for title (Rumble's HTML structure can vary)
|
||
title = $('h1.video-title, .video-title h1, [data-video-title]').first().text().trim() ||
|
||
$('meta[property="og:title"]').attr('content') ||
|
||
$('title').text().trim() ||
|
||
$('h1').first().text().trim() || '';
|
||
|
||
// Clean up title (remove site name if present)
|
||
title = title.replace(/\s*\|\s*Rumble$/i, '').trim();
|
||
|
||
// Extract thumbnail with fallbacks
|
||
thumbnail = $('meta[property="og:image"], meta[name="twitter:image"]').attr('content') ||
|
||
$('meta[property="og:image:secure_url"]').attr('content') ||
|
||
$('.video-thumbnail img, .thumbnail img').attr('src') || '';
|
||
|
||
// Make thumbnail URL absolute if relative
|
||
if (thumbnail && !thumbnail.startsWith('http')) {
|
||
thumbnail = thumbnail.startsWith('//') ? 'https:' + thumbnail : 'https://rumble.com' + thumbnail;
|
||
}
|
||
|
||
// Extract uploader information with multiple selectors
|
||
uploaderName = $('.media-by--a, .channel-name, .uploader-name').first().text().trim() ||
|
||
$('meta[property="article:author"]').attr('content') ||
|
||
$('.author-name, .channel-link').first().text().trim() || '';
|
||
|
||
uploaderAvatar = $('.channel-avatar img, .uploader-avatar img').attr('src') ||
|
||
$('.author-avatar img').attr('src') || '';
|
||
|
||
// Make uploader avatar URL absolute
|
||
if (uploaderAvatar && !uploaderAvatar.startsWith('http')) {
|
||
uploaderAvatar = uploaderAvatar.startsWith('//') ? 'https:' + uploaderAvatar : 'https://rumble.com' + uploaderAvatar;
|
||
}
|
||
|
||
// Extract views with better parsing
|
||
const viewsText = $('.rumbles-views, .video-views, .views-count, .video-info .views').first().text().trim();
|
||
if (viewsText) {
|
||
const viewsMatch = viewsText.match(/([\d,]+(?:\.\d+)?)\s*(K|M|B)?/i);
|
||
if (viewsMatch) {
|
||
let num = parseFloat(viewsMatch[1].replace(/,/g, ''));
|
||
const multiplier = viewsMatch[2]?.toUpperCase();
|
||
if (multiplier === 'K') num *= 1000;
|
||
else if (multiplier === 'M') num *= 1000000;
|
||
else if (multiplier === 'B') num *= 1000000000;
|
||
views = Math.floor(num);
|
||
} else {
|
||
// Try direct number parsing
|
||
const directMatch = viewsText.match(/(\d+(?:,\d+)*)/);
|
||
if (directMatch) {
|
||
views = parseInt(directMatch[1].replace(/,/g, ''));
|
||
}
|
||
}
|
||
}
|
||
|
||
// Extract duration with improved parsing
|
||
const durationText = $('meta[property="video:duration"]').attr('content') ||
|
||
$('video').attr('duration') ||
|
||
$('.video-duration, .duration, .video-time').first().text().trim() ||
|
||
$('.time-duration').text().trim();
|
||
|
||
if (durationText) {
|
||
if (!isNaN(durationText)) {
|
||
duration = parseInt(durationText);
|
||
} else {
|
||
// Parse various duration formats
|
||
const timeMatch = durationText.match(/(\d+):(\d+)(?::(\d+))?/);
|
||
if (timeMatch) {
|
||
const hours = parseInt(timeMatch[3] || '0');
|
||
const minutes = parseInt(timeMatch[1]);
|
||
const seconds = parseInt(timeMatch[2]);
|
||
duration = hours * 3600 + minutes * 60 + seconds;
|
||
} else {
|
||
// Try HH:MM:SS format or MM:SS
|
||
const parts = durationText.split(':').map(p => parseInt(p.trim()) || 0);
|
||
if (parts.length === 3) {
|
||
duration = parts[0] * 3600 + parts[1] * 60 + parts[2];
|
||
} else if (parts.length === 2) {
|
||
duration = parts[0] * 60 + parts[1];
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Extract upload date
|
||
uploadedDate = $('meta[property="article:published_time"]').attr('content') ||
|
||
$('.upload-date, .published-date, .video-date').first().text().trim() || '';
|
||
|
||
// Try to parse relative dates
|
||
if (!uploadedDate || uploadedDate.includes('ago')) {
|
||
const relativeDate = $('.upload-date, .published-date').first().text().trim();
|
||
if (relativeDate && relativeDate.includes('ago')) {
|
||
// Convert relative date to ISO string (simple conversion)
|
||
const now = new Date();
|
||
if (relativeDate.includes('hour')) {
|
||
const hours = parseInt(relativeDate.match(/(\d+)/)?.[1] || '1');
|
||
now.setHours(now.getHours() - hours);
|
||
uploadedDate = now.toISOString();
|
||
} else if (relativeDate.includes('day')) {
|
||
const days = parseInt(relativeDate.match(/(\d+)/)?.[1] || '1');
|
||
now.setDate(now.getDate() - days);
|
||
uploadedDate = now.toISOString();
|
||
}
|
||
}
|
||
}
|
||
|
||
// Extract description
|
||
description = $('meta[property="og:description"]').attr('content') ||
|
||
$('.video-description, .description, .video-summary').first().text().trim() || '';
|
||
|
||
// Extract video ID from various sources
|
||
let extractedVideoId = videoId;
|
||
const videoIdMatch = html.match(/"video_id"\s*:\s*"([^"]+)"/) ||
|
||
html.match(/video[_-]id["\s:]+([^"\s]+)/) ||
|
||
html.match(/embed\/([^/?]+)/);
|
||
if (videoIdMatch && videoIdMatch[1]) {
|
||
extractedVideoId = videoIdMatch[1];
|
||
}
|
||
|
||
// Validate extracted data
|
||
const isValidVideo = title || thumbnail || uploaderName;
|
||
|
||
return {
|
||
videoId: extractedVideoId,
|
||
title: title || 'Untitled Video',
|
||
thumbnail,
|
||
uploaderName: uploaderName || 'Unknown Uploader',
|
||
uploaderAvatar: uploaderAvatar || thumbnail,
|
||
views: Math.max(0, views),
|
||
duration: Math.max(0, duration),
|
||
uploadedDate: uploadedDate || new Date().toISOString(),
|
||
description,
|
||
url,
|
||
type: 'video',
|
||
scraped: true,
|
||
confidence: isValidVideo ? 'high' : 'low'
|
||
};
|
||
} catch (error) {
|
||
console.error('Erreur scraping Rumble:', error.message);
|
||
|
||
// Return minimal data for fallback with error info
|
||
return {
|
||
videoId,
|
||
title: 'Video unavailable',
|
||
thumbnail: '',
|
||
uploaderName: 'Unknown',
|
||
uploaderAvatar: '',
|
||
views: 0,
|
||
duration: 0,
|
||
uploadedDate: '',
|
||
description: '',
|
||
url,
|
||
type: 'video',
|
||
error: error.message,
|
||
scraped: false,
|
||
confidence: 'none'
|
||
};
|
||
}
|
||
}
|
||
|
||
function guessContentTypeByExt(ext) {
|
||
const e = String(ext || '').toLowerCase();
|
||
if (e === 'mp4' || e === 'm4v') return 'video/mp4';
|
||
if (e === 'webm') return 'video/webm';
|
||
if (e === 'mkv') return 'video/x-matroska';
|
||
if (e === 'mp3') return 'audio/mpeg';
|
||
if (e === 'm4a' || e === 'aac') return 'audio/mp4';
|
||
if (e === 'opus' || e === 'ogg') return 'audio/ogg';
|
||
return 'application/octet-stream';
|
||
}
|
||
|
||
function formatListFromMeta(meta) {
|
||
const items = Array.isArray(meta?.formats) ? meta.formats : [];
|
||
const mapped = items.map(f => {
|
||
const height = f.height || 0;
|
||
const fps = f.fps || 0;
|
||
const resolution = height ? `${height}p${fps && fps >= 50 ? fps : ''}` : (f.format_note || '');
|
||
const sizeEstimate = f.filesize || f.filesize_approx || null;
|
||
const labelParts = [];
|
||
if (resolution) labelParts.push(resolution);
|
||
if (f.ext) labelParts.push(f.ext);
|
||
if (f.vcodec && f.vcodec !== 'none') labelParts.push(f.vcodec);
|
||
if (f.acodec && f.acodec !== 'none') labelParts.push(`+${f.acodec}`);
|
||
return {
|
||
id: f.format_id,
|
||
resolution,
|
||
fps: fps || undefined,
|
||
ext: f.ext || '',
|
||
vcodec: f.vcodec || '',
|
||
acodec: f.acodec || '',
|
||
sizeEstimate,
|
||
label: labelParts.filter(Boolean).join(' '),
|
||
};
|
||
});
|
||
// Deduplicate by id
|
||
const seen = new Set();
|
||
const out = [];
|
||
for (const m of mapped) {
|
||
if (!m.id || seen.has(m.id)) continue;
|
||
seen.add(m.id);
|
||
out.push(m);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// Routes under /api
|
||
|
||
// -------------------- YouTube simple cache (GET) --------------------
|
||
// Cache TTL defaults to 15 minutes, configurable via env YT_CACHE_TTL_MS
|
||
const YT_CACHE_TTL_MS = Number(process.env.YT_CACHE_TTL_MS || 15 * 60 * 1000);
|
||
/** @type {Map<string, { ts: number, data: any }>} */
|
||
const ytCache = new Map();
|
||
|
||
// Example: /api/yt/youtube/v3/videos?... -> https://www.googleapis.com/youtube/v3/videos?...
|
||
r.get('/yt/*', async (req, res) => {
|
||
try {
|
||
const googlePath = req.originalUrl.replace(/^\/api\/yt/, '');
|
||
const targetUrl = `https://www.googleapis.com${googlePath}`;
|
||
const now = Date.now();
|
||
const cached = ytCache.get(targetUrl);
|
||
if (cached && (now - cached.ts) < YT_CACHE_TTL_MS) {
|
||
return res.json(cached.data);
|
||
}
|
||
const response = await axios.get(targetUrl, { timeout: 15000, validateStatus: s => s >= 200 && s < 400 });
|
||
const data = response.data;
|
||
ytCache.set(targetUrl, { ts: now, data });
|
||
return res.status(response.status || 200).json(data);
|
||
} catch (e) {
|
||
const status = e?.response?.status || 500;
|
||
const data = e?.response?.data || { error: 'yt_cache_upstream_error', details: String(e?.message || e) };
|
||
return res.status(status).json(data);
|
||
}
|
||
});
|
||
|
||
// -------------------- PeerTube proxy (GET) --------------------
|
||
// Usage example: /api/peertube/video.manu.quebec/api/v1/videos?sort=-trending&count=24&start=0
|
||
r.get('/peertube/:instance/*', async (req, res) => {
|
||
try {
|
||
const instance = String(req.params.instance || '').replace(/[^a-zA-Z0-9.-]/g, '');
|
||
if (!instance) return res.status(400).json({ error: 'missing_instance' });
|
||
const rest = req.params[0] ? '/' + req.params[0] : '';
|
||
const qs = req.url.includes('?') ? req.url.substring(req.url.indexOf('?')) : '';
|
||
const targetUrl = `https://${instance}${rest}${qs}`;
|
||
const response = await axios.get(targetUrl, { timeout: 15000, validateStatus: s => s >= 200 && s < 400 });
|
||
return res.status(response.status || 200).json(response.data);
|
||
} catch (e) {
|
||
const status = e?.response?.status || 500;
|
||
const data = e?.response?.data || { error: 'peertube_upstream_error', details: String(e?.message || e) };
|
||
return res.status(status).json(data);
|
||
}
|
||
});
|
||
|
||
// -------------------- Generic video details (GET) --------------------
|
||
// Returns metadata such as title, description, uploader, thumbnail, duration and views for a provider/videoId
|
||
// Supports query params similar to download endpoints: instance (PeerTube), slug (Odysee), sourceUrl (direct)
|
||
r.get('/details/:provider/:videoId', async (req, res) => {
|
||
try {
|
||
const { provider, videoId } = req.params;
|
||
const instance = req.query.instance || undefined;
|
||
const slug = req.query.slug || undefined;
|
||
const sourceUrl = req.query.sourceUrl || undefined;
|
||
const url = providerUrlFrom(provider, videoId, { instance, slug, sourceUrl });
|
||
const raw = await youtubedl(url, { dumpSingleJson: true, noWarnings: true, noCheckCertificates: true, skipDownload: true });
|
||
const meta = (typeof raw === 'string') ? JSON.parse(raw || '{}') : (raw || {});
|
||
const out = {
|
||
videoId,
|
||
title: meta.title || '',
|
||
thumbnail: meta.thumbnail || (Array.isArray(meta.thumbnails) && meta.thumbnails.length ? meta.thumbnails[0].url : ''),
|
||
uploaderName: meta.uploader || meta.channel || '',
|
||
uploaderAvatar: '',
|
||
views: typeof meta.view_count === 'number' ? meta.view_count : (typeof meta.viewCount === 'number' ? meta.viewCount : 0),
|
||
duration: typeof meta.duration === 'number' ? meta.duration : 0,
|
||
uploadedDate: meta.upload_date ? new Date(meta.upload_date.replace(/(\d{4})(\d{2})(\d{2})/, '$1-$2-$3')).toISOString() : (meta.release_timestamp ? new Date(meta.release_timestamp * 1000).toISOString() : ''),
|
||
description: meta.description || meta.summary || '',
|
||
url,
|
||
type: 'video',
|
||
};
|
||
return res.json(out);
|
||
} catch (e) {
|
||
return res.status(500).json({ error: 'details_failed', details: String(e?.message || e) });
|
||
}
|
||
});
|
||
|
||
// Download routes middleware (auth supports both Authorization header and cookies)
|
||
r.use('/download', authMiddlewareCookieAware, downloadLimiter);
|
||
|
||
// List available formats for a given video
|
||
r.get('/download/:provider/:videoId/formats', async (req, res) => {
|
||
try {
|
||
const { provider, videoId } = req.params;
|
||
if (!DOWNLOAD_ALLOWED_PROVIDERS.includes(String(provider))) {
|
||
return res.status(403).json({ error: 'download_disabled_for_provider' });
|
||
}
|
||
const instance = req.query.instance || undefined;
|
||
const slug = req.query.slug || undefined;
|
||
const sourceUrl = req.query.sourceUrl || undefined;
|
||
const url = providerUrlFrom(provider, videoId, { instance, slug, sourceUrl });
|
||
const raw = await youtubedl(url, { dumpSingleJson: true, noWarnings: true, noCheckCertificates: true, skipDownload: true });
|
||
const meta = (typeof raw === 'string') ? JSON.parse(raw || '{}') : (raw || {});
|
||
const formats = formatListFromMeta(meta);
|
||
return res.json({ url, formats, title: meta?.title || '', duration: meta?.duration || 0 });
|
||
} catch (e) {
|
||
const code = (e && e.message === 'peertube_instance_required') ? 400 : 500;
|
||
return res.status(code).json({ error: 'formats_failed', details: String(e?.message || e) });
|
||
}
|
||
});
|
||
|
||
// Start a download job
|
||
r.post('/download/:provider/:videoId', async (req, res) => {
|
||
try {
|
||
const { provider, videoId } = req.params;
|
||
if (!DOWNLOAD_ALLOWED_PROVIDERS.includes(String(provider))) {
|
||
return res.status(403).json({ error: 'download_disabled_for_provider' });
|
||
}
|
||
const userId = req.user?.id || 'anonymous';
|
||
// Limit concurrent jobs per user
|
||
const activeCount = Array.from(jobs.values()).filter(j => j.userId === userId && (j.state === 'queued' || j.state === 'running' || j.state === 'merging')).length;
|
||
if (activeCount >= Number(process.env.DOWNLOAD_MAX_CONCURRENT || 2)) {
|
||
return res.status(429).json({ error: 'too_many_downloads' });
|
||
}
|
||
const { formatId, audioOnly, sourceUrl } = req.body || {};
|
||
const instance = req.query.instance || undefined;
|
||
const slug = req.query.slug || undefined;
|
||
const url = providerUrlFrom(provider, videoId, { instance, slug, sourceUrl });
|
||
|
||
const jobId = cryptoRandomId();
|
||
// Keep the produced filename simple and rename after completion
|
||
const tmpOutTpl = path.join(downloadsRoot, `${jobId}.%(ext)s`);
|
||
// Fetch metadata to build the final filename (title & resolution)
|
||
let expectedBaseName = '';
|
||
let chosenFormatId = formatId ? String(formatId) : '';
|
||
let chosenResolution = 'best';
|
||
try {
|
||
const rawMeta = await youtubedl(url, { dumpSingleJson: true, noWarnings: true, noCheckCertificates: true, skipDownload: true });
|
||
const meta = (typeof rawMeta === 'string') ? JSON.parse(rawMeta || '{}') : (rawMeta || {});
|
||
const title = sanitizeFileName(meta?.title || `${provider}-${videoId}`);
|
||
if (audioOnly) {
|
||
chosenResolution = 'audio';
|
||
} else if (chosenFormatId) {
|
||
try {
|
||
const fmts = formatListFromMeta(meta);
|
||
const picked = fmts.find(f => f.id === String(chosenFormatId));
|
||
chosenResolution = normalizeResolutionLabel(picked?.resolution || picked?.label || 'best');
|
||
} catch {}
|
||
} else {
|
||
// No explicit selection: choose best progressive format and use its resolution
|
||
const bestProg = pickBestProgressiveFormat(meta);
|
||
if (bestProg && bestProg.format_id) {
|
||
chosenFormatId = String(bestProg.format_id);
|
||
const res = bestProg.height ? `${bestProg.height}p` : (bestProg.format_note || bestProg.ext || 'best');
|
||
chosenResolution = normalizeResolutionLabel(res);
|
||
}
|
||
}
|
||
expectedBaseName = `${providerLabel(provider)}_${title}_${chosenResolution}`;
|
||
} catch {}
|
||
|
||
const job = {
|
||
id: jobId,
|
||
userId,
|
||
provider,
|
||
videoId,
|
||
state: 'queued',
|
||
progress: 0,
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
filePath: null,
|
||
fileExt: null,
|
||
fileSize: null,
|
||
fileName: null,
|
||
expectedBaseName,
|
||
error: null,
|
||
url,
|
||
};
|
||
jobs.set(jobId, job);
|
||
|
||
// Start the process asynchronously
|
||
const args = {
|
||
output: tmpOutTpl,
|
||
ffmpegLocation: ffmpegPath || undefined,
|
||
noWarnings: true,
|
||
noCheckCertificates: true,
|
||
preferFreeFormats: true,
|
||
progress: true,
|
||
newline: true,
|
||
// Do not force mp4; let yt-dlp pick a compatible container (mkv/webm/mp4)
|
||
};
|
||
if (audioOnly) {
|
||
args.extractAudio = true;
|
||
args.audioFormat = 'm4a';
|
||
}
|
||
if (!audioOnly && chosenFormatId) args.format = String(chosenFormatId);
|
||
|
||
const cp = youtubedl.exec(url, args, { shell: false });
|
||
job.state = 'running';
|
||
job.proc = cp;
|
||
|
||
const onLine = (text) => {
|
||
const s = String(text);
|
||
const m = /(\d+(?:\.\d+)?)%/.exec(s);
|
||
if (m) {
|
||
job.progress = Math.max(job.progress || 0, Math.min(100, Number(m[1])));
|
||
job.updatedAt = new Date().toISOString();
|
||
}
|
||
if (/\[Merger]/.test(s)) {
|
||
job.state = 'merging';
|
||
}
|
||
};
|
||
cp.stdout?.on('data', (chunk) => onLine(chunk.toString()));
|
||
cp.stderr?.on('data', (chunk) => onLine(chunk.toString()));
|
||
|
||
cp.on('error', (err) => {
|
||
job.state = 'failed';
|
||
job.error = String(err?.message || err);
|
||
job.updatedAt = new Date().toISOString();
|
||
});
|
||
|
||
cp.on('close', async (code) => {
|
||
try {
|
||
if (code !== 0) {
|
||
job.state = 'failed';
|
||
job.error = `yt-dlp exited with code ${code}`;
|
||
job.updatedAt = new Date().toISOString();
|
||
return;
|
||
}
|
||
// Find produced file
|
||
const files = fs.readdirSync(downloadsRoot).filter(f => f.startsWith(`${jobId}.`));
|
||
if (files.length > 0) {
|
||
const f = files[0];
|
||
const p = path.join(downloadsRoot, f);
|
||
const st = fs.statSync(p);
|
||
const ext = f.split('.').pop();
|
||
let finalName = f;
|
||
// Build final friendly file name if we have enough info
|
||
try {
|
||
const base = job.expectedBaseName ? sanitizeFileName(job.expectedBaseName) : `${providerLabel(job.provider)}_${job.videoId}`;
|
||
const uniq = uniquePath(downloadsRoot, base, ext);
|
||
finalName = uniq.fileName;
|
||
const finalPath = uniq.filePath;
|
||
// Rename the temporary file to the final name
|
||
fs.renameSync(p, finalPath);
|
||
job.filePath = finalPath;
|
||
job.fileName = finalName;
|
||
} catch {
|
||
// Fallback to temporary file name
|
||
job.filePath = p;
|
||
job.fileName = f;
|
||
}
|
||
job.fileExt = ext;
|
||
job.fileSize = st.size;
|
||
job.state = 'completed';
|
||
job.progress = 100;
|
||
job.updatedAt = new Date().toISOString();
|
||
} else {
|
||
job.state = 'failed';
|
||
job.error = 'file_not_found_after_download';
|
||
job.updatedAt = new Date().toISOString();
|
||
}
|
||
} catch (err) {
|
||
job.state = 'failed';
|
||
job.error = String(err?.message || err);
|
||
job.updatedAt = new Date().toISOString();
|
||
}
|
||
});
|
||
|
||
return res.status(202).json({ jobId });
|
||
} catch (e) {
|
||
const code = (e && e.message === 'peertube_instance_required') ? 400 : 500;
|
||
return res.status(code).json({ error: 'start_failed', details: String(e?.message || e) });
|
||
}
|
||
});
|
||
|
||
// Job status
|
||
r.get('/download/jobs/:id', (req, res) => {
|
||
const { id } = req.params;
|
||
const job = jobs.get(id);
|
||
if (!job) return res.status(404).json({ error: 'not_found' });
|
||
// Hide internal Proc from response
|
||
const { proc, ...safe } = job;
|
||
return res.json(safe);
|
||
});
|
||
|
||
// Stream the file (supports Range)
|
||
r.get('/download/jobs/:id/file', (req, res) => {
|
||
const { id } = req.params;
|
||
const job = jobs.get(id);
|
||
if (!job || job.state !== 'completed' || !job.filePath) return res.status(404).json({ error: 'not_found' });
|
||
const filePath = job.filePath;
|
||
const stat = fs.statSync(filePath);
|
||
const total = stat.size;
|
||
const ext = job.fileExt || 'mp4';
|
||
const ctype = guessContentTypeByExt(ext);
|
||
const fileName = sanitizeFileName(job.fileName || `${job.provider}-${job.videoId}.${ext}`);
|
||
|
||
res.setHeader('Content-Type', ctype);
|
||
res.setHeader('Accept-Ranges', 'bytes');
|
||
res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`);
|
||
const range = req.headers.range;
|
||
if (range) {
|
||
const m = /bytes=(\d+)-(\d+)?/.exec(range);
|
||
if (m) {
|
||
const start = parseInt(m[1], 10);
|
||
const end = m[2] ? parseInt(m[2], 10) : total - 1;
|
||
if (start >= total || end >= total) {
|
||
return res.status(416).setHeader('Content-Range', `bytes */${total}`).end();
|
||
}
|
||
res.status(206);
|
||
res.setHeader('Content-Range', `bytes ${start}-${end}/${total}`);
|
||
res.setHeader('Content-Length', String(end - start + 1));
|
||
fs.createReadStream(filePath, { start, end }).pipe(res);
|
||
return;
|
||
}
|
||
}
|
||
res.setHeader('Content-Length', String(total));
|
||
fs.createReadStream(filePath).pipe(res);
|
||
});
|
||
|
||
// Cancel a job
|
||
r.delete('/download/jobs/:id', (req, res) => {
|
||
const { id } = req.params;
|
||
const job = jobs.get(id);
|
||
if (!job) return res.status(404).json({ error: 'not_found' });
|
||
try {
|
||
if (job.proc && typeof job.proc.kill === 'function') {
|
||
job.proc.kill('SIGKILL');
|
||
}
|
||
} catch {}
|
||
try {
|
||
if (job.filePath && fs.existsSync(job.filePath)) fs.unlinkSync(job.filePath);
|
||
} catch {}
|
||
jobs.delete(id);
|
||
return res.status(204).end();
|
||
});
|
||
|
||
// Rumble routes are handled by the dedicated router in server/rumble.mjs
|
||
|
||
// Auth routes
|
||
r.post('/auth/register', loginLimiter, async (req, res) => {
|
||
const rawUsername = (req.body?.username ?? '').trim();
|
||
const email = (req.body?.email ?? '')?.trim() || null;
|
||
const password = req.body?.password ?? '';
|
||
// allow using email as username if username is missing
|
||
const username = rawUsername || (email || '');
|
||
if (!username || !password) return res.status(400).json({ error: 'username and password are required' });
|
||
const existing = getUserByUsername(username);
|
||
if (existing) return res.status(409).json({ error: 'username already exists' });
|
||
const id = cryptoRandomUUID();
|
||
const passwordHash = await hashPassword(password);
|
||
insertUser({ id, username, email, passwordHash });
|
||
const sessionId = cryptoRandomId();
|
||
const refreshToken = cryptoRandomId();
|
||
const refreshTokenHash = await hashToken(refreshToken);
|
||
const days = REFRESH_TTL_DAYS;
|
||
const expiresAt = new Date(Date.now() + days * 86400_000).toISOString();
|
||
insertSession({ id: sessionId, userId: id, refreshTokenHash, isRemember: false, userAgent: req.headers['user-agent'] || '', deviceInfo: '', ip: getClientIp(req), expiresAt });
|
||
setUserLastLogin(id);
|
||
insertLoginAudit({ userId: id, username, ip: getClientIp(req), userAgent: req.headers['user-agent'] || '', success: true });
|
||
setRefreshCookies(res, { sessionId, token: refreshToken, days });
|
||
const accessToken = makeAccessToken(id, sessionId);
|
||
return res.status(201).json({ user: { id, username, email: email || null }, accessToken, sessionId });
|
||
});
|
||
|
||
r.post('/auth/login', loginLimiter, async (req, res) => {
|
||
const rawUsername = (req.body?.username ?? '').trim();
|
||
const email = (req.body?.email ?? '')?.trim() || null;
|
||
const password = req.body?.password ?? '';
|
||
const rememberMe = !!req.body?.rememberMe;
|
||
const username = rawUsername || (email || '');
|
||
if (!username || !password) return res.status(400).json({ error: 'username and password are required' });
|
||
const user = getUserByUsername(username);
|
||
const ip = getClientIp(req);
|
||
const ua = req.headers['user-agent'] || '';
|
||
if (!user) {
|
||
insertLoginAudit({ userId: null, username, ip, userAgent: ua, success: false, reason: 'user_not_found' });
|
||
return res.status(401).json({ error: 'invalid credentials' });
|
||
}
|
||
const ok = await verifyPassword(password, user.password_hash);
|
||
if (!ok) {
|
||
insertLoginAudit({ userId: user.id, username, ip, userAgent: ua, success: false, reason: 'invalid_password' });
|
||
return res.status(401).json({ error: 'invalid credentials' });
|
||
}
|
||
const sessionId = cryptoRandomId();
|
||
const refreshToken = cryptoRandomId();
|
||
const refreshTokenHash = await hashToken(refreshToken);
|
||
const days = rememberMe ? REMEMBER_TTL_DAYS : REFRESH_TTL_DAYS;
|
||
const expiresAt = new Date(Date.now() + days * 86400_000).toISOString();
|
||
insertSession({ id: sessionId, userId: user.id, refreshTokenHash, isRemember: !!rememberMe, userAgent: ua, deviceInfo: '', ip, expiresAt });
|
||
setUserLastLogin(user.id);
|
||
insertLoginAudit({ userId: user.id, username, ip, userAgent: ua, success: true });
|
||
setRefreshCookies(res, { sessionId, token: refreshToken, days });
|
||
const accessToken = makeAccessToken(user.id, sessionId);
|
||
return res.json({ user: { id: user.id, username: user.username, email: user.email }, accessToken, sessionId });
|
||
});
|
||
|
||
r.post('/auth/refresh', async (req, res) => {
|
||
const { sid, refreshToken } = req.cookies || {};
|
||
if (!sid || !refreshToken) return res.status(401).json({ error: 'Unauthorized' });
|
||
const session = getSessionById(sid);
|
||
if (!session || session.revoked_at) return res.status(401).json({ error: 'Unauthorized' });
|
||
const ok = await bcrypt.compare(refreshToken, session.refresh_token_hash);
|
||
if (!ok) return res.status(401).json({ error: 'Unauthorized' });
|
||
// rotate token
|
||
const nextToken = cryptoRandomId();
|
||
const nextHash = await hashToken(nextToken);
|
||
const days = session.isRemember ? REMEMBER_TTL_DAYS : REFRESH_TTL_DAYS;
|
||
const expiresAt = new Date(Date.now() + days * 86400_000).toISOString();
|
||
updateSessionToken(session.id, nextHash, expiresAt);
|
||
setRefreshCookies(res, { sessionId: session.id, token: nextToken, days });
|
||
const accessToken = makeAccessToken(session.user_id, session.id);
|
||
return res.json({ accessToken });
|
||
});
|
||
|
||
r.post('/auth/logout', (req, res) => {
|
||
const { allDevices } = req.body || {};
|
||
const { sid } = req.cookies || {};
|
||
if (sid) {
|
||
if (allDevices) {
|
||
const session = getSessionById(sid);
|
||
if (session) revokeAllUserSessions(session.user_id);
|
||
} else {
|
||
revokeSession(sid);
|
||
}
|
||
}
|
||
clearRefreshCookies(res);
|
||
return res.status(204).end();
|
||
});
|
||
|
||
r.get('/auth/sessions', authMiddleware, (req, res) => {
|
||
const items = listUserSessions(req.user.id);
|
||
return res.json(items);
|
||
});
|
||
|
||
r.delete('/auth/sessions/:id', authMiddleware, (req, res) => {
|
||
const { id } = req.params;
|
||
const session = getSessionById(id);
|
||
if (!session || session.user_id !== req.user.id) return res.status(404).json({ error: 'not_found' });
|
||
revokeSession(id);
|
||
return res.status(204).end();
|
||
});
|
||
|
||
r.get('/user/me', authMiddleware, (req, res) => {
|
||
const u = getUserById(req.user.id);
|
||
if (!u) return res.status(404).json({ error: 'not_found' });
|
||
return res.json({ id: u.id, username: u.username, email: u.email, created_at: u.created_at, last_login_at: u.last_login_at });
|
||
});
|
||
|
||
r.get('/user/preferences', authMiddleware, (req, res) => {
|
||
const prefs = getPreferences(req.user.id);
|
||
return res.json(prefs || {});
|
||
});
|
||
|
||
r.patch('/user/preferences', authMiddleware, (req, res) => {
|
||
const patch = req.body || {};
|
||
upsertPreferences(req.user.id, patch);
|
||
const prefs = getPreferences(req.user.id);
|
||
return res.json(prefs || {});
|
||
});
|
||
|
||
// --- History: Search ---
|
||
r.post('/user/history/search', authMiddleware, (req, res) => {
|
||
const { query, filters } = req.body || {};
|
||
if (!query || typeof query !== 'string') return res.status(400).json({ error: 'query required' });
|
||
const row = insertSearchHistory({ userId: req.user.id, query, filters });
|
||
return res.status(201).json(row);
|
||
});
|
||
|
||
r.get('/user/history/search', authMiddleware, (req, res) => {
|
||
const limit = Math.min(200, Number(req.query.limit || 50));
|
||
const before = req.query.before ? String(req.query.before) : undefined;
|
||
const q = typeof req.query.q === 'string' ? req.query.q : undefined;
|
||
const rows = listSearchHistory({ userId: req.user.id, limit, before, q });
|
||
return res.json(rows);
|
||
});
|
||
|
||
r.delete('/user/history/search/:id', authMiddleware, (req, res) => {
|
||
deleteSearchHistoryById(req.user.id, req.params.id);
|
||
return res.status(204).end();
|
||
});
|
||
|
||
r.delete('/user/history/search', authMiddleware, (req, res) => {
|
||
if (String(req.query.all || '') !== '1') return res.status(400).json({ error: 'set all=1' });
|
||
deleteAllSearchHistory(req.user.id);
|
||
return res.status(204).end();
|
||
});
|
||
|
||
// Delete a single search history item
|
||
r.delete('/user/history/search/:id', authMiddleware, (req, res) => {
|
||
const { id } = req.params;
|
||
if (!id) return res.status(400).json({ error: 'id is required' });
|
||
try {
|
||
deleteSearchHistoryById(req.user.id, id);
|
||
return res.status(204).end();
|
||
} catch (e) {
|
||
return res.status(500).json({ error: 'delete_failed', details: String(e?.message || e) });
|
||
}
|
||
});
|
||
|
||
// --- History: Watch ---
|
||
r.post('/user/history/watch', authMiddleware, (req, res) => {
|
||
const { provider, videoId, title, thumbnail, watchedAt, progressSeconds, durationSeconds, lastPositionSeconds } = req.body || {};
|
||
if (!provider || !videoId) return res.status(400).json({ error: 'provider and videoId are required' });
|
||
const row = upsertWatchHistory({ userId: req.user.id, provider, videoId, title, thumbnail, watchedAt, progressSeconds, durationSeconds, lastPositionSeconds });
|
||
return res.status(201).json(row);
|
||
});
|
||
|
||
r.get('/user/history/watch', authMiddleware, (req, res) => {
|
||
const limit = Math.min(200, Number(req.query.limit || 50));
|
||
const before = req.query.before ? String(req.query.before) : undefined;
|
||
const q = typeof req.query.q === 'string' ? req.query.q : undefined;
|
||
const rows = listWatchHistory({ userId: req.user.id, limit, before, q });
|
||
return res.json(rows);
|
||
});
|
||
|
||
// Delete a single watch history item
|
||
r.delete('/user/history/watch/:id', authMiddleware, (req, res) => {
|
||
const { id } = req.params;
|
||
if (!id) return res.status(400).json({ error: 'id is required' });
|
||
try {
|
||
deleteWatchHistoryById(req.user.id, id);
|
||
return res.status(204).end();
|
||
} catch (e) {
|
||
return res.status(500).json({ error: 'delete_failed', details: String(e?.message || e) });
|
||
}
|
||
});
|
||
|
||
r.patch('/user/history/watch/:id', authMiddleware, (req, res) => {
|
||
const { progressSeconds, lastPositionSeconds } = req.body || {};
|
||
const row = updateWatchHistoryById(req.params.id, { progressSeconds, lastPositionSeconds });
|
||
if (!row) return res.status(404).json({ error: 'not_found' });
|
||
return res.json(row);
|
||
});
|
||
|
||
r.delete('/user/history/watch', authMiddleware, (req, res) => {
|
||
if (String(req.query.all || '') !== '1') return res.status(400).json({ error: 'set all=1' });
|
||
deleteAllWatchHistory(req.user.id);
|
||
return res.status(204).end();
|
||
});
|
||
|
||
// --- Likes ---
|
||
r.get('/user/likes', authMiddleware, (req, res) => {
|
||
const limit = Math.min(500, Number(req.query.limit || 100));
|
||
const q = typeof req.query.q === 'string' ? req.query.q : undefined;
|
||
const rows = listLikedVideos({ userId: req.user.id, limit, q });
|
||
return res.json(rows);
|
||
});
|
||
|
||
r.post('/user/likes', authMiddleware, async (req, res) => {
|
||
let { provider, videoId, title, thumbnail } = req.body || {};
|
||
try {
|
||
console.log('[POST /user/likes] payload:', {
|
||
provider,
|
||
videoId,
|
||
titlePreview: typeof title === 'string' ? title.slice(0, 80) : title,
|
||
hasThumbnail: Boolean(thumbnail)
|
||
});
|
||
} catch {}
|
||
if (!provider || !videoId) return res.status(400).json({ error: 'provider and videoId are required' });
|
||
|
||
// Server-side enrichment: if title or thumbnail is missing, fetch minimal details via yt-dlp
|
||
try {
|
||
const needTitle = !(typeof title === 'string' && title.trim().length > 0);
|
||
const needThumb = !(typeof thumbnail === 'string' && thumbnail.trim().length > 0);
|
||
if (needTitle || needThumb) {
|
||
const url = providerUrlFrom(provider, videoId, { instance: req.query.instance, slug: req.query.slug, sourceUrl: req.query.sourceUrl });
|
||
try {
|
||
const raw = await youtubedl(url, { dumpSingleJson: true, noWarnings: true, noCheckCertificates: true, skipDownload: true });
|
||
const meta = (typeof raw === 'string') ? JSON.parse(raw || '{}') : (raw || {});
|
||
if (needTitle) title = meta?.title || title || '';
|
||
if (needThumb) thumbnail = meta?.thumbnail || (Array.isArray(meta?.thumbnails) && meta.thumbnails.length ? meta.thumbnails[0].url : thumbnail || '');
|
||
} catch {}
|
||
}
|
||
} catch {}
|
||
|
||
const row = likeVideo({ userId: req.user.id, provider, videoId, title, thumbnail });
|
||
return res.status(201).json(row);
|
||
});
|
||
|
||
r.delete('/user/likes', authMiddleware, (req, res) => {
|
||
const provider = req.query.provider ? String(req.query.provider) : '';
|
||
const videoId = req.query.videoId ? String(req.query.videoId) : '';
|
||
if (!provider || !videoId) return res.status(400).json({ error: 'provider and videoId are required' });
|
||
const result = unlikeVideo({ userId: req.user.id, provider, videoId });
|
||
return res.json(result);
|
||
});
|
||
|
||
// Like status for a specific video
|
||
r.get('/user/likes/status', authMiddleware, (req, res) => {
|
||
const provider = req.query.provider ? String(req.query.provider) : '';
|
||
const videoId = req.query.videoId ? String(req.query.videoId) : '';
|
||
if (!provider || !videoId) return res.status(400).json({ error: 'provider and videoId are required' });
|
||
const liked = isVideoLiked({ userId: req.user.id, provider, videoId });
|
||
return res.json({ liked });
|
||
});
|
||
|
||
// Odysee image proxy to avoid CORS/ORB issues
|
||
r.get('/img/odysee', async (req, res) => {
|
||
try {
|
||
const u = String(req.query.u || '').trim();
|
||
if (!u) return res.status(400).json({ error: 'missing_url' });
|
||
let target = u;
|
||
// Ensure absolute https URL
|
||
if (target.startsWith('//')) target = 'https:' + target;
|
||
if (!/^https?:\/\//i.test(target)) target = 'https://' + target.replace(/^\/*/, '');
|
||
// Parse and validate host
|
||
let parsed;
|
||
try { parsed = new URL(target); } catch { return res.status(400).json({ error: 'invalid_url' }); }
|
||
const host = parsed.hostname.toLowerCase();
|
||
const allowed = new Set([
|
||
'thumbnails.odycdn.com',
|
||
'thumbs.odycdn.com',
|
||
'thumb.odycdn.com',
|
||
'static.odycdn.com',
|
||
'images.odycdn.com',
|
||
'cdn.lbryplayer.xyz',
|
||
'thumbnails.lbry.com',
|
||
'thumbnails.lbry.tech'
|
||
]);
|
||
const headers = {
|
||
'Accept': 'image/avif,image/webp,image/apng,image/*,*/*;q=0.8',
|
||
'Referer': 'https://odysee.com/',
|
||
'User-Agent': 'Mozilla/5.0'
|
||
};
|
||
|
||
// Build candidates: extract inner URL after '/plain/' when present, try host alternates, toggle extension, strip query
|
||
function extractPlainUrl(href) {
|
||
try {
|
||
const idx = href.indexOf('/plain/');
|
||
if (idx !== -1) {
|
||
const tail = href.substring(idx + 7); // after '/plain/'
|
||
// Tail can be absolute URL possibly percent-encoded
|
||
try { return new URL(tail).toString(); } catch {}
|
||
try { return new URL(decodeURIComponent(tail)).toString(); } catch {}
|
||
}
|
||
} catch {}
|
||
return '';
|
||
}
|
||
|
||
function toggleExt(u0) {
|
||
try {
|
||
const u1 = new URL(u0);
|
||
if (/\.webp(\?|$)/i.test(u1.pathname)) u1.pathname = u1.pathname.replace(/\.webp(\?|$)/i, '.jpg$1');
|
||
else if (/\.jpg(\?|$)/i.test(u1.pathname)) u1.pathname = u1.pathname.replace(/\.jpg(\?|$)/i, '.webp$1');
|
||
return u1.toString();
|
||
} catch { return u0; }
|
||
}
|
||
|
||
function stripQuery(u0) {
|
||
try { const u1 = new URL(u0); u1.search = ''; return u1.toString(); } catch { return u0; }
|
||
}
|
||
|
||
const hosts = ['thumbs.odycdn.com','thumbnails.lbry.com'];
|
||
// const hosts = ['thumbnails.odycdn.com','thumbnails.lbry.com','thumbs.odycdn.com','thumb.odycdn.com','static.odycdn.com','images.odycdn.com','thumbnails.lbry.tech','cdn.lbryplayer.xyz'];
|
||
function swapHost(u0, h) { try { const u1 = new URL(u0); u1.hostname = h; return u1.toString(); } catch { return u0; } }
|
||
|
||
const baseHref = parsed.toString();
|
||
const inner = extractPlainUrl(baseHref);
|
||
const seed = inner && (() => { try { const p = new URL(inner); return allowed.has(p.hostname.toLowerCase()) ? inner : ''; } catch { return ''; } })() || baseHref;
|
||
const candidates = new Set();
|
||
candidates.add(seed);
|
||
candidates.add(stripQuery(seed));
|
||
candidates.add(toggleExt(seed));
|
||
// host alternatives
|
||
for (const h of hosts) { candidates.add(swapHost(seed, h)); }
|
||
// If it contained optimize/plain, also try removing that segment entirely
|
||
try {
|
||
if (/\/optimize\//.test(seed) && /\/plain\//.test(seed)) {
|
||
const idx = seed.indexOf('/plain/');
|
||
const after = seed.substring(idx + 7);
|
||
try { const direct = new URL(after).toString(); candidates.add(direct); candidates.add(stripQuery(direct)); candidates.add(toggleExt(direct)); } catch {}
|
||
try { const dec = new URL(decodeURIComponent(after)).toString(); candidates.add(dec); candidates.add(stripQuery(dec)); candidates.add(toggleExt(dec)); } catch {}
|
||
}
|
||
} catch {}
|
||
|
||
// Try sequentially and stream the first success
|
||
let lastStatus = 0;
|
||
for (const href of candidates) {
|
||
try {
|
||
const u2 = new URL(href);
|
||
if (!allowed.has(u2.hostname.toLowerCase())) continue;
|
||
const upstream = await axios.get(u2.toString(), { responseType: 'stream', maxRedirects: 3, timeout: 15000, headers, validateStatus: s => s >= 200 && s < 400 });
|
||
const ctype = upstream.headers['content-type'] || 'image/jpeg';
|
||
const clen = upstream.headers['content-length'];
|
||
res.setHeader('Content-Type', ctype);
|
||
if (clen) res.setHeader('Content-Length', String(clen));
|
||
res.setHeader('Cache-Control', 'public, max-age=600');
|
||
upstream.data.pipe(res);
|
||
return; // success
|
||
} catch (e) {
|
||
lastStatus = e?.response?.status || lastStatus || 0;
|
||
continue;
|
||
}
|
||
}
|
||
// All attempts failed
|
||
return res.status(lastStatus || 502).json({ error: 'odysee_img_proxy_error', details: 'no_variant_succeeded' });
|
||
} catch (e) {
|
||
const status = e?.response?.status || 502;
|
||
return res.status(status).json({ error: 'odysee_img_proxy_error', details: String(e?.message || e) });
|
||
}
|
||
});
|
||
|
||
// Mount API router (prod) and alias for dev proxy
|
||
app.use('/api', r);
|
||
// Health endpoint for container checks
|
||
app.get('/api/health', (_req, res) => res.json({ status: 'ok' }));
|
||
// Alias to support Angular dev proxy paths in both dev and production builds
|
||
app.use('/proxy/api', r);
|
||
// Mount dedicated Rumble router (browse, search, video)
|
||
app.use('/api/rumble', rumbleRouter);
|
||
|
||
// -------------------- Client config from environment --------------------
|
||
function jsVal(v) { return JSON.stringify(v == null ? '' : v); }
|
||
app.get(['/assets/config.local.js', '/assets/config.js', '/config.js'], (_req, res) => {
|
||
// WARNING: Values served here are exposed to the browser. Do not put secrets here unless you accept this.
|
||
const lines = [];
|
||
const env = process.env || {};
|
||
if (env.YOUTUBE_API_KEY) lines.push(`window.YOUTUBE_API_KEY = ${jsVal(env.YOUTUBE_API_KEY)};`);
|
||
if (env.YOUTUBE_API_KEYS) {
|
||
try {
|
||
const arr = String(env.YOUTUBE_API_KEYS).split(',').map(s => s.trim()).filter(Boolean);
|
||
lines.push(`window.YOUTUBE_API_KEYS = ${JSON.stringify(arr)};`);
|
||
} catch {}
|
||
}
|
||
if (env.TWITCH_CLIENT_ID) lines.push(`window.TWITCH_CLIENT_ID = ${jsVal(env.TWITCH_CLIENT_ID)};`);
|
||
if (env.TWITCH_CLIENT_SECRET) lines.push(`window.TWITCH_CLIENT_SECRET = ${jsVal(env.TWITCH_CLIENT_SECRET)};`);
|
||
if (env.GEMINI_API_KEY) lines.push(`window.GEMINI_API_KEY = ${jsVal(env.GEMINI_API_KEY)};`);
|
||
if (env.RUMBLE_API_KEY) lines.push(`window.RUMBLE_API_KEY = ${jsVal(env.RUMBLE_API_KEY)};`);
|
||
res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
|
||
res.send(lines.join('\n'));
|
||
});
|
||
|
||
// -------------------- Simple production proxies to avoid CORS --------------------
|
||
// Generic JSON forwarder helper
|
||
async function forwardJson(req, res, base) {
|
||
try {
|
||
const pathPart = req.originalUrl.replace(/^\/api\/(dm|odysee|twitch-api|twitch-auth)/, '');
|
||
const targetUrl = `${base}${pathPart}`;
|
||
const method = (req.method || 'GET').toUpperCase();
|
||
const headers = { ...req.headers };
|
||
delete headers['host'];
|
||
// Avoid sending cookie headers to third-parties
|
||
delete headers['cookie'];
|
||
const resp = await axios({ url: targetUrl, method, headers, data: req.body, timeout: 20000, validateStatus: s => s >= 200 && s < 500 });
|
||
return res.status(resp.status).json(resp.data);
|
||
} catch (e) {
|
||
const status = e?.response?.status || 500;
|
||
const data = e?.response?.data || { error: 'proxy_error', details: String(e?.message || e) };
|
||
return res.status(status).json(data);
|
||
}
|
||
}
|
||
|
||
app.all('/api/dm/*', (req, res) => forwardJson(req, res, 'https://api.dailymotion.com'));
|
||
app.all('/api/odysee/*', (req, res) => forwardJson(req, res, 'https://api.na-backend.odysee.com'));
|
||
app.all('/api/twitch-api/*', (req, res) => forwardJson(req, res, 'https://api.twitch.tv'));
|
||
app.all('/api/twitch-auth/*', (req, res) => forwardJson(req, res, 'https://id.twitch.tv'));
|
||
|
||
// -------------------- Static Frontend (Angular build) --------------------
|
||
const distRoot = path.join(process.cwd(), 'dist');
|
||
const distBrowser = path.join(distRoot, 'browser');
|
||
const staticDir = fs.existsSync(distBrowser) ? distBrowser : distRoot;
|
||
// Mount static files unconditionally; if path missing, it will just not serve anything
|
||
app.use(express.static(staticDir, { maxAge: '1h', index: 'index.html' }));
|
||
// SPA fallback: any non-API GET should serve index.html
|
||
app.get('*', (req, res, next) => {
|
||
try {
|
||
const url = req.originalUrl || req.url || '';
|
||
if (url.startsWith('/api/')) return next();
|
||
const indexPath = path.join(staticDir, 'index.html');
|
||
if (fs.existsSync(indexPath)) return res.sendFile(indexPath);
|
||
return next();
|
||
} catch {
|
||
return next();
|
||
}
|
||
});
|
||
|
||
app.listen(PORT, () => {
|
||
const cwd = process.cwd();
|
||
const hasDistRoot = fs.existsSync(distRoot);
|
||
const hasDistBrowser = fs.existsSync(distBrowser);
|
||
const hasIndex = fs.existsSync(path.join(staticDir, 'index.html'));
|
||
console.log(`[newtube-api] listening on http://localhost:${PORT}`);
|
||
console.log(`[newtube-api] cwd=${cwd}`);
|
||
console.log(`[newtube-api] distRoot=${distRoot} exists=${hasDistRoot}`);
|
||
console.log(`[newtube-api] distBrowser=${distBrowser} exists=${hasDistBrowser}`);
|
||
console.log(`[newtube-api] staticDir=${staticDir} indexExists=${hasIndex}`);
|
||
});
|
||
|
||
// --- Playlists ---
|
||
// Create a new playlist
|
||
r.post('/playlists', authMiddleware, (req, res) => {
|
||
try {
|
||
const { title, description, thumbnail, isPrivate } = req.body || {};
|
||
if (!title || String(title).trim().length === 0) {
|
||
return res.status(400).json({ error: 'title_required' });
|
||
}
|
||
const pl = createPlaylist({ userId: req.user.id, title: String(title).trim(), description, thumbnail, isPrivate: !!isPrivate });
|
||
return res.status(201).json(pl);
|
||
} catch (e) {
|
||
const msg = String(e?.message || e);
|
||
if (msg === 'title_required') return res.status(400).json({ error: msg });
|
||
return res.status(500).json({ error: 'create_failed', details: msg });
|
||
}
|
||
});
|
||
|
||
// List current user's playlists (pagination + search)
|
||
r.get('/playlists', authMiddleware, (req, res) => {
|
||
try {
|
||
const limit = Math.min(200, Math.max(1, Number(req.query.limit || 50)));
|
||
const offset = Math.max(0, Number(req.query.offset || 0));
|
||
const q = typeof req.query.q === 'string' ? req.query.q : undefined;
|
||
const rows = listPlaylists({ userId: req.user.id, limit, offset, q });
|
||
return res.json(rows);
|
||
} catch (e) {
|
||
return res.status(500).json({ error: 'list_failed', details: String(e?.message || e) });
|
||
}
|
||
});
|
||
|
||
// Get playlist details (owner only for now)
|
||
r.get('/playlists/:id', authMiddleware, (req, res) => {
|
||
try {
|
||
const id = String(req.params.id || '');
|
||
const pl = getPlaylistRaw(id);
|
||
if (!pl) return res.status(404).json({ error: 'not_found' });
|
||
if (pl.userId !== req.user.id) return res.status(404).json({ error: 'not_found' });
|
||
const limit = Math.min(2000, Math.max(1, Number(req.query.limit || 500)));
|
||
const offset = Math.max(0, Number(req.query.offset || 0));
|
||
const items = listPlaylistItems({ playlistId: id, limit, offset });
|
||
return res.json({ ...pl, items });
|
||
} catch (e) {
|
||
return res.status(500).json({ error: 'get_failed', details: String(e?.message || e) });
|
||
}
|
||
});
|
||
|
||
// Update a playlist (title/description/thumbnail/isPrivate)
|
||
r.put('/playlists/:id', authMiddleware, (req, res) => {
|
||
try {
|
||
const id = String(req.params.id || '');
|
||
const patch = req.body || {};
|
||
const result = updatePlaylist({ userId: req.user.id, id, patch });
|
||
if (result === 'forbidden') return res.status(403).json({ error: 'forbidden' });
|
||
if (!result) return res.status(404).json({ error: 'not_found' });
|
||
return res.json(result);
|
||
} catch (e) {
|
||
return res.status(500).json({ error: 'update_failed', details: String(e?.message || e) });
|
||
}
|
||
});
|
||
|
||
// Delete a playlist
|
||
r.delete('/playlists/:id', authMiddleware, (req, res) => {
|
||
try {
|
||
const id = String(req.params.id || '');
|
||
const result = deletePlaylist({ userId: req.user.id, id });
|
||
if (result === 'forbidden') return res.status(403).json({ error: 'forbidden' });
|
||
if (!result || !result.removed) return res.status(404).json({ error: 'not_found' });
|
||
return res.status(204).end();
|
||
} catch (e) {
|
||
return res.status(500).json({ error: 'delete_failed', details: String(e?.message || e) });
|
||
}
|
||
});
|
||
|
||
// Add a video to a playlist (enrich title/thumbnail if missing)
|
||
r.post('/playlists/:id/videos', authMiddleware, async (req, res) => {
|
||
try {
|
||
const playlistId = String(req.params.id || '');
|
||
let { provider, videoId, title, thumbnail, sourceUrl, slug, instance } = req.body || {};
|
||
provider = String(provider || '').trim();
|
||
videoId = String(videoId || '').trim();
|
||
if (!provider || !videoId) return res.status(400).json({ error: 'provider_and_videoId_required' });
|
||
|
||
// Optional enrichment like likes route
|
||
try {
|
||
const needTitle = !(typeof title === 'string' && title.trim().length > 0);
|
||
const needThumb = !(typeof thumbnail === 'string' && thumbnail.trim().length > 0);
|
||
if (needTitle || needThumb) {
|
||
const url = providerUrlFrom(provider, videoId, { instance, slug, sourceUrl });
|
||
try {
|
||
const raw = await youtubedl(url, { dumpSingleJson: true, noWarnings: true, noCheckCertificates: true, skipDownload: true });
|
||
const meta = (typeof raw === 'string') ? JSON.parse(raw || '{}') : (raw || {});
|
||
if (needTitle) title = meta?.title || title || '';
|
||
if (needThumb) thumbnail = meta?.thumbnail || (Array.isArray(meta?.thumbnails) && meta.thumbnails.length ? meta.thumbnails[0].url : thumbnail || '');
|
||
} catch {}
|
||
}
|
||
} catch {}
|
||
|
||
const row = addPlaylistVideo({ userId: req.user.id, playlistId, provider, videoId, title, thumbnail });
|
||
if (row === 'not_found') return res.status(404).json({ error: 'playlist_not_found' });
|
||
if (row === 'forbidden') return res.status(403).json({ error: 'forbidden' });
|
||
return res.status(201).json(row);
|
||
} catch (e) {
|
||
return res.status(500).json({ error: 'add_video_failed', details: String(e?.message || e) });
|
||
}
|
||
});
|
||
|
||
// Remove a video from a playlist (provider required via query)
|
||
r.delete('/playlists/:id/videos/:videoId', authMiddleware, (req, res) => {
|
||
try {
|
||
const playlistId = String(req.params.id || '');
|
||
const videoId = String(req.params.videoId || '');
|
||
const provider = req.query.provider ? String(req.query.provider) : '';
|
||
if (!provider || !videoId) return res.status(400).json({ error: 'provider_and_videoId_required' });
|
||
const result = removePlaylistVideo({ userId: req.user.id, playlistId, provider, videoId });
|
||
if (result === 'not_found') return res.status(404).json({ error: 'playlist_not_found' });
|
||
if (result === 'forbidden') return res.status(403).json({ error: 'forbidden' });
|
||
return res.json(result);
|
||
} catch (e) {
|
||
return res.status(500).json({ error: 'remove_video_failed', details: String(e?.message || e) });
|
||
}
|
||
});
|
||
|
||
// Reorder playlist items
|
||
r.put('/playlists/:id/reorder', authMiddleware, (req, res) => {
|
||
try {
|
||
const playlistId = String(req.params.id || '');
|
||
const order = Array.isArray(req.body?.order) ? req.body.order : [];
|
||
if (!order.length) return res.status(400).json({ error: 'order_required' });
|
||
const result = reorderPlaylistVideos({ userId: req.user.id, playlistId, order });
|
||
if (result === 'not_found') return res.status(404).json({ error: 'playlist_not_found' });
|
||
if (result === 'forbidden') return res.status(403).json({ error: 'forbidden' });
|
||
return res.json(result);
|
||
} catch (e) {
|
||
return res.status(500).json({ error: 'reorder_failed', details: String(e?.message || e) });
|
||
}
|
||
});
|
||
|
||
// --- OpenAPI (minimal for playlists) ---
|
||
r.get('/openapi.json', (_req, res) => {
|
||
const doc = {
|
||
openapi: '3.0.0',
|
||
info: { title: 'NewTube API', version: '1.0.0' },
|
||
paths: {
|
||
'/playlists': {
|
||
get: { summary: 'List user playlists', security: [{ bearerAuth: [] }], parameters: [
|
||
{ name: 'limit', in: 'query', schema: { type: 'integer' } },
|
||
{ name: 'offset', in: 'query', schema: { type: 'integer' } },
|
||
{ name: 'q', in: 'query', schema: { type: 'string' } },
|
||
] },
|
||
post: { summary: 'Create playlist', security: [{ bearerAuth: [] }], requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', properties: { title: { type: 'string' }, description: { type: 'string' }, thumbnail: { type: 'string' }, isPrivate: { type: 'boolean' } }, required: ['title'] } } } } }
|
||
},
|
||
'/playlists/{id}': {
|
||
get: { summary: 'Get playlist details', security: [{ bearerAuth: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }] },
|
||
put: { summary: 'Update playlist', security: [{ bearerAuth: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }] },
|
||
delete: { summary: 'Delete playlist', security: [{ bearerAuth: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }] }
|
||
},
|
||
'/playlists/{id}/videos': {
|
||
post: { summary: 'Add video to playlist', security: [{ bearerAuth: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }] }
|
||
},
|
||
'/playlists/{id}/videos/{videoId}': {
|
||
delete: { summary: 'Remove video from playlist', security: [{ bearerAuth: [] }], parameters: [
|
||
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
|
||
{ name: 'videoId', in: 'path', required: true, schema: { type: 'string' } },
|
||
{ name: 'provider', in: 'query', required: true, schema: { type: 'string' } }
|
||
] }
|
||
},
|
||
'/playlists/{id}/reorder': {
|
||
put: { summary: 'Reorder playlist items', security: [{ bearerAuth: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }] }
|
||
}
|
||
},
|
||
components: { securitySchemes: { bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' } } }
|
||
};
|
||
res.json(doc);
|
||
});
|