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