diff --git a/desktop/src/api/local/local.controller.ts b/desktop/src/api/local/local.controller.ts index 659bbfc..432a6e4 100644 --- a/desktop/src/api/local/local.controller.ts +++ b/desktop/src/api/local/local.controller.ts @@ -399,7 +399,6 @@ export async function downloadBook(request: FastifyRequest<{ Body: DownloadBookB } } -// NUEVO: Estado de descargas export async function getDownloadStatus(request: FastifyRequest, reply: FastifyReply) { try { 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) { try { 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) { try { 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); 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' }); - } } \ No newline at end of file diff --git a/desktop/src/api/local/local.routes.ts b/desktop/src/api/local/local.routes.ts index 791b40f..91c736b 100644 --- a/desktop/src/api/local/local.routes.ts +++ b/desktop/src/api/local/local.routes.ts @@ -8,7 +8,6 @@ async function localRoutes(fastify: FastifyInstance) { // Streaming básico (legacy) 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.get('/library/:id/units', controller.getUnits); fastify.get('/library/:unitId/manifest', controller.getManifest); diff --git a/desktop/src/api/local/local.service.ts b/desktop/src/api/local/local.service.ts index 6129f28..c10b4b3 100644 --- a/desktop/src/api/local/local.service.ts +++ b/desktop/src/api/local/local.service.ts @@ -510,27 +510,4 @@ function parseEpubToHtml(filePath: string) { const entry = zip.getEntry('OEBPS/chapter.xhtml'); if (!entry) throw new Error('CHAPTER_NOT_FOUND'); 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; } \ No newline at end of file diff --git a/desktop/src/api/local/streaming.service.ts b/desktop/src/api/local/streaming.service.ts index ba76aee..117e50d 100644 --- a/desktop/src/api/local/streaming.service.ts +++ b/desktop/src/api/local/streaming.service.ts @@ -9,7 +9,7 @@ const { values } = loadConfig(); const FFMPEG_PATH = values.paths?.ffmpeg || 'ffmpeg'; 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 = { index: number; @@ -33,6 +33,7 @@ type SubtitleStreamInfo = { codec: string; language?: string; title?: string; + duration: number; }; type ChapterInfo = { @@ -63,7 +64,6 @@ type ActiveStream = { const activeStreams = new Map(); -// Limpieza automática de streams antiguos setInterval(() => { const now = Date.now(); @@ -73,12 +73,10 @@ setInterval(() => { if (age > STREAM_TTL) { console.log(`🗑️ Limpiando stream antiguo: ${hash}`); - // Matar proceso si sigue corriendo if (stream.process && !stream.process.killed) { stream.process.kill('SIGKILL'); } - // Eliminar archivos HLS if (fs.existsSync(stream.hlsDir)) { fs.rmSync(stream.hlsDir, { recursive: true, force: true }); } @@ -86,7 +84,274 @@ setInterval(() => { 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 = { + 'ass': 'ass', + 'ssa': 'ass', + 'subrip': 'srt', + 'webvtt': 'vtt', + 'hdmv_pgs_subtitle': 'sup', + 'mov_text': 'srt', + 'dvd_subtitle': 'sub', + 'text': 'srt' +}; + +async function extractSubtitles(filePath: string, info: MediaInfo, hlsDir: string): Promise { + if (info.subtitles.length === 0) return; + + console.log('📝 Extrayendo subtítulos...'); + + const promises = info.subtitles.map((s, i) => { + return new Promise((resolve, reject) => { + const codec = s.codec.toLowerCase(); + const ext = codec === 'subrip' ? 'srt' + : codec === 'ass' || codec === 'ssa' ? 'ass' + : codec === 'webvtt' ? 'vtt' + : 'sub'; + + const outputFilename = `s${i}_full.${ext}`; + const outputPath = path.join(hlsDir, outputFilename); + const playlistPath = path.join(hlsDir, `subs_${i}.m3u8`); + + if (s.duration === 0) { + console.log(`⚠️ Sub vacío, skip: ${i}`); + createDummySubtitlePlaylist(playlistPath, outputFilename, info.duration); + fs.writeFileSync(outputPath, ''); + resolve(); + return; + } + + console.log(` Track ${i}: ${s.title || s.language || 'Unknown'} [${codec}] -> ${ext}`); + + const args = [ + '-i', filePath, + '-map', `0:s:${i}` + ]; + + args.push('-c:s', 'copy'); + args.push('-y', outputPath); + + console.log(` Comando: ffmpeg ${args.join(' ')}`); + + const p = spawn(FFMPEG_PATH, args, { + stdio: ['ignore', 'pipe', 'pipe'] + }); + + let errorOutput = ''; + + p.stderr.on('data', (data) => { + errorOutput += data.toString(); + }); + + p.on('close', (code) => { + if (code === 0) { + try { + + if (!fs.existsSync(outputPath)) { + console.error(` ❌ Archivo no creado: ${outputFilename}`); + + fs.writeFileSync(outputPath, ''); + } else { + const stat = fs.statSync(outputPath); + if (stat.size === 0) { + console.error(` ⚠️ Subtítulo ${i} tiene 0 bytes`); + console.error(` FFmpeg stderr:`, errorOutput.slice(-500)); + } else { + console.log(` ✅ Subtítulo ${i} extraído: ${stat.size} bytes`); + } + } + + createDummySubtitlePlaylist(playlistPath, outputFilename, info.duration); + resolve(); + } catch (e) { + console.error(` Error procesando subtítulo ${i}:`, e); + resolve(); + } + } else { + console.error(` ❌ Error extrayendo subtítulo ${i}. Exit code: ${code}`); + console.error(` FFmpeg stderr:`, errorOutput.slice(-500)); + + fs.writeFileSync(outputPath, ''); + createDummySubtitlePlaylist(playlistPath, outputFilename, info.duration); + resolve(); + } + }); + + p.on('error', (err) => { + console.error(` Error spawn ffmpeg subs ${i}:`, err); + fs.writeFileSync(outputPath, ''); + createDummySubtitlePlaylist(playlistPath, outputFilename, info.duration); + resolve(); + }); + }); + }); + + await Promise.all(promises); + console.log('✅ Proceso de subtítulos finalizado.'); +} + +function createDummySubtitlePlaylist(playlistPath: string, subtitleFilename: string, duration: number) { + const content = [ + '#EXTM3U', + '#EXT-X-VERSION:3', + `#EXT-X-TARGETDURATION:${Math.ceil(duration)}`, + '#EXT-X-MEDIA-SEQUENCE:0', + '#EXT-X-PLAYLIST-TYPE:VOD', + `#EXTINF:${duration.toFixed(6)},`, + subtitleFilename, + '#EXT-X-ENDLIST' + ].join('\n'); + + fs.writeFileSync(playlistPath, content); +} async function probeMediaFile(filePath: string): Promise { return new Promise((resolve, reject) => { @@ -141,7 +406,8 @@ async function probeMediaFile(filePath: string): Promise { index: s.index, codec: s.codec_name, 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) => ({ @@ -153,6 +419,8 @@ async function probeMediaFile(filePath: string): Promise { const duration = parseFloat(data.format?.duration) || 0; + console.log(`📊 Media info: ${video.length} video, ${audio.length} audio, ${subtitles.length} subs`); + resolve({ video, audio, subtitles, chapters, duration }); } catch (e) { reject(e); @@ -163,261 +431,6 @@ async function probeMediaFile(filePath: string): Promise { }); } -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 = { - '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 { - if (info.subtitles.length === 0) return; - - console.log('📑 Extrayendo subtítulos...'); - - const promises = info.subtitles.map((s, i) => { - return new Promise((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 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) { if (!fs.existsSync(filePath)) { return null; @@ -425,7 +438,6 @@ export async function getStreamingManifest(filePath: string) { const hash = getStreamHash(filePath); - // 1. DEFINIMOS LA FUNCIÓN HELPER AQUÍ FUERA PARA USARLA EN AMBOS CASOS const formatSubtitles = (subs: SubtitleStreamInfo[]) => { return subs.map((s, i) => { const ext = SUBTITLE_EXTENSIONS[s.codec] || 'vtt'; @@ -434,13 +446,12 @@ export async function getStreamingManifest(filePath: string) { codec: s.codec, language: s.language || 'und', 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}` }; }); }; - // Caso 1: Stream ya existente const existing = activeStreams.get(hash); if (existing) { existing.lastAccessed = Date.now(); @@ -464,7 +475,7 @@ export async function getStreamingManifest(filePath: string) { title: a.title || `Audio ${a.index}`, channels: a.channels })), - // USAMOS EL HELPER + subtitles: formatSubtitles(existing.info.subtitles), chapters: existing.info.chapters.map(c => ({ id: c.id, @@ -475,10 +486,40 @@ export async function getStreamingManifest(filePath: string) { }; } - // Caso 2: Nuevo stream (Generating) - const info = await probeMediaFile(filePath); - const stream = startHLSConversion(filePath, info, hash); + const duplicateCheck = activeStreams.get(hash); + if (duplicateCheck) { + duplicateCheck.lastAccessed = Date.now(); + return { + type: 'hls', + hash, + masterPlaylist: `/api/library/hls/${hash}/master.m3u8`, + duration: duplicateCheck.info.duration, + isComplete: duplicateCheck.isComplete, + video: duplicateCheck.info.video.map(v => ({ + index: v.index, + codec: v.codec, + resolution: `${v.width}x${v.height}`, + fps: v.fps + })), + audio: duplicateCheck.info.audio.map(a => ({ + index: a.index, + codec: a.codec, + language: a.language || 'und', + title: a.title || `Audio ${a.index}`, + channels: a.channels + })), + subtitles: formatSubtitles(duplicateCheck.info.subtitles), + chapters: duplicateCheck.info.chapters.map(c => ({ + id: c.id, + start: c.start, + end: c.end, + title: c.title + })) + }; + } + + const info = await probeMediaFile(filePath); return { type: 'hls', hash, @@ -513,11 +554,10 @@ export function getSubtitleFileStream(hash: string, trackIndex: number) { const stream = activeStreams.get(hash); 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); if(fs.existsSync(tempDir)) { - // Lógica de rescate si el servidor se reinició pero los archivos siguen ahí + const files = fs.readdirSync(tempDir); const subFile = files.find(f => f.startsWith(`s${trackIndex}_full.`)); 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; - // Buscamos s{index}_full.{ext} const files = fs.readdirSync(stream.hlsDir); const subtitleFile = files.find(f => f.startsWith(`s${trackIndex}_full.`)); @@ -543,13 +582,12 @@ export async function getHLSFile(hash: string, filename: string) { return null; } - // Actualizar último acceso stream.lastAccessed = Date.now(); const filePath = path.join(stream.hlsDir, filename); - // Esperar a que el archivo exista (con timeout) - const maxWait = 30000; // 30 segundos + const maxWait = 30000; + const startWait = Date.now(); while (!fs.existsSync(filePath)) { @@ -558,12 +596,10 @@ export async function getHLSFile(hash: string, filename: string) { return null; } - // Si el proceso terminó y el archivo no existe, error if (stream.isComplete && !fs.existsSync(filePath)) { return null; } - // Esperar un poco antes de reintentar await new Promise(resolve => setTimeout(resolve, 100)); } diff --git a/desktop/src/scripts/anime/player.js b/desktop/src/scripts/anime/player.js index 05b5616..89ccb81 100644 --- a/desktop/src/scripts/anime/player.js +++ b/desktop/src/scripts/anime/player.js @@ -857,27 +857,15 @@ const AnimePlayer = (function() { if (hlsInstance) hlsInstance.audioTrack = parseInt(value); } else if (type === 'subtitle') { 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) { Array.from(els.video.textTracks).forEach((track, i) => { - // Si usamos JASSUB, ocultamos la nativa. Si no, mostramos la seleccionada. - track.mode = (subtitleRenderer && idx !== -1) ? 'hidden' : ((i === idx) ? 'showing' : 'hidden'); + track.mode = 'hidden'; }); } - // 2. Lógica de JASSUB - if (subtitleRenderer) { - if (idx === -1) { - subtitleRenderer.dispose(); - } else { - const sub = _currentSubtitles[idx]; - if (sub) { - subtitleRenderer.setTrack(sub.src); - } - } - } + initSubtitleRenderer(); } else if (type === 'speed') { if (els.video) els.video.playbackRate = parseFloat(value); } @@ -1185,20 +1173,6 @@ const AnimePlayer = (function() { 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() { if (!_currentEpisode) return; _progressUpdated = false; @@ -1225,16 +1199,21 @@ const AnimePlayer = (function() { _rawVideoData = null; } + // En la función loadStream(), cuando detectas local: + if (currentExt === 'local') { try { 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`); if (!unitsRes.ok) throw new Error("Could not fetch local units"); 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) { throw new Error(`Episode ${_currentEpisode} not found in local library`); @@ -1242,46 +1221,70 @@ const AnimePlayer = (function() { 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"); const manifestData = await manifestRes.json(); + // DEBUG: Verificar que llega aquí + console.log("Manifest Loaded:", manifestData); + + // 3. Chapters if (manifestData.chapters && manifestData.chapters.length > 0) { _skipIntervals = manifestData.chapters.map(c => ({ startTime: c.start, endTime: c.end, - type: c.title.toLowerCase().includes('op') ? 'op' : - c.title.toLowerCase().includes('ed') ? 'ed' : 'chapter' + type: (c.title || '').toLowerCase().includes('op') ? 'op' : + (c.title || '').toLowerCase().includes('ed') ? 'ed' : 'chapter' })); renderSkipMarkers(_skipIntervals); monitorSkipButton(_skipIntervals); } - // 4. Mapear Subtítulos WebVTT - const subs = (manifestData.subtitles || []).map(s => ({ - label: s.title || s.language || `Track ${s.index}`, - srclang: s.language || 'unk', - src: s.url // URL al endpoint de conversión VTT (.vtt) - })); + // 4. CORRECCIÓN DE SUBTÍTULOS + const rawSubs = manifestData.subtitles || []; + console.log("Raw Subtitles from JSON:", rawSubs); + + 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 = { url: manifestData.masterPlaylist, 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); } catch(e) { console.error("Local HLS Error:", e); setLoading("Local Error: " + e.message); - // Fallback: si falla, intentar cargar desde extensión online + // Fallback logic... const localOption = els.extSelect.querySelector('option[value="local"]'); if (localOption) localOption.remove(); - const fallbackSource = (_entrySource === 'local') ? 'anilist' : _entrySource; els.extSelect.value = fallbackSource; handleExtensionChange(true); diff --git a/desktop/src/scripts/anime/subtitle-renderer.js b/desktop/src/scripts/anime/subtitle-renderer.js index fe07912..aa93e63 100644 --- a/desktop/src/scripts/anime/subtitle-renderer.js +++ b/desktop/src/scripts/anime/subtitle-renderer.js @@ -1,60 +1,46 @@ const BASE_PATH = '/src/scripts/jassub/'; - class SubtitleRenderer { - // 1. Aceptamos 'canvas' en el constructor constructor(video, canvas) { this.video = video; this.canvas = canvas; this.instance = null; this.currentUrl = null; } - async init(subtitleUrl) { - if (!this.video || !this.canvas) return; // 2. Verificamos canvas - + if (!this.video || !this.canvas) return; this.dispose(); - const finalUrl = subtitleUrl.includes('/api/proxy') ? subtitleUrl : `/api/proxy?url=${encodeURIComponent(subtitleUrl)}`; - this.currentUrl = finalUrl; - try { this.instance = new JASSUB({ video: this.video, canvas: this.canvas, subUrl: finalUrl, - workerUrl: `${BASE_PATH}jassub-worker.js`, wasmUrl: `${BASE_PATH}jassub-worker.wasm`, modernWasmUrl: `${BASE_PATH}jassub-worker-modern.wasm`, - blendMode: 'js', asyncRender: true, onDemand: true, targetFps: 60, debug: false }); - console.log('JASSUB initialized for:', finalUrl); - } catch (e) { console.error("JASSUB Init Error:", e); } } - resize() { if (this.instance && this.instance.resize) { this.instance.resize(); } } - setTrack(url) { const finalUrl = url.includes('/api/proxy') ? url : `/api/proxy?url=${encodeURIComponent(url)}`; - if (this.instance) { this.instance.setTrackByUrl(finalUrl); this.currentUrl = finalUrl; @@ -62,7 +48,6 @@ class SubtitleRenderer { this.init(url); } } - dispose() { if (this.instance) { try { @@ -74,8 +59,6 @@ class SubtitleRenderer { } } } - -// Simple Renderer remains unchanged for SRT/VTT (Non-ASS) class SimpleSubtitleRenderer { constructor(video, canvas) { this.video = video; @@ -83,11 +66,9 @@ class SimpleSubtitleRenderer { this.ctx = canvas.getContext('2d'); this.cues = []; this.destroyed = false; - this.setupCanvas(); this.video.addEventListener('timeupdate', () => this.render()); } - setupCanvas() { const updateSize = () => { if (!this.video || !this.canvas) return; @@ -99,19 +80,31 @@ class SimpleSubtitleRenderer { window.addEventListener('resize', updateSize); this.resizeHandler = updateSize; } - async loadSubtitles(url) { 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(); this.cues = this.parseSRT(text); } catch (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) { - 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 => { const lines = block.split('\n'); if (lines.length < 3) return null; @@ -119,11 +112,12 @@ class SimpleSubtitleRenderer { if (!timeMatch) return null; 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 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 }; }).filter(Boolean); } - render() { if (this.destroyed) return; 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); if (cue) this.drawSubtitle(cue.text); } - drawSubtitle(text) { const lines = text.split('\n'); 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); }); } - dispose() { this.destroyed = true; this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); if (this.resizeHandler) window.removeEventListener('resize', this.resizeHandler); } } - window.SubtitleRenderer = SubtitleRenderer; window.SimpleSubtitleRenderer = SimpleSubtitleRenderer; \ No newline at end of file diff --git a/docker/src/api/local/local.controller.ts b/docker/src/api/local/local.controller.ts index 659bbfc..432a6e4 100644 --- a/docker/src/api/local/local.controller.ts +++ b/docker/src/api/local/local.controller.ts @@ -399,7 +399,6 @@ export async function downloadBook(request: FastifyRequest<{ Body: DownloadBookB } } -// NUEVO: Estado de descargas export async function getDownloadStatus(request: FastifyRequest, reply: FastifyReply) { try { 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) { try { 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) { try { 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); 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' }); - } } \ No newline at end of file diff --git a/docker/src/api/local/local.routes.ts b/docker/src/api/local/local.routes.ts index 791b40f..91c736b 100644 --- a/docker/src/api/local/local.routes.ts +++ b/docker/src/api/local/local.routes.ts @@ -8,7 +8,6 @@ async function localRoutes(fastify: FastifyInstance) { // Streaming básico (legacy) 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.get('/library/:id/units', controller.getUnits); fastify.get('/library/:unitId/manifest', controller.getManifest); diff --git a/docker/src/api/local/local.service.ts b/docker/src/api/local/local.service.ts index 6129f28..c10b4b3 100644 --- a/docker/src/api/local/local.service.ts +++ b/docker/src/api/local/local.service.ts @@ -510,27 +510,4 @@ function parseEpubToHtml(filePath: string) { const entry = zip.getEntry('OEBPS/chapter.xhtml'); if (!entry) throw new Error('CHAPTER_NOT_FOUND'); 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; } \ No newline at end of file diff --git a/docker/src/api/local/streaming.service.ts b/docker/src/api/local/streaming.service.ts index ba76aee..117e50d 100644 --- a/docker/src/api/local/streaming.service.ts +++ b/docker/src/api/local/streaming.service.ts @@ -9,7 +9,7 @@ const { values } = loadConfig(); const FFMPEG_PATH = values.paths?.ffmpeg || 'ffmpeg'; 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 = { index: number; @@ -33,6 +33,7 @@ type SubtitleStreamInfo = { codec: string; language?: string; title?: string; + duration: number; }; type ChapterInfo = { @@ -63,7 +64,6 @@ type ActiveStream = { const activeStreams = new Map(); -// Limpieza automática de streams antiguos setInterval(() => { const now = Date.now(); @@ -73,12 +73,10 @@ setInterval(() => { if (age > STREAM_TTL) { console.log(`🗑️ Limpiando stream antiguo: ${hash}`); - // Matar proceso si sigue corriendo if (stream.process && !stream.process.killed) { stream.process.kill('SIGKILL'); } - // Eliminar archivos HLS if (fs.existsSync(stream.hlsDir)) { fs.rmSync(stream.hlsDir, { recursive: true, force: true }); } @@ -86,7 +84,274 @@ setInterval(() => { 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 = { + 'ass': 'ass', + 'ssa': 'ass', + 'subrip': 'srt', + 'webvtt': 'vtt', + 'hdmv_pgs_subtitle': 'sup', + 'mov_text': 'srt', + 'dvd_subtitle': 'sub', + 'text': 'srt' +}; + +async function extractSubtitles(filePath: string, info: MediaInfo, hlsDir: string): Promise { + if (info.subtitles.length === 0) return; + + console.log('📝 Extrayendo subtítulos...'); + + const promises = info.subtitles.map((s, i) => { + return new Promise((resolve, reject) => { + const codec = s.codec.toLowerCase(); + const ext = codec === 'subrip' ? 'srt' + : codec === 'ass' || codec === 'ssa' ? 'ass' + : codec === 'webvtt' ? 'vtt' + : 'sub'; + + const outputFilename = `s${i}_full.${ext}`; + const outputPath = path.join(hlsDir, outputFilename); + const playlistPath = path.join(hlsDir, `subs_${i}.m3u8`); + + if (s.duration === 0) { + console.log(`⚠️ Sub vacío, skip: ${i}`); + createDummySubtitlePlaylist(playlistPath, outputFilename, info.duration); + fs.writeFileSync(outputPath, ''); + resolve(); + return; + } + + console.log(` Track ${i}: ${s.title || s.language || 'Unknown'} [${codec}] -> ${ext}`); + + const args = [ + '-i', filePath, + '-map', `0:s:${i}` + ]; + + args.push('-c:s', 'copy'); + args.push('-y', outputPath); + + console.log(` Comando: ffmpeg ${args.join(' ')}`); + + const p = spawn(FFMPEG_PATH, args, { + stdio: ['ignore', 'pipe', 'pipe'] + }); + + let errorOutput = ''; + + p.stderr.on('data', (data) => { + errorOutput += data.toString(); + }); + + p.on('close', (code) => { + if (code === 0) { + try { + + if (!fs.existsSync(outputPath)) { + console.error(` ❌ Archivo no creado: ${outputFilename}`); + + fs.writeFileSync(outputPath, ''); + } else { + const stat = fs.statSync(outputPath); + if (stat.size === 0) { + console.error(` ⚠️ Subtítulo ${i} tiene 0 bytes`); + console.error(` FFmpeg stderr:`, errorOutput.slice(-500)); + } else { + console.log(` ✅ Subtítulo ${i} extraído: ${stat.size} bytes`); + } + } + + createDummySubtitlePlaylist(playlistPath, outputFilename, info.duration); + resolve(); + } catch (e) { + console.error(` Error procesando subtítulo ${i}:`, e); + resolve(); + } + } else { + console.error(` ❌ Error extrayendo subtítulo ${i}. Exit code: ${code}`); + console.error(` FFmpeg stderr:`, errorOutput.slice(-500)); + + fs.writeFileSync(outputPath, ''); + createDummySubtitlePlaylist(playlistPath, outputFilename, info.duration); + resolve(); + } + }); + + p.on('error', (err) => { + console.error(` Error spawn ffmpeg subs ${i}:`, err); + fs.writeFileSync(outputPath, ''); + createDummySubtitlePlaylist(playlistPath, outputFilename, info.duration); + resolve(); + }); + }); + }); + + await Promise.all(promises); + console.log('✅ Proceso de subtítulos finalizado.'); +} + +function createDummySubtitlePlaylist(playlistPath: string, subtitleFilename: string, duration: number) { + const content = [ + '#EXTM3U', + '#EXT-X-VERSION:3', + `#EXT-X-TARGETDURATION:${Math.ceil(duration)}`, + '#EXT-X-MEDIA-SEQUENCE:0', + '#EXT-X-PLAYLIST-TYPE:VOD', + `#EXTINF:${duration.toFixed(6)},`, + subtitleFilename, + '#EXT-X-ENDLIST' + ].join('\n'); + + fs.writeFileSync(playlistPath, content); +} async function probeMediaFile(filePath: string): Promise { return new Promise((resolve, reject) => { @@ -141,7 +406,8 @@ async function probeMediaFile(filePath: string): Promise { index: s.index, codec: s.codec_name, 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) => ({ @@ -153,6 +419,8 @@ async function probeMediaFile(filePath: string): Promise { const duration = parseFloat(data.format?.duration) || 0; + console.log(`📊 Media info: ${video.length} video, ${audio.length} audio, ${subtitles.length} subs`); + resolve({ video, audio, subtitles, chapters, duration }); } catch (e) { reject(e); @@ -163,261 +431,6 @@ async function probeMediaFile(filePath: string): Promise { }); } -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 = { - '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 { - if (info.subtitles.length === 0) return; - - console.log('📑 Extrayendo subtítulos...'); - - const promises = info.subtitles.map((s, i) => { - return new Promise((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 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) { if (!fs.existsSync(filePath)) { return null; @@ -425,7 +438,6 @@ export async function getStreamingManifest(filePath: string) { const hash = getStreamHash(filePath); - // 1. DEFINIMOS LA FUNCIÓN HELPER AQUÍ FUERA PARA USARLA EN AMBOS CASOS const formatSubtitles = (subs: SubtitleStreamInfo[]) => { return subs.map((s, i) => { const ext = SUBTITLE_EXTENSIONS[s.codec] || 'vtt'; @@ -434,13 +446,12 @@ export async function getStreamingManifest(filePath: string) { codec: s.codec, language: s.language || 'und', 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}` }; }); }; - // Caso 1: Stream ya existente const existing = activeStreams.get(hash); if (existing) { existing.lastAccessed = Date.now(); @@ -464,7 +475,7 @@ export async function getStreamingManifest(filePath: string) { title: a.title || `Audio ${a.index}`, channels: a.channels })), - // USAMOS EL HELPER + subtitles: formatSubtitles(existing.info.subtitles), chapters: existing.info.chapters.map(c => ({ id: c.id, @@ -475,10 +486,40 @@ export async function getStreamingManifest(filePath: string) { }; } - // Caso 2: Nuevo stream (Generating) - const info = await probeMediaFile(filePath); - const stream = startHLSConversion(filePath, info, hash); + const duplicateCheck = activeStreams.get(hash); + if (duplicateCheck) { + duplicateCheck.lastAccessed = Date.now(); + return { + type: 'hls', + hash, + masterPlaylist: `/api/library/hls/${hash}/master.m3u8`, + duration: duplicateCheck.info.duration, + isComplete: duplicateCheck.isComplete, + video: duplicateCheck.info.video.map(v => ({ + index: v.index, + codec: v.codec, + resolution: `${v.width}x${v.height}`, + fps: v.fps + })), + audio: duplicateCheck.info.audio.map(a => ({ + index: a.index, + codec: a.codec, + language: a.language || 'und', + title: a.title || `Audio ${a.index}`, + channels: a.channels + })), + subtitles: formatSubtitles(duplicateCheck.info.subtitles), + chapters: duplicateCheck.info.chapters.map(c => ({ + id: c.id, + start: c.start, + end: c.end, + title: c.title + })) + }; + } + + const info = await probeMediaFile(filePath); return { type: 'hls', hash, @@ -513,11 +554,10 @@ export function getSubtitleFileStream(hash: string, trackIndex: number) { const stream = activeStreams.get(hash); 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); if(fs.existsSync(tempDir)) { - // Lógica de rescate si el servidor se reinició pero los archivos siguen ahí + const files = fs.readdirSync(tempDir); const subFile = files.find(f => f.startsWith(`s${trackIndex}_full.`)); 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; - // Buscamos s{index}_full.{ext} const files = fs.readdirSync(stream.hlsDir); const subtitleFile = files.find(f => f.startsWith(`s${trackIndex}_full.`)); @@ -543,13 +582,12 @@ export async function getHLSFile(hash: string, filename: string) { return null; } - // Actualizar último acceso stream.lastAccessed = Date.now(); const filePath = path.join(stream.hlsDir, filename); - // Esperar a que el archivo exista (con timeout) - const maxWait = 30000; // 30 segundos + const maxWait = 30000; + const startWait = Date.now(); while (!fs.existsSync(filePath)) { @@ -558,12 +596,10 @@ export async function getHLSFile(hash: string, filename: string) { return null; } - // Si el proceso terminó y el archivo no existe, error if (stream.isComplete && !fs.existsSync(filePath)) { return null; } - // Esperar un poco antes de reintentar await new Promise(resolve => setTimeout(resolve, 100)); } diff --git a/docker/src/scripts/anime/player.js b/docker/src/scripts/anime/player.js index 05b5616..89ccb81 100644 --- a/docker/src/scripts/anime/player.js +++ b/docker/src/scripts/anime/player.js @@ -857,27 +857,15 @@ const AnimePlayer = (function() { if (hlsInstance) hlsInstance.audioTrack = parseInt(value); } else if (type === 'subtitle') { 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) { Array.from(els.video.textTracks).forEach((track, i) => { - // Si usamos JASSUB, ocultamos la nativa. Si no, mostramos la seleccionada. - track.mode = (subtitleRenderer && idx !== -1) ? 'hidden' : ((i === idx) ? 'showing' : 'hidden'); + track.mode = 'hidden'; }); } - // 2. Lógica de JASSUB - if (subtitleRenderer) { - if (idx === -1) { - subtitleRenderer.dispose(); - } else { - const sub = _currentSubtitles[idx]; - if (sub) { - subtitleRenderer.setTrack(sub.src); - } - } - } + initSubtitleRenderer(); } else if (type === 'speed') { if (els.video) els.video.playbackRate = parseFloat(value); } @@ -1185,20 +1173,6 @@ const AnimePlayer = (function() { 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() { if (!_currentEpisode) return; _progressUpdated = false; @@ -1225,16 +1199,21 @@ const AnimePlayer = (function() { _rawVideoData = null; } + // En la función loadStream(), cuando detectas local: + if (currentExt === 'local') { try { 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`); if (!unitsRes.ok) throw new Error("Could not fetch local units"); 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) { throw new Error(`Episode ${_currentEpisode} not found in local library`); @@ -1242,46 +1221,70 @@ const AnimePlayer = (function() { 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"); const manifestData = await manifestRes.json(); + // DEBUG: Verificar que llega aquí + console.log("Manifest Loaded:", manifestData); + + // 3. Chapters if (manifestData.chapters && manifestData.chapters.length > 0) { _skipIntervals = manifestData.chapters.map(c => ({ startTime: c.start, endTime: c.end, - type: c.title.toLowerCase().includes('op') ? 'op' : - c.title.toLowerCase().includes('ed') ? 'ed' : 'chapter' + type: (c.title || '').toLowerCase().includes('op') ? 'op' : + (c.title || '').toLowerCase().includes('ed') ? 'ed' : 'chapter' })); renderSkipMarkers(_skipIntervals); monitorSkipButton(_skipIntervals); } - // 4. Mapear Subtítulos WebVTT - const subs = (manifestData.subtitles || []).map(s => ({ - label: s.title || s.language || `Track ${s.index}`, - srclang: s.language || 'unk', - src: s.url // URL al endpoint de conversión VTT (.vtt) - })); + // 4. CORRECCIÓN DE SUBTÍTULOS + const rawSubs = manifestData.subtitles || []; + console.log("Raw Subtitles from JSON:", rawSubs); + + 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 = { url: manifestData.masterPlaylist, 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); } catch(e) { console.error("Local HLS Error:", e); setLoading("Local Error: " + e.message); - // Fallback: si falla, intentar cargar desde extensión online + // Fallback logic... const localOption = els.extSelect.querySelector('option[value="local"]'); if (localOption) localOption.remove(); - const fallbackSource = (_entrySource === 'local') ? 'anilist' : _entrySource; els.extSelect.value = fallbackSource; handleExtensionChange(true); diff --git a/docker/src/scripts/anime/subtitle-renderer.js b/docker/src/scripts/anime/subtitle-renderer.js index fe07912..aa93e63 100644 --- a/docker/src/scripts/anime/subtitle-renderer.js +++ b/docker/src/scripts/anime/subtitle-renderer.js @@ -1,60 +1,46 @@ const BASE_PATH = '/src/scripts/jassub/'; - class SubtitleRenderer { - // 1. Aceptamos 'canvas' en el constructor constructor(video, canvas) { this.video = video; this.canvas = canvas; this.instance = null; this.currentUrl = null; } - async init(subtitleUrl) { - if (!this.video || !this.canvas) return; // 2. Verificamos canvas - + if (!this.video || !this.canvas) return; this.dispose(); - const finalUrl = subtitleUrl.includes('/api/proxy') ? subtitleUrl : `/api/proxy?url=${encodeURIComponent(subtitleUrl)}`; - this.currentUrl = finalUrl; - try { this.instance = new JASSUB({ video: this.video, canvas: this.canvas, subUrl: finalUrl, - workerUrl: `${BASE_PATH}jassub-worker.js`, wasmUrl: `${BASE_PATH}jassub-worker.wasm`, modernWasmUrl: `${BASE_PATH}jassub-worker-modern.wasm`, - blendMode: 'js', asyncRender: true, onDemand: true, targetFps: 60, debug: false }); - console.log('JASSUB initialized for:', finalUrl); - } catch (e) { console.error("JASSUB Init Error:", e); } } - resize() { if (this.instance && this.instance.resize) { this.instance.resize(); } } - setTrack(url) { const finalUrl = url.includes('/api/proxy') ? url : `/api/proxy?url=${encodeURIComponent(url)}`; - if (this.instance) { this.instance.setTrackByUrl(finalUrl); this.currentUrl = finalUrl; @@ -62,7 +48,6 @@ class SubtitleRenderer { this.init(url); } } - dispose() { if (this.instance) { try { @@ -74,8 +59,6 @@ class SubtitleRenderer { } } } - -// Simple Renderer remains unchanged for SRT/VTT (Non-ASS) class SimpleSubtitleRenderer { constructor(video, canvas) { this.video = video; @@ -83,11 +66,9 @@ class SimpleSubtitleRenderer { this.ctx = canvas.getContext('2d'); this.cues = []; this.destroyed = false; - this.setupCanvas(); this.video.addEventListener('timeupdate', () => this.render()); } - setupCanvas() { const updateSize = () => { if (!this.video || !this.canvas) return; @@ -99,19 +80,31 @@ class SimpleSubtitleRenderer { window.addEventListener('resize', updateSize); this.resizeHandler = updateSize; } - async loadSubtitles(url) { 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(); this.cues = this.parseSRT(text); } catch (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) { - 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 => { const lines = block.split('\n'); if (lines.length < 3) return null; @@ -119,11 +112,12 @@ class SimpleSubtitleRenderer { if (!timeMatch) return null; 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 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 }; }).filter(Boolean); } - render() { if (this.destroyed) return; 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); if (cue) this.drawSubtitle(cue.text); } - drawSubtitle(text) { const lines = text.split('\n'); 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); }); } - dispose() { this.destroyed = true; this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); if (this.resizeHandler) window.removeEventListener('resize', this.resizeHandler); } } - window.SubtitleRenderer = SubtitleRenderer; window.SimpleSubtitleRenderer = SimpleSubtitleRenderer; \ No newline at end of file