feat(watch): refonte pro page lecture + dedup transcript

- Supprime le hint debug 'touche s' (raccourci conserve, tooltip discret)
- server/transcript.mjs: dedupeTranscriptLines collapse les auto-captions
  a fenetre glissante (12 lignes -> 3 sur l'exemple signale), applique dans
  parseJson3/parseVtt/parseXmlCaptions + filet avant cache API
- watch: layout pro (grille 1fr/360px, carte chaine+actions, labels FR,
  panneaus description/download, sidebar A suivre sticky, a11y/focus)
- transcript interactif: recherche, clic timestamp -> seek, suivi auto +
  surlignage ligne active, copie, retry
- tests: 4 nouveaux cas dedupe (23/23 pass), build prod OK
This commit is contained in:
2026-09-26 08:51:04 -04:00
parent 4bdcd393fe
commit 2db1f2deee
5 changed files with 948 additions and 335 deletions
+69 -10
View File
@@ -16,7 +16,7 @@ import * as cheerio from 'cheerio';
import axios from 'axios';
import rumbleRouter from './rumble.mjs';
import { providerRegistry, validateProviders } from './providers/registry.mjs';
import { pickTrack, parseTrackText, parseVtt, orderedTracks, normalizeTranscriptProvider, transcriptTrackExt, looksLikeHtmlError } from './transcript.mjs';
import { pickTrack, parseTrackText, parseVtt, dedupeTranscriptLines, orderedTracks, translatedFallbacks, firstPerLanguage, normalizeTranscriptProvider, transcriptTrackExt, looksLikeHtmlError } from './transcript.mjs';
import {
getUserByUsername,
getUserById,
@@ -80,6 +80,30 @@ import { fetchChannelContent } from './providers/channel-content.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
@@ -2066,7 +2090,9 @@ app.get('/api/trending', async (req, res) => {
// 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.
@@ -2409,7 +2435,7 @@ const TRANSCRIPT_UNSUPPORTED_PROVIDERS = new Set(['twitch', 'odysee', 'rumble'])
* 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) {
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) => {
@@ -2457,6 +2483,7 @@ async function transcriptViaYtDlp(url, langs) {
noWarnings: true,
noCheckCertificates: true,
noPlaylist: true,
...netOpts,
output: path.join(dir, '%(id)s'),
});
const timer = setTimeout(() => { try { child.kill('SIGKILL'); } catch {} }, 90000);
@@ -2496,7 +2523,11 @@ async function fetchTimedTextLines(track) {
return parseTrackText(text, transcriptTrackExt(track));
}
app.get('/api/transcript/:provider/:videoId', transcriptLimiter, async (req, res) => {
// 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;
const lang = String(req.query.lang || 'fr').slice(0, 12) || 'fr';
@@ -2541,7 +2572,7 @@ app.get('/api/transcript/:provider/:videoId', transcriptLimiter, async (req, res
}
if (!meta) {
try {
const raw = await youtubedl(transcriptUrl, { dumpSingleJson: true, skipDownload: true, noWarnings: true, noCheckCertificates: true });
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);
@@ -2561,11 +2592,19 @@ app.get('/api/transcript/:provider/:videoId', transcriptLimiter, async (req, res
// then everything else. Translated tracks are often rate-limited while the
// original still works — never fail on the first track alone.
// Bounded: hammering dozens of timedtext URLs only worsens YouTube 429s.
// YouTube-only last resort: server-side auto-translation (`&tlang=`) of a
// fetchable track when the requested language's own track stays walled.
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
let lines = [];
let workedLang = chosenLang;
let sawRateLimit = false;
for (const cand of orderedTracks(meta, lang).slice(0, 5)) {
const baseCands = normalized === 'youtube'
? firstPerLanguage(orderedTracks(meta, lang), 10)
: orderedTracks(meta, lang).slice(0, 5);
const candidates = normalized === 'youtube'
? baseCands.concat(translatedFallbacks(baseCands, lang)).slice(0, 12)
: baseCands;
for (const cand of candidates) {
let attempt = 0;
for (;;) {
try {
@@ -2595,7 +2634,7 @@ app.get('/api/transcript/:provider/:videoId', transcriptLimiter, async (req, res
// Direct timedtext fetches failed (429 / Sorry pages / impersonation):
// let yt-dlp download the subtitles instead (handles all of the above).
try {
const viaDlp = await transcriptViaYtDlp(transcriptUrl, [lang, chosenLang].filter(Boolean));
const viaDlp = await transcriptViaYtDlp(transcriptUrl, [lang, chosenLang].filter(Boolean), ytdlpNetOpts());
if (viaDlp && viaDlp.lines && viaDlp.lines.length) {
lines = viaDlp.lines;
if (viaDlp.lang) workedLang = viaDlp.lang;
@@ -2616,6 +2655,7 @@ app.get('/api/transcript/:provider/:videoId', transcriptLimiter, async (req, res
rateLimited: sawRateLimit || undefined,
});
}
lines = dedupeTranscriptLines(lines);
const result = { lang: workedLang, available: true, languages: languages || [], lines };
transcriptCacheSet(cacheKey, result);
return res.json(result);
@@ -2629,15 +2669,34 @@ app.get('/api/transcript/:provider/:videoId', transcriptLimiter, async (req, res
const distRoot = path.join(process.cwd(), 'dist');
const distBrowser = path.join(distRoot, 'browser');
const staticDir = fs.existsSync(distBrowser) ? distBrowser : distRoot;
// Mount static files unconditionally; if path missing, it will just not serve anything
app.use(express.static(staticDir, { maxAge: '1h', index: 'index.html' }));
// SPA fallback: any non-API GET should serve index.html
// 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)) return res.sendFile(indexPath);
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();