stream local files to player

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

View File

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