stream local files to player
This commit is contained in:
@@ -9,8 +9,38 @@ import AdmZip from 'adm-zip';
|
||||
import { spawn } from 'child_process';
|
||||
const { values } = loadConfig();
|
||||
|
||||
const FFMPEG_PATH =
|
||||
values.paths?.ffmpeg || 'ffmpeg';
|
||||
const FFMPEG_PATH = values.paths?.ffmpeg || 'ffmpeg';
|
||||
|
||||
type DownloadStatus = {
|
||||
id: string;
|
||||
type: 'anime' | 'manga' | 'novel';
|
||||
anilistId: number;
|
||||
unitNumber: number;
|
||||
status: 'pending' | 'downloading' | 'completed' | 'failed';
|
||||
progress: number;
|
||||
speed?: string;
|
||||
timeElapsed?: string;
|
||||
error?: string;
|
||||
startedAt: number;
|
||||
completedAt?: number;
|
||||
};
|
||||
|
||||
const activeDownloads = new Map<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 = {
|
||||
anilistId: number;
|
||||
@@ -109,6 +139,18 @@ async function getOrCreateEntry(
|
||||
export async function downloadAnimeEpisode(params: AnimeDownloadParams) {
|
||||
const { anilistId, episodeNumber, streamUrl, subtitles, chapters } = params;
|
||||
|
||||
const downloadId = crypto.randomUUID();
|
||||
|
||||
activeDownloads.set(downloadId, {
|
||||
id: downloadId,
|
||||
type: 'anime',
|
||||
anilistId,
|
||||
unitNumber: episodeNumber,
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
startedAt: Date.now()
|
||||
});
|
||||
|
||||
const entry = await getOrCreateEntry(anilistId, 'anime');
|
||||
|
||||
const exists = await queryOne(
|
||||
@@ -116,20 +158,25 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) {
|
||||
[entry.id, episodeNumber],
|
||||
'local_library'
|
||||
);
|
||||
if (exists) return { status: 'ALREADY_EXISTS', entry_id: entry.id, episode: episodeNumber };
|
||||
|
||||
if (exists) {
|
||||
activeDownloads.delete(downloadId);
|
||||
return { status: 'ALREADY_EXISTS', entry_id: entry.id, episode: episodeNumber };
|
||||
}
|
||||
|
||||
const outputPath = path.join(entry.path, `Episode_${episodeNumber.toString().padStart(2, '0')}.mkv`);
|
||||
const tempDir = path.join(entry.path, '.temp');
|
||||
await ensureDirectory(tempDir);
|
||||
|
||||
try {
|
||||
updateDownloadProgress(downloadId, { status: 'downloading' });
|
||||
|
||||
let videoInput = streamUrl;
|
||||
let audioInputs: string[] = [];
|
||||
|
||||
const isMaster = (params as any).is_master === true;
|
||||
|
||||
if (isMaster) {
|
||||
|
||||
const variant = (params as any).variant;
|
||||
const audios = (params as any).audio;
|
||||
|
||||
@@ -178,13 +225,11 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) {
|
||||
|
||||
if (chapters?.length) {
|
||||
const meta = path.join(tempDir, 'chapters.txt');
|
||||
|
||||
const sorted = [...chapters].sort((a, b) => a.start_time - b.start_time);
|
||||
const lines: string[] = [';FFMETADATA1'];
|
||||
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
const c = sorted[i];
|
||||
|
||||
const start = Math.floor(c.start_time * 1000);
|
||||
const end = Math.floor(c.end_time * 1000);
|
||||
const title = (c.title || 'chapter').toUpperCase();
|
||||
@@ -220,18 +265,14 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) {
|
||||
|
||||
fs.writeFileSync(meta, lines.join('\n'));
|
||||
args.push('-i', meta);
|
||||
|
||||
// índice correcto del metadata input
|
||||
chaptersInputIndex = 1 + audioInputs.length + subFiles.length;
|
||||
}
|
||||
|
||||
args.push('-map', '0:v:0');
|
||||
|
||||
if (audioInputs.length > 0) {
|
||||
|
||||
audioInputs.forEach((_, i) => {
|
||||
args.push('-map', `${i + 1}:a:0`);
|
||||
|
||||
const audioInfo = (params as any).audio?.[i];
|
||||
if (audioInfo) {
|
||||
const audioStreamIndex = i;
|
||||
@@ -244,7 +285,6 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) {
|
||||
}
|
||||
});
|
||||
} else {
|
||||
|
||||
args.push('-map', '0:a:0?');
|
||||
}
|
||||
|
||||
@@ -258,68 +298,33 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) {
|
||||
args.push('-map_metadata', `${chaptersInputIndex}`);
|
||||
}
|
||||
|
||||
args.push('-c:v', 'copy');
|
||||
|
||||
args.push('-c:a', 'copy');
|
||||
|
||||
if (subFiles.length) {
|
||||
args.push('-c:s', 'srt');
|
||||
|
||||
}
|
||||
|
||||
args.push('-y');
|
||||
|
||||
args.push(outputPath);
|
||||
args.push('-c:v', 'copy', '-c:a', 'copy');
|
||||
if (subFiles.length) args.push('-c:s', 'srt');
|
||||
args.push('-y', outputPath);
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
console.log('🎬 Iniciando descarga con FFmpeg...');
|
||||
console.log('📹 Video:', videoInput);
|
||||
if (audioInputs.length > 0) {
|
||||
console.log('🔊 Audio tracks:', audioInputs.length);
|
||||
}
|
||||
console.log('💾 Output:', outputPath);
|
||||
console.log('Args:', args.join(' '));
|
||||
|
||||
const ff = spawn(FFMPEG_PATH, args, {
|
||||
windowsHide: true,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
let lastProgress = '';
|
||||
|
||||
ff.stdout.on('data', (data) => {
|
||||
const text = data.toString();
|
||||
console.log('[stdout]', text);
|
||||
});
|
||||
|
||||
ff.stderr.on('data', (data) => {
|
||||
const text = data.toString();
|
||||
const timeMatch = text.match(/time=(\S+)/);
|
||||
const speedMatch = text.match(/speed=(\S+)/);
|
||||
|
||||
if (text.includes('time=') || text.includes('speed=')) {
|
||||
const timeMatch = text.match(/time=(\S+)/);
|
||||
const speedMatch = text.match(/speed=(\S+)/);
|
||||
if (timeMatch || speedMatch) {
|
||||
lastProgress = `⏱️ Time: ${timeMatch?.[1] || 'N/A'} | Speed: ${speedMatch?.[1] || 'N/A'}`;
|
||||
console.log(lastProgress);
|
||||
}
|
||||
} else {
|
||||
console.log('[ffmpeg]', text);
|
||||
if (timeMatch || speedMatch) {
|
||||
updateDownloadProgress(downloadId, {
|
||||
timeElapsed: timeMatch?.[1],
|
||||
speed: speedMatch?.[1]
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
ff.on('error', (error) => {
|
||||
console.error('❌ Error al iniciar FFmpeg:', error);
|
||||
reject(error);
|
||||
});
|
||||
|
||||
ff.on('error', (error) => reject(error));
|
||||
ff.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
console.log('✅ Descarga completada exitosamente');
|
||||
resolve(true);
|
||||
} else {
|
||||
console.error(`❌ FFmpeg terminó con código: ${code}`);
|
||||
reject(new Error(`FFmpeg exited with code ${code}`));
|
||||
}
|
||||
if (code === 0) resolve(true);
|
||||
else reject(new Error(`FFmpeg exited with code ${code}`));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -339,8 +344,17 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) {
|
||||
'local_library'
|
||||
);
|
||||
|
||||
updateDownloadProgress(downloadId, {
|
||||
status: 'completed',
|
||||
progress: 100,
|
||||
completedAt: Date.now()
|
||||
});
|
||||
|
||||
setTimeout(() => activeDownloads.delete(downloadId), 30000);
|
||||
|
||||
return {
|
||||
status: 'SUCCESS',
|
||||
download_id: downloadId,
|
||||
entry_id: entry.id,
|
||||
file_id: fileId,
|
||||
episode: episodeNumber,
|
||||
@@ -350,6 +364,14 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) {
|
||||
} catch (e: any) {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
if (fs.existsSync(outputPath)) fs.unlinkSync(outputPath);
|
||||
|
||||
updateDownloadProgress(downloadId, {
|
||||
status: 'failed',
|
||||
error: e.message
|
||||
});
|
||||
|
||||
setTimeout(() => activeDownloads.delete(downloadId), 60000);
|
||||
|
||||
const err = new Error('DOWNLOAD_FAILED');
|
||||
(err as any).details = e.message;
|
||||
throw err;
|
||||
@@ -359,6 +381,18 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) {
|
||||
export async function downloadBookChapter(params: BookDownloadParams) {
|
||||
const { anilistId, chapterNumber, format, content, images } = params;
|
||||
|
||||
const downloadId = crypto.randomUUID();
|
||||
|
||||
activeDownloads.set(downloadId, {
|
||||
id: downloadId,
|
||||
type: format === 'manga' ? 'manga' : 'novel',
|
||||
anilistId,
|
||||
unitNumber: chapterNumber,
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
startedAt: Date.now()
|
||||
});
|
||||
|
||||
const type = format === 'manga' ? 'manga' : 'novels';
|
||||
const entry = await getOrCreateEntry(anilistId, type);
|
||||
|
||||
@@ -369,6 +403,7 @@ export async function downloadBookChapter(params: BookDownloadParams) {
|
||||
);
|
||||
|
||||
if (existingFile) {
|
||||
activeDownloads.delete(downloadId);
|
||||
return {
|
||||
status: 'ALREADY_EXISTS',
|
||||
message: `Chapter ${chapterNumber} already exists`,
|
||||
@@ -378,6 +413,8 @@ export async function downloadBookChapter(params: BookDownloadParams) {
|
||||
}
|
||||
|
||||
try {
|
||||
updateDownloadProgress(downloadId, { status: 'downloading' });
|
||||
|
||||
let outputPath: string;
|
||||
let fileId: string;
|
||||
|
||||
@@ -388,7 +425,8 @@ export async function downloadBookChapter(params: BookDownloadParams) {
|
||||
const zip = new AdmZip();
|
||||
const sortedImages = images!.sort((a, b) => a.index - b.index);
|
||||
|
||||
for (const img of sortedImages) {
|
||||
for (let i = 0; i < sortedImages.length; i++) {
|
||||
const img = sortedImages[i];
|
||||
const res = await fetch(img.url);
|
||||
if (!res.ok) throw new Error(`HTTP_${res.status}`);
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
@@ -396,6 +434,10 @@ export async function downloadBookChapter(params: BookDownloadParams) {
|
||||
const ext = path.extname(new URL(img.url).pathname) || '.jpg';
|
||||
const filename = `${img.index.toString().padStart(4, '0')}${ext}`;
|
||||
zip.addFile(filename, buf);
|
||||
|
||||
updateDownloadProgress(downloadId, {
|
||||
progress: Math.floor((i / sortedImages.length) * 100)
|
||||
});
|
||||
}
|
||||
|
||||
zip.writeZip(outputPath);
|
||||
@@ -405,7 +447,6 @@ export async function downloadBookChapter(params: BookDownloadParams) {
|
||||
outputPath = path.join(entry.path, chapterName);
|
||||
|
||||
const zip = new AdmZip();
|
||||
|
||||
zip.addFile('mimetype', Buffer.from('application/epub+zip'), '', 0);
|
||||
|
||||
const containerXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
@@ -443,7 +484,6 @@ ${content}
|
||||
</body>
|
||||
</html>`;
|
||||
zip.addFile('OEBPS/chapter.xhtml', Buffer.from(chapterXhtml));
|
||||
|
||||
zip.writeZip(outputPath);
|
||||
}
|
||||
|
||||
@@ -461,8 +501,17 @@ ${content}
|
||||
'local_library'
|
||||
);
|
||||
|
||||
updateDownloadProgress(downloadId, {
|
||||
status: 'completed',
|
||||
progress: 100,
|
||||
completedAt: Date.now()
|
||||
});
|
||||
|
||||
setTimeout(() => activeDownloads.delete(downloadId), 30000);
|
||||
|
||||
return {
|
||||
status: 'SUCCESS',
|
||||
download_id: downloadId,
|
||||
entry_id: entry.id,
|
||||
file_id: fileId,
|
||||
chapter: chapterNumber,
|
||||
@@ -471,6 +520,13 @@ ${content}
|
||||
};
|
||||
|
||||
} catch (error: any) {
|
||||
updateDownloadProgress(downloadId, {
|
||||
status: 'failed',
|
||||
error: error.message
|
||||
});
|
||||
|
||||
setTimeout(() => activeDownloads.delete(downloadId), 60000);
|
||||
|
||||
const err = new Error('DOWNLOAD_FAILED');
|
||||
(err as any).details = error.message;
|
||||
throw err;
|
||||
|
||||
@@ -2,6 +2,7 @@ import {FastifyReply, FastifyRequest} from 'fastify';
|
||||
import fs from 'fs';
|
||||
import * as service from './local.service';
|
||||
import * as downloadService from './download.service';
|
||||
import * as streamingService from './streaming.service';
|
||||
|
||||
type ScanQuery = {
|
||||
mode?: 'full' | 'incremental';
|
||||
@@ -21,7 +22,7 @@ type DownloadAnimeBody =
|
||||
| {
|
||||
anilist_id: number;
|
||||
episode_number: number;
|
||||
stream_url: string; // media playlist FINAL
|
||||
stream_url: string;
|
||||
is_master?: false;
|
||||
subtitles?: {
|
||||
language: string;
|
||||
@@ -36,28 +37,24 @@ type DownloadAnimeBody =
|
||||
| {
|
||||
anilist_id: number;
|
||||
episode_number: number;
|
||||
stream_url: string; // master.m3u8
|
||||
stream_url: string;
|
||||
is_master: true;
|
||||
|
||||
variant: {
|
||||
resolution: string;
|
||||
bandwidth?: number;
|
||||
codecs?: string;
|
||||
playlist_url: string;
|
||||
};
|
||||
|
||||
audio?: {
|
||||
group?: string;
|
||||
language?: string;
|
||||
name?: string;
|
||||
playlist_url: string;
|
||||
}[];
|
||||
|
||||
subtitles?: {
|
||||
language: string;
|
||||
url: string;
|
||||
}[];
|
||||
|
||||
chapters?: {
|
||||
title: string;
|
||||
start_time: number;
|
||||
@@ -266,7 +263,6 @@ export async function downloadAnime(request: FastifyRequest<{ Body: DownloadAnim
|
||||
|
||||
const clientHeaders = (request.body as any).headers || {};
|
||||
|
||||
// Validación básica
|
||||
if (!anilist_id || !episode_number || !stream_url) {
|
||||
return reply.status(400).send({
|
||||
error: 'MISSING_REQUIRED_FIELDS',
|
||||
@@ -274,17 +270,14 @@ export async function downloadAnime(request: FastifyRequest<{ Body: DownloadAnim
|
||||
});
|
||||
}
|
||||
|
||||
// Proxy del stream URL principal
|
||||
const proxyUrl = buildProxyUrl(stream_url, clientHeaders);
|
||||
console.log('Stream URL:', proxyUrl);
|
||||
|
||||
// Proxy de subtítulos
|
||||
const proxiedSubs = subtitles?.map(sub => ({
|
||||
...sub,
|
||||
url: buildProxyUrl(sub.url, clientHeaders)
|
||||
}));
|
||||
|
||||
// Preparar parámetros base
|
||||
const downloadParams: any = {
|
||||
anilistId: anilist_id,
|
||||
episodeNumber: episode_number,
|
||||
@@ -293,7 +286,6 @@ export async function downloadAnime(request: FastifyRequest<{ Body: DownloadAnim
|
||||
chapters
|
||||
};
|
||||
|
||||
// Si es master playlist, agregar campos adicionales
|
||||
if (is_master === true) {
|
||||
const { variant, audio } = request.body as any;
|
||||
|
||||
@@ -305,14 +297,11 @@ export async function downloadAnime(request: FastifyRequest<{ Body: DownloadAnim
|
||||
}
|
||||
|
||||
downloadParams.is_master = true;
|
||||
|
||||
// Proxy del variant playlist
|
||||
downloadParams.variant = {
|
||||
...variant,
|
||||
playlist_url: buildProxyUrl(variant.playlist_url, clientHeaders)
|
||||
};
|
||||
|
||||
// Proxy de audio tracks si existen
|
||||
if (audio && audio.length > 0) {
|
||||
downloadParams.audio = audio.map((a: any) => ({
|
||||
...a,
|
||||
@@ -409,3 +398,115 @@ export async function downloadBook(request: FastifyRequest<{ Body: DownloadBookB
|
||||
return reply.status(500).send({ error: 'FAILED_TO_DOWNLOAD_BOOK' });
|
||||
}
|
||||
}
|
||||
|
||||
// NUEVO: Estado de descargas
|
||||
export async function getDownloadStatus(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const downloads = downloadService.getActiveDownloads();
|
||||
const streams = streamingService.getActiveStreamsStatus();
|
||||
|
||||
return {
|
||||
downloads: {
|
||||
total: downloads.length,
|
||||
active: downloads.filter(d => d.status === 'downloading').length,
|
||||
completed: downloads.filter(d => d.status === 'completed').length,
|
||||
failed: downloads.filter(d => d.status === 'failed').length,
|
||||
list: downloads
|
||||
},
|
||||
streams: {
|
||||
total: streams.length,
|
||||
active: streams.filter(s => !s.isComplete).length,
|
||||
completed: streams.filter(s => s.isComplete).length,
|
||||
list: streams
|
||||
}
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('Error getting download status:', err);
|
||||
return reply.status(500).send({ error: 'FAILED_TO_GET_DOWNLOAD_STATUS' });
|
||||
}
|
||||
}
|
||||
|
||||
// NUEVO: Streaming HLS para anime local
|
||||
export async function getAnimeStreamManifest(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const { type, id, unit } = request.params as any;
|
||||
|
||||
if (type !== 'anime') {
|
||||
return reply.status(400).send({ error: 'ONLY_ANIME_SUPPORTED' });
|
||||
}
|
||||
|
||||
const fileInfo = await service.getFileForStreaming(id, unit);
|
||||
|
||||
if (!fileInfo) {
|
||||
return reply.status(404).send({ error: 'FILE_NOT_FOUND' });
|
||||
}
|
||||
|
||||
const manifest = await streamingService.getStreamingManifest(fileInfo.filePath);
|
||||
|
||||
if (!manifest) {
|
||||
return reply.status(500).send({ error: 'FAILED_TO_GENERATE_MANIFEST' });
|
||||
}
|
||||
|
||||
return manifest;
|
||||
} catch (err: any) {
|
||||
console.error('Error getting stream manifest:', err);
|
||||
return reply.status(500).send({ error: 'FAILED_TO_GET_STREAM_MANIFEST' });
|
||||
}
|
||||
}
|
||||
|
||||
// NUEVO: Servir archivos HLS
|
||||
export async function serveHLSFile(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const { hash, filename } = request.params as any;
|
||||
|
||||
const file = await streamingService.getHLSFile(hash, filename);
|
||||
|
||||
if (!file) {
|
||||
return reply.status(404).send({ error: 'FILE_NOT_FOUND' });
|
||||
}
|
||||
|
||||
const contentType = filename.endsWith('.m3u8')
|
||||
? 'application/vnd.apple.mpegurl'
|
||||
: filename.endsWith('.vtt')
|
||||
? 'text/vtt'
|
||||
: 'video/mp2t';
|
||||
|
||||
reply
|
||||
.header('Content-Type', contentType)
|
||||
.header('Content-Length', file.stat.size)
|
||||
.header('Access-Control-Allow-Origin', '*');
|
||||
|
||||
return fs.createReadStream(file.path);
|
||||
} catch (err) {
|
||||
console.error('Error serving HLS file:', err);
|
||||
return reply.status(500).send({ error: 'FAILED_TO_SERVE_HLS_FILE' });
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSubtitle(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const { id, unit, track } = request.params as any;
|
||||
|
||||
// Validar que el track sea un número
|
||||
const trackIndex = parseInt(track, 10);
|
||||
if (isNaN(trackIndex)) {
|
||||
return reply.status(400).send({ error: 'INVALID_TRACK_INDEX' });
|
||||
}
|
||||
|
||||
const subtitleStream = await service.extractSubtitleTrack(id, unit, trackIndex);
|
||||
|
||||
if (!subtitleStream) {
|
||||
return reply.status(404).send({ error: 'FILE_NOT_FOUND' });
|
||||
}
|
||||
|
||||
// Cabecera esencial para que el navegador entienda que son subtítulos
|
||||
reply.header('Content-Type', 'text/vtt');
|
||||
reply.header('Cache-Control', 'public, max-age=86400'); // Cachear por 1 día si quieres
|
||||
|
||||
return subtitleStream;
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error serving subtitles:', err);
|
||||
return reply.status(500).send({ error: 'FAILED_TO_SERVE_SUBTITLES' });
|
||||
}
|
||||
}
|
||||
@@ -5,13 +5,19 @@ async function localRoutes(fastify: FastifyInstance) {
|
||||
fastify.post('/library/scan', controller.scanLibrary);
|
||||
fastify.get('/library/:type', controller.listEntries);
|
||||
fastify.get('/library/:type/:id', controller.getEntry);
|
||||
|
||||
// Streaming básico (legacy)
|
||||
fastify.get('/library/stream/:type/:id/:unit', controller.streamUnit);
|
||||
fastify.get('/library/stream/:type/:id/:unit/subs/:track', controller.getSubtitle);
|
||||
fastify.post('/library/:type/:id/match', controller.matchEntry);
|
||||
fastify.get('/library/:id/units', controller.getUnits);
|
||||
fastify.get('/library/:unitId/manifest', controller.getManifest);
|
||||
fastify.get('/library/:unitId/resource/:resId', controller.getPage);
|
||||
fastify.post('/library/download/anime', controller.downloadAnime);
|
||||
fastify.post('/library/download/book', controller.downloadBook);
|
||||
fastify.get('/library/downloads/status', controller.getDownloadStatus);
|
||||
fastify.get('/library/stream/:type/:id/:unit/manifest', controller.getAnimeStreamManifest);
|
||||
fastify.get('/library/hls/:hash/:filename', controller.serveHLSFile);
|
||||
}
|
||||
|
||||
export default localRoutes;
|
||||
@@ -7,6 +7,7 @@ import path from "path";
|
||||
import { getAnimeById, searchAnimeLocal } from "../anime/anime.service";
|
||||
import { getBookById, searchBooksAniList } from "../books/books.service";
|
||||
import AdmZip from 'adm-zip';
|
||||
import { getStreamHash, getSubtitleFileStream } from './streaming.service';
|
||||
|
||||
const MANGA_IMAGE_EXTS = ['.jpg', '.jpeg', '.png', '.webp'];
|
||||
const MANGA_ARCHIVES = ['.cbz', '.cbr', '.zip'];
|
||||
@@ -510,3 +511,26 @@ function parseEpubToHtml(filePath: string) {
|
||||
if (!entry) throw new Error('CHAPTER_NOT_FOUND');
|
||||
return entry.getData().toString('utf8');
|
||||
}
|
||||
|
||||
export async function extractSubtitleTrack(id: string, unit: string, trackIndex: number) {
|
||||
// 1. Obtenemos la ruta real del archivo usando tu función existente
|
||||
const fileInfo = await getFileForStreaming(id, unit);
|
||||
|
||||
if (!fileInfo || !fs.existsSync(fileInfo.filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. Generamos el hash (ID único del stream) igual que lo hace el streaming
|
||||
const hash = getStreamHash(fileInfo.filePath);
|
||||
|
||||
// 3. Pedimos al servicio de streaming que busque el archivo en la carpeta temp
|
||||
const stream = getSubtitleFileStream(hash, trackIndex);
|
||||
|
||||
if (stream) {
|
||||
console.log(`✅ Subtítulo recuperado de caché (Hash: ${hash}, Track: ${trackIndex})`);
|
||||
} else {
|
||||
console.error(`❌ Subtítulo no encontrado en caché para ${hash}`);
|
||||
}
|
||||
|
||||
return stream;
|
||||
}
|
||||
586
desktop/src/api/local/streaming.service.ts
Normal file
586
desktop/src/api/local/streaming.service.ts
Normal 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
|
||||
}));
|
||||
}
|
||||
@@ -882,7 +882,6 @@ const AnimePlayer = (function() {
|
||||
if (els.video) els.video.playbackRate = parseFloat(value);
|
||||
}
|
||||
|
||||
// Volvemos al menú principal para confirmar visualmente (opcional, estilo YouTube)
|
||||
_settingsView = 'main';
|
||||
buildSettingsPanel();
|
||||
}
|
||||
@@ -904,31 +903,41 @@ const AnimePlayer = (function() {
|
||||
subtitleRenderer = null;
|
||||
}
|
||||
|
||||
// Find ASS subtitle
|
||||
const assSubtitle = _currentSubtitles.find(sub =>
|
||||
(sub.src && sub.src.endsWith('.ass')) ||
|
||||
(sub.label && sub.label.toLowerCase().includes('ass'))
|
||||
);
|
||||
const activeIdx = getActiveSubtitleIndex();
|
||||
if (activeIdx === -1) return;
|
||||
|
||||
if (!assSubtitle) {
|
||||
console.log('No ASS subtitles found in current list');
|
||||
return;
|
||||
}
|
||||
const currentSub = _currentSubtitles[activeIdx];
|
||||
if (!currentSub) return;
|
||||
|
||||
try {
|
||||
console.log('Initializing JASSUB for:', assSubtitle.label);
|
||||
const src = currentSub.src.toLowerCase();
|
||||
const label = (currentSub.label || '').toLowerCase();
|
||||
|
||||
// Check if JASSUB global is available
|
||||
if (window.SubtitleRenderer && typeof window.JASSUB !== 'undefined') {
|
||||
// --- CAMBIO AQUÍ: Pasamos els.subtitlesCanvas ---
|
||||
subtitleRenderer = new SubtitleRenderer(els.video, els.subtitlesCanvas);
|
||||
await subtitleRenderer.init(assSubtitle.src);
|
||||
} else {
|
||||
console.warn('JASSUB library not loaded.');
|
||||
// CASO 1: ASS (Usa JASSUB)
|
||||
if (src.endsWith('.ass') || label.includes('ass')) {
|
||||
try {
|
||||
console.log('Initializing JASSUB for:', currentSub.label);
|
||||
if (window.SubtitleRenderer && typeof window.JASSUB !== 'undefined') {
|
||||
subtitleRenderer = new SubtitleRenderer(els.video, els.subtitlesCanvas);
|
||||
await subtitleRenderer.init(currentSub.src);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('JASSUB setup error:', e);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Subtitle renderer setup error:', e);
|
||||
subtitleRenderer = null;
|
||||
}
|
||||
// CASO 2: SRT (Usa SimpleSubtitleRenderer)
|
||||
else if (src.endsWith('.srt') || label.includes('srt')) {
|
||||
try {
|
||||
console.log('Initializing Simple Renderer for:', currentSub.label);
|
||||
if (window.SimpleSubtitleRenderer) {
|
||||
subtitleRenderer = new SimpleSubtitleRenderer(els.video, els.subtitlesCanvas);
|
||||
await subtitleRenderer.loadSubtitles(currentSub.src);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Simple Renderer setup error:', e);
|
||||
}
|
||||
}
|
||||
else {
|
||||
console.log('Using native browser rendering for VTT');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -991,13 +1000,15 @@ const AnimePlayer = (function() {
|
||||
_rpcActive = false;
|
||||
setLoading("Checking availability...");
|
||||
|
||||
// Check local availability
|
||||
let shouldPlayLocal = false;
|
||||
try {
|
||||
const check = await fetch(`/api/library/${_animeId}/units`);
|
||||
const data = await check.json();
|
||||
const localUnit = data.units ? data.units.find(u => u.number === targetEp) : null;
|
||||
if (localUnit) shouldPlayLocal = true;
|
||||
|
||||
if (localUnit && els.extSelect.value === 'local') {
|
||||
shouldPlayLocal = true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Availability check failed:", e);
|
||||
shouldPlayLocal = (els.extSelect.value === 'local');
|
||||
@@ -1216,49 +1227,64 @@ const AnimePlayer = (function() {
|
||||
|
||||
if (currentExt === 'local') {
|
||||
try {
|
||||
const localId = await getLocalEntryId();
|
||||
const check = await fetch(`/api/library/${_animeId}/units`);
|
||||
const data = await check.json();
|
||||
const targetUnit = data.units ? data.units.find(u => u.number === parseInt(_currentEpisode)) : null;
|
||||
setLoading("Fetching Local Unit Data...");
|
||||
|
||||
// 1. Obtener las unidades locales para encontrar el ID del episodio específico
|
||||
const unitsRes = await fetch(`/api/library/${_animeId}/units`);
|
||||
if (!unitsRes.ok) throw new Error("Could not fetch local units");
|
||||
|
||||
const unitsData = await unitsRes.json();
|
||||
const targetUnit = unitsData.units ? unitsData.units.find(u => u.number === parseInt(_currentEpisode)) : null;
|
||||
|
||||
if (!targetUnit) {
|
||||
console.log(`Episode ${_currentEpisode} not found locally.`);
|
||||
const localOption = els.extSelect.querySelector('option[value="local"]');
|
||||
if (localOption) localOption.remove();
|
||||
|
||||
const fallbackSource = (_entrySource === 'local') ? 'anilist' : _entrySource;
|
||||
if (els.extSelect.querySelector(`option[value="${fallbackSource}"]`)) {
|
||||
els.extSelect.value = fallbackSource;
|
||||
} else if (els.extSelect.options.length > 0) {
|
||||
els.extSelect.selectedIndex = 0;
|
||||
}
|
||||
handleExtensionChange(true);
|
||||
return;
|
||||
throw new Error(`Episode ${_currentEpisode} not found in local library`);
|
||||
}
|
||||
|
||||
const ext = targetUnit.format || targetUnit.name.split('.').pop().toLowerCase();
|
||||
setLoading("Initializing HLS Stream...");
|
||||
|
||||
if (![''].includes(ext)) {
|
||||
setLoading(`Local files are not supported on the web player yet. Use MPV.`);
|
||||
_rawVideoData = {
|
||||
url: targetUnit.path,
|
||||
headers: {}
|
||||
};
|
||||
if (els.mpvBtn) els.mpvBtn.style.display = 'flex';
|
||||
return;
|
||||
const manifestRes = await fetch(`/api/library/stream/anime/${unitsData.entry_id}/${targetUnit.number}/manifest`);
|
||||
if (!manifestRes.ok) throw new Error("Failed to generate stream manifest");
|
||||
|
||||
const manifestData = await manifestRes.json();
|
||||
|
||||
if (manifestData.chapters && manifestData.chapters.length > 0) {
|
||||
_skipIntervals = manifestData.chapters.map(c => ({
|
||||
startTime: c.start,
|
||||
endTime: c.end,
|
||||
type: c.title.toLowerCase().includes('op') ? 'op' :
|
||||
c.title.toLowerCase().includes('ed') ? 'ed' : 'chapter'
|
||||
}));
|
||||
renderSkipMarkers(_skipIntervals);
|
||||
monitorSkipButton(_skipIntervals);
|
||||
}
|
||||
|
||||
const localUrl = `/api/library/stream/${targetUnit.id}`;
|
||||
// 4. Mapear Subtítulos WebVTT
|
||||
const subs = (manifestData.subtitles || []).map(s => ({
|
||||
label: s.title || s.language || `Track ${s.index}`,
|
||||
srclang: s.language || 'unk',
|
||||
src: s.url // URL al endpoint de conversión VTT (.vtt)
|
||||
}));
|
||||
|
||||
// 5. Guardar referencia para MPV o descargas
|
||||
_rawVideoData = {
|
||||
url: localUrl,
|
||||
url: manifestData.masterPlaylist,
|
||||
headers: {}
|
||||
};
|
||||
_currentSubtitles = [];
|
||||
|
||||
initVideoPlayer(localUrl, 'mp4');
|
||||
// 6. Cargar en el reproductor (Hls.js gestionará los audios del master.m3u8)
|
||||
initVideoPlayer(manifestData.masterPlaylist, 'm3u8', subs);
|
||||
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
console.error("Local HLS Error:", e);
|
||||
setLoading("Local Error: " + e.message);
|
||||
|
||||
// Fallback: si falla, intentar cargar desde extensión online
|
||||
const localOption = els.extSelect.querySelector('option[value="local"]');
|
||||
if (localOption) localOption.remove();
|
||||
|
||||
const fallbackSource = (_entrySource === 'local') ? 'anilist' : _entrySource;
|
||||
els.extSelect.value = fallbackSource;
|
||||
handleExtensionChange(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1530,23 +1556,19 @@ const AnimePlayer = (function() {
|
||||
|
||||
function renderSkipMarkers(intervals) {
|
||||
if (!els.progressContainer || !els.video.duration) return;
|
||||
|
||||
els.progressContainer.querySelectorAll('.skip-range, .skip-cut').forEach(e => e.remove());
|
||||
|
||||
const duration = els.video.duration;
|
||||
|
||||
intervals.forEach(skip => {
|
||||
const startPct = (skip.startTime / duration) * 100;
|
||||
const endPct = (skip.endTime / duration) * 100;
|
||||
const startPct = (skip.startTime / els.video.duration) * 100;
|
||||
const endPct = (skip.endTime / els.video.duration) * 100;
|
||||
|
||||
const range = document.createElement('div');
|
||||
range.className = `skip-range ${skip.type}`; // 'op' o 'ed'
|
||||
range.className = `skip-range ${skip.type}`;
|
||||
range.style.left = `${startPct}%`;
|
||||
range.style.width = `${endPct - startPct}%`;
|
||||
els.progressContainer.appendChild(range);
|
||||
|
||||
createCut(startPct);
|
||||
|
||||
createCut(endPct);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ const DEFAULT_CONFIG = {
|
||||
paths: {
|
||||
mpv: null,
|
||||
ffmpeg: null,
|
||||
ffprobe: null,
|
||||
cloudflared: null,
|
||||
}
|
||||
};
|
||||
@@ -28,6 +29,7 @@ export const CONFIG_SCHEMA = {
|
||||
paths: {
|
||||
mpv: { description: "Required to open anime episodes in mpv on desktop version." },
|
||||
ffmpeg: { description: "Required for downloading anime episodes." },
|
||||
ffprobe: { description: "Required for watching local anime episodes." },
|
||||
cloudflared: { description: "Required for creating pubic rooms." }
|
||||
}
|
||||
};
|
||||
|
||||
@@ -29,7 +29,7 @@ const configRoutes = require("./dist/api/config/config.routes");
|
||||
const roomRoutes = require("./dist/api/rooms/rooms.routes");
|
||||
const { setupRoomWebSocket } = require("./dist/api/rooms/rooms.websocket");
|
||||
|
||||
fastify.addHook("preHandler", async (request) => {
|
||||
fastify.addHook("preHandler", async (request, reply) => {
|
||||
const auth = request.headers.authorization;
|
||||
if (!auth) return;
|
||||
|
||||
|
||||
@@ -9,8 +9,38 @@ import AdmZip from 'adm-zip';
|
||||
import { spawn } from 'child_process';
|
||||
const { values } = loadConfig();
|
||||
|
||||
const FFMPEG_PATH =
|
||||
values.paths?.ffmpeg || 'ffmpeg';
|
||||
const FFMPEG_PATH = values.paths?.ffmpeg || 'ffmpeg';
|
||||
|
||||
type DownloadStatus = {
|
||||
id: string;
|
||||
type: 'anime' | 'manga' | 'novel';
|
||||
anilistId: number;
|
||||
unitNumber: number;
|
||||
status: 'pending' | 'downloading' | 'completed' | 'failed';
|
||||
progress: number;
|
||||
speed?: string;
|
||||
timeElapsed?: string;
|
||||
error?: string;
|
||||
startedAt: number;
|
||||
completedAt?: number;
|
||||
};
|
||||
|
||||
const activeDownloads = new Map<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 = {
|
||||
anilistId: number;
|
||||
@@ -109,6 +139,18 @@ async function getOrCreateEntry(
|
||||
export async function downloadAnimeEpisode(params: AnimeDownloadParams) {
|
||||
const { anilistId, episodeNumber, streamUrl, subtitles, chapters } = params;
|
||||
|
||||
const downloadId = crypto.randomUUID();
|
||||
|
||||
activeDownloads.set(downloadId, {
|
||||
id: downloadId,
|
||||
type: 'anime',
|
||||
anilistId,
|
||||
unitNumber: episodeNumber,
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
startedAt: Date.now()
|
||||
});
|
||||
|
||||
const entry = await getOrCreateEntry(anilistId, 'anime');
|
||||
|
||||
const exists = await queryOne(
|
||||
@@ -116,20 +158,25 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) {
|
||||
[entry.id, episodeNumber],
|
||||
'local_library'
|
||||
);
|
||||
if (exists) return { status: 'ALREADY_EXISTS', entry_id: entry.id, episode: episodeNumber };
|
||||
|
||||
if (exists) {
|
||||
activeDownloads.delete(downloadId);
|
||||
return { status: 'ALREADY_EXISTS', entry_id: entry.id, episode: episodeNumber };
|
||||
}
|
||||
|
||||
const outputPath = path.join(entry.path, `Episode_${episodeNumber.toString().padStart(2, '0')}.mkv`);
|
||||
const tempDir = path.join(entry.path, '.temp');
|
||||
await ensureDirectory(tempDir);
|
||||
|
||||
try {
|
||||
updateDownloadProgress(downloadId, { status: 'downloading' });
|
||||
|
||||
let videoInput = streamUrl;
|
||||
let audioInputs: string[] = [];
|
||||
|
||||
const isMaster = (params as any).is_master === true;
|
||||
|
||||
if (isMaster) {
|
||||
|
||||
const variant = (params as any).variant;
|
||||
const audios = (params as any).audio;
|
||||
|
||||
@@ -178,13 +225,11 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) {
|
||||
|
||||
if (chapters?.length) {
|
||||
const meta = path.join(tempDir, 'chapters.txt');
|
||||
|
||||
const sorted = [...chapters].sort((a, b) => a.start_time - b.start_time);
|
||||
const lines: string[] = [';FFMETADATA1'];
|
||||
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
const c = sorted[i];
|
||||
|
||||
const start = Math.floor(c.start_time * 1000);
|
||||
const end = Math.floor(c.end_time * 1000);
|
||||
const title = (c.title || 'chapter').toUpperCase();
|
||||
@@ -220,18 +265,14 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) {
|
||||
|
||||
fs.writeFileSync(meta, lines.join('\n'));
|
||||
args.push('-i', meta);
|
||||
|
||||
// índice correcto del metadata input
|
||||
chaptersInputIndex = 1 + audioInputs.length + subFiles.length;
|
||||
}
|
||||
|
||||
args.push('-map', '0:v:0');
|
||||
|
||||
if (audioInputs.length > 0) {
|
||||
|
||||
audioInputs.forEach((_, i) => {
|
||||
args.push('-map', `${i + 1}:a:0`);
|
||||
|
||||
const audioInfo = (params as any).audio?.[i];
|
||||
if (audioInfo) {
|
||||
const audioStreamIndex = i;
|
||||
@@ -244,7 +285,6 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) {
|
||||
}
|
||||
});
|
||||
} else {
|
||||
|
||||
args.push('-map', '0:a:0?');
|
||||
}
|
||||
|
||||
@@ -258,68 +298,33 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) {
|
||||
args.push('-map_metadata', `${chaptersInputIndex}`);
|
||||
}
|
||||
|
||||
args.push('-c:v', 'copy');
|
||||
|
||||
args.push('-c:a', 'copy');
|
||||
|
||||
if (subFiles.length) {
|
||||
args.push('-c:s', 'srt');
|
||||
|
||||
}
|
||||
|
||||
args.push('-y');
|
||||
|
||||
args.push(outputPath);
|
||||
args.push('-c:v', 'copy', '-c:a', 'copy');
|
||||
if (subFiles.length) args.push('-c:s', 'srt');
|
||||
args.push('-y', outputPath);
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
console.log('🎬 Iniciando descarga con FFmpeg...');
|
||||
console.log('📹 Video:', videoInput);
|
||||
if (audioInputs.length > 0) {
|
||||
console.log('🔊 Audio tracks:', audioInputs.length);
|
||||
}
|
||||
console.log('💾 Output:', outputPath);
|
||||
console.log('Args:', args.join(' '));
|
||||
|
||||
const ff = spawn(FFMPEG_PATH, args, {
|
||||
windowsHide: true,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
let lastProgress = '';
|
||||
|
||||
ff.stdout.on('data', (data) => {
|
||||
const text = data.toString();
|
||||
console.log('[stdout]', text);
|
||||
});
|
||||
|
||||
ff.stderr.on('data', (data) => {
|
||||
const text = data.toString();
|
||||
const timeMatch = text.match(/time=(\S+)/);
|
||||
const speedMatch = text.match(/speed=(\S+)/);
|
||||
|
||||
if (text.includes('time=') || text.includes('speed=')) {
|
||||
const timeMatch = text.match(/time=(\S+)/);
|
||||
const speedMatch = text.match(/speed=(\S+)/);
|
||||
if (timeMatch || speedMatch) {
|
||||
lastProgress = `⏱️ Time: ${timeMatch?.[1] || 'N/A'} | Speed: ${speedMatch?.[1] || 'N/A'}`;
|
||||
console.log(lastProgress);
|
||||
}
|
||||
} else {
|
||||
console.log('[ffmpeg]', text);
|
||||
if (timeMatch || speedMatch) {
|
||||
updateDownloadProgress(downloadId, {
|
||||
timeElapsed: timeMatch?.[1],
|
||||
speed: speedMatch?.[1]
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
ff.on('error', (error) => {
|
||||
console.error('❌ Error al iniciar FFmpeg:', error);
|
||||
reject(error);
|
||||
});
|
||||
|
||||
ff.on('error', (error) => reject(error));
|
||||
ff.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
console.log('✅ Descarga completada exitosamente');
|
||||
resolve(true);
|
||||
} else {
|
||||
console.error(`❌ FFmpeg terminó con código: ${code}`);
|
||||
reject(new Error(`FFmpeg exited with code ${code}`));
|
||||
}
|
||||
if (code === 0) resolve(true);
|
||||
else reject(new Error(`FFmpeg exited with code ${code}`));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -339,8 +344,17 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) {
|
||||
'local_library'
|
||||
);
|
||||
|
||||
updateDownloadProgress(downloadId, {
|
||||
status: 'completed',
|
||||
progress: 100,
|
||||
completedAt: Date.now()
|
||||
});
|
||||
|
||||
setTimeout(() => activeDownloads.delete(downloadId), 30000);
|
||||
|
||||
return {
|
||||
status: 'SUCCESS',
|
||||
download_id: downloadId,
|
||||
entry_id: entry.id,
|
||||
file_id: fileId,
|
||||
episode: episodeNumber,
|
||||
@@ -350,6 +364,14 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) {
|
||||
} catch (e: any) {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
if (fs.existsSync(outputPath)) fs.unlinkSync(outputPath);
|
||||
|
||||
updateDownloadProgress(downloadId, {
|
||||
status: 'failed',
|
||||
error: e.message
|
||||
});
|
||||
|
||||
setTimeout(() => activeDownloads.delete(downloadId), 60000);
|
||||
|
||||
const err = new Error('DOWNLOAD_FAILED');
|
||||
(err as any).details = e.message;
|
||||
throw err;
|
||||
@@ -359,6 +381,18 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) {
|
||||
export async function downloadBookChapter(params: BookDownloadParams) {
|
||||
const { anilistId, chapterNumber, format, content, images } = params;
|
||||
|
||||
const downloadId = crypto.randomUUID();
|
||||
|
||||
activeDownloads.set(downloadId, {
|
||||
id: downloadId,
|
||||
type: format === 'manga' ? 'manga' : 'novel',
|
||||
anilistId,
|
||||
unitNumber: chapterNumber,
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
startedAt: Date.now()
|
||||
});
|
||||
|
||||
const type = format === 'manga' ? 'manga' : 'novels';
|
||||
const entry = await getOrCreateEntry(anilistId, type);
|
||||
|
||||
@@ -369,6 +403,7 @@ export async function downloadBookChapter(params: BookDownloadParams) {
|
||||
);
|
||||
|
||||
if (existingFile) {
|
||||
activeDownloads.delete(downloadId);
|
||||
return {
|
||||
status: 'ALREADY_EXISTS',
|
||||
message: `Chapter ${chapterNumber} already exists`,
|
||||
@@ -378,6 +413,8 @@ export async function downloadBookChapter(params: BookDownloadParams) {
|
||||
}
|
||||
|
||||
try {
|
||||
updateDownloadProgress(downloadId, { status: 'downloading' });
|
||||
|
||||
let outputPath: string;
|
||||
let fileId: string;
|
||||
|
||||
@@ -388,7 +425,8 @@ export async function downloadBookChapter(params: BookDownloadParams) {
|
||||
const zip = new AdmZip();
|
||||
const sortedImages = images!.sort((a, b) => a.index - b.index);
|
||||
|
||||
for (const img of sortedImages) {
|
||||
for (let i = 0; i < sortedImages.length; i++) {
|
||||
const img = sortedImages[i];
|
||||
const res = await fetch(img.url);
|
||||
if (!res.ok) throw new Error(`HTTP_${res.status}`);
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
@@ -396,6 +434,10 @@ export async function downloadBookChapter(params: BookDownloadParams) {
|
||||
const ext = path.extname(new URL(img.url).pathname) || '.jpg';
|
||||
const filename = `${img.index.toString().padStart(4, '0')}${ext}`;
|
||||
zip.addFile(filename, buf);
|
||||
|
||||
updateDownloadProgress(downloadId, {
|
||||
progress: Math.floor((i / sortedImages.length) * 100)
|
||||
});
|
||||
}
|
||||
|
||||
zip.writeZip(outputPath);
|
||||
@@ -405,7 +447,6 @@ export async function downloadBookChapter(params: BookDownloadParams) {
|
||||
outputPath = path.join(entry.path, chapterName);
|
||||
|
||||
const zip = new AdmZip();
|
||||
|
||||
zip.addFile('mimetype', Buffer.from('application/epub+zip'), '', 0);
|
||||
|
||||
const containerXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
@@ -443,7 +484,6 @@ ${content}
|
||||
</body>
|
||||
</html>`;
|
||||
zip.addFile('OEBPS/chapter.xhtml', Buffer.from(chapterXhtml));
|
||||
|
||||
zip.writeZip(outputPath);
|
||||
}
|
||||
|
||||
@@ -461,8 +501,17 @@ ${content}
|
||||
'local_library'
|
||||
);
|
||||
|
||||
updateDownloadProgress(downloadId, {
|
||||
status: 'completed',
|
||||
progress: 100,
|
||||
completedAt: Date.now()
|
||||
});
|
||||
|
||||
setTimeout(() => activeDownloads.delete(downloadId), 30000);
|
||||
|
||||
return {
|
||||
status: 'SUCCESS',
|
||||
download_id: downloadId,
|
||||
entry_id: entry.id,
|
||||
file_id: fileId,
|
||||
chapter: chapterNumber,
|
||||
@@ -471,6 +520,13 @@ ${content}
|
||||
};
|
||||
|
||||
} catch (error: any) {
|
||||
updateDownloadProgress(downloadId, {
|
||||
status: 'failed',
|
||||
error: error.message
|
||||
});
|
||||
|
||||
setTimeout(() => activeDownloads.delete(downloadId), 60000);
|
||||
|
||||
const err = new Error('DOWNLOAD_FAILED');
|
||||
(err as any).details = error.message;
|
||||
throw err;
|
||||
|
||||
@@ -2,6 +2,7 @@ import {FastifyReply, FastifyRequest} from 'fastify';
|
||||
import fs from 'fs';
|
||||
import * as service from './local.service';
|
||||
import * as downloadService from './download.service';
|
||||
import * as streamingService from './streaming.service';
|
||||
|
||||
type ScanQuery = {
|
||||
mode?: 'full' | 'incremental';
|
||||
@@ -21,7 +22,7 @@ type DownloadAnimeBody =
|
||||
| {
|
||||
anilist_id: number;
|
||||
episode_number: number;
|
||||
stream_url: string; // media playlist FINAL
|
||||
stream_url: string;
|
||||
is_master?: false;
|
||||
subtitles?: {
|
||||
language: string;
|
||||
@@ -36,28 +37,24 @@ type DownloadAnimeBody =
|
||||
| {
|
||||
anilist_id: number;
|
||||
episode_number: number;
|
||||
stream_url: string; // master.m3u8
|
||||
stream_url: string;
|
||||
is_master: true;
|
||||
|
||||
variant: {
|
||||
resolution: string;
|
||||
bandwidth?: number;
|
||||
codecs?: string;
|
||||
playlist_url: string;
|
||||
};
|
||||
|
||||
audio?: {
|
||||
group?: string;
|
||||
language?: string;
|
||||
name?: string;
|
||||
playlist_url: string;
|
||||
}[];
|
||||
|
||||
subtitles?: {
|
||||
language: string;
|
||||
url: string;
|
||||
}[];
|
||||
|
||||
chapters?: {
|
||||
title: string;
|
||||
start_time: number;
|
||||
@@ -266,7 +263,6 @@ export async function downloadAnime(request: FastifyRequest<{ Body: DownloadAnim
|
||||
|
||||
const clientHeaders = (request.body as any).headers || {};
|
||||
|
||||
// Validación básica
|
||||
if (!anilist_id || !episode_number || !stream_url) {
|
||||
return reply.status(400).send({
|
||||
error: 'MISSING_REQUIRED_FIELDS',
|
||||
@@ -274,17 +270,14 @@ export async function downloadAnime(request: FastifyRequest<{ Body: DownloadAnim
|
||||
});
|
||||
}
|
||||
|
||||
// Proxy del stream URL principal
|
||||
const proxyUrl = buildProxyUrl(stream_url, clientHeaders);
|
||||
console.log('Stream URL:', proxyUrl);
|
||||
|
||||
// Proxy de subtítulos
|
||||
const proxiedSubs = subtitles?.map(sub => ({
|
||||
...sub,
|
||||
url: buildProxyUrl(sub.url, clientHeaders)
|
||||
}));
|
||||
|
||||
// Preparar parámetros base
|
||||
const downloadParams: any = {
|
||||
anilistId: anilist_id,
|
||||
episodeNumber: episode_number,
|
||||
@@ -293,7 +286,6 @@ export async function downloadAnime(request: FastifyRequest<{ Body: DownloadAnim
|
||||
chapters
|
||||
};
|
||||
|
||||
// Si es master playlist, agregar campos adicionales
|
||||
if (is_master === true) {
|
||||
const { variant, audio } = request.body as any;
|
||||
|
||||
@@ -305,14 +297,11 @@ export async function downloadAnime(request: FastifyRequest<{ Body: DownloadAnim
|
||||
}
|
||||
|
||||
downloadParams.is_master = true;
|
||||
|
||||
// Proxy del variant playlist
|
||||
downloadParams.variant = {
|
||||
...variant,
|
||||
playlist_url: buildProxyUrl(variant.playlist_url, clientHeaders)
|
||||
};
|
||||
|
||||
// Proxy de audio tracks si existen
|
||||
if (audio && audio.length > 0) {
|
||||
downloadParams.audio = audio.map((a: any) => ({
|
||||
...a,
|
||||
@@ -409,3 +398,115 @@ export async function downloadBook(request: FastifyRequest<{ Body: DownloadBookB
|
||||
return reply.status(500).send({ error: 'FAILED_TO_DOWNLOAD_BOOK' });
|
||||
}
|
||||
}
|
||||
|
||||
// NUEVO: Estado de descargas
|
||||
export async function getDownloadStatus(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const downloads = downloadService.getActiveDownloads();
|
||||
const streams = streamingService.getActiveStreamsStatus();
|
||||
|
||||
return {
|
||||
downloads: {
|
||||
total: downloads.length,
|
||||
active: downloads.filter(d => d.status === 'downloading').length,
|
||||
completed: downloads.filter(d => d.status === 'completed').length,
|
||||
failed: downloads.filter(d => d.status === 'failed').length,
|
||||
list: downloads
|
||||
},
|
||||
streams: {
|
||||
total: streams.length,
|
||||
active: streams.filter(s => !s.isComplete).length,
|
||||
completed: streams.filter(s => s.isComplete).length,
|
||||
list: streams
|
||||
}
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('Error getting download status:', err);
|
||||
return reply.status(500).send({ error: 'FAILED_TO_GET_DOWNLOAD_STATUS' });
|
||||
}
|
||||
}
|
||||
|
||||
// NUEVO: Streaming HLS para anime local
|
||||
export async function getAnimeStreamManifest(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const { type, id, unit } = request.params as any;
|
||||
|
||||
if (type !== 'anime') {
|
||||
return reply.status(400).send({ error: 'ONLY_ANIME_SUPPORTED' });
|
||||
}
|
||||
|
||||
const fileInfo = await service.getFileForStreaming(id, unit);
|
||||
|
||||
if (!fileInfo) {
|
||||
return reply.status(404).send({ error: 'FILE_NOT_FOUND' });
|
||||
}
|
||||
|
||||
const manifest = await streamingService.getStreamingManifest(fileInfo.filePath);
|
||||
|
||||
if (!manifest) {
|
||||
return reply.status(500).send({ error: 'FAILED_TO_GENERATE_MANIFEST' });
|
||||
}
|
||||
|
||||
return manifest;
|
||||
} catch (err: any) {
|
||||
console.error('Error getting stream manifest:', err);
|
||||
return reply.status(500).send({ error: 'FAILED_TO_GET_STREAM_MANIFEST' });
|
||||
}
|
||||
}
|
||||
|
||||
// NUEVO: Servir archivos HLS
|
||||
export async function serveHLSFile(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const { hash, filename } = request.params as any;
|
||||
|
||||
const file = await streamingService.getHLSFile(hash, filename);
|
||||
|
||||
if (!file) {
|
||||
return reply.status(404).send({ error: 'FILE_NOT_FOUND' });
|
||||
}
|
||||
|
||||
const contentType = filename.endsWith('.m3u8')
|
||||
? 'application/vnd.apple.mpegurl'
|
||||
: filename.endsWith('.vtt')
|
||||
? 'text/vtt'
|
||||
: 'video/mp2t';
|
||||
|
||||
reply
|
||||
.header('Content-Type', contentType)
|
||||
.header('Content-Length', file.stat.size)
|
||||
.header('Access-Control-Allow-Origin', '*');
|
||||
|
||||
return fs.createReadStream(file.path);
|
||||
} catch (err) {
|
||||
console.error('Error serving HLS file:', err);
|
||||
return reply.status(500).send({ error: 'FAILED_TO_SERVE_HLS_FILE' });
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSubtitle(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const { id, unit, track } = request.params as any;
|
||||
|
||||
// Validar que el track sea un número
|
||||
const trackIndex = parseInt(track, 10);
|
||||
if (isNaN(trackIndex)) {
|
||||
return reply.status(400).send({ error: 'INVALID_TRACK_INDEX' });
|
||||
}
|
||||
|
||||
const subtitleStream = await service.extractSubtitleTrack(id, unit, trackIndex);
|
||||
|
||||
if (!subtitleStream) {
|
||||
return reply.status(404).send({ error: 'FILE_NOT_FOUND' });
|
||||
}
|
||||
|
||||
// Cabecera esencial para que el navegador entienda que son subtítulos
|
||||
reply.header('Content-Type', 'text/vtt');
|
||||
reply.header('Cache-Control', 'public, max-age=86400'); // Cachear por 1 día si quieres
|
||||
|
||||
return subtitleStream;
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error serving subtitles:', err);
|
||||
return reply.status(500).send({ error: 'FAILED_TO_SERVE_SUBTITLES' });
|
||||
}
|
||||
}
|
||||
@@ -5,13 +5,19 @@ async function localRoutes(fastify: FastifyInstance) {
|
||||
fastify.post('/library/scan', controller.scanLibrary);
|
||||
fastify.get('/library/:type', controller.listEntries);
|
||||
fastify.get('/library/:type/:id', controller.getEntry);
|
||||
|
||||
// Streaming básico (legacy)
|
||||
fastify.get('/library/stream/:type/:id/:unit', controller.streamUnit);
|
||||
fastify.get('/library/stream/:type/:id/:unit/subs/:track', controller.getSubtitle);
|
||||
fastify.post('/library/:type/:id/match', controller.matchEntry);
|
||||
fastify.get('/library/:id/units', controller.getUnits);
|
||||
fastify.get('/library/:unitId/manifest', controller.getManifest);
|
||||
fastify.get('/library/:unitId/resource/:resId', controller.getPage);
|
||||
fastify.post('/library/download/anime', controller.downloadAnime);
|
||||
fastify.post('/library/download/book', controller.downloadBook);
|
||||
fastify.get('/library/downloads/status', controller.getDownloadStatus);
|
||||
fastify.get('/library/stream/:type/:id/:unit/manifest', controller.getAnimeStreamManifest);
|
||||
fastify.get('/library/hls/:hash/:filename', controller.serveHLSFile);
|
||||
}
|
||||
|
||||
export default localRoutes;
|
||||
@@ -7,6 +7,7 @@ import path from "path";
|
||||
import { getAnimeById, searchAnimeLocal } from "../anime/anime.service";
|
||||
import { getBookById, searchBooksAniList } from "../books/books.service";
|
||||
import AdmZip from 'adm-zip';
|
||||
import { getStreamHash, getSubtitleFileStream } from './streaming.service';
|
||||
|
||||
const MANGA_IMAGE_EXTS = ['.jpg', '.jpeg', '.png', '.webp'];
|
||||
const MANGA_ARCHIVES = ['.cbz', '.cbr', '.zip'];
|
||||
@@ -510,3 +511,26 @@ function parseEpubToHtml(filePath: string) {
|
||||
if (!entry) throw new Error('CHAPTER_NOT_FOUND');
|
||||
return entry.getData().toString('utf8');
|
||||
}
|
||||
|
||||
export async function extractSubtitleTrack(id: string, unit: string, trackIndex: number) {
|
||||
// 1. Obtenemos la ruta real del archivo usando tu función existente
|
||||
const fileInfo = await getFileForStreaming(id, unit);
|
||||
|
||||
if (!fileInfo || !fs.existsSync(fileInfo.filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. Generamos el hash (ID único del stream) igual que lo hace el streaming
|
||||
const hash = getStreamHash(fileInfo.filePath);
|
||||
|
||||
// 3. Pedimos al servicio de streaming que busque el archivo en la carpeta temp
|
||||
const stream = getSubtitleFileStream(hash, trackIndex);
|
||||
|
||||
if (stream) {
|
||||
console.log(`✅ Subtítulo recuperado de caché (Hash: ${hash}, Track: ${trackIndex})`);
|
||||
} else {
|
||||
console.error(`❌ Subtítulo no encontrado en caché para ${hash}`);
|
||||
}
|
||||
|
||||
return stream;
|
||||
}
|
||||
586
docker/src/api/local/streaming.service.ts
Normal file
586
docker/src/api/local/streaming.service.ts
Normal 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
|
||||
}));
|
||||
}
|
||||
@@ -882,7 +882,6 @@ const AnimePlayer = (function() {
|
||||
if (els.video) els.video.playbackRate = parseFloat(value);
|
||||
}
|
||||
|
||||
// Volvemos al menú principal para confirmar visualmente (opcional, estilo YouTube)
|
||||
_settingsView = 'main';
|
||||
buildSettingsPanel();
|
||||
}
|
||||
@@ -904,31 +903,41 @@ const AnimePlayer = (function() {
|
||||
subtitleRenderer = null;
|
||||
}
|
||||
|
||||
// Find ASS subtitle
|
||||
const assSubtitle = _currentSubtitles.find(sub =>
|
||||
(sub.src && sub.src.endsWith('.ass')) ||
|
||||
(sub.label && sub.label.toLowerCase().includes('ass'))
|
||||
);
|
||||
const activeIdx = getActiveSubtitleIndex();
|
||||
if (activeIdx === -1) return;
|
||||
|
||||
if (!assSubtitle) {
|
||||
console.log('No ASS subtitles found in current list');
|
||||
return;
|
||||
}
|
||||
const currentSub = _currentSubtitles[activeIdx];
|
||||
if (!currentSub) return;
|
||||
|
||||
try {
|
||||
console.log('Initializing JASSUB for:', assSubtitle.label);
|
||||
const src = currentSub.src.toLowerCase();
|
||||
const label = (currentSub.label || '').toLowerCase();
|
||||
|
||||
// Check if JASSUB global is available
|
||||
if (window.SubtitleRenderer && typeof window.JASSUB !== 'undefined') {
|
||||
// --- CAMBIO AQUÍ: Pasamos els.subtitlesCanvas ---
|
||||
subtitleRenderer = new SubtitleRenderer(els.video, els.subtitlesCanvas);
|
||||
await subtitleRenderer.init(assSubtitle.src);
|
||||
} else {
|
||||
console.warn('JASSUB library not loaded.');
|
||||
// CASO 1: ASS (Usa JASSUB)
|
||||
if (src.endsWith('.ass') || label.includes('ass')) {
|
||||
try {
|
||||
console.log('Initializing JASSUB for:', currentSub.label);
|
||||
if (window.SubtitleRenderer && typeof window.JASSUB !== 'undefined') {
|
||||
subtitleRenderer = new SubtitleRenderer(els.video, els.subtitlesCanvas);
|
||||
await subtitleRenderer.init(currentSub.src);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('JASSUB setup error:', e);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Subtitle renderer setup error:', e);
|
||||
subtitleRenderer = null;
|
||||
}
|
||||
// CASO 2: SRT (Usa SimpleSubtitleRenderer)
|
||||
else if (src.endsWith('.srt') || label.includes('srt')) {
|
||||
try {
|
||||
console.log('Initializing Simple Renderer for:', currentSub.label);
|
||||
if (window.SimpleSubtitleRenderer) {
|
||||
subtitleRenderer = new SimpleSubtitleRenderer(els.video, els.subtitlesCanvas);
|
||||
await subtitleRenderer.loadSubtitles(currentSub.src);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Simple Renderer setup error:', e);
|
||||
}
|
||||
}
|
||||
else {
|
||||
console.log('Using native browser rendering for VTT');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -991,13 +1000,15 @@ const AnimePlayer = (function() {
|
||||
_rpcActive = false;
|
||||
setLoading("Checking availability...");
|
||||
|
||||
// Check local availability
|
||||
let shouldPlayLocal = false;
|
||||
try {
|
||||
const check = await fetch(`/api/library/${_animeId}/units`);
|
||||
const data = await check.json();
|
||||
const localUnit = data.units ? data.units.find(u => u.number === targetEp) : null;
|
||||
if (localUnit) shouldPlayLocal = true;
|
||||
|
||||
if (localUnit && els.extSelect.value === 'local') {
|
||||
shouldPlayLocal = true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Availability check failed:", e);
|
||||
shouldPlayLocal = (els.extSelect.value === 'local');
|
||||
@@ -1216,49 +1227,64 @@ const AnimePlayer = (function() {
|
||||
|
||||
if (currentExt === 'local') {
|
||||
try {
|
||||
const localId = await getLocalEntryId();
|
||||
const check = await fetch(`/api/library/${_animeId}/units`);
|
||||
const data = await check.json();
|
||||
const targetUnit = data.units ? data.units.find(u => u.number === parseInt(_currentEpisode)) : null;
|
||||
setLoading("Fetching Local Unit Data...");
|
||||
|
||||
// 1. Obtener las unidades locales para encontrar el ID del episodio específico
|
||||
const unitsRes = await fetch(`/api/library/${_animeId}/units`);
|
||||
if (!unitsRes.ok) throw new Error("Could not fetch local units");
|
||||
|
||||
const unitsData = await unitsRes.json();
|
||||
const targetUnit = unitsData.units ? unitsData.units.find(u => u.number === parseInt(_currentEpisode)) : null;
|
||||
|
||||
if (!targetUnit) {
|
||||
console.log(`Episode ${_currentEpisode} not found locally.`);
|
||||
const localOption = els.extSelect.querySelector('option[value="local"]');
|
||||
if (localOption) localOption.remove();
|
||||
|
||||
const fallbackSource = (_entrySource === 'local') ? 'anilist' : _entrySource;
|
||||
if (els.extSelect.querySelector(`option[value="${fallbackSource}"]`)) {
|
||||
els.extSelect.value = fallbackSource;
|
||||
} else if (els.extSelect.options.length > 0) {
|
||||
els.extSelect.selectedIndex = 0;
|
||||
}
|
||||
handleExtensionChange(true);
|
||||
return;
|
||||
throw new Error(`Episode ${_currentEpisode} not found in local library`);
|
||||
}
|
||||
|
||||
const ext = targetUnit.format || targetUnit.name.split('.').pop().toLowerCase();
|
||||
setLoading("Initializing HLS Stream...");
|
||||
|
||||
if (![''].includes(ext)) {
|
||||
setLoading(`Local files are not supported on the web player yet. Use MPV.`);
|
||||
_rawVideoData = {
|
||||
url: targetUnit.path,
|
||||
headers: {}
|
||||
};
|
||||
if (els.mpvBtn) els.mpvBtn.style.display = 'flex';
|
||||
return;
|
||||
const manifestRes = await fetch(`/api/library/stream/anime/${unitsData.entry_id}/${targetUnit.number}/manifest`);
|
||||
if (!manifestRes.ok) throw new Error("Failed to generate stream manifest");
|
||||
|
||||
const manifestData = await manifestRes.json();
|
||||
|
||||
if (manifestData.chapters && manifestData.chapters.length > 0) {
|
||||
_skipIntervals = manifestData.chapters.map(c => ({
|
||||
startTime: c.start,
|
||||
endTime: c.end,
|
||||
type: c.title.toLowerCase().includes('op') ? 'op' :
|
||||
c.title.toLowerCase().includes('ed') ? 'ed' : 'chapter'
|
||||
}));
|
||||
renderSkipMarkers(_skipIntervals);
|
||||
monitorSkipButton(_skipIntervals);
|
||||
}
|
||||
|
||||
const localUrl = `/api/library/stream/${targetUnit.id}`;
|
||||
// 4. Mapear Subtítulos WebVTT
|
||||
const subs = (manifestData.subtitles || []).map(s => ({
|
||||
label: s.title || s.language || `Track ${s.index}`,
|
||||
srclang: s.language || 'unk',
|
||||
src: s.url // URL al endpoint de conversión VTT (.vtt)
|
||||
}));
|
||||
|
||||
// 5. Guardar referencia para MPV o descargas
|
||||
_rawVideoData = {
|
||||
url: localUrl,
|
||||
url: manifestData.masterPlaylist,
|
||||
headers: {}
|
||||
};
|
||||
_currentSubtitles = [];
|
||||
|
||||
initVideoPlayer(localUrl, 'mp4');
|
||||
// 6. Cargar en el reproductor (Hls.js gestionará los audios del master.m3u8)
|
||||
initVideoPlayer(manifestData.masterPlaylist, 'm3u8', subs);
|
||||
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
console.error("Local HLS Error:", e);
|
||||
setLoading("Local Error: " + e.message);
|
||||
|
||||
// Fallback: si falla, intentar cargar desde extensión online
|
||||
const localOption = els.extSelect.querySelector('option[value="local"]');
|
||||
if (localOption) localOption.remove();
|
||||
|
||||
const fallbackSource = (_entrySource === 'local') ? 'anilist' : _entrySource;
|
||||
els.extSelect.value = fallbackSource;
|
||||
handleExtensionChange(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1530,23 +1556,19 @@ const AnimePlayer = (function() {
|
||||
|
||||
function renderSkipMarkers(intervals) {
|
||||
if (!els.progressContainer || !els.video.duration) return;
|
||||
|
||||
els.progressContainer.querySelectorAll('.skip-range, .skip-cut').forEach(e => e.remove());
|
||||
|
||||
const duration = els.video.duration;
|
||||
|
||||
intervals.forEach(skip => {
|
||||
const startPct = (skip.startTime / duration) * 100;
|
||||
const endPct = (skip.endTime / duration) * 100;
|
||||
const startPct = (skip.startTime / els.video.duration) * 100;
|
||||
const endPct = (skip.endTime / els.video.duration) * 100;
|
||||
|
||||
const range = document.createElement('div');
|
||||
range.className = `skip-range ${skip.type}`; // 'op' o 'ed'
|
||||
range.className = `skip-range ${skip.type}`;
|
||||
range.style.left = `${startPct}%`;
|
||||
range.style.width = `${endPct - startPct}%`;
|
||||
els.progressContainer.appendChild(range);
|
||||
|
||||
createCut(startPct);
|
||||
|
||||
createCut(endPct);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ const DEFAULT_CONFIG = {
|
||||
paths: {
|
||||
mpv: null,
|
||||
ffmpeg: null,
|
||||
ffprobe: null,
|
||||
cloudflared: null,
|
||||
}
|
||||
};
|
||||
@@ -28,6 +29,7 @@ export const CONFIG_SCHEMA = {
|
||||
paths: {
|
||||
mpv: { description: "Required to open anime episodes in mpv on desktop version." },
|
||||
ffmpeg: { description: "Required for downloading anime episodes." },
|
||||
ffprobe: { description: "Required for watching local anime episodes." },
|
||||
cloudflared: { description: "Required for creating pubic rooms." }
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user