feat: add subtitle extraction and display for MKV/container streams

This commit is contained in:
Trevor Mears
2026-01-10 12:35:39 -08:00
parent 346a0519e9
commit 8d36a2c07e
6 changed files with 96 additions and 1 deletions
+1
View File
@@ -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
+13 -1
View File
@@ -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
};
}
+1
View File
@@ -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
+58
View File
@@ -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;
+1
View File
@@ -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)