feat: add subtitle extraction and display for MKV/container streams
This commit is contained in:
@@ -676,6 +676,28 @@ class VideoPlayer {
|
|||||||
const info = await probeRes.json();
|
const info = await probeRes.json();
|
||||||
console.log(`[Player] Probe result: video=${info.video}, audio=${info.audio}, compatible=${info.compatible}`);
|
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) {
|
if (info.needsTranscode) {
|
||||||
// Incompatible audio (AC3/EAC3/DTS) - use transcode
|
// Incompatible audio (AC3/EAC3/DTS) - use transcode
|
||||||
console.log('[Player] Auto: Using transcode (incompatible audio)');
|
console.log('[Player] Auto: Using transcode (incompatible audio)');
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ app.use('/api/favorites', require('./routes/favorites'));
|
|||||||
app.use('/api/transcode', require('./routes/transcode'));
|
app.use('/api/transcode', require('./routes/transcode'));
|
||||||
app.use('/api/remux', require('./routes/remux'));
|
app.use('/api/remux', require('./routes/remux'));
|
||||||
app.use('/api/probe', require('./routes/probe'));
|
app.use('/api/probe', require('./routes/probe'));
|
||||||
|
app.use('/api/subtitle', require('./routes/subtitle'));
|
||||||
app.use('/api/settings', require('./routes/settings'));
|
app.use('/api/settings', require('./routes/settings'));
|
||||||
|
|
||||||
// SPA fallback - serve index.html for all non-API routes
|
// SPA fallback - serve index.html for all non-API routes
|
||||||
|
|||||||
+13
-1
@@ -32,6 +32,7 @@ function probeStream(url, ffprobePath, timeout = 15000) {
|
|||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const args = [
|
const args = [
|
||||||
'-v', 'error',
|
'-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',
|
'-print_format', 'json',
|
||||||
'-show_streams',
|
'-show_streams',
|
||||||
'-show_format',
|
'-show_format',
|
||||||
@@ -100,6 +101,16 @@ function analyzeProbeResult(probeResult, url) {
|
|||||||
// Check if it's a raw TS stream (not HLS)
|
// Check if it's a raw TS stream (not HLS)
|
||||||
const isRawTs = (container.includes('mpegts') || url.endsWith('.ts')) && !url.includes('.m3u8');
|
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
|
// Determine what processing is needed
|
||||||
// 1. Incompatible audio -> Transcode (highest priority)
|
// 1. Incompatible audio -> Transcode (highest priority)
|
||||||
const needsTranscode = !audioOk;
|
const needsTranscode = !audioOk;
|
||||||
@@ -116,7 +127,8 @@ function analyzeProbeResult(probeResult, url) {
|
|||||||
container: container,
|
container: container,
|
||||||
compatible: compatible,
|
compatible: compatible,
|
||||||
needsRemux: needsRemux,
|
needsRemux: needsRemux,
|
||||||
needsTranscode: needsTranscode
|
needsTranscode: needsTranscode,
|
||||||
|
subtitles: subtitles
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ router.get('/', (req, res) => {
|
|||||||
const args = [
|
const args = [
|
||||||
'-hide_banner',
|
'-hide_banner',
|
||||||
'-loglevel', 'warning',
|
'-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
|
// Low-latency startup: reduce probe/analyze time for faster first bytes
|
||||||
'-probesize', '32768',
|
'-probesize', '32768',
|
||||||
'-analyzeduration', '500000', // 0.5 seconds - enough to detect audio
|
'-analyzeduration', '500000', // 0.5 seconds - enough to detect audio
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -25,6 +25,7 @@ router.get('/', (req, res) => {
|
|||||||
const args = [
|
const args = [
|
||||||
'-hide_banner',
|
'-hide_banner',
|
||||||
'-loglevel', 'warning',
|
'-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
|
// Faster startup - reduced probe/analyze for quicker first bytes
|
||||||
'-probesize', '2000000', // 2MB (reduced from 5MB)
|
'-probesize', '2000000', // 2MB (reduced from 5MB)
|
||||||
'-analyzeduration', '3000000', // 3 seconds (reduced from 10s)
|
'-analyzeduration', '3000000', // 3 seconds (reduced from 10s)
|
||||||
|
|||||||
Reference in New Issue
Block a user