stream local files to player

This commit is contained in:
2026-01-05 13:21:54 +01:00
parent 5b5cedcc98
commit 5fd2341e8e
15 changed files with 1869 additions and 275 deletions

View File

@@ -9,8 +9,38 @@ import AdmZip from 'adm-zip';
import { spawn } from 'child_process'; import { spawn } from 'child_process';
const { values } = loadConfig(); const { values } = loadConfig();
const FFMPEG_PATH = const FFMPEG_PATH = values.paths?.ffmpeg || 'ffmpeg';
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<string, DownloadStatus>();
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<DownloadStatus>) {
const current = activeDownloads.get(id);
if (current) {
activeDownloads.set(id, { ...current, ...updates });
}
}
type AnimeDownloadParams = { type AnimeDownloadParams = {
anilistId: number; anilistId: number;
@@ -109,6 +139,18 @@ async function getOrCreateEntry(
export async function downloadAnimeEpisode(params: AnimeDownloadParams) { export async function downloadAnimeEpisode(params: AnimeDownloadParams) {
const { anilistId, episodeNumber, streamUrl, subtitles, chapters } = params; 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 entry = await getOrCreateEntry(anilistId, 'anime');
const exists = await queryOne( const exists = await queryOne(
@@ -116,20 +158,25 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) {
[entry.id, episodeNumber], [entry.id, episodeNumber],
'local_library' '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 outputPath = path.join(entry.path, `Episode_${episodeNumber.toString().padStart(2, '0')}.mkv`);
const tempDir = path.join(entry.path, '.temp'); const tempDir = path.join(entry.path, '.temp');
await ensureDirectory(tempDir); await ensureDirectory(tempDir);
try { try {
updateDownloadProgress(downloadId, { status: 'downloading' });
let videoInput = streamUrl; let videoInput = streamUrl;
let audioInputs: string[] = []; let audioInputs: string[] = [];
const isMaster = (params as any).is_master === true; const isMaster = (params as any).is_master === true;
if (isMaster) { if (isMaster) {
const variant = (params as any).variant; const variant = (params as any).variant;
const audios = (params as any).audio; const audios = (params as any).audio;
@@ -178,13 +225,11 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) {
if (chapters?.length) { if (chapters?.length) {
const meta = path.join(tempDir, 'chapters.txt'); const meta = path.join(tempDir, 'chapters.txt');
const sorted = [...chapters].sort((a, b) => a.start_time - b.start_time); const sorted = [...chapters].sort((a, b) => a.start_time - b.start_time);
const lines: string[] = [';FFMETADATA1']; const lines: string[] = [';FFMETADATA1'];
for (let i = 0; i < sorted.length; i++) { for (let i = 0; i < sorted.length; i++) {
const c = sorted[i]; const c = sorted[i];
const start = Math.floor(c.start_time * 1000); const start = Math.floor(c.start_time * 1000);
const end = Math.floor(c.end_time * 1000); const end = Math.floor(c.end_time * 1000);
const title = (c.title || 'chapter').toUpperCase(); const title = (c.title || 'chapter').toUpperCase();
@@ -220,18 +265,14 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) {
fs.writeFileSync(meta, lines.join('\n')); fs.writeFileSync(meta, lines.join('\n'));
args.push('-i', meta); args.push('-i', meta);
// índice correcto del metadata input
chaptersInputIndex = 1 + audioInputs.length + subFiles.length; chaptersInputIndex = 1 + audioInputs.length + subFiles.length;
} }
args.push('-map', '0:v:0'); args.push('-map', '0:v:0');
if (audioInputs.length > 0) { if (audioInputs.length > 0) {
audioInputs.forEach((_, i) => { audioInputs.forEach((_, i) => {
args.push('-map', `${i + 1}:a:0`); args.push('-map', `${i + 1}:a:0`);
const audioInfo = (params as any).audio?.[i]; const audioInfo = (params as any).audio?.[i];
if (audioInfo) { if (audioInfo) {
const audioStreamIndex = i; const audioStreamIndex = i;
@@ -244,7 +285,6 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) {
} }
}); });
} else { } else {
args.push('-map', '0:a:0?'); args.push('-map', '0:a:0?');
} }
@@ -258,68 +298,33 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) {
args.push('-map_metadata', `${chaptersInputIndex}`); args.push('-map_metadata', `${chaptersInputIndex}`);
} }
args.push('-c:v', 'copy'); args.push('-c:v', 'copy', '-c:a', 'copy');
if (subFiles.length) args.push('-c:s', 'srt');
args.push('-c:a', 'copy'); args.push('-y', outputPath);
if (subFiles.length) {
args.push('-c:s', 'srt');
}
args.push('-y');
args.push(outputPath);
await new Promise((resolve, reject) => { 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, { const ff = spawn(FFMPEG_PATH, args, {
windowsHide: true, windowsHide: true,
stdio: ['ignore', 'pipe', 'pipe'] stdio: ['ignore', 'pipe', 'pipe']
}); });
let lastProgress = '';
ff.stdout.on('data', (data) => {
const text = data.toString();
console.log('[stdout]', text);
});
ff.stderr.on('data', (data) => { ff.stderr.on('data', (data) => {
const text = data.toString(); const text = data.toString();
const timeMatch = text.match(/time=(\S+)/);
const speedMatch = text.match(/speed=(\S+)/);
if (text.includes('time=') || text.includes('speed=')) { if (timeMatch || speedMatch) {
const timeMatch = text.match(/time=(\S+)/); updateDownloadProgress(downloadId, {
const speedMatch = text.match(/speed=(\S+)/); timeElapsed: timeMatch?.[1],
if (timeMatch || speedMatch) { speed: speedMatch?.[1]
lastProgress = `⏱️ Time: ${timeMatch?.[1] || 'N/A'} | Speed: ${speedMatch?.[1] || 'N/A'}`; });
console.log(lastProgress);
}
} else {
console.log('[ffmpeg]', text);
} }
}); });
ff.on('error', (error) => { ff.on('error', (error) => reject(error));
console.error('❌ Error al iniciar FFmpeg:', error);
reject(error);
});
ff.on('close', (code) => { ff.on('close', (code) => {
if (code === 0) { if (code === 0) resolve(true);
console.log('✅ Descarga completada exitosamente'); else reject(new Error(`FFmpeg exited with code ${code}`));
resolve(true);
} else {
console.error(`❌ FFmpeg terminó con código: ${code}`);
reject(new Error(`FFmpeg exited with code ${code}`));
}
}); });
}); });
@@ -339,8 +344,17 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) {
'local_library' 'local_library'
); );
updateDownloadProgress(downloadId, {
status: 'completed',
progress: 100,
completedAt: Date.now()
});
setTimeout(() => activeDownloads.delete(downloadId), 30000);
return { return {
status: 'SUCCESS', status: 'SUCCESS',
download_id: downloadId,
entry_id: entry.id, entry_id: entry.id,
file_id: fileId, file_id: fileId,
episode: episodeNumber, episode: episodeNumber,
@@ -350,6 +364,14 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) {
} catch (e: any) { } catch (e: any) {
fs.rmSync(tempDir, { recursive: true, force: true }); fs.rmSync(tempDir, { recursive: true, force: true });
if (fs.existsSync(outputPath)) fs.unlinkSync(outputPath); 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'); const err = new Error('DOWNLOAD_FAILED');
(err as any).details = e.message; (err as any).details = e.message;
throw err; throw err;
@@ -359,6 +381,18 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) {
export async function downloadBookChapter(params: BookDownloadParams) { export async function downloadBookChapter(params: BookDownloadParams) {
const { anilistId, chapterNumber, format, content, images } = params; 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 type = format === 'manga' ? 'manga' : 'novels';
const entry = await getOrCreateEntry(anilistId, type); const entry = await getOrCreateEntry(anilistId, type);
@@ -369,6 +403,7 @@ export async function downloadBookChapter(params: BookDownloadParams) {
); );
if (existingFile) { if (existingFile) {
activeDownloads.delete(downloadId);
return { return {
status: 'ALREADY_EXISTS', status: 'ALREADY_EXISTS',
message: `Chapter ${chapterNumber} already exists`, message: `Chapter ${chapterNumber} already exists`,
@@ -378,6 +413,8 @@ export async function downloadBookChapter(params: BookDownloadParams) {
} }
try { try {
updateDownloadProgress(downloadId, { status: 'downloading' });
let outputPath: string; let outputPath: string;
let fileId: string; let fileId: string;
@@ -388,7 +425,8 @@ export async function downloadBookChapter(params: BookDownloadParams) {
const zip = new AdmZip(); const zip = new AdmZip();
const sortedImages = images!.sort((a, b) => a.index - b.index); 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); const res = await fetch(img.url);
if (!res.ok) throw new Error(`HTTP_${res.status}`); if (!res.ok) throw new Error(`HTTP_${res.status}`);
const buf = Buffer.from(await res.arrayBuffer()); 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 ext = path.extname(new URL(img.url).pathname) || '.jpg';
const filename = `${img.index.toString().padStart(4, '0')}${ext}`; const filename = `${img.index.toString().padStart(4, '0')}${ext}`;
zip.addFile(filename, buf); zip.addFile(filename, buf);
updateDownloadProgress(downloadId, {
progress: Math.floor((i / sortedImages.length) * 100)
});
} }
zip.writeZip(outputPath); zip.writeZip(outputPath);
@@ -405,7 +447,6 @@ export async function downloadBookChapter(params: BookDownloadParams) {
outputPath = path.join(entry.path, chapterName); outputPath = path.join(entry.path, chapterName);
const zip = new AdmZip(); const zip = new AdmZip();
zip.addFile('mimetype', Buffer.from('application/epub+zip'), '', 0); zip.addFile('mimetype', Buffer.from('application/epub+zip'), '', 0);
const containerXml = `<?xml version="1.0" encoding="UTF-8"?> const containerXml = `<?xml version="1.0" encoding="UTF-8"?>
@@ -443,7 +484,6 @@ ${content}
</body> </body>
</html>`; </html>`;
zip.addFile('OEBPS/chapter.xhtml', Buffer.from(chapterXhtml)); zip.addFile('OEBPS/chapter.xhtml', Buffer.from(chapterXhtml));
zip.writeZip(outputPath); zip.writeZip(outputPath);
} }
@@ -461,8 +501,17 @@ ${content}
'local_library' 'local_library'
); );
updateDownloadProgress(downloadId, {
status: 'completed',
progress: 100,
completedAt: Date.now()
});
setTimeout(() => activeDownloads.delete(downloadId), 30000);
return { return {
status: 'SUCCESS', status: 'SUCCESS',
download_id: downloadId,
entry_id: entry.id, entry_id: entry.id,
file_id: fileId, file_id: fileId,
chapter: chapterNumber, chapter: chapterNumber,
@@ -471,6 +520,13 @@ ${content}
}; };
} catch (error: any) { } catch (error: any) {
updateDownloadProgress(downloadId, {
status: 'failed',
error: error.message
});
setTimeout(() => activeDownloads.delete(downloadId), 60000);
const err = new Error('DOWNLOAD_FAILED'); const err = new Error('DOWNLOAD_FAILED');
(err as any).details = error.message; (err as any).details = error.message;
throw err; throw err;

View File

@@ -2,6 +2,7 @@ import {FastifyReply, FastifyRequest} from 'fastify';
import fs from 'fs'; import fs from 'fs';
import * as service from './local.service'; import * as service from './local.service';
import * as downloadService from './download.service'; import * as downloadService from './download.service';
import * as streamingService from './streaming.service';
type ScanQuery = { type ScanQuery = {
mode?: 'full' | 'incremental'; mode?: 'full' | 'incremental';
@@ -21,7 +22,7 @@ type DownloadAnimeBody =
| { | {
anilist_id: number; anilist_id: number;
episode_number: number; episode_number: number;
stream_url: string; // media playlist FINAL stream_url: string;
is_master?: false; is_master?: false;
subtitles?: { subtitles?: {
language: string; language: string;
@@ -36,28 +37,24 @@ type DownloadAnimeBody =
| { | {
anilist_id: number; anilist_id: number;
episode_number: number; episode_number: number;
stream_url: string; // master.m3u8 stream_url: string;
is_master: true; is_master: true;
variant: { variant: {
resolution: string; resolution: string;
bandwidth?: number; bandwidth?: number;
codecs?: string; codecs?: string;
playlist_url: string; playlist_url: string;
}; };
audio?: { audio?: {
group?: string; group?: string;
language?: string; language?: string;
name?: string; name?: string;
playlist_url: string; playlist_url: string;
}[]; }[];
subtitles?: { subtitles?: {
language: string; language: string;
url: string; url: string;
}[]; }[];
chapters?: { chapters?: {
title: string; title: string;
start_time: number; start_time: number;
@@ -266,7 +263,6 @@ export async function downloadAnime(request: FastifyRequest<{ Body: DownloadAnim
const clientHeaders = (request.body as any).headers || {}; const clientHeaders = (request.body as any).headers || {};
// Validación básica
if (!anilist_id || !episode_number || !stream_url) { if (!anilist_id || !episode_number || !stream_url) {
return reply.status(400).send({ return reply.status(400).send({
error: 'MISSING_REQUIRED_FIELDS', 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); const proxyUrl = buildProxyUrl(stream_url, clientHeaders);
console.log('Stream URL:', proxyUrl); console.log('Stream URL:', proxyUrl);
// Proxy de subtítulos
const proxiedSubs = subtitles?.map(sub => ({ const proxiedSubs = subtitles?.map(sub => ({
...sub, ...sub,
url: buildProxyUrl(sub.url, clientHeaders) url: buildProxyUrl(sub.url, clientHeaders)
})); }));
// Preparar parámetros base
const downloadParams: any = { const downloadParams: any = {
anilistId: anilist_id, anilistId: anilist_id,
episodeNumber: episode_number, episodeNumber: episode_number,
@@ -293,7 +286,6 @@ export async function downloadAnime(request: FastifyRequest<{ Body: DownloadAnim
chapters chapters
}; };
// Si es master playlist, agregar campos adicionales
if (is_master === true) { if (is_master === true) {
const { variant, audio } = request.body as any; const { variant, audio } = request.body as any;
@@ -305,14 +297,11 @@ export async function downloadAnime(request: FastifyRequest<{ Body: DownloadAnim
} }
downloadParams.is_master = true; downloadParams.is_master = true;
// Proxy del variant playlist
downloadParams.variant = { downloadParams.variant = {
...variant, ...variant,
playlist_url: buildProxyUrl(variant.playlist_url, clientHeaders) playlist_url: buildProxyUrl(variant.playlist_url, clientHeaders)
}; };
// Proxy de audio tracks si existen
if (audio && audio.length > 0) { if (audio && audio.length > 0) {
downloadParams.audio = audio.map((a: any) => ({ downloadParams.audio = audio.map((a: any) => ({
...a, ...a,
@@ -408,4 +397,116 @@ export async function downloadBook(request: FastifyRequest<{ Body: DownloadBookB
return reply.status(500).send({ error: 'FAILED_TO_DOWNLOAD_BOOK' }); 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' });
}
} }

View File

@@ -5,13 +5,19 @@ async function localRoutes(fastify: FastifyInstance) {
fastify.post('/library/scan', controller.scanLibrary); fastify.post('/library/scan', controller.scanLibrary);
fastify.get('/library/:type', controller.listEntries); fastify.get('/library/:type', controller.listEntries);
fastify.get('/library/:type/:id', controller.getEntry); 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', controller.streamUnit);
fastify.get('/library/stream/:type/:id/:unit/subs/:track', controller.getSubtitle);
fastify.post('/library/:type/:id/match', controller.matchEntry); fastify.post('/library/:type/:id/match', controller.matchEntry);
fastify.get('/library/:id/units', controller.getUnits); fastify.get('/library/:id/units', controller.getUnits);
fastify.get('/library/:unitId/manifest', controller.getManifest); fastify.get('/library/:unitId/manifest', controller.getManifest);
fastify.get('/library/:unitId/resource/:resId', controller.getPage); fastify.get('/library/:unitId/resource/:resId', controller.getPage);
fastify.post('/library/download/anime', controller.downloadAnime); fastify.post('/library/download/anime', controller.downloadAnime);
fastify.post('/library/download/book', controller.downloadBook); 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; export default localRoutes;

View File

@@ -7,6 +7,7 @@ import path from "path";
import { getAnimeById, searchAnimeLocal } from "../anime/anime.service"; import { getAnimeById, searchAnimeLocal } from "../anime/anime.service";
import { getBookById, searchBooksAniList } from "../books/books.service"; import { getBookById, searchBooksAniList } from "../books/books.service";
import AdmZip from 'adm-zip'; import AdmZip from 'adm-zip';
import { getStreamHash, getSubtitleFileStream } from './streaming.service';
const MANGA_IMAGE_EXTS = ['.jpg', '.jpeg', '.png', '.webp']; const MANGA_IMAGE_EXTS = ['.jpg', '.jpeg', '.png', '.webp'];
const MANGA_ARCHIVES = ['.cbz', '.cbr', '.zip']; const MANGA_ARCHIVES = ['.cbz', '.cbr', '.zip'];
@@ -509,4 +510,27 @@ function parseEpubToHtml(filePath: string) {
const entry = zip.getEntry('OEBPS/chapter.xhtml'); const entry = zip.getEntry('OEBPS/chapter.xhtml');
if (!entry) throw new Error('CHAPTER_NOT_FOUND'); if (!entry) throw new Error('CHAPTER_NOT_FOUND');
return entry.getData().toString('utf8'); return entry.getData().toString('utf8');
}
export async function extractSubtitleTrack(id: string, unit: string, trackIndex: number) {
// 1. Obtenemos la ruta real del archivo usando tu función existente
const fileInfo = await getFileForStreaming(id, unit);
if (!fileInfo || !fs.existsSync(fileInfo.filePath)) {
return null;
}
// 2. Generamos el hash (ID único del stream) igual que lo hace el streaming
const hash = getStreamHash(fileInfo.filePath);
// 3. Pedimos al servicio de streaming que busque el archivo en la carpeta temp
const stream = getSubtitleFileStream(hash, trackIndex);
if (stream) {
console.log(`✅ Subtítulo recuperado de caché (Hash: ${hash}, Track: ${trackIndex})`);
} else {
console.error(`❌ Subtítulo no encontrado en caché para ${hash}`);
}
return stream;
} }

View File

@@ -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<string, ActiveStream>();
// 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<MediaInfo> {
return new Promise((resolve, reject) => {
const args = [
'-v', 'quiet',
'-print_format', 'json',
'-show_streams',
'-show_chapters',
'-show_format',
filePath
];
const ffprobe = spawn(FFPROBE_PATH, args);
let output = '';
ffprobe.stdout.on('data', (data) => {
output += data.toString();
});
ffprobe.on('close', (code) => {
if (code !== 0) {
return reject(new Error(`FFprobe failed with code ${code}`));
}
try {
const data = JSON.parse(output);
const video: VideoStreamInfo[] = data.streams
.filter((s: any) => s.codec_type === 'video')
.map((s: any) => ({
index: s.index,
codec: s.codec_name,
width: s.width,
height: s.height,
fps: eval(s.r_frame_rate) || 24
}));
const audio: AudioStreamInfo[] = data.streams
.filter((s: any) => s.codec_type === 'audio')
.map((s: any) => ({
index: s.index,
codec: s.codec_name,
language: s.tags?.language,
title: s.tags?.title,
channels: s.channels || 2,
sampleRate: parseInt(s.sample_rate) || 48000
}));
const subtitles: SubtitleStreamInfo[] = data.streams
.filter((s: any) => s.codec_type === 'subtitle')
.map((s: any) => ({
index: s.index,
codec: s.codec_name,
language: s.tags?.language,
title: s.tags?.title
}));
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<string, string> = {
'ass': 'ass',
'ssa': 'ass',
'subrip': 'srt',
'webvtt': 'vtt',
'hdmv_pgs_subtitle': 'sup', // PGS son imágenes, cuidado con esto en reproductores web
'mov_text': 'srt', // Convertiremos mov_text a srt
'dvd_subtitle': 'sub',
'text': 'srt'
};
async function extractSubtitles(filePath: string, info: MediaInfo, hlsDir: string): Promise<void> {
if (info.subtitles.length === 0) return;
console.log('📑 Extrayendo subtítulos...');
const promises = info.subtitles.map((s, i) => {
return new Promise<void>((resolve, reject) => {
const ext = SUBTITLE_EXTENSIONS[s.codec] || 'vtt';
const outputFilename = `s${i}_full.${ext}`;
const outputPath = path.join(hlsDir, outputFilename);
const playlistPath = path.join(hlsDir, `subs_${i}.m3u8`);
// Determinamos el encoder correcto
let encoderArgs = ['-c:s', 'copy']; // Default
// Lógica de conversión segura
if (ext === 'vtt') {
encoderArgs = ['-c:s', 'webvtt'];
} else if (ext === 'srt') {
encoderArgs = ['-c:s', 'srt'];
} else if (ext === 'ass') {
// ASS suele funcionar bien con copy, pero si falla, ffmpeg avisará
encoderArgs = ['-c:s', 'copy'];
}
// CASO ESPECIAL: Si es imagen (PGS/DVD) y no queremos extraerlo (opcional)
// Si quieres extraer PGS tal cual (no se verá en tag <track> de HTML5):
if (s.codec === 'hdmv_pgs_subtitle' || s.codec === 'dvd_subtitle') {
encoderArgs = ['-c:s', 'copy'];
}
const args = [
'-i', filePath,
'-map', `0:${s.index}`,
...encoderArgs,
'-y',
outputPath
];
const p = spawn(FFMPEG_PATH, args, { stdio: 'ignore' });
p.on('close', (code) => {
if (code === 0) {
try {
// Verificar que el archivo no esté vacío
const stat = fs.statSync(outputPath);
if (stat.size === 0) {
console.error(`⚠️ Subtítulo ${i} extraído pero tiene 0 bytes.`);
}
createDummySubtitlePlaylist(playlistPath, outputFilename, info.duration);
resolve();
} catch (e) {
console.error(`Error procesando playlist subtítulo ${i}:`, e);
resolve(); // Resolvemos para no bloquear el video
}
} else {
console.error(`❌ Error extrayendo subtítulo ${i} (Codec: ${s.codec} -> ${ext}). Code: ${code}`);
resolve();
}
});
p.on('error', (err) => {
console.error(`Error spawn ffmpeg subs ${i}:`, err);
resolve();
});
});
});
await Promise.all(promises);
console.log('✅ Proceso de subtítulos finalizado.');
}
function createDummySubtitlePlaylist(playlistPath: string, subtitleFilename: string, duration: number) {
const content = [
'#EXTM3U',
'#EXT-X-VERSION:3',
`#EXT-X-TARGETDURATION:${Math.ceil(duration)}`,
'#EXT-X-MEDIA-SEQUENCE:0',
'#EXT-X-PLAYLIST-TYPE:VOD',
`#EXTINF:${duration.toFixed(6)},`,
subtitleFilename,
'#EXT-X-ENDLIST'
].join('\n');
fs.writeFileSync(playlistPath, content);
}
export async function getStreamingManifest(filePath: string) {
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
}));
}

View File

@@ -882,7 +882,6 @@ const AnimePlayer = (function() {
if (els.video) els.video.playbackRate = parseFloat(value); if (els.video) els.video.playbackRate = parseFloat(value);
} }
// Volvemos al menú principal para confirmar visualmente (opcional, estilo YouTube)
_settingsView = 'main'; _settingsView = 'main';
buildSettingsPanel(); buildSettingsPanel();
} }
@@ -904,31 +903,41 @@ const AnimePlayer = (function() {
subtitleRenderer = null; subtitleRenderer = null;
} }
// Find ASS subtitle const activeIdx = getActiveSubtitleIndex();
const assSubtitle = _currentSubtitles.find(sub => if (activeIdx === -1) return;
(sub.src && sub.src.endsWith('.ass')) ||
(sub.label && sub.label.toLowerCase().includes('ass'))
);
if (!assSubtitle) { const currentSub = _currentSubtitles[activeIdx];
console.log('No ASS subtitles found in current list'); if (!currentSub) return;
return;
}
try { const src = currentSub.src.toLowerCase();
console.log('Initializing JASSUB for:', assSubtitle.label); const label = (currentSub.label || '').toLowerCase();
// Check if JASSUB global is available // CASO 1: ASS (Usa JASSUB)
if (window.SubtitleRenderer && typeof window.JASSUB !== 'undefined') { if (src.endsWith('.ass') || label.includes('ass')) {
// --- CAMBIO AQUÍ: Pasamos els.subtitlesCanvas --- try {
subtitleRenderer = new SubtitleRenderer(els.video, els.subtitlesCanvas); console.log('Initializing JASSUB for:', currentSub.label);
await subtitleRenderer.init(assSubtitle.src); if (window.SubtitleRenderer && typeof window.JASSUB !== 'undefined') {
} else { subtitleRenderer = new SubtitleRenderer(els.video, els.subtitlesCanvas);
console.warn('JASSUB library not loaded.'); await subtitleRenderer.init(currentSub.src);
}
} catch (e) {
console.error('JASSUB setup error:', e);
} }
} catch (e) { }
console.error('Subtitle renderer setup error:', e); // CASO 2: SRT (Usa SimpleSubtitleRenderer)
subtitleRenderer = null; 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; _rpcActive = false;
setLoading("Checking availability..."); setLoading("Checking availability...");
// Check local availability
let shouldPlayLocal = false; let shouldPlayLocal = false;
try { try {
const check = await fetch(`/api/library/${_animeId}/units`); const check = await fetch(`/api/library/${_animeId}/units`);
const data = await check.json(); const data = await check.json();
const localUnit = data.units ? data.units.find(u => u.number === targetEp) : null; 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) { } catch (e) {
console.warn("Availability check failed:", e); console.warn("Availability check failed:", e);
shouldPlayLocal = (els.extSelect.value === 'local'); shouldPlayLocal = (els.extSelect.value === 'local');
@@ -1216,49 +1227,64 @@ const AnimePlayer = (function() {
if (currentExt === 'local') { if (currentExt === 'local') {
try { try {
const localId = await getLocalEntryId(); setLoading("Fetching Local Unit Data...");
const check = await fetch(`/api/library/${_animeId}/units`);
const data = await check.json(); // 1. Obtener las unidades locales para encontrar el ID del episodio específico
const targetUnit = data.units ? data.units.find(u => u.number === parseInt(_currentEpisode)) : null; 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) { if (!targetUnit) {
console.log(`Episode ${_currentEpisode} not found locally.`); throw new Error(`Episode ${_currentEpisode} not found in local library`);
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;
} }
const ext = targetUnit.format || targetUnit.name.split('.').pop().toLowerCase(); setLoading("Initializing HLS Stream...");
if (![''].includes(ext)) { const manifestRes = await fetch(`/api/library/stream/anime/${unitsData.entry_id}/${targetUnit.number}/manifest`);
setLoading(`Local files are not supported on the web player yet. Use MPV.`); if (!manifestRes.ok) throw new Error("Failed to generate stream manifest");
_rawVideoData = {
url: targetUnit.path, const manifestData = await manifestRes.json();
headers: {}
}; if (manifestData.chapters && manifestData.chapters.length > 0) {
if (els.mpvBtn) els.mpvBtn.style.display = 'flex'; _skipIntervals = manifestData.chapters.map(c => ({
return; 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 = { _rawVideoData = {
url: localUrl, url: manifestData.masterPlaylist,
headers: {} 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) { } catch(e) {
console.error(e); console.error("Local HLS Error:", e);
setLoading("Local Error: " + e.message); setLoading("Local Error: " + e.message);
// Fallback: si falla, intentar cargar desde extensión online
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; return;
} }
@@ -1530,23 +1556,19 @@ const AnimePlayer = (function() {
function renderSkipMarkers(intervals) { function renderSkipMarkers(intervals) {
if (!els.progressContainer || !els.video.duration) return; if (!els.progressContainer || !els.video.duration) return;
els.progressContainer.querySelectorAll('.skip-range, .skip-cut').forEach(e => e.remove()); els.progressContainer.querySelectorAll('.skip-range, .skip-cut').forEach(e => e.remove());
const duration = els.video.duration;
intervals.forEach(skip => { intervals.forEach(skip => {
const startPct = (skip.startTime / duration) * 100; const startPct = (skip.startTime / els.video.duration) * 100;
const endPct = (skip.endTime / duration) * 100; const endPct = (skip.endTime / els.video.duration) * 100;
const range = document.createElement('div'); 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.left = `${startPct}%`;
range.style.width = `${endPct - startPct}%`; range.style.width = `${endPct - startPct}%`;
els.progressContainer.appendChild(range); els.progressContainer.appendChild(range);
createCut(startPct); createCut(startPct);
createCut(endPct); createCut(endPct);
}); });
} }

View File

@@ -15,6 +15,7 @@ const DEFAULT_CONFIG = {
paths: { paths: {
mpv: null, mpv: null,
ffmpeg: null, ffmpeg: null,
ffprobe: null,
cloudflared: null, cloudflared: null,
} }
}; };
@@ -28,6 +29,7 @@ export const CONFIG_SCHEMA = {
paths: { paths: {
mpv: { description: "Required to open anime episodes in mpv on desktop version." }, mpv: { description: "Required to open anime episodes in mpv on desktop version." },
ffmpeg: { description: "Required for downloading anime episodes." }, ffmpeg: { description: "Required for downloading anime episodes." },
ffprobe: { description: "Required for watching local anime episodes." },
cloudflared: { description: "Required for creating pubic rooms." } cloudflared: { description: "Required for creating pubic rooms." }
} }
}; };

View File

@@ -29,7 +29,7 @@ const configRoutes = require("./dist/api/config/config.routes");
const roomRoutes = require("./dist/api/rooms/rooms.routes"); const roomRoutes = require("./dist/api/rooms/rooms.routes");
const { setupRoomWebSocket } = require("./dist/api/rooms/rooms.websocket"); const { setupRoomWebSocket } = require("./dist/api/rooms/rooms.websocket");
fastify.addHook("preHandler", async (request) => { fastify.addHook("preHandler", async (request, reply) => {
const auth = request.headers.authorization; const auth = request.headers.authorization;
if (!auth) return; if (!auth) return;

View File

@@ -9,8 +9,38 @@ import AdmZip from 'adm-zip';
import { spawn } from 'child_process'; import { spawn } from 'child_process';
const { values } = loadConfig(); const { values } = loadConfig();
const FFMPEG_PATH = const FFMPEG_PATH = values.paths?.ffmpeg || 'ffmpeg';
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<string, DownloadStatus>();
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<DownloadStatus>) {
const current = activeDownloads.get(id);
if (current) {
activeDownloads.set(id, { ...current, ...updates });
}
}
type AnimeDownloadParams = { type AnimeDownloadParams = {
anilistId: number; anilistId: number;
@@ -109,6 +139,18 @@ async function getOrCreateEntry(
export async function downloadAnimeEpisode(params: AnimeDownloadParams) { export async function downloadAnimeEpisode(params: AnimeDownloadParams) {
const { anilistId, episodeNumber, streamUrl, subtitles, chapters } = params; 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 entry = await getOrCreateEntry(anilistId, 'anime');
const exists = await queryOne( const exists = await queryOne(
@@ -116,20 +158,25 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) {
[entry.id, episodeNumber], [entry.id, episodeNumber],
'local_library' '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 outputPath = path.join(entry.path, `Episode_${episodeNumber.toString().padStart(2, '0')}.mkv`);
const tempDir = path.join(entry.path, '.temp'); const tempDir = path.join(entry.path, '.temp');
await ensureDirectory(tempDir); await ensureDirectory(tempDir);
try { try {
updateDownloadProgress(downloadId, { status: 'downloading' });
let videoInput = streamUrl; let videoInput = streamUrl;
let audioInputs: string[] = []; let audioInputs: string[] = [];
const isMaster = (params as any).is_master === true; const isMaster = (params as any).is_master === true;
if (isMaster) { if (isMaster) {
const variant = (params as any).variant; const variant = (params as any).variant;
const audios = (params as any).audio; const audios = (params as any).audio;
@@ -178,13 +225,11 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) {
if (chapters?.length) { if (chapters?.length) {
const meta = path.join(tempDir, 'chapters.txt'); const meta = path.join(tempDir, 'chapters.txt');
const sorted = [...chapters].sort((a, b) => a.start_time - b.start_time); const sorted = [...chapters].sort((a, b) => a.start_time - b.start_time);
const lines: string[] = [';FFMETADATA1']; const lines: string[] = [';FFMETADATA1'];
for (let i = 0; i < sorted.length; i++) { for (let i = 0; i < sorted.length; i++) {
const c = sorted[i]; const c = sorted[i];
const start = Math.floor(c.start_time * 1000); const start = Math.floor(c.start_time * 1000);
const end = Math.floor(c.end_time * 1000); const end = Math.floor(c.end_time * 1000);
const title = (c.title || 'chapter').toUpperCase(); const title = (c.title || 'chapter').toUpperCase();
@@ -220,18 +265,14 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) {
fs.writeFileSync(meta, lines.join('\n')); fs.writeFileSync(meta, lines.join('\n'));
args.push('-i', meta); args.push('-i', meta);
// índice correcto del metadata input
chaptersInputIndex = 1 + audioInputs.length + subFiles.length; chaptersInputIndex = 1 + audioInputs.length + subFiles.length;
} }
args.push('-map', '0:v:0'); args.push('-map', '0:v:0');
if (audioInputs.length > 0) { if (audioInputs.length > 0) {
audioInputs.forEach((_, i) => { audioInputs.forEach((_, i) => {
args.push('-map', `${i + 1}:a:0`); args.push('-map', `${i + 1}:a:0`);
const audioInfo = (params as any).audio?.[i]; const audioInfo = (params as any).audio?.[i];
if (audioInfo) { if (audioInfo) {
const audioStreamIndex = i; const audioStreamIndex = i;
@@ -244,7 +285,6 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) {
} }
}); });
} else { } else {
args.push('-map', '0:a:0?'); args.push('-map', '0:a:0?');
} }
@@ -258,68 +298,33 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) {
args.push('-map_metadata', `${chaptersInputIndex}`); args.push('-map_metadata', `${chaptersInputIndex}`);
} }
args.push('-c:v', 'copy'); args.push('-c:v', 'copy', '-c:a', 'copy');
if (subFiles.length) args.push('-c:s', 'srt');
args.push('-c:a', 'copy'); args.push('-y', outputPath);
if (subFiles.length) {
args.push('-c:s', 'srt');
}
args.push('-y');
args.push(outputPath);
await new Promise((resolve, reject) => { 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, { const ff = spawn(FFMPEG_PATH, args, {
windowsHide: true, windowsHide: true,
stdio: ['ignore', 'pipe', 'pipe'] stdio: ['ignore', 'pipe', 'pipe']
}); });
let lastProgress = '';
ff.stdout.on('data', (data) => {
const text = data.toString();
console.log('[stdout]', text);
});
ff.stderr.on('data', (data) => { ff.stderr.on('data', (data) => {
const text = data.toString(); const text = data.toString();
const timeMatch = text.match(/time=(\S+)/);
const speedMatch = text.match(/speed=(\S+)/);
if (text.includes('time=') || text.includes('speed=')) { if (timeMatch || speedMatch) {
const timeMatch = text.match(/time=(\S+)/); updateDownloadProgress(downloadId, {
const speedMatch = text.match(/speed=(\S+)/); timeElapsed: timeMatch?.[1],
if (timeMatch || speedMatch) { speed: speedMatch?.[1]
lastProgress = `⏱️ Time: ${timeMatch?.[1] || 'N/A'} | Speed: ${speedMatch?.[1] || 'N/A'}`; });
console.log(lastProgress);
}
} else {
console.log('[ffmpeg]', text);
} }
}); });
ff.on('error', (error) => { ff.on('error', (error) => reject(error));
console.error('❌ Error al iniciar FFmpeg:', error);
reject(error);
});
ff.on('close', (code) => { ff.on('close', (code) => {
if (code === 0) { if (code === 0) resolve(true);
console.log('✅ Descarga completada exitosamente'); else reject(new Error(`FFmpeg exited with code ${code}`));
resolve(true);
} else {
console.error(`❌ FFmpeg terminó con código: ${code}`);
reject(new Error(`FFmpeg exited with code ${code}`));
}
}); });
}); });
@@ -339,8 +344,17 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) {
'local_library' 'local_library'
); );
updateDownloadProgress(downloadId, {
status: 'completed',
progress: 100,
completedAt: Date.now()
});
setTimeout(() => activeDownloads.delete(downloadId), 30000);
return { return {
status: 'SUCCESS', status: 'SUCCESS',
download_id: downloadId,
entry_id: entry.id, entry_id: entry.id,
file_id: fileId, file_id: fileId,
episode: episodeNumber, episode: episodeNumber,
@@ -350,6 +364,14 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) {
} catch (e: any) { } catch (e: any) {
fs.rmSync(tempDir, { recursive: true, force: true }); fs.rmSync(tempDir, { recursive: true, force: true });
if (fs.existsSync(outputPath)) fs.unlinkSync(outputPath); 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'); const err = new Error('DOWNLOAD_FAILED');
(err as any).details = e.message; (err as any).details = e.message;
throw err; throw err;
@@ -359,6 +381,18 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) {
export async function downloadBookChapter(params: BookDownloadParams) { export async function downloadBookChapter(params: BookDownloadParams) {
const { anilistId, chapterNumber, format, content, images } = params; 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 type = format === 'manga' ? 'manga' : 'novels';
const entry = await getOrCreateEntry(anilistId, type); const entry = await getOrCreateEntry(anilistId, type);
@@ -369,6 +403,7 @@ export async function downloadBookChapter(params: BookDownloadParams) {
); );
if (existingFile) { if (existingFile) {
activeDownloads.delete(downloadId);
return { return {
status: 'ALREADY_EXISTS', status: 'ALREADY_EXISTS',
message: `Chapter ${chapterNumber} already exists`, message: `Chapter ${chapterNumber} already exists`,
@@ -378,6 +413,8 @@ export async function downloadBookChapter(params: BookDownloadParams) {
} }
try { try {
updateDownloadProgress(downloadId, { status: 'downloading' });
let outputPath: string; let outputPath: string;
let fileId: string; let fileId: string;
@@ -388,7 +425,8 @@ export async function downloadBookChapter(params: BookDownloadParams) {
const zip = new AdmZip(); const zip = new AdmZip();
const sortedImages = images!.sort((a, b) => a.index - b.index); 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); const res = await fetch(img.url);
if (!res.ok) throw new Error(`HTTP_${res.status}`); if (!res.ok) throw new Error(`HTTP_${res.status}`);
const buf = Buffer.from(await res.arrayBuffer()); 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 ext = path.extname(new URL(img.url).pathname) || '.jpg';
const filename = `${img.index.toString().padStart(4, '0')}${ext}`; const filename = `${img.index.toString().padStart(4, '0')}${ext}`;
zip.addFile(filename, buf); zip.addFile(filename, buf);
updateDownloadProgress(downloadId, {
progress: Math.floor((i / sortedImages.length) * 100)
});
} }
zip.writeZip(outputPath); zip.writeZip(outputPath);
@@ -405,7 +447,6 @@ export async function downloadBookChapter(params: BookDownloadParams) {
outputPath = path.join(entry.path, chapterName); outputPath = path.join(entry.path, chapterName);
const zip = new AdmZip(); const zip = new AdmZip();
zip.addFile('mimetype', Buffer.from('application/epub+zip'), '', 0); zip.addFile('mimetype', Buffer.from('application/epub+zip'), '', 0);
const containerXml = `<?xml version="1.0" encoding="UTF-8"?> const containerXml = `<?xml version="1.0" encoding="UTF-8"?>
@@ -443,7 +484,6 @@ ${content}
</body> </body>
</html>`; </html>`;
zip.addFile('OEBPS/chapter.xhtml', Buffer.from(chapterXhtml)); zip.addFile('OEBPS/chapter.xhtml', Buffer.from(chapterXhtml));
zip.writeZip(outputPath); zip.writeZip(outputPath);
} }
@@ -461,8 +501,17 @@ ${content}
'local_library' 'local_library'
); );
updateDownloadProgress(downloadId, {
status: 'completed',
progress: 100,
completedAt: Date.now()
});
setTimeout(() => activeDownloads.delete(downloadId), 30000);
return { return {
status: 'SUCCESS', status: 'SUCCESS',
download_id: downloadId,
entry_id: entry.id, entry_id: entry.id,
file_id: fileId, file_id: fileId,
chapter: chapterNumber, chapter: chapterNumber,
@@ -471,6 +520,13 @@ ${content}
}; };
} catch (error: any) { } catch (error: any) {
updateDownloadProgress(downloadId, {
status: 'failed',
error: error.message
});
setTimeout(() => activeDownloads.delete(downloadId), 60000);
const err = new Error('DOWNLOAD_FAILED'); const err = new Error('DOWNLOAD_FAILED');
(err as any).details = error.message; (err as any).details = error.message;
throw err; throw err;

View File

@@ -2,6 +2,7 @@ import {FastifyReply, FastifyRequest} from 'fastify';
import fs from 'fs'; import fs from 'fs';
import * as service from './local.service'; import * as service from './local.service';
import * as downloadService from './download.service'; import * as downloadService from './download.service';
import * as streamingService from './streaming.service';
type ScanQuery = { type ScanQuery = {
mode?: 'full' | 'incremental'; mode?: 'full' | 'incremental';
@@ -21,7 +22,7 @@ type DownloadAnimeBody =
| { | {
anilist_id: number; anilist_id: number;
episode_number: number; episode_number: number;
stream_url: string; // media playlist FINAL stream_url: string;
is_master?: false; is_master?: false;
subtitles?: { subtitles?: {
language: string; language: string;
@@ -36,28 +37,24 @@ type DownloadAnimeBody =
| { | {
anilist_id: number; anilist_id: number;
episode_number: number; episode_number: number;
stream_url: string; // master.m3u8 stream_url: string;
is_master: true; is_master: true;
variant: { variant: {
resolution: string; resolution: string;
bandwidth?: number; bandwidth?: number;
codecs?: string; codecs?: string;
playlist_url: string; playlist_url: string;
}; };
audio?: { audio?: {
group?: string; group?: string;
language?: string; language?: string;
name?: string; name?: string;
playlist_url: string; playlist_url: string;
}[]; }[];
subtitles?: { subtitles?: {
language: string; language: string;
url: string; url: string;
}[]; }[];
chapters?: { chapters?: {
title: string; title: string;
start_time: number; start_time: number;
@@ -266,7 +263,6 @@ export async function downloadAnime(request: FastifyRequest<{ Body: DownloadAnim
const clientHeaders = (request.body as any).headers || {}; const clientHeaders = (request.body as any).headers || {};
// Validación básica
if (!anilist_id || !episode_number || !stream_url) { if (!anilist_id || !episode_number || !stream_url) {
return reply.status(400).send({ return reply.status(400).send({
error: 'MISSING_REQUIRED_FIELDS', 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); const proxyUrl = buildProxyUrl(stream_url, clientHeaders);
console.log('Stream URL:', proxyUrl); console.log('Stream URL:', proxyUrl);
// Proxy de subtítulos
const proxiedSubs = subtitles?.map(sub => ({ const proxiedSubs = subtitles?.map(sub => ({
...sub, ...sub,
url: buildProxyUrl(sub.url, clientHeaders) url: buildProxyUrl(sub.url, clientHeaders)
})); }));
// Preparar parámetros base
const downloadParams: any = { const downloadParams: any = {
anilistId: anilist_id, anilistId: anilist_id,
episodeNumber: episode_number, episodeNumber: episode_number,
@@ -293,7 +286,6 @@ export async function downloadAnime(request: FastifyRequest<{ Body: DownloadAnim
chapters chapters
}; };
// Si es master playlist, agregar campos adicionales
if (is_master === true) { if (is_master === true) {
const { variant, audio } = request.body as any; const { variant, audio } = request.body as any;
@@ -305,14 +297,11 @@ export async function downloadAnime(request: FastifyRequest<{ Body: DownloadAnim
} }
downloadParams.is_master = true; downloadParams.is_master = true;
// Proxy del variant playlist
downloadParams.variant = { downloadParams.variant = {
...variant, ...variant,
playlist_url: buildProxyUrl(variant.playlist_url, clientHeaders) playlist_url: buildProxyUrl(variant.playlist_url, clientHeaders)
}; };
// Proxy de audio tracks si existen
if (audio && audio.length > 0) { if (audio && audio.length > 0) {
downloadParams.audio = audio.map((a: any) => ({ downloadParams.audio = audio.map((a: any) => ({
...a, ...a,
@@ -408,4 +397,116 @@ export async function downloadBook(request: FastifyRequest<{ Body: DownloadBookB
return reply.status(500).send({ error: 'FAILED_TO_DOWNLOAD_BOOK' }); 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' });
}
} }

View File

@@ -5,13 +5,19 @@ async function localRoutes(fastify: FastifyInstance) {
fastify.post('/library/scan', controller.scanLibrary); fastify.post('/library/scan', controller.scanLibrary);
fastify.get('/library/:type', controller.listEntries); fastify.get('/library/:type', controller.listEntries);
fastify.get('/library/:type/:id', controller.getEntry); 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', controller.streamUnit);
fastify.get('/library/stream/:type/:id/:unit/subs/:track', controller.getSubtitle);
fastify.post('/library/:type/:id/match', controller.matchEntry); fastify.post('/library/:type/:id/match', controller.matchEntry);
fastify.get('/library/:id/units', controller.getUnits); fastify.get('/library/:id/units', controller.getUnits);
fastify.get('/library/:unitId/manifest', controller.getManifest); fastify.get('/library/:unitId/manifest', controller.getManifest);
fastify.get('/library/:unitId/resource/:resId', controller.getPage); fastify.get('/library/:unitId/resource/:resId', controller.getPage);
fastify.post('/library/download/anime', controller.downloadAnime); fastify.post('/library/download/anime', controller.downloadAnime);
fastify.post('/library/download/book', controller.downloadBook); 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; export default localRoutes;

View File

@@ -7,6 +7,7 @@ import path from "path";
import { getAnimeById, searchAnimeLocal } from "../anime/anime.service"; import { getAnimeById, searchAnimeLocal } from "../anime/anime.service";
import { getBookById, searchBooksAniList } from "../books/books.service"; import { getBookById, searchBooksAniList } from "../books/books.service";
import AdmZip from 'adm-zip'; import AdmZip from 'adm-zip';
import { getStreamHash, getSubtitleFileStream } from './streaming.service';
const MANGA_IMAGE_EXTS = ['.jpg', '.jpeg', '.png', '.webp']; const MANGA_IMAGE_EXTS = ['.jpg', '.jpeg', '.png', '.webp'];
const MANGA_ARCHIVES = ['.cbz', '.cbr', '.zip']; const MANGA_ARCHIVES = ['.cbz', '.cbr', '.zip'];
@@ -509,4 +510,27 @@ function parseEpubToHtml(filePath: string) {
const entry = zip.getEntry('OEBPS/chapter.xhtml'); const entry = zip.getEntry('OEBPS/chapter.xhtml');
if (!entry) throw new Error('CHAPTER_NOT_FOUND'); if (!entry) throw new Error('CHAPTER_NOT_FOUND');
return entry.getData().toString('utf8'); return entry.getData().toString('utf8');
}
export async function extractSubtitleTrack(id: string, unit: string, trackIndex: number) {
// 1. Obtenemos la ruta real del archivo usando tu función existente
const fileInfo = await getFileForStreaming(id, unit);
if (!fileInfo || !fs.existsSync(fileInfo.filePath)) {
return null;
}
// 2. Generamos el hash (ID único del stream) igual que lo hace el streaming
const hash = getStreamHash(fileInfo.filePath);
// 3. Pedimos al servicio de streaming que busque el archivo en la carpeta temp
const stream = getSubtitleFileStream(hash, trackIndex);
if (stream) {
console.log(`✅ Subtítulo recuperado de caché (Hash: ${hash}, Track: ${trackIndex})`);
} else {
console.error(`❌ Subtítulo no encontrado en caché para ${hash}`);
}
return stream;
} }

View File

@@ -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<string, ActiveStream>();
// 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<MediaInfo> {
return new Promise((resolve, reject) => {
const args = [
'-v', 'quiet',
'-print_format', 'json',
'-show_streams',
'-show_chapters',
'-show_format',
filePath
];
const ffprobe = spawn(FFPROBE_PATH, args);
let output = '';
ffprobe.stdout.on('data', (data) => {
output += data.toString();
});
ffprobe.on('close', (code) => {
if (code !== 0) {
return reject(new Error(`FFprobe failed with code ${code}`));
}
try {
const data = JSON.parse(output);
const video: VideoStreamInfo[] = data.streams
.filter((s: any) => s.codec_type === 'video')
.map((s: any) => ({
index: s.index,
codec: s.codec_name,
width: s.width,
height: s.height,
fps: eval(s.r_frame_rate) || 24
}));
const audio: AudioStreamInfo[] = data.streams
.filter((s: any) => s.codec_type === 'audio')
.map((s: any) => ({
index: s.index,
codec: s.codec_name,
language: s.tags?.language,
title: s.tags?.title,
channels: s.channels || 2,
sampleRate: parseInt(s.sample_rate) || 48000
}));
const subtitles: SubtitleStreamInfo[] = data.streams
.filter((s: any) => s.codec_type === 'subtitle')
.map((s: any) => ({
index: s.index,
codec: s.codec_name,
language: s.tags?.language,
title: s.tags?.title
}));
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<string, string> = {
'ass': 'ass',
'ssa': 'ass',
'subrip': 'srt',
'webvtt': 'vtt',
'hdmv_pgs_subtitle': 'sup', // PGS son imágenes, cuidado con esto en reproductores web
'mov_text': 'srt', // Convertiremos mov_text a srt
'dvd_subtitle': 'sub',
'text': 'srt'
};
async function extractSubtitles(filePath: string, info: MediaInfo, hlsDir: string): Promise<void> {
if (info.subtitles.length === 0) return;
console.log('📑 Extrayendo subtítulos...');
const promises = info.subtitles.map((s, i) => {
return new Promise<void>((resolve, reject) => {
const ext = SUBTITLE_EXTENSIONS[s.codec] || 'vtt';
const outputFilename = `s${i}_full.${ext}`;
const outputPath = path.join(hlsDir, outputFilename);
const playlistPath = path.join(hlsDir, `subs_${i}.m3u8`);
// Determinamos el encoder correcto
let encoderArgs = ['-c:s', 'copy']; // Default
// Lógica de conversión segura
if (ext === 'vtt') {
encoderArgs = ['-c:s', 'webvtt'];
} else if (ext === 'srt') {
encoderArgs = ['-c:s', 'srt'];
} else if (ext === 'ass') {
// ASS suele funcionar bien con copy, pero si falla, ffmpeg avisará
encoderArgs = ['-c:s', 'copy'];
}
// CASO ESPECIAL: Si es imagen (PGS/DVD) y no queremos extraerlo (opcional)
// Si quieres extraer PGS tal cual (no se verá en tag <track> de HTML5):
if (s.codec === 'hdmv_pgs_subtitle' || s.codec === 'dvd_subtitle') {
encoderArgs = ['-c:s', 'copy'];
}
const args = [
'-i', filePath,
'-map', `0:${s.index}`,
...encoderArgs,
'-y',
outputPath
];
const p = spawn(FFMPEG_PATH, args, { stdio: 'ignore' });
p.on('close', (code) => {
if (code === 0) {
try {
// Verificar que el archivo no esté vacío
const stat = fs.statSync(outputPath);
if (stat.size === 0) {
console.error(`⚠️ Subtítulo ${i} extraído pero tiene 0 bytes.`);
}
createDummySubtitlePlaylist(playlistPath, outputFilename, info.duration);
resolve();
} catch (e) {
console.error(`Error procesando playlist subtítulo ${i}:`, e);
resolve(); // Resolvemos para no bloquear el video
}
} else {
console.error(`❌ Error extrayendo subtítulo ${i} (Codec: ${s.codec} -> ${ext}). Code: ${code}`);
resolve();
}
});
p.on('error', (err) => {
console.error(`Error spawn ffmpeg subs ${i}:`, err);
resolve();
});
});
});
await Promise.all(promises);
console.log('✅ Proceso de subtítulos finalizado.');
}
function createDummySubtitlePlaylist(playlistPath: string, subtitleFilename: string, duration: number) {
const content = [
'#EXTM3U',
'#EXT-X-VERSION:3',
`#EXT-X-TARGETDURATION:${Math.ceil(duration)}`,
'#EXT-X-MEDIA-SEQUENCE:0',
'#EXT-X-PLAYLIST-TYPE:VOD',
`#EXTINF:${duration.toFixed(6)},`,
subtitleFilename,
'#EXT-X-ENDLIST'
].join('\n');
fs.writeFileSync(playlistPath, content);
}
export async function getStreamingManifest(filePath: string) {
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
}));
}

View File

@@ -882,7 +882,6 @@ const AnimePlayer = (function() {
if (els.video) els.video.playbackRate = parseFloat(value); if (els.video) els.video.playbackRate = parseFloat(value);
} }
// Volvemos al menú principal para confirmar visualmente (opcional, estilo YouTube)
_settingsView = 'main'; _settingsView = 'main';
buildSettingsPanel(); buildSettingsPanel();
} }
@@ -904,31 +903,41 @@ const AnimePlayer = (function() {
subtitleRenderer = null; subtitleRenderer = null;
} }
// Find ASS subtitle const activeIdx = getActiveSubtitleIndex();
const assSubtitle = _currentSubtitles.find(sub => if (activeIdx === -1) return;
(sub.src && sub.src.endsWith('.ass')) ||
(sub.label && sub.label.toLowerCase().includes('ass'))
);
if (!assSubtitle) { const currentSub = _currentSubtitles[activeIdx];
console.log('No ASS subtitles found in current list'); if (!currentSub) return;
return;
}
try { const src = currentSub.src.toLowerCase();
console.log('Initializing JASSUB for:', assSubtitle.label); const label = (currentSub.label || '').toLowerCase();
// Check if JASSUB global is available // CASO 1: ASS (Usa JASSUB)
if (window.SubtitleRenderer && typeof window.JASSUB !== 'undefined') { if (src.endsWith('.ass') || label.includes('ass')) {
// --- CAMBIO AQUÍ: Pasamos els.subtitlesCanvas --- try {
subtitleRenderer = new SubtitleRenderer(els.video, els.subtitlesCanvas); console.log('Initializing JASSUB for:', currentSub.label);
await subtitleRenderer.init(assSubtitle.src); if (window.SubtitleRenderer && typeof window.JASSUB !== 'undefined') {
} else { subtitleRenderer = new SubtitleRenderer(els.video, els.subtitlesCanvas);
console.warn('JASSUB library not loaded.'); await subtitleRenderer.init(currentSub.src);
}
} catch (e) {
console.error('JASSUB setup error:', e);
} }
} catch (e) { }
console.error('Subtitle renderer setup error:', e); // CASO 2: SRT (Usa SimpleSubtitleRenderer)
subtitleRenderer = null; 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; _rpcActive = false;
setLoading("Checking availability..."); setLoading("Checking availability...");
// Check local availability
let shouldPlayLocal = false; let shouldPlayLocal = false;
try { try {
const check = await fetch(`/api/library/${_animeId}/units`); const check = await fetch(`/api/library/${_animeId}/units`);
const data = await check.json(); const data = await check.json();
const localUnit = data.units ? data.units.find(u => u.number === targetEp) : null; 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) { } catch (e) {
console.warn("Availability check failed:", e); console.warn("Availability check failed:", e);
shouldPlayLocal = (els.extSelect.value === 'local'); shouldPlayLocal = (els.extSelect.value === 'local');
@@ -1216,49 +1227,64 @@ const AnimePlayer = (function() {
if (currentExt === 'local') { if (currentExt === 'local') {
try { try {
const localId = await getLocalEntryId(); setLoading("Fetching Local Unit Data...");
const check = await fetch(`/api/library/${_animeId}/units`);
const data = await check.json(); // 1. Obtener las unidades locales para encontrar el ID del episodio específico
const targetUnit = data.units ? data.units.find(u => u.number === parseInt(_currentEpisode)) : null; 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) { if (!targetUnit) {
console.log(`Episode ${_currentEpisode} not found locally.`); throw new Error(`Episode ${_currentEpisode} not found in local library`);
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;
} }
const ext = targetUnit.format || targetUnit.name.split('.').pop().toLowerCase(); setLoading("Initializing HLS Stream...");
if (![''].includes(ext)) { const manifestRes = await fetch(`/api/library/stream/anime/${unitsData.entry_id}/${targetUnit.number}/manifest`);
setLoading(`Local files are not supported on the web player yet. Use MPV.`); if (!manifestRes.ok) throw new Error("Failed to generate stream manifest");
_rawVideoData = {
url: targetUnit.path, const manifestData = await manifestRes.json();
headers: {}
}; if (manifestData.chapters && manifestData.chapters.length > 0) {
if (els.mpvBtn) els.mpvBtn.style.display = 'flex'; _skipIntervals = manifestData.chapters.map(c => ({
return; 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 = { _rawVideoData = {
url: localUrl, url: manifestData.masterPlaylist,
headers: {} 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) { } catch(e) {
console.error(e); console.error("Local HLS Error:", e);
setLoading("Local Error: " + e.message); setLoading("Local Error: " + e.message);
// Fallback: si falla, intentar cargar desde extensión online
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; return;
} }
@@ -1530,23 +1556,19 @@ const AnimePlayer = (function() {
function renderSkipMarkers(intervals) { function renderSkipMarkers(intervals) {
if (!els.progressContainer || !els.video.duration) return; if (!els.progressContainer || !els.video.duration) return;
els.progressContainer.querySelectorAll('.skip-range, .skip-cut').forEach(e => e.remove()); els.progressContainer.querySelectorAll('.skip-range, .skip-cut').forEach(e => e.remove());
const duration = els.video.duration;
intervals.forEach(skip => { intervals.forEach(skip => {
const startPct = (skip.startTime / duration) * 100; const startPct = (skip.startTime / els.video.duration) * 100;
const endPct = (skip.endTime / duration) * 100; const endPct = (skip.endTime / els.video.duration) * 100;
const range = document.createElement('div'); 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.left = `${startPct}%`;
range.style.width = `${endPct - startPct}%`; range.style.width = `${endPct - startPct}%`;
els.progressContainer.appendChild(range); els.progressContainer.appendChild(range);
createCut(startPct); createCut(startPct);
createCut(endPct); createCut(endPct);
}); });
} }

View File

@@ -15,6 +15,7 @@ const DEFAULT_CONFIG = {
paths: { paths: {
mpv: null, mpv: null,
ffmpeg: null, ffmpeg: null,
ffprobe: null,
cloudflared: null, cloudflared: null,
} }
}; };
@@ -28,6 +29,7 @@ export const CONFIG_SCHEMA = {
paths: { paths: {
mpv: { description: "Required to open anime episodes in mpv on desktop version." }, mpv: { description: "Required to open anime episodes in mpv on desktop version." },
ffmpeg: { description: "Required for downloading anime episodes." }, ffmpeg: { description: "Required for downloading anime episodes." },
ffprobe: { description: "Required for watching local anime episodes." },
cloudflared: { description: "Required for creating pubic rooms." } cloudflared: { description: "Required for creating pubic rooms." }
} }
}; };