437 lines
14 KiB
TypeScript
437 lines
14 KiB
TypeScript
import { getConfig as loadConfig } from '../../shared/config';
|
|
import { queryOne, queryAll, run } from '../../shared/database';
|
|
import { getAnimeById } from '../anime/anime.service';
|
|
import { getBookById } from '../books/books.service';
|
|
import crypto from 'crypto';
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
import AdmZip from 'adm-zip';
|
|
import { spawn } from 'child_process';
|
|
|
|
const FFMPEG_PATH = 'D:\\ffmpeg\\bin\\ffmpeg.exe';
|
|
|
|
type AnimeDownloadParams = {
|
|
anilistId: number;
|
|
episodeNumber: number;
|
|
streamUrl: string;
|
|
headers?: Record<string, string>;
|
|
quality?: string;
|
|
subtitles?: Array<{ language: string; url: string }>;
|
|
chapters?: Array<{ title: string; start_time: number; end_time: number }>;
|
|
};
|
|
|
|
type BookDownloadParams = {
|
|
anilistId: number;
|
|
chapterNumber: number;
|
|
format: 'manga' | 'novel';
|
|
content?: string;
|
|
images?: Array<{ index: number; url: string }>;
|
|
};
|
|
|
|
async function ensureDirectory(dirPath: string) {
|
|
if (!fs.existsSync(dirPath)) {
|
|
fs.mkdirSync(dirPath, { recursive: true });
|
|
}
|
|
}
|
|
|
|
async function downloadFile(url: string, outputPath: string): Promise<void> {
|
|
const res = await fetch(url);
|
|
if (!res.ok) throw new Error(`HTTP_${res.status}`);
|
|
|
|
await ensureDirectory(path.dirname(outputPath));
|
|
const buf = Buffer.from(await res.arrayBuffer());
|
|
fs.writeFileSync(outputPath, buf);
|
|
}
|
|
|
|
async function getOrCreateEntry(
|
|
anilistId: number,
|
|
type: 'anime' | 'manga' | 'novels'
|
|
): Promise<{ id: string; path: string; folderName: string }> {
|
|
const existing = await queryOne(
|
|
`SELECT id, path, folder_name FROM local_entries
|
|
WHERE matched_id = ? AND matched_source = 'anilist' AND type = ?`,
|
|
[anilistId, type],
|
|
'local_library'
|
|
);
|
|
|
|
if (existing) {
|
|
return {
|
|
id: existing.id,
|
|
path: existing.path,
|
|
folderName: existing.folder_name
|
|
};
|
|
}
|
|
|
|
const metadata: any = type === 'anime'
|
|
? await getAnimeById(anilistId)
|
|
: await getBookById(anilistId);
|
|
|
|
if (!metadata) {
|
|
throw new Error('METADATA_NOT_FOUND');
|
|
}
|
|
|
|
const { values } = loadConfig();
|
|
const basePath = values.library?.[type];
|
|
|
|
if (!basePath) {
|
|
throw new Error(`NO_LIBRARY_PATH_FOR_${type.toUpperCase()}`);
|
|
}
|
|
|
|
const title = metadata.title?.romaji || metadata.title?.english || `ID_${anilistId}`;
|
|
const safeName = title.replace(/[<>:"/\\|?*]/g, '_');
|
|
const folderPath = path.join(basePath, safeName);
|
|
|
|
await ensureDirectory(folderPath);
|
|
|
|
const entryId = crypto
|
|
.createHash('sha1')
|
|
.update(`anilist:${type}:${anilistId}`)
|
|
.digest('hex');
|
|
const now = Date.now();
|
|
|
|
await run(
|
|
`INSERT OR IGNORE INTO local_entries
|
|
(id, type, path, folder_name, matched_id, matched_source, last_scan)
|
|
VALUES (?, ?, ?, ?, ?, 'anilist', ?)`,
|
|
[entryId, type, folderPath, safeName, anilistId, now],
|
|
'local_library'
|
|
);
|
|
|
|
return {
|
|
id: entryId,
|
|
path: folderPath,
|
|
folderName: safeName
|
|
};
|
|
}
|
|
|
|
export async function downloadAnimeEpisode(params: AnimeDownloadParams) {
|
|
const { anilistId, episodeNumber, streamUrl, subtitles, chapters } = params;
|
|
|
|
const entry = await getOrCreateEntry(anilistId, 'anime');
|
|
|
|
const exists = await queryOne(
|
|
`SELECT id FROM local_files WHERE entry_id = ? AND unit_number = ?`,
|
|
[entry.id, episodeNumber],
|
|
'local_library'
|
|
);
|
|
if (exists) 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 {
|
|
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;
|
|
|
|
if (!variant || !variant.playlist_url) {
|
|
throw new Error('VARIANT_REQUIRED_FOR_MASTER');
|
|
}
|
|
|
|
videoInput = variant.playlist_url;
|
|
|
|
if (audios && audios.length > 0) {
|
|
audioInputs = audios.map((a: any) => a.playlist_url);
|
|
}
|
|
}
|
|
|
|
const subFiles: string[] = [];
|
|
if (subtitles?.length) {
|
|
for (let i = 0; i < subtitles.length; i++) {
|
|
const ext = subtitles[i].url.endsWith('.vtt') ? 'vtt' : 'srt';
|
|
const p = path.join(tempDir, `sub_${i}.${ext}`);
|
|
await downloadFile(subtitles[i].url, p);
|
|
subFiles.push(p);
|
|
}
|
|
}
|
|
|
|
const args = [
|
|
'-protocol_whitelist', 'file,http,https,tcp,tls,crypto',
|
|
'-allowed_extensions', 'ALL',
|
|
'-f', 'hls',
|
|
'-extension_picky', '0',
|
|
'-i', videoInput
|
|
];
|
|
|
|
audioInputs.forEach(audioUrl => {
|
|
args.push(
|
|
'-protocol_whitelist', 'file,http,https,tcp,tls,crypto',
|
|
'-allowed_extensions', 'ALL',
|
|
'-f', 'hls',
|
|
'-extension_picky', '0',
|
|
'-i', audioUrl
|
|
);
|
|
});
|
|
|
|
subFiles.forEach(f => args.push('-i', f));
|
|
|
|
let chaptersInputIndex = -1;
|
|
if (chapters?.length) {
|
|
const meta = path.join(tempDir, 'chapters.txt');
|
|
let txt = ';FFMETADATA1\n';
|
|
for (const c of chapters) {
|
|
txt += `[CHAPTER]\nTIMEBASE=1/1000\nSTART=${c.start_time * 1000 | 0}\nEND=${c.end_time * 1000 | 0}\ntitle=${c.title}\n`;
|
|
}
|
|
fs.writeFileSync(meta, txt);
|
|
args.push('-i', meta);
|
|
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;
|
|
if (audioInfo.language) {
|
|
args.push(`-metadata:s:a:${audioStreamIndex}`, `language=${audioInfo.language}`);
|
|
}
|
|
if (audioInfo.name) {
|
|
args.push(`-metadata:s:a:${audioStreamIndex}`, `title=${audioInfo.name}`);
|
|
}
|
|
}
|
|
});
|
|
} else {
|
|
|
|
args.push('-map', '0:a:0?');
|
|
}
|
|
|
|
const subtitleStartIndex = 1 + audioInputs.length;
|
|
subFiles.forEach((_, i) => {
|
|
args.push('-map', `${subtitleStartIndex + i}:0`);
|
|
args.push(`-metadata:s:s:${i}`, `language=${subtitles![i].language}`);
|
|
});
|
|
|
|
if (chaptersInputIndex >= 0) {
|
|
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);
|
|
|
|
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();
|
|
|
|
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);
|
|
}
|
|
});
|
|
|
|
ff.on('error', (error) => {
|
|
console.error('❌ Error al iniciar FFmpeg:', 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}`));
|
|
}
|
|
});
|
|
});
|
|
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
|
|
const fileId = crypto.randomUUID();
|
|
await run(
|
|
`INSERT INTO local_files (id, entry_id, file_path, unit_number)
|
|
VALUES (?, ?, ?, ?)`,
|
|
[fileId, entry.id, outputPath, episodeNumber],
|
|
'local_library'
|
|
);
|
|
|
|
await run(
|
|
`UPDATE local_entries SET last_scan = ? WHERE id = ?`,
|
|
[Date.now(), entry.id],
|
|
'local_library'
|
|
);
|
|
|
|
return {
|
|
status: 'SUCCESS',
|
|
entry_id: entry.id,
|
|
file_id: fileId,
|
|
episode: episodeNumber,
|
|
path: outputPath
|
|
};
|
|
|
|
} catch (e: any) {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
if (fs.existsSync(outputPath)) fs.unlinkSync(outputPath);
|
|
const err = new Error('DOWNLOAD_FAILED');
|
|
(err as any).details = e.message;
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
export async function downloadBookChapter(params: BookDownloadParams) {
|
|
const { anilistId, chapterNumber, format, content, images } = params;
|
|
|
|
const type = format === 'manga' ? 'manga' : 'novels';
|
|
const entry = await getOrCreateEntry(anilistId, type);
|
|
|
|
const existingFile = await queryOne(
|
|
`SELECT id FROM local_files WHERE entry_id = ? AND unit_number = ?`,
|
|
[entry.id, chapterNumber],
|
|
'local_library'
|
|
);
|
|
|
|
if (existingFile) {
|
|
return {
|
|
status: 'ALREADY_EXISTS',
|
|
message: `Chapter ${chapterNumber} already exists`,
|
|
entry_id: entry.id,
|
|
chapter: chapterNumber
|
|
};
|
|
}
|
|
|
|
try {
|
|
let outputPath: string;
|
|
let fileId: string;
|
|
|
|
if (format === 'manga') {
|
|
const chapterName = `Chapter_${chapterNumber.toString().padStart(3, '0')}.cbz`;
|
|
outputPath = path.join(entry.path, chapterName);
|
|
|
|
const zip = new AdmZip();
|
|
const sortedImages = images!.sort((a, b) => a.index - b.index);
|
|
|
|
for (const img of sortedImages) {
|
|
const res = await fetch(img.url);
|
|
if (!res.ok) throw new Error(`HTTP_${res.status}`);
|
|
const buf = Buffer.from(await res.arrayBuffer());
|
|
|
|
const ext = path.extname(new URL(img.url).pathname) || '.jpg';
|
|
const filename = `${img.index.toString().padStart(4, '0')}${ext}`;
|
|
zip.addFile(filename, buf);
|
|
}
|
|
|
|
zip.writeZip(outputPath);
|
|
|
|
} else {
|
|
const chapterName = `Chapter_${chapterNumber.toString().padStart(3, '0')}.epub`;
|
|
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"?>
|
|
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
|
|
<rootfiles>
|
|
<rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
|
|
</rootfiles>
|
|
</container>`;
|
|
zip.addFile('META-INF/container.xml', Buffer.from(containerXml));
|
|
|
|
const contentOpf = `<?xml version="1.0" encoding="UTF-8"?>
|
|
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="bookid">
|
|
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
|
<dc:title>Chapter ${chapterNumber}</dc:title>
|
|
<dc:identifier id="bookid">chapter-${anilistId}-${chapterNumber}</dc:identifier>
|
|
<dc:language>en</dc:language>
|
|
</metadata>
|
|
<manifest>
|
|
<item id="chapter" href="chapter.xhtml" media-type="application/xhtml+xml"/>
|
|
</manifest>
|
|
<spine>
|
|
<itemref idref="chapter"/>
|
|
</spine>
|
|
</package>`;
|
|
zip.addFile('OEBPS/content.opf', Buffer.from(contentOpf));
|
|
|
|
const chapterXhtml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
<!DOCTYPE html>
|
|
<html xmlns="http://www.w3.org/1999/xhtml">
|
|
<head>
|
|
<title>Chapter ${chapterNumber}</title>
|
|
</head>
|
|
<body>
|
|
${content}
|
|
</body>
|
|
</html>`;
|
|
zip.addFile('OEBPS/chapter.xhtml', Buffer.from(chapterXhtml));
|
|
|
|
zip.writeZip(outputPath);
|
|
}
|
|
|
|
fileId = crypto.randomUUID();
|
|
await run(
|
|
`INSERT INTO local_files (id, entry_id, file_path, unit_number)
|
|
VALUES (?, ?, ?, ?)`,
|
|
[fileId, entry.id, outputPath, chapterNumber],
|
|
'local_library'
|
|
);
|
|
|
|
await run(
|
|
`UPDATE local_entries SET last_scan = ? WHERE id = ?`,
|
|
[Date.now(), entry.id],
|
|
'local_library'
|
|
);
|
|
|
|
return {
|
|
status: 'SUCCESS',
|
|
entry_id: entry.id,
|
|
file_id: fileId,
|
|
chapter: chapterNumber,
|
|
format,
|
|
path: outputPath
|
|
};
|
|
|
|
} catch (error: any) {
|
|
const err = new Error('DOWNLOAD_FAILED');
|
|
(err as any).details = error.message;
|
|
throw err;
|
|
}
|
|
} |