Instancia transcoder t1: cambios locales
This commit is contained in:
+134
-26
@@ -1,10 +1,45 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { spawn } = require('child_process');
|
||||
const { spawn, execFile } = require('child_process');
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const db = require('../db');
|
||||
const transcodeSession = require('../services/transcodeSession');
|
||||
const streamInput = require('../utils/streamInput');
|
||||
|
||||
/**
|
||||
* Probe a stream URL with FFprobe to get its total duration in seconds.
|
||||
* VOD pesado usa probes mayores y timeout más alto (MKV Jellyfin/remux).
|
||||
*/
|
||||
function probeDuration(url, ffprobePath, userAgent) {
|
||||
return new Promise((resolve) => {
|
||||
const sizing = streamInput.probeSizingForUrl(url);
|
||||
const referer = streamInput.deriveRefererFromUrl(url);
|
||||
const args = [
|
||||
'-v', 'quiet',
|
||||
'-print_format', 'json',
|
||||
'-show_entries', 'format=duration',
|
||||
'-user_agent', userAgent || 'Mozilla/5.0',
|
||||
'-probesize', sizing.probesize,
|
||||
'-analyzeduration', sizing.analyzeduration,
|
||||
];
|
||||
if (referer) {
|
||||
args.push('-referer', referer);
|
||||
}
|
||||
args.push(url);
|
||||
const timeoutMs = sizing.heavy ? 45000 : 8000;
|
||||
const proc = execFile(ffprobePath || 'ffprobe', args, { timeout: timeoutMs }, (err, stdout) => {
|
||||
if (err) { resolve(0); return; }
|
||||
try {
|
||||
const data = JSON.parse(stdout);
|
||||
const dur = parseFloat(data?.format?.duration);
|
||||
resolve(Number.isFinite(dur) && dur > 0 ? Math.round(dur) : 0);
|
||||
} catch { resolve(0); }
|
||||
});
|
||||
// Ensure the process is killed if execFile timeout fires
|
||||
proc.on('error', () => resolve(0));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Transcode Routes
|
||||
@@ -40,38 +75,78 @@ router.post('/session', async (req, res) => {
|
||||
const userAgent = db.getUserAgent(settings);
|
||||
|
||||
try {
|
||||
const session = await transcodeSession.createSession(url, {
|
||||
ffmpegPath,
|
||||
userAgent,
|
||||
seekOffset: seekOffset || 0,
|
||||
hwEncoder: settings.hwEncoder || 'software',
|
||||
maxResolution: settings.maxResolution || '1080p',
|
||||
quality: settings.quality || 'medium',
|
||||
audioMixPreset: settings.audioMixPreset || 'auto', // Audio downmix preset
|
||||
// Upscaling options
|
||||
upscaleEnabled: settings.upscaleEnabled || false,
|
||||
upscaleMethod: settings.upscaleMethod || 'hardware',
|
||||
upscaleTarget: settings.upscaleTarget || '1080p',
|
||||
videoMode: videoMode, // 'copy' or 'encode'
|
||||
videoCodec: videoCodec, // 'h264', 'hevc', etc.
|
||||
audioCodec: audioCodec, // 'aac', 'ac3', etc.
|
||||
audioChannels: audioChannels // number of channels (2=stereo)
|
||||
});
|
||||
const ffprobePath = (ffmpegPath || 'ffmpeg').replace(/ffmpeg$/, 'ffprobe');
|
||||
|
||||
await session.start();
|
||||
// Probe duration and start session in parallel (don't block on duration)
|
||||
const [session, durationSec] = await Promise.all([
|
||||
transcodeSession.createSession(url, {
|
||||
ffmpegPath,
|
||||
userAgent,
|
||||
seekOffset: seekOffset || 0,
|
||||
hwEncoder: settings.hwEncoder || 'software',
|
||||
maxResolution: settings.maxResolution || '1080p',
|
||||
quality: settings.quality || 'medium',
|
||||
audioMixPreset: settings.audioMixPreset || 'auto',
|
||||
upscaleEnabled: settings.upscaleEnabled || false,
|
||||
upscaleMethod: settings.upscaleMethod || 'hardware',
|
||||
upscaleTarget: settings.upscaleTarget || '1080p',
|
||||
videoMode: videoMode,
|
||||
videoCodec: videoCodec,
|
||||
audioCodec: audioCodec,
|
||||
audioChannels: audioChannels
|
||||
}).then(async s => { await s.start(); return s; }),
|
||||
probeDuration(url, ffprobePath, userAgent)
|
||||
]);
|
||||
|
||||
// Wait for playlist to be ready (first segments generated)
|
||||
const ready = await session.waitForPlaylist(15000);
|
||||
// VOD pesado (4K HEVC/remux) puede tardar >15 s en generar el primer segmento
|
||||
const waitTimeout = streamInput.isHeavyVodUrl(url) ? 60000 : 15000;
|
||||
let ready = await session.waitForPlaylist(waitTimeout);
|
||||
|
||||
// Fallback a software si el encoder HW falla inmediatamente (error rápido)
|
||||
if (!ready && session.status === 'error' && (settings.hwEncoder || 'software') !== 'software') {
|
||||
console.warn(`[Transcode] HW encoder (${settings.hwEncoder}) falló, reintentando con software libx264...`);
|
||||
await transcodeSession.removeSession(session.id);
|
||||
const swSession = await transcodeSession.createSession(url, {
|
||||
ffmpegPath,
|
||||
userAgent,
|
||||
seekOffset: seekOffset || 0,
|
||||
hwEncoder: 'software',
|
||||
maxResolution: settings.maxResolution || '1080p',
|
||||
quality: settings.quality || 'medium',
|
||||
audioMixPreset: settings.audioMixPreset || 'auto',
|
||||
upscaleEnabled: false,
|
||||
videoMode: videoMode,
|
||||
videoCodec: videoCodec,
|
||||
audioCodec: audioCodec,
|
||||
audioChannels: audioChannels
|
||||
});
|
||||
await swSession.start();
|
||||
ready = await swSession.waitForPlaylist(waitTimeout);
|
||||
if (ready) {
|
||||
console.log(`[Transcode] Software fallback OK → session ${swSession.id}`);
|
||||
return res.json({
|
||||
sessionId: swSession.id,
|
||||
playlistUrl: `/api/transcode/${swSession.id}/stream.m3u8`,
|
||||
status: swSession.status,
|
||||
durationSec: durationSec || 0
|
||||
});
|
||||
}
|
||||
await transcodeSession.removeSession(swSession.id);
|
||||
}
|
||||
|
||||
if (!ready) {
|
||||
await transcodeSession.removeSession(session.id);
|
||||
return res.status(500).json({ error: 'Transcoding failed to start', reason: 'Playlist not generated in time' });
|
||||
}
|
||||
|
||||
console.log(`[Transcode] Session ${session.id} ready. Source duration: ${durationSec}s`);
|
||||
|
||||
res.json({
|
||||
sessionId: session.id,
|
||||
playlistUrl: `/api/transcode/${session.id}/stream.m3u8`,
|
||||
status: session.status
|
||||
status: session.status,
|
||||
durationSec: durationSec || 0
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
@@ -144,6 +219,30 @@ router.delete('/:sessionId', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Heartbeat — keeps a session alive while the player is active.
|
||||
* POST /api/transcode/:sessionId/heartbeat
|
||||
*/
|
||||
router.post('/:sessionId/heartbeat', (req, res) => {
|
||||
const session = transcodeSession.getSession(req.params.sessionId);
|
||||
if (!session) return res.status(404).json({ error: 'Session not found' });
|
||||
session.lastAccess = Date.now();
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* Stop session via POST (for sendBeacon on page close)
|
||||
* POST /api/transcode/:sessionId/stop
|
||||
*/
|
||||
router.post('/:sessionId/stop', async (req, res) => {
|
||||
try {
|
||||
await transcodeSession.removeSession(req.params.sessionId);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Failed to remove session', details: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* List all active sessions (for debugging)
|
||||
* GET /api/transcode/sessions
|
||||
@@ -175,6 +274,9 @@ router.get('/', async (req, res) => {
|
||||
console.log(`[Transcode] Using User-Agent: ${settings.userAgentPreset}`);
|
||||
console.log(`[Transcode] Using binary: ${ffmpegPath}`);
|
||||
|
||||
const sizing = streamInput.probeSizingForUrl(url);
|
||||
const referer = streamInput.deriveRefererFromUrl(url);
|
||||
|
||||
// FFmpeg arguments for transcoding
|
||||
// Optimized for VOD content with incompatible audio (Dolby/AC3/EAC3)
|
||||
// Also works for live streams with ad stitching (Pluto TV, etc.)
|
||||
@@ -182,9 +284,13 @@ router.get('/', async (req, res) => {
|
||||
'-hide_banner',
|
||||
'-loglevel', 'warning',
|
||||
'-user_agent', userAgent,
|
||||
// Faster startup - reduced probe/analyze for quicker first bytes
|
||||
'-probesize', '2000000', // 2MB (reduced from 5MB)
|
||||
'-analyzeduration', '3000000', // 3 seconds (reduced from 10s)
|
||||
];
|
||||
if (referer) {
|
||||
args.push('-referer', referer);
|
||||
}
|
||||
args.push(
|
||||
'-probesize', sizing.probesize,
|
||||
'-analyzeduration', sizing.analyzeduration,
|
||||
// Error resilience: generate timestamps, discard corrupt packets
|
||||
'-fflags', '+genpts+discardcorrupt+nobuffer',
|
||||
// Ignore errors in stream and continue
|
||||
@@ -197,7 +303,9 @@ router.get('/', async (req, res) => {
|
||||
'-reconnect_delay_max', '3',
|
||||
// Prevent Range/HEAD requests that some providers reject with 405
|
||||
'-seekable', '0',
|
||||
'-i', url,
|
||||
'-i', url
|
||||
);
|
||||
args.push(
|
||||
// Map only first video and audio stream (avoid subtitle streams causing issues)
|
||||
'-map', '0:v:0',
|
||||
'-map', '0:a:0?', // ? makes audio optional if not present
|
||||
@@ -218,7 +326,7 @@ router.get('/', async (req, res) => {
|
||||
'-movflags', 'frag_keyframe+empty_moov+default_base_moof+faststart',
|
||||
'-flush_packets', '1', // Send data immediately
|
||||
'-' // Output to stdout
|
||||
];
|
||||
);
|
||||
|
||||
console.log(`[Transcode] Full command: ${ffmpegPath} ${args.join(' ')}`);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user