import { getConfig as loadConfig } from '../../shared/config'; import { queryOne } from '../../shared/database'; import crypto from 'crypto'; import fs from 'fs'; import path from 'path'; import { spawn } from 'child_process'; const { values } = loadConfig(); const FFMPEG_PATH = values.paths?.ffmpeg || 'ffmpeg'; const FFPROBE_PATH = values.paths?.ffprobe || 'ffprobe'; const STREAM_TTL = 2 * 60 * 60 * 1000; type VideoStreamInfo = { index: number; codec: string; width: number; height: number; fps: number; }; type AudioStreamInfo = { index: number; codec: string; language?: string; title?: string; channels: number; sampleRate: number; }; type SubtitleStreamInfo = { index: number; codec: string; language?: string; title?: string; duration: number; }; type ChapterInfo = { id: number; start: number; end: number; title: string; }; type MediaInfo = { video: VideoStreamInfo[]; audio: AudioStreamInfo[]; subtitles: SubtitleStreamInfo[]; chapters: ChapterInfo[]; duration: number; }; type ActiveStream = { hash: string; filePath: string; hlsDir: string; info: MediaInfo; process?: any; startedAt: number; lastAccessed: number; isComplete: boolean; }; const activeStreams = new Map(); setInterval(() => { const now = Date.now(); for (const [hash, stream] of activeStreams.entries()) { const age = now - stream.lastAccessed; if (age > STREAM_TTL) { console.log(`🗑️ Limpiando stream antiguo: ${hash}`); if (stream.process && !stream.process.killed) { stream.process.kill('SIGKILL'); } if (fs.existsSync(stream.hlsDir)) { fs.rmSync(stream.hlsDir, { recursive: true, force: true }); } activeStreams.delete(hash); } } }, 60 * 1000); export function getStreamHash(filePath: string): string { const stat = fs.statSync(filePath); return crypto .createHash('md5') .update(`${filePath}-${stat.mtime.getTime()}`) .digest('hex'); } function ensureHLSDirectory(hash: string): string { const tempDir = path.join(require('os').tmpdir(), 'hls-streams', hash); if (!fs.existsSync(tempDir)) { fs.mkdirSync(tempDir, { recursive: true }); } return tempDir; } function buildStreamMap(info: MediaInfo): string { const maps: string[] = []; maps.push(`v:0,a:0`); return maps.join(' '); } function writeMasterPlaylist(info: MediaInfo, hlsDir: string) { const lines: string[] = ['#EXTM3U']; info.audio.forEach((a, i) => { lines.push( `#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",NAME="${a.title || `Audio ${i+1}`}",LANGUAGE="${a.language || 'und'}",AUTOSELECT=YES,DEFAULT=${i===0?'YES':'NO'},URI="audio_${i}.m3u8"` ); }); info.subtitles.forEach((s, i) => { lines.push( `#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",NAME="${s.title || `Sub ${i+1}`}",LANGUAGE="${s.language || 'und'}",AUTOSELECT=YES,DEFAULT=NO,URI="subs_${i}.m3u8"` ); }); const v = info.video[0]; const bandwidth = v.width * v.height * v.fps * 0.07 | 0; lines.push( `#EXT-X-STREAM-INF:BANDWIDTH=${bandwidth},RESOLUTION=${v.width}x${v.height},AUDIO="audio"${info.subtitles.length ? ',SUBTITLES="subs"' : ''}` ); lines.push('video.m3u8'); fs.writeFileSync(path.join(hlsDir, 'master.m3u8'), lines.join('\n')); } function startHLSConversion(filePath: string, info: MediaInfo, hash: string): ActiveStream { const hlsDir = ensureHLSDirectory(hash); const stream: ActiveStream = { hash, filePath, hlsDir, info, process: null, startedAt: Date.now(), lastAccessed: Date.now(), isComplete: false }; activeStreams.set(hash, stream); (async () => { try { await extractSubtitles(filePath, info, hlsDir); writeMasterPlaylist(info, hlsDir); startVideoTranscoding(stream, filePath, info, hlsDir); } catch (err) { console.error('❌ Error en el flujo de inicio:', err); } })(); return stream; } function startVideoTranscoding(stream: ActiveStream, filePath: string, info: MediaInfo, hlsDir: string) { const args: string[] = ['-i', filePath]; args.push( '-map', '0:v:0', '-c:v', 'libx264', '-preset', 'veryfast', '-profile:v', 'main', '-g', '48', '-keyint_min', '48', '-sc_threshold', '0', '-pix_fmt', 'yuv420p', '-f', 'hls', '-hls_time', '4', '-hls_playlist_type', 'event', '-hls_flags', 'independent_segments', '-hls_segment_filename', path.join(hlsDir, 'v_%05d.ts'), path.join(hlsDir, 'video.m3u8') ); info.audio.forEach((a, i) => { args.push( '-map', `0:${a.index}`, `-c:a:${i}`, 'aac', `-b:a:${i}`, '128k', '-f', 'hls', '-hls_time', '4', '-hls_playlist_type', 'event', '-hls_flags', 'independent_segments', '-hls_segment_filename', path.join(hlsDir, `a${i}_%05d.ts`), path.join(hlsDir, `audio_${i}.m3u8`) ); }); console.log('🎬 Starting Video/Audio transcoding:', args.join(' ')); const ffmpeg = spawn(FFMPEG_PATH, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }); stream.process = ffmpeg; ffmpeg.stderr.on('data', (data) => { const text = data.toString(); if (text.includes('time=')) { const timeMatch = text.match(/time=(\S+)/); if (timeMatch) console.log(`⏱️ Converting ${stream.hash.substr(0,6)}: ${timeMatch[1]}`); } }); ffmpeg.on('close', (code) => { if (code === 0) { console.log('✅ Video transcoding complete'); stream.isComplete = true; } }); } const SUBTITLE_EXTENSIONS: Record = { 'ass': 'ass', 'ssa': 'ass', 'subrip': 'srt', 'webvtt': 'vtt', 'hdmv_pgs_subtitle': 'sup', 'mov_text': 'srt', 'dvd_subtitle': 'sub', 'text': 'srt' }; async function extractSubtitles(filePath: string, info: MediaInfo, hlsDir: string): Promise { if (info.subtitles.length === 0) return; console.log('📝 Extrayendo subtítulos...'); const promises = info.subtitles.map((s, i) => { return new Promise((resolve, reject) => { const codec = s.codec.toLowerCase(); const ext = codec === 'subrip' ? 'srt' : codec === 'ass' || codec === 'ssa' ? 'ass' : codec === 'webvtt' ? 'vtt' : 'sub'; const outputFilename = `s${i}_full.${ext}`; const outputPath = path.join(hlsDir, outputFilename); const playlistPath = path.join(hlsDir, `subs_${i}.m3u8`); if (s.duration === 0) { console.log(`⚠️ Sub vacío, skip: ${i}`); createDummySubtitlePlaylist(playlistPath, outputFilename, info.duration); fs.writeFileSync(outputPath, ''); resolve(); return; } console.log(` Track ${i}: ${s.title || s.language || 'Unknown'} [${codec}] -> ${ext}`); const args = [ '-i', filePath, '-map', `0:s:${i}` ]; args.push('-c:s', 'copy'); args.push('-y', outputPath); console.log(` Comando: ffmpeg ${args.join(' ')}`); const p = spawn(FFMPEG_PATH, args, { stdio: ['ignore', 'pipe', 'pipe'] }); let errorOutput = ''; p.stderr.on('data', (data) => { errorOutput += data.toString(); }); p.on('close', (code) => { if (code === 0) { try { if (!fs.existsSync(outputPath)) { console.error(` ❌ Archivo no creado: ${outputFilename}`); fs.writeFileSync(outputPath, ''); } else { const stat = fs.statSync(outputPath); if (stat.size === 0) { console.error(` ⚠️ Subtítulo ${i} tiene 0 bytes`); console.error(` FFmpeg stderr:`, errorOutput.slice(-500)); } else { console.log(` ✅ Subtítulo ${i} extraído: ${stat.size} bytes`); } } createDummySubtitlePlaylist(playlistPath, outputFilename, info.duration); resolve(); } catch (e) { console.error(` Error procesando subtítulo ${i}:`, e); resolve(); } } else { console.error(` ❌ Error extrayendo subtítulo ${i}. Exit code: ${code}`); console.error(` FFmpeg stderr:`, errorOutput.slice(-500)); fs.writeFileSync(outputPath, ''); createDummySubtitlePlaylist(playlistPath, outputFilename, info.duration); resolve(); } }); p.on('error', (err) => { console.error(` Error spawn ffmpeg subs ${i}:`, err); fs.writeFileSync(outputPath, ''); createDummySubtitlePlaylist(playlistPath, outputFilename, info.duration); resolve(); }); }); }); await Promise.all(promises); console.log('✅ Proceso de subtítulos finalizado.'); } function createDummySubtitlePlaylist(playlistPath: string, subtitleFilename: string, duration: number) { const content = [ '#EXTM3U', '#EXT-X-VERSION:3', `#EXT-X-TARGETDURATION:${Math.ceil(duration)}`, '#EXT-X-MEDIA-SEQUENCE:0', '#EXT-X-PLAYLIST-TYPE:VOD', `#EXTINF:${duration.toFixed(6)},`, subtitleFilename, '#EXT-X-ENDLIST' ].join('\n'); fs.writeFileSync(playlistPath, content); } async function probeMediaFile(filePath: string): Promise { return new Promise((resolve, reject) => { const args = [ '-v', 'quiet', '-print_format', 'json', '-show_streams', '-show_chapters', '-show_format', filePath ]; const ffprobe = spawn(FFPROBE_PATH, args); let output = ''; ffprobe.stdout.on('data', (data) => { output += data.toString(); }); ffprobe.on('close', (code) => { if (code !== 0) { return reject(new Error(`FFprobe failed with code ${code}`)); } try { const data = JSON.parse(output); const video: VideoStreamInfo[] = data.streams .filter((s: any) => s.codec_type === 'video') .map((s: any) => ({ index: s.index, codec: s.codec_name, width: s.width, height: s.height, fps: eval(s.r_frame_rate) || 24 })); const audio: AudioStreamInfo[] = data.streams .filter((s: any) => s.codec_type === 'audio') .map((s: any) => ({ index: s.index, codec: s.codec_name, language: s.tags?.language, title: s.tags?.title, channels: s.channels || 2, sampleRate: parseInt(s.sample_rate) || 48000 })); const subtitles: SubtitleStreamInfo[] = data.streams .filter((s: any) => s.codec_type === 'subtitle') .map((s: any) => ({ index: s.index, codec: s.codec_name, language: s.tags?.language, title: s.tags?.title, duration: parseFloat(s.duration) || 0 })); const chapters: ChapterInfo[] = (data.chapters || []).map((c: any) => ({ id: c.id, start: parseFloat(c.start_time), end: parseFloat(c.end_time), title: c.tags?.title || `Chapter ${c.id + 1}` })); const duration = parseFloat(data.format?.duration) || 0; console.log(`📊 Media info: ${video.length} video, ${audio.length} audio, ${subtitles.length} subs`); resolve({ video, audio, subtitles, chapters, duration }); } catch (e) { reject(e); } }); ffprobe.on('error', reject); }); } export async function getStreamingManifest(filePath: string) { if (!fs.existsSync(filePath)) { return null; } const hash = getStreamHash(filePath); const formatSubtitles = (subs: SubtitleStreamInfo[]) => { return subs.map((s, i) => { const ext = SUBTITLE_EXTENSIONS[s.codec] || 'vtt'; return { index: s.index, codec: s.codec, language: s.language || 'und', title: s.title || `Subtitle ${s.index}`, url: `/api/library/hls/${hash}/s${i}_full.${ext}` }; }); }; const existing = activeStreams.get(hash); if (existing) { existing.lastAccessed = Date.now(); return { type: 'hls', hash, masterPlaylist: `/api/library/hls/${hash}/master.m3u8`, duration: existing.info.duration, isComplete: existing.isComplete, video: existing.info.video.map(v => ({ index: v.index, codec: v.codec, resolution: `${v.width}x${v.height}`, fps: v.fps })), audio: existing.info.audio.map(a => ({ index: a.index, codec: a.codec, language: a.language || 'und', title: a.title || `Audio ${a.index}`, channels: a.channels })), subtitles: formatSubtitles(existing.info.subtitles), chapters: existing.info.chapters.map(c => ({ id: c.id, start: c.start, end: c.end, title: c.title })) }; } const duplicateCheck = activeStreams.get(hash); if (duplicateCheck) { duplicateCheck.lastAccessed = Date.now(); return { type: 'hls', hash, masterPlaylist: `/api/library/hls/${hash}/master.m3u8`, duration: duplicateCheck.info.duration, isComplete: duplicateCheck.isComplete, video: duplicateCheck.info.video.map(v => ({ index: v.index, codec: v.codec, resolution: `${v.width}x${v.height}`, fps: v.fps })), audio: duplicateCheck.info.audio.map(a => ({ index: a.index, codec: a.codec, language: a.language || 'und', title: a.title || `Audio ${a.index}`, channels: a.channels })), subtitles: formatSubtitles(duplicateCheck.info.subtitles), chapters: duplicateCheck.info.chapters.map(c => ({ id: c.id, start: c.start, end: c.end, title: c.title })) }; } const info = await probeMediaFile(filePath); return { type: 'hls', hash, masterPlaylist: `/api/library/hls/${hash}/master.m3u8`, duration: info.duration, isComplete: false, generating: true, video: info.video.map(v => ({ index: v.index, codec: v.codec, resolution: `${v.width}x${v.height}`, fps: v.fps })), audio: info.audio.map(a => ({ index: a.index, codec: a.codec, language: a.language || 'und', title: a.title || `Audio ${a.index}`, channels: a.channels })), subtitles: formatSubtitles(info.subtitles), chapters: info.chapters.map(c => ({ id: c.id, start: c.start, end: c.end, title: c.title })) }; } export function getSubtitleFileStream(hash: string, trackIndex: number) { const stream = activeStreams.get(hash); if (!stream) { const tempDir = path.join(require('os').tmpdir(), 'hls-streams', hash); if(fs.existsSync(tempDir)) { const files = fs.readdirSync(tempDir); const subFile = files.find(f => f.startsWith(`s${trackIndex}_full.`)); if (subFile) return fs.createReadStream(path.join(tempDir, subFile)); } return null; } if (!fs.existsSync(stream.hlsDir)) return null; const files = fs.readdirSync(stream.hlsDir); const subtitleFile = files.find(f => f.startsWith(`s${trackIndex}_full.`)); if (!subtitleFile) return null; return fs.createReadStream(path.join(stream.hlsDir, subtitleFile)); } export async function getHLSFile(hash: string, filename: string) { const stream = activeStreams.get(hash); if (!stream) { return null; } stream.lastAccessed = Date.now(); const filePath = path.join(stream.hlsDir, filename); const maxWait = 30000; const startWait = Date.now(); while (!fs.existsSync(filePath)) { if (Date.now() - startWait > maxWait) { console.error(`⏱️ Timeout esperando archivo: ${filename}`); return null; } if (stream.isComplete && !fs.existsSync(filePath)) { return null; } await new Promise(resolve => setTimeout(resolve, 100)); } return { path: filePath, stat: fs.statSync(filePath) }; } export function getActiveStreamsStatus() { return Array.from(activeStreams.values()).map(stream => ({ hash: stream.hash, filePath: stream.filePath, isComplete: stream.isComplete, startedAt: stream.startedAt, lastAccessed: stream.lastAccessed, age: Date.now() - stream.startedAt, idle: Date.now() - stream.lastAccessed })); }