From 8d36a2c07e004ee3f08d08cd1ab2503611871435 Mon Sep 17 00:00:00 2001 From: Trevor Mears Date: Sat, 10 Jan 2026 12:35:39 -0800 Subject: [PATCH] feat: add subtitle extraction and display for MKV/container streams --- public/js/components/VideoPlayer.js | 22 +++++++++++ server/index.js | 1 + server/routes/probe.js | 14 ++++++- server/routes/remux.js | 1 + server/routes/subtitle.js | 58 +++++++++++++++++++++++++++++ server/routes/transcode.js | 1 + 6 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 server/routes/subtitle.js diff --git a/public/js/components/VideoPlayer.js b/public/js/components/VideoPlayer.js index 8adaf39..39b1c5a 100644 --- a/public/js/components/VideoPlayer.js +++ b/public/js/components/VideoPlayer.js @@ -676,6 +676,28 @@ class VideoPlayer { const info = await probeRes.json(); console.log(`[Player] Probe result: video=${info.video}, audio=${info.audio}, compatible=${info.compatible}`); + // Handle subtitles from probe result + // Clear existing remote tracks (from previous streams) + const oldTracks = this.video.querySelectorAll('track'); + oldTracks.forEach(t => t.remove()); + + if (info.subtitles && info.subtitles.length > 0) { + console.log(`[Player] Found ${info.subtitles.length} subtitle tracks`); + info.subtitles.forEach(sub => { + const track = document.createElement('track'); + track.kind = 'subtitles'; + track.label = sub.title; + track.srclang = sub.language; + track.src = `/api/subtitle?url=${encodeURIComponent(streamUrl)}&index=${sub.index}`; + this.video.appendChild(track); + }); + + // Force update of captions menu if it's open + if (this.captionsMenuOpen) { + this.updateCaptionsTracks(); + } + } + if (info.needsTranscode) { // Incompatible audio (AC3/EAC3/DTS) - use transcode console.log('[Player] Auto: Using transcode (incompatible audio)'); diff --git a/server/index.js b/server/index.js index f4057b9..4dc4679 100644 --- a/server/index.js +++ b/server/index.js @@ -89,6 +89,7 @@ app.use('/api/favorites', require('./routes/favorites')); app.use('/api/transcode', require('./routes/transcode')); app.use('/api/remux', require('./routes/remux')); app.use('/api/probe', require('./routes/probe')); +app.use('/api/subtitle', require('./routes/subtitle')); app.use('/api/settings', require('./routes/settings')); // SPA fallback - serve index.html for all non-API routes diff --git a/server/routes/probe.js b/server/routes/probe.js index 11d2648..885388a 100644 --- a/server/routes/probe.js +++ b/server/routes/probe.js @@ -32,6 +32,7 @@ function probeStream(url, ffprobePath, timeout = 15000) { return new Promise((resolve, reject) => { const args = [ '-v', 'error', + '-user_agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36', '-print_format', 'json', '-show_streams', '-show_format', @@ -100,6 +101,16 @@ function analyzeProbeResult(probeResult, url) { // Check if it's a raw TS stream (not HLS) const isRawTs = (container.includes('mpegts') || url.endsWith('.ts')) && !url.includes('.m3u8'); + // Extract subtitle tracks + const subtitles = streams + .filter(s => s.codec_type === 'subtitle' && s.codec_name !== 'timed_id3' && s.codec_name !== 'bin_data') + .map(s => ({ + index: s.index, + language: s.tags?.language || 'und', + title: s.tags?.title || s.tags?.language || `Track ${s.index}`, + codec: s.codec_name + })); + // Determine what processing is needed // 1. Incompatible audio -> Transcode (highest priority) const needsTranscode = !audioOk; @@ -116,7 +127,8 @@ function analyzeProbeResult(probeResult, url) { container: container, compatible: compatible, needsRemux: needsRemux, - needsTranscode: needsTranscode + needsTranscode: needsTranscode, + subtitles: subtitles }; } diff --git a/server/routes/remux.js b/server/routes/remux.js index cf5c6d1..db5fddd 100644 --- a/server/routes/remux.js +++ b/server/routes/remux.js @@ -26,6 +26,7 @@ router.get('/', (req, res) => { const args = [ '-hide_banner', '-loglevel', 'warning', + '-user_agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36', // Low-latency startup: reduce probe/analyze time for faster first bytes '-probesize', '32768', '-analyzeduration', '500000', // 0.5 seconds - enough to detect audio diff --git a/server/routes/subtitle.js b/server/routes/subtitle.js new file mode 100644 index 0000000..e8a596c --- /dev/null +++ b/server/routes/subtitle.js @@ -0,0 +1,58 @@ +const express = require('express'); +const router = express.Router(); +const { spawn } = require('child_process'); + +/** + * Subtitle extraction endpoint + * GET /api/subtitle?url=...&index=... + * + * Extracts a specific subtitle track and converts it to WebVTT on the fly. + */ +router.get('/', (req, res) => { + const { url, index } = req.query; + + if (!url || index === undefined) { + return res.status(400).json({ error: 'URL and index parameters are required' }); + } + + const ffmpegPath = req.app.locals.ffmpegPath || 'ffmpeg'; + // console.log(`[Subtitle] Extracting track ${index} from: ${url}`); + + const args = [ + '-hide_banner', + '-loglevel', 'warning', + '-user_agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36', + '-probesize', '5000000', + '-analyzeduration', '5000000', + '-i', url, + '-map', `0:${index}`, + '-c:s', 'webvtt', + '-f', 'webvtt', + '-' + ]; + + const ffmpeg = spawn(ffmpegPath, args); + + res.setHeader('Content-Type', 'text/vtt'); + res.setHeader('Access-Control-Allow-Origin', '*'); + + // Pipe stdout to response + ffmpeg.stdout.pipe(res); + + ffmpeg.stderr.on('data', (data) => { + // console.error(`[Subtitle FFmpeg] ${data}`); + }); + + req.on('close', () => { + ffmpeg.kill('SIGKILL'); + }); + + ffmpeg.on('error', (err) => { + console.error('[Subtitle] Failed to spawn FFmpeg:', err); + if (!res.headersSent) { + res.status(500).send('Subtitle extraction failed'); + } + }); +}); + +module.exports = router; diff --git a/server/routes/transcode.js b/server/routes/transcode.js index 969730d..bdc0402 100644 --- a/server/routes/transcode.js +++ b/server/routes/transcode.js @@ -25,6 +25,7 @@ router.get('/', (req, res) => { const args = [ '-hide_banner', '-loglevel', 'warning', + '-user_agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36', // Faster startup - reduced probe/analyze for quicker first bytes '-probesize', '2000000', // 2MB (reduced from 5MB) '-analyzeduration', '3000000', // 3 seconds (reduced from 10s)