622 lines
18 KiB
TypeScript
622 lines
18 KiB
TypeScript
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<string, ActiveStream>();
|
|
|
|
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<string, string> = {
|
|
'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<void> {
|
|
if (info.subtitles.length === 0) return;
|
|
|
|
console.log('📝 Extrayendo subtítulos...');
|
|
|
|
const promises = info.subtitles.map((s, i) => {
|
|
return new Promise<void>((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<MediaInfo> {
|
|
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
|
|
}));
|
|
} |