support for subs on local files
This commit is contained in:
@@ -399,7 +399,6 @@ export async function downloadBook(request: FastifyRequest<{ Body: DownloadBookB
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NUEVO: Estado de descargas
|
|
||||||
export async function getDownloadStatus(request: FastifyRequest, reply: FastifyReply) {
|
export async function getDownloadStatus(request: FastifyRequest, reply: FastifyReply) {
|
||||||
try {
|
try {
|
||||||
const downloads = downloadService.getActiveDownloads();
|
const downloads = downloadService.getActiveDownloads();
|
||||||
@@ -426,7 +425,6 @@ export async function getDownloadStatus(request: FastifyRequest, reply: FastifyR
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NUEVO: Streaming HLS para anime local
|
|
||||||
export async function getAnimeStreamManifest(request: FastifyRequest, reply: FastifyReply) {
|
export async function getAnimeStreamManifest(request: FastifyRequest, reply: FastifyReply) {
|
||||||
try {
|
try {
|
||||||
const { type, id, unit } = request.params as any;
|
const { type, id, unit } = request.params as any;
|
||||||
@@ -454,7 +452,6 @@ export async function getAnimeStreamManifest(request: FastifyRequest, reply: Fas
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NUEVO: Servir archivos HLS
|
|
||||||
export async function serveHLSFile(request: FastifyRequest, reply: FastifyReply) {
|
export async function serveHLSFile(request: FastifyRequest, reply: FastifyReply) {
|
||||||
try {
|
try {
|
||||||
const { hash, filename } = request.params as any;
|
const { hash, filename } = request.params as any;
|
||||||
@@ -482,31 +479,3 @@ export async function serveHLSFile(request: FastifyRequest, reply: FastifyReply)
|
|||||||
return reply.status(500).send({ error: 'FAILED_TO_SERVE_HLS_FILE' });
|
return reply.status(500).send({ error: 'FAILED_TO_SERVE_HLS_FILE' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getSubtitle(request: FastifyRequest, reply: FastifyReply) {
|
|
||||||
try {
|
|
||||||
const { id, unit, track } = request.params as any;
|
|
||||||
|
|
||||||
// Validar que el track sea un número
|
|
||||||
const trackIndex = parseInt(track, 10);
|
|
||||||
if (isNaN(trackIndex)) {
|
|
||||||
return reply.status(400).send({ error: 'INVALID_TRACK_INDEX' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const subtitleStream = await service.extractSubtitleTrack(id, unit, trackIndex);
|
|
||||||
|
|
||||||
if (!subtitleStream) {
|
|
||||||
return reply.status(404).send({ error: 'FILE_NOT_FOUND' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cabecera esencial para que el navegador entienda que son subtítulos
|
|
||||||
reply.header('Content-Type', 'text/vtt');
|
|
||||||
reply.header('Cache-Control', 'public, max-age=86400'); // Cachear por 1 día si quieres
|
|
||||||
|
|
||||||
return subtitleStream;
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error serving subtitles:', err);
|
|
||||||
return reply.status(500).send({ error: 'FAILED_TO_SERVE_SUBTITLES' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,7 +8,6 @@ async function localRoutes(fastify: FastifyInstance) {
|
|||||||
|
|
||||||
// Streaming básico (legacy)
|
// Streaming básico (legacy)
|
||||||
fastify.get('/library/stream/:type/:id/:unit', controller.streamUnit);
|
fastify.get('/library/stream/:type/:id/:unit', controller.streamUnit);
|
||||||
fastify.get('/library/stream/:type/:id/:unit/subs/:track', controller.getSubtitle);
|
|
||||||
fastify.post('/library/:type/:id/match', controller.matchEntry);
|
fastify.post('/library/:type/:id/match', controller.matchEntry);
|
||||||
fastify.get('/library/:id/units', controller.getUnits);
|
fastify.get('/library/:id/units', controller.getUnits);
|
||||||
fastify.get('/library/:unitId/manifest', controller.getManifest);
|
fastify.get('/library/:unitId/manifest', controller.getManifest);
|
||||||
|
|||||||
@@ -511,26 +511,3 @@ function parseEpubToHtml(filePath: string) {
|
|||||||
if (!entry) throw new Error('CHAPTER_NOT_FOUND');
|
if (!entry) throw new Error('CHAPTER_NOT_FOUND');
|
||||||
return entry.getData().toString('utf8');
|
return entry.getData().toString('utf8');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function extractSubtitleTrack(id: string, unit: string, trackIndex: number) {
|
|
||||||
// 1. Obtenemos la ruta real del archivo usando tu función existente
|
|
||||||
const fileInfo = await getFileForStreaming(id, unit);
|
|
||||||
|
|
||||||
if (!fileInfo || !fs.existsSync(fileInfo.filePath)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Generamos el hash (ID único del stream) igual que lo hace el streaming
|
|
||||||
const hash = getStreamHash(fileInfo.filePath);
|
|
||||||
|
|
||||||
// 3. Pedimos al servicio de streaming que busque el archivo en la carpeta temp
|
|
||||||
const stream = getSubtitleFileStream(hash, trackIndex);
|
|
||||||
|
|
||||||
if (stream) {
|
|
||||||
console.log(`✅ Subtítulo recuperado de caché (Hash: ${hash}, Track: ${trackIndex})`);
|
|
||||||
} else {
|
|
||||||
console.error(`❌ Subtítulo no encontrado en caché para ${hash}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return stream;
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,7 @@ const { values } = loadConfig();
|
|||||||
const FFMPEG_PATH = values.paths?.ffmpeg || 'ffmpeg';
|
const FFMPEG_PATH = values.paths?.ffmpeg || 'ffmpeg';
|
||||||
const FFPROBE_PATH = values.paths?.ffprobe || 'ffprobe';
|
const FFPROBE_PATH = values.paths?.ffprobe || 'ffprobe';
|
||||||
|
|
||||||
const STREAM_TTL = 2 * 60 * 60 * 1000; // 2 horas
|
const STREAM_TTL = 2 * 60 * 60 * 1000;
|
||||||
|
|
||||||
type VideoStreamInfo = {
|
type VideoStreamInfo = {
|
||||||
index: number;
|
index: number;
|
||||||
@@ -33,6 +33,7 @@ type SubtitleStreamInfo = {
|
|||||||
codec: string;
|
codec: string;
|
||||||
language?: string;
|
language?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
|
duration: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ChapterInfo = {
|
type ChapterInfo = {
|
||||||
@@ -63,7 +64,6 @@ type ActiveStream = {
|
|||||||
|
|
||||||
const activeStreams = new Map<string, ActiveStream>();
|
const activeStreams = new Map<string, ActiveStream>();
|
||||||
|
|
||||||
// Limpieza automática de streams antiguos
|
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
@@ -73,12 +73,10 @@ setInterval(() => {
|
|||||||
if (age > STREAM_TTL) {
|
if (age > STREAM_TTL) {
|
||||||
console.log(`🗑️ Limpiando stream antiguo: ${hash}`);
|
console.log(`🗑️ Limpiando stream antiguo: ${hash}`);
|
||||||
|
|
||||||
// Matar proceso si sigue corriendo
|
|
||||||
if (stream.process && !stream.process.killed) {
|
if (stream.process && !stream.process.killed) {
|
||||||
stream.process.kill('SIGKILL');
|
stream.process.kill('SIGKILL');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Eliminar archivos HLS
|
|
||||||
if (fs.existsSync(stream.hlsDir)) {
|
if (fs.existsSync(stream.hlsDir)) {
|
||||||
fs.rmSync(stream.hlsDir, { recursive: true, force: true });
|
fs.rmSync(stream.hlsDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
@@ -86,7 +84,274 @@ setInterval(() => {
|
|||||||
activeStreams.delete(hash);
|
activeStreams.delete(hash);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, 60 * 1000); // Revisar cada minuto
|
}, 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> {
|
async function probeMediaFile(filePath: string): Promise<MediaInfo> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -141,7 +406,8 @@ async function probeMediaFile(filePath: string): Promise<MediaInfo> {
|
|||||||
index: s.index,
|
index: s.index,
|
||||||
codec: s.codec_name,
|
codec: s.codec_name,
|
||||||
language: s.tags?.language,
|
language: s.tags?.language,
|
||||||
title: s.tags?.title
|
title: s.tags?.title,
|
||||||
|
duration: parseFloat(s.duration) || 0
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const chapters: ChapterInfo[] = (data.chapters || []).map((c: any) => ({
|
const chapters: ChapterInfo[] = (data.chapters || []).map((c: any) => ({
|
||||||
@@ -153,6 +419,8 @@ async function probeMediaFile(filePath: string): Promise<MediaInfo> {
|
|||||||
|
|
||||||
const duration = parseFloat(data.format?.duration) || 0;
|
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 });
|
resolve({ video, audio, subtitles, chapters, duration });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
reject(e);
|
reject(e);
|
||||||
@@ -163,261 +431,6 @@ async function probeMediaFile(filePath: string): Promise<MediaInfo> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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[] = [];
|
|
||||||
|
|
||||||
// Video principal con primer audio
|
|
||||||
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 {
|
|
||||||
// 1. Extraer subs (Copia rápida)
|
|
||||||
await extractSubtitles(filePath, info, hlsDir);
|
|
||||||
|
|
||||||
// 2. Crear Master Playlist (ahora los .m3u8 de los subs existen)
|
|
||||||
writeMasterPlaylist(info, hlsDir);
|
|
||||||
|
|
||||||
// 3. Empezar Video
|
|
||||||
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];
|
|
||||||
|
|
||||||
// VIDEO
|
|
||||||
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', // Event para streaming en vivo
|
|
||||||
'-hls_flags', 'independent_segments',
|
|
||||||
'-hls_segment_filename', path.join(hlsDir, 'v_%05d.ts'),
|
|
||||||
path.join(hlsDir, 'video.m3u8')
|
|
||||||
);
|
|
||||||
|
|
||||||
// AUDIOS
|
|
||||||
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`)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// SIN SUBTÍTULOS AQUÍ (Ya están hechos)
|
|
||||||
|
|
||||||
console.log('🎬 Starting Video/Audio transcoding:', args.join(' '));
|
|
||||||
|
|
||||||
const ffmpeg = spawn(FFMPEG_PATH, args, {
|
|
||||||
windowsHide: true,
|
|
||||||
stdio: ['ignore', 'pipe', 'pipe']
|
|
||||||
});
|
|
||||||
|
|
||||||
// Actualizamos el proceso en el objeto stream
|
|
||||||
stream.process = ffmpeg;
|
|
||||||
|
|
||||||
ffmpeg.stderr.on('data', (data) => {
|
|
||||||
const text = data.toString();
|
|
||||||
// Filtrar logs para ver el tiempo
|
|
||||||
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;
|
|
||||||
|
|
||||||
// Opcional: Convertir playlists a VOD estático
|
|
||||||
// changePlaylistTypeToVod(hlsDir);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const SUBTITLE_EXTENSIONS: Record<string, string> = {
|
|
||||||
'ass': 'ass',
|
|
||||||
'ssa': 'ass',
|
|
||||||
'subrip': 'srt',
|
|
||||||
'webvtt': 'vtt',
|
|
||||||
'hdmv_pgs_subtitle': 'sup', // PGS son imágenes, cuidado con esto en reproductores web
|
|
||||||
'mov_text': 'srt', // Convertiremos mov_text a 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 ext = SUBTITLE_EXTENSIONS[s.codec] || 'vtt';
|
|
||||||
const outputFilename = `s${i}_full.${ext}`;
|
|
||||||
const outputPath = path.join(hlsDir, outputFilename);
|
|
||||||
const playlistPath = path.join(hlsDir, `subs_${i}.m3u8`);
|
|
||||||
|
|
||||||
// Determinamos el encoder correcto
|
|
||||||
let encoderArgs = ['-c:s', 'copy']; // Default
|
|
||||||
|
|
||||||
// Lógica de conversión segura
|
|
||||||
if (ext === 'vtt') {
|
|
||||||
encoderArgs = ['-c:s', 'webvtt'];
|
|
||||||
} else if (ext === 'srt') {
|
|
||||||
encoderArgs = ['-c:s', 'srt'];
|
|
||||||
} else if (ext === 'ass') {
|
|
||||||
// ASS suele funcionar bien con copy, pero si falla, ffmpeg avisará
|
|
||||||
encoderArgs = ['-c:s', 'copy'];
|
|
||||||
}
|
|
||||||
|
|
||||||
// CASO ESPECIAL: Si es imagen (PGS/DVD) y no queremos extraerlo (opcional)
|
|
||||||
// Si quieres extraer PGS tal cual (no se verá en tag <track> de HTML5):
|
|
||||||
if (s.codec === 'hdmv_pgs_subtitle' || s.codec === 'dvd_subtitle') {
|
|
||||||
encoderArgs = ['-c:s', 'copy'];
|
|
||||||
}
|
|
||||||
|
|
||||||
const args = [
|
|
||||||
'-i', filePath,
|
|
||||||
'-map', `0:${s.index}`,
|
|
||||||
...encoderArgs,
|
|
||||||
'-y',
|
|
||||||
outputPath
|
|
||||||
];
|
|
||||||
|
|
||||||
const p = spawn(FFMPEG_PATH, args, { stdio: 'ignore' });
|
|
||||||
|
|
||||||
p.on('close', (code) => {
|
|
||||||
if (code === 0) {
|
|
||||||
try {
|
|
||||||
// Verificar que el archivo no esté vacío
|
|
||||||
const stat = fs.statSync(outputPath);
|
|
||||||
if (stat.size === 0) {
|
|
||||||
console.error(`⚠️ Subtítulo ${i} extraído pero tiene 0 bytes.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
createDummySubtitlePlaylist(playlistPath, outputFilename, info.duration);
|
|
||||||
resolve();
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`Error procesando playlist subtítulo ${i}:`, e);
|
|
||||||
resolve(); // Resolvemos para no bloquear el video
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.error(`❌ Error extrayendo subtítulo ${i} (Codec: ${s.codec} -> ${ext}). Code: ${code}`);
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
p.on('error', (err) => {
|
|
||||||
console.error(`Error spawn ffmpeg subs ${i}:`, err);
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getStreamingManifest(filePath: string) {
|
export async function getStreamingManifest(filePath: string) {
|
||||||
if (!fs.existsSync(filePath)) {
|
if (!fs.existsSync(filePath)) {
|
||||||
return null;
|
return null;
|
||||||
@@ -425,7 +438,6 @@ export async function getStreamingManifest(filePath: string) {
|
|||||||
|
|
||||||
const hash = getStreamHash(filePath);
|
const hash = getStreamHash(filePath);
|
||||||
|
|
||||||
// 1. DEFINIMOS LA FUNCIÓN HELPER AQUÍ FUERA PARA USARLA EN AMBOS CASOS
|
|
||||||
const formatSubtitles = (subs: SubtitleStreamInfo[]) => {
|
const formatSubtitles = (subs: SubtitleStreamInfo[]) => {
|
||||||
return subs.map((s, i) => {
|
return subs.map((s, i) => {
|
||||||
const ext = SUBTITLE_EXTENSIONS[s.codec] || 'vtt';
|
const ext = SUBTITLE_EXTENSIONS[s.codec] || 'vtt';
|
||||||
@@ -434,13 +446,12 @@ export async function getStreamingManifest(filePath: string) {
|
|||||||
codec: s.codec,
|
codec: s.codec,
|
||||||
language: s.language || 'und',
|
language: s.language || 'und',
|
||||||
title: s.title || `Subtitle ${s.index}`,
|
title: s.title || `Subtitle ${s.index}`,
|
||||||
// Aquí generamos la URL basada en el hash y el índice
|
|
||||||
url: `/api/library/hls/${hash}/s${i}_full.${ext}`
|
url: `/api/library/hls/${hash}/s${i}_full.${ext}`
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Caso 1: Stream ya existente
|
|
||||||
const existing = activeStreams.get(hash);
|
const existing = activeStreams.get(hash);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.lastAccessed = Date.now();
|
existing.lastAccessed = Date.now();
|
||||||
@@ -464,7 +475,7 @@ export async function getStreamingManifest(filePath: string) {
|
|||||||
title: a.title || `Audio ${a.index}`,
|
title: a.title || `Audio ${a.index}`,
|
||||||
channels: a.channels
|
channels: a.channels
|
||||||
})),
|
})),
|
||||||
// USAMOS EL HELPER
|
|
||||||
subtitles: formatSubtitles(existing.info.subtitles),
|
subtitles: formatSubtitles(existing.info.subtitles),
|
||||||
chapters: existing.info.chapters.map(c => ({
|
chapters: existing.info.chapters.map(c => ({
|
||||||
id: c.id,
|
id: c.id,
|
||||||
@@ -475,10 +486,40 @@ export async function getStreamingManifest(filePath: string) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Caso 2: Nuevo stream (Generating)
|
const duplicateCheck = activeStreams.get(hash);
|
||||||
const info = await probeMediaFile(filePath);
|
|
||||||
const stream = startHLSConversion(filePath, info, 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 {
|
return {
|
||||||
type: 'hls',
|
type: 'hls',
|
||||||
hash,
|
hash,
|
||||||
@@ -513,11 +554,10 @@ export function getSubtitleFileStream(hash: string, trackIndex: number) {
|
|||||||
const stream = activeStreams.get(hash);
|
const stream = activeStreams.get(hash);
|
||||||
|
|
||||||
if (!stream) {
|
if (!stream) {
|
||||||
// Opcional: Si el stream no está activo, podríamos intentar buscar
|
|
||||||
// si la carpeta existe en temporales de todas formas.
|
|
||||||
const tempDir = path.join(require('os').tmpdir(), 'hls-streams', hash);
|
const tempDir = path.join(require('os').tmpdir(), 'hls-streams', hash);
|
||||||
if(fs.existsSync(tempDir)) {
|
if(fs.existsSync(tempDir)) {
|
||||||
// Lógica de rescate si el servidor se reinició pero los archivos siguen ahí
|
|
||||||
const files = fs.readdirSync(tempDir);
|
const files = fs.readdirSync(tempDir);
|
||||||
const subFile = files.find(f => f.startsWith(`s${trackIndex}_full.`));
|
const subFile = files.find(f => f.startsWith(`s${trackIndex}_full.`));
|
||||||
if (subFile) return fs.createReadStream(path.join(tempDir, subFile));
|
if (subFile) return fs.createReadStream(path.join(tempDir, subFile));
|
||||||
@@ -527,7 +567,6 @@ export function getSubtitleFileStream(hash: string, trackIndex: number) {
|
|||||||
|
|
||||||
if (!fs.existsSync(stream.hlsDir)) return null;
|
if (!fs.existsSync(stream.hlsDir)) return null;
|
||||||
|
|
||||||
// Buscamos s{index}_full.{ext}
|
|
||||||
const files = fs.readdirSync(stream.hlsDir);
|
const files = fs.readdirSync(stream.hlsDir);
|
||||||
const subtitleFile = files.find(f => f.startsWith(`s${trackIndex}_full.`));
|
const subtitleFile = files.find(f => f.startsWith(`s${trackIndex}_full.`));
|
||||||
|
|
||||||
@@ -543,13 +582,12 @@ export async function getHLSFile(hash: string, filename: string) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Actualizar último acceso
|
|
||||||
stream.lastAccessed = Date.now();
|
stream.lastAccessed = Date.now();
|
||||||
|
|
||||||
const filePath = path.join(stream.hlsDir, filename);
|
const filePath = path.join(stream.hlsDir, filename);
|
||||||
|
|
||||||
// Esperar a que el archivo exista (con timeout)
|
const maxWait = 30000;
|
||||||
const maxWait = 30000; // 30 segundos
|
|
||||||
const startWait = Date.now();
|
const startWait = Date.now();
|
||||||
|
|
||||||
while (!fs.existsSync(filePath)) {
|
while (!fs.existsSync(filePath)) {
|
||||||
@@ -558,12 +596,10 @@ export async function getHLSFile(hash: string, filename: string) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Si el proceso terminó y el archivo no existe, error
|
|
||||||
if (stream.isComplete && !fs.existsSync(filePath)) {
|
if (stream.isComplete && !fs.existsSync(filePath)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Esperar un poco antes de reintentar
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -857,27 +857,15 @@ const AnimePlayer = (function() {
|
|||||||
if (hlsInstance) hlsInstance.audioTrack = parseInt(value);
|
if (hlsInstance) hlsInstance.audioTrack = parseInt(value);
|
||||||
} else if (type === 'subtitle') {
|
} else if (type === 'subtitle') {
|
||||||
const idx = parseInt(value);
|
const idx = parseInt(value);
|
||||||
_activeSubtitleIndex = idx; // <--- ACTUALIZAMOS EL ESTADO AQUÍ
|
_activeSubtitleIndex = idx;
|
||||||
|
|
||||||
// 1. Lógica nativa (para mantener compatibilidad interna)
|
|
||||||
if (els.video && els.video.textTracks) {
|
if (els.video && els.video.textTracks) {
|
||||||
Array.from(els.video.textTracks).forEach((track, i) => {
|
Array.from(els.video.textTracks).forEach((track, i) => {
|
||||||
// Si usamos JASSUB, ocultamos la nativa. Si no, mostramos la seleccionada.
|
track.mode = 'hidden';
|
||||||
track.mode = (subtitleRenderer && idx !== -1) ? 'hidden' : ((i === idx) ? 'showing' : 'hidden');
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Lógica de JASSUB
|
initSubtitleRenderer();
|
||||||
if (subtitleRenderer) {
|
|
||||||
if (idx === -1) {
|
|
||||||
subtitleRenderer.dispose();
|
|
||||||
} else {
|
|
||||||
const sub = _currentSubtitles[idx];
|
|
||||||
if (sub) {
|
|
||||||
subtitleRenderer.setTrack(sub.src);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (type === 'speed') {
|
} else if (type === 'speed') {
|
||||||
if (els.video) els.video.playbackRate = parseFloat(value);
|
if (els.video) els.video.playbackRate = parseFloat(value);
|
||||||
}
|
}
|
||||||
@@ -1185,20 +1173,6 @@ const AnimePlayer = (function() {
|
|||||||
if(els.loader) els.loader.style.display = 'flex';
|
if(els.loader) els.loader.style.display = 'flex';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getLocalEntryId() {
|
|
||||||
if (_localEntryId) return _localEntryId;
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/library/anime/${_animeId}`);
|
|
||||||
if (!res.ok) return null;
|
|
||||||
const data = await res.json();
|
|
||||||
_localEntryId = data.id;
|
|
||||||
return _localEntryId;
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Error fetching local ID:", e);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadStream() {
|
async function loadStream() {
|
||||||
if (!_currentEpisode) return;
|
if (!_currentEpisode) return;
|
||||||
_progressUpdated = false;
|
_progressUpdated = false;
|
||||||
@@ -1225,16 +1199,21 @@ const AnimePlayer = (function() {
|
|||||||
_rawVideoData = null;
|
_rawVideoData = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// En la función loadStream(), cuando detectas local:
|
||||||
|
|
||||||
if (currentExt === 'local') {
|
if (currentExt === 'local') {
|
||||||
try {
|
try {
|
||||||
setLoading("Fetching Local Unit Data...");
|
setLoading("Fetching Local Unit Data...");
|
||||||
|
|
||||||
// 1. Obtener las unidades locales para encontrar el ID del episodio específico
|
// 1. Obtener unidades
|
||||||
const unitsRes = await fetch(`/api/library/${_animeId}/units`);
|
const unitsRes = await fetch(`/api/library/${_animeId}/units`);
|
||||||
if (!unitsRes.ok) throw new Error("Could not fetch local units");
|
if (!unitsRes.ok) throw new Error("Could not fetch local units");
|
||||||
|
|
||||||
const unitsData = await unitsRes.json();
|
const unitsData = await unitsRes.json();
|
||||||
const targetUnit = unitsData.units ? unitsData.units.find(u => u.number === parseInt(_currentEpisode)) : null;
|
_localEntryId = unitsData.entry_id;
|
||||||
|
|
||||||
|
const targetUnit = unitsData.units ?
|
||||||
|
unitsData.units.find(u => u.number === parseInt(_currentEpisode)) : null;
|
||||||
|
|
||||||
if (!targetUnit) {
|
if (!targetUnit) {
|
||||||
throw new Error(`Episode ${_currentEpisode} not found in local library`);
|
throw new Error(`Episode ${_currentEpisode} not found in local library`);
|
||||||
@@ -1242,46 +1221,70 @@ const AnimePlayer = (function() {
|
|||||||
|
|
||||||
setLoading("Initializing HLS Stream...");
|
setLoading("Initializing HLS Stream...");
|
||||||
|
|
||||||
const manifestRes = await fetch(`/api/library/stream/anime/${unitsData.entry_id}/${targetUnit.number}/manifest`);
|
// 2. Obtener Manifest
|
||||||
|
const manifestRes = await fetch(
|
||||||
|
`/api/library/stream/anime/${unitsData.entry_id}/${targetUnit.number}/manifest`
|
||||||
|
);
|
||||||
|
|
||||||
if (!manifestRes.ok) throw new Error("Failed to generate stream manifest");
|
if (!manifestRes.ok) throw new Error("Failed to generate stream manifest");
|
||||||
|
|
||||||
const manifestData = await manifestRes.json();
|
const manifestData = await manifestRes.json();
|
||||||
|
|
||||||
|
// DEBUG: Verificar que llega aquí
|
||||||
|
console.log("Manifest Loaded:", manifestData);
|
||||||
|
|
||||||
|
// 3. Chapters
|
||||||
if (manifestData.chapters && manifestData.chapters.length > 0) {
|
if (manifestData.chapters && manifestData.chapters.length > 0) {
|
||||||
_skipIntervals = manifestData.chapters.map(c => ({
|
_skipIntervals = manifestData.chapters.map(c => ({
|
||||||
startTime: c.start,
|
startTime: c.start,
|
||||||
endTime: c.end,
|
endTime: c.end,
|
||||||
type: c.title.toLowerCase().includes('op') ? 'op' :
|
type: (c.title || '').toLowerCase().includes('op') ? 'op' :
|
||||||
c.title.toLowerCase().includes('ed') ? 'ed' : 'chapter'
|
(c.title || '').toLowerCase().includes('ed') ? 'ed' : 'chapter'
|
||||||
}));
|
}));
|
||||||
renderSkipMarkers(_skipIntervals);
|
renderSkipMarkers(_skipIntervals);
|
||||||
monitorSkipButton(_skipIntervals);
|
monitorSkipButton(_skipIntervals);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Mapear Subtítulos WebVTT
|
// 4. CORRECCIÓN DE SUBTÍTULOS
|
||||||
const subs = (manifestData.subtitles || []).map(s => ({
|
const rawSubs = manifestData.subtitles || [];
|
||||||
label: s.title || s.language || `Track ${s.index}`,
|
console.log("Raw Subtitles from JSON:", rawSubs);
|
||||||
srclang: s.language || 'unk',
|
|
||||||
src: s.url // URL al endpoint de conversión VTT (.vtt)
|
const subs = rawSubs.map((s, index) => {
|
||||||
}));
|
// Limpieza segura del código de idioma (ej. "English" -> "en")
|
||||||
|
let langCode = 'en';
|
||||||
|
if (s.language && typeof s.language === 'string' && s.language.length >= 2) {
|
||||||
|
langCode = s.language.substring(0, 2).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Título legible (Prioridad: Title > Language > Track #)
|
||||||
|
const label = s.title || s.language || `Track ${index + 1}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: label,
|
||||||
|
srclang: langCode,
|
||||||
|
src: s.url // Usamos la URL directa del JSON local
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// ACTUALIZACIÓN CRÍTICA DEL ESTADO GLOBAL
|
||||||
|
_currentSubtitles = subs;
|
||||||
|
console.log("Processed Subtitles:", _currentSubtitles);
|
||||||
|
|
||||||
// 5. Guardar referencia para MPV o descargas
|
|
||||||
_rawVideoData = {
|
_rawVideoData = {
|
||||||
url: manifestData.masterPlaylist,
|
url: manifestData.masterPlaylist,
|
||||||
headers: {}
|
headers: {}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 6. Cargar en el reproductor (Hls.js gestionará los audios del master.m3u8)
|
// Inicializar video pasando explícitamente los subs procesados
|
||||||
initVideoPlayer(manifestData.masterPlaylist, 'm3u8', subs);
|
initVideoPlayer(manifestData.masterPlaylist, 'm3u8', subs);
|
||||||
|
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
console.error("Local HLS Error:", e);
|
console.error("Local HLS Error:", e);
|
||||||
setLoading("Local Error: " + e.message);
|
setLoading("Local Error: " + e.message);
|
||||||
|
|
||||||
// Fallback: si falla, intentar cargar desde extensión online
|
// Fallback logic...
|
||||||
const localOption = els.extSelect.querySelector('option[value="local"]');
|
const localOption = els.extSelect.querySelector('option[value="local"]');
|
||||||
if (localOption) localOption.remove();
|
if (localOption) localOption.remove();
|
||||||
|
|
||||||
const fallbackSource = (_entrySource === 'local') ? 'anilist' : _entrySource;
|
const fallbackSource = (_entrySource === 'local') ? 'anilist' : _entrySource;
|
||||||
els.extSelect.value = fallbackSource;
|
els.extSelect.value = fallbackSource;
|
||||||
handleExtensionChange(true);
|
handleExtensionChange(true);
|
||||||
|
|||||||
@@ -1,60 +1,46 @@
|
|||||||
const BASE_PATH = '/src/scripts/jassub/';
|
const BASE_PATH = '/src/scripts/jassub/';
|
||||||
|
|
||||||
class SubtitleRenderer {
|
class SubtitleRenderer {
|
||||||
// 1. Aceptamos 'canvas' en el constructor
|
|
||||||
constructor(video, canvas) {
|
constructor(video, canvas) {
|
||||||
this.video = video;
|
this.video = video;
|
||||||
this.canvas = canvas;
|
this.canvas = canvas;
|
||||||
this.instance = null;
|
this.instance = null;
|
||||||
this.currentUrl = null;
|
this.currentUrl = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async init(subtitleUrl) {
|
async init(subtitleUrl) {
|
||||||
if (!this.video || !this.canvas) return; // 2. Verificamos canvas
|
if (!this.video || !this.canvas) return;
|
||||||
|
|
||||||
this.dispose();
|
this.dispose();
|
||||||
|
|
||||||
const finalUrl = subtitleUrl.includes('/api/proxy')
|
const finalUrl = subtitleUrl.includes('/api/proxy')
|
||||||
? subtitleUrl
|
? subtitleUrl
|
||||||
: `/api/proxy?url=${encodeURIComponent(subtitleUrl)}`;
|
: `/api/proxy?url=${encodeURIComponent(subtitleUrl)}`;
|
||||||
|
|
||||||
this.currentUrl = finalUrl;
|
this.currentUrl = finalUrl;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.instance = new JASSUB({
|
this.instance = new JASSUB({
|
||||||
video: this.video,
|
video: this.video,
|
||||||
canvas: this.canvas,
|
canvas: this.canvas,
|
||||||
subUrl: finalUrl,
|
subUrl: finalUrl,
|
||||||
|
|
||||||
workerUrl: `${BASE_PATH}jassub-worker.js`,
|
workerUrl: `${BASE_PATH}jassub-worker.js`,
|
||||||
wasmUrl: `${BASE_PATH}jassub-worker.wasm`,
|
wasmUrl: `${BASE_PATH}jassub-worker.wasm`,
|
||||||
modernWasmUrl: `${BASE_PATH}jassub-worker-modern.wasm`,
|
modernWasmUrl: `${BASE_PATH}jassub-worker-modern.wasm`,
|
||||||
|
|
||||||
blendMode: 'js',
|
blendMode: 'js',
|
||||||
asyncRender: true,
|
asyncRender: true,
|
||||||
onDemand: true,
|
onDemand: true,
|
||||||
targetFps: 60,
|
targetFps: 60,
|
||||||
debug: false
|
debug: false
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('JASSUB initialized for:', finalUrl);
|
console.log('JASSUB initialized for:', finalUrl);
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("JASSUB Init Error:", e);
|
console.error("JASSUB Init Error:", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
resize() {
|
resize() {
|
||||||
if (this.instance && this.instance.resize) {
|
if (this.instance && this.instance.resize) {
|
||||||
this.instance.resize();
|
this.instance.resize();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setTrack(url) {
|
setTrack(url) {
|
||||||
const finalUrl = url.includes('/api/proxy')
|
const finalUrl = url.includes('/api/proxy')
|
||||||
? url
|
? url
|
||||||
: `/api/proxy?url=${encodeURIComponent(url)}`;
|
: `/api/proxy?url=${encodeURIComponent(url)}`;
|
||||||
|
|
||||||
if (this.instance) {
|
if (this.instance) {
|
||||||
this.instance.setTrackByUrl(finalUrl);
|
this.instance.setTrackByUrl(finalUrl);
|
||||||
this.currentUrl = finalUrl;
|
this.currentUrl = finalUrl;
|
||||||
@@ -62,7 +48,6 @@ class SubtitleRenderer {
|
|||||||
this.init(url);
|
this.init(url);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dispose() {
|
dispose() {
|
||||||
if (this.instance) {
|
if (this.instance) {
|
||||||
try {
|
try {
|
||||||
@@ -74,8 +59,6 @@ class SubtitleRenderer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simple Renderer remains unchanged for SRT/VTT (Non-ASS)
|
|
||||||
class SimpleSubtitleRenderer {
|
class SimpleSubtitleRenderer {
|
||||||
constructor(video, canvas) {
|
constructor(video, canvas) {
|
||||||
this.video = video;
|
this.video = video;
|
||||||
@@ -83,11 +66,9 @@ class SimpleSubtitleRenderer {
|
|||||||
this.ctx = canvas.getContext('2d');
|
this.ctx = canvas.getContext('2d');
|
||||||
this.cues = [];
|
this.cues = [];
|
||||||
this.destroyed = false;
|
this.destroyed = false;
|
||||||
|
|
||||||
this.setupCanvas();
|
this.setupCanvas();
|
||||||
this.video.addEventListener('timeupdate', () => this.render());
|
this.video.addEventListener('timeupdate', () => this.render());
|
||||||
}
|
}
|
||||||
|
|
||||||
setupCanvas() {
|
setupCanvas() {
|
||||||
const updateSize = () => {
|
const updateSize = () => {
|
||||||
if (!this.video || !this.canvas) return;
|
if (!this.video || !this.canvas) return;
|
||||||
@@ -99,19 +80,31 @@ class SimpleSubtitleRenderer {
|
|||||||
window.addEventListener('resize', updateSize);
|
window.addEventListener('resize', updateSize);
|
||||||
this.resizeHandler = updateSize;
|
this.resizeHandler = updateSize;
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadSubtitles(url) {
|
async loadSubtitles(url) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/proxy?url=${encodeURIComponent(url)}`);
|
let finalUrl = url;
|
||||||
|
const isLocal = url.startsWith('/');
|
||||||
|
const isAlreadyProxied = url.includes('/api/proxy');
|
||||||
|
if (!isLocal && !isAlreadyProxied && (url.startsWith('http:') || url.startsWith('https:'))) {
|
||||||
|
finalUrl = `/api/proxy?url=${encodeURIComponent(url)}`;
|
||||||
|
}
|
||||||
|
console.log('Fetching subtitles from:', finalUrl);
|
||||||
|
const response = await fetch(finalUrl);
|
||||||
|
if (!response.ok) throw new Error(`Status: ${response.status}`);
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
this.cues = this.parseSRT(text);
|
this.cues = this.parseSRT(text);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load subtitles:', error);
|
console.error('Failed to load subtitles:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
setTrack(url) {
|
||||||
|
this.cues = [];
|
||||||
|
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||||
|
this.loadSubtitles(url);
|
||||||
|
}
|
||||||
parseSRT(srtText) {
|
parseSRT(srtText) {
|
||||||
const blocks = srtText.trim().split('\n\n');
|
const normalizedText = srtText.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||||
|
const blocks = normalizedText.trim().split('\n\n');
|
||||||
return blocks.map(block => {
|
return blocks.map(block => {
|
||||||
const lines = block.split('\n');
|
const lines = block.split('\n');
|
||||||
if (lines.length < 3) return null;
|
if (lines.length < 3) return null;
|
||||||
@@ -119,11 +112,12 @@ class SimpleSubtitleRenderer {
|
|||||||
if (!timeMatch) return null;
|
if (!timeMatch) return null;
|
||||||
const start = parseInt(timeMatch[1]) * 3600 + parseInt(timeMatch[2]) * 60 + parseInt(timeMatch[3]) + parseInt(timeMatch[4]) / 1000;
|
const start = parseInt(timeMatch[1]) * 3600 + parseInt(timeMatch[2]) * 60 + parseInt(timeMatch[3]) + parseInt(timeMatch[4]) / 1000;
|
||||||
const end = parseInt(timeMatch[5]) * 3600 + parseInt(timeMatch[6]) * 60 + parseInt(timeMatch[7]) + parseInt(timeMatch[8]) / 1000;
|
const end = parseInt(timeMatch[5]) * 3600 + parseInt(timeMatch[6]) * 60 + parseInt(timeMatch[7]) + parseInt(timeMatch[8]) / 1000;
|
||||||
const text = lines.slice(2).join('\n');
|
let text = lines.slice(2).join('\n');
|
||||||
|
text = text.replace(/<[^>]*>/g, '');
|
||||||
|
text = text.replace(/\{[^}]*\}/g, '');
|
||||||
return { start, end, text };
|
return { start, end, text };
|
||||||
}).filter(Boolean);
|
}).filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
if (this.destroyed) return;
|
if (this.destroyed) return;
|
||||||
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||||
@@ -131,7 +125,6 @@ class SimpleSubtitleRenderer {
|
|||||||
const cue = this.cues.find(c => currentTime >= c.start && currentTime <= c.end);
|
const cue = this.cues.find(c => currentTime >= c.start && currentTime <= c.end);
|
||||||
if (cue) this.drawSubtitle(cue.text);
|
if (cue) this.drawSubtitle(cue.text);
|
||||||
}
|
}
|
||||||
|
|
||||||
drawSubtitle(text) {
|
drawSubtitle(text) {
|
||||||
const lines = text.split('\n');
|
const lines = text.split('\n');
|
||||||
const fontSize = Math.max(20, this.canvas.height * 0.04);
|
const fontSize = Math.max(20, this.canvas.height * 0.04);
|
||||||
@@ -149,13 +142,11 @@ class SimpleSubtitleRenderer {
|
|||||||
this.ctx.fillText(line, this.canvas.width / 2, y);
|
this.ctx.fillText(line, this.canvas.width / 2, y);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
dispose() {
|
dispose() {
|
||||||
this.destroyed = true;
|
this.destroyed = true;
|
||||||
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||||
if (this.resizeHandler) window.removeEventListener('resize', this.resizeHandler);
|
if (this.resizeHandler) window.removeEventListener('resize', this.resizeHandler);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.SubtitleRenderer = SubtitleRenderer;
|
window.SubtitleRenderer = SubtitleRenderer;
|
||||||
window.SimpleSubtitleRenderer = SimpleSubtitleRenderer;
|
window.SimpleSubtitleRenderer = SimpleSubtitleRenderer;
|
||||||
@@ -399,7 +399,6 @@ export async function downloadBook(request: FastifyRequest<{ Body: DownloadBookB
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NUEVO: Estado de descargas
|
|
||||||
export async function getDownloadStatus(request: FastifyRequest, reply: FastifyReply) {
|
export async function getDownloadStatus(request: FastifyRequest, reply: FastifyReply) {
|
||||||
try {
|
try {
|
||||||
const downloads = downloadService.getActiveDownloads();
|
const downloads = downloadService.getActiveDownloads();
|
||||||
@@ -426,7 +425,6 @@ export async function getDownloadStatus(request: FastifyRequest, reply: FastifyR
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NUEVO: Streaming HLS para anime local
|
|
||||||
export async function getAnimeStreamManifest(request: FastifyRequest, reply: FastifyReply) {
|
export async function getAnimeStreamManifest(request: FastifyRequest, reply: FastifyReply) {
|
||||||
try {
|
try {
|
||||||
const { type, id, unit } = request.params as any;
|
const { type, id, unit } = request.params as any;
|
||||||
@@ -454,7 +452,6 @@ export async function getAnimeStreamManifest(request: FastifyRequest, reply: Fas
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NUEVO: Servir archivos HLS
|
|
||||||
export async function serveHLSFile(request: FastifyRequest, reply: FastifyReply) {
|
export async function serveHLSFile(request: FastifyRequest, reply: FastifyReply) {
|
||||||
try {
|
try {
|
||||||
const { hash, filename } = request.params as any;
|
const { hash, filename } = request.params as any;
|
||||||
@@ -482,31 +479,3 @@ export async function serveHLSFile(request: FastifyRequest, reply: FastifyReply)
|
|||||||
return reply.status(500).send({ error: 'FAILED_TO_SERVE_HLS_FILE' });
|
return reply.status(500).send({ error: 'FAILED_TO_SERVE_HLS_FILE' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getSubtitle(request: FastifyRequest, reply: FastifyReply) {
|
|
||||||
try {
|
|
||||||
const { id, unit, track } = request.params as any;
|
|
||||||
|
|
||||||
// Validar que el track sea un número
|
|
||||||
const trackIndex = parseInt(track, 10);
|
|
||||||
if (isNaN(trackIndex)) {
|
|
||||||
return reply.status(400).send({ error: 'INVALID_TRACK_INDEX' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const subtitleStream = await service.extractSubtitleTrack(id, unit, trackIndex);
|
|
||||||
|
|
||||||
if (!subtitleStream) {
|
|
||||||
return reply.status(404).send({ error: 'FILE_NOT_FOUND' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cabecera esencial para que el navegador entienda que son subtítulos
|
|
||||||
reply.header('Content-Type', 'text/vtt');
|
|
||||||
reply.header('Cache-Control', 'public, max-age=86400'); // Cachear por 1 día si quieres
|
|
||||||
|
|
||||||
return subtitleStream;
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error serving subtitles:', err);
|
|
||||||
return reply.status(500).send({ error: 'FAILED_TO_SERVE_SUBTITLES' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,7 +8,6 @@ async function localRoutes(fastify: FastifyInstance) {
|
|||||||
|
|
||||||
// Streaming básico (legacy)
|
// Streaming básico (legacy)
|
||||||
fastify.get('/library/stream/:type/:id/:unit', controller.streamUnit);
|
fastify.get('/library/stream/:type/:id/:unit', controller.streamUnit);
|
||||||
fastify.get('/library/stream/:type/:id/:unit/subs/:track', controller.getSubtitle);
|
|
||||||
fastify.post('/library/:type/:id/match', controller.matchEntry);
|
fastify.post('/library/:type/:id/match', controller.matchEntry);
|
||||||
fastify.get('/library/:id/units', controller.getUnits);
|
fastify.get('/library/:id/units', controller.getUnits);
|
||||||
fastify.get('/library/:unitId/manifest', controller.getManifest);
|
fastify.get('/library/:unitId/manifest', controller.getManifest);
|
||||||
|
|||||||
@@ -511,26 +511,3 @@ function parseEpubToHtml(filePath: string) {
|
|||||||
if (!entry) throw new Error('CHAPTER_NOT_FOUND');
|
if (!entry) throw new Error('CHAPTER_NOT_FOUND');
|
||||||
return entry.getData().toString('utf8');
|
return entry.getData().toString('utf8');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function extractSubtitleTrack(id: string, unit: string, trackIndex: number) {
|
|
||||||
// 1. Obtenemos la ruta real del archivo usando tu función existente
|
|
||||||
const fileInfo = await getFileForStreaming(id, unit);
|
|
||||||
|
|
||||||
if (!fileInfo || !fs.existsSync(fileInfo.filePath)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Generamos el hash (ID único del stream) igual que lo hace el streaming
|
|
||||||
const hash = getStreamHash(fileInfo.filePath);
|
|
||||||
|
|
||||||
// 3. Pedimos al servicio de streaming que busque el archivo en la carpeta temp
|
|
||||||
const stream = getSubtitleFileStream(hash, trackIndex);
|
|
||||||
|
|
||||||
if (stream) {
|
|
||||||
console.log(`✅ Subtítulo recuperado de caché (Hash: ${hash}, Track: ${trackIndex})`);
|
|
||||||
} else {
|
|
||||||
console.error(`❌ Subtítulo no encontrado en caché para ${hash}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return stream;
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,7 @@ const { values } = loadConfig();
|
|||||||
const FFMPEG_PATH = values.paths?.ffmpeg || 'ffmpeg';
|
const FFMPEG_PATH = values.paths?.ffmpeg || 'ffmpeg';
|
||||||
const FFPROBE_PATH = values.paths?.ffprobe || 'ffprobe';
|
const FFPROBE_PATH = values.paths?.ffprobe || 'ffprobe';
|
||||||
|
|
||||||
const STREAM_TTL = 2 * 60 * 60 * 1000; // 2 horas
|
const STREAM_TTL = 2 * 60 * 60 * 1000;
|
||||||
|
|
||||||
type VideoStreamInfo = {
|
type VideoStreamInfo = {
|
||||||
index: number;
|
index: number;
|
||||||
@@ -33,6 +33,7 @@ type SubtitleStreamInfo = {
|
|||||||
codec: string;
|
codec: string;
|
||||||
language?: string;
|
language?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
|
duration: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ChapterInfo = {
|
type ChapterInfo = {
|
||||||
@@ -63,7 +64,6 @@ type ActiveStream = {
|
|||||||
|
|
||||||
const activeStreams = new Map<string, ActiveStream>();
|
const activeStreams = new Map<string, ActiveStream>();
|
||||||
|
|
||||||
// Limpieza automática de streams antiguos
|
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
@@ -73,12 +73,10 @@ setInterval(() => {
|
|||||||
if (age > STREAM_TTL) {
|
if (age > STREAM_TTL) {
|
||||||
console.log(`🗑️ Limpiando stream antiguo: ${hash}`);
|
console.log(`🗑️ Limpiando stream antiguo: ${hash}`);
|
||||||
|
|
||||||
// Matar proceso si sigue corriendo
|
|
||||||
if (stream.process && !stream.process.killed) {
|
if (stream.process && !stream.process.killed) {
|
||||||
stream.process.kill('SIGKILL');
|
stream.process.kill('SIGKILL');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Eliminar archivos HLS
|
|
||||||
if (fs.existsSync(stream.hlsDir)) {
|
if (fs.existsSync(stream.hlsDir)) {
|
||||||
fs.rmSync(stream.hlsDir, { recursive: true, force: true });
|
fs.rmSync(stream.hlsDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
@@ -86,7 +84,274 @@ setInterval(() => {
|
|||||||
activeStreams.delete(hash);
|
activeStreams.delete(hash);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, 60 * 1000); // Revisar cada minuto
|
}, 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> {
|
async function probeMediaFile(filePath: string): Promise<MediaInfo> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -141,7 +406,8 @@ async function probeMediaFile(filePath: string): Promise<MediaInfo> {
|
|||||||
index: s.index,
|
index: s.index,
|
||||||
codec: s.codec_name,
|
codec: s.codec_name,
|
||||||
language: s.tags?.language,
|
language: s.tags?.language,
|
||||||
title: s.tags?.title
|
title: s.tags?.title,
|
||||||
|
duration: parseFloat(s.duration) || 0
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const chapters: ChapterInfo[] = (data.chapters || []).map((c: any) => ({
|
const chapters: ChapterInfo[] = (data.chapters || []).map((c: any) => ({
|
||||||
@@ -153,6 +419,8 @@ async function probeMediaFile(filePath: string): Promise<MediaInfo> {
|
|||||||
|
|
||||||
const duration = parseFloat(data.format?.duration) || 0;
|
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 });
|
resolve({ video, audio, subtitles, chapters, duration });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
reject(e);
|
reject(e);
|
||||||
@@ -163,261 +431,6 @@ async function probeMediaFile(filePath: string): Promise<MediaInfo> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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[] = [];
|
|
||||||
|
|
||||||
// Video principal con primer audio
|
|
||||||
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 {
|
|
||||||
// 1. Extraer subs (Copia rápida)
|
|
||||||
await extractSubtitles(filePath, info, hlsDir);
|
|
||||||
|
|
||||||
// 2. Crear Master Playlist (ahora los .m3u8 de los subs existen)
|
|
||||||
writeMasterPlaylist(info, hlsDir);
|
|
||||||
|
|
||||||
// 3. Empezar Video
|
|
||||||
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];
|
|
||||||
|
|
||||||
// VIDEO
|
|
||||||
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', // Event para streaming en vivo
|
|
||||||
'-hls_flags', 'independent_segments',
|
|
||||||
'-hls_segment_filename', path.join(hlsDir, 'v_%05d.ts'),
|
|
||||||
path.join(hlsDir, 'video.m3u8')
|
|
||||||
);
|
|
||||||
|
|
||||||
// AUDIOS
|
|
||||||
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`)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// SIN SUBTÍTULOS AQUÍ (Ya están hechos)
|
|
||||||
|
|
||||||
console.log('🎬 Starting Video/Audio transcoding:', args.join(' '));
|
|
||||||
|
|
||||||
const ffmpeg = spawn(FFMPEG_PATH, args, {
|
|
||||||
windowsHide: true,
|
|
||||||
stdio: ['ignore', 'pipe', 'pipe']
|
|
||||||
});
|
|
||||||
|
|
||||||
// Actualizamos el proceso en el objeto stream
|
|
||||||
stream.process = ffmpeg;
|
|
||||||
|
|
||||||
ffmpeg.stderr.on('data', (data) => {
|
|
||||||
const text = data.toString();
|
|
||||||
// Filtrar logs para ver el tiempo
|
|
||||||
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;
|
|
||||||
|
|
||||||
// Opcional: Convertir playlists a VOD estático
|
|
||||||
// changePlaylistTypeToVod(hlsDir);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const SUBTITLE_EXTENSIONS: Record<string, string> = {
|
|
||||||
'ass': 'ass',
|
|
||||||
'ssa': 'ass',
|
|
||||||
'subrip': 'srt',
|
|
||||||
'webvtt': 'vtt',
|
|
||||||
'hdmv_pgs_subtitle': 'sup', // PGS son imágenes, cuidado con esto en reproductores web
|
|
||||||
'mov_text': 'srt', // Convertiremos mov_text a 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 ext = SUBTITLE_EXTENSIONS[s.codec] || 'vtt';
|
|
||||||
const outputFilename = `s${i}_full.${ext}`;
|
|
||||||
const outputPath = path.join(hlsDir, outputFilename);
|
|
||||||
const playlistPath = path.join(hlsDir, `subs_${i}.m3u8`);
|
|
||||||
|
|
||||||
// Determinamos el encoder correcto
|
|
||||||
let encoderArgs = ['-c:s', 'copy']; // Default
|
|
||||||
|
|
||||||
// Lógica de conversión segura
|
|
||||||
if (ext === 'vtt') {
|
|
||||||
encoderArgs = ['-c:s', 'webvtt'];
|
|
||||||
} else if (ext === 'srt') {
|
|
||||||
encoderArgs = ['-c:s', 'srt'];
|
|
||||||
} else if (ext === 'ass') {
|
|
||||||
// ASS suele funcionar bien con copy, pero si falla, ffmpeg avisará
|
|
||||||
encoderArgs = ['-c:s', 'copy'];
|
|
||||||
}
|
|
||||||
|
|
||||||
// CASO ESPECIAL: Si es imagen (PGS/DVD) y no queremos extraerlo (opcional)
|
|
||||||
// Si quieres extraer PGS tal cual (no se verá en tag <track> de HTML5):
|
|
||||||
if (s.codec === 'hdmv_pgs_subtitle' || s.codec === 'dvd_subtitle') {
|
|
||||||
encoderArgs = ['-c:s', 'copy'];
|
|
||||||
}
|
|
||||||
|
|
||||||
const args = [
|
|
||||||
'-i', filePath,
|
|
||||||
'-map', `0:${s.index}`,
|
|
||||||
...encoderArgs,
|
|
||||||
'-y',
|
|
||||||
outputPath
|
|
||||||
];
|
|
||||||
|
|
||||||
const p = spawn(FFMPEG_PATH, args, { stdio: 'ignore' });
|
|
||||||
|
|
||||||
p.on('close', (code) => {
|
|
||||||
if (code === 0) {
|
|
||||||
try {
|
|
||||||
// Verificar que el archivo no esté vacío
|
|
||||||
const stat = fs.statSync(outputPath);
|
|
||||||
if (stat.size === 0) {
|
|
||||||
console.error(`⚠️ Subtítulo ${i} extraído pero tiene 0 bytes.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
createDummySubtitlePlaylist(playlistPath, outputFilename, info.duration);
|
|
||||||
resolve();
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`Error procesando playlist subtítulo ${i}:`, e);
|
|
||||||
resolve(); // Resolvemos para no bloquear el video
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.error(`❌ Error extrayendo subtítulo ${i} (Codec: ${s.codec} -> ${ext}). Code: ${code}`);
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
p.on('error', (err) => {
|
|
||||||
console.error(`Error spawn ffmpeg subs ${i}:`, err);
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getStreamingManifest(filePath: string) {
|
export async function getStreamingManifest(filePath: string) {
|
||||||
if (!fs.existsSync(filePath)) {
|
if (!fs.existsSync(filePath)) {
|
||||||
return null;
|
return null;
|
||||||
@@ -425,7 +438,6 @@ export async function getStreamingManifest(filePath: string) {
|
|||||||
|
|
||||||
const hash = getStreamHash(filePath);
|
const hash = getStreamHash(filePath);
|
||||||
|
|
||||||
// 1. DEFINIMOS LA FUNCIÓN HELPER AQUÍ FUERA PARA USARLA EN AMBOS CASOS
|
|
||||||
const formatSubtitles = (subs: SubtitleStreamInfo[]) => {
|
const formatSubtitles = (subs: SubtitleStreamInfo[]) => {
|
||||||
return subs.map((s, i) => {
|
return subs.map((s, i) => {
|
||||||
const ext = SUBTITLE_EXTENSIONS[s.codec] || 'vtt';
|
const ext = SUBTITLE_EXTENSIONS[s.codec] || 'vtt';
|
||||||
@@ -434,13 +446,12 @@ export async function getStreamingManifest(filePath: string) {
|
|||||||
codec: s.codec,
|
codec: s.codec,
|
||||||
language: s.language || 'und',
|
language: s.language || 'und',
|
||||||
title: s.title || `Subtitle ${s.index}`,
|
title: s.title || `Subtitle ${s.index}`,
|
||||||
// Aquí generamos la URL basada en el hash y el índice
|
|
||||||
url: `/api/library/hls/${hash}/s${i}_full.${ext}`
|
url: `/api/library/hls/${hash}/s${i}_full.${ext}`
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Caso 1: Stream ya existente
|
|
||||||
const existing = activeStreams.get(hash);
|
const existing = activeStreams.get(hash);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.lastAccessed = Date.now();
|
existing.lastAccessed = Date.now();
|
||||||
@@ -464,7 +475,7 @@ export async function getStreamingManifest(filePath: string) {
|
|||||||
title: a.title || `Audio ${a.index}`,
|
title: a.title || `Audio ${a.index}`,
|
||||||
channels: a.channels
|
channels: a.channels
|
||||||
})),
|
})),
|
||||||
// USAMOS EL HELPER
|
|
||||||
subtitles: formatSubtitles(existing.info.subtitles),
|
subtitles: formatSubtitles(existing.info.subtitles),
|
||||||
chapters: existing.info.chapters.map(c => ({
|
chapters: existing.info.chapters.map(c => ({
|
||||||
id: c.id,
|
id: c.id,
|
||||||
@@ -475,10 +486,40 @@ export async function getStreamingManifest(filePath: string) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Caso 2: Nuevo stream (Generating)
|
const duplicateCheck = activeStreams.get(hash);
|
||||||
const info = await probeMediaFile(filePath);
|
|
||||||
const stream = startHLSConversion(filePath, info, 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 {
|
return {
|
||||||
type: 'hls',
|
type: 'hls',
|
||||||
hash,
|
hash,
|
||||||
@@ -513,11 +554,10 @@ export function getSubtitleFileStream(hash: string, trackIndex: number) {
|
|||||||
const stream = activeStreams.get(hash);
|
const stream = activeStreams.get(hash);
|
||||||
|
|
||||||
if (!stream) {
|
if (!stream) {
|
||||||
// Opcional: Si el stream no está activo, podríamos intentar buscar
|
|
||||||
// si la carpeta existe en temporales de todas formas.
|
|
||||||
const tempDir = path.join(require('os').tmpdir(), 'hls-streams', hash);
|
const tempDir = path.join(require('os').tmpdir(), 'hls-streams', hash);
|
||||||
if(fs.existsSync(tempDir)) {
|
if(fs.existsSync(tempDir)) {
|
||||||
// Lógica de rescate si el servidor se reinició pero los archivos siguen ahí
|
|
||||||
const files = fs.readdirSync(tempDir);
|
const files = fs.readdirSync(tempDir);
|
||||||
const subFile = files.find(f => f.startsWith(`s${trackIndex}_full.`));
|
const subFile = files.find(f => f.startsWith(`s${trackIndex}_full.`));
|
||||||
if (subFile) return fs.createReadStream(path.join(tempDir, subFile));
|
if (subFile) return fs.createReadStream(path.join(tempDir, subFile));
|
||||||
@@ -527,7 +567,6 @@ export function getSubtitleFileStream(hash: string, trackIndex: number) {
|
|||||||
|
|
||||||
if (!fs.existsSync(stream.hlsDir)) return null;
|
if (!fs.existsSync(stream.hlsDir)) return null;
|
||||||
|
|
||||||
// Buscamos s{index}_full.{ext}
|
|
||||||
const files = fs.readdirSync(stream.hlsDir);
|
const files = fs.readdirSync(stream.hlsDir);
|
||||||
const subtitleFile = files.find(f => f.startsWith(`s${trackIndex}_full.`));
|
const subtitleFile = files.find(f => f.startsWith(`s${trackIndex}_full.`));
|
||||||
|
|
||||||
@@ -543,13 +582,12 @@ export async function getHLSFile(hash: string, filename: string) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Actualizar último acceso
|
|
||||||
stream.lastAccessed = Date.now();
|
stream.lastAccessed = Date.now();
|
||||||
|
|
||||||
const filePath = path.join(stream.hlsDir, filename);
|
const filePath = path.join(stream.hlsDir, filename);
|
||||||
|
|
||||||
// Esperar a que el archivo exista (con timeout)
|
const maxWait = 30000;
|
||||||
const maxWait = 30000; // 30 segundos
|
|
||||||
const startWait = Date.now();
|
const startWait = Date.now();
|
||||||
|
|
||||||
while (!fs.existsSync(filePath)) {
|
while (!fs.existsSync(filePath)) {
|
||||||
@@ -558,12 +596,10 @@ export async function getHLSFile(hash: string, filename: string) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Si el proceso terminó y el archivo no existe, error
|
|
||||||
if (stream.isComplete && !fs.existsSync(filePath)) {
|
if (stream.isComplete && !fs.existsSync(filePath)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Esperar un poco antes de reintentar
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -857,27 +857,15 @@ const AnimePlayer = (function() {
|
|||||||
if (hlsInstance) hlsInstance.audioTrack = parseInt(value);
|
if (hlsInstance) hlsInstance.audioTrack = parseInt(value);
|
||||||
} else if (type === 'subtitle') {
|
} else if (type === 'subtitle') {
|
||||||
const idx = parseInt(value);
|
const idx = parseInt(value);
|
||||||
_activeSubtitleIndex = idx; // <--- ACTUALIZAMOS EL ESTADO AQUÍ
|
_activeSubtitleIndex = idx;
|
||||||
|
|
||||||
// 1. Lógica nativa (para mantener compatibilidad interna)
|
|
||||||
if (els.video && els.video.textTracks) {
|
if (els.video && els.video.textTracks) {
|
||||||
Array.from(els.video.textTracks).forEach((track, i) => {
|
Array.from(els.video.textTracks).forEach((track, i) => {
|
||||||
// Si usamos JASSUB, ocultamos la nativa. Si no, mostramos la seleccionada.
|
track.mode = 'hidden';
|
||||||
track.mode = (subtitleRenderer && idx !== -1) ? 'hidden' : ((i === idx) ? 'showing' : 'hidden');
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Lógica de JASSUB
|
initSubtitleRenderer();
|
||||||
if (subtitleRenderer) {
|
|
||||||
if (idx === -1) {
|
|
||||||
subtitleRenderer.dispose();
|
|
||||||
} else {
|
|
||||||
const sub = _currentSubtitles[idx];
|
|
||||||
if (sub) {
|
|
||||||
subtitleRenderer.setTrack(sub.src);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (type === 'speed') {
|
} else if (type === 'speed') {
|
||||||
if (els.video) els.video.playbackRate = parseFloat(value);
|
if (els.video) els.video.playbackRate = parseFloat(value);
|
||||||
}
|
}
|
||||||
@@ -1185,20 +1173,6 @@ const AnimePlayer = (function() {
|
|||||||
if(els.loader) els.loader.style.display = 'flex';
|
if(els.loader) els.loader.style.display = 'flex';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getLocalEntryId() {
|
|
||||||
if (_localEntryId) return _localEntryId;
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/library/anime/${_animeId}`);
|
|
||||||
if (!res.ok) return null;
|
|
||||||
const data = await res.json();
|
|
||||||
_localEntryId = data.id;
|
|
||||||
return _localEntryId;
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Error fetching local ID:", e);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadStream() {
|
async function loadStream() {
|
||||||
if (!_currentEpisode) return;
|
if (!_currentEpisode) return;
|
||||||
_progressUpdated = false;
|
_progressUpdated = false;
|
||||||
@@ -1225,16 +1199,21 @@ const AnimePlayer = (function() {
|
|||||||
_rawVideoData = null;
|
_rawVideoData = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// En la función loadStream(), cuando detectas local:
|
||||||
|
|
||||||
if (currentExt === 'local') {
|
if (currentExt === 'local') {
|
||||||
try {
|
try {
|
||||||
setLoading("Fetching Local Unit Data...");
|
setLoading("Fetching Local Unit Data...");
|
||||||
|
|
||||||
// 1. Obtener las unidades locales para encontrar el ID del episodio específico
|
// 1. Obtener unidades
|
||||||
const unitsRes = await fetch(`/api/library/${_animeId}/units`);
|
const unitsRes = await fetch(`/api/library/${_animeId}/units`);
|
||||||
if (!unitsRes.ok) throw new Error("Could not fetch local units");
|
if (!unitsRes.ok) throw new Error("Could not fetch local units");
|
||||||
|
|
||||||
const unitsData = await unitsRes.json();
|
const unitsData = await unitsRes.json();
|
||||||
const targetUnit = unitsData.units ? unitsData.units.find(u => u.number === parseInt(_currentEpisode)) : null;
|
_localEntryId = unitsData.entry_id;
|
||||||
|
|
||||||
|
const targetUnit = unitsData.units ?
|
||||||
|
unitsData.units.find(u => u.number === parseInt(_currentEpisode)) : null;
|
||||||
|
|
||||||
if (!targetUnit) {
|
if (!targetUnit) {
|
||||||
throw new Error(`Episode ${_currentEpisode} not found in local library`);
|
throw new Error(`Episode ${_currentEpisode} not found in local library`);
|
||||||
@@ -1242,46 +1221,70 @@ const AnimePlayer = (function() {
|
|||||||
|
|
||||||
setLoading("Initializing HLS Stream...");
|
setLoading("Initializing HLS Stream...");
|
||||||
|
|
||||||
const manifestRes = await fetch(`/api/library/stream/anime/${unitsData.entry_id}/${targetUnit.number}/manifest`);
|
// 2. Obtener Manifest
|
||||||
|
const manifestRes = await fetch(
|
||||||
|
`/api/library/stream/anime/${unitsData.entry_id}/${targetUnit.number}/manifest`
|
||||||
|
);
|
||||||
|
|
||||||
if (!manifestRes.ok) throw new Error("Failed to generate stream manifest");
|
if (!manifestRes.ok) throw new Error("Failed to generate stream manifest");
|
||||||
|
|
||||||
const manifestData = await manifestRes.json();
|
const manifestData = await manifestRes.json();
|
||||||
|
|
||||||
|
// DEBUG: Verificar que llega aquí
|
||||||
|
console.log("Manifest Loaded:", manifestData);
|
||||||
|
|
||||||
|
// 3. Chapters
|
||||||
if (manifestData.chapters && manifestData.chapters.length > 0) {
|
if (manifestData.chapters && manifestData.chapters.length > 0) {
|
||||||
_skipIntervals = manifestData.chapters.map(c => ({
|
_skipIntervals = manifestData.chapters.map(c => ({
|
||||||
startTime: c.start,
|
startTime: c.start,
|
||||||
endTime: c.end,
|
endTime: c.end,
|
||||||
type: c.title.toLowerCase().includes('op') ? 'op' :
|
type: (c.title || '').toLowerCase().includes('op') ? 'op' :
|
||||||
c.title.toLowerCase().includes('ed') ? 'ed' : 'chapter'
|
(c.title || '').toLowerCase().includes('ed') ? 'ed' : 'chapter'
|
||||||
}));
|
}));
|
||||||
renderSkipMarkers(_skipIntervals);
|
renderSkipMarkers(_skipIntervals);
|
||||||
monitorSkipButton(_skipIntervals);
|
monitorSkipButton(_skipIntervals);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Mapear Subtítulos WebVTT
|
// 4. CORRECCIÓN DE SUBTÍTULOS
|
||||||
const subs = (manifestData.subtitles || []).map(s => ({
|
const rawSubs = manifestData.subtitles || [];
|
||||||
label: s.title || s.language || `Track ${s.index}`,
|
console.log("Raw Subtitles from JSON:", rawSubs);
|
||||||
srclang: s.language || 'unk',
|
|
||||||
src: s.url // URL al endpoint de conversión VTT (.vtt)
|
const subs = rawSubs.map((s, index) => {
|
||||||
}));
|
// Limpieza segura del código de idioma (ej. "English" -> "en")
|
||||||
|
let langCode = 'en';
|
||||||
|
if (s.language && typeof s.language === 'string' && s.language.length >= 2) {
|
||||||
|
langCode = s.language.substring(0, 2).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Título legible (Prioridad: Title > Language > Track #)
|
||||||
|
const label = s.title || s.language || `Track ${index + 1}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: label,
|
||||||
|
srclang: langCode,
|
||||||
|
src: s.url // Usamos la URL directa del JSON local
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// ACTUALIZACIÓN CRÍTICA DEL ESTADO GLOBAL
|
||||||
|
_currentSubtitles = subs;
|
||||||
|
console.log("Processed Subtitles:", _currentSubtitles);
|
||||||
|
|
||||||
// 5. Guardar referencia para MPV o descargas
|
|
||||||
_rawVideoData = {
|
_rawVideoData = {
|
||||||
url: manifestData.masterPlaylist,
|
url: manifestData.masterPlaylist,
|
||||||
headers: {}
|
headers: {}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 6. Cargar en el reproductor (Hls.js gestionará los audios del master.m3u8)
|
// Inicializar video pasando explícitamente los subs procesados
|
||||||
initVideoPlayer(manifestData.masterPlaylist, 'm3u8', subs);
|
initVideoPlayer(manifestData.masterPlaylist, 'm3u8', subs);
|
||||||
|
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
console.error("Local HLS Error:", e);
|
console.error("Local HLS Error:", e);
|
||||||
setLoading("Local Error: " + e.message);
|
setLoading("Local Error: " + e.message);
|
||||||
|
|
||||||
// Fallback: si falla, intentar cargar desde extensión online
|
// Fallback logic...
|
||||||
const localOption = els.extSelect.querySelector('option[value="local"]');
|
const localOption = els.extSelect.querySelector('option[value="local"]');
|
||||||
if (localOption) localOption.remove();
|
if (localOption) localOption.remove();
|
||||||
|
|
||||||
const fallbackSource = (_entrySource === 'local') ? 'anilist' : _entrySource;
|
const fallbackSource = (_entrySource === 'local') ? 'anilist' : _entrySource;
|
||||||
els.extSelect.value = fallbackSource;
|
els.extSelect.value = fallbackSource;
|
||||||
handleExtensionChange(true);
|
handleExtensionChange(true);
|
||||||
|
|||||||
@@ -1,60 +1,46 @@
|
|||||||
const BASE_PATH = '/src/scripts/jassub/';
|
const BASE_PATH = '/src/scripts/jassub/';
|
||||||
|
|
||||||
class SubtitleRenderer {
|
class SubtitleRenderer {
|
||||||
// 1. Aceptamos 'canvas' en el constructor
|
|
||||||
constructor(video, canvas) {
|
constructor(video, canvas) {
|
||||||
this.video = video;
|
this.video = video;
|
||||||
this.canvas = canvas;
|
this.canvas = canvas;
|
||||||
this.instance = null;
|
this.instance = null;
|
||||||
this.currentUrl = null;
|
this.currentUrl = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async init(subtitleUrl) {
|
async init(subtitleUrl) {
|
||||||
if (!this.video || !this.canvas) return; // 2. Verificamos canvas
|
if (!this.video || !this.canvas) return;
|
||||||
|
|
||||||
this.dispose();
|
this.dispose();
|
||||||
|
|
||||||
const finalUrl = subtitleUrl.includes('/api/proxy')
|
const finalUrl = subtitleUrl.includes('/api/proxy')
|
||||||
? subtitleUrl
|
? subtitleUrl
|
||||||
: `/api/proxy?url=${encodeURIComponent(subtitleUrl)}`;
|
: `/api/proxy?url=${encodeURIComponent(subtitleUrl)}`;
|
||||||
|
|
||||||
this.currentUrl = finalUrl;
|
this.currentUrl = finalUrl;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.instance = new JASSUB({
|
this.instance = new JASSUB({
|
||||||
video: this.video,
|
video: this.video,
|
||||||
canvas: this.canvas,
|
canvas: this.canvas,
|
||||||
subUrl: finalUrl,
|
subUrl: finalUrl,
|
||||||
|
|
||||||
workerUrl: `${BASE_PATH}jassub-worker.js`,
|
workerUrl: `${BASE_PATH}jassub-worker.js`,
|
||||||
wasmUrl: `${BASE_PATH}jassub-worker.wasm`,
|
wasmUrl: `${BASE_PATH}jassub-worker.wasm`,
|
||||||
modernWasmUrl: `${BASE_PATH}jassub-worker-modern.wasm`,
|
modernWasmUrl: `${BASE_PATH}jassub-worker-modern.wasm`,
|
||||||
|
|
||||||
blendMode: 'js',
|
blendMode: 'js',
|
||||||
asyncRender: true,
|
asyncRender: true,
|
||||||
onDemand: true,
|
onDemand: true,
|
||||||
targetFps: 60,
|
targetFps: 60,
|
||||||
debug: false
|
debug: false
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('JASSUB initialized for:', finalUrl);
|
console.log('JASSUB initialized for:', finalUrl);
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("JASSUB Init Error:", e);
|
console.error("JASSUB Init Error:", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
resize() {
|
resize() {
|
||||||
if (this.instance && this.instance.resize) {
|
if (this.instance && this.instance.resize) {
|
||||||
this.instance.resize();
|
this.instance.resize();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setTrack(url) {
|
setTrack(url) {
|
||||||
const finalUrl = url.includes('/api/proxy')
|
const finalUrl = url.includes('/api/proxy')
|
||||||
? url
|
? url
|
||||||
: `/api/proxy?url=${encodeURIComponent(url)}`;
|
: `/api/proxy?url=${encodeURIComponent(url)}`;
|
||||||
|
|
||||||
if (this.instance) {
|
if (this.instance) {
|
||||||
this.instance.setTrackByUrl(finalUrl);
|
this.instance.setTrackByUrl(finalUrl);
|
||||||
this.currentUrl = finalUrl;
|
this.currentUrl = finalUrl;
|
||||||
@@ -62,7 +48,6 @@ class SubtitleRenderer {
|
|||||||
this.init(url);
|
this.init(url);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dispose() {
|
dispose() {
|
||||||
if (this.instance) {
|
if (this.instance) {
|
||||||
try {
|
try {
|
||||||
@@ -74,8 +59,6 @@ class SubtitleRenderer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simple Renderer remains unchanged for SRT/VTT (Non-ASS)
|
|
||||||
class SimpleSubtitleRenderer {
|
class SimpleSubtitleRenderer {
|
||||||
constructor(video, canvas) {
|
constructor(video, canvas) {
|
||||||
this.video = video;
|
this.video = video;
|
||||||
@@ -83,11 +66,9 @@ class SimpleSubtitleRenderer {
|
|||||||
this.ctx = canvas.getContext('2d');
|
this.ctx = canvas.getContext('2d');
|
||||||
this.cues = [];
|
this.cues = [];
|
||||||
this.destroyed = false;
|
this.destroyed = false;
|
||||||
|
|
||||||
this.setupCanvas();
|
this.setupCanvas();
|
||||||
this.video.addEventListener('timeupdate', () => this.render());
|
this.video.addEventListener('timeupdate', () => this.render());
|
||||||
}
|
}
|
||||||
|
|
||||||
setupCanvas() {
|
setupCanvas() {
|
||||||
const updateSize = () => {
|
const updateSize = () => {
|
||||||
if (!this.video || !this.canvas) return;
|
if (!this.video || !this.canvas) return;
|
||||||
@@ -99,19 +80,31 @@ class SimpleSubtitleRenderer {
|
|||||||
window.addEventListener('resize', updateSize);
|
window.addEventListener('resize', updateSize);
|
||||||
this.resizeHandler = updateSize;
|
this.resizeHandler = updateSize;
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadSubtitles(url) {
|
async loadSubtitles(url) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/proxy?url=${encodeURIComponent(url)}`);
|
let finalUrl = url;
|
||||||
|
const isLocal = url.startsWith('/');
|
||||||
|
const isAlreadyProxied = url.includes('/api/proxy');
|
||||||
|
if (!isLocal && !isAlreadyProxied && (url.startsWith('http:') || url.startsWith('https:'))) {
|
||||||
|
finalUrl = `/api/proxy?url=${encodeURIComponent(url)}`;
|
||||||
|
}
|
||||||
|
console.log('Fetching subtitles from:', finalUrl);
|
||||||
|
const response = await fetch(finalUrl);
|
||||||
|
if (!response.ok) throw new Error(`Status: ${response.status}`);
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
this.cues = this.parseSRT(text);
|
this.cues = this.parseSRT(text);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load subtitles:', error);
|
console.error('Failed to load subtitles:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
setTrack(url) {
|
||||||
|
this.cues = [];
|
||||||
|
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||||
|
this.loadSubtitles(url);
|
||||||
|
}
|
||||||
parseSRT(srtText) {
|
parseSRT(srtText) {
|
||||||
const blocks = srtText.trim().split('\n\n');
|
const normalizedText = srtText.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||||
|
const blocks = normalizedText.trim().split('\n\n');
|
||||||
return blocks.map(block => {
|
return blocks.map(block => {
|
||||||
const lines = block.split('\n');
|
const lines = block.split('\n');
|
||||||
if (lines.length < 3) return null;
|
if (lines.length < 3) return null;
|
||||||
@@ -119,11 +112,12 @@ class SimpleSubtitleRenderer {
|
|||||||
if (!timeMatch) return null;
|
if (!timeMatch) return null;
|
||||||
const start = parseInt(timeMatch[1]) * 3600 + parseInt(timeMatch[2]) * 60 + parseInt(timeMatch[3]) + parseInt(timeMatch[4]) / 1000;
|
const start = parseInt(timeMatch[1]) * 3600 + parseInt(timeMatch[2]) * 60 + parseInt(timeMatch[3]) + parseInt(timeMatch[4]) / 1000;
|
||||||
const end = parseInt(timeMatch[5]) * 3600 + parseInt(timeMatch[6]) * 60 + parseInt(timeMatch[7]) + parseInt(timeMatch[8]) / 1000;
|
const end = parseInt(timeMatch[5]) * 3600 + parseInt(timeMatch[6]) * 60 + parseInt(timeMatch[7]) + parseInt(timeMatch[8]) / 1000;
|
||||||
const text = lines.slice(2).join('\n');
|
let text = lines.slice(2).join('\n');
|
||||||
|
text = text.replace(/<[^>]*>/g, '');
|
||||||
|
text = text.replace(/\{[^}]*\}/g, '');
|
||||||
return { start, end, text };
|
return { start, end, text };
|
||||||
}).filter(Boolean);
|
}).filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
if (this.destroyed) return;
|
if (this.destroyed) return;
|
||||||
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||||
@@ -131,7 +125,6 @@ class SimpleSubtitleRenderer {
|
|||||||
const cue = this.cues.find(c => currentTime >= c.start && currentTime <= c.end);
|
const cue = this.cues.find(c => currentTime >= c.start && currentTime <= c.end);
|
||||||
if (cue) this.drawSubtitle(cue.text);
|
if (cue) this.drawSubtitle(cue.text);
|
||||||
}
|
}
|
||||||
|
|
||||||
drawSubtitle(text) {
|
drawSubtitle(text) {
|
||||||
const lines = text.split('\n');
|
const lines = text.split('\n');
|
||||||
const fontSize = Math.max(20, this.canvas.height * 0.04);
|
const fontSize = Math.max(20, this.canvas.height * 0.04);
|
||||||
@@ -149,13 +142,11 @@ class SimpleSubtitleRenderer {
|
|||||||
this.ctx.fillText(line, this.canvas.width / 2, y);
|
this.ctx.fillText(line, this.canvas.width / 2, y);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
dispose() {
|
dispose() {
|
||||||
this.destroyed = true;
|
this.destroyed = true;
|
||||||
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||||
if (this.resizeHandler) window.removeEventListener('resize', this.resizeHandler);
|
if (this.resizeHandler) window.removeEventListener('resize', this.resizeHandler);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.SubtitleRenderer = SubtitleRenderer;
|
window.SubtitleRenderer = SubtitleRenderer;
|
||||||
window.SimpleSubtitleRenderer = SimpleSubtitleRenderer;
|
window.SimpleSubtitleRenderer = SimpleSubtitleRenderer;
|
||||||
Reference in New Issue
Block a user