CI / build-and-test (push) Successful in 14m1s
Les URLs timedtext InnerTube repondent 200-vide (throttle YouTube) : sonde rapide max 2 sans retry, puis dump yt-dlp -> URLs signees en direct (max 4), puis --write-sub. 200-vide traite comme 429 (retryAfterSec), fmt= plus jamais duplique (signature), cookies YT_COOKIES_FILE reutilises par le fetch direct. Tests 26/26.
3949 lines
171 KiB
JavaScript
3949 lines
171 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 youtubedlPkg, { create as createYtDlp } from 'youtube-dl-exec';
|
||
import { execFile as execFileCb } from 'node:child_process';
|
||
import { promisify } from 'node:util';
|
||
import { fileURLToPath as serverFileURLToPath } from 'node:url';
|
||
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 { dedupeSuggestGroups } from './suggest.mjs';
|
||
import { fetchWebSuggest, fetchOdyseeLighthouseSuggest } from './suggest-web.mjs';
|
||
import { pickTrack, parseTrackText, parseVtt, dedupeTranscriptLines, orderedTracks, translatedFallbacks, firstPerLanguage, normalizeTranscriptProvider, transcriptTrackExt, looksLikeHtmlError, ensureFmtParam, isEmptyTimedTextBody, mergeTranscriptCandidates } from './transcript.mjs';
|
||
import {
|
||
getUserByUsername,
|
||
getUserById,
|
||
insertUser,
|
||
insertSession,
|
||
getSessionById,
|
||
updateSessionToken,
|
||
revokeSession,
|
||
revokeAllUserSessions,
|
||
listUserSessions,
|
||
setUserLastLogin,
|
||
insertLoginAudit,
|
||
getPreferences,
|
||
getPreferencesForApi,
|
||
upsertPreferences,
|
||
insertTelemetryEvent,
|
||
listTelemetryEvents,
|
||
countTelemetryEvents,
|
||
cryptoRandomId,
|
||
cryptoRandomUUID,
|
||
insertSearchHistory,
|
||
insertSearchHistoryAt,
|
||
listSearchHistory,
|
||
deleteSearchHistoryById,
|
||
deleteAllSearchHistory,
|
||
upsertWatchHistory,
|
||
listWatchHistory,
|
||
updateWatchHistoryById,
|
||
deleteWatchHistoryById,
|
||
deleteAllWatchHistory,
|
||
likeVideo,
|
||
unlikeVideo,
|
||
listLikedVideos,
|
||
isVideoLiked,
|
||
createPlaylist,
|
||
listPlaylists,
|
||
listPublicPlaylists,
|
||
getPlaylistRaw,
|
||
getPlaylistWithItemsIfAllowed,
|
||
updatePlaylist,
|
||
deletePlaylist,
|
||
listPlaylistItems,
|
||
addPlaylistVideo,
|
||
removePlaylistVideo,
|
||
reorderPlaylistVideos,
|
||
ensureChannelFresh,
|
||
listSubscriptionsByUser,
|
||
subscribeChannel,
|
||
unsubscribeChannel,
|
||
isSubscribed,
|
||
listSubscriptionGroups,
|
||
createSubscriptionGroup,
|
||
updateSubscriptionGroup,
|
||
deleteSubscriptionGroup,
|
||
setSubscriptionGroupMembers,
|
||
setSubscriptionGroups,
|
||
listSubscriptionGroupMembersByUser,
|
||
getUserByEmail,
|
||
getUserByOAuth,
|
||
upsertOAuthConnection,
|
||
listOAuthConnections,
|
||
getOAuthConnection,
|
||
updateOAuthTokens,
|
||
deleteOAuthConnection,
|
||
setOAuthChannel,
|
||
upsertChannelRow,
|
||
insertDownloadJob,
|
||
getDownloadJob,
|
||
listDownloadJobs,
|
||
updateDownloadJob,
|
||
deleteDownloadJob,
|
||
resetActiveDownloadJobs,
|
||
countActiveDownloadJobs,
|
||
sumCompletedDownloadBytes,
|
||
SUPPORTED_DOWNLOAD_LANGUAGES as SUPPORTED_DL_LANGS,
|
||
DEFAULT_DOWNLOAD_LANGUAGES as DEFAULT_DL_LANGS,
|
||
upsertTranscriptHistory,
|
||
getTranscriptHistoryItem,
|
||
listTranscriptHistory,
|
||
deleteTranscriptHistoryById,
|
||
deleteAllTranscriptHistory,
|
||
} from './db.mjs';
|
||
import { getChannelAdapter, setTwitchTokenProvider } from './providers/channel-registry.mjs';
|
||
import { fetchChannelContent } from './providers/channel-content.mjs';
|
||
import {
|
||
oauthStatus, buildAuthUrl, createOAuthState, consumeOAuthState, exchangeCode,
|
||
refreshAccessToken, redirectUriFor, fetchGoogleProfile, fetchGoogleSubscriptions,
|
||
fetchGoogleLiked, fetchTwitchProfile, fetchTwitchFollows, hasGoogleWriteScope,
|
||
fetchGoogleWatchLater, pushGoogleWatchLater, fetchInnerTubeHistory,
|
||
fetchInnerTubeWatchLaterViaLib, pushInnerTubeWatchLater, fetchGoogleWatchLaterId,
|
||
fetchAccountChannels, diagnoseInnertube, fetchMineChannel,
|
||
} from './oauth.mjs';
|
||
import { getSearchMode, getYtDlpBin, hasCookiesFile, metricsSnapshot } from './providers/youtube-common.mjs';
|
||
import { ytScrapeCacheStats } from './providers/youtube.mjs';
|
||
|
||
/**
|
||
* Options réseau communes pour les appels yt-dlp : cookies YouTube exportés
|
||
* (session résidentielle de confiance) + proxy sortant. Sans eux, les IPs
|
||
* de datacenter se voient servir du vide/429 sur timedtext et parfois sur
|
||
* les dumps. Absents par défaut -> comportement inchangé.
|
||
*/
|
||
function ytdlpNetOpts() {
|
||
const opts = {};
|
||
try {
|
||
const cookies = String(process.env.YT_COOKIES_FILE || '').trim();
|
||
if (cookies) {
|
||
try {
|
||
if (fs.existsSync(cookies)) opts.cookies = cookies;
|
||
else console.warn(`[transcript] YT_COOKIES_FILE introuvable : ${cookies}`);
|
||
} catch {}
|
||
}
|
||
} catch {}
|
||
try {
|
||
const proxy = String(process.env.YT_EGRESS_PROXY || '').trim();
|
||
if (proxy) opts.proxy = proxy;
|
||
} catch {}
|
||
return opts;
|
||
}
|
||
|
||
const app = express();
|
||
const PORT = Number(process.env.PORT || 4000);
|
||
// yt-dlp: prefer the newest binary available. The copy bundled with
|
||
// youtube-dl-exec goes stale (YouTube then answers "The page needs to be
|
||
// reloaded" to dump-single-json), while a system install is usually fresher.
|
||
// `YT_DLP_PATH` wins, then `yt-dlp` on PATH, then the bundled binary.
|
||
const execFileAsync = promisify(execFileCb);
|
||
async function binaryVersion(bin) {
|
||
try {
|
||
const { stdout } = await execFileAsync(bin, ['--version'], { timeout: 15000 });
|
||
return String(stdout || '').trim().split('\n')[0].trim();
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
let youtubedl = youtubedlPkg;
|
||
let ytDlpInfo = 'bundled';
|
||
try {
|
||
let bundledBin = null;
|
||
try {
|
||
const u = new URL('../node_modules/youtube-dl-exec/bin/', import.meta.url);
|
||
const cand = path.join(String(serverFileURLToPath(u)), process.platform === 'win32' ? 'yt-dlp.exe' : 'yt-dlp');
|
||
if (fs.existsSync(cand)) bundledBin = cand;
|
||
} catch {}
|
||
const bundledVer = bundledBin ? await binaryVersion(bundledBin) : null;
|
||
const candidates = [];
|
||
if (process.env.YT_DLP_PATH) candidates.push(process.env.YT_DLP_PATH);
|
||
candidates.push(process.platform === 'win32' ? 'yt-dlp.exe' : 'yt-dlp', 'yt-dlp');
|
||
let best = null;
|
||
for (const cand of candidates) {
|
||
if (!cand) continue;
|
||
const ver = await binaryVersion(cand);
|
||
if (ver && (!best || ver > best.ver)) best = { bin: cand, ver };
|
||
}
|
||
if (best && (!bundledVer || best.ver >= bundledVer)) {
|
||
youtubedl = createYtDlp(best.bin);
|
||
ytDlpInfo = `${best.bin} (${best.ver})`;
|
||
} else if (bundledVer) {
|
||
ytDlpInfo = `bundled (${bundledVer})`;
|
||
}
|
||
} catch (e) {
|
||
console.warn('[config] yt-dlp binary selection failed, using bundled:', e?.message || e);
|
||
}
|
||
console.log(`[config] yt-dlp: ${ytDlpInfo}`);
|
||
const IS_PROD = String(process.env.NODE_ENV || '').toLowerCase() === 'production';
|
||
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-me';
|
||
if (!process.env.JWT_SECRET) {
|
||
const msg = '[config] JWT_SECRET non défini — utilisation du secret de développement. NE PAS UTILISER EN PRODUCTION.';
|
||
if (IS_PROD) console.error(msg);
|
||
else console.warn(msg);
|
||
}
|
||
const ACCESS_TTL_MIN = Number(process.env.ACCESS_TTL_MIN || 15);
|
||
// Garde-fou : une erreur non capturée dans une route ne doit jamais tuer tout le serveur.
|
||
process.on('uncaughtException', (err) => {
|
||
try { console.error('[fatal] uncaughtException:', err?.stack || err); } catch {}
|
||
});
|
||
process.on('unhandledRejection', (reason) => {
|
||
try { console.error('[fatal] unhandledRejection:', reason); } catch {}
|
||
});
|
||
const REFRESH_TTL_DAYS = Number(process.env.REFRESH_TTL_DAYS || 2);
|
||
const REMEMBER_TTL_DAYS = Number(process.env.REMEMBER_TTL_DAYS || 30);
|
||
const CHANNEL_TTL_MS = Number(process.env.CHANNEL_TTL_MS || (6 * 60 * 60 * 1000));
|
||
|
||
const corsOptions = {
|
||
origin: ['http://localhost:4200', 'http://localhost:4000', 'http://localhost:3000'],
|
||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||
allowedHeaders: ['Content-Type', 'Authorization'],
|
||
credentials: true,
|
||
maxAge: 86400 // 24h
|
||
};
|
||
|
||
// Middleware de logging — verbeux uniquement hors production, sinon une ligne sobre.
|
||
// Jamais de headers complets (tokens) ni de body brut (mots de passe) en prod.
|
||
const SENSITIVE_FIELDS = new Set(['password', 'currentPassword', 'newPassword', 'token', 'refreshToken', 'accessToken']);
|
||
|
||
function sanitizeBody(body) {
|
||
if (!body || typeof body !== 'object') return body;
|
||
const out = Array.isArray(body) ? [...body] : { ...body };
|
||
for (const k of Object.keys(out)) {
|
||
if (SENSITIVE_FIELDS.has(k)) out[k] = '[redacted]';
|
||
}
|
||
return out;
|
||
}
|
||
|
||
const requestLogger = (req, res, next) => {
|
||
if (IS_PROD) {
|
||
console.log(`[${new Date().toISOString()}] ${req.method} ${req.originalUrl}`);
|
||
return next();
|
||
}
|
||
console.log(`[${new Date().toISOString()}] ${req.method} ${req.originalUrl}`);
|
||
console.log('Headers:', JSON.stringify({ ...req.headers, authorization: req.headers.authorization ? '[redacted]' : undefined }, null, 2));
|
||
console.log('Query:', JSON.stringify(req.query, null, 2));
|
||
console.log('Body:', JSON.stringify(sanitizeBody(req.body), null, 2));
|
||
next();
|
||
};
|
||
|
||
const subscriptionsLimiter = rateLimit({
|
||
windowMs: 60 * 1000,
|
||
max: 30,
|
||
standardHeaders: true,
|
||
legacyHeaders: false,
|
||
});
|
||
|
||
const channelsLimiter = rateLimit({
|
||
windowMs: 60 * 1000,
|
||
max: 60,
|
||
standardHeaders: true,
|
||
legacyHeaders: false,
|
||
});
|
||
|
||
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) });
|
||
}
|
||
});
|
||
|
||
// -------------------- Channels APIs --------------------
|
||
|
||
function requireProviderId(value) {
|
||
const allowed = ['yt','dm','tw','pt','od','ru'];
|
||
if (!allowed.includes(String(value))) {
|
||
throw Object.assign(new Error('invalid_provider'), { status: 400 });
|
||
}
|
||
return /** @type {'yt'|'dm'|'tw'|'pt'|'od'|'ru'} */(value);
|
||
}
|
||
|
||
async function resolveChannel(provider, externalId, { forceRefresh = false } = {}) {
|
||
const adapterEntry = getChannelAdapter(provider) || providerRegistry[provider];
|
||
if (!adapterEntry || typeof adapterEntry.fetchChannelById !== 'function') {
|
||
throw Object.assign(new Error('provider_not_supported'), { status: 501 });
|
||
}
|
||
return ensureChannelFresh(provider, externalId, () => adapterEntry.fetchChannelById(externalId), { force: forceRefresh });
|
||
}
|
||
|
||
r.post('/channels/resolve', authMiddlewareCookieAware, channelsLimiter, async (req, res) => {
|
||
try {
|
||
const provider = requireProviderId(req.body?.provider);
|
||
const externalId = String(req.body?.externalId || '').trim();
|
||
if (!externalId) return res.status(400).json({ error: 'external_id_required' });
|
||
const meta = await resolveChannel(provider, externalId, { forceRefresh: Boolean(req.body?.refresh) });
|
||
return res.json(meta);
|
||
} catch (error) {
|
||
const status = error?.status || 500;
|
||
return res.status(status).json({ error: error?.message || 'channel_resolve_failed' });
|
||
}
|
||
});
|
||
|
||
// Lecture publique (logo chaîne sur /watch même déconnecté) — ne crée pas d'abonnement.
|
||
r.get('/channels/:provider/:externalId', channelsLimiter, async (req, res) => {
|
||
try {
|
||
const provider = requireProviderId(req.params.provider);
|
||
const externalId = String(req.params.externalId || '').trim();
|
||
if (!externalId) return res.status(400).json({ error: 'external_id_required' });
|
||
|
||
const refresh = req.query.refresh === '1' || req.query.refresh === 'true';
|
||
const meta = await resolveChannel(provider, externalId, { forceRefresh: refresh });
|
||
return res.json(meta);
|
||
} catch (error) {
|
||
const status = error?.status || 500;
|
||
return res.status(status).json({ error: error?.message || 'channel_fetch_failed' });
|
||
}
|
||
});
|
||
|
||
// Contenu d'une chaîne : ?type=videos|shorts|playlists|live&page=&limit=&sort=recent|popular&q=
|
||
r.get('/channels/:provider/:externalId/content', channelsLimiter, async (req, res) => {
|
||
try {
|
||
const provider = requireProviderId(req.params.provider);
|
||
const externalId = String(req.params.externalId || '').trim();
|
||
if (!externalId) return res.status(400).json({ error: 'external_id_required' });
|
||
const type = String(req.query.type || 'videos');
|
||
if (!['videos', 'shorts', 'playlists', 'live'].includes(type)) {
|
||
return res.status(400).json({ error: 'invalid_type' });
|
||
}
|
||
const page = Math.max(1, Number(req.query.page || 1));
|
||
const limit = Math.min(50, Math.max(1, Number(req.query.limit || 24)));
|
||
const sort = req.query.sort === 'popular' ? 'popular' : req.query.sort === 'relevance' ? 'relevance' : 'recent';
|
||
const q = typeof req.query.q === 'string' ? req.query.q.slice(0, 200) : '';
|
||
const data = await fetchChannelContent(provider, externalId, { type, page, limit, sort, q }, { searchRegistry: providerRegistry });
|
||
return res.json({ ...data, page, limit, sort, type });
|
||
} catch (error) {
|
||
const status = error?.status || 500;
|
||
return res.status(status).json({ error: error?.message || 'channel_content_failed', items: [], nextPage: null });
|
||
}
|
||
});
|
||
|
||
// -------------------- Subscriptions APIs --------------------
|
||
|
||
r.get('/subscriptions', authMiddlewareCookieAware, subscriptionsLimiter, (req, res) => {
|
||
try {
|
||
const items = listSubscriptionsByUser(req.user.id);
|
||
return res.json({ items, ttl: CHANNEL_TTL_MS });
|
||
} catch (error) {
|
||
return res.status(500).json({ error: 'subscriptions_list_failed', details: String(error?.message || error) });
|
||
}
|
||
});
|
||
|
||
r.post('/subscriptions', authMiddlewareCookieAware, subscriptionsLimiter, async (req, res) => {
|
||
try {
|
||
const provider = requireProviderId(req.body?.provider);
|
||
const externalId = String(req.body?.externalId || '').trim();
|
||
if (!externalId) return res.status(400).json({ error: 'external_id_required' });
|
||
|
||
const entity = await resolveChannel(provider, externalId, { forceRefresh: Boolean(req.body?.refresh) });
|
||
const sub = subscribeChannel({ userId: req.user.id, provider, externalId, channelId: entity?.id });
|
||
return res.status(201).json(sub);
|
||
} catch (error) {
|
||
const status = error?.status || 500;
|
||
return res.status(status).json({ error: error?.message || 'subscription_create_failed' });
|
||
}
|
||
});
|
||
|
||
// Import en lot (ex. abonnements.csv de Takeout) : upsert direct, sans
|
||
// résolution externe (les IDs Takeout sont déjà fiables).
|
||
r.post('/subscriptions/batch', authMiddlewareCookieAware, subscriptionsLimiter, (req, res) => {
|
||
try {
|
||
const items = Array.isArray(req.body?.items) ? req.body.items : [];
|
||
if (!items.length) return res.status(400).json({ error: 'items_required' });
|
||
if (items.length > 1000) return res.status(400).json({ error: 'batch_too_large' });
|
||
const allowed = new Set(['youtube', 'twitch', 'dailymotion', 'peertube', 'odysee', 'rumble', 'yt', 'dm', 'tw', 'pt', 'od', 'ru']);
|
||
let imported = 0;
|
||
let skipped = 0;
|
||
for (const it of items) {
|
||
const provider = String(it?.provider || 'youtube').trim().toLowerCase();
|
||
const externalId = String(it?.externalId || '').trim().slice(0, 128);
|
||
if (!allowed.has(provider) || !externalId) { skipped++; continue; }
|
||
try {
|
||
const row = upsertChannelRow({
|
||
provider, externalId,
|
||
title: String(it?.title || externalId).slice(0, 200) || null,
|
||
handle: String(it?.handle || '').slice(0, 128) || null,
|
||
avatarUrl: String(it?.avatarUrl || '').slice(0, 500) || null,
|
||
url: String(it?.url || '').slice(0, 500) || null,
|
||
lastRefreshedAt: Date.now(),
|
||
});
|
||
subscribeChannel({ userId: req.user.id, provider, externalId, channelId: row?.id });
|
||
imported++;
|
||
} catch { skipped++; }
|
||
}
|
||
return res.json({ imported, skipped, total: items.length });
|
||
} catch {
|
||
return res.status(500).json({ error: 'subscriptions_batch_failed' });
|
||
}
|
||
});
|
||
|
||
r.delete('/subscriptions/:subscriptionId', authMiddlewareCookieAware, subscriptionsLimiter, (req, res) => {
|
||
try {
|
||
const info = unsubscribeChannel({ userId: req.user.id, subscriptionId: req.params.subscriptionId });
|
||
if ((info?.changes || 0) === 0) return res.status(404).json({ error: 'not_found' });
|
||
return res.status(204).end();
|
||
} catch (error) {
|
||
return res.status(500).json({ error: 'subscription_delete_failed', details: String(error?.message || error) });
|
||
}
|
||
});
|
||
|
||
// -------------------- Subscription groups APIs (façon PocketTube) --------------------
|
||
|
||
r.get('/subscription-groups', authMiddlewareCookieAware, subscriptionsLimiter, (req, res) => {
|
||
try {
|
||
const groups = listSubscriptionGroups(req.user.id);
|
||
const members = listSubscriptionGroupMembersByUser(req.user.id);
|
||
return res.json({ groups, members });
|
||
} catch (error) {
|
||
return res.status(500).json({ error: 'groups_list_failed', details: String(error?.message || error) });
|
||
}
|
||
});
|
||
|
||
r.post('/subscription-groups', authMiddlewareCookieAware, subscriptionsLimiter, (req, res) => {
|
||
try {
|
||
const group = createSubscriptionGroup({
|
||
userId: req.user.id,
|
||
name: req.body?.name,
|
||
color: req.body?.color,
|
||
icon: req.body?.icon,
|
||
});
|
||
return res.status(201).json(group);
|
||
} catch (error) {
|
||
const msg = error?.message || 'group_create_failed';
|
||
if (msg === 'group_name_required') return res.status(400).json({ error: msg });
|
||
if (msg === 'group_name_taken') return res.status(409).json({ error: msg });
|
||
return res.status(500).json({ error: 'group_create_failed' });
|
||
}
|
||
});
|
||
|
||
r.patch('/subscription-groups/:groupId', authMiddlewareCookieAware, subscriptionsLimiter, (req, res) => {
|
||
try {
|
||
const group = updateSubscriptionGroup({
|
||
userId: req.user.id,
|
||
groupId: req.params.groupId,
|
||
name: req.body?.name,
|
||
color: req.body?.color,
|
||
icon: req.body?.icon,
|
||
});
|
||
if (!group) return res.status(404).json({ error: 'not_found' });
|
||
return res.json(group);
|
||
} catch (error) {
|
||
const msg = error?.message || 'group_update_failed';
|
||
if (msg === 'group_name_required') return res.status(400).json({ error: msg });
|
||
if (msg === 'group_name_taken') return res.status(409).json({ error: msg });
|
||
return res.status(500).json({ error: 'group_update_failed' });
|
||
}
|
||
});
|
||
|
||
r.delete('/subscription-groups/:groupId', authMiddlewareCookieAware, subscriptionsLimiter, (req, res) => {
|
||
try {
|
||
const info = deleteSubscriptionGroup({ userId: req.user.id, groupId: req.params.groupId });
|
||
if ((info?.changes || 0) === 0) return res.status(404).json({ error: 'not_found' });
|
||
return res.status(204).end();
|
||
} catch (error) {
|
||
return res.status(500).json({ error: 'group_delete_failed' });
|
||
}
|
||
});
|
||
|
||
// Remplace les chaînes d'un groupe : { subscriptionIds: number[] }
|
||
r.put('/subscription-groups/:groupId/members', authMiddlewareCookieAware, subscriptionsLimiter, (req, res) => {
|
||
try {
|
||
const group = setSubscriptionGroupMembers({
|
||
userId: req.user.id,
|
||
groupId: req.params.groupId,
|
||
subscriptionIds: req.body?.subscriptionIds,
|
||
});
|
||
if (!group) return res.status(404).json({ error: 'not_found' });
|
||
return res.json(group);
|
||
} catch (error) {
|
||
return res.status(500).json({ error: 'group_members_failed' });
|
||
}
|
||
});
|
||
|
||
// Remplace les groupes d'un abonnement : { groupIds: string[] }
|
||
r.put('/subscriptions/:subscriptionId/groups', authMiddlewareCookieAware, subscriptionsLimiter, (req, res) => {
|
||
try {
|
||
const groupIds = setSubscriptionGroups({
|
||
userId: req.user.id,
|
||
subscriptionId: Number(req.params.subscriptionId),
|
||
groupIds: req.body?.groupIds,
|
||
});
|
||
if (!groupIds) return res.status(404).json({ error: 'not_found' });
|
||
return res.json({ subscriptionId: Number(req.params.subscriptionId), groupIds });
|
||
} catch (error) {
|
||
return res.status(500).json({ error: 'subscription_groups_failed' });
|
||
}
|
||
});
|
||
|
||
// -------------------- OAuth Google / Twitch (import favoris + abonnements) --------------------
|
||
|
||
const oauthLimiter = rateLimit({ windowMs: 60 * 1000, max: 30, standardHeaders: true, legacyHeaders: false });
|
||
|
||
function requireOAuthProvider(value) {
|
||
const p = String(value || '').toLowerCase();
|
||
if (p !== 'google' && p !== 'twitch') throw Object.assign(new Error('invalid_oauth_provider'), { status: 400 });
|
||
return p;
|
||
}
|
||
|
||
/** Token frais (refresh si expiré dans < 60 s et refresh_token dispo). */
|
||
async function freshOAuthToken(userId, provider) {
|
||
const conn = getOAuthConnection(userId, provider, true);
|
||
if (!conn?.accessToken) throw Object.assign(new Error('oauth_not_connected'), { status: 404 });
|
||
const expired = typeof conn.expiresAt === 'number' && conn.expiresAt - Date.now() < 60_000;
|
||
if (!expired || !conn.refreshToken) return conn;
|
||
const refreshed = await refreshAccessToken(provider, conn.refreshToken);
|
||
updateOAuthTokens(userId, provider, refreshed);
|
||
return { ...conn, accessToken: refreshed.accessToken, refreshToken: refreshed.refreshToken, expiresAt: refreshed.expiresAt };
|
||
}
|
||
|
||
// Public : l'UI affiche "Connecter" seulement si configuré côté serveur.
|
||
r.get('/oauth/status', (req, res) => res.json(oauthStatus()));
|
||
|
||
// URL d'autorisation (connecté requis : le state mémorise l'utilisateur).
|
||
r.get('/oauth/:provider/url', authMiddlewareCookieAware, oauthLimiter, (req, res) => {
|
||
try {
|
||
const provider = requireOAuthProvider(req.params.provider);
|
||
const status = oauthStatus()[provider];
|
||
if (!status?.configured) return res.status(503).json({ error: `${provider}_oauth_not_configured`, missing: status?.missing || [] });
|
||
const state = createOAuthState(req.user.id, provider);
|
||
return res.json({ url: buildAuthUrl(provider, state, req) });
|
||
} catch (error) {
|
||
return res.status(error?.status || 500).json({ error: error?.message || 'oauth_url_failed' });
|
||
}
|
||
});
|
||
|
||
// Callback OAuth (public : retrouvé via le state). Redirige vers le front.
|
||
r.get('/oauth/:provider/callback', oauthLimiter, async (req, res) => {
|
||
const provider = String(req.params.provider || '').toLowerCase();
|
||
const frontBase = (() => {
|
||
try {
|
||
const explicit = String(process.env.OAUTH_APP_BASE_URL || '').trim().replace(/\/+$/, '');
|
||
if (explicit) return explicit;
|
||
const proto = String(req.headers?.['x-forwarded-proto'] || req.protocol || 'http').split(',')[0].trim() || 'http';
|
||
const host = String(req.headers?.['x-forwarded-host'] || req.headers?.host || '').trim();
|
||
// En prod Docker le callback arrive sur :4000 mais l'UI est sur :4200 (hôte).
|
||
// Si l'hôte pointe le port API interne, on rebascule vers le port public.
|
||
const publicPort = String(process.env.OAUTH_PUBLIC_PORT || process.env.HOST_PORT || '').trim();
|
||
if (host && publicPort) {
|
||
const bare = host.split(':')[0];
|
||
return `${proto}://${bare}:${publicPort}`;
|
||
}
|
||
if (host) return `${proto}://${host}`;
|
||
} catch {}
|
||
return 'http://localhost:4200';
|
||
})();
|
||
const fail = (code) => res.redirect(302, `${frontBase}/library/import?provider=${provider}&error=${encodeURIComponent(code)}`);
|
||
try {
|
||
if (provider !== 'google' && provider !== 'twitch') return fail('invalid_oauth_provider');
|
||
if (req.query?.error) return fail(String(req.query.error_description || req.query.error));
|
||
const { code, state } = req.query || {};
|
||
if (!code || !state) return fail('oauth_missing_code_or_state');
|
||
const entry = consumeOAuthState(String(state));
|
||
if (!entry || entry.provider !== provider) return fail('oauth_invalid_state');
|
||
// Le login Google revient ici : buildAuthUrl utilise toujours la redirect URI
|
||
// /api/oauth/google/callback (une seule URI à enregistrer côté Google).
|
||
if (provider === 'google' && entry.purpose === 'login') {
|
||
return completeGoogleLogin(req, res, frontBaseForOAuth(req), entry, String(code));
|
||
}
|
||
const tokens = await exchangeCode(provider, String(code), req);
|
||
if (!tokens?.accessToken) return fail('oauth_token_failed');
|
||
let profile = { id: '', displayName: provider, avatarUrl: '' };
|
||
try {
|
||
profile = provider === 'google' ? await fetchGoogleProfile(tokens.accessToken) : await fetchTwitchProfile(tokens.accessToken);
|
||
} catch {}
|
||
upsertOAuthConnection({
|
||
userId: entry.userId, provider, externalUserId: profile.id || null,
|
||
displayName: profile.displayName || null, avatarUrl: profile.avatarUrl || null,
|
||
accessToken: tokens.accessToken, refreshToken: tokens.refreshToken,
|
||
expiresAt: tokens.expiresAt, scopes: tokens.scopes,
|
||
});
|
||
return res.redirect(302, `${frontBase}/library/import?provider=${provider}&connected=1`);
|
||
} catch (error) {
|
||
return fail(error?.message || 'oauth_callback_failed');
|
||
}
|
||
});
|
||
|
||
r.get('/oauth/connections', authMiddlewareCookieAware, oauthLimiter, (req, res) => {
|
||
try {
|
||
return res.json({ connections: listOAuthConnections(req.user.id), status: oauthStatus() });
|
||
} catch {
|
||
return res.status(500).json({ error: 'oauth_connections_failed' });
|
||
}
|
||
});
|
||
|
||
r.delete('/oauth/:provider', authMiddlewareCookieAware, oauthLimiter, (req, res) => {
|
||
try {
|
||
const provider = requireOAuthProvider(req.params.provider);
|
||
deleteOAuthConnection(req.user.id, provider);
|
||
return res.status(204).end();
|
||
} catch (error) {
|
||
return res.status(error?.status || 500).json({ error: error?.message || 'oauth_disconnect_failed' });
|
||
}
|
||
});
|
||
|
||
// -------------------- Google : choix de la chaîne (multi-chaînes / marque) --------------------
|
||
// La chaîne par défaut peut être une coquille vide (pas d'historique ni WL)
|
||
// alors que l'activité est sur une chaîne secondaire : l'utilisateur la
|
||
// choisit ici, et les appels InnerTube la ciblent via X-Goog-PageId.
|
||
|
||
r.get('/oauth/google/yt-channels', authMiddlewareCookieAware, oauthLimiter, async (req, res) => {
|
||
try {
|
||
const conn = await freshOAuthToken(req.user.id, 'google');
|
||
const { channels } = await fetchAccountChannels(conn);
|
||
try {
|
||
console.log(`[oauth] yt-channels user=${req.user?.id} count=${channels.length}`);
|
||
} catch {}
|
||
return res.json({ channels, selected: { channelId: conn.ytChannelId || null, pageId: conn.ytPageId || null } });
|
||
} catch (error) {
|
||
const out = { error: error?.message || 'yt_channels_failed' };
|
||
if (error?.detail) out.detail = error.detail;
|
||
if (error?.hint) out.hint = error.hint;
|
||
try {
|
||
console.warn(`[oauth] yt-channels échec user=${req.user?.id} code=${out.error} detail=${String(error?.detail || '').slice(0, 300)}`);
|
||
} catch {}
|
||
return res.status(error?.status || 502).json(out);
|
||
}
|
||
});
|
||
|
||
// Diagnostic : état de chaque endpoint InnerTube authentifié + Data API.
|
||
r.get('/oauth/google/diag', authMiddlewareCookieAware, oauthLimiter, async (req, res) => {
|
||
try {
|
||
const conn = await freshOAuthToken(req.user.id, 'google');
|
||
const dataApi = {};
|
||
try {
|
||
dataApi.mineChannel = await fetchMineChannel(conn.accessToken);
|
||
} catch (e) {
|
||
dataApi.mineChannel = { error: String(e?.message || '').slice(0, 80) };
|
||
}
|
||
try {
|
||
await fetchGoogleWatchLaterId(conn.accessToken);
|
||
dataApi.watchLaterId = true;
|
||
} catch (e) {
|
||
dataApi.watchLaterId = false;
|
||
dataApi.watchLaterError = String(e?.message || '').slice(0, 80);
|
||
}
|
||
const innertube = await diagnoseInnertube(conn);
|
||
try {
|
||
console.log(`[oauth] diag user=${req.user?.id} ${JSON.stringify({ dataApi, innertube }).slice(0, 800)}`);
|
||
} catch {}
|
||
return res.json({ dataApi, innertube });
|
||
} catch (error) {
|
||
return res.status(error?.status || 502).json({ error: error?.message || 'diag_failed' });
|
||
}
|
||
});
|
||
|
||
r.put('/oauth/google/yt-channel', authMiddlewareCookieAware, oauthLimiter, (req, res) => {
|
||
try {
|
||
const channelId = String(req.body?.channelId || '').trim().slice(0, 64) || null;
|
||
const pageId = String(req.body?.pageId || '').trim().slice(0, 64) || null;
|
||
const conn = setOAuthChannel(req.user.id, 'google', { channelId, pageId });
|
||
if (!conn) return res.status(404).json({ error: 'oauth_not_connected' });
|
||
return res.json({ selected: { channelId: conn.ytChannelId || null, pageId: conn.ytPageId || null } });
|
||
} catch {
|
||
return res.status(500).json({ error: 'yt_channel_save_failed' });
|
||
}
|
||
});
|
||
|
||
// Aperçu distant (sans rien importer) : abonnements + favoris.
|
||
r.get('/oauth/:provider/preview', authMiddlewareCookieAware, oauthLimiter, async (req, res) => {
|
||
try {
|
||
const provider = requireOAuthProvider(req.params.provider);
|
||
const conn = await freshOAuthToken(req.user.id, provider);
|
||
if (provider === 'google') {
|
||
const [subsRes, likesRes] = await Promise.all([
|
||
fetchGoogleSubscriptions(conn.accessToken, 50).catch(() => ({ items: [], total: 0 })),
|
||
fetchGoogleLiked(conn.accessToken, 25).catch(() => ({ items: [], total: 0 })),
|
||
]);
|
||
return res.json({
|
||
provider,
|
||
subscriptions: subsRes.items,
|
||
likes: likesRes.items,
|
||
subscriptionCount: subsRes.items.length,
|
||
likeCount: likesRes.items.length,
|
||
// Totaux du compte (l'aperçu n'affiche que les 50/25 premiers, l'import prend tout).
|
||
subscriptionTotal: subsRes.total,
|
||
likeTotal: likesRes.total,
|
||
});
|
||
}
|
||
const profile = await fetchTwitchProfile(conn.accessToken).catch(() => ({ id: conn.externalUserId || '' }));
|
||
const follows = await fetchTwitchFollows(conn.accessToken, profile?.id || conn.externalUserId || '', 100);
|
||
return res.json({ provider, subscriptions: follows, likes: [], subscriptionCount: follows.length, likeCount: 0 });
|
||
} catch (error) {
|
||
const status = error?.status || 502;
|
||
return res.status(status).json({ error: error?.message || 'oauth_preview_failed' });
|
||
}
|
||
});
|
||
|
||
// Import : { types: ['subscriptions','likes'] } (likes = Google uniquement).
|
||
r.post('/oauth/:provider/import', authMiddlewareCookieAware, oauthLimiter, async (req, res) => {
|
||
try {
|
||
const provider = requireOAuthProvider(req.params.provider);
|
||
const rawTypes = Array.isArray(req.body?.types) ? req.body.types : ['subscriptions', 'likes'];
|
||
const wantSubs = rawTypes.includes('subscriptions');
|
||
const wantLikes = rawTypes.includes('likes');
|
||
const conn = await freshOAuthToken(req.user.id, provider);
|
||
let importedSubscriptions = 0;
|
||
let importedLikes = 0;
|
||
let skippedSubscriptions = 0;
|
||
let skippedLikes = 0;
|
||
|
||
if (provider === 'google') {
|
||
if (wantSubs) {
|
||
// Import complet paginé (l'aperçu n'en montre que 50).
|
||
const { items: subs } = await fetchGoogleSubscriptions(conn.accessToken, 1000);
|
||
for (const s of subs) {
|
||
try {
|
||
const row = upsertChannelRow({
|
||
provider: 'youtube', externalId: s.externalId, title: s.title,
|
||
handle: s.handle || null, avatarUrl: s.avatarUrl || null, url: s.url || null,
|
||
lastRefreshedAt: Date.now(),
|
||
});
|
||
subscribeChannel({ userId: req.user.id, provider: 'youtube', externalId: s.externalId, channelId: row?.id });
|
||
importedSubscriptions++;
|
||
} catch { skippedSubscriptions++; }
|
||
}
|
||
}
|
||
if (wantLikes) {
|
||
// Import complet paginé (l'aperçu n'en montre que 25).
|
||
const { items: likes } = await fetchGoogleLiked(conn.accessToken, 500);
|
||
for (const v of likes) {
|
||
try {
|
||
likeVideo({ userId: req.user.id, provider: 'youtube', videoId: v.videoId, title: v.title, thumbnail: v.thumbnail });
|
||
importedLikes++;
|
||
} catch { skippedLikes++; }
|
||
}
|
||
}
|
||
} else {
|
||
if (wantSubs) {
|
||
const profile = await fetchTwitchProfile(conn.accessToken).catch(() => ({ id: conn.externalUserId || '' }));
|
||
const follows = await fetchTwitchFollows(conn.accessToken, profile?.id || conn.externalUserId || '', 100);
|
||
for (const f of follows) {
|
||
try {
|
||
const row = upsertChannelRow({
|
||
provider: 'twitch', externalId: f.externalId, title: f.title,
|
||
handle: f.handle || null, avatarUrl: f.avatarUrl || null, url: f.url || null,
|
||
lastRefreshedAt: Date.now(),
|
||
});
|
||
subscribeChannel({ userId: req.user.id, provider: 'twitch', externalId: f.externalId, channelId: row?.id });
|
||
importedSubscriptions++;
|
||
} catch { skippedSubscriptions++; }
|
||
}
|
||
}
|
||
// Twitch : pas de likes vidéo → on l'indique au lieu d'échouer silencieusement.
|
||
if (wantLikes) skippedLikes = 0;
|
||
}
|
||
return res.json({ provider, importedSubscriptions, importedLikes, skippedSubscriptions, skippedLikes });
|
||
} catch (error) {
|
||
const status = error?.status || 502;
|
||
return res.status(status).json({ error: error?.message || 'oauth_import_failed' });
|
||
}
|
||
});
|
||
|
||
// -------------------- Google : Watch Later (lecture + écriture) --------------------
|
||
// Lecture (youtube.readonly OK). `writeGranted` = le jeton stocké a le scope
|
||
// force-ssl ; sinon le push répond 403 et l'UI propose de reconnecter.
|
||
|
||
r.get('/oauth/google/watchlater', authMiddlewareCookieAware, oauthLimiter, async (req, res) => {
|
||
try {
|
||
const conn = await freshOAuthToken(req.user.id, 'google');
|
||
const writeGranted = hasGoogleWriteScope(conn.scopes);
|
||
try {
|
||
const { items } = await fetchGoogleWatchLater(conn.accessToken, 50);
|
||
return res.json({ items, count: items.length, writeGranted, via: 'api' });
|
||
} catch (apiError) {
|
||
// L'API Data ne renvoie pas d'ID WL pour certains comptes : repli
|
||
// youtubei.js authentifié (getPlaylist WL, aucun ID requis).
|
||
if (apiError?.message !== 'watchlater_not_found' && apiError?.message !== 'youtube_no_channel') throw apiError;
|
||
try {
|
||
console.warn(`[oauth] WL Data API indisponible (${apiError.message}), repli youtubei.js`);
|
||
} catch {}
|
||
const { items } = await fetchInnerTubeWatchLaterViaLib(conn, 100);
|
||
try {
|
||
console.log(`[oauth] WL via innertube user=${req.user?.id} count=${items.length}`);
|
||
} catch {}
|
||
return res.json({
|
||
items, count: items.length, writeGranted, via: 'innertube',
|
||
note: 'Liste lue via InnerTube (l’API YouTube ne l’expose pas pour ce compte).',
|
||
});
|
||
}
|
||
} catch (error) {
|
||
const out = { error: error?.message || 'watchlater_fetch_failed' };
|
||
if (error?.hint) out.hint = error.hint;
|
||
if (error?.ytReason) out.ytReason = error.ytReason;
|
||
if (error?.detail) out.detail = error.detail;
|
||
if (!out.hint && String(error?.ytReason || '').toLowerCase().includes('insufficientpermissions')) {
|
||
out.hint = 'Scope manquant : déconnectez puis reconnectez Google pour autoriser l’accès complet YouTube.';
|
||
}
|
||
return res.status(error?.status || 502).json(out);
|
||
}
|
||
});
|
||
|
||
// -------------------- Google : historique auto via InnerTube authentifié --------------------
|
||
// L'API Data v3 n'expose pas l'historique : on lit youtubei/v1/browse
|
||
// (FEhistory) avec le Bearer OAuth de l'utilisateur (mécanisme SmartTube).
|
||
// Le client réutilise ensuite POST /user/history/takeout pour l'importer.
|
||
|
||
function innertubeApiKey() {
|
||
const single = String(process.env.YOUTUBE_API_KEY || '').trim();
|
||
if (single && !single.includes(',')) return single;
|
||
const csv = String(process.env.YOUTUBE_API_KEYS || '').trim();
|
||
try {
|
||
if (csv.startsWith('[')) {
|
||
const arr = JSON.parse(csv);
|
||
if (Array.isArray(arr) && arr[0]) return String(arr[0]);
|
||
}
|
||
} catch {}
|
||
if (csv) {
|
||
const first = csv.split(',').map((s) => s.trim()).filter(Boolean)[0];
|
||
if (first) return first;
|
||
}
|
||
if (single) return single.split(',').map((s) => s.trim()).filter(Boolean)[0] || '';
|
||
return '';
|
||
}
|
||
|
||
r.get('/oauth/google/yt-history', authMiddlewareCookieAware, oauthLimiter, async (req, res) => {
|
||
try {
|
||
const conn = await freshOAuthToken(req.user.id, 'google');
|
||
const max = Math.min(500, Math.max(1, Number(req.query.max || 200)));
|
||
const { items, hasMore } = await fetchInnerTubeHistory(conn, max);
|
||
try {
|
||
console.log(`[oauth] yt-history user=${req.user?.id} count=${items.length} hasMore=${hasMore}`);
|
||
} catch {}
|
||
return res.json({ items, count: items.length, hasMore });
|
||
} catch (error) {
|
||
const out = { error: error?.message || 'ythistory_fetch_failed' };
|
||
if (error?.ytReason) out.ytReason = error.ytReason;
|
||
if (error?.detail) out.detail = error.detail;
|
||
if (error?.hint) out.hint = error.hint;
|
||
try {
|
||
console.warn(`[oauth] yt-history échec user=${req.user?.id} code=${out.error} detail=${String(error?.detail || error?.ytReason || '').slice(0, 300)}`);
|
||
} catch {}
|
||
if (!out.hint && error?.status === 401) {
|
||
out.hint = 'Session Google expirée : déconnectez puis reconnectez Google.';
|
||
} else if (!out.hint && String(error?.ytReason || '').toLowerCase().includes('insufficientpermissions')) {
|
||
out.hint = 'Scope manquant : déconnectez puis reconnectez Google pour autoriser l’accès complet YouTube.';
|
||
}
|
||
return res.status(error?.status || 502).json(out);
|
||
}
|
||
});
|
||
|
||
r.post('/oauth/google/watchlater', authMiddlewareCookieAware, oauthLimiter, async (req, res) => {
|
||
try {
|
||
const conn = await freshOAuthToken(req.user.id, 'google');
|
||
if (!hasGoogleWriteScope(conn.scopes)) {
|
||
return res.status(403).json({ error: 'youtube_write_scope_missing', hint: 'Reconnectez Google pour autoriser l’écriture (Watch Later).' });
|
||
}
|
||
const ids = Array.isArray(req.body?.videoIds) ? req.body.videoIds : [];
|
||
const clean = [...new Set(ids.map((v) => String(v || '').trim()).filter(Boolean))].slice(0, 50);
|
||
if (!clean.length) return res.status(400).json({ error: 'videoIds_required' });
|
||
// ID WL via Data API, sinon repli InnerTube (playlist logique "WL").
|
||
let useInnertube = false;
|
||
try {
|
||
await fetchGoogleWatchLaterId(conn.accessToken);
|
||
} catch (e) {
|
||
if (e?.message === 'watchlater_not_found' || e?.message === 'youtube_no_channel') useInnertube = true;
|
||
else throw e;
|
||
}
|
||
let added = 0;
|
||
let skipped = 0;
|
||
for (const videoId of clean) {
|
||
try {
|
||
if (useInnertube) await pushInnerTubeWatchLater(conn.accessToken, innertubeApiKey(), videoId);
|
||
else await pushGoogleWatchLater(conn.accessToken, videoId, innertubeApiKey());
|
||
added++;
|
||
} catch { skipped++; }
|
||
}
|
||
return res.json({ added, skipped, total: clean.length, via: useInnertube ? 'innertube' : 'api' });
|
||
} catch (error) {
|
||
const status = error?.status || 502;
|
||
if (status === 403) {
|
||
return res.status(403).json({ error: 'youtube_write_scope_missing', hint: 'Reconnectez Google pour autoriser l’écriture (Watch Later).' });
|
||
}
|
||
return res.status(status).json({ error: error?.message || 'watchlater_push_failed' });
|
||
}
|
||
});
|
||
|
||
// -------------------- Import historique Takeout (Google) --------------------
|
||
// Le client parse le fichier `watch-history.json` localement (confidentialité,
|
||
// pas de limite d'upload) et envoie des lots { videoId, title, watchedAt }.
|
||
// L'historique YouTube n'est pas lisible par API (limite Google), d'où Takeout.
|
||
|
||
r.post('/user/history/takeout', authMiddleware, oauthLimiter, (req, res) => {
|
||
try {
|
||
const items = Array.isArray(req.body?.items) ? req.body.items : [];
|
||
if (!items.length) return res.status(400).json({ error: 'items_required' });
|
||
if (items.length > 1000) return res.status(400).json({ error: 'batch_too_large' });
|
||
let imported = 0;
|
||
let skipped = 0;
|
||
for (const it of items) {
|
||
const videoId = String(it?.videoId || '').trim().slice(0, 64);
|
||
if (!/^[A-Za-z0-9_-]{6,64}$/.test(videoId)) { skipped++; continue; }
|
||
const title = String(it?.title || '').slice(0, 300) || videoId;
|
||
// Miniature YouTube déterministe quand Takeout n'en fournit pas.
|
||
let thumbnail = String(it?.thumbnail || '').slice(0, 500);
|
||
if (!thumbnail && /^[A-Za-z0-9_-]{11}$/.test(videoId)) {
|
||
thumbnail = `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`;
|
||
}
|
||
let watchedAt = new Date().toISOString();
|
||
if (it?.watchedAt) {
|
||
const d = new Date(String(it.watchedAt));
|
||
if (!Number.isNaN(d.getTime())) watchedAt = d.toISOString();
|
||
}
|
||
try {
|
||
upsertWatchHistory({ userId: req.user.id, provider: 'youtube', videoId, title, thumbnail, watchedAt });
|
||
imported++;
|
||
} catch { skipped++; }
|
||
}
|
||
return res.json({ imported, skipped, total: items.length });
|
||
} catch {
|
||
return res.status(500).json({ error: 'takeout_import_failed' });
|
||
}
|
||
});
|
||
|
||
// 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(express.urlencoded({ extended: true }));
|
||
app.use(cookieParser());
|
||
app.use(cors(corsOptions));
|
||
app.options('*', cors(corsOptions)); // Pré-vol CORS
|
||
|
||
// Logging des requêtes
|
||
app.use(requestLogger);
|
||
|
||
// Routes API
|
||
app.use('/api', r);
|
||
|
||
// -------------------- Downloads configuration --------------------
|
||
// Downloads directory (per-user sub-directories)
|
||
const downloadsRoot = path.join(process.cwd(), 'tmp', 'downloads');
|
||
if (!fs.existsSync(downloadsRoot)) {
|
||
fs.mkdirSync(downloadsRoot, { recursive: true });
|
||
}
|
||
|
||
// Storage quota (bytes) per user for completed downloads in the retention window.
|
||
// Default: 5 GiB. Set to 0 to disable.
|
||
const DOWNLOAD_STORAGE_QUOTA_BYTES = Number(process.env.DOWNLOAD_STORAGE_QUOTA_BYTES ?? (5 * 1024 * 1024 * 1024));
|
||
// Retention window (ms) for quota accounting. Default: 30 days. Set to 0 for no window.
|
||
const DOWNLOAD_QUOTA_WINDOW_MS = Number(process.env.DOWNLOAD_QUOTA_WINDOW_MS ?? (30 * 24 * 60 * 60 * 1000));
|
||
|
||
function userDownloadsDir(userId) {
|
||
const safeId = String(userId || 'anonymous').replace(/[^a-zA-Z0-9_-]+/g, '_').slice(0, 64) || 'anonymous';
|
||
const dir = path.join(downloadsRoot, safeId);
|
||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||
return dir;
|
||
}
|
||
|
||
// On boot: mark jobs that were active when the API stopped as 'interrupted'
|
||
// so users can retry them, and clean orphan files left by jobs that never completed.
|
||
resetActiveDownloadJobs();
|
||
(function cleanupOrphanDownloadFiles() {
|
||
try {
|
||
const known = new Set(
|
||
listDownloadJobs({ userId: '', limit: 100000 })
|
||
.filter(j => j.state === 'completed' && j.filePath)
|
||
.map(j => path.resolve(j.filePath))
|
||
);
|
||
if (!fs.existsSync(downloadsRoot)) return;
|
||
for (const entry of fs.readdirSync(downloadsRoot, { withFileTypes: true })) {
|
||
const full = path.join(downloadsRoot, entry.name);
|
||
if (entry.isDirectory()) {
|
||
for (const f of fs.readdirSync(full)) {
|
||
const fp = path.join(full, f);
|
||
if (!known.has(path.resolve(fp))) {
|
||
try { fs.unlinkSync(fp); } catch {}
|
||
}
|
||
}
|
||
} else if (entry.isFile() && !known.has(path.resolve(full))) {
|
||
// Legacy layout: files directly under downloadsRoot
|
||
try { fs.unlinkSync(full); } catch {}
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.warn('[downloads] orphan cleanup failed:', e?.message || e);
|
||
}
|
||
})();
|
||
|
||
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,
|
||
});
|
||
|
||
/**
|
||
* Réponse JSON (et non texte brut) quand un rate-limiter se déclenche, pour
|
||
* que le front puisse afficher un message FR précis avec compte à rebours.
|
||
*/
|
||
function jsonLimitHandler(req, res, _next, options) {
|
||
let retryAfterSec = 60;
|
||
try {
|
||
const resetMs = req?.rateLimit?.resetTime ? new Date(req.rateLimit.resetTime).getTime() : 0;
|
||
if (resetMs > Date.now()) retryAfterSec = Math.max(1, Math.ceil((resetMs - Date.now()) / 1000));
|
||
else if (options?.windowMs) retryAfterSec = Math.max(1, Math.ceil(options.windowMs / 1000));
|
||
} catch {}
|
||
try { res.set('Retry-After', String(retryAfterSec)); } catch {}
|
||
return res.status(options?.statusCode || 429).json({ error: 'rate_limited', retryAfterSec });
|
||
}
|
||
|
||
const downloadReadLimiter = rateLimit({
|
||
windowMs: 60 * 1000, // polling légitime des jobs (2-5 s) : seau large
|
||
max: 120,
|
||
standardHeaders: true,
|
||
legacyHeaders: false,
|
||
handler: jsonLimitHandler,
|
||
});
|
||
|
||
const downloadWriteLimiter = rateLimit({
|
||
windowMs: 60 * 1000,
|
||
max: 30,
|
||
standardHeaders: true,
|
||
legacyHeaders: false,
|
||
handler: jsonLimitHandler,
|
||
});
|
||
|
||
const downloadFormatsLimiter = rateLimit({
|
||
windowMs: 60 * 1000,
|
||
max: 30,
|
||
standardHeaders: true,
|
||
legacyHeaders: false,
|
||
handler: jsonLimitHandler,
|
||
});
|
||
|
||
/** Cache mémoire des listes de formats (un dump yt-dlp = 5-15 s + quota YouTube). */
|
||
const formatsCache = new Map(); // key -> { ts, data }
|
||
const FORMATS_CACHE_TTL_MS = 10 * 60 * 1000;
|
||
const FORMATS_CACHE_MAX = 200;
|
||
function formatsCacheGet(key) {
|
||
const hit = formatsCache.get(key);
|
||
if (!hit) return null;
|
||
if (Date.now() - hit.ts > FORMATS_CACHE_TTL_MS) { formatsCache.delete(key); return null; }
|
||
// LRU : rejoue l'entrée en fin de Map
|
||
formatsCache.delete(key);
|
||
formatsCache.set(key, hit);
|
||
return hit.data;
|
||
}
|
||
function formatsCacheSet(key, data) {
|
||
if (formatsCache.has(key)) formatsCache.delete(key);
|
||
formatsCache.set(key, { ts: Date.now(), data });
|
||
while (formatsCache.size > FORMATS_CACHE_MAX) {
|
||
const oldest = formatsCache.keys().next().value;
|
||
formatsCache.delete(oldest);
|
||
}
|
||
}
|
||
|
||
// 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 isSecureRequest(req) {
|
||
try {
|
||
if (process.env.COOKIE_SECURE === 'true') return true;
|
||
if (process.env.COOKIE_SECURE === 'false') return false;
|
||
if (req?.secure) return true;
|
||
const proto = String(req?.headers?.['x-forwarded-proto'] || req?.protocol || '').split(',')[0].trim().toLowerCase();
|
||
return proto === 'https';
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function setRefreshCookies(res, { sessionId, token, days }, req) {
|
||
const maxAgeMs = days * 24 * 60 * 60 * 1000;
|
||
const cookieOpts = {
|
||
httpOnly: true,
|
||
// 'lax' : envoyé sur les navigations top-level (liens directs, <video>) tout en
|
||
// bloquant l'envoi cross-site sur POST (CSRF). 'strict' cassait les téléchargements directs.
|
||
sameSite: 'lax',
|
||
// Ne jamais forcer Secure sur du http local (sinon le navigateur n'envoie jamais
|
||
// les cookies et les liens directs /proxy/api/.../file répondent 401 Unauthorized).
|
||
secure: isSecureRequest(req),
|
||
// '/' : les cookies doivent partir aussi bien sur /api/* que sur /proxy/api/*.
|
||
path: '/',
|
||
maxAge: maxAgeMs,
|
||
};
|
||
res.cookie('sid', sessionId, cookieOpts);
|
||
res.cookie('refreshToken', token, cookieOpts);
|
||
}
|
||
|
||
function clearRefreshCookies(res) {
|
||
// Supprime les cookies actuels + les variantes historiques (anciens Path/Secure).
|
||
const variants = [
|
||
{ httpOnly: true, sameSite: 'lax', secure: false, path: '/' },
|
||
{ httpOnly: true, sameSite: 'strict', secure: false, path: '/api' },
|
||
{ httpOnly: true, sameSite: 'strict', secure: false, path: '/proxy/api' },
|
||
{ httpOnly: true, sameSite: 'strict', secure: true, path: '/api' },
|
||
{ httpOnly: true, sameSite: 'lax', secure: true, path: '/' },
|
||
];
|
||
for (const base of variants) {
|
||
try { res.clearCookie('sid', base); } catch {}
|
||
try { res.clearCookie('refreshToken', base); } catch {}
|
||
}
|
||
}
|
||
|
||
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 --------------------
|
||
// Par défaut tous les providers gérés par yt-dlp sont autorisés (youtube inclus).
|
||
// Restreindre via DOWNLOAD_PROVIDERS="peertube,odysee" si besoin.
|
||
const DOWNLOAD_ALLOWED_PROVIDERS = (process.env.DOWNLOAD_PROVIDERS || 'youtube,dailymotion,twitch,peertube,odysee,rumble').split(',').map(s => s.trim()).filter(Boolean);
|
||
|
||
/** @type {Map<string, any>} */
|
||
const jobs = new Map();
|
||
|
||
function sanitizeFileName(name) {
|
||
return String(name || 'video')
|
||
.replace(/[^a-zA-Z0-9-_\. ]+/g, '_')
|
||
.replace(/[\s]+/g, ' ')
|
||
.trim()
|
||
.slice(0, 140);
|
||
}
|
||
|
||
function providerUrlFrom(provider, videoId, { instance, slug, sourceUrl }) {
|
||
if (sourceUrl && /^https?:\/\//i.test(sourceUrl)) return sourceUrl;
|
||
const id = String(videoId);
|
||
switch (String(provider)) {
|
||
case 'youtube':
|
||
return `https://www.youtube.com/watch?v=${encodeURIComponent(id)}`;
|
||
case 'dailymotion':
|
||
return `https://www.dailymotion.com/video/${encodeURIComponent(id)}`;
|
||
case 'twitch':
|
||
return `https://www.twitch.tv/videos/${encodeURIComponent(id)}`;
|
||
case 'peertube': {
|
||
const inst = String(instance || '').trim();
|
||
if (!inst) throw new Error('peertube_instance_required');
|
||
return `https://${inst}/w/${encodeURIComponent(id)}`;
|
||
}
|
||
case 'odysee': {
|
||
const s = String(slug || id);
|
||
return `https://odysee.com/${s.replace(/^\//, '')}`;
|
||
}
|
||
case 'rumble':
|
||
return `https://rumble.com/${encodeURIComponent(id)}`;
|
||
default:
|
||
throw new Error('unsupported_provider');
|
||
}
|
||
}
|
||
|
||
async function scrapeRumbleVideo(videoId) {
|
||
const url = `https://rumble.com/${videoId}`;
|
||
|
||
try {
|
||
const response = await axios.get(url, {
|
||
headers: {
|
||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
|
||
'Accept-Language': 'en-US,en;q=0.5',
|
||
'Accept-Encoding': 'gzip, deflate, br',
|
||
'DNT': '1',
|
||
'Connection': 'keep-alive',
|
||
'Upgrade-Insecure-Requests': '1',
|
||
'Cache-Control': 'no-cache'
|
||
},
|
||
timeout: 15000,
|
||
maxRedirects: 5,
|
||
validateStatus: function (status) {
|
||
return status >= 200 && status < 400; // Accept redirects
|
||
}
|
||
});
|
||
|
||
const $ = cheerio.load(response.data);
|
||
const html = response.data;
|
||
|
||
// Extract basic video information
|
||
let title = '';
|
||
let thumbnail = '';
|
||
let uploaderName = '';
|
||
let uploaderAvatar = '';
|
||
let views = 0;
|
||
let duration = 0;
|
||
let uploadedDate = '';
|
||
let description = '';
|
||
|
||
// Try multiple selectors for title (Rumble's HTML structure can vary)
|
||
title = $('h1.video-title, .video-title h1, [data-video-title]').first().text().trim() ||
|
||
$('meta[property="og:title"]').attr('content') ||
|
||
$('title').text().trim() ||
|
||
$('h1').first().text().trim() || '';
|
||
|
||
// Clean up title (remove site name if present)
|
||
title = title.replace(/\s*\|\s*Rumble$/i, '').trim();
|
||
|
||
// Extract thumbnail with fallbacks
|
||
thumbnail = $('meta[property="og:image"], meta[name="twitter:image"]').attr('content') ||
|
||
$('meta[property="og:image:secure_url"]').attr('content') ||
|
||
$('.video-thumbnail img, .thumbnail img').attr('src') || '';
|
||
|
||
// Make thumbnail URL absolute if relative
|
||
if (thumbnail && !thumbnail.startsWith('http')) {
|
||
thumbnail = thumbnail.startsWith('//') ? 'https:' + thumbnail : 'https://rumble.com' + thumbnail;
|
||
}
|
||
|
||
// Extract uploader information with multiple selectors
|
||
uploaderName = $('.media-by--a, .channel-name, .uploader-name').first().text().trim() ||
|
||
$('meta[property="article:author"]').attr('content') ||
|
||
$('.author-name, .channel-link').first().text().trim() || '';
|
||
|
||
uploaderAvatar = $('.channel-avatar img, .uploader-avatar img').attr('src') ||
|
||
$('.author-avatar img').attr('src') || '';
|
||
|
||
// Make uploader avatar URL absolute
|
||
if (uploaderAvatar && !uploaderAvatar.startsWith('http')) {
|
||
uploaderAvatar = uploaderAvatar.startsWith('//') ? 'https:' + uploaderAvatar : 'https://rumble.com' + uploaderAvatar;
|
||
}
|
||
|
||
// Extract views with better parsing
|
||
const viewsText = $('.rumbles-views, .video-views, .views-count, .video-info .views').first().text().trim();
|
||
if (viewsText) {
|
||
const viewsMatch = viewsText.match(/([\d,]+(?:\.\d+)?)\s*(K|M|B)?/i);
|
||
if (viewsMatch) {
|
||
let num = parseFloat(viewsMatch[1].replace(/,/g, ''));
|
||
const multiplier = viewsMatch[2]?.toUpperCase();
|
||
if (multiplier === 'K') num *= 1000;
|
||
else if (multiplier === 'M') num *= 1000000;
|
||
else if (multiplier === 'B') num *= 1000000000;
|
||
views = Math.floor(num);
|
||
} else {
|
||
// Try direct number parsing
|
||
const directMatch = viewsText.match(/(\d+(?:,\d+)*)/);
|
||
if (directMatch) {
|
||
views = parseInt(directMatch[1].replace(/,/g, ''));
|
||
}
|
||
}
|
||
}
|
||
|
||
// Extract duration with improved parsing
|
||
const durationText = $('meta[property="video:duration"]').attr('content') ||
|
||
$('video').attr('duration') ||
|
||
$('.video-duration, .duration, .video-time').first().text().trim() ||
|
||
$('.time-duration').text().trim();
|
||
|
||
if (durationText) {
|
||
if (!isNaN(durationText)) {
|
||
duration = parseInt(durationText);
|
||
} else {
|
||
// Parse various duration formats
|
||
const timeMatch = durationText.match(/(\d+):(\d+)(?::(\d+))?/);
|
||
if (timeMatch) {
|
||
const hours = parseInt(timeMatch[3] || '0');
|
||
const minutes = parseInt(timeMatch[1]);
|
||
const seconds = parseInt(timeMatch[2]);
|
||
duration = hours * 3600 + minutes * 60 + seconds;
|
||
} else {
|
||
// Try HH:MM:SS format or MM:SS
|
||
const parts = durationText.split(':').map(p => parseInt(p.trim()) || 0);
|
||
if (parts.length === 3) {
|
||
duration = parts[0] * 3600 + parts[1] * 60 + parts[2];
|
||
} else if (parts.length === 2) {
|
||
duration = parts[0] * 60 + parts[1];
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Extract upload date
|
||
uploadedDate = $('meta[property="article:published_time"]').attr('content') ||
|
||
$('.upload-date, .published-date, .video-date').first().text().trim() || '';
|
||
|
||
// Try to parse relative dates
|
||
if (!uploadedDate || uploadedDate.includes('ago')) {
|
||
const relativeDate = $('.upload-date, .published-date').first().text().trim();
|
||
if (relativeDate && relativeDate.includes('ago')) {
|
||
// Convert relative date to ISO string (simple conversion)
|
||
const now = new Date();
|
||
if (relativeDate.includes('hour')) {
|
||
const hours = parseInt(relativeDate.match(/(\d+)/)?.[1] || '1');
|
||
now.setHours(now.getHours() - hours);
|
||
uploadedDate = now.toISOString();
|
||
} else if (relativeDate.includes('day')) {
|
||
const days = parseInt(relativeDate.match(/(\d+)/)?.[1] || '1');
|
||
now.setDate(now.getDate() - days);
|
||
uploadedDate = now.toISOString();
|
||
}
|
||
}
|
||
}
|
||
|
||
// Extract description
|
||
description = $('meta[property="og:description"]').attr('content') ||
|
||
$('.video-description, .description, .video-summary').first().text().trim() || '';
|
||
|
||
// Extract video ID from various sources
|
||
let extractedVideoId = videoId;
|
||
const videoIdMatch = html.match(/"video_id"\s*:\s*"([^"]+)"/) ||
|
||
html.match(/video[_-]id["\s:]+([^"\s]+)/) ||
|
||
html.match(/embed\/([^/?]+)/);
|
||
if (videoIdMatch && videoIdMatch[1]) {
|
||
extractedVideoId = videoIdMatch[1];
|
||
}
|
||
|
||
// Validate extracted data
|
||
const isValidVideo = title || thumbnail || uploaderName;
|
||
|
||
return {
|
||
videoId: extractedVideoId,
|
||
title: title || 'Untitled Video',
|
||
thumbnail,
|
||
uploaderName: uploaderName || 'Unknown Uploader',
|
||
uploaderAvatar: uploaderAvatar || thumbnail,
|
||
views: Math.max(0, views),
|
||
duration: Math.max(0, duration),
|
||
uploadedDate: uploadedDate || new Date().toISOString(),
|
||
description,
|
||
url,
|
||
type: 'video',
|
||
scraped: true,
|
||
confidence: isValidVideo ? 'high' : 'low'
|
||
};
|
||
} catch (error) {
|
||
console.error('Erreur scraping Rumble:', error.message);
|
||
|
||
// Return minimal data for fallback with error info
|
||
return {
|
||
videoId,
|
||
title: 'Video unavailable',
|
||
thumbnail: '',
|
||
uploaderName: 'Unknown',
|
||
uploaderAvatar: '',
|
||
views: 0,
|
||
duration: 0,
|
||
uploadedDate: '',
|
||
description: '',
|
||
url,
|
||
type: 'video',
|
||
error: error.message,
|
||
scraped: false,
|
||
confidence: 'none'
|
||
};
|
||
}
|
||
}
|
||
|
||
function guessContentTypeByExt(ext) {
|
||
const e = String(ext || '').toLowerCase();
|
||
if (e === 'mp4' || e === 'm4v') return 'video/mp4';
|
||
if (e === 'webm') return 'video/webm';
|
||
if (e === 'mkv') return 'video/x-matroska';
|
||
if (e === 'mp3') return 'audio/mpeg';
|
||
if (e === 'm4a' || e === 'aac') return 'audio/mp4';
|
||
if (e === 'opus' || e === 'ogg') return 'audio/ogg';
|
||
return 'application/octet-stream';
|
||
}
|
||
|
||
function formatListFromMeta(meta) {
|
||
const items = Array.isArray(meta?.formats) ? meta.formats : [];
|
||
const mapped = items.map(f => {
|
||
const height = f.height || 0;
|
||
const fps = f.fps || 0;
|
||
const resolution = height ? `${height}p${fps && fps >= 50 ? fps : ''}` : (f.format_note || '');
|
||
const sizeEstimate = f.filesize || f.filesize_approx || null;
|
||
const labelParts = [];
|
||
if (resolution) labelParts.push(resolution);
|
||
if (f.ext) labelParts.push(f.ext);
|
||
if (f.vcodec && f.vcodec !== 'none') labelParts.push(f.vcodec);
|
||
if (f.acodec && f.acodec !== 'none') labelParts.push(`+${f.acodec}`);
|
||
return {
|
||
id: f.format_id,
|
||
resolution,
|
||
fps: fps || undefined,
|
||
ext: f.ext || '',
|
||
vcodec: f.vcodec || '',
|
||
acodec: f.acodec || '',
|
||
sizeEstimate,
|
||
label: labelParts.filter(Boolean).join(' '),
|
||
};
|
||
});
|
||
// Deduplicate by id
|
||
const seen = new Set();
|
||
const out = [];
|
||
for (const m of mapped) {
|
||
if (!m.id || seen.has(m.id)) continue;
|
||
seen.add(m.id);
|
||
out.push(m);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// Routes under /api
|
||
|
||
// -------------------- YouTube simple cache (GET) --------------------
|
||
// YouTube API key rotation and error handling (similar to Angular service)
|
||
const ytKeys = (() => {
|
||
const out = [];
|
||
try {
|
||
const raw = process.env.YOUTUBE_API_KEYS;
|
||
if (raw && String(raw).trim() && String(raw).trim() !== 'undefined' && String(raw).trim() !== 'null') {
|
||
const s = String(raw).trim();
|
||
if (s.startsWith('[')) {
|
||
try {
|
||
const arr = JSON.parse(s);
|
||
if (Array.isArray(arr)) out.push(...arr.map(v => String(v || '').trim()).filter(Boolean));
|
||
} catch {}
|
||
} else {
|
||
out.push(...s.split(',').map(v => String(v || '').trim()).filter(Boolean));
|
||
}
|
||
}
|
||
} catch {}
|
||
const single = process.env.YOUTUBE_API_KEY;
|
||
if (single && String(single).trim()) out.push(String(single).trim());
|
||
return Array.from(new Set(out.filter(Boolean)));
|
||
})();
|
||
|
||
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);
|
||
|
||
// Simple in-memory response cache for the YouTube proxy (URL -> { ts, data })
|
||
// TTL configurable via YT_CACHE_TTL_MS (default 5 min). Cap to avoid unbounded growth.
|
||
const YT_CACHE_TTL_MS = Number(process.env.YT_CACHE_TTL_MS || 5 * 60 * 1000);
|
||
const YT_CACHE_MAX_ENTRIES = Number(process.env.YT_CACHE_MAX_ENTRIES || 500);
|
||
const ytCache = new Map();
|
||
|
||
function ytCacheGet(key) {
|
||
const hit = ytCache.get(key);
|
||
if (!hit) return null;
|
||
// Refresh recency for a naive LRU behavior
|
||
ytCache.delete(key);
|
||
ytCache.set(key, hit);
|
||
if ((Date.now() - hit.ts) >= YT_CACHE_TTL_MS) {
|
||
ytCache.delete(key);
|
||
return null;
|
||
}
|
||
return hit;
|
||
}
|
||
|
||
function ytCacheSet(key, value) {
|
||
if (ytCache.has(key)) ytCache.delete(key);
|
||
ytCache.set(key, value);
|
||
while (ytCache.size > YT_CACHE_MAX_ENTRIES) {
|
||
const oldest = ytCache.keys().next().value;
|
||
if (oldest === undefined) break;
|
||
ytCache.delete(oldest);
|
||
}
|
||
}
|
||
|
||
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}`);
|
||
}
|
||
|
||
function isYouTubeKeyFailure(status, data) {
|
||
try {
|
||
const reason = data?.error?.errors?.[0]?.reason || '';
|
||
const message = String(data?.error?.message || '');
|
||
if (status === 400 && (reason === 'API_KEY_INVALID' || /api key (expired|invalid)/i.test(message))) return true;
|
||
if (status === 403 && /quota|rateLimit|dailyLimit|userRateLimit/i.test(reason + ' ' + message)) return true;
|
||
} catch {}
|
||
return false;
|
||
}
|
||
|
||
r.get('/yt/*', async (req, res) => {
|
||
try {
|
||
const googlePath = req.originalUrl.replace(/^\/api\/yt/, '');
|
||
// Cache key WITHOUT any client-supplied key (évite la fragmentation + fuite de clé en cache)
|
||
const cacheUrl = new URL(`https://www.googleapis.com${googlePath}`);
|
||
cacheUrl.searchParams.delete('key');
|
||
const cacheKey = cacheUrl.toString();
|
||
const now = Date.now();
|
||
const cached = ytCacheGet(cacheKey);
|
||
|
||
// Check if we have cached data and it's still valid (only success is cached)
|
||
if (cached) {
|
||
return res.status(cached.status || 200).json(cached.data);
|
||
}
|
||
|
||
let lastStatus = 503;
|
||
let lastData = { error: 'youtube_api_key_unavailable' };
|
||
const tried = new Set();
|
||
// Try each configured server key in turn (rotation on expired/quota keys)
|
||
const keysToTry = [...ytKeys];
|
||
if (keysToTry.length === 0) {
|
||
console.warn('[YouTube API] No API key available');
|
||
return res.status(503).json(lastData);
|
||
}
|
||
for (let i = 0; i < keysToTry.length; i++) {
|
||
const key = getActiveYouTubeKey();
|
||
if (!key || tried.has(key)) continue;
|
||
tried.add(key);
|
||
|
||
// Add API key to the URL (server key is source of truth; ignore client key)
|
||
const url = new URL(`https://www.googleapis.com${googlePath}`);
|
||
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);
|
||
|
||
if (status < 400) {
|
||
// Only cache successful responses (jamais d'erreurs : une clé expirée
|
||
// ne doit pas polluer le cache pour les autres clés)
|
||
ytCacheSet(cacheKey, { ts: now, data, status, isError: false });
|
||
return res.status(status).json(data);
|
||
}
|
||
lastStatus = status;
|
||
lastData = data;
|
||
if (isYouTubeKeyFailure(status, data)) {
|
||
banYouTubeKey(key);
|
||
continue; // try next key
|
||
}
|
||
return res.status(status).json(data);
|
||
}
|
||
return res.status(lastStatus).json(lastData);
|
||
} 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) --------------------
|
||
// Cache mémoire pour les vidéos connexes InnerTube (watch-next) : TTL 1h, LRU 200.
|
||
const YT_RELATED_TTL_MS = Number(process.env.YT_RELATED_TTL_MS || 60 * 60 * 1000);
|
||
const ytRelatedCache = new Map();
|
||
function ytRelatedCacheSet(key, items) {
|
||
if (ytRelatedCache.has(key)) ytRelatedCache.delete(key);
|
||
ytRelatedCache.set(key, { ts: Date.now(), items });
|
||
while (ytRelatedCache.size > 200) { const o = ytRelatedCache.keys().next().value; if (o === undefined) break; ytRelatedCache.delete(o); }
|
||
}
|
||
// 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 channelId = meta.channel_id || meta.uploader_id || meta.channel_url?.split('/').filter(Boolean).pop() || '';
|
||
const channelExternalId = channelId || meta.channel || meta.uploader || '';
|
||
const uploaderUrl = meta.channel_url || meta.uploader_url || '';
|
||
// Best-effort avatar: yt-dlp ne fournit pas l'avatar de chaîne, on tente
|
||
// l'API YouTube Data (si clé dispo) puis le registre de chaînes (cache 6h).
|
||
let uploaderAvatar = '';
|
||
let subscribers = 0;
|
||
try {
|
||
if (String(provider) === 'youtube' && channelId) {
|
||
const yKey = (typeof getActiveYouTubeKey === 'function') ? getActiveYouTubeKey() : null;
|
||
if (yKey) {
|
||
const params = new URLSearchParams({ part: 'snippet,statistics', id: String(channelId), key: String(yKey) });
|
||
const resp = await fetch(`https://www.googleapis.com/youtube/v3/channels?${params.toString()}`);
|
||
if (resp.ok) {
|
||
const data = await resp.json().catch(() => ({}));
|
||
const item = data?.items?.[0];
|
||
const thumbs = item?.snippet?.thumbnails || {};
|
||
uploaderAvatar = thumbs.high?.url || thumbs.medium?.url || thumbs.default?.url || '';
|
||
const subsRaw = item?.statistics?.subscriberCount;
|
||
if (subsRaw != null) subscribers = Number(subsRaw) || 0;
|
||
} else if (resp.status === 403 || resp.status === 400) {
|
||
try { banYouTubeKey(yKey); } catch {}
|
||
}
|
||
}
|
||
}
|
||
// Fallback générique via le registre (yt/dm/tw/pt/od/ru) pour avatar + subs.
|
||
if (!uploaderAvatar && channelExternalId) {
|
||
const shortToRegistry = { youtube: 'yt', dailymotion: 'dm', twitch: 'tw', peertube: 'pt', odysee: 'od', rumble: 'ru' };
|
||
const regProvider = shortToRegistry[String(provider)] || String(provider);
|
||
const adapter = (typeof getChannelAdapter === 'function') ? getChannelAdapter(regProvider) : null;
|
||
if (adapter && typeof adapter.fetchChannelById === 'function') {
|
||
const ch = await adapter.fetchChannelById(String(channelExternalId));
|
||
if (ch?.avatarUrl) uploaderAvatar = ch.avatarUrl;
|
||
if (typeof ch?.subsCount === 'number' && !subscribers) subscribers = ch.subsCount;
|
||
}
|
||
}
|
||
} catch {}
|
||
const out = {
|
||
videoId,
|
||
title: meta.title || '',
|
||
thumbnail: meta.thumbnail || (Array.isArray(meta.thumbnails) && meta.thumbnails.length ? meta.thumbnails[meta.thumbnails.length - 1].url || meta.thumbnails[0].url : ''),
|
||
uploaderName: meta.uploader || meta.channel || '',
|
||
uploaderUrl,
|
||
uploaderAvatar,
|
||
channelId: channelId || undefined,
|
||
channelExternalId: channelExternalId || undefined,
|
||
subscribers,
|
||
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',
|
||
};
|
||
// Step 18 : vidéos connexes façon SmartTube (watch-next InnerTube, best-effort, 0 quota).
|
||
// N'implique que YouTube ; toute erreur -> `related: []`, la réponse reste 200.
|
||
if (String(provider) === 'youtube' && req.query.related !== '0') {
|
||
try {
|
||
const { getRelatedViaInnerTube } = await import('./providers/youtube-innertube.mjs');
|
||
const relKey = `related:${videoId}`;
|
||
const cached = ytRelatedCache.get(relKey);
|
||
if (cached && (Date.now() - cached.ts) < YT_RELATED_TTL_MS) {
|
||
out.related = cached.items;
|
||
} else {
|
||
const items = await getRelatedViaInnerTube(videoId, 24).catch(() => []);
|
||
out.related = items;
|
||
ytRelatedCacheSet(relKey, items);
|
||
}
|
||
} catch { out.related = []; }
|
||
}
|
||
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).
|
||
// Les lectures (polling jobs) et écritures ont des seaux séparés : le polling
|
||
// ne doit jamais affamer les suppressions ni les listes de formats.
|
||
r.use('/download', authMiddlewareCookieAware);
|
||
|
||
// List available formats for a given video (cache 10 min : un dump = 5-15 s)
|
||
r.get('/download/:provider/:videoId/formats', downloadFormatsLimiter, 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 cacheKey = `formats:${provider}:${videoId}:${instance || ''}:${slug || ''}:${sourceUrl || ''}`;
|
||
const cached = formatsCacheGet(cacheKey);
|
||
if (cached) return res.json(cached);
|
||
const url = providerUrlFrom(provider, videoId, { instance, slug, sourceUrl });
|
||
const raw = await youtubedl(url, { dumpSingleJson: true, noWarnings: true, noCheckCertificates: true, skipDownload: true, ...ytdlpNetOpts() });
|
||
const meta = (typeof raw === 'string') ? JSON.parse(raw || '{}') : (raw || {});
|
||
const formats = formatListFromMeta(meta);
|
||
const out = { url, formats, title: meta?.title || '', duration: meta?.duration || 0 };
|
||
formatsCacheSet(cacheKey, out);
|
||
return res.json(out);
|
||
} 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).slice(0, 300) });
|
||
}
|
||
});
|
||
|
||
// Start a download job
|
||
r.post('/download/:provider/:videoId', downloadWriteLimiter, 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';
|
||
// Concurrency quota per user (DB-backed so it survives restarts)
|
||
const activeCount = countActiveDownloadJobs(userId);
|
||
if (activeCount >= Number(process.env.DOWNLOAD_MAX_CONCURRENT || 2)) {
|
||
return res.status(429).json({ error: 'too_many_downloads' });
|
||
}
|
||
// Storage quota: sum of completed files within the retention window
|
||
if (DOWNLOAD_STORAGE_QUOTA_BYTES > 0) {
|
||
const used = sumCompletedDownloadBytes(userId, DOWNLOAD_QUOTA_WINDOW_MS > 0 ? Date.now() - DOWNLOAD_QUOTA_WINDOW_MS : 0);
|
||
if (used >= DOWNLOAD_STORAGE_QUOTA_BYTES) {
|
||
return res.status(429).json({ error: 'storage_quota_exceeded', usedBytes: used, quotaBytes: DOWNLOAD_STORAGE_QUOTA_BYTES });
|
||
}
|
||
}
|
||
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();
|
||
// Per-user sub-directory keeps files isolated and easy to purge
|
||
const userDir = userDownloadsDir(userId);
|
||
// Keep the produced filename simple and rename after completion
|
||
const tmpOutTpl = path.join(userDir, `${jobId}.%(ext)s`);
|
||
// Fetch metadata to build the final filename (title & resolution)
|
||
let expectedBaseName = '';
|
||
let metaTitle = '';
|
||
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 || {});
|
||
metaTitle = meta?.title || '';
|
||
const title = sanitizeFileName(metaTitle || `${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);
|
||
try {
|
||
insertDownloadJob({ id: jobId, userId, provider, videoId, title: metaTitle, formatId: chosenFormatId, audioOnly: !!audioOnly, url });
|
||
} catch (e) {
|
||
console.warn('[downloads] persist job failed:', e?.message || e);
|
||
}
|
||
|
||
// 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;
|
||
updateDownloadJob(jobId, { state: 'running' });
|
||
|
||
// Throttled DB progress sync (avoid hammering SQLite with every progress line)
|
||
let lastDbSync = 0;
|
||
const syncProgressDb = (force = false) => {
|
||
const now = Date.now();
|
||
if (!force && now - lastDbSync < 5000) return;
|
||
lastDbSync = now;
|
||
try { updateDownloadJob(jobId, { state: job.state, progress: job.progress || 0 }); } catch {}
|
||
};
|
||
|
||
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();
|
||
syncProgressDb();
|
||
}
|
||
if (/\[Merger]/.test(s)) {
|
||
job.state = 'merging';
|
||
syncProgressDb(true);
|
||
}
|
||
};
|
||
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();
|
||
updateDownloadJob(jobId, { state: 'failed', error: job.error });
|
||
});
|
||
|
||
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();
|
||
updateDownloadJob(jobId, { state: 'failed', error: job.error });
|
||
return;
|
||
}
|
||
// Find produced file in the user's download directory
|
||
const files = fs.readdirSync(userDir).filter(f => f.startsWith(`${jobId}.`));
|
||
if (files.length > 0) {
|
||
const f = files[0];
|
||
const p = path.join(userDir, 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(userDir, 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();
|
||
updateDownloadJob(jobId, { state: 'completed', progress: 100, fileName: job.fileName, fileExt: ext, fileSize: st.size, filePath: job.filePath, completedAt: Date.now() });
|
||
} else {
|
||
job.state = 'failed';
|
||
job.error = 'file_not_found_after_download';
|
||
job.updatedAt = new Date().toISOString();
|
||
updateDownloadJob(jobId, { state: 'failed', error: job.error });
|
||
}
|
||
} catch (err) {
|
||
job.state = 'failed';
|
||
job.error = String(err?.message || err);
|
||
updateDownloadJob(jobId, { state: 'failed', error: job.error });
|
||
}
|
||
});
|
||
|
||
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) });
|
||
}
|
||
});
|
||
|
||
// List current user's download jobs (persistent queue history)
|
||
r.get('/download/jobs', downloadReadLimiter, (req, res) => {
|
||
try {
|
||
const userId = req.user?.id || 'anonymous';
|
||
const limit = Math.min(200, Math.max(1, Number(req.query.limit || 50)));
|
||
const offset = Math.max(0, Number(req.query.offset || 0));
|
||
const state = typeof req.query.state === 'string' ? req.query.state : undefined;
|
||
const items = listDownloadJobs({ userId, limit, offset, state });
|
||
const quota = DOWNLOAD_STORAGE_QUOTA_BYTES > 0
|
||
? {
|
||
usedBytes: sumCompletedDownloadBytes(userId, DOWNLOAD_QUOTA_WINDOW_MS > 0 ? Date.now() - DOWNLOAD_QUOTA_WINDOW_MS : 0),
|
||
quotaBytes: DOWNLOAD_STORAGE_QUOTA_BYTES,
|
||
}
|
||
: null;
|
||
return res.json({ items, quota });
|
||
} catch (e) {
|
||
return res.status(500).json({ error: 'download_jobs_list_failed', details: String(e?.message || e) });
|
||
}
|
||
});
|
||
|
||
// Load a job row (DB) enforcing ownership; falls back gracefully
|
||
function loadOwnedJob(req) {
|
||
const { id } = req.params;
|
||
const userId = req.user?.id || 'anonymous';
|
||
const row = getDownloadJob(id);
|
||
if (!row) return { error: 'not_found' };
|
||
if (row.userId !== userId) return { error: 'forbidden' };
|
||
return { row };
|
||
}
|
||
|
||
// Retry a failed/interrupted job (reprise)
|
||
r.post('/download/jobs/:id/retry', downloadWriteLimiter, async (req, res) => {
|
||
try {
|
||
const { row, error } = loadOwnedJob(req);
|
||
if (error === 'not_found' || error === 'forbidden') return res.status(error === 'forbidden' ? 403 : 404).json({ error });
|
||
if (!['failed', 'interrupted'].includes(row.state)) {
|
||
return res.status(400).json({ error: 'job_not_retryable', state: row.state });
|
||
}
|
||
const activeCount = countActiveDownloadJobs(row.userId);
|
||
if (activeCount >= Number(process.env.DOWNLOAD_MAX_CONCURRENT || 2)) {
|
||
return res.status(429).json({ error: 'too_many_downloads' });
|
||
}
|
||
if (!row.url) return res.status(400).json({ error: 'job_url_missing' });
|
||
|
||
const userDir = userDownloadsDir(row.userId);
|
||
const jobId = row.id;
|
||
// Clean any partial file left by the previous attempt
|
||
try {
|
||
for (const f of fs.readdirSync(userDir).filter(f => f.startsWith(`${jobId}.`))) {
|
||
fs.unlinkSync(path.join(userDir, f));
|
||
}
|
||
} catch {}
|
||
|
||
const args = {
|
||
output: path.join(userDir, `${jobId}.%(ext)s`),
|
||
ffmpegLocation: ffmpegPath || undefined,
|
||
noWarnings: true,
|
||
noCheckCertificates: true,
|
||
preferFreeFormats: true,
|
||
progress: true,
|
||
newline: true,
|
||
};
|
||
if (row.audioOnly) {
|
||
args.extractAudio = true;
|
||
args.audioFormat = 'm4a';
|
||
} else if (row.formatId) {
|
||
args.format = String(row.formatId);
|
||
}
|
||
|
||
const inMemory = jobs.get(jobId) || {};
|
||
const job = {
|
||
id: jobId,
|
||
userId: row.userId,
|
||
provider: row.provider,
|
||
videoId: row.videoId,
|
||
state: 'queued',
|
||
progress: 0,
|
||
createdAt: inMemory.createdAt || new Date(row.createdAt).toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
filePath: null,
|
||
fileExt: null,
|
||
fileSize: null,
|
||
fileName: null,
|
||
expectedBaseName: inMemory.expectedBaseName || `${providerLabel(row.provider)}_${sanitizeFileName(row.title || row.videoId)}`,
|
||
error: null,
|
||
url: row.url,
|
||
};
|
||
jobs.set(jobId, job);
|
||
updateDownloadJob(jobId, { state: 'running', progress: 0, error: null });
|
||
|
||
const cp = youtubedl.exec(row.url, args, { shell: false });
|
||
job.state = 'running';
|
||
job.proc = cp;
|
||
|
||
let lastDbSync = 0;
|
||
const syncProgressDb = (force = false) => {
|
||
const now = Date.now();
|
||
if (!force && now - lastDbSync < 5000) return;
|
||
lastDbSync = now;
|
||
try { updateDownloadJob(jobId, { state: job.state, progress: job.progress || 0 }); } catch {}
|
||
};
|
||
|
||
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();
|
||
syncProgressDb();
|
||
}
|
||
if (/\[Merger]/.test(s)) {
|
||
job.state = 'merging';
|
||
syncProgressDb(true);
|
||
}
|
||
};
|
||
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();
|
||
updateDownloadJob(jobId, { state: 'failed', error: job.error });
|
||
});
|
||
|
||
cp.on('close', (code) => {
|
||
try {
|
||
if (code !== 0) {
|
||
job.state = 'failed';
|
||
job.error = `yt-dlp exited with code ${code}`;
|
||
updateDownloadJob(jobId, { state: 'failed', error: job.error });
|
||
return;
|
||
}
|
||
const files = fs.readdirSync(userDir).filter(f => f.startsWith(`${jobId}.`));
|
||
if (files.length > 0) {
|
||
const f = files[0];
|
||
const p = path.join(userDir, f);
|
||
const st = fs.statSync(p);
|
||
const ext = f.split('.').pop();
|
||
let finalName = f;
|
||
try {
|
||
const base = job.expectedBaseName ? sanitizeFileName(job.expectedBaseName) : `${providerLabel(job.provider)}_${job.videoId}`;
|
||
const uniq = uniquePath(userDir, base, ext);
|
||
finalName = uniq.fileName;
|
||
fs.renameSync(p, uniq.filePath);
|
||
job.filePath = uniq.filePath;
|
||
job.fileName = finalName;
|
||
} catch {
|
||
job.filePath = p;
|
||
job.fileName = f;
|
||
}
|
||
job.fileExt = ext;
|
||
job.fileSize = st.size;
|
||
job.state = 'completed';
|
||
job.progress = 100;
|
||
updateDownloadJob(jobId, { state: 'completed', progress: 100, fileName: job.fileName, fileExt: ext, fileSize: st.size, filePath: job.filePath, completedAt: Date.now() });
|
||
} else {
|
||
job.state = 'failed';
|
||
job.error = 'file_not_found_after_download';
|
||
updateDownloadJob(jobId, { state: 'failed', error: job.error });
|
||
}
|
||
} catch (err) {
|
||
job.state = 'failed';
|
||
job.error = String(err?.message || err);
|
||
updateDownloadJob(jobId, { state: 'failed', error: job.error });
|
||
}
|
||
});
|
||
|
||
return res.status(202).json({ jobId });
|
||
} catch (e) {
|
||
return res.status(500).json({ error: 'retry_failed', details: String(e?.message || e) });
|
||
}
|
||
});
|
||
|
||
// Job status — DB first (persistent, survives restarts), memory fallback
|
||
r.get('/download/jobs/:id', downloadReadLimiter, (req, res) => {
|
||
const { id } = req.params;
|
||
const { row, error } = loadOwnedJob(req);
|
||
if (error === 'forbidden') return res.status(403).json({ error });
|
||
if (row) {
|
||
const { filePath, ...safe } = row; // never leak absolute server paths
|
||
return res.json(safe);
|
||
}
|
||
if (error === 'not_found') {
|
||
const job = jobs.get(id);
|
||
if (job && (job.userId === (req.user?.id || 'anonymous'))) {
|
||
const { proc, expectedBaseName, filePath, ...safe } = job;
|
||
return res.json(safe);
|
||
}
|
||
}
|
||
return res.status(404).json({ error: 'not_found' });
|
||
});
|
||
|
||
// Stream the file (supports Range) — path resolved from the DB row (owner only)
|
||
r.get('/download/jobs/:id/file', downloadReadLimiter, (req, res) => {
|
||
const { id } = req.params;
|
||
const { row, error } = loadOwnedJob(req);
|
||
if (error === 'forbidden') return res.status(403).json({ error });
|
||
if (!row || row.state !== 'completed' || !row.filePath) return res.status(404).json({ error: 'not_found' });
|
||
const filePath = row.filePath;
|
||
if (!fs.existsSync(filePath)) return res.status(410).json({ error: 'file_gone' });
|
||
const stat = fs.statSync(filePath);
|
||
const total = stat.size;
|
||
const ext = row.fileExt || 'mp4';
|
||
const ctype = guessContentTypeByExt(ext);
|
||
const fileName = sanitizeFileName(row.fileName || `${row.provider}-${row.videoId}.${ext}`);
|
||
|
||
res.setHeader('Content-Type', ctype);
|
||
res.setHeader('Accept-Ranges', 'bytes');
|
||
// ?inline=1 -> lecture dans le navigateur (page "Lire"), sinon téléchargement.
|
||
const inline = req.query.inline === '1' || req.query.inline === 'true';
|
||
res.setHeader('Content-Disposition', `${inline ? 'inline' : '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 (owner only) — also removes the DB row
|
||
r.delete('/download/jobs/:id', downloadWriteLimiter, (req, res) => {
|
||
const { id } = req.params;
|
||
const { row, error } = loadOwnedJob(req);
|
||
if (error === 'forbidden') return res.status(403).json({ error });
|
||
const job = jobs.get(id);
|
||
if (!row && !job) return res.status(404).json({ error: 'not_found' });
|
||
try {
|
||
if (job?.proc && typeof job.proc.kill === 'function') {
|
||
job.proc.kill('SIGKILL');
|
||
}
|
||
} catch {}
|
||
const filePath = row?.filePath || job?.filePath;
|
||
try {
|
||
if (filePath && fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
||
} catch {}
|
||
jobs.delete(id);
|
||
if (row) {
|
||
try { deleteDownloadJob({ userId: row.userId, id }); } catch {}
|
||
}
|
||
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();
|
||
const ua = req.headers['user-agent'] || '';
|
||
insertSession({ id: sessionId, userId: id, refreshTokenHash, isRemember: false, userAgent: ua, deviceInfo: '', ip: getClientIp(req), expiresAt });
|
||
setUserLastLogin(id);
|
||
insertLoginAudit({ userId: id, username, ip: getClientIp(req), userAgent: ua, success: true });
|
||
setRefreshCookies(res, { sessionId, token: refreshToken, days }, req);
|
||
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 }, req);
|
||
const accessToken = makeAccessToken(user.id, sessionId);
|
||
return res.json({ user: { id: user.id, username: user.username, email: user.email }, accessToken, sessionId });
|
||
});
|
||
|
||
// -------------------- Connexion avec Google (compte Google au lieu du compte interne) --------------------
|
||
// Même OAuth que l'import : le consentement `youtube.readonly` sert ensuite
|
||
// directement à l'import abonnements + favoris (aucun 2e consentement).
|
||
// Comptes Google-only : password_hash aléatoire (login mot de passe impossible).
|
||
|
||
function frontBaseForOAuth(req) {
|
||
try {
|
||
const explicit = String(process.env.OAUTH_APP_BASE_URL || '').trim().replace(/\/+$/, '');
|
||
if (explicit) return explicit;
|
||
const proto = String(req.headers?.['x-forwarded-proto'] || req.protocol || 'http').split(',')[0].trim() || 'http';
|
||
const host = String(req.headers?.['x-forwarded-host'] || req.headers?.host || '').trim();
|
||
const publicPort = String(process.env.OAUTH_PUBLIC_PORT || process.env.HOST_PORT || '').trim();
|
||
if (host && publicPort) return `${proto}://${host.split(':')[0]}:${publicPort}`;
|
||
if (host) return `${proto}://${host}`;
|
||
} catch {}
|
||
return 'http://localhost:4200';
|
||
}
|
||
|
||
r.get('/auth/google/url', loginLimiter, (req, res) => {
|
||
try {
|
||
const status = oauthStatus().google;
|
||
if (!status?.configured) return res.status(503).json({ error: 'google_oauth_not_configured', missing: status?.missing || [] });
|
||
const state = createOAuthState(null, 'google', 'login');
|
||
return res.json({ url: buildAuthUrl('google', state, req) });
|
||
} catch (error) {
|
||
return res.status(error?.status || 500).json({ error: error?.message || 'google_auth_url_failed' });
|
||
}
|
||
});
|
||
|
||
/**
|
||
* Finalise un login Google à partir d'un state `login` déjà consommé :
|
||
* échange code → profil → find-or-create → session + cookies → front.
|
||
* Partagé par /api/auth/google/callback ET /api/oauth/google/callback
|
||
* (buildAuthUrl n'enregistre qu'une seule redirect URI côté Google).
|
||
*/
|
||
async function completeGoogleLogin(req, res, frontBase, entry, code) {
|
||
const fail = (code) => res.redirect(302, `${frontBase}/auth/login?error=${encodeURIComponent(code)}`);
|
||
try {
|
||
const tokens = await exchangeCode('google', String(code), req);
|
||
if (!tokens?.accessToken) return fail('google_token_failed');
|
||
const profile = await fetchGoogleProfile(tokens.accessToken);
|
||
if (!profile?.id) return fail('google_profile_failed');
|
||
const email = String(profile.email || '').trim() || null;
|
||
|
||
// 1) Compte déjà lié à ce Google sub → lui. 2) Email identique → on lie.
|
||
// 3) Sinon création (username dérivé, suffixe si collision).
|
||
let user = getUserByOAuth('google', profile.id);
|
||
if (!user && email) user = getUserByEmail(email);
|
||
if (!user) {
|
||
const base = String(profile.displayName || (email ? email.split('@')[0] : 'google') || 'google')
|
||
.trim().replace(/\s+/g, ' ').slice(0, 40) || 'google-user';
|
||
let username = base;
|
||
let n = 0;
|
||
while (getUserByUsername(username)) {
|
||
n++;
|
||
username = `${base} ${n}`.slice(0, 40);
|
||
if (n > 50) return fail('username_taken');
|
||
}
|
||
const id = cryptoRandomUUID();
|
||
await insertUser({ id, username, email, passwordHash: `oauth-google:${cryptoRandomId()}` });
|
||
user = getUserById(id);
|
||
}
|
||
if (!user) return fail('google_login_failed');
|
||
upsertOAuthConnection({
|
||
userId: user.id, provider: 'google', externalUserId: profile.id,
|
||
displayName: profile.displayName || null, avatarUrl: profile.avatarUrl || null,
|
||
accessToken: tokens.accessToken, refreshToken: tokens.refreshToken,
|
||
expiresAt: tokens.expiresAt, scopes: tokens.scopes,
|
||
});
|
||
|
||
const sessionId = cryptoRandomId();
|
||
const refreshToken = cryptoRandomId();
|
||
const refreshTokenHash = await hashToken(refreshToken);
|
||
const days = REMEMBER_TTL_DAYS;
|
||
const expiresAt = new Date(Date.now() + days * 86400_000).toISOString();
|
||
const ua = req.headers['user-agent'] || '';
|
||
insertSession({ id: sessionId, userId: user.id, refreshTokenHash, isRemember: true, userAgent: ua, deviceInfo: '', ip: getClientIp(req), expiresAt });
|
||
setUserLastLogin(user.id);
|
||
insertLoginAudit({ userId: user.id, username: user.username, ip: getClientIp(req), userAgent: ua, success: true, reason: 'google' });
|
||
setRefreshCookies(res, { sessionId, token: refreshToken, days }, req);
|
||
return res.redirect(302, `${frontBase}/auth/google/callback?connected=1`);
|
||
} catch {
|
||
return fail('google_login_failed');
|
||
}
|
||
}
|
||
|
||
r.get('/auth/google/callback', loginLimiter, async (req, res) => {
|
||
const frontBase = frontBaseForOAuth(req);
|
||
const fail = (code) => res.redirect(302, `${frontBase}/auth/login?error=${encodeURIComponent(code)}`);
|
||
try {
|
||
if (req.query?.error) return fail(String(req.query.error_description || req.query.error));
|
||
const { code, state } = req.query || {};
|
||
if (!code || !state) return fail('google_missing_code_or_state');
|
||
const entry = consumeOAuthState(String(state));
|
||
if (!entry || entry.provider !== 'google' || entry.purpose !== 'login') return fail('google_invalid_state');
|
||
return await completeGoogleLogin(req, res, frontBase, entry, String(code));
|
||
} catch {
|
||
return fail('google_login_failed');
|
||
}
|
||
});
|
||
|
||
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 }, req);
|
||
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 = getPreferencesForApi(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 = getPreferencesForApi(req.user.id);
|
||
return res.json(prefs || {});
|
||
});
|
||
|
||
// --- Telemetry: minimal anonymous product events (Step 13) ---
|
||
const telemetryLimiter = rateLimit({ windowMs: 60 * 1000, max: 120, standardHeaders: true, legacyHeaders: false });
|
||
|
||
r.post('/telemetry/events', authMiddleware, telemetryLimiter, (req, res) => {
|
||
const { event, meta } = req.body || {};
|
||
if (!event || typeof event !== 'string') return res.status(400).json({ error: 'event_required' });
|
||
// Whitelist known event names to keep the table clean
|
||
const allowed = new Set(['search_submit', 'provider_picker_open', 'provider_apply', 'at_autocomplete_use', 'quick_menu_open', 'suggest_shown', 'suggest_used']);
|
||
if (!allowed.has(event)) return res.status(400).json({ error: 'unknown_event' });
|
||
const row = insertTelemetryEvent({ userId: req.user.id, event, meta: (meta && typeof meta === 'object') ? meta : null });
|
||
return res.status(201).json(row || { ok: true });
|
||
});
|
||
|
||
r.get('/telemetry/events', authMiddleware, (req, res) => {
|
||
// Admin-ish introspection: only the user's own events
|
||
const rows = listTelemetryEvents({
|
||
event: typeof req.query.event === 'string' ? req.query.event : undefined,
|
||
limit: Math.min(500, Number(req.query.limit || 100)),
|
||
since: typeof req.query.since === 'string' ? req.query.since : undefined,
|
||
});
|
||
const mine = rows.filter(row => row.userId === req.user.id);
|
||
return res.json(mine);
|
||
});
|
||
|
||
r.get('/telemetry/summary', authMiddleware, (req, res) => {
|
||
const since = typeof req.query.since === 'string' ? req.query.since : undefined;
|
||
const events = {};
|
||
for (const name of ['search_submit', 'provider_picker_open', 'provider_apply', 'at_autocomplete_use', 'quick_menu_open', 'suggest_shown', 'suggest_used']) {
|
||
events[name] = countTelemetryEvents({ event: name, since });
|
||
}
|
||
return res.json({ events });
|
||
});
|
||
|
||
// --- 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);
|
||
});
|
||
|
||
// Import en lot des recherches Takeout (historique-recherches.html).
|
||
r.post('/user/history/search/batch', authMiddleware, oauthLimiter, (req, res) => {
|
||
try {
|
||
const items = Array.isArray(req.body?.items) ? req.body.items : [];
|
||
if (!items.length) return res.status(400).json({ error: 'items_required' });
|
||
if (items.length > 1000) return res.status(400).json({ error: 'batch_too_large' });
|
||
let imported = 0;
|
||
let skipped = 0;
|
||
for (const it of items) {
|
||
const query = String(it?.query || '').trim().slice(0, 300);
|
||
if (!query) { skipped++; continue; }
|
||
try {
|
||
insertSearchHistoryAt({ userId: req.user.id, query, createdAt: it?.createdAt });
|
||
imported++;
|
||
} catch { skipped++; }
|
||
}
|
||
return res.json({ imported, skipped, total: items.length });
|
||
} catch {
|
||
return res.status(500).json({ error: 'search_batch_failed' });
|
||
}
|
||
});
|
||
|
||
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 provider = typeof req.query.provider === 'string' ? req.query.provider : undefined;
|
||
const rows = listSearchHistory({ userId: req.user.id, limit, before, q, provider });
|
||
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();
|
||
});
|
||
|
||
// --- 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 provider = typeof req.query.provider === 'string' ? req.query.provider : undefined;
|
||
const rows = listWatchHistory({ userId: req.user.id, limit, before, q, provider });
|
||
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();
|
||
});
|
||
|
||
// --- History: Transcripts (conservés, rejoués sans régénération) ---
|
||
r.post('/user/history/transcripts', authMiddleware, (req, res) => {
|
||
const { provider, videoId, title, thumbnail, lang, languages, lines } = req.body || {};
|
||
if (!provider || !videoId || !lang) {
|
||
return res.status(400).json({ error: 'provider, videoId and lang are required' });
|
||
}
|
||
if (!Array.isArray(lines) || !lines.length) {
|
||
return res.status(400).json({ error: 'lines required' });
|
||
}
|
||
try {
|
||
const row = upsertTranscriptHistory({
|
||
userId: req.user.id, provider, videoId, title, thumbnail, lang, languages, lines,
|
||
});
|
||
if (!row) return res.status(400).json({ error: 'invalid_transcript' });
|
||
return res.status(201).json(row);
|
||
} catch (e) {
|
||
return res.status(500).json({ error: 'save_failed', details: String(e?.message || e) });
|
||
}
|
||
});
|
||
|
||
r.get('/user/history/transcripts', 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 provider = typeof req.query.provider === 'string' ? req.query.provider : undefined;
|
||
const lang = typeof req.query.lang === 'string' ? req.query.lang : undefined;
|
||
try {
|
||
const rows = listTranscriptHistory({ userId: req.user.id, limit, before, q, provider, lang });
|
||
return res.json(rows);
|
||
} catch (e) {
|
||
return res.status(500).json({ error: 'list_failed', details: String(e?.message || e) });
|
||
}
|
||
});
|
||
|
||
r.get('/user/history/transcripts/:provider/:videoId', authMiddleware, (req, res) => {
|
||
const { provider, videoId } = req.params;
|
||
const lang = typeof req.query.lang === 'string' ? req.query.lang : undefined;
|
||
try {
|
||
const row = getTranscriptHistoryItem({ userId: req.user.id, provider, videoId, lang });
|
||
if (!row) return res.status(404).json({ available: false, error: 'not_found' });
|
||
return res.json({ available: true, fromHistory: true, ...row });
|
||
} catch (e) {
|
||
return res.status(500).json({ available: false, error: 'lookup_failed' });
|
||
}
|
||
});
|
||
|
||
r.delete('/user/history/transcripts/:id', authMiddleware, (req, res) => {
|
||
try {
|
||
deleteTranscriptHistoryById(req.user.id, req.params.id);
|
||
return res.status(204).end();
|
||
} catch (e) {
|
||
return res.status(500).json({ error: 'delete_failed', details: String(e?.message || e) });
|
||
}
|
||
});
|
||
|
||
r.delete('/user/history/transcripts', authMiddleware, (req, res) => {
|
||
if (String(req.query.all || '') !== '1') return res.status(400).json({ error: 'set all=1' });
|
||
const provider = typeof req.query.provider === 'string' && req.query.provider ? String(req.query.provider) : undefined;
|
||
try {
|
||
deleteAllTranscriptHistory(req.user.id, provider);
|
||
return res.status(204).end();
|
||
} catch (e) {
|
||
return res.status(500).json({ error: 'delete_failed', details: String(e?.message || e) });
|
||
}
|
||
});
|
||
|
||
// --- Likes ---
|
||
// NOTE : cookie-aware (et non Bearer seul) : le front s'authentifie via
|
||
// cookies httpOnly + Bearer en mémoire (perdu au F5) ; exiger le Bearer
|
||
// seul renvoyait 401 « Unauthorized » même connecté.
|
||
r.get('/user/likes', authMiddlewareCookieAware, (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', authMiddlewareCookieAware, 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', authMiddlewareCookieAware, (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', authMiddlewareCookieAware, (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' }));
|
||
// Step 17 : observabilité YouTube (mode, yt-dlp, cache, quota). Aucun secret exposé.
|
||
app.get(['/healthz', '/api/healthz'], async (_req, res) => {
|
||
try {
|
||
const [{ getYoutubeMetricsToday, countYoutubeCacheRows }, common] =
|
||
await Promise.all([import('./db.mjs'), import('./providers/youtube-common.mjs')]);
|
||
let ytdlpVersion = null;
|
||
let resolvedBin = null;
|
||
try {
|
||
resolvedBin = await common.resolveYtDlpBin();
|
||
const { stdout } = await execFileAsync(resolvedBin, ['--version'], { timeout: 10000 });
|
||
ytdlpVersion = String(stdout || '').trim().split('\n')[0].trim() || null;
|
||
} catch {}
|
||
const keys = common.getYouTubeKeys();
|
||
const banned = [];
|
||
try { for (const [k, until] of ytKeyBans || []) if (Date.now() < until) banned.push(`...${String(k).slice(-4)}`); } catch {}
|
||
res.json({
|
||
status: 'ok',
|
||
youtube: {
|
||
mode: getSearchMode(),
|
||
ytdlp: { bin: resolvedBin || getYtDlpBin(), version: ytdlpVersion, info: ytDlpInfo, binOk: Boolean(ytdlpVersion) },
|
||
antiban: {
|
||
cookiesFile: hasCookiesFile(),
|
||
poToken: Boolean(String(process.env.YT_PO_TOKEN || '').trim()),
|
||
egressProxy: Boolean(String(process.env.YT_EGRESS_PROXY || '').trim()),
|
||
},
|
||
cache: { ...ytScrapeCacheStats(), sqliteRows: countYoutubeCacheRows() },
|
||
metrics: { ...metricsSnapshot(), today: getYoutubeMetricsToday() },
|
||
keys: { count: keys.length, banned },
|
||
},
|
||
});
|
||
} catch (e) {
|
||
res.status(500).json({ status: 'error', error: String(e?.message || e) });
|
||
}
|
||
});
|
||
// Step 17 : trending YouTube sans clé (scrape) avec fallback [] propre.
|
||
app.get('/api/trending', async (req, res) => {
|
||
try {
|
||
const provider = String(req.query.provider || 'yt');
|
||
const limit = Math.min(50, Math.max(1, Number(req.query.limit || 24)));
|
||
if (provider !== 'yt') return res.status(400).json({ error: 'only yt supported in phase 1' });
|
||
const { getTrendingViaScrape } = await import('./providers/youtube-scrape.mjs');
|
||
const items = await getTrendingViaScrape(limit);
|
||
return res.json({ provider, items });
|
||
} catch (e) {
|
||
return res.json({ provider: 'yt', items: [], error: String(e?.code || e?.message || 'trending_failed') });
|
||
}
|
||
});
|
||
// 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)
|
||
// Idem transcript : exposé sous /api ET /proxy/api (fallback du front Watch).
|
||
app.use('/api/rumble', rumbleRouter);
|
||
app.use('/proxy/api/rumble', rumbleRouter);
|
||
|
||
// -------------------- Client config from environment --------------------
|
||
// WARNING: Values served here are exposed to the browser.
|
||
// Only browser-safe values belong here (e.g. referrer-restricted YouTube keys).
|
||
// Secrets like TWITCH_CLIENT_SECRET / GEMINI_API_KEY stay server-side and are
|
||
// consumed through dedicated server endpoints (/api/twitch-token, /api/ai/*).
|
||
function jsVal(v) { return JSON.stringify(v == null ? '' : v); }
|
||
app.get(['/assets/config.local.js', '/assets/config.js', '/config.js'], (_req, res) => {
|
||
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 {
|
||
// Accepte JSON array ('["k1","k2"]', fourni par ex. via compose) ou CSV ('k1,k2').
|
||
const raw = String(env.YOUTUBE_API_KEYS).trim();
|
||
let arr = [];
|
||
if (raw.startsWith('[')) {
|
||
try { arr = JSON.parse(raw); } catch { arr = []; }
|
||
if (!Array.isArray(arr)) arr = [];
|
||
} else {
|
||
arr = raw.split(',');
|
||
}
|
||
arr = arr.map(s => String(s || '').trim().replace(/^["'\[]+|["'\]]+$/g, '')).filter(Boolean);
|
||
if (arr.length) 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)};`);
|
||
// Intentionally NOT exposed: TWITCH_CLIENT_SECRET, GEMINI_API_KEY — proxy via /api/twitch-token & /api/ai/*
|
||
res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
|
||
res.send(lines.join('\n'));
|
||
});
|
||
|
||
// -------------------- Twitch app token (server-side) --------------------
|
||
// Exchanges TWITCH_CLIENT_ID + TWITCH_CLIENT_SECRET server-side and returns the
|
||
// app access token to the client. The secret never leaves the server.
|
||
let twitchAppToken = null; // { token, expiresAtMs, clientId }
|
||
let twitchAppTokenPromise = null;
|
||
|
||
async function fetchTwitchAppToken() {
|
||
const clientId = process.env.TWITCH_CLIENT_ID;
|
||
const clientSecret = process.env.TWITCH_CLIENT_SECRET;
|
||
if (!clientId || !clientSecret) {
|
||
throw Object.assign(new Error('twitch_not_configured'), { status: 503 });
|
||
}
|
||
const body = new URLSearchParams({ client_id: clientId, client_secret: clientSecret, grant_type: 'client_credentials' });
|
||
const resp = await axios.post('https://id.twitch.tv/oauth2/token', body.toString(), {
|
||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
timeout: 15000,
|
||
validateStatus: s => s >= 200 && s < 500,
|
||
});
|
||
if (resp.status !== 200 || !resp.data?.access_token) {
|
||
throw Object.assign(new Error('twitch_token_failed'), { status: 502 });
|
||
}
|
||
const expiresInSec = Number(resp.data.expires_in || 0);
|
||
twitchAppToken = {
|
||
token: resp.data.access_token,
|
||
// Refresh 2 minutes before actual expiry
|
||
expiresAtMs: Date.now() + Math.max(60, expiresInSec - 120) * 1000,
|
||
clientId,
|
||
};
|
||
return twitchAppToken;
|
||
}
|
||
|
||
// Le registre de chaînes réutilise le token Twitch du serveur (cache + refresh
|
||
// centralisés) pour résoudre titres/avatars des chaînes Twitch.
|
||
try {
|
||
setTwitchTokenProvider(async () => (await fetchTwitchAppToken()).token);
|
||
} catch {}
|
||
|
||
app.get('/api/twitch-token', async (_req, res) => {
|
||
try {
|
||
if (twitchAppToken && Date.now() < twitchAppToken.expiresAtMs) {
|
||
return res.json({ accessToken: twitchAppToken.token, clientId: twitchAppToken.clientId });
|
||
}
|
||
if (!twitchAppTokenPromise) {
|
||
twitchAppTokenPromise = fetchTwitchAppToken().finally(() => { twitchAppTokenPromise = null; });
|
||
}
|
||
const t = await twitchAppTokenPromise;
|
||
return res.json({ accessToken: t.token, clientId: t.clientId });
|
||
} catch (e) {
|
||
const status = e?.status || 502;
|
||
return res.status(status).json({ error: e?.message || 'twitch_token_failed' });
|
||
}
|
||
});
|
||
|
||
// -------------------- Gemini summarize (server-side) --------------------
|
||
// Keeps GEMINI_API_KEY on the server. Client calls POST /api/ai/summarize.
|
||
app.get('/api/ai/status', (_req, res) => {
|
||
const ready = Boolean(process.env.GEMINI_API_KEY);
|
||
return res.json({ ready, reason: ready ? undefined : 'GEMINI_API_KEY is not configured on the server.' });
|
||
});
|
||
|
||
const aiLimiter = rateLimit({
|
||
windowMs: 60 * 1000,
|
||
max: 10,
|
||
standardHeaders: true,
|
||
legacyHeaders: false,
|
||
});
|
||
|
||
app.post('/api/ai/summarize', aiLimiter, async (req, res) => {
|
||
try {
|
||
if (!process.env.GEMINI_API_KEY) {
|
||
return res.status(503).json({ error: 'gemini_not_configured' });
|
||
}
|
||
const comments = Array.isArray(req.body?.comments) ? req.body.comments.filter(c => typeof c === 'string') : [];
|
||
if (comments.length === 0) return res.status(400).json({ error: 'comments_required' });
|
||
const capped = comments.slice(0, 500).map(c => String(c).slice(0, 2000));
|
||
const commentsText = capped.join('\n- ');
|
||
const prompt = `Please summarize the following YouTube comments. Provide a concise, neutral overview of the general sentiment and the main topics discussed. Do not add any preamble or sign-off. Just provide the summary. Here are the comments:\n\n- ${commentsText}`;
|
||
const resp = await axios.post(
|
||
`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${encodeURIComponent(process.env.GEMINI_API_KEY)}`,
|
||
{ contents: [{ parts: [{ text: prompt }] }] },
|
||
{ timeout: 30000, validateStatus: s => s >= 200 && s < 500 }
|
||
);
|
||
if (resp.status !== 200) {
|
||
return res.status(resp.status).json({ error: 'gemini_upstream_error', details: resp.data });
|
||
}
|
||
const text = resp.data?.candidates?.[0]?.content?.parts?.map(p => p?.text).filter(Boolean).join('') || '';
|
||
return res.json({ summary: text });
|
||
} catch (e) {
|
||
const status = e?.response?.status || 500;
|
||
return res.status(status).json({ error: 'summarize_failed', details: String(e?.message || e) });
|
||
}
|
||
});
|
||
|
||
// -------------------- 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();
|
||
// Ne transfère que les headers utiles : tout le reste (cookies, content-length
|
||
// d'origine, UA navigateur, referer...) peut faire rejeter la requête en amont
|
||
// (ex. 403 CloudFront de api.twitch.tv).
|
||
const headers = {};
|
||
for (const [k, v] of Object.entries(req.headers || {})) {
|
||
const lk = String(k).toLowerCase();
|
||
if (['authorization', 'client-id', 'client_id', 'content-type', 'accept'].includes(lk) && v != null) {
|
||
headers[lk] = v;
|
||
}
|
||
}
|
||
const hasBody = !['GET', 'HEAD', 'OPTIONS'].includes(method) && req.body != null && !(typeof req.body === 'object' && Object.keys(req.body).length === 0);
|
||
const resp = await axios({
|
||
url: targetUrl,
|
||
method,
|
||
headers,
|
||
...(hasBody ? { 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 {
|
||
console.log('[SEARCH] Requête reçue - Query:', req.query);
|
||
const { q, providers } = req.query;
|
||
|
||
// Validation des paramètres
|
||
if (!q || typeof q !== 'string' || q.trim().length < 2) {
|
||
console.log('[SEARCH] Requête invalide - q manquant ou trop court:', q);
|
||
return res.status(400).json({ error: 'q is required and must be at least 2 characters long' });
|
||
}
|
||
const pageNum = Math.max(1, Number(req.query.page || 1));
|
||
const pageSize = Math.min(50, Math.max(1, Number(req.query.pageSize || 24)));
|
||
// Optional sort parameter (normalized to known set)
|
||
const allowedSort = new Set(['relevance', 'date', 'views']);
|
||
let sort = (typeof req.query.sort === 'string' ? req.query.sort.trim().toLowerCase() : 'relevance');
|
||
if (!allowedSort.has(sort)) sort = 'relevance';
|
||
// 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 include pagination and sort hints
|
||
return Promise.resolve().then(() => mod.search(q, { limit: pageSize, page: pageNum, sort }));
|
||
})
|
||
);
|
||
|
||
// Group results by provider id (+ per-provider errors for diagnosable UI)
|
||
const groups = /** @type {Record<string, any[]>} */ ({});
|
||
const errors = /** @type {Record<string, { message: string, status?: number, code?: string }>} */ ({});
|
||
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?.message || result.reason);
|
||
groups[providerId] = [];
|
||
try {
|
||
const r = /** @type {any} */ (result.reason);
|
||
errors[providerId] = {
|
||
message: String(r?.message || r || 'search_failed'),
|
||
...(typeof r?.ytStatus === 'number' ? { status: r.ytStatus } : {}),
|
||
...(r?.code ? { code: String(r.code) } : {}),
|
||
};
|
||
} catch {}
|
||
}
|
||
});
|
||
|
||
return res.json({ q, providers: validProviders, groups, errors, page: pageNum, pageSize, sort });
|
||
} catch (e) {
|
||
return res.status(500).json({ error: 'search_failed', details: String(e?.message || e) });
|
||
}
|
||
});
|
||
|
||
// -------------------- Query typeahead suggestions (Step 15) --------------------
|
||
// GET /api/search/suggest?q=…&providers=yt,dm&limit=10 -> { q, groups: { yt: string[], dm: string[] } }
|
||
// Fan-out over provider `suggest()` handlers; providers without one degrade to [] (never 500).
|
||
const SUGGEST_CACHE_TTL_MS = Number(process.env.SUGGEST_CACHE_TTL_MS || 5 * 60 * 1000);
|
||
const SUGGEST_CACHE_MAX_ENTRIES = 500;
|
||
/** @type {Map<string, { ts: number, data: any }>} */
|
||
const suggestCache = new Map();
|
||
function suggestCacheGet(key) {
|
||
const hit = suggestCache.get(key);
|
||
if (!hit) return null;
|
||
if ((Date.now() - hit.ts) >= SUGGEST_CACHE_TTL_MS) {
|
||
suggestCache.delete(key);
|
||
return null;
|
||
}
|
||
// LRU refresh
|
||
suggestCache.delete(key);
|
||
suggestCache.set(key, hit);
|
||
return hit.data;
|
||
}
|
||
function suggestCacheSet(key, data) {
|
||
if (suggestCache.has(key)) suggestCache.delete(key);
|
||
suggestCache.set(key, { ts: Date.now(), data });
|
||
while (suggestCache.size > SUGGEST_CACHE_MAX_ENTRIES) {
|
||
const oldest = suggestCache.keys().next().value;
|
||
suggestCache.delete(oldest);
|
||
}
|
||
}
|
||
const suggestLimiter = rateLimit({
|
||
windowMs: 60 * 1000,
|
||
max: Number(process.env.SUGGEST_RATE_LIMIT || 60),
|
||
standardHeaders: true,
|
||
legacyHeaders: false,
|
||
});
|
||
// NOTE: montée sur le router `r` (et non `app`) pour être servie sous les
|
||
// DEUX préfixes `/api` et `/proxy/api` (cf. apiBase() côté front : en prod le
|
||
// front appelle `/proxy/api`, sinon fallback SPA -> index.html -> parse error
|
||
// et seules les suggestions locales s'affichaient). Même pattern que transcript.
|
||
r.get('/search/suggest', suggestLimiter, async (req, res) => {
|
||
try {
|
||
const rawQ = typeof req.query.q === 'string' ? req.query.q : '';
|
||
const q = rawQ.trim();
|
||
if (q.length < 2) {
|
||
return res.status(400).json({ error: 'q is required and must be at least 2 characters long' });
|
||
}
|
||
const limit = Math.min(20, Math.max(1, Number(req.query.limit || 10)));
|
||
const requested = typeof req.query.providers === 'string' ? String(req.query.providers) : '';
|
||
const validProviders = validateProviders(requested);
|
||
const cacheKey = `suggest:${validProviders.join(',')}:${q.toLowerCase()}:${limit}`;
|
||
const cached = suggestCacheGet(cacheKey);
|
||
if (cached) return res.json(cached);
|
||
// Enrichissement gratuit (sans clé) lancé en parallèle du fan-out
|
||
// providers : suggestions web génériques (Bing + Google) + vrais titres
|
||
// vidéo Odysee (Lighthouse) quand `od` est demandé. Jamais bloquant.
|
||
const webEnabled = String(process.env.SUGGEST_WEB_ENABLED ?? '1') !== '0';
|
||
const wantOdysee = validProviders.includes('od');
|
||
const webPromise = webEnabled ? fetchWebSuggest(q, { limit }) : Promise.resolve([]);
|
||
const lighthousePromise = wantOdysee ? fetchOdyseeLighthouseSuggest(q, { limit }) : Promise.resolve([]);
|
||
const results = await Promise.allSettled(
|
||
validProviders.map((providerId) => {
|
||
const mod = providerRegistry[providerId];
|
||
if (!mod || typeof mod.suggest !== 'function') return Promise.resolve([]);
|
||
return Promise.resolve().then(() => mod.suggest(q, { limit }));
|
||
})
|
||
);
|
||
const [webRes, lightRes] = await Promise.allSettled([webPromise, lighthousePromise]);
|
||
const groups = {};
|
||
results.forEach((result, index) => {
|
||
const providerId = validProviders[index];
|
||
if (result.status === 'fulfilled' && Array.isArray(result.value)) {
|
||
groups[providerId] = result.value
|
||
.map((s) => String(s ?? '').trim())
|
||
.filter(Boolean)
|
||
.slice(0, limit);
|
||
} else {
|
||
groups[providerId] = [];
|
||
}
|
||
});
|
||
if (wantOdysee && lightRes.status === 'fulfilled' && Array.isArray(lightRes.value) && lightRes.value.length > 0) {
|
||
groups.od = [...(groups.od || []), ...lightRes.value].slice(0, limit);
|
||
}
|
||
if (webEnabled && webRes.status === 'fulfilled' && Array.isArray(webRes.value) && webRes.value.length > 0) {
|
||
groups.web = webRes.value.slice(0, limit);
|
||
}
|
||
// La même occurrence n'est renvoyée qu'une fois (providers d'abord, web en dernier).
|
||
const data = { q, groups: dedupeSuggestGroups(groups, [...validProviders, 'web']) };
|
||
suggestCacheSet(cacheKey, data);
|
||
return res.json(data);
|
||
} catch (e) {
|
||
return res.status(500).json({ error: 'suggest_failed', details: String(e?.message || e) });
|
||
}
|
||
});
|
||
|
||
// -------------------- Video transcripts (Step 16, Phase 1) --------------------
|
||
// GET /api/transcript/:provider/:videoId?lang=&instance=&slug=&sourceUrl=
|
||
// -> { lang, available, languages, lines: [{ t, dur, text }] }
|
||
// One endpoint / one parser / one UI whatever the provider (yt-dlp subtitles).
|
||
const TRANSCRIPT_CACHE_TTL_MS = Number(process.env.TRANSCRIPT_CACHE_TTL || 24 * 60 * 60 * 1000);
|
||
const TRANSCRIPT_CACHE_MAX_ENTRIES = 200;
|
||
/** @type {Map<string, { ts: number, data: any }>} */
|
||
const transcriptCache = new Map();
|
||
function transcriptCacheGet(key) {
|
||
const hit = transcriptCache.get(key);
|
||
if (!hit) return null;
|
||
if ((Date.now() - hit.ts) >= TRANSCRIPT_CACHE_TTL_MS) {
|
||
transcriptCache.delete(key);
|
||
return null;
|
||
}
|
||
transcriptCache.delete(key);
|
||
transcriptCache.set(key, hit);
|
||
return hit.data;
|
||
}
|
||
function transcriptCacheSet(key, data) {
|
||
if (transcriptCache.has(key)) transcriptCache.delete(key);
|
||
transcriptCache.set(key, { ts: Date.now(), data });
|
||
while (transcriptCache.size > TRANSCRIPT_CACHE_MAX_ENTRIES) {
|
||
const oldest = transcriptCache.keys().next().value;
|
||
transcriptCache.delete(oldest);
|
||
}
|
||
}
|
||
const transcriptLimiter = rateLimit({
|
||
windowMs: 60 * 1000,
|
||
max: Number(process.env.TRANSCRIPT_RATE_LIMIT || 10),
|
||
standardHeaders: true,
|
||
legacyHeaders: false,
|
||
// Always answer JSON (default handler sends an HTML/text page, which the
|
||
// Angular HttpClient cannot parse as JSON and surfaces as a raw SyntaxError).
|
||
handler: (req, res) => {
|
||
return res.status(429).json({ available: false, error: 'rate_limited' });
|
||
},
|
||
});
|
||
|
||
/** Providers known NOT to expose subtitle tracks via yt-dlp (no transcript possible). */
|
||
const TRANSCRIPT_UNSUPPORTED_PROVIDERS = new Set(['twitch', 'odysee', 'rumble']);
|
||
|
||
/** Langues de transcripts autorisées : défaut fr+en, jamais de téléchargement hors liste. */
|
||
function sanitizeAllowedTranscriptLangs(raw) {
|
||
const supported = Array.isArray(SUPPORTED_DL_LANGS) && SUPPORTED_DL_LANGS.length
|
||
? SUPPORTED_DL_LANGS
|
||
: ['fr', 'en'];
|
||
const fallback = Array.isArray(DEFAULT_DL_LANGS) && DEFAULT_DL_LANGS.length
|
||
? DEFAULT_DL_LANGS
|
||
: ['fr', 'en'];
|
||
let arr = raw;
|
||
if (typeof arr === 'string') arr = arr.split(',');
|
||
if (!Array.isArray(arr)) return [...fallback];
|
||
const cleaned = arr
|
||
.map(v => String(v || '').trim().toLowerCase().replace(/_/g, '-').split('-')[0])
|
||
.filter(v => /^[a-z]{2,3}$/.test(v) && supported.includes(v));
|
||
return Array.from(new Set(cleaned));
|
||
}
|
||
|
||
function transcriptPrimary(lang) {
|
||
return String(lang || '').trim().toLowerCase().replace(/_/g, '-').split('-')[0];
|
||
}
|
||
|
||
/** Ne jamais exposer ni télécharger une langue non activée dans les préférences. */
|
||
function filterTranscriptLanguages(languages, allowed) {
|
||
const set = new Set((allowed || []).map(transcriptPrimary).filter(Boolean));
|
||
return (Array.isArray(languages) ? languages : []).filter(l => set.has(transcriptPrimary(l)));
|
||
}
|
||
|
||
/** Résout les langues autorisées : `?langs=` explicite > préférence user (JWT) > défaut fr,en. */
|
||
function resolveTranscriptAllowedLangs(req) {
|
||
const fromQuery = sanitizeAllowedTranscriptLangs(req.query?.langs);
|
||
const queryAsked = req.query?.langs !== undefined;
|
||
if (queryAsked) return fromQuery;
|
||
try {
|
||
const hdr = req.headers?.['authorization'] || '';
|
||
const [, token] = String(hdr).split(' ');
|
||
if (token) {
|
||
const payload = jwt.verify(token, JWT_SECRET);
|
||
const uid = payload?.sub;
|
||
if (uid) {
|
||
const prefs = getPreferencesForApi(uid);
|
||
if (prefs && Array.isArray(prefs.downloadLanguages)) {
|
||
return sanitizeAllowedTranscriptLangs(prefs.downloadLanguages);
|
||
}
|
||
}
|
||
}
|
||
} catch {}
|
||
return sanitizeAllowedTranscriptLangs(undefined);
|
||
}
|
||
|
||
/** Download subtitles via yt-dlp (handles YouTube impersonation + 429-prone
|
||
* translated tracks). Tries `langs` in order, returns the first non-empty
|
||
* parsed lines with the language that worked, or null. Bounded by a timeout
|
||
* (yt-dlp subtitle downloads can hang on .part files); partial results on
|
||
* disk are still scanned when yt-dlp exits non-zero. */
|
||
async function transcriptViaYtDlp(url, langs, netOpts = {}) {
|
||
const osMod = await import('node:os');
|
||
const dir = await fs.promises.mkdtemp(path.join(osMod.tmpdir(), 'newtube-transcript-'));
|
||
const scanDir = async (wanted) => {
|
||
let files = [];
|
||
try {
|
||
files = (await fs.promises.readdir(dir)).filter((f) => /\.vtt$/i.test(f) && !/\.part$/i.test(f));
|
||
} catch { return null; }
|
||
// Ignore stale/empty files
|
||
const nonEmpty = [];
|
||
for (const f of files) {
|
||
try {
|
||
const st = await fs.promises.stat(path.join(dir, f));
|
||
if (st.size > 0) nonEmpty.push(f);
|
||
} catch {}
|
||
}
|
||
// Prefer requested languages first
|
||
nonEmpty.sort((a, b) => {
|
||
const la = a.toLowerCase(), lb = b.toLowerCase();
|
||
const ia = wanted.findIndex((w) => la.includes(`.${w.toLowerCase()}.`) || la.endsWith(`.${w.toLowerCase()}.vtt`));
|
||
const ib = wanted.findIndex((w) => lb.includes(`.${w.toLowerCase()}.`) || lb.endsWith(`.${w.toLowerCase()}.vtt`));
|
||
return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib);
|
||
});
|
||
for (const file of nonEmpty) {
|
||
try {
|
||
const text = await fs.promises.readFile(path.join(dir, file), 'utf8');
|
||
const lines = parseVtt(text);
|
||
if (lines.length) {
|
||
const m = /\.([a-z]{2,3}(?:-[a-z]{2,4})?)\.vtt$/i.exec(file);
|
||
return { lines, lang: m ? m[1] : null };
|
||
}
|
||
} catch {}
|
||
}
|
||
return null;
|
||
};
|
||
try {
|
||
// Ne télécharger QUE les langues autorisées (aucun ajout forcé hors préférence).
|
||
const wanted = Array.from(new Set((langs || []).map((l) => String(l || '').split('-')[0]).filter(Boolean)));
|
||
if (!wanted.length) return null;
|
||
const subLangs = wanted.slice(0, 6).join(',');
|
||
const child = youtubedl(url, {
|
||
writeSub: true,
|
||
writeAutoSub: true,
|
||
subLangs,
|
||
subFormat: 'vtt/best',
|
||
skipDownload: true,
|
||
noWarnings: true,
|
||
noCheckCertificates: true,
|
||
noPlaylist: true,
|
||
// Espace les requêtes sous-titres : les rafales déclenchent des 429
|
||
// YouTube que les retries immédiats ne font qu'aggraver.
|
||
sleepRequests: 2,
|
||
sleepSubtitles: 5,
|
||
...netOpts,
|
||
output: path.join(dir, '%(id)s'),
|
||
});
|
||
const timer = setTimeout(() => { try { child.kill('SIGKILL'); } catch {} }, 90000);
|
||
try {
|
||
await child;
|
||
} catch (e) {
|
||
// Non-zero exit (e.g. one language 429'd) — partial files may still exist.
|
||
console.warn('[transcript] yt-dlp subtitle download exited non-zero:', String(e?.message || e).slice(0, 200));
|
||
} finally {
|
||
clearTimeout(timer);
|
||
}
|
||
return await scanDir(wanted);
|
||
} catch (e) {
|
||
console.warn('[transcript] yt-dlp subtitle download failed:', e?.message || e);
|
||
try {
|
||
const wanted = Array.from(new Set((langs || []).map((l) => String(l || '').split('-')[0]).filter(Boolean)));
|
||
return await scanDir(wanted);
|
||
} catch { return null; }
|
||
} finally {
|
||
try { await fs.promises.rm(dir, { recursive: true, force: true }); } catch {}
|
||
}
|
||
}
|
||
|
||
/** Build a `Cookie` header from a Netscape-format cookies file (YT_COOKIES_FILE).
|
||
* Lets plain timedtext fetches reuse the same trusted residential session as
|
||
* yt-dlp instead of going out bare (datacenter egress => 200-empty/429). */
|
||
function youtubeCookiesHeader() {
|
||
try {
|
||
const file = String(process.env.YT_COOKIES_FILE || '').trim();
|
||
if (!file || !fs.existsSync(file)) return null;
|
||
const raw = fs.readFileSync(file, 'utf8');
|
||
const pairs = [];
|
||
for (const line of raw.split('\n')) {
|
||
const l = line.trim();
|
||
if (!l || l.startsWith('#')) continue;
|
||
const parts = l.split('\t');
|
||
if (parts.length < 7) continue;
|
||
const domain = parts[0] || '';
|
||
const name = parts[5] || '';
|
||
const value = parts[6] || '';
|
||
if (!name || !/youtube\.com|googlevideo\.com|google\.com/i.test(domain)) continue;
|
||
pairs.push(`${name.trim()}=${value.trim()}`);
|
||
}
|
||
return pairs.length ? Array.from(new Set(pairs)).join('; ') : null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/** Fetch one timedtext track URL and parse it (any format). Throws on any failure. */
|
||
async function fetchTimedTextLines(track) {
|
||
// Les URLs signées yt-dlp portent déjà `fmt=` : ne jamais le dupliquer
|
||
// (signature invalidée => 429/Sorry).
|
||
const url = ensureFmtParam(String(track?.url || ''), transcriptTrackExt(track) === 'vtt' ? 'vtt' : 'json3');
|
||
const headers = {
|
||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36',
|
||
Accept: 'application/json, text/vtt, text/*;q=0.9, */*;q=0.8',
|
||
'Accept-Language': 'en-US,en;q=0.9,fr;q=0.8',
|
||
};
|
||
const cookies = youtubeCookiesHeader();
|
||
if (cookies) headers.Cookie = cookies;
|
||
const resp = await fetch(url, {
|
||
headers,
|
||
signal: AbortSignal.timeout(10000),
|
||
});
|
||
if (!resp.ok) throw new Error(`track_fetch_failed:${resp.status}`);
|
||
const text = await resp.text();
|
||
// Throttle silencieux de YouTube sur les IPs de datacenter : HTTP 200 avec
|
||
// corps vide (ou page Sorry). Transitoire, comme un 429 — jamais "no subtitles".
|
||
if (isEmptyTimedTextBody(text)) throw new Error('track_fetch_failed:empty_200');
|
||
const lines = parseTrackText(text, transcriptTrackExt(track));
|
||
if (!lines.length) throw new Error('track_fetch_failed:unparsable');
|
||
return lines;
|
||
}
|
||
|
||
// NOTE: montée sur le router `r` (et non `app`) pour être servie sous les
|
||
// DEUX préfixes `/api` et `/proxy/api` (cf. apiBase() côté front + rewrite
|
||
// du proxy dev). Une route `app.get('/api/...')` ici répondrait index.html
|
||
// (fallback SPA) sous `/proxy/api/...` en prod.
|
||
r.get('/transcript/:provider/:videoId', transcriptLimiter, async (req, res) => {
|
||
try {
|
||
const { provider, videoId } = req.params;
|
||
// Langues autorisées par les préférences (défaut fr,en) : rien d'autre
|
||
// n'est affiché ni téléchargé.
|
||
const allowed = resolveTranscriptAllowedLangs(req);
|
||
let lang = String(req.query.lang || allowed[0] || 'fr').slice(0, 12) || 'fr';
|
||
if (!allowed.map(transcriptPrimary).includes(transcriptPrimary(lang))) {
|
||
lang = allowed[0] || 'fr';
|
||
}
|
||
const normalized = normalizeTranscriptProvider(provider);
|
||
if (!normalized || !videoId) {
|
||
return res.status(400).json({ available: false, error: 'invalid_provider_or_video' });
|
||
}
|
||
if (!allowed.length) {
|
||
return res.json({ lang: null, available: false, languages: [], lines: [], reason: 'no_subtitles' });
|
||
}
|
||
const cacheKey = `transcript:${normalized}:${videoId}:${lang.toLowerCase()}:${[...allowed].sort().join(',')}`;
|
||
const cached = transcriptCacheGet(cacheKey);
|
||
if (cached) return res.json(cached);
|
||
// Source de découverte des pistes : InnerTube d'abord pour YouTube
|
||
// (mêmes URLs timedtext, sans spawn yt-dlp), yt-dlp sinon/en secours.
|
||
// YT_TRANSCRIPT_SOURCE=innertube-first (défaut) | ytdlp-only | innertube-only
|
||
const transcriptSource = String(process.env.YT_TRANSCRIPT_SOURCE || 'innertube-first').trim().toLowerCase();
|
||
const transcriptUrl = providerUrlFrom(normalized, String(videoId), {
|
||
instance: req.query.instance || undefined,
|
||
slug: req.query.slug || undefined,
|
||
sourceUrl: req.query.sourceUrl || undefined,
|
||
});
|
||
let meta = null;
|
||
if (normalized === 'youtube' && transcriptSource !== 'ytdlp-only') {
|
||
try {
|
||
const { getCaptionTracksViaInnerTube } = await import('./providers/youtube-innertube.mjs');
|
||
const cap = await getCaptionTracksViaInnerTube(String(videoId));
|
||
if (cap.trackCount > 0) {
|
||
meta = { subtitles: cap.subtitles, automatic_captions: cap.automatic_captions };
|
||
console.log(`[transcript] source=innertube tracks=${cap.trackCount} langs=${cap.languages.join(',')}`);
|
||
} else {
|
||
// InnerTube fait foi (même backend que le lecteur) : 0 piste = pas
|
||
// de sous-titres, sans payer un dump yt-dlp complet.
|
||
console.log('[transcript] source=innertube tracks=0 -> no_subtitles');
|
||
const empty = { lang: null, available: false, languages: [], lines: [], reason: 'no_subtitles' };
|
||
transcriptCacheSet(cacheKey, empty);
|
||
return res.json(empty);
|
||
}
|
||
} catch (e) {
|
||
console.warn('[transcript] innertube discovery failed, fallback yt-dlp:', e?.code || String(e?.message || e).slice(0, 120));
|
||
if (transcriptSource === 'innertube-only') {
|
||
return res.status(502).json({ available: false, languages: [], lines: [], error: 'transcript_temporarily_unavailable', retryable: true, retryAfterSec: 30 });
|
||
}
|
||
}
|
||
}
|
||
if (!meta) {
|
||
try {
|
||
const raw = await youtubedl(transcriptUrl, { dumpSingleJson: true, skipDownload: true, noWarnings: true, noCheckCertificates: true, ...ytdlpNetOpts() });
|
||
meta = (typeof raw === 'string') ? JSON.parse(raw || '{}') : (raw || {});
|
||
} catch (e) {
|
||
console.error('[transcript] yt-dlp failed:', e?.message || e);
|
||
return res.status(502).json({ available: false, languages: [], lines: [], error: 'transcript_temporarily_unavailable', retryable: true, retryAfterSec: 30 });
|
||
}
|
||
}
|
||
const { track, languages, lang: chosenLang } = pickTrack(meta, lang);
|
||
const visibleLanguages = filterTranscriptLanguages(languages, allowed);
|
||
if (!track || !allowed.map(transcriptPrimary).includes(transcriptPrimary(chosenLang))) {
|
||
// Definitive absence: distinguish "provider never exposes subtitles"
|
||
// (twitch/odysee/rumble) from "this video has none" — cacheable 200s.
|
||
const reason = TRANSCRIPT_UNSUPPORTED_PROVIDERS.has(normalized) ? 'provider_unsupported' : 'no_subtitles';
|
||
const empty = { lang: null, available: false, languages: visibleLanguages, lines: [], reason };
|
||
transcriptCacheSet(cacheKey, empty);
|
||
return res.json(empty);
|
||
}
|
||
// Stratégie de récupération (résultat des tests live 2026-09-28) :
|
||
// 1. Sonde rapide des URLs InnerTube (découverte, max 2, sans retry) :
|
||
// elles répondent désormais 200-vide depuis les egress filtrés.
|
||
// 2. Dump yt-dlp -> URLs signées fraîches (`signature`/`expire`) fetchées
|
||
// en direct (max 3) : c'est la voie rapide qui fonctionne encore.
|
||
// 3. Dernier recours : `yt-dlp --write-sub` (client visionos + retries
|
||
// internes, le plus robuste : ~6 s, 80+ Ko vérifiés live).
|
||
// On ne martèle jamais plus de 2 URLs mortes d'affilée : chaque 200-vide
|
||
// ou 429 supplémentaire aggrave le throttle YouTube à la minute.
|
||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||
let lines = [];
|
||
let workedLang = chosenLang;
|
||
let sawRateLimit = false;
|
||
let sawEmpty = false;
|
||
const allowedSet = new Set(allowed.map(transcriptPrimary));
|
||
const keepAllowed = (cands) => (cands || []).filter(c => allowedSet.has(transcriptPrimary(c.lang)));
|
||
const markTransient = (msg) => {
|
||
if (msg.includes(':429') || msg.includes('Sorry') || msg.includes('sorry')) sawRateLimit = true;
|
||
if (msg.includes(':empty_200') || msg.includes(':unparsable') || msg.includes('html_error')) sawEmpty = true;
|
||
};
|
||
const tryCandidates = async (cands, { retry429 = false, label = '' } = {}) => {
|
||
for (const cand of cands) {
|
||
let attempt = 0;
|
||
for (;;) {
|
||
try {
|
||
const parsed = await fetchTimedTextLines(cand.track);
|
||
if (parsed && parsed.length) {
|
||
lines = parsed;
|
||
workedLang = cand.lang || chosenLang;
|
||
}
|
||
break;
|
||
} catch (e) {
|
||
const msg = String(e?.message || e);
|
||
console.warn(`[transcript] track fetch failed${label ? ` (${label})` : ''}:`, cand.lang, msg.slice(0, 120));
|
||
markTransient(msg);
|
||
if (msg.includes(':429') && retry429 && attempt < 1) {
|
||
sawRateLimit = true;
|
||
attempt += 1;
|
||
await sleep(2000 + Math.floor(Math.random() * 1000));
|
||
continue;
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
if (lines.length) return true;
|
||
}
|
||
return false;
|
||
};
|
||
const innertubeCands = keepAllowed(normalized === 'youtube'
|
||
? firstPerLanguage(orderedTracks(meta, lang), 2)
|
||
: orderedTracks(meta, lang).slice(0, 2));
|
||
// Server-side auto-translation only towards an allowed target language.
|
||
const tlangCands = normalized === 'youtube'
|
||
? keepAllowed(translatedFallbacks(innertubeCands, lang)).slice(0, 1)
|
||
: [];
|
||
await tryCandidates(innertubeCands.concat(tlangCands), { retry429: false, label: 'innertube' });
|
||
if (!lines.length && normalized === 'youtube' && transcriptSource !== 'innertube-only') {
|
||
// Étape 2 : URLs signées via dump yt-dlp (même quand InnerTube a déjà
|
||
// découvert les pistes : ce sont les seules URLs fetchables en direct).
|
||
try {
|
||
const raw = await youtubedl(transcriptUrl, { dumpSingleJson: true, skipDownload: true, noWarnings: true, noCheckCertificates: true, ...ytdlpNetOpts() });
|
||
const dump = (typeof raw === 'string') ? JSON.parse(raw || '{}') : (raw || {});
|
||
const signedBase = keepAllowed(firstPerLanguage(orderedTracks(dump, lang), 3));
|
||
const signedTlang = keepAllowed(translatedFallbacks(signedBase, lang)).slice(0, 1);
|
||
const signed = mergeTranscriptCandidates({ signed: signedBase.concat(signedTlang), innertube: [], max: 4 });
|
||
if (signed.length) {
|
||
console.log(`[transcript] retry via signed yt-dlp urls (${signed.length} candidats)`);
|
||
await tryCandidates(signed, { retry429: true, label: 'signed' });
|
||
}
|
||
} catch (e) {
|
||
console.warn('[transcript] signed-url dump failed:', String(e?.message || e).slice(0, 150));
|
||
}
|
||
}
|
||
if (!lines || lines.length === 0) {
|
||
// Étape 3 : téléchargement via yt-dlp (gère impersonation + retries).
|
||
// Uniquement les langues autorisées.
|
||
try {
|
||
const viaDlp = await transcriptViaYtDlp(transcriptUrl, [lang, chosenLang].filter(l => allowedSet.has(transcriptPrimary(l))), ytdlpNetOpts());
|
||
if (viaDlp && viaDlp.lines && viaDlp.lines.length && allowedSet.has(transcriptPrimary(viaDlp.lang))) {
|
||
lines = viaDlp.lines;
|
||
if (viaDlp.lang) workedLang = viaDlp.lang;
|
||
}
|
||
} catch {}
|
||
}
|
||
if (!lines || lines.length === 0) {
|
||
// Subtitle tracks exist but their content could not be retrieved or
|
||
// parsed (YouTube 429 / 200-vide / Sorry pages) : the video HAS
|
||
// subtitles, so this is transient — 502 with languages + retryable flag,
|
||
// never cached as "no subtitles".
|
||
return res.status(502).json({
|
||
available: false,
|
||
languages: visibleLanguages,
|
||
lines: [],
|
||
error: 'transcript_temporarily_unavailable',
|
||
retryable: true,
|
||
rateLimited: (sawRateLimit || sawEmpty) || undefined,
|
||
retryAfterSec: sawRateLimit ? 60 : 30,
|
||
});
|
||
}
|
||
lines = dedupeTranscriptLines(lines);
|
||
const result = { lang: workedLang, available: true, languages: visibleLanguages, lines };
|
||
transcriptCacheSet(cacheKey, result);
|
||
return res.json(result);
|
||
} catch (e) {
|
||
console.error('[transcript] unexpected error:', e?.message || e);
|
||
return res.status(502).json({ available: false, languages: [], lines: [], error: 'transcript_temporarily_unavailable', retryable: true, retryAfterSec: 30 });
|
||
}
|
||
});
|
||
|
||
// -------------------- 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.
|
||
// Hashed bundles (.js/.css) sont immuables -> cache long ; index.html ne doit
|
||
// JAMAIS être caché (sinon le navigateur rejoue d'anciens chunks après un
|
||
// redéploiement et appelle l'API avec d'anciens formats d'URL).
|
||
app.use(express.static(staticDir, {
|
||
maxAge: '1h',
|
||
index: 'index.html',
|
||
setHeaders: (res, filePath) => {
|
||
try {
|
||
if (String(filePath || '').toLowerCase().endsWith('.html')) {
|
||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||
res.setHeader('Pragma', 'no-cache');
|
||
res.removeHeader('Expires');
|
||
}
|
||
} catch {}
|
||
},
|
||
}));
|
||
// SPA fallback: any non-API GET should serve index.html (toujours frais, cf. ci-dessus)
|
||
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)) {
|
||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||
res.setHeader('Pragma', 'no-cache');
|
||
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 ---
|
||
// NOTE : cookie-aware (et non Bearer seul) : voir commentaire section Likes.
|
||
// Sans cela, création/ajout renvoyait 401 « Unauthorized » même connecté.
|
||
// Create a new playlist
|
||
r.post('/playlists', authMiddlewareCookieAware, (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', authMiddlewareCookieAware, (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', authMiddlewareCookieAware, (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', authMiddlewareCookieAware, (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', authMiddlewareCookieAware, (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', authMiddlewareCookieAware, 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', authMiddlewareCookieAware, (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', authMiddlewareCookieAware, (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 (référence machine : lecture publique + espace utilisateur) ---
|
||
r.get('/openapi.json', (_req, res) => {
|
||
const bearer = [{ bearerAuth: [] }];
|
||
const doc = {
|
||
openapi: '3.0.0',
|
||
info: { title: 'NewTube API', version: '1.1.0' },
|
||
paths: {
|
||
'/healthz': {
|
||
get: { summary: 'Santé API (mode YT, yt-dlp, cache, quota)', responses: { 200: { description: 'ok' } } },
|
||
},
|
||
'/search': {
|
||
get: { summary: 'Recherche unifiée multi-providers', parameters: [
|
||
{ name: 'q', in: 'query', required: true, schema: { type: 'string', minLength: 2 } },
|
||
{ name: 'providers', in: 'query', schema: { type: 'string', example: 'yt,dm' } },
|
||
{ name: 'page', in: 'query', schema: { type: 'integer', default: 1 } },
|
||
{ name: 'pageSize', in: 'query', schema: { type: 'integer', maximum: 50 } },
|
||
{ name: 'sort', in: 'query', schema: { type: 'string', enum: ['relevance', 'date', 'views'] } },
|
||
], responses: { 200: { description: '{ q, providers, groups, errors, page, pageSize, sort }' } } },
|
||
},
|
||
'/search/suggest': {
|
||
get: { summary: 'Typeahead groupé par provider', parameters: [
|
||
{ name: 'q', in: 'query', required: true, schema: { type: 'string', minLength: 2 } },
|
||
{ name: 'providers', in: 'query', schema: { type: 'string' } },
|
||
{ name: 'limit', in: 'query', schema: { type: 'integer', maximum: 20 } },
|
||
], responses: { 200: { description: '{ q, groups }' } } },
|
||
},
|
||
'/details/{provider}/{videoId}': {
|
||
get: { summary: 'Métadonnées vidéo + connexes YouTube', parameters: [
|
||
{ name: 'provider', in: 'path', required: true, schema: { type: 'string' } },
|
||
{ name: 'videoId', in: 'path', required: true, schema: { type: 'string' } },
|
||
{ name: 'instance', in: 'query', schema: { type: 'string' } },
|
||
{ name: 'related', in: 'query', schema: { type: 'string', enum: ['0'] } },
|
||
], responses: { 200: { description: 'video + related[]' } } },
|
||
},
|
||
'/trending': {
|
||
get: { summary: 'Tendances YouTube sans clé (phase 1 : yt)', parameters: [
|
||
{ name: 'provider', in: 'query', schema: { type: 'string', default: 'yt' } },
|
||
{ name: 'limit', in: 'query', schema: { type: 'integer', maximum: 50 } },
|
||
], responses: { 200: { description: '{ provider, items }' } } },
|
||
},
|
||
'/transcript/{provider}/{videoId}': {
|
||
get: { summary: 'Transcript { lang, available, languages, lines }', parameters: [
|
||
{ name: 'provider', in: 'path', required: true, schema: { type: 'string' } },
|
||
{ name: 'videoId', in: 'path', required: true, schema: { type: 'string' } },
|
||
{ name: 'lang', in: 'query', schema: { type: 'string', default: 'fr' } },
|
||
{ name: 'instance', in: 'query', schema: { type: 'string' } },
|
||
], responses: { 200: { description: '200 available:true|false ; 502 retryable:true si 429 YouTube' } } },
|
||
},
|
||
'/auth/register': {
|
||
post: { summary: 'Créer un compte', responses: { 201: { description: '{ user, accessToken }' } } },
|
||
},
|
||
'/auth/login': {
|
||
post: { summary: 'Connexion (retourne accessToken + cookies)', responses: { 200: { description: '{ user, accessToken }' } } },
|
||
},
|
||
'/user/me': {
|
||
get: { summary: 'Profil courant', security: bearer, responses: { 200: { description: 'user' } } },
|
||
},
|
||
'/user/preferences': {
|
||
get: { summary: 'Lire préférences', security: bearer, responses: { 200: { description: 'prefs' } } },
|
||
patch: { summary: 'Modifier préférences (defaultProviders…)', security: bearer, responses: { 200: { description: 'prefs' } } },
|
||
},
|
||
'/user/likes': {
|
||
get: { summary: 'Vidéos likées', security: bearer, responses: { 200: { description: 'likes[]' } } },
|
||
post: { summary: 'Liker', security: bearer, responses: { 201: { description: 'like' } } },
|
||
},
|
||
'/subscriptions': {
|
||
get: { summary: 'Abonnements { items, ttl }', security: bearer, responses: { 200: { description: 'subs' } } },
|
||
post: { summary: 'S abonner (résolution chaîne)', security: bearer, responses: { 201: { description: 'sub' } } },
|
||
},
|
||
'/download/{provider}/{videoId}/formats': {
|
||
get: { summary: 'Formats téléchargeables (cache 10 min)', security: bearer, responses: { 200: { description: '{ url, formats }' } } },
|
||
},
|
||
'/oauth/status': {
|
||
get: { summary: 'Connecteurs OAuth configurés', responses: { 200: { description: '{ google, twitch }' } } },
|
||
},
|
||
'/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);
|
||
});
|