support for subs on local files

This commit is contained in:
2026-01-06 18:55:03 +01:00
parent 6c9f021e8d
commit ba05e08e71
12 changed files with 760 additions and 810 deletions

View File

@@ -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;
@@ -481,32 +478,4 @@ export async function serveHLSFile(request: FastifyRequest, reply: FastifyReply)
console.error('Error serving HLS file:', err); console.error('Error serving HLS file:', err);
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' });
}
} }

View File

@@ -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);

View File

@@ -510,27 +510,4 @@ function parseEpubToHtml(filePath: string) {
const entry = zip.getEntry('OEBPS/chapter.xhtml'); const entry = zip.getEntry('OEBPS/chapter.xhtml');
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;
} }

View File

@@ -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));
} }

View File

@@ -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);

View File

@@ -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;

View File

@@ -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;
@@ -481,32 +478,4 @@ export async function serveHLSFile(request: FastifyRequest, reply: FastifyReply)
console.error('Error serving HLS file:', err); console.error('Error serving HLS file:', err);
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' });
}
} }

View File

@@ -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);

View File

@@ -510,27 +510,4 @@ function parseEpubToHtml(filePath: string) {
const entry = zip.getEntry('OEBPS/chapter.xhtml'); const entry = zip.getEntry('OEBPS/chapter.xhtml');
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;
} }

View File

@@ -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));
} }

View File

@@ -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);

View File

@@ -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;