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