From 5fd2341e8e98d086db42ea573bd51edb5e3bd64e Mon Sep 17 00:00:00 2001 From: lenafx Date: Mon, 5 Jan 2026 13:21:54 +0100 Subject: [PATCH] stream local files to player --- desktop/src/api/local/download.service.ts | 180 ++++--- desktop/src/api/local/local.controller.ts | 129 ++++- desktop/src/api/local/local.routes.ts | 6 + desktop/src/api/local/local.service.ts | 24 + desktop/src/api/local/streaming.service.ts | 586 +++++++++++++++++++++ desktop/src/scripts/anime/player.js | 144 ++--- desktop/src/shared/config.js | 2 + docker/server.js | 2 +- docker/src/api/local/download.service.ts | 180 ++++--- docker/src/api/local/local.controller.ts | 129 ++++- docker/src/api/local/local.routes.ts | 6 + docker/src/api/local/local.service.ts | 24 + docker/src/api/local/streaming.service.ts | 586 +++++++++++++++++++++ docker/src/scripts/anime/player.js | 144 ++--- docker/src/shared/config.js | 2 + 15 files changed, 1869 insertions(+), 275 deletions(-) create mode 100644 desktop/src/api/local/streaming.service.ts create mode 100644 docker/src/api/local/streaming.service.ts diff --git a/desktop/src/api/local/download.service.ts b/desktop/src/api/local/download.service.ts index 72e5b28..0139cca 100644 --- a/desktop/src/api/local/download.service.ts +++ b/desktop/src/api/local/download.service.ts @@ -9,8 +9,38 @@ import AdmZip from 'adm-zip'; import { spawn } from 'child_process'; const { values } = loadConfig(); -const FFMPEG_PATH = - values.paths?.ffmpeg || 'ffmpeg'; +const FFMPEG_PATH = values.paths?.ffmpeg || 'ffmpeg'; + +type DownloadStatus = { + id: string; + type: 'anime' | 'manga' | 'novel'; + anilistId: number; + unitNumber: number; + status: 'pending' | 'downloading' | 'completed' | 'failed'; + progress: number; + speed?: string; + timeElapsed?: string; + error?: string; + startedAt: number; + completedAt?: number; +}; + +const activeDownloads = new Map(); + +export function getActiveDownloads(): DownloadStatus[] { + return Array.from(activeDownloads.values()); +} + +export function getDownloadById(id: string): DownloadStatus | undefined { + return activeDownloads.get(id); +} + +function updateDownloadProgress(id: string, updates: Partial) { + const current = activeDownloads.get(id); + if (current) { + activeDownloads.set(id, { ...current, ...updates }); + } +} type AnimeDownloadParams = { anilistId: number; @@ -109,6 +139,18 @@ async function getOrCreateEntry( export async function downloadAnimeEpisode(params: AnimeDownloadParams) { const { anilistId, episodeNumber, streamUrl, subtitles, chapters } = params; + const downloadId = crypto.randomUUID(); + + activeDownloads.set(downloadId, { + id: downloadId, + type: 'anime', + anilistId, + unitNumber: episodeNumber, + status: 'pending', + progress: 0, + startedAt: Date.now() + }); + const entry = await getOrCreateEntry(anilistId, 'anime'); const exists = await queryOne( @@ -116,20 +158,25 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) { [entry.id, episodeNumber], 'local_library' ); - if (exists) return { status: 'ALREADY_EXISTS', entry_id: entry.id, episode: episodeNumber }; + + if (exists) { + activeDownloads.delete(downloadId); + return { status: 'ALREADY_EXISTS', entry_id: entry.id, episode: episodeNumber }; + } const outputPath = path.join(entry.path, `Episode_${episodeNumber.toString().padStart(2, '0')}.mkv`); const tempDir = path.join(entry.path, '.temp'); await ensureDirectory(tempDir); try { + updateDownloadProgress(downloadId, { status: 'downloading' }); + let videoInput = streamUrl; let audioInputs: string[] = []; const isMaster = (params as any).is_master === true; if (isMaster) { - const variant = (params as any).variant; const audios = (params as any).audio; @@ -178,13 +225,11 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) { if (chapters?.length) { const meta = path.join(tempDir, 'chapters.txt'); - const sorted = [...chapters].sort((a, b) => a.start_time - b.start_time); const lines: string[] = [';FFMETADATA1']; for (let i = 0; i < sorted.length; i++) { const c = sorted[i]; - const start = Math.floor(c.start_time * 1000); const end = Math.floor(c.end_time * 1000); const title = (c.title || 'chapter').toUpperCase(); @@ -220,18 +265,14 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) { fs.writeFileSync(meta, lines.join('\n')); args.push('-i', meta); - - // índice correcto del metadata input chaptersInputIndex = 1 + audioInputs.length + subFiles.length; } args.push('-map', '0:v:0'); if (audioInputs.length > 0) { - audioInputs.forEach((_, i) => { args.push('-map', `${i + 1}:a:0`); - const audioInfo = (params as any).audio?.[i]; if (audioInfo) { const audioStreamIndex = i; @@ -244,7 +285,6 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) { } }); } else { - args.push('-map', '0:a:0?'); } @@ -258,68 +298,33 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) { args.push('-map_metadata', `${chaptersInputIndex}`); } - args.push('-c:v', 'copy'); - - args.push('-c:a', 'copy'); - - if (subFiles.length) { - args.push('-c:s', 'srt'); - - } - - args.push('-y'); - - args.push(outputPath); + args.push('-c:v', 'copy', '-c:a', 'copy'); + if (subFiles.length) args.push('-c:s', 'srt'); + args.push('-y', outputPath); await new Promise((resolve, reject) => { - console.log('🎬 Iniciando descarga con FFmpeg...'); - console.log('📹 Video:', videoInput); - if (audioInputs.length > 0) { - console.log('🔊 Audio tracks:', audioInputs.length); - } - console.log('💾 Output:', outputPath); - console.log('Args:', args.join(' ')); - const ff = spawn(FFMPEG_PATH, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }); - let lastProgress = ''; - - ff.stdout.on('data', (data) => { - const text = data.toString(); - console.log('[stdout]', text); - }); - ff.stderr.on('data', (data) => { const text = data.toString(); + const timeMatch = text.match(/time=(\S+)/); + const speedMatch = text.match(/speed=(\S+)/); - if (text.includes('time=') || text.includes('speed=')) { - const timeMatch = text.match(/time=(\S+)/); - const speedMatch = text.match(/speed=(\S+)/); - if (timeMatch || speedMatch) { - lastProgress = `⏱️ Time: ${timeMatch?.[1] || 'N/A'} | Speed: ${speedMatch?.[1] || 'N/A'}`; - console.log(lastProgress); - } - } else { - console.log('[ffmpeg]', text); + if (timeMatch || speedMatch) { + updateDownloadProgress(downloadId, { + timeElapsed: timeMatch?.[1], + speed: speedMatch?.[1] + }); } }); - ff.on('error', (error) => { - console.error('❌ Error al iniciar FFmpeg:', error); - reject(error); - }); - + ff.on('error', (error) => reject(error)); ff.on('close', (code) => { - if (code === 0) { - console.log('✅ Descarga completada exitosamente'); - resolve(true); - } else { - console.error(`❌ FFmpeg terminó con código: ${code}`); - reject(new Error(`FFmpeg exited with code ${code}`)); - } + if (code === 0) resolve(true); + else reject(new Error(`FFmpeg exited with code ${code}`)); }); }); @@ -339,8 +344,17 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) { 'local_library' ); + updateDownloadProgress(downloadId, { + status: 'completed', + progress: 100, + completedAt: Date.now() + }); + + setTimeout(() => activeDownloads.delete(downloadId), 30000); + return { status: 'SUCCESS', + download_id: downloadId, entry_id: entry.id, file_id: fileId, episode: episodeNumber, @@ -350,6 +364,14 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) { } catch (e: any) { fs.rmSync(tempDir, { recursive: true, force: true }); if (fs.existsSync(outputPath)) fs.unlinkSync(outputPath); + + updateDownloadProgress(downloadId, { + status: 'failed', + error: e.message + }); + + setTimeout(() => activeDownloads.delete(downloadId), 60000); + const err = new Error('DOWNLOAD_FAILED'); (err as any).details = e.message; throw err; @@ -359,6 +381,18 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) { export async function downloadBookChapter(params: BookDownloadParams) { const { anilistId, chapterNumber, format, content, images } = params; + const downloadId = crypto.randomUUID(); + + activeDownloads.set(downloadId, { + id: downloadId, + type: format === 'manga' ? 'manga' : 'novel', + anilistId, + unitNumber: chapterNumber, + status: 'pending', + progress: 0, + startedAt: Date.now() + }); + const type = format === 'manga' ? 'manga' : 'novels'; const entry = await getOrCreateEntry(anilistId, type); @@ -369,6 +403,7 @@ export async function downloadBookChapter(params: BookDownloadParams) { ); if (existingFile) { + activeDownloads.delete(downloadId); return { status: 'ALREADY_EXISTS', message: `Chapter ${chapterNumber} already exists`, @@ -378,6 +413,8 @@ export async function downloadBookChapter(params: BookDownloadParams) { } try { + updateDownloadProgress(downloadId, { status: 'downloading' }); + let outputPath: string; let fileId: string; @@ -388,7 +425,8 @@ export async function downloadBookChapter(params: BookDownloadParams) { const zip = new AdmZip(); const sortedImages = images!.sort((a, b) => a.index - b.index); - for (const img of sortedImages) { + for (let i = 0; i < sortedImages.length; i++) { + const img = sortedImages[i]; const res = await fetch(img.url); if (!res.ok) throw new Error(`HTTP_${res.status}`); const buf = Buffer.from(await res.arrayBuffer()); @@ -396,6 +434,10 @@ export async function downloadBookChapter(params: BookDownloadParams) { const ext = path.extname(new URL(img.url).pathname) || '.jpg'; const filename = `${img.index.toString().padStart(4, '0')}${ext}`; zip.addFile(filename, buf); + + updateDownloadProgress(downloadId, { + progress: Math.floor((i / sortedImages.length) * 100) + }); } zip.writeZip(outputPath); @@ -405,7 +447,6 @@ export async function downloadBookChapter(params: BookDownloadParams) { outputPath = path.join(entry.path, chapterName); const zip = new AdmZip(); - zip.addFile('mimetype', Buffer.from('application/epub+zip'), '', 0); const containerXml = ` @@ -443,7 +484,6 @@ ${content} `; zip.addFile('OEBPS/chapter.xhtml', Buffer.from(chapterXhtml)); - zip.writeZip(outputPath); } @@ -461,8 +501,17 @@ ${content} 'local_library' ); + updateDownloadProgress(downloadId, { + status: 'completed', + progress: 100, + completedAt: Date.now() + }); + + setTimeout(() => activeDownloads.delete(downloadId), 30000); + return { status: 'SUCCESS', + download_id: downloadId, entry_id: entry.id, file_id: fileId, chapter: chapterNumber, @@ -471,6 +520,13 @@ ${content} }; } catch (error: any) { + updateDownloadProgress(downloadId, { + status: 'failed', + error: error.message + }); + + setTimeout(() => activeDownloads.delete(downloadId), 60000); + const err = new Error('DOWNLOAD_FAILED'); (err as any).details = error.message; throw err; diff --git a/desktop/src/api/local/local.controller.ts b/desktop/src/api/local/local.controller.ts index 96a62d7..659bbfc 100644 --- a/desktop/src/api/local/local.controller.ts +++ b/desktop/src/api/local/local.controller.ts @@ -2,6 +2,7 @@ import {FastifyReply, FastifyRequest} from 'fastify'; import fs from 'fs'; import * as service from './local.service'; import * as downloadService from './download.service'; +import * as streamingService from './streaming.service'; type ScanQuery = { mode?: 'full' | 'incremental'; @@ -21,7 +22,7 @@ type DownloadAnimeBody = | { anilist_id: number; episode_number: number; - stream_url: string; // media playlist FINAL + stream_url: string; is_master?: false; subtitles?: { language: string; @@ -36,28 +37,24 @@ type DownloadAnimeBody = | { anilist_id: number; episode_number: number; - stream_url: string; // master.m3u8 + stream_url: string; is_master: true; - variant: { resolution: string; bandwidth?: number; codecs?: string; playlist_url: string; }; - audio?: { group?: string; language?: string; name?: string; playlist_url: string; }[]; - subtitles?: { language: string; url: string; }[]; - chapters?: { title: string; start_time: number; @@ -266,7 +263,6 @@ export async function downloadAnime(request: FastifyRequest<{ Body: DownloadAnim const clientHeaders = (request.body as any).headers || {}; - // Validación básica if (!anilist_id || !episode_number || !stream_url) { return reply.status(400).send({ error: 'MISSING_REQUIRED_FIELDS', @@ -274,17 +270,14 @@ export async function downloadAnime(request: FastifyRequest<{ Body: DownloadAnim }); } - // Proxy del stream URL principal const proxyUrl = buildProxyUrl(stream_url, clientHeaders); console.log('Stream URL:', proxyUrl); - // Proxy de subtítulos const proxiedSubs = subtitles?.map(sub => ({ ...sub, url: buildProxyUrl(sub.url, clientHeaders) })); - // Preparar parámetros base const downloadParams: any = { anilistId: anilist_id, episodeNumber: episode_number, @@ -293,7 +286,6 @@ export async function downloadAnime(request: FastifyRequest<{ Body: DownloadAnim chapters }; - // Si es master playlist, agregar campos adicionales if (is_master === true) { const { variant, audio } = request.body as any; @@ -305,14 +297,11 @@ export async function downloadAnime(request: FastifyRequest<{ Body: DownloadAnim } downloadParams.is_master = true; - - // Proxy del variant playlist downloadParams.variant = { ...variant, playlist_url: buildProxyUrl(variant.playlist_url, clientHeaders) }; - // Proxy de audio tracks si existen if (audio && audio.length > 0) { downloadParams.audio = audio.map((a: any) => ({ ...a, @@ -408,4 +397,116 @@ export async function downloadBook(request: FastifyRequest<{ Body: DownloadBookB return reply.status(500).send({ error: 'FAILED_TO_DOWNLOAD_BOOK' }); } +} + +// NUEVO: Estado de descargas +export async function getDownloadStatus(request: FastifyRequest, reply: FastifyReply) { + try { + const downloads = downloadService.getActiveDownloads(); + const streams = streamingService.getActiveStreamsStatus(); + + return { + downloads: { + total: downloads.length, + active: downloads.filter(d => d.status === 'downloading').length, + completed: downloads.filter(d => d.status === 'completed').length, + failed: downloads.filter(d => d.status === 'failed').length, + list: downloads + }, + streams: { + total: streams.length, + active: streams.filter(s => !s.isComplete).length, + completed: streams.filter(s => s.isComplete).length, + list: streams + } + }; + } catch (err) { + console.error('Error getting download status:', err); + return reply.status(500).send({ error: 'FAILED_TO_GET_DOWNLOAD_STATUS' }); + } +} + +// NUEVO: Streaming HLS para anime local +export async function getAnimeStreamManifest(request: FastifyRequest, reply: FastifyReply) { + try { + const { type, id, unit } = request.params as any; + + if (type !== 'anime') { + return reply.status(400).send({ error: 'ONLY_ANIME_SUPPORTED' }); + } + + const fileInfo = await service.getFileForStreaming(id, unit); + + if (!fileInfo) { + return reply.status(404).send({ error: 'FILE_NOT_FOUND' }); + } + + const manifest = await streamingService.getStreamingManifest(fileInfo.filePath); + + if (!manifest) { + return reply.status(500).send({ error: 'FAILED_TO_GENERATE_MANIFEST' }); + } + + return manifest; + } catch (err: any) { + console.error('Error getting stream manifest:', err); + return reply.status(500).send({ error: 'FAILED_TO_GET_STREAM_MANIFEST' }); + } +} + +// NUEVO: Servir archivos HLS +export async function serveHLSFile(request: FastifyRequest, reply: FastifyReply) { + try { + const { hash, filename } = request.params as any; + + const file = await streamingService.getHLSFile(hash, filename); + + if (!file) { + return reply.status(404).send({ error: 'FILE_NOT_FOUND' }); + } + + const contentType = filename.endsWith('.m3u8') + ? 'application/vnd.apple.mpegurl' + : filename.endsWith('.vtt') + ? 'text/vtt' + : 'video/mp2t'; + + reply + .header('Content-Type', contentType) + .header('Content-Length', file.stat.size) + .header('Access-Control-Allow-Origin', '*'); + + return fs.createReadStream(file.path); + } catch (err) { + 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 277f358..791b40f 100644 --- a/desktop/src/api/local/local.routes.ts +++ b/desktop/src/api/local/local.routes.ts @@ -5,13 +5,19 @@ async function localRoutes(fastify: FastifyInstance) { fastify.post('/library/scan', controller.scanLibrary); fastify.get('/library/:type', controller.listEntries); fastify.get('/library/:type/:id', controller.getEntry); + + // 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); fastify.get('/library/:unitId/resource/:resId', controller.getPage); fastify.post('/library/download/anime', controller.downloadAnime); fastify.post('/library/download/book', controller.downloadBook); + fastify.get('/library/downloads/status', controller.getDownloadStatus); + fastify.get('/library/stream/:type/:id/:unit/manifest', controller.getAnimeStreamManifest); + fastify.get('/library/hls/:hash/:filename', controller.serveHLSFile); } export default localRoutes; \ No newline at end of file diff --git a/desktop/src/api/local/local.service.ts b/desktop/src/api/local/local.service.ts index bd44928..6129f28 100644 --- a/desktop/src/api/local/local.service.ts +++ b/desktop/src/api/local/local.service.ts @@ -7,6 +7,7 @@ import path from "path"; import { getAnimeById, searchAnimeLocal } from "../anime/anime.service"; import { getBookById, searchBooksAniList } from "../books/books.service"; import AdmZip from 'adm-zip'; +import { getStreamHash, getSubtitleFileStream } from './streaming.service'; const MANGA_IMAGE_EXTS = ['.jpg', '.jpeg', '.png', '.webp']; const MANGA_ARCHIVES = ['.cbz', '.cbr', '.zip']; @@ -509,4 +510,27 @@ 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 new file mode 100644 index 0000000..ba76aee --- /dev/null +++ b/desktop/src/api/local/streaming.service.ts @@ -0,0 +1,586 @@ +import { getConfig as loadConfig } from '../../shared/config'; +import { queryOne } from '../../shared/database'; +import crypto from 'crypto'; +import fs from 'fs'; +import path from 'path'; +import { spawn } from 'child_process'; + +const { values } = loadConfig(); +const FFMPEG_PATH = values.paths?.ffmpeg || 'ffmpeg'; +const FFPROBE_PATH = values.paths?.ffprobe || 'ffprobe'; + +const STREAM_TTL = 2 * 60 * 60 * 1000; // 2 horas + +type VideoStreamInfo = { + index: number; + codec: string; + width: number; + height: number; + fps: number; +}; + +type AudioStreamInfo = { + index: number; + codec: string; + language?: string; + title?: string; + channels: number; + sampleRate: number; +}; + +type SubtitleStreamInfo = { + index: number; + codec: string; + language?: string; + title?: string; +}; + +type ChapterInfo = { + id: number; + start: number; + end: number; + title: string; +}; + +type MediaInfo = { + video: VideoStreamInfo[]; + audio: AudioStreamInfo[]; + subtitles: SubtitleStreamInfo[]; + chapters: ChapterInfo[]; + duration: number; +}; + +type ActiveStream = { + hash: string; + filePath: string; + hlsDir: string; + info: MediaInfo; + process?: any; + startedAt: number; + lastAccessed: number; + isComplete: boolean; +}; + +const activeStreams = new Map(); + +// Limpieza automática de streams antiguos +setInterval(() => { + const now = Date.now(); + + for (const [hash, stream] of activeStreams.entries()) { + const age = now - stream.lastAccessed; + + if (age > STREAM_TTL) { + console.log(`🗑️ Limpiando stream antiguo: ${hash}`); + + // 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 }); + } + + activeStreams.delete(hash); + } + } +}, 60 * 1000); // Revisar cada minuto + +async function probeMediaFile(filePath: string): Promise { + return new Promise((resolve, reject) => { + const args = [ + '-v', 'quiet', + '-print_format', 'json', + '-show_streams', + '-show_chapters', + '-show_format', + filePath + ]; + + const ffprobe = spawn(FFPROBE_PATH, args); + let output = ''; + + ffprobe.stdout.on('data', (data) => { + output += data.toString(); + }); + + ffprobe.on('close', (code) => { + if (code !== 0) { + return reject(new Error(`FFprobe failed with code ${code}`)); + } + + try { + const data = JSON.parse(output); + + const video: VideoStreamInfo[] = data.streams + .filter((s: any) => s.codec_type === 'video') + .map((s: any) => ({ + index: s.index, + codec: s.codec_name, + width: s.width, + height: s.height, + fps: eval(s.r_frame_rate) || 24 + })); + + const audio: AudioStreamInfo[] = data.streams + .filter((s: any) => s.codec_type === 'audio') + .map((s: any) => ({ + index: s.index, + codec: s.codec_name, + language: s.tags?.language, + title: s.tags?.title, + channels: s.channels || 2, + sampleRate: parseInt(s.sample_rate) || 48000 + })); + + const subtitles: SubtitleStreamInfo[] = data.streams + .filter((s: any) => s.codec_type === 'subtitle') + .map((s: any) => ({ + index: s.index, + codec: s.codec_name, + language: s.tags?.language, + title: s.tags?.title + })); + + const chapters: ChapterInfo[] = (data.chapters || []).map((c: any) => ({ + id: c.id, + start: parseFloat(c.start_time), + end: parseFloat(c.end_time), + title: c.tags?.title || `Chapter ${c.id + 1}` + })); + + const duration = parseFloat(data.format?.duration) || 0; + + resolve({ video, audio, subtitles, chapters, duration }); + } catch (e) { + reject(e); + } + }); + + ffprobe.on('error', reject); + }); +} + +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; + } + + 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'; + return { + index: s.index, + 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(); + + return { + type: 'hls', + hash, + masterPlaylist: `/api/library/hls/${hash}/master.m3u8`, + duration: existing.info.duration, + isComplete: existing.isComplete, + video: existing.info.video.map(v => ({ + index: v.index, + codec: v.codec, + resolution: `${v.width}x${v.height}`, + fps: v.fps + })), + audio: existing.info.audio.map(a => ({ + index: a.index, + codec: a.codec, + language: a.language || 'und', + title: a.title || `Audio ${a.index}`, + channels: a.channels + })), + // USAMOS EL HELPER + subtitles: formatSubtitles(existing.info.subtitles), + chapters: existing.info.chapters.map(c => ({ + id: c.id, + start: c.start, + end: c.end, + title: c.title + })) + }; + } + + // Caso 2: Nuevo stream (Generating) + const info = await probeMediaFile(filePath); + const stream = startHLSConversion(filePath, info, hash); + + return { + type: 'hls', + hash, + masterPlaylist: `/api/library/hls/${hash}/master.m3u8`, + duration: info.duration, + isComplete: false, + generating: true, + video: info.video.map(v => ({ + index: v.index, + codec: v.codec, + resolution: `${v.width}x${v.height}`, + fps: v.fps + })), + audio: info.audio.map(a => ({ + index: a.index, + codec: a.codec, + language: a.language || 'und', + title: a.title || `Audio ${a.index}`, + channels: a.channels + })), + subtitles: formatSubtitles(info.subtitles), + chapters: info.chapters.map(c => ({ + id: c.id, + start: c.start, + end: c.end, + title: c.title + })) + }; +} + +export function getSubtitleFileStream(hash: string, trackIndex: number) { + const stream = activeStreams.get(hash); + + if (!stream) { + // 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)); + } + return null; + } + + 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.`)); + + if (!subtitleFile) return null; + + return fs.createReadStream(path.join(stream.hlsDir, subtitleFile)); +} + +export async function getHLSFile(hash: string, filename: string) { + const stream = activeStreams.get(hash); + + if (!stream) { + return null; + } + + // 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 startWait = Date.now(); + + while (!fs.existsSync(filePath)) { + if (Date.now() - startWait > maxWait) { + console.error(`⏱️ Timeout esperando archivo: ${filename}`); + 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)); + } + + return { + path: filePath, + stat: fs.statSync(filePath) + }; +} + +export function getActiveStreamsStatus() { + return Array.from(activeStreams.values()).map(stream => ({ + hash: stream.hash, + filePath: stream.filePath, + isComplete: stream.isComplete, + startedAt: stream.startedAt, + lastAccessed: stream.lastAccessed, + age: Date.now() - stream.startedAt, + idle: Date.now() - stream.lastAccessed + })); +} \ No newline at end of file diff --git a/desktop/src/scripts/anime/player.js b/desktop/src/scripts/anime/player.js index 2c1a133..05b5616 100644 --- a/desktop/src/scripts/anime/player.js +++ b/desktop/src/scripts/anime/player.js @@ -882,7 +882,6 @@ const AnimePlayer = (function() { if (els.video) els.video.playbackRate = parseFloat(value); } - // Volvemos al menú principal para confirmar visualmente (opcional, estilo YouTube) _settingsView = 'main'; buildSettingsPanel(); } @@ -904,31 +903,41 @@ const AnimePlayer = (function() { subtitleRenderer = null; } - // Find ASS subtitle - const assSubtitle = _currentSubtitles.find(sub => - (sub.src && sub.src.endsWith('.ass')) || - (sub.label && sub.label.toLowerCase().includes('ass')) - ); + const activeIdx = getActiveSubtitleIndex(); + if (activeIdx === -1) return; - if (!assSubtitle) { - console.log('No ASS subtitles found in current list'); - return; - } + const currentSub = _currentSubtitles[activeIdx]; + if (!currentSub) return; - try { - console.log('Initializing JASSUB for:', assSubtitle.label); + const src = currentSub.src.toLowerCase(); + const label = (currentSub.label || '').toLowerCase(); - // Check if JASSUB global is available - if (window.SubtitleRenderer && typeof window.JASSUB !== 'undefined') { - // --- CAMBIO AQUÍ: Pasamos els.subtitlesCanvas --- - subtitleRenderer = new SubtitleRenderer(els.video, els.subtitlesCanvas); - await subtitleRenderer.init(assSubtitle.src); - } else { - console.warn('JASSUB library not loaded.'); + // CASO 1: ASS (Usa JASSUB) + if (src.endsWith('.ass') || label.includes('ass')) { + try { + console.log('Initializing JASSUB for:', currentSub.label); + if (window.SubtitleRenderer && typeof window.JASSUB !== 'undefined') { + subtitleRenderer = new SubtitleRenderer(els.video, els.subtitlesCanvas); + await subtitleRenderer.init(currentSub.src); + } + } catch (e) { + console.error('JASSUB setup error:', e); } - } catch (e) { - console.error('Subtitle renderer setup error:', e); - subtitleRenderer = null; + } + // CASO 2: SRT (Usa SimpleSubtitleRenderer) + else if (src.endsWith('.srt') || label.includes('srt')) { + try { + console.log('Initializing Simple Renderer for:', currentSub.label); + if (window.SimpleSubtitleRenderer) { + subtitleRenderer = new SimpleSubtitleRenderer(els.video, els.subtitlesCanvas); + await subtitleRenderer.loadSubtitles(currentSub.src); + } + } catch (e) { + console.error('Simple Renderer setup error:', e); + } + } + else { + console.log('Using native browser rendering for VTT'); } } @@ -991,13 +1000,15 @@ const AnimePlayer = (function() { _rpcActive = false; setLoading("Checking availability..."); - // Check local availability let shouldPlayLocal = false; try { const check = await fetch(`/api/library/${_animeId}/units`); const data = await check.json(); const localUnit = data.units ? data.units.find(u => u.number === targetEp) : null; - if (localUnit) shouldPlayLocal = true; + + if (localUnit && els.extSelect.value === 'local') { + shouldPlayLocal = true; + } } catch (e) { console.warn("Availability check failed:", e); shouldPlayLocal = (els.extSelect.value === 'local'); @@ -1216,49 +1227,64 @@ const AnimePlayer = (function() { if (currentExt === 'local') { try { - const localId = await getLocalEntryId(); - const check = await fetch(`/api/library/${_animeId}/units`); - const data = await check.json(); - const targetUnit = data.units ? data.units.find(u => u.number === parseInt(_currentEpisode)) : null; + setLoading("Fetching Local Unit Data..."); + + // 1. Obtener las unidades locales para encontrar el ID del episodio específico + 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; if (!targetUnit) { - console.log(`Episode ${_currentEpisode} not found locally.`); - const localOption = els.extSelect.querySelector('option[value="local"]'); - if (localOption) localOption.remove(); - - const fallbackSource = (_entrySource === 'local') ? 'anilist' : _entrySource; - if (els.extSelect.querySelector(`option[value="${fallbackSource}"]`)) { - els.extSelect.value = fallbackSource; - } else if (els.extSelect.options.length > 0) { - els.extSelect.selectedIndex = 0; - } - handleExtensionChange(true); - return; + throw new Error(`Episode ${_currentEpisode} not found in local library`); } - const ext = targetUnit.format || targetUnit.name.split('.').pop().toLowerCase(); + setLoading("Initializing HLS Stream..."); - if (![''].includes(ext)) { - setLoading(`Local files are not supported on the web player yet. Use MPV.`); - _rawVideoData = { - url: targetUnit.path, - headers: {} - }; - if (els.mpvBtn) els.mpvBtn.style.display = 'flex'; - return; + 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(); + + 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' + })); + renderSkipMarkers(_skipIntervals); + monitorSkipButton(_skipIntervals); } - const localUrl = `/api/library/stream/${targetUnit.id}`; + // 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) + })); + + // 5. Guardar referencia para MPV o descargas _rawVideoData = { - url: localUrl, + url: manifestData.masterPlaylist, headers: {} }; - _currentSubtitles = []; - initVideoPlayer(localUrl, 'mp4'); + // 6. Cargar en el reproductor (Hls.js gestionará los audios del master.m3u8) + initVideoPlayer(manifestData.masterPlaylist, 'm3u8', subs); + } catch(e) { - console.error(e); + console.error("Local HLS Error:", e); setLoading("Local Error: " + e.message); + + // Fallback: si falla, intentar cargar desde extensión online + const localOption = els.extSelect.querySelector('option[value="local"]'); + if (localOption) localOption.remove(); + + const fallbackSource = (_entrySource === 'local') ? 'anilist' : _entrySource; + els.extSelect.value = fallbackSource; + handleExtensionChange(true); } return; } @@ -1530,23 +1556,19 @@ const AnimePlayer = (function() { function renderSkipMarkers(intervals) { if (!els.progressContainer || !els.video.duration) return; - els.progressContainer.querySelectorAll('.skip-range, .skip-cut').forEach(e => e.remove()); - const duration = els.video.duration; - intervals.forEach(skip => { - const startPct = (skip.startTime / duration) * 100; - const endPct = (skip.endTime / duration) * 100; + const startPct = (skip.startTime / els.video.duration) * 100; + const endPct = (skip.endTime / els.video.duration) * 100; const range = document.createElement('div'); - range.className = `skip-range ${skip.type}`; // 'op' o 'ed' + range.className = `skip-range ${skip.type}`; range.style.left = `${startPct}%`; range.style.width = `${endPct - startPct}%`; els.progressContainer.appendChild(range); createCut(startPct); - createCut(endPct); }); } diff --git a/desktop/src/shared/config.js b/desktop/src/shared/config.js index c656ee0..aa388a9 100644 --- a/desktop/src/shared/config.js +++ b/desktop/src/shared/config.js @@ -15,6 +15,7 @@ const DEFAULT_CONFIG = { paths: { mpv: null, ffmpeg: null, + ffprobe: null, cloudflared: null, } }; @@ -28,6 +29,7 @@ export const CONFIG_SCHEMA = { paths: { mpv: { description: "Required to open anime episodes in mpv on desktop version." }, ffmpeg: { description: "Required for downloading anime episodes." }, + ffprobe: { description: "Required for watching local anime episodes." }, cloudflared: { description: "Required for creating pubic rooms." } } }; diff --git a/docker/server.js b/docker/server.js index 5423bf0..e2d82fe 100644 --- a/docker/server.js +++ b/docker/server.js @@ -29,7 +29,7 @@ const configRoutes = require("./dist/api/config/config.routes"); const roomRoutes = require("./dist/api/rooms/rooms.routes"); const { setupRoomWebSocket } = require("./dist/api/rooms/rooms.websocket"); -fastify.addHook("preHandler", async (request) => { +fastify.addHook("preHandler", async (request, reply) => { const auth = request.headers.authorization; if (!auth) return; diff --git a/docker/src/api/local/download.service.ts b/docker/src/api/local/download.service.ts index 72e5b28..0139cca 100644 --- a/docker/src/api/local/download.service.ts +++ b/docker/src/api/local/download.service.ts @@ -9,8 +9,38 @@ import AdmZip from 'adm-zip'; import { spawn } from 'child_process'; const { values } = loadConfig(); -const FFMPEG_PATH = - values.paths?.ffmpeg || 'ffmpeg'; +const FFMPEG_PATH = values.paths?.ffmpeg || 'ffmpeg'; + +type DownloadStatus = { + id: string; + type: 'anime' | 'manga' | 'novel'; + anilistId: number; + unitNumber: number; + status: 'pending' | 'downloading' | 'completed' | 'failed'; + progress: number; + speed?: string; + timeElapsed?: string; + error?: string; + startedAt: number; + completedAt?: number; +}; + +const activeDownloads = new Map(); + +export function getActiveDownloads(): DownloadStatus[] { + return Array.from(activeDownloads.values()); +} + +export function getDownloadById(id: string): DownloadStatus | undefined { + return activeDownloads.get(id); +} + +function updateDownloadProgress(id: string, updates: Partial) { + const current = activeDownloads.get(id); + if (current) { + activeDownloads.set(id, { ...current, ...updates }); + } +} type AnimeDownloadParams = { anilistId: number; @@ -109,6 +139,18 @@ async function getOrCreateEntry( export async function downloadAnimeEpisode(params: AnimeDownloadParams) { const { anilistId, episodeNumber, streamUrl, subtitles, chapters } = params; + const downloadId = crypto.randomUUID(); + + activeDownloads.set(downloadId, { + id: downloadId, + type: 'anime', + anilistId, + unitNumber: episodeNumber, + status: 'pending', + progress: 0, + startedAt: Date.now() + }); + const entry = await getOrCreateEntry(anilistId, 'anime'); const exists = await queryOne( @@ -116,20 +158,25 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) { [entry.id, episodeNumber], 'local_library' ); - if (exists) return { status: 'ALREADY_EXISTS', entry_id: entry.id, episode: episodeNumber }; + + if (exists) { + activeDownloads.delete(downloadId); + return { status: 'ALREADY_EXISTS', entry_id: entry.id, episode: episodeNumber }; + } const outputPath = path.join(entry.path, `Episode_${episodeNumber.toString().padStart(2, '0')}.mkv`); const tempDir = path.join(entry.path, '.temp'); await ensureDirectory(tempDir); try { + updateDownloadProgress(downloadId, { status: 'downloading' }); + let videoInput = streamUrl; let audioInputs: string[] = []; const isMaster = (params as any).is_master === true; if (isMaster) { - const variant = (params as any).variant; const audios = (params as any).audio; @@ -178,13 +225,11 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) { if (chapters?.length) { const meta = path.join(tempDir, 'chapters.txt'); - const sorted = [...chapters].sort((a, b) => a.start_time - b.start_time); const lines: string[] = [';FFMETADATA1']; for (let i = 0; i < sorted.length; i++) { const c = sorted[i]; - const start = Math.floor(c.start_time * 1000); const end = Math.floor(c.end_time * 1000); const title = (c.title || 'chapter').toUpperCase(); @@ -220,18 +265,14 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) { fs.writeFileSync(meta, lines.join('\n')); args.push('-i', meta); - - // índice correcto del metadata input chaptersInputIndex = 1 + audioInputs.length + subFiles.length; } args.push('-map', '0:v:0'); if (audioInputs.length > 0) { - audioInputs.forEach((_, i) => { args.push('-map', `${i + 1}:a:0`); - const audioInfo = (params as any).audio?.[i]; if (audioInfo) { const audioStreamIndex = i; @@ -244,7 +285,6 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) { } }); } else { - args.push('-map', '0:a:0?'); } @@ -258,68 +298,33 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) { args.push('-map_metadata', `${chaptersInputIndex}`); } - args.push('-c:v', 'copy'); - - args.push('-c:a', 'copy'); - - if (subFiles.length) { - args.push('-c:s', 'srt'); - - } - - args.push('-y'); - - args.push(outputPath); + args.push('-c:v', 'copy', '-c:a', 'copy'); + if (subFiles.length) args.push('-c:s', 'srt'); + args.push('-y', outputPath); await new Promise((resolve, reject) => { - console.log('🎬 Iniciando descarga con FFmpeg...'); - console.log('📹 Video:', videoInput); - if (audioInputs.length > 0) { - console.log('🔊 Audio tracks:', audioInputs.length); - } - console.log('💾 Output:', outputPath); - console.log('Args:', args.join(' ')); - const ff = spawn(FFMPEG_PATH, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }); - let lastProgress = ''; - - ff.stdout.on('data', (data) => { - const text = data.toString(); - console.log('[stdout]', text); - }); - ff.stderr.on('data', (data) => { const text = data.toString(); + const timeMatch = text.match(/time=(\S+)/); + const speedMatch = text.match(/speed=(\S+)/); - if (text.includes('time=') || text.includes('speed=')) { - const timeMatch = text.match(/time=(\S+)/); - const speedMatch = text.match(/speed=(\S+)/); - if (timeMatch || speedMatch) { - lastProgress = `⏱️ Time: ${timeMatch?.[1] || 'N/A'} | Speed: ${speedMatch?.[1] || 'N/A'}`; - console.log(lastProgress); - } - } else { - console.log('[ffmpeg]', text); + if (timeMatch || speedMatch) { + updateDownloadProgress(downloadId, { + timeElapsed: timeMatch?.[1], + speed: speedMatch?.[1] + }); } }); - ff.on('error', (error) => { - console.error('❌ Error al iniciar FFmpeg:', error); - reject(error); - }); - + ff.on('error', (error) => reject(error)); ff.on('close', (code) => { - if (code === 0) { - console.log('✅ Descarga completada exitosamente'); - resolve(true); - } else { - console.error(`❌ FFmpeg terminó con código: ${code}`); - reject(new Error(`FFmpeg exited with code ${code}`)); - } + if (code === 0) resolve(true); + else reject(new Error(`FFmpeg exited with code ${code}`)); }); }); @@ -339,8 +344,17 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) { 'local_library' ); + updateDownloadProgress(downloadId, { + status: 'completed', + progress: 100, + completedAt: Date.now() + }); + + setTimeout(() => activeDownloads.delete(downloadId), 30000); + return { status: 'SUCCESS', + download_id: downloadId, entry_id: entry.id, file_id: fileId, episode: episodeNumber, @@ -350,6 +364,14 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) { } catch (e: any) { fs.rmSync(tempDir, { recursive: true, force: true }); if (fs.existsSync(outputPath)) fs.unlinkSync(outputPath); + + updateDownloadProgress(downloadId, { + status: 'failed', + error: e.message + }); + + setTimeout(() => activeDownloads.delete(downloadId), 60000); + const err = new Error('DOWNLOAD_FAILED'); (err as any).details = e.message; throw err; @@ -359,6 +381,18 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) { export async function downloadBookChapter(params: BookDownloadParams) { const { anilistId, chapterNumber, format, content, images } = params; + const downloadId = crypto.randomUUID(); + + activeDownloads.set(downloadId, { + id: downloadId, + type: format === 'manga' ? 'manga' : 'novel', + anilistId, + unitNumber: chapterNumber, + status: 'pending', + progress: 0, + startedAt: Date.now() + }); + const type = format === 'manga' ? 'manga' : 'novels'; const entry = await getOrCreateEntry(anilistId, type); @@ -369,6 +403,7 @@ export async function downloadBookChapter(params: BookDownloadParams) { ); if (existingFile) { + activeDownloads.delete(downloadId); return { status: 'ALREADY_EXISTS', message: `Chapter ${chapterNumber} already exists`, @@ -378,6 +413,8 @@ export async function downloadBookChapter(params: BookDownloadParams) { } try { + updateDownloadProgress(downloadId, { status: 'downloading' }); + let outputPath: string; let fileId: string; @@ -388,7 +425,8 @@ export async function downloadBookChapter(params: BookDownloadParams) { const zip = new AdmZip(); const sortedImages = images!.sort((a, b) => a.index - b.index); - for (const img of sortedImages) { + for (let i = 0; i < sortedImages.length; i++) { + const img = sortedImages[i]; const res = await fetch(img.url); if (!res.ok) throw new Error(`HTTP_${res.status}`); const buf = Buffer.from(await res.arrayBuffer()); @@ -396,6 +434,10 @@ export async function downloadBookChapter(params: BookDownloadParams) { const ext = path.extname(new URL(img.url).pathname) || '.jpg'; const filename = `${img.index.toString().padStart(4, '0')}${ext}`; zip.addFile(filename, buf); + + updateDownloadProgress(downloadId, { + progress: Math.floor((i / sortedImages.length) * 100) + }); } zip.writeZip(outputPath); @@ -405,7 +447,6 @@ export async function downloadBookChapter(params: BookDownloadParams) { outputPath = path.join(entry.path, chapterName); const zip = new AdmZip(); - zip.addFile('mimetype', Buffer.from('application/epub+zip'), '', 0); const containerXml = ` @@ -443,7 +484,6 @@ ${content} `; zip.addFile('OEBPS/chapter.xhtml', Buffer.from(chapterXhtml)); - zip.writeZip(outputPath); } @@ -461,8 +501,17 @@ ${content} 'local_library' ); + updateDownloadProgress(downloadId, { + status: 'completed', + progress: 100, + completedAt: Date.now() + }); + + setTimeout(() => activeDownloads.delete(downloadId), 30000); + return { status: 'SUCCESS', + download_id: downloadId, entry_id: entry.id, file_id: fileId, chapter: chapterNumber, @@ -471,6 +520,13 @@ ${content} }; } catch (error: any) { + updateDownloadProgress(downloadId, { + status: 'failed', + error: error.message + }); + + setTimeout(() => activeDownloads.delete(downloadId), 60000); + const err = new Error('DOWNLOAD_FAILED'); (err as any).details = error.message; throw err; diff --git a/docker/src/api/local/local.controller.ts b/docker/src/api/local/local.controller.ts index 96a62d7..659bbfc 100644 --- a/docker/src/api/local/local.controller.ts +++ b/docker/src/api/local/local.controller.ts @@ -2,6 +2,7 @@ import {FastifyReply, FastifyRequest} from 'fastify'; import fs from 'fs'; import * as service from './local.service'; import * as downloadService from './download.service'; +import * as streamingService from './streaming.service'; type ScanQuery = { mode?: 'full' | 'incremental'; @@ -21,7 +22,7 @@ type DownloadAnimeBody = | { anilist_id: number; episode_number: number; - stream_url: string; // media playlist FINAL + stream_url: string; is_master?: false; subtitles?: { language: string; @@ -36,28 +37,24 @@ type DownloadAnimeBody = | { anilist_id: number; episode_number: number; - stream_url: string; // master.m3u8 + stream_url: string; is_master: true; - variant: { resolution: string; bandwidth?: number; codecs?: string; playlist_url: string; }; - audio?: { group?: string; language?: string; name?: string; playlist_url: string; }[]; - subtitles?: { language: string; url: string; }[]; - chapters?: { title: string; start_time: number; @@ -266,7 +263,6 @@ export async function downloadAnime(request: FastifyRequest<{ Body: DownloadAnim const clientHeaders = (request.body as any).headers || {}; - // Validación básica if (!anilist_id || !episode_number || !stream_url) { return reply.status(400).send({ error: 'MISSING_REQUIRED_FIELDS', @@ -274,17 +270,14 @@ export async function downloadAnime(request: FastifyRequest<{ Body: DownloadAnim }); } - // Proxy del stream URL principal const proxyUrl = buildProxyUrl(stream_url, clientHeaders); console.log('Stream URL:', proxyUrl); - // Proxy de subtítulos const proxiedSubs = subtitles?.map(sub => ({ ...sub, url: buildProxyUrl(sub.url, clientHeaders) })); - // Preparar parámetros base const downloadParams: any = { anilistId: anilist_id, episodeNumber: episode_number, @@ -293,7 +286,6 @@ export async function downloadAnime(request: FastifyRequest<{ Body: DownloadAnim chapters }; - // Si es master playlist, agregar campos adicionales if (is_master === true) { const { variant, audio } = request.body as any; @@ -305,14 +297,11 @@ export async function downloadAnime(request: FastifyRequest<{ Body: DownloadAnim } downloadParams.is_master = true; - - // Proxy del variant playlist downloadParams.variant = { ...variant, playlist_url: buildProxyUrl(variant.playlist_url, clientHeaders) }; - // Proxy de audio tracks si existen if (audio && audio.length > 0) { downloadParams.audio = audio.map((a: any) => ({ ...a, @@ -408,4 +397,116 @@ export async function downloadBook(request: FastifyRequest<{ Body: DownloadBookB return reply.status(500).send({ error: 'FAILED_TO_DOWNLOAD_BOOK' }); } +} + +// NUEVO: Estado de descargas +export async function getDownloadStatus(request: FastifyRequest, reply: FastifyReply) { + try { + const downloads = downloadService.getActiveDownloads(); + const streams = streamingService.getActiveStreamsStatus(); + + return { + downloads: { + total: downloads.length, + active: downloads.filter(d => d.status === 'downloading').length, + completed: downloads.filter(d => d.status === 'completed').length, + failed: downloads.filter(d => d.status === 'failed').length, + list: downloads + }, + streams: { + total: streams.length, + active: streams.filter(s => !s.isComplete).length, + completed: streams.filter(s => s.isComplete).length, + list: streams + } + }; + } catch (err) { + console.error('Error getting download status:', err); + return reply.status(500).send({ error: 'FAILED_TO_GET_DOWNLOAD_STATUS' }); + } +} + +// NUEVO: Streaming HLS para anime local +export async function getAnimeStreamManifest(request: FastifyRequest, reply: FastifyReply) { + try { + const { type, id, unit } = request.params as any; + + if (type !== 'anime') { + return reply.status(400).send({ error: 'ONLY_ANIME_SUPPORTED' }); + } + + const fileInfo = await service.getFileForStreaming(id, unit); + + if (!fileInfo) { + return reply.status(404).send({ error: 'FILE_NOT_FOUND' }); + } + + const manifest = await streamingService.getStreamingManifest(fileInfo.filePath); + + if (!manifest) { + return reply.status(500).send({ error: 'FAILED_TO_GENERATE_MANIFEST' }); + } + + return manifest; + } catch (err: any) { + console.error('Error getting stream manifest:', err); + return reply.status(500).send({ error: 'FAILED_TO_GET_STREAM_MANIFEST' }); + } +} + +// NUEVO: Servir archivos HLS +export async function serveHLSFile(request: FastifyRequest, reply: FastifyReply) { + try { + const { hash, filename } = request.params as any; + + const file = await streamingService.getHLSFile(hash, filename); + + if (!file) { + return reply.status(404).send({ error: 'FILE_NOT_FOUND' }); + } + + const contentType = filename.endsWith('.m3u8') + ? 'application/vnd.apple.mpegurl' + : filename.endsWith('.vtt') + ? 'text/vtt' + : 'video/mp2t'; + + reply + .header('Content-Type', contentType) + .header('Content-Length', file.stat.size) + .header('Access-Control-Allow-Origin', '*'); + + return fs.createReadStream(file.path); + } catch (err) { + 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 277f358..791b40f 100644 --- a/docker/src/api/local/local.routes.ts +++ b/docker/src/api/local/local.routes.ts @@ -5,13 +5,19 @@ async function localRoutes(fastify: FastifyInstance) { fastify.post('/library/scan', controller.scanLibrary); fastify.get('/library/:type', controller.listEntries); fastify.get('/library/:type/:id', controller.getEntry); + + // 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); fastify.get('/library/:unitId/resource/:resId', controller.getPage); fastify.post('/library/download/anime', controller.downloadAnime); fastify.post('/library/download/book', controller.downloadBook); + fastify.get('/library/downloads/status', controller.getDownloadStatus); + fastify.get('/library/stream/:type/:id/:unit/manifest', controller.getAnimeStreamManifest); + fastify.get('/library/hls/:hash/:filename', controller.serveHLSFile); } export default localRoutes; \ No newline at end of file diff --git a/docker/src/api/local/local.service.ts b/docker/src/api/local/local.service.ts index bd44928..6129f28 100644 --- a/docker/src/api/local/local.service.ts +++ b/docker/src/api/local/local.service.ts @@ -7,6 +7,7 @@ import path from "path"; import { getAnimeById, searchAnimeLocal } from "../anime/anime.service"; import { getBookById, searchBooksAniList } from "../books/books.service"; import AdmZip from 'adm-zip'; +import { getStreamHash, getSubtitleFileStream } from './streaming.service'; const MANGA_IMAGE_EXTS = ['.jpg', '.jpeg', '.png', '.webp']; const MANGA_ARCHIVES = ['.cbz', '.cbr', '.zip']; @@ -509,4 +510,27 @@ 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 new file mode 100644 index 0000000..ba76aee --- /dev/null +++ b/docker/src/api/local/streaming.service.ts @@ -0,0 +1,586 @@ +import { getConfig as loadConfig } from '../../shared/config'; +import { queryOne } from '../../shared/database'; +import crypto from 'crypto'; +import fs from 'fs'; +import path from 'path'; +import { spawn } from 'child_process'; + +const { values } = loadConfig(); +const FFMPEG_PATH = values.paths?.ffmpeg || 'ffmpeg'; +const FFPROBE_PATH = values.paths?.ffprobe || 'ffprobe'; + +const STREAM_TTL = 2 * 60 * 60 * 1000; // 2 horas + +type VideoStreamInfo = { + index: number; + codec: string; + width: number; + height: number; + fps: number; +}; + +type AudioStreamInfo = { + index: number; + codec: string; + language?: string; + title?: string; + channels: number; + sampleRate: number; +}; + +type SubtitleStreamInfo = { + index: number; + codec: string; + language?: string; + title?: string; +}; + +type ChapterInfo = { + id: number; + start: number; + end: number; + title: string; +}; + +type MediaInfo = { + video: VideoStreamInfo[]; + audio: AudioStreamInfo[]; + subtitles: SubtitleStreamInfo[]; + chapters: ChapterInfo[]; + duration: number; +}; + +type ActiveStream = { + hash: string; + filePath: string; + hlsDir: string; + info: MediaInfo; + process?: any; + startedAt: number; + lastAccessed: number; + isComplete: boolean; +}; + +const activeStreams = new Map(); + +// Limpieza automática de streams antiguos +setInterval(() => { + const now = Date.now(); + + for (const [hash, stream] of activeStreams.entries()) { + const age = now - stream.lastAccessed; + + if (age > STREAM_TTL) { + console.log(`🗑️ Limpiando stream antiguo: ${hash}`); + + // 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 }); + } + + activeStreams.delete(hash); + } + } +}, 60 * 1000); // Revisar cada minuto + +async function probeMediaFile(filePath: string): Promise { + return new Promise((resolve, reject) => { + const args = [ + '-v', 'quiet', + '-print_format', 'json', + '-show_streams', + '-show_chapters', + '-show_format', + filePath + ]; + + const ffprobe = spawn(FFPROBE_PATH, args); + let output = ''; + + ffprobe.stdout.on('data', (data) => { + output += data.toString(); + }); + + ffprobe.on('close', (code) => { + if (code !== 0) { + return reject(new Error(`FFprobe failed with code ${code}`)); + } + + try { + const data = JSON.parse(output); + + const video: VideoStreamInfo[] = data.streams + .filter((s: any) => s.codec_type === 'video') + .map((s: any) => ({ + index: s.index, + codec: s.codec_name, + width: s.width, + height: s.height, + fps: eval(s.r_frame_rate) || 24 + })); + + const audio: AudioStreamInfo[] = data.streams + .filter((s: any) => s.codec_type === 'audio') + .map((s: any) => ({ + index: s.index, + codec: s.codec_name, + language: s.tags?.language, + title: s.tags?.title, + channels: s.channels || 2, + sampleRate: parseInt(s.sample_rate) || 48000 + })); + + const subtitles: SubtitleStreamInfo[] = data.streams + .filter((s: any) => s.codec_type === 'subtitle') + .map((s: any) => ({ + index: s.index, + codec: s.codec_name, + language: s.tags?.language, + title: s.tags?.title + })); + + const chapters: ChapterInfo[] = (data.chapters || []).map((c: any) => ({ + id: c.id, + start: parseFloat(c.start_time), + end: parseFloat(c.end_time), + title: c.tags?.title || `Chapter ${c.id + 1}` + })); + + const duration = parseFloat(data.format?.duration) || 0; + + resolve({ video, audio, subtitles, chapters, duration }); + } catch (e) { + reject(e); + } + }); + + ffprobe.on('error', reject); + }); +} + +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; + } + + 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'; + return { + index: s.index, + 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(); + + return { + type: 'hls', + hash, + masterPlaylist: `/api/library/hls/${hash}/master.m3u8`, + duration: existing.info.duration, + isComplete: existing.isComplete, + video: existing.info.video.map(v => ({ + index: v.index, + codec: v.codec, + resolution: `${v.width}x${v.height}`, + fps: v.fps + })), + audio: existing.info.audio.map(a => ({ + index: a.index, + codec: a.codec, + language: a.language || 'und', + title: a.title || `Audio ${a.index}`, + channels: a.channels + })), + // USAMOS EL HELPER + subtitles: formatSubtitles(existing.info.subtitles), + chapters: existing.info.chapters.map(c => ({ + id: c.id, + start: c.start, + end: c.end, + title: c.title + })) + }; + } + + // Caso 2: Nuevo stream (Generating) + const info = await probeMediaFile(filePath); + const stream = startHLSConversion(filePath, info, hash); + + return { + type: 'hls', + hash, + masterPlaylist: `/api/library/hls/${hash}/master.m3u8`, + duration: info.duration, + isComplete: false, + generating: true, + video: info.video.map(v => ({ + index: v.index, + codec: v.codec, + resolution: `${v.width}x${v.height}`, + fps: v.fps + })), + audio: info.audio.map(a => ({ + index: a.index, + codec: a.codec, + language: a.language || 'und', + title: a.title || `Audio ${a.index}`, + channels: a.channels + })), + subtitles: formatSubtitles(info.subtitles), + chapters: info.chapters.map(c => ({ + id: c.id, + start: c.start, + end: c.end, + title: c.title + })) + }; +} + +export function getSubtitleFileStream(hash: string, trackIndex: number) { + const stream = activeStreams.get(hash); + + if (!stream) { + // 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)); + } + return null; + } + + 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.`)); + + if (!subtitleFile) return null; + + return fs.createReadStream(path.join(stream.hlsDir, subtitleFile)); +} + +export async function getHLSFile(hash: string, filename: string) { + const stream = activeStreams.get(hash); + + if (!stream) { + return null; + } + + // 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 startWait = Date.now(); + + while (!fs.existsSync(filePath)) { + if (Date.now() - startWait > maxWait) { + console.error(`⏱️ Timeout esperando archivo: ${filename}`); + 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)); + } + + return { + path: filePath, + stat: fs.statSync(filePath) + }; +} + +export function getActiveStreamsStatus() { + return Array.from(activeStreams.values()).map(stream => ({ + hash: stream.hash, + filePath: stream.filePath, + isComplete: stream.isComplete, + startedAt: stream.startedAt, + lastAccessed: stream.lastAccessed, + age: Date.now() - stream.startedAt, + idle: Date.now() - stream.lastAccessed + })); +} \ No newline at end of file diff --git a/docker/src/scripts/anime/player.js b/docker/src/scripts/anime/player.js index 2c1a133..05b5616 100644 --- a/docker/src/scripts/anime/player.js +++ b/docker/src/scripts/anime/player.js @@ -882,7 +882,6 @@ const AnimePlayer = (function() { if (els.video) els.video.playbackRate = parseFloat(value); } - // Volvemos al menú principal para confirmar visualmente (opcional, estilo YouTube) _settingsView = 'main'; buildSettingsPanel(); } @@ -904,31 +903,41 @@ const AnimePlayer = (function() { subtitleRenderer = null; } - // Find ASS subtitle - const assSubtitle = _currentSubtitles.find(sub => - (sub.src && sub.src.endsWith('.ass')) || - (sub.label && sub.label.toLowerCase().includes('ass')) - ); + const activeIdx = getActiveSubtitleIndex(); + if (activeIdx === -1) return; - if (!assSubtitle) { - console.log('No ASS subtitles found in current list'); - return; - } + const currentSub = _currentSubtitles[activeIdx]; + if (!currentSub) return; - try { - console.log('Initializing JASSUB for:', assSubtitle.label); + const src = currentSub.src.toLowerCase(); + const label = (currentSub.label || '').toLowerCase(); - // Check if JASSUB global is available - if (window.SubtitleRenderer && typeof window.JASSUB !== 'undefined') { - // --- CAMBIO AQUÍ: Pasamos els.subtitlesCanvas --- - subtitleRenderer = new SubtitleRenderer(els.video, els.subtitlesCanvas); - await subtitleRenderer.init(assSubtitle.src); - } else { - console.warn('JASSUB library not loaded.'); + // CASO 1: ASS (Usa JASSUB) + if (src.endsWith('.ass') || label.includes('ass')) { + try { + console.log('Initializing JASSUB for:', currentSub.label); + if (window.SubtitleRenderer && typeof window.JASSUB !== 'undefined') { + subtitleRenderer = new SubtitleRenderer(els.video, els.subtitlesCanvas); + await subtitleRenderer.init(currentSub.src); + } + } catch (e) { + console.error('JASSUB setup error:', e); } - } catch (e) { - console.error('Subtitle renderer setup error:', e); - subtitleRenderer = null; + } + // CASO 2: SRT (Usa SimpleSubtitleRenderer) + else if (src.endsWith('.srt') || label.includes('srt')) { + try { + console.log('Initializing Simple Renderer for:', currentSub.label); + if (window.SimpleSubtitleRenderer) { + subtitleRenderer = new SimpleSubtitleRenderer(els.video, els.subtitlesCanvas); + await subtitleRenderer.loadSubtitles(currentSub.src); + } + } catch (e) { + console.error('Simple Renderer setup error:', e); + } + } + else { + console.log('Using native browser rendering for VTT'); } } @@ -991,13 +1000,15 @@ const AnimePlayer = (function() { _rpcActive = false; setLoading("Checking availability..."); - // Check local availability let shouldPlayLocal = false; try { const check = await fetch(`/api/library/${_animeId}/units`); const data = await check.json(); const localUnit = data.units ? data.units.find(u => u.number === targetEp) : null; - if (localUnit) shouldPlayLocal = true; + + if (localUnit && els.extSelect.value === 'local') { + shouldPlayLocal = true; + } } catch (e) { console.warn("Availability check failed:", e); shouldPlayLocal = (els.extSelect.value === 'local'); @@ -1216,49 +1227,64 @@ const AnimePlayer = (function() { if (currentExt === 'local') { try { - const localId = await getLocalEntryId(); - const check = await fetch(`/api/library/${_animeId}/units`); - const data = await check.json(); - const targetUnit = data.units ? data.units.find(u => u.number === parseInt(_currentEpisode)) : null; + setLoading("Fetching Local Unit Data..."); + + // 1. Obtener las unidades locales para encontrar el ID del episodio específico + 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; if (!targetUnit) { - console.log(`Episode ${_currentEpisode} not found locally.`); - const localOption = els.extSelect.querySelector('option[value="local"]'); - if (localOption) localOption.remove(); - - const fallbackSource = (_entrySource === 'local') ? 'anilist' : _entrySource; - if (els.extSelect.querySelector(`option[value="${fallbackSource}"]`)) { - els.extSelect.value = fallbackSource; - } else if (els.extSelect.options.length > 0) { - els.extSelect.selectedIndex = 0; - } - handleExtensionChange(true); - return; + throw new Error(`Episode ${_currentEpisode} not found in local library`); } - const ext = targetUnit.format || targetUnit.name.split('.').pop().toLowerCase(); + setLoading("Initializing HLS Stream..."); - if (![''].includes(ext)) { - setLoading(`Local files are not supported on the web player yet. Use MPV.`); - _rawVideoData = { - url: targetUnit.path, - headers: {} - }; - if (els.mpvBtn) els.mpvBtn.style.display = 'flex'; - return; + 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(); + + 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' + })); + renderSkipMarkers(_skipIntervals); + monitorSkipButton(_skipIntervals); } - const localUrl = `/api/library/stream/${targetUnit.id}`; + // 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) + })); + + // 5. Guardar referencia para MPV o descargas _rawVideoData = { - url: localUrl, + url: manifestData.masterPlaylist, headers: {} }; - _currentSubtitles = []; - initVideoPlayer(localUrl, 'mp4'); + // 6. Cargar en el reproductor (Hls.js gestionará los audios del master.m3u8) + initVideoPlayer(manifestData.masterPlaylist, 'm3u8', subs); + } catch(e) { - console.error(e); + console.error("Local HLS Error:", e); setLoading("Local Error: " + e.message); + + // Fallback: si falla, intentar cargar desde extensión online + const localOption = els.extSelect.querySelector('option[value="local"]'); + if (localOption) localOption.remove(); + + const fallbackSource = (_entrySource === 'local') ? 'anilist' : _entrySource; + els.extSelect.value = fallbackSource; + handleExtensionChange(true); } return; } @@ -1530,23 +1556,19 @@ const AnimePlayer = (function() { function renderSkipMarkers(intervals) { if (!els.progressContainer || !els.video.duration) return; - els.progressContainer.querySelectorAll('.skip-range, .skip-cut').forEach(e => e.remove()); - const duration = els.video.duration; - intervals.forEach(skip => { - const startPct = (skip.startTime / duration) * 100; - const endPct = (skip.endTime / duration) * 100; + const startPct = (skip.startTime / els.video.duration) * 100; + const endPct = (skip.endTime / els.video.duration) * 100; const range = document.createElement('div'); - range.className = `skip-range ${skip.type}`; // 'op' o 'ed' + range.className = `skip-range ${skip.type}`; range.style.left = `${startPct}%`; range.style.width = `${endPct - startPct}%`; els.progressContainer.appendChild(range); createCut(startPct); - createCut(endPct); }); } diff --git a/docker/src/shared/config.js b/docker/src/shared/config.js index c656ee0..aa388a9 100644 --- a/docker/src/shared/config.js +++ b/docker/src/shared/config.js @@ -15,6 +15,7 @@ const DEFAULT_CONFIG = { paths: { mpv: null, ffmpeg: null, + ffprobe: null, cloudflared: null, } }; @@ -28,6 +29,7 @@ export const CONFIG_SCHEMA = { paths: { mpv: { description: "Required to open anime episodes in mpv on desktop version." }, ffmpeg: { description: "Required for downloading anime episodes." }, + ffprobe: { description: "Required for watching local anime episodes." }, cloudflared: { description: "Required for creating pubic rooms." } } };