download anime episodes choosing quality, audio, subs...
This commit is contained in:
@@ -1,22 +1,20 @@
|
||||
import { getConfig as loadConfig } from '../../shared/config.js';
|
||||
import { queryOne, queryAll, run } from '../../shared/database.js';
|
||||
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 { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import AdmZip from 'adm-zip';
|
||||
import { spawn } from 'child_process';
|
||||
|
||||
const execPromise = promisify(exec);
|
||||
|
||||
const FFMPEG_PATH = 'D:\\ffmpeg\\bin\\ffmpeg.exe'; // Hardcoded como pediste
|
||||
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 }>;
|
||||
@@ -50,7 +48,7 @@ async function getOrCreateEntry(
|
||||
type: 'anime' | 'manga' | 'novels'
|
||||
): Promise<{ id: string; path: string; folderName: string }> {
|
||||
const existing = await queryOne(
|
||||
`SELECT id, path, folder_name FROM local_entries
|
||||
`SELECT id, path, folder_name FROM local_entries
|
||||
WHERE matched_id = ? AND matched_source = 'anilist' AND type = ?`,
|
||||
[anilistId, type],
|
||||
'local_library'
|
||||
@@ -85,12 +83,16 @@ async function getOrCreateEntry(
|
||||
|
||||
await ensureDirectory(folderPath);
|
||||
|
||||
const entryId = crypto.createHash('sha1').update(folderPath).digest('hex');
|
||||
const entryId = crypto
|
||||
.createHash('sha1')
|
||||
.update(`anilist:${type}:${anilistId}`)
|
||||
.digest('hex');
|
||||
const now = Date.now();
|
||||
|
||||
await run(
|
||||
`INSERT INTO local_entries (id, type, path, folder_name, matched_id, matched_source, last_scan)
|
||||
VALUES (?, ?, ?, ?, ?, 'anilist', ?)`,
|
||||
`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'
|
||||
);
|
||||
@@ -103,112 +105,182 @@ async function getOrCreateEntry(
|
||||
}
|
||||
|
||||
export async function downloadAnimeEpisode(params: AnimeDownloadParams) {
|
||||
const { anilistId, episodeNumber, streamUrl, quality, subtitles, chapters } = params;
|
||||
const { anilistId, episodeNumber, streamUrl, subtitles, chapters } = params;
|
||||
|
||||
const entry = await getOrCreateEntry(anilistId, 'anime');
|
||||
|
||||
const existingFile = await queryOne(
|
||||
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 };
|
||||
|
||||
if (existingFile) {
|
||||
return {
|
||||
status: 'ALREADY_EXISTS',
|
||||
message: `Episode ${episodeNumber} already exists`,
|
||||
entry_id: entry.id,
|
||||
episode: episodeNumber
|
||||
};
|
||||
}
|
||||
|
||||
const outputFileName = `Episode_${episodeNumber.toString().padStart(2, '0')}.mkv`;
|
||||
const outputPath = path.join(entry.path, outputFileName);
|
||||
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 inputArgs: string[] = [];
|
||||
let videoInput = streamUrl;
|
||||
let audioInputs: string[] = [];
|
||||
|
||||
if (streamUrl.includes('.m3u8')) {
|
||||
if (quality) {
|
||||
const tempM3u8 = path.join(tempDir, 'stream.m3u8');
|
||||
await downloadFile(streamUrl, tempM3u8);
|
||||
const content = fs.readFileSync(tempM3u8, 'utf8');
|
||||
const isMaster = (params as any).is_master === true;
|
||||
|
||||
const qualities = content.match(/RESOLUTION=\d+x(\d+)/g) || [];
|
||||
const targetHeight = quality.replace('p', '');
|
||||
const targetLine = content.split('\n').find(line =>
|
||||
line.includes(`RESOLUTION=`) && line.includes(`x${targetHeight}`)
|
||||
);
|
||||
if (isMaster) {
|
||||
|
||||
if (targetLine) {
|
||||
const nextLine = content.split('\n')[content.split('\n').indexOf(targetLine) + 1];
|
||||
if (nextLine && !nextLine.startsWith('#')) {
|
||||
const baseUrl = streamUrl.substring(0, streamUrl.lastIndexOf('/') + 1);
|
||||
videoInput = nextLine.startsWith('http') ? nextLine : baseUrl + nextLine;
|
||||
}
|
||||
}
|
||||
const variant = (params as any).variant;
|
||||
const audios = (params as any).audio;
|
||||
|
||||
fs.unlinkSync(tempM3u8);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
inputArgs = ['-i', videoInput];
|
||||
|
||||
const subtitleFiles: string[] = [];
|
||||
if (subtitles && subtitles.length > 0) {
|
||||
const subFiles: string[] = [];
|
||||
if (subtitles?.length) {
|
||||
for (let i = 0; i < subtitles.length; i++) {
|
||||
const sub = subtitles[i];
|
||||
const subPath = path.join(tempDir, `subtitle_${i}.${sub.url.endsWith('.vtt') ? 'vtt' : 'srt'}`);
|
||||
await downloadFile(sub.url, subPath);
|
||||
subtitleFiles.push(subPath);
|
||||
inputArgs.push('-i', subPath);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
let ffmpegArgs = [
|
||||
...inputArgs,
|
||||
'-map', '0:v',
|
||||
'-map', '0:a',
|
||||
'-c:v', 'copy',
|
||||
'-c:a', 'copy'
|
||||
const args = [
|
||||
'-protocol_whitelist', 'file,http,https,tcp,tls,crypto',
|
||||
'-allowed_extensions', 'ALL',
|
||||
'-f', 'hls',
|
||||
'-extension_picky', '0',
|
||||
'-i', videoInput
|
||||
];
|
||||
|
||||
for (let i = 0; i < subtitleFiles.length; i++) {
|
||||
ffmpegArgs.push('-map', `${i + 1}:s`);
|
||||
ffmpegArgs.push(`-metadata:s:s:${i}`, `language=${subtitles![i].language}`);
|
||||
}
|
||||
audioInputs.forEach(audioUrl => {
|
||||
args.push(
|
||||
'-protocol_whitelist', 'file,http,https,tcp,tls,crypto',
|
||||
'-allowed_extensions', 'ALL',
|
||||
'-f', 'hls',
|
||||
'-extension_picky', '0',
|
||||
'-i', audioUrl
|
||||
);
|
||||
});
|
||||
|
||||
if (subtitleFiles.length > 0) {
|
||||
ffmpegArgs.push('-c:s', 'copy');
|
||||
}
|
||||
subFiles.forEach(f => args.push('-i', f));
|
||||
|
||||
if (chapters && chapters.length > 0) {
|
||||
const metadataFile = path.join(tempDir, 'chapters.txt');
|
||||
let chapterContent = ';FFMETADATA1\n';
|
||||
|
||||
for (const chapter of chapters) {
|
||||
const startMs = Math.floor(chapter.start_time * 1000);
|
||||
const endMs = Math.floor(chapter.end_time * 1000);
|
||||
|
||||
chapterContent += '[CHAPTER]\n';
|
||||
chapterContent += `TIMEBASE=1/1000\n`;
|
||||
chapterContent += `START=${startMs}\n`;
|
||||
chapterContent += `END=${endMs}\n`;
|
||||
chapterContent += `title=${chapter.title}\n`;
|
||||
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(metadataFile, chapterContent);
|
||||
ffmpegArgs.push('-i', metadataFile);
|
||||
ffmpegArgs.push('-map_metadata', `${inputArgs.length / 2}`);
|
||||
fs.writeFileSync(meta, txt);
|
||||
args.push('-i', meta);
|
||||
chaptersInputIndex = 1 + audioInputs.length + subFiles.length;
|
||||
}
|
||||
|
||||
ffmpegArgs.push(outputPath);
|
||||
args.push('-map', '0:v:0');
|
||||
|
||||
const command = `${FFMPEG_PATH} ${ffmpegArgs.join(' ')}`;
|
||||
await execPromise(command, { maxBuffer: 1024 * 1024 * 100 });
|
||||
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 });
|
||||
|
||||
@@ -234,14 +306,11 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) {
|
||||
path: outputPath
|
||||
};
|
||||
|
||||
} catch (error: any) {
|
||||
} catch (e: any) {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
if (fs.existsSync(outputPath)) {
|
||||
fs.unlinkSync(outputPath);
|
||||
}
|
||||
|
||||
if (fs.existsSync(outputPath)) fs.unlinkSync(outputPath);
|
||||
const err = new Error('DOWNLOAD_FAILED');
|
||||
(err as any).details = error.message;
|
||||
(err as any).details = e.message;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,20 +17,52 @@ type MatchBody = {
|
||||
matched_id: number | null;
|
||||
};
|
||||
|
||||
type DownloadAnimeBody = {
|
||||
type DownloadAnimeBody =
|
||||
| {
|
||||
anilist_id: number;
|
||||
episode_number: number;
|
||||
stream_url: string;
|
||||
quality?: string;
|
||||
subtitles?: Array<{
|
||||
stream_url: string; // media playlist FINAL
|
||||
is_master?: false;
|
||||
subtitles?: {
|
||||
language: string;
|
||||
url: string;
|
||||
}>;
|
||||
chapters?: Array<{
|
||||
}[];
|
||||
chapters?: {
|
||||
title: string;
|
||||
start_time: number;
|
||||
end_time: number;
|
||||
}>;
|
||||
}[];
|
||||
}
|
||||
| {
|
||||
anilist_id: number;
|
||||
episode_number: number;
|
||||
stream_url: string; // master.m3u8
|
||||
is_master: true;
|
||||
|
||||
variant: {
|
||||
resolution: string;
|
||||
bandwidth?: number;
|
||||
codecs?: string;
|
||||
playlist_url: string;
|
||||
};
|
||||
|
||||
audio?: {
|
||||
group?: string;
|
||||
language?: string;
|
||||
name?: string;
|
||||
playlist_url: string;
|
||||
}[];
|
||||
|
||||
subtitles?: {
|
||||
language: string;
|
||||
url: string;
|
||||
}[];
|
||||
|
||||
chapters?: {
|
||||
title: string;
|
||||
start_time: number;
|
||||
end_time: number;
|
||||
}[];
|
||||
};
|
||||
|
||||
type DownloadBookBody = {
|
||||
@@ -212,17 +244,30 @@ export async function getPage(request: FastifyRequest, reply: FastifyReply) {
|
||||
return reply.status(400).send();
|
||||
}
|
||||
|
||||
function buildProxyUrl(rawUrl: string, headers: Record<string, string>) {
|
||||
const params = new URLSearchParams({ url: rawUrl });
|
||||
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
params.set(key.toLowerCase(), value);
|
||||
}
|
||||
|
||||
return `http://localhost:54322/api/proxy?${params.toString()}`;
|
||||
}
|
||||
|
||||
export async function downloadAnime(request: FastifyRequest<{ Body: DownloadAnimeBody }>, reply: FastifyReply) {
|
||||
try {
|
||||
const {
|
||||
anilist_id,
|
||||
episode_number,
|
||||
stream_url,
|
||||
quality,
|
||||
is_master,
|
||||
subtitles,
|
||||
chapters
|
||||
} = request.body;
|
||||
|
||||
const clientHeaders = (request.body as any).headers || {};
|
||||
|
||||
// Validación básica
|
||||
if (!anilist_id || !episode_number || !stream_url) {
|
||||
return reply.status(400).send({
|
||||
error: 'MISSING_REQUIRED_FIELDS',
|
||||
@@ -230,14 +275,58 @@ export async function downloadAnime(request: FastifyRequest<{ Body: DownloadAnim
|
||||
});
|
||||
}
|
||||
|
||||
const result = await downloadService.downloadAnimeEpisode({
|
||||
// Proxy del stream URL principal
|
||||
const proxyUrl = buildProxyUrl(stream_url, clientHeaders);
|
||||
console.log('Stream URL:', proxyUrl);
|
||||
|
||||
// Proxy de subtítulos
|
||||
const proxiedSubs = subtitles?.map(sub => ({
|
||||
...sub,
|
||||
url: buildProxyUrl(sub.url, clientHeaders)
|
||||
}));
|
||||
|
||||
// Preparar parámetros base
|
||||
const downloadParams: any = {
|
||||
anilistId: anilist_id,
|
||||
episodeNumber: episode_number,
|
||||
streamUrl: stream_url,
|
||||
quality,
|
||||
subtitles,
|
||||
streamUrl: proxyUrl,
|
||||
subtitles: proxiedSubs,
|
||||
chapters
|
||||
});
|
||||
};
|
||||
|
||||
// Si es master playlist, agregar campos adicionales
|
||||
if (is_master === true) {
|
||||
const { variant, audio } = request.body as any;
|
||||
|
||||
if (!variant || !variant.playlist_url) {
|
||||
return reply.status(400).send({
|
||||
error: 'MISSING_VARIANT',
|
||||
message: 'variant with playlist_url is required when is_master is true'
|
||||
});
|
||||
}
|
||||
|
||||
downloadParams.is_master = true;
|
||||
|
||||
// Proxy del variant playlist
|
||||
downloadParams.variant = {
|
||||
...variant,
|
||||
playlist_url: buildProxyUrl(variant.playlist_url, clientHeaders)
|
||||
};
|
||||
|
||||
// Proxy de audio tracks si existen
|
||||
if (audio && audio.length > 0) {
|
||||
downloadParams.audio = audio.map((a: any) => ({
|
||||
...a,
|
||||
playlist_url: buildProxyUrl(a.playlist_url, clientHeaders)
|
||||
}));
|
||||
}
|
||||
|
||||
console.log('Master playlist detected');
|
||||
console.log('Variant:', downloadParams.variant.resolution);
|
||||
console.log('Audio tracks:', downloadParams.audio?.length || 0);
|
||||
}
|
||||
|
||||
const result = await downloadService.downloadAnimeEpisode(downloadParams);
|
||||
|
||||
if (result.status === 'ALREADY_EXISTS') {
|
||||
return reply.status(409).send(result);
|
||||
@@ -251,6 +340,10 @@ export async function downloadAnime(request: FastifyRequest<{ Body: DownloadAnim
|
||||
return reply.status(404).send({ error: 'ANIME_NOT_FOUND_IN_ANILIST' });
|
||||
}
|
||||
|
||||
if (err.message === 'VARIANT_REQUIRED_FOR_MASTER') {
|
||||
return reply.status(400).send({ error: 'VARIANT_REQUIRED_FOR_MASTER' });
|
||||
}
|
||||
|
||||
if (err.message === 'DOWNLOAD_FAILED') {
|
||||
return reply.status(500).send({ error: 'DOWNLOAD_FAILED', details: err.details });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user