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 { getConfig as loadConfig } from '../../shared/config';
|
||||||
import { queryOne, queryAll, run } from '../../shared/database.js';
|
import { queryOne, queryAll, run } from '../../shared/database';
|
||||||
import { getAnimeById } from '../anime/anime.service';
|
import { getAnimeById } from '../anime/anime.service';
|
||||||
import { getBookById } from '../books/books.service';
|
import { getBookById } from '../books/books.service';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { exec } from 'child_process';
|
|
||||||
import { promisify } from 'util';
|
|
||||||
import AdmZip from 'adm-zip';
|
import AdmZip from 'adm-zip';
|
||||||
|
import { spawn } from 'child_process';
|
||||||
|
|
||||||
const execPromise = promisify(exec);
|
const FFMPEG_PATH = 'D:\\ffmpeg\\bin\\ffmpeg.exe';
|
||||||
|
|
||||||
const FFMPEG_PATH = 'D:\\ffmpeg\\bin\\ffmpeg.exe'; // Hardcoded como pediste
|
|
||||||
|
|
||||||
type AnimeDownloadParams = {
|
type AnimeDownloadParams = {
|
||||||
anilistId: number;
|
anilistId: number;
|
||||||
episodeNumber: number;
|
episodeNumber: number;
|
||||||
streamUrl: string;
|
streamUrl: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
quality?: string;
|
quality?: string;
|
||||||
subtitles?: Array<{ language: string; url: string }>;
|
subtitles?: Array<{ language: string; url: string }>;
|
||||||
chapters?: Array<{ title: string; start_time: number; end_time: number }>;
|
chapters?: Array<{ title: string; start_time: number; end_time: number }>;
|
||||||
@@ -50,7 +48,7 @@ async function getOrCreateEntry(
|
|||||||
type: 'anime' | 'manga' | 'novels'
|
type: 'anime' | 'manga' | 'novels'
|
||||||
): Promise<{ id: string; path: string; folderName: string }> {
|
): Promise<{ id: string; path: string; folderName: string }> {
|
||||||
const existing = await queryOne(
|
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 = ?`,
|
WHERE matched_id = ? AND matched_source = 'anilist' AND type = ?`,
|
||||||
[anilistId, type],
|
[anilistId, type],
|
||||||
'local_library'
|
'local_library'
|
||||||
@@ -85,12 +83,16 @@ async function getOrCreateEntry(
|
|||||||
|
|
||||||
await ensureDirectory(folderPath);
|
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();
|
const now = Date.now();
|
||||||
|
|
||||||
await run(
|
await run(
|
||||||
`INSERT INTO local_entries (id, type, path, folder_name, matched_id, matched_source, last_scan)
|
`INSERT OR IGNORE INTO local_entries
|
||||||
VALUES (?, ?, ?, ?, ?, 'anilist', ?)`,
|
(id, type, path, folder_name, matched_id, matched_source, last_scan)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 'anilist', ?)`,
|
||||||
[entryId, type, folderPath, safeName, anilistId, now],
|
[entryId, type, folderPath, safeName, anilistId, now],
|
||||||
'local_library'
|
'local_library'
|
||||||
);
|
);
|
||||||
@@ -103,112 +105,182 @@ async function getOrCreateEntry(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function downloadAnimeEpisode(params: AnimeDownloadParams) {
|
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 entry = await getOrCreateEntry(anilistId, 'anime');
|
||||||
|
|
||||||
const existingFile = await queryOne(
|
const exists = await queryOne(
|
||||||
`SELECT id FROM local_files WHERE entry_id = ? AND unit_number = ?`,
|
`SELECT id FROM local_files WHERE entry_id = ? AND unit_number = ?`,
|
||||||
[entry.id, episodeNumber],
|
[entry.id, episodeNumber],
|
||||||
'local_library'
|
'local_library'
|
||||||
);
|
);
|
||||||
|
if (exists) return { status: 'ALREADY_EXISTS', entry_id: entry.id, episode: episodeNumber };
|
||||||
|
|
||||||
if (existingFile) {
|
const outputPath = path.join(entry.path, `Episode_${episodeNumber.toString().padStart(2, '0')}.mkv`);
|
||||||
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 tempDir = path.join(entry.path, '.temp');
|
const tempDir = path.join(entry.path, '.temp');
|
||||||
await ensureDirectory(tempDir);
|
await ensureDirectory(tempDir);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let inputArgs: string[] = [];
|
|
||||||
let videoInput = streamUrl;
|
let videoInput = streamUrl;
|
||||||
|
let audioInputs: string[] = [];
|
||||||
|
|
||||||
if (streamUrl.includes('.m3u8')) {
|
const isMaster = (params as any).is_master === true;
|
||||||
if (quality) {
|
|
||||||
const tempM3u8 = path.join(tempDir, 'stream.m3u8');
|
|
||||||
await downloadFile(streamUrl, tempM3u8);
|
|
||||||
const content = fs.readFileSync(tempM3u8, 'utf8');
|
|
||||||
|
|
||||||
const qualities = content.match(/RESOLUTION=\d+x(\d+)/g) || [];
|
if (isMaster) {
|
||||||
const targetHeight = quality.replace('p', '');
|
|
||||||
const targetLine = content.split('\n').find(line =>
|
|
||||||
line.includes(`RESOLUTION=`) && line.includes(`x${targetHeight}`)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (targetLine) {
|
const variant = (params as any).variant;
|
||||||
const nextLine = content.split('\n')[content.split('\n').indexOf(targetLine) + 1];
|
const audios = (params as any).audio;
|
||||||
if (nextLine && !nextLine.startsWith('#')) {
|
|
||||||
const baseUrl = streamUrl.substring(0, streamUrl.lastIndexOf('/') + 1);
|
|
||||||
videoInput = nextLine.startsWith('http') ? nextLine : baseUrl + nextLine;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 subFiles: string[] = [];
|
||||||
|
if (subtitles?.length) {
|
||||||
const subtitleFiles: string[] = [];
|
|
||||||
if (subtitles && subtitles.length > 0) {
|
|
||||||
for (let i = 0; i < subtitles.length; i++) {
|
for (let i = 0; i < subtitles.length; i++) {
|
||||||
const sub = subtitles[i];
|
const ext = subtitles[i].url.endsWith('.vtt') ? 'vtt' : 'srt';
|
||||||
const subPath = path.join(tempDir, `subtitle_${i}.${sub.url.endsWith('.vtt') ? 'vtt' : 'srt'}`);
|
const p = path.join(tempDir, `sub_${i}.${ext}`);
|
||||||
await downloadFile(sub.url, subPath);
|
await downloadFile(subtitles[i].url, p);
|
||||||
subtitleFiles.push(subPath);
|
subFiles.push(p);
|
||||||
inputArgs.push('-i', subPath);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let ffmpegArgs = [
|
const args = [
|
||||||
...inputArgs,
|
'-protocol_whitelist', 'file,http,https,tcp,tls,crypto',
|
||||||
'-map', '0:v',
|
'-allowed_extensions', 'ALL',
|
||||||
'-map', '0:a',
|
'-f', 'hls',
|
||||||
'-c:v', 'copy',
|
'-extension_picky', '0',
|
||||||
'-c:a', 'copy'
|
'-i', videoInput
|
||||||
];
|
];
|
||||||
|
|
||||||
for (let i = 0; i < subtitleFiles.length; i++) {
|
audioInputs.forEach(audioUrl => {
|
||||||
ffmpegArgs.push('-map', `${i + 1}:s`);
|
args.push(
|
||||||
ffmpegArgs.push(`-metadata:s:s:${i}`, `language=${subtitles![i].language}`);
|
'-protocol_whitelist', 'file,http,https,tcp,tls,crypto',
|
||||||
}
|
'-allowed_extensions', 'ALL',
|
||||||
|
'-f', 'hls',
|
||||||
|
'-extension_picky', '0',
|
||||||
|
'-i', audioUrl
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
if (subtitleFiles.length > 0) {
|
subFiles.forEach(f => args.push('-i', f));
|
||||||
ffmpegArgs.push('-c:s', 'copy');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (chapters && chapters.length > 0) {
|
let chaptersInputIndex = -1;
|
||||||
const metadataFile = path.join(tempDir, 'chapters.txt');
|
if (chapters?.length) {
|
||||||
let chapterContent = ';FFMETADATA1\n';
|
const meta = path.join(tempDir, 'chapters.txt');
|
||||||
|
let txt = ';FFMETADATA1\n';
|
||||||
for (const chapter of chapters) {
|
for (const c of chapters) {
|
||||||
const startMs = Math.floor(chapter.start_time * 1000);
|
txt += `[CHAPTER]\nTIMEBASE=1/1000\nSTART=${c.start_time * 1000 | 0}\nEND=${c.end_time * 1000 | 0}\ntitle=${c.title}\n`;
|
||||||
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`;
|
|
||||||
}
|
}
|
||||||
|
fs.writeFileSync(meta, txt);
|
||||||
fs.writeFileSync(metadataFile, chapterContent);
|
args.push('-i', meta);
|
||||||
ffmpegArgs.push('-i', metadataFile);
|
chaptersInputIndex = 1 + audioInputs.length + subFiles.length;
|
||||||
ffmpegArgs.push('-map_metadata', `${inputArgs.length / 2}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ffmpegArgs.push(outputPath);
|
args.push('-map', '0:v:0');
|
||||||
|
|
||||||
const command = `${FFMPEG_PATH} ${ffmpegArgs.join(' ')}`;
|
if (audioInputs.length > 0) {
|
||||||
await execPromise(command, { maxBuffer: 1024 * 1024 * 100 });
|
|
||||||
|
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 });
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
|
||||||
@@ -234,14 +306,11 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) {
|
|||||||
path: outputPath
|
path: outputPath
|
||||||
};
|
};
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (e: any) {
|
||||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
if (fs.existsSync(outputPath)) {
|
if (fs.existsSync(outputPath)) fs.unlinkSync(outputPath);
|
||||||
fs.unlinkSync(outputPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
const err = new Error('DOWNLOAD_FAILED');
|
const err = new Error('DOWNLOAD_FAILED');
|
||||||
(err as any).details = error.message;
|
(err as any).details = e.message;
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,20 +17,52 @@ type MatchBody = {
|
|||||||
matched_id: number | null;
|
matched_id: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type DownloadAnimeBody = {
|
type DownloadAnimeBody =
|
||||||
|
| {
|
||||||
anilist_id: number;
|
anilist_id: number;
|
||||||
episode_number: number;
|
episode_number: number;
|
||||||
stream_url: string;
|
stream_url: string; // media playlist FINAL
|
||||||
quality?: string;
|
is_master?: false;
|
||||||
subtitles?: Array<{
|
subtitles?: {
|
||||||
language: string;
|
language: string;
|
||||||
url: string;
|
url: string;
|
||||||
}>;
|
}[];
|
||||||
chapters?: Array<{
|
chapters?: {
|
||||||
title: string;
|
title: string;
|
||||||
start_time: number;
|
start_time: number;
|
||||||
end_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 = {
|
type DownloadBookBody = {
|
||||||
@@ -212,17 +244,30 @@ export async function getPage(request: FastifyRequest, reply: FastifyReply) {
|
|||||||
return reply.status(400).send();
|
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) {
|
export async function downloadAnime(request: FastifyRequest<{ Body: DownloadAnimeBody }>, reply: FastifyReply) {
|
||||||
try {
|
try {
|
||||||
const {
|
const {
|
||||||
anilist_id,
|
anilist_id,
|
||||||
episode_number,
|
episode_number,
|
||||||
stream_url,
|
stream_url,
|
||||||
quality,
|
is_master,
|
||||||
subtitles,
|
subtitles,
|
||||||
chapters
|
chapters
|
||||||
} = request.body;
|
} = request.body;
|
||||||
|
|
||||||
|
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',
|
||||||
@@ -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,
|
anilistId: anilist_id,
|
||||||
episodeNumber: episode_number,
|
episodeNumber: episode_number,
|
||||||
streamUrl: stream_url,
|
streamUrl: proxyUrl,
|
||||||
quality,
|
subtitles: proxiedSubs,
|
||||||
subtitles,
|
|
||||||
chapters
|
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') {
|
if (result.status === 'ALREADY_EXISTS') {
|
||||||
return reply.status(409).send(result);
|
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' });
|
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') {
|
if (err.message === 'DOWNLOAD_FAILED') {
|
||||||
return reply.status(500).send({ error: 'DOWNLOAD_FAILED', details: err.details });
|
return reply.status(500).send({ error: 'DOWNLOAD_FAILED', details: err.details });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,14 @@ const AnimePlayer = (function() {
|
|||||||
epTitle: null,
|
epTitle: null,
|
||||||
prevBtn: null,
|
prevBtn: null,
|
||||||
nextBtn: null,
|
nextBtn: null,
|
||||||
mpvBtn: null
|
mpvBtn: null,
|
||||||
|
downloadBtn: null,
|
||||||
|
downloadModal: null,
|
||||||
|
dlQualityList: null,
|
||||||
|
dlAudioList: null,
|
||||||
|
dlSubsList: null,
|
||||||
|
dlConfirmBtn: null,
|
||||||
|
dlCancelBtn: null
|
||||||
};
|
};
|
||||||
|
|
||||||
function init(animeId, initialSource, isLocal, animeData) {
|
function init(animeId, initialSource, isLocal, animeData) {
|
||||||
@@ -57,8 +64,46 @@ const AnimePlayer = (function() {
|
|||||||
els.video = document.getElementById('player');
|
els.video = document.getElementById('player');
|
||||||
els.loader = document.getElementById('player-loading');
|
els.loader = document.getElementById('player-loading');
|
||||||
els.loaderText = document.getElementById('player-loading-text');
|
els.loaderText = document.getElementById('player-loading-text');
|
||||||
|
els.downloadBtn = document.getElementById('download-btn');
|
||||||
|
if (els.downloadBtn) {
|
||||||
|
els.downloadBtn.addEventListener('click', downloadEpisode);
|
||||||
|
}
|
||||||
|
els.downloadModal = document.getElementById('download-modal');
|
||||||
|
els.dlQualityList = document.getElementById('dl-quality-list');
|
||||||
|
els.dlAudioList = document.getElementById('dl-audio-list');
|
||||||
|
els.dlSubsList = document.getElementById('dl-subs-list');
|
||||||
|
els.dlConfirmBtn = document.getElementById('confirm-dl-btn');
|
||||||
|
els.dlCancelBtn = document.getElementById('cancel-dl-btn');
|
||||||
|
|
||||||
|
const closeDlModalBtn = document.getElementById('close-download-modal');
|
||||||
|
|
||||||
|
if (els.dlConfirmBtn) els.dlConfirmBtn.onclick = executeDownload;
|
||||||
|
if (els.dlCancelBtn) els.dlCancelBtn.onclick = () => els.downloadModal.style.display = 'none';
|
||||||
|
if (closeDlModalBtn) closeDlModalBtn.onclick = () => els.downloadModal.style.display = 'none';
|
||||||
|
const closeModal = () => {
|
||||||
|
if (els.downloadModal) {
|
||||||
|
els.downloadModal.classList.remove('show');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
|
||||||
|
if(!els.downloadModal.classList.contains('show')) {
|
||||||
|
els.downloadModal.style.display = 'none';
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (els.dlCancelBtn) els.dlCancelBtn.onclick = closeModal;
|
||||||
|
|
||||||
|
if (closeDlModalBtn) closeDlModalBtn.onclick = closeModal;
|
||||||
els.mpvBtn = document.getElementById('mpv-btn');
|
els.mpvBtn = document.getElementById('mpv-btn');
|
||||||
|
if (els.downloadModal) {
|
||||||
|
els.downloadModal.addEventListener('click', (e) => {
|
||||||
|
|
||||||
|
if (e.target === els.downloadModal) {
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
if (els.mpvBtn) els.mpvBtn.addEventListener('click', openInMPV);
|
if (els.mpvBtn) els.mpvBtn.addEventListener('click', openInMPV);
|
||||||
|
|
||||||
els.serverSelect = document.getElementById('server-select');
|
els.serverSelect = document.getElementById('server-select');
|
||||||
@@ -183,6 +228,11 @@ const AnimePlayer = (function() {
|
|||||||
|
|
||||||
_currentEpisode = targetEp;
|
_currentEpisode = targetEp;
|
||||||
|
|
||||||
|
if (els.downloadBtn) {
|
||||||
|
els.downloadBtn.style.display = _isLocal ? 'none' : 'flex';
|
||||||
|
resetDownloadButtonIcon();
|
||||||
|
}
|
||||||
|
|
||||||
if(els.epTitle) els.epTitle.innerText = `Episode ${targetEp}`;
|
if(els.epTitle) els.epTitle.innerText = `Episode ${targetEp}`;
|
||||||
if(els.prevBtn) els.prevBtn.disabled = (_currentEpisode <= 1);
|
if(els.prevBtn) els.prevBtn.disabled = (_currentEpisode <= 1);
|
||||||
if(els.nextBtn) els.nextBtn.disabled = (_currentEpisode >= _totalEpisodes);
|
if(els.nextBtn) els.nextBtn.disabled = (_currentEpisode >= _totalEpisodes);
|
||||||
@@ -215,6 +265,256 @@ const AnimePlayer = (function() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function downloadEpisode() {
|
||||||
|
if (!_rawVideoData || !_rawVideoData.url) {
|
||||||
|
alert("Stream not loaded yet.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isInFullscreen = document.fullscreenElement || document.webkitFullscreenElement;
|
||||||
|
|
||||||
|
if (isInFullscreen) {
|
||||||
|
try {
|
||||||
|
if (document.exitFullscreen) {
|
||||||
|
await document.exitFullscreen();
|
||||||
|
} else if (document.webkitExitFullscreen) {
|
||||||
|
await document.webkitExitFullscreen();
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("Error al salir de fullscreen:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isM3U8 = hlsInstance && hlsInstance.levels && hlsInstance.levels.length > 0;
|
||||||
|
const hasMultipleAudio = hlsInstance && hlsInstance.audioTracks && hlsInstance.audioTracks.length > 1;
|
||||||
|
|
||||||
|
const hasSubs = _currentSubtitles && _currentSubtitles.length > 0;
|
||||||
|
|
||||||
|
if (isM3U8 || hasMultipleAudio || hasSubs) {
|
||||||
|
|
||||||
|
await new Promise(resolve => requestAnimationFrame(resolve));
|
||||||
|
openDownloadModal();
|
||||||
|
} else {
|
||||||
|
|
||||||
|
executeDownload(null, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDownloadModal() {
|
||||||
|
if(!els.downloadModal) {
|
||||||
|
console.error("Modal element not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
els.dlQualityList.innerHTML = '';
|
||||||
|
els.dlAudioList.innerHTML = '';
|
||||||
|
els.dlSubsList.innerHTML = '';
|
||||||
|
|
||||||
|
let showQuality = false;
|
||||||
|
let showAudio = false;
|
||||||
|
let showSubs = false;
|
||||||
|
|
||||||
|
if (hlsInstance && hlsInstance.levels && hlsInstance.levels.length > 0) {
|
||||||
|
showQuality = true;
|
||||||
|
|
||||||
|
const levels = hlsInstance.levels.map((l, index) => ({...l, originalIndex: index}))
|
||||||
|
.sort((a, b) => b.height - a.height);
|
||||||
|
|
||||||
|
levels.forEach((level, i) => {
|
||||||
|
const isSelected = i === 0;
|
||||||
|
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'dl-item';
|
||||||
|
div.innerHTML = `
|
||||||
|
<input type="radio" name="dl-quality" value="${level.originalIndex}" ${isSelected ? 'checked' : ''}>
|
||||||
|
<span>${level.height}p</span>
|
||||||
|
<span class="tag-info">${(level.bitrate / 1000000).toFixed(1)} Mbps</span>
|
||||||
|
`;
|
||||||
|
div.onclick = (e) => {
|
||||||
|
if(e.target.tagName !== 'INPUT') div.querySelector('input').checked = true;
|
||||||
|
};
|
||||||
|
els.dlQualityList.appendChild(div);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.getElementById('dl-quality-section').style.display = showQuality ? 'block' : 'none';
|
||||||
|
|
||||||
|
if (hlsInstance && hlsInstance.audioTracks && hlsInstance.audioTracks.length > 0) {
|
||||||
|
showAudio = true;
|
||||||
|
hlsInstance.audioTracks.forEach((track, index) => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'dl-item';
|
||||||
|
|
||||||
|
const isCurrent = hlsInstance.audioTrack === index;
|
||||||
|
|
||||||
|
div.innerHTML = `
|
||||||
|
<input type="checkbox" name="dl-audio" value="${index}" checked>
|
||||||
|
<span>${track.name || track.lang || `Audio ${index+1}`}</span>
|
||||||
|
<span class="tag-info">${track.lang || 'unk'}</span>
|
||||||
|
`;
|
||||||
|
div.onclick = (e) => {
|
||||||
|
if(e.target.tagName !== 'INPUT') {
|
||||||
|
const cb = div.querySelector('input');
|
||||||
|
cb.checked = !cb.checked;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
els.dlAudioList.appendChild(div);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.getElementById('dl-audio-section').style.display = showAudio ? 'block' : 'none';
|
||||||
|
|
||||||
|
if (_currentSubtitles && _currentSubtitles.length > 0) {
|
||||||
|
showSubs = true;
|
||||||
|
_currentSubtitles.forEach((sub, index) => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'dl-item';
|
||||||
|
div.innerHTML = `
|
||||||
|
<input type="checkbox" name="dl-subs" value="${index}" checked>
|
||||||
|
<span>${sub.label || sub.language || 'Unknown'}</span>
|
||||||
|
`;
|
||||||
|
div.onclick = (e) => {
|
||||||
|
if(e.target.tagName !== 'INPUT') {
|
||||||
|
const cb = div.querySelector('input');
|
||||||
|
cb.checked = !cb.checked;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
els.dlSubsList.appendChild(div);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.getElementById('dl-subs-section').style.display = showSubs ? 'block' : 'none';
|
||||||
|
|
||||||
|
els.downloadModal.style.display = 'flex';
|
||||||
|
|
||||||
|
els.downloadModal.offsetHeight;
|
||||||
|
els.downloadModal.classList.add('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeDownload(e, skipModal = false) {
|
||||||
|
if(els.downloadModal) {
|
||||||
|
els.downloadModal.classList.remove('show');
|
||||||
|
setTimeout(() => els.downloadModal.style.display = 'none', 300);
|
||||||
|
}
|
||||||
|
const btn = els.downloadBtn;
|
||||||
|
const originalBtnContent = btn.innerHTML;
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = `<div class="spinner" style="width:18px; height:18px; border-width:2px;"></div>`;
|
||||||
|
|
||||||
|
let body = {
|
||||||
|
anilist_id: parseInt(_animeId),
|
||||||
|
episode_number: parseInt(_currentEpisode),
|
||||||
|
stream_url: _rawVideoData.url,
|
||||||
|
headers: _rawVideoData.headers || {},
|
||||||
|
chapters: _skipIntervals.map(i => ({
|
||||||
|
title: i.type === 'op' ? 'Opening' : 'Ending',
|
||||||
|
start_time: i.startTime,
|
||||||
|
end_time: i.endTime
|
||||||
|
})),
|
||||||
|
subtitles: []
|
||||||
|
};
|
||||||
|
|
||||||
|
if (skipModal) {
|
||||||
|
|
||||||
|
if (_currentSubtitles) {
|
||||||
|
body.subtitles = _currentSubtitles.map(sub => ({
|
||||||
|
language: sub.label || 'Unknown',
|
||||||
|
url: sub.src
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
|
||||||
|
const selectedSubs = Array.from(els.dlSubsList.querySelectorAll('input:checked'));
|
||||||
|
body.subtitles = selectedSubs.map(cb => {
|
||||||
|
const i = parseInt(cb.value);
|
||||||
|
return {
|
||||||
|
language: _currentSubtitles[i].label || 'Unknown',
|
||||||
|
url: _currentSubtitles[i].src
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const isQualityVisible = document.getElementById('dl-quality-section').style.display !== 'none';
|
||||||
|
|
||||||
|
if (isQualityVisible && hlsInstance && hlsInstance.levels) {
|
||||||
|
body.is_master = true;
|
||||||
|
|
||||||
|
const qualityInput = document.querySelector('input[name="dl-quality"]:checked');
|
||||||
|
const qualityIndex = qualityInput ? parseInt(qualityInput.value) : 0;
|
||||||
|
const level = hlsInstance.levels[qualityIndex];
|
||||||
|
|
||||||
|
if (level) {
|
||||||
|
body.variant = {
|
||||||
|
resolution: level.width ? `${level.width}x${level.height}` : '1920x1080',
|
||||||
|
bandwidth: level.bitrate,
|
||||||
|
codecs: level.attrs ? level.attrs.CODECS : '',
|
||||||
|
playlist_url: level.url
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const audioInputs = document.querySelectorAll('input[name="dl-audio"]:checked');
|
||||||
|
if (audioInputs.length > 0 && hlsInstance.audioTracks) {
|
||||||
|
body.audio = Array.from(audioInputs).map(input => {
|
||||||
|
const i = parseInt(input.value);
|
||||||
|
const track = hlsInstance.audioTracks[i];
|
||||||
|
return {
|
||||||
|
group: track.groupId || 'audio',
|
||||||
|
language: track.lang || 'unk',
|
||||||
|
name: track.name || `Audio ${i}`,
|
||||||
|
playlist_url: track.url
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
const res = await fetch('/api/library/download/anime', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': token ? `Bearer ${token}` : ''
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (res.status === 200) {
|
||||||
|
|
||||||
|
btn.innerHTML = `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#22c55e" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>`;
|
||||||
|
} else if (res.status === 409) {
|
||||||
|
|
||||||
|
btn.innerHTML = `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fbbf24" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg>`;
|
||||||
|
} else {
|
||||||
|
|
||||||
|
console.error("Download Error:", data);
|
||||||
|
btn.innerHTML = `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>`;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Request failed:", err);
|
||||||
|
btn.innerHTML = `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>`;
|
||||||
|
} finally {
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (btn) {
|
||||||
|
btn.disabled = false;
|
||||||
|
resetDownloadButtonIcon();
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetDownloadButtonIcon() {
|
||||||
|
if (!els.downloadBtn) return;
|
||||||
|
els.downloadBtn.innerHTML = `
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||||
|
<polyline points="7 10 12 15 17 10"></polyline>
|
||||||
|
<line x1="12" y1="15" x2="12" y2="3"></line>
|
||||||
|
</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
function closePlayer() {
|
function closePlayer() {
|
||||||
if (plyrInstance) plyrInstance.destroy();
|
if (plyrInstance) plyrInstance.destroy();
|
||||||
if (hlsInstance) hlsInstance.destroy();
|
if (hlsInstance) hlsInstance.destroy();
|
||||||
@@ -508,40 +808,34 @@ const AnimePlayer = (function() {
|
|||||||
const controls = plyrEl.querySelector('.plyr__controls');
|
const controls = plyrEl.querySelector('.plyr__controls');
|
||||||
if (!controls || controls.querySelector('#quality-control-wrapper')) return;
|
if (!controls || controls.querySelector('#quality-control-wrapper')) return;
|
||||||
|
|
||||||
// 1. Crear el Wrapper
|
|
||||||
const wrapper = document.createElement('div');
|
const wrapper = document.createElement('div');
|
||||||
wrapper.className = 'plyr__controls__item plyr__custom-select-wrapper';
|
wrapper.className = 'plyr__controls__item plyr__custom-select-wrapper';
|
||||||
wrapper.id = 'quality-control-wrapper';
|
wrapper.id = 'quality-control-wrapper';
|
||||||
|
|
||||||
// 2. Crear el Botón Visual (Fake)
|
|
||||||
const btn = document.createElement('div');
|
const btn = document.createElement('div');
|
||||||
btn.className = 'plyr__custom-control-btn';
|
btn.className = 'plyr__custom-control-btn';
|
||||||
// Icono + Texto Inicial
|
|
||||||
btn.innerHTML = `${ICONS.settings} <span class="label-text">Auto</span>`;
|
btn.innerHTML = `${ICONS.settings} <span class="label-text">Auto</span>`;
|
||||||
|
|
||||||
// 3. Crear el Select Real (Invisible)
|
|
||||||
const select = document.createElement('select');
|
const select = document.createElement('select');
|
||||||
select.className = 'plyr__sr-only-select'; // Clase auxiliar si quieres depurar, sino usa el CSS wrapper
|
select.className = 'plyr__sr-only-select';
|
||||||
|
|
||||||
// Opción AUTO
|
|
||||||
const autoOpt = document.createElement('option');
|
const autoOpt = document.createElement('option');
|
||||||
autoOpt.value = -1;
|
autoOpt.value = -1;
|
||||||
autoOpt.textContent = 'Auto';
|
autoOpt.textContent = 'Auto';
|
||||||
select.appendChild(autoOpt);
|
select.appendChild(autoOpt);
|
||||||
|
|
||||||
// Opciones de Niveles
|
|
||||||
levels.forEach((l, i) => {
|
levels.forEach((l, i) => {
|
||||||
const opt = document.createElement('option');
|
const opt = document.createElement('option');
|
||||||
opt.value = i;
|
opt.value = i;
|
||||||
opt.textContent = `${l.height}p`; // Texto que sale en el dropdown nativo
|
opt.textContent = `${l.height}p`;
|
||||||
|
|
||||||
select.appendChild(opt);
|
select.appendChild(opt);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sincronizar estado inicial
|
|
||||||
select.value = hls.currentLevel;
|
select.value = hls.currentLevel;
|
||||||
updateLabel(select.value);
|
updateLabel(select.value);
|
||||||
|
|
||||||
// Evento Change
|
|
||||||
select.onchange = () => {
|
select.onchange = () => {
|
||||||
hls.currentLevel = Number(select.value);
|
hls.currentLevel = Number(select.value);
|
||||||
updateLabel(select.value);
|
updateLabel(select.value);
|
||||||
@@ -551,7 +845,7 @@ const AnimePlayer = (function() {
|
|||||||
const index = Number(val);
|
const index = Number(val);
|
||||||
let text = 'Auto';
|
let text = 'Auto';
|
||||||
if (index !== -1 && levels[index]) {
|
if (index !== -1 && levels[index]) {
|
||||||
// Solo el número + p (ej: 720p)
|
|
||||||
text = `${levels[index].height}p`;
|
text = `${levels[index].height}p`;
|
||||||
}
|
}
|
||||||
btn.innerHTML = `<span class="label-text">${text}</span>`;
|
btn.innerHTML = `<span class="label-text">${text}</span>`;
|
||||||
@@ -560,8 +854,6 @@ const AnimePlayer = (function() {
|
|||||||
wrapper.appendChild(select);
|
wrapper.appendChild(select);
|
||||||
wrapper.appendChild(btn);
|
wrapper.appendChild(btn);
|
||||||
|
|
||||||
// Insertar en controles Plyr (antes del botón de pantalla completa o ajustes)
|
|
||||||
// Insertamos antes del 5º elemento (usualmente settings o fullscreen)
|
|
||||||
const insertIndex = controls.children.length > 4 ? 4 : controls.children.length - 1;
|
const insertIndex = controls.children.length > 4 ? 4 : controls.children.length - 1;
|
||||||
controls.insertBefore(wrapper, controls.children[insertIndex]);
|
controls.insertBefore(wrapper, controls.children[insertIndex]);
|
||||||
}
|
}
|
||||||
@@ -573,17 +865,14 @@ const AnimePlayer = (function() {
|
|||||||
const controls = plyrEl.querySelector('.plyr__controls');
|
const controls = plyrEl.querySelector('.plyr__controls');
|
||||||
if (!controls || controls.querySelector('#audio-control-wrapper')) return;
|
if (!controls || controls.querySelector('#audio-control-wrapper')) return;
|
||||||
|
|
||||||
// 1. Wrapper
|
|
||||||
const wrapper = document.createElement('div');
|
const wrapper = document.createElement('div');
|
||||||
wrapper.className = 'plyr__controls__item plyr__custom-select-wrapper';
|
wrapper.className = 'plyr__controls__item plyr__custom-select-wrapper';
|
||||||
wrapper.id = 'audio-control-wrapper';
|
wrapper.id = 'audio-control-wrapper';
|
||||||
|
|
||||||
// 2. Botón Visual
|
|
||||||
const btn = document.createElement('div');
|
const btn = document.createElement('div');
|
||||||
btn.className = 'plyr__custom-control-btn';
|
btn.className = 'plyr__custom-control-btn';
|
||||||
btn.innerHTML = `<span class="label-text">Audio 1</span>`;
|
btn.innerHTML = `<span class="label-text">Audio 1</span>`;
|
||||||
|
|
||||||
// 3. Select Invisible
|
|
||||||
const select = document.createElement('select');
|
const select = document.createElement('select');
|
||||||
|
|
||||||
hls.audioTracks.forEach((t, i) => {
|
hls.audioTracks.forEach((t, i) => {
|
||||||
@@ -605,10 +894,8 @@ const AnimePlayer = (function() {
|
|||||||
const index = Number(val);
|
const index = Number(val);
|
||||||
const track = hls.audioTracks[index];
|
const track = hls.audioTracks[index];
|
||||||
|
|
||||||
// Priorizamos el idioma (lang), luego el nombre
|
|
||||||
let rawText = track.lang || track.name || `A${index + 1}`;
|
let rawText = track.lang || track.name || `A${index + 1}`;
|
||||||
|
|
||||||
// Tomamos solo las 2 primeras letras y las pasamos a Mayúsculas
|
|
||||||
let shortText = rawText.substring(0, 2).toUpperCase();
|
let shortText = rawText.substring(0, 2).toUpperCase();
|
||||||
|
|
||||||
btn.querySelector('.label-text').innerText = shortText;
|
btn.querySelector('.label-text').innerText = shortText;
|
||||||
@@ -617,7 +904,6 @@ const AnimePlayer = (function() {
|
|||||||
wrapper.appendChild(select);
|
wrapper.appendChild(select);
|
||||||
wrapper.appendChild(btn);
|
wrapper.appendChild(btn);
|
||||||
|
|
||||||
// Insertar antes del selector de calidad si existe, o en la posición 4
|
|
||||||
const qualityWrapper = controls.querySelector('#quality-control-wrapper');
|
const qualityWrapper = controls.querySelector('#quality-control-wrapper');
|
||||||
if(qualityWrapper) {
|
if(qualityWrapper) {
|
||||||
controls.insertBefore(wrapper, qualityWrapper);
|
controls.insertBefore(wrapper, qualityWrapper);
|
||||||
|
|||||||
@@ -73,13 +73,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
|
<button class="btn-icon-glass" id="download-btn" title="Download Episode" style="display: none;">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||||
|
<polyline points="7 10 12 15 17 10"></polyline>
|
||||||
|
<line x1="12" y1="15" x2="12" y2="3"></line>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<div class="settings-group">
|
<div class="settings-group">
|
||||||
<button id="mpv-btn" class="glass-btn-mpv" title="Open in MPV">
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path d="M5 3l14 9-14 9V3z"></path>
|
|
||||||
</svg>
|
|
||||||
<span>MPV</span>
|
|
||||||
</button>
|
|
||||||
<div class="sd-toggle" id="sd-toggle" data-state="sub">
|
<div class="sd-toggle" id="sd-toggle" data-state="sub">
|
||||||
<div class="sd-bg"></div>
|
<div class="sd-bg"></div>
|
||||||
<div class="sd-option active" id="opt-sub">Sub</div>
|
<div class="sd-option active" id="opt-sub">Sub</div>
|
||||||
@@ -101,6 +102,34 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="modal-overlay" id="download-modal">
|
||||||
|
<div class="modal-content download-settings-content">
|
||||||
|
<button class="modal-close" id="close-download-modal">✕</button>
|
||||||
|
<h2 class="modal-title">Download Settings</h2>
|
||||||
|
|
||||||
|
<div class="download-sections-wrapper">
|
||||||
|
<div id="dl-quality-section" class="dl-section" style="display:none;">
|
||||||
|
<h3>Video Quality</h3>
|
||||||
|
<div class="dl-list" id="dl-quality-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="dl-audio-section" class="dl-section" style="display:none;">
|
||||||
|
<h3>Audio Tracks</h3>
|
||||||
|
<div class="dl-list" id="dl-audio-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="dl-subs-section" class="dl-section">
|
||||||
|
<h3>Subtitles</h3>
|
||||||
|
<div class="dl-list" id="dl-subs-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dl-actions">
|
||||||
|
<button class="btn-cancel" id="cancel-dl-btn">Cancel</button>
|
||||||
|
<button class="btn-confirm" id="confirm-dl-btn">Start Download</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="content-container" id="main-content">
|
<div class="content-container" id="main-content">
|
||||||
|
|||||||
@@ -599,4 +599,321 @@ body.stop-scrolling {
|
|||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-settings-content {
|
||||||
|
background: #1a1a1a;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 500px;
|
||||||
|
max-height: 85vh;
|
||||||
|
border: 1px solid #333;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-sections-wrapper {
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 5px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dl-section {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dl-section h3 {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--brand-color-light);
|
||||||
|
margin-bottom: 0.8rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.1);
|
||||||
|
padding-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dl-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dl-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
background: rgba(255,255,255,0.05);
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dl-item:hover {
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dl-item input[type="radio"],
|
||||||
|
.dl-item input[type="checkbox"] {
|
||||||
|
accent-color: var(--brand-color);
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dl-item span {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #eee;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dl-item .tag-info {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #aaa;
|
||||||
|
background: rgba(0,0,0,0.3);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dl-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px solid rgba(255,255,255,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid #444;
|
||||||
|
color: #ccc;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-confirm {
|
||||||
|
background: var(--brand-color);
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
padding: 8px 20px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn-confirm:hover {
|
||||||
|
background: #7c3aed;
|
||||||
|
}
|
||||||
|
/* =========================================
|
||||||
|
MODAL DE DESCARGAS - REDISEÑO "GLASS"
|
||||||
|
========================================= */
|
||||||
|
|
||||||
|
#download-modal {
|
||||||
|
position: fixed !important;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
background: rgba(0, 0, 0, 0.6); /* Fondo oscurecido */
|
||||||
|
backdrop-filter: blur(8px); /* Desenfoque del fondo */
|
||||||
|
z-index: 2147483647 !important; /* Encima de todo */
|
||||||
|
display: none; /* Controlado por JS/Clases */
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
pointer-events: none; /* Por defecto no bloquea, JS lo activa */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Estado visible activado por JS */
|
||||||
|
#download-modal.show {
|
||||||
|
display: flex !important;
|
||||||
|
opacity: 1 !important;
|
||||||
|
pointer-events: auto !important;
|
||||||
|
visibility: visible !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-settings-content {
|
||||||
|
background: rgba(20, 20, 20, 0.85); /* Fondo semitransparente oscuro */
|
||||||
|
width: 90%;
|
||||||
|
max-width: 480px;
|
||||||
|
max-height: 85vh;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 16px; /* Bordes más redondeados como anime.css */
|
||||||
|
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.7), 0 0 0 1px rgba(255,255,255,0.05);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 24px;
|
||||||
|
position: relative;
|
||||||
|
transform: scale(0.95);
|
||||||
|
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
color: #fff;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#download-modal.show .download-settings-content {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Botón de cerrar (X) */
|
||||||
|
.download-settings-content .modal-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-settings-content .modal-close:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
margin: 0 0 20px 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 800;
|
||||||
|
background: linear-gradient(to right, #fff, rgba(255,255,255,0.5));
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Wrappers internos */
|
||||||
|
.download-sections-wrapper {
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 8px; /* Espacio para scrollbar */
|
||||||
|
margin-bottom: 20px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar personalizado fino */
|
||||||
|
.download-sections-wrapper::-webkit-scrollbar { width: 6px; }
|
||||||
|
.download-sections-wrapper::-webkit-scrollbar-track { background: rgba(255,255,255,0.02); }
|
||||||
|
.download-sections-wrapper::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.2); border-radius: 10px; }
|
||||||
|
.download-sections-wrapper::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.4); }
|
||||||
|
|
||||||
|
.dl-section { margin-bottom: 1.5rem; }
|
||||||
|
|
||||||
|
.dl-section h3 {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--brand-color-light); /* Morado claro */
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.dl-section h3::after {
|
||||||
|
content: '';
|
||||||
|
flex: 1;
|
||||||
|
height: 1px;
|
||||||
|
background: linear-gradient(to right, rgba(139, 92, 246, 0.3), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dl-list { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
|
||||||
|
/* Items de la lista */
|
||||||
|
.dl-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dl-item:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border-color: rgba(255, 255, 255, 0.1);
|
||||||
|
transform: translateX(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inputs nativos con color de marca */
|
||||||
|
.dl-item input[type="radio"],
|
||||||
|
.dl-item input[type="checkbox"] {
|
||||||
|
accent-color: var(--brand-color);
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dl-item span {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: rgba(255,255,255,0.9);
|
||||||
|
flex: 1;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dl-item .tag-info {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: #ccc;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: 600;
|
||||||
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pie del modal (Botones) */
|
||||||
|
.dl-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.btn-cancel:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: white;
|
||||||
|
border-color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-confirm {
|
||||||
|
background: var(--brand-color);
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
padding: 10px 24px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
box-shadow: 0 4px 15px rgba(139, 92, 246, 0.3);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.btn-confirm:hover {
|
||||||
|
background: #7c3aed; /* Un tono más oscuro del brand */
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 20px rgba(139, 92, 246, 0.4);
|
||||||
}
|
}
|
||||||
@@ -1,22 +1,20 @@
|
|||||||
import { getConfig as loadConfig } from '../../shared/config.js';
|
import { getConfig as loadConfig } from '../../shared/config';
|
||||||
import { queryOne, queryAll, run } from '../../shared/database.js';
|
import { queryOne, queryAll, run } from '../../shared/database';
|
||||||
import { getAnimeById } from '../anime/anime.service';
|
import { getAnimeById } from '../anime/anime.service';
|
||||||
import { getBookById } from '../books/books.service';
|
import { getBookById } from '../books/books.service';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { exec } from 'child_process';
|
|
||||||
import { promisify } from 'util';
|
|
||||||
import AdmZip from 'adm-zip';
|
import AdmZip from 'adm-zip';
|
||||||
|
import { spawn } from 'child_process';
|
||||||
|
|
||||||
const execPromise = promisify(exec);
|
const FFMPEG_PATH = 'D:\\ffmpeg\\bin\\ffmpeg.exe';
|
||||||
|
|
||||||
const FFMPEG_PATH = 'D:\\ffmpeg\\bin\\ffmpeg.exe'; // Hardcoded como pediste
|
|
||||||
|
|
||||||
type AnimeDownloadParams = {
|
type AnimeDownloadParams = {
|
||||||
anilistId: number;
|
anilistId: number;
|
||||||
episodeNumber: number;
|
episodeNumber: number;
|
||||||
streamUrl: string;
|
streamUrl: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
quality?: string;
|
quality?: string;
|
||||||
subtitles?: Array<{ language: string; url: string }>;
|
subtitles?: Array<{ language: string; url: string }>;
|
||||||
chapters?: Array<{ title: string; start_time: number; end_time: number }>;
|
chapters?: Array<{ title: string; start_time: number; end_time: number }>;
|
||||||
@@ -50,7 +48,7 @@ async function getOrCreateEntry(
|
|||||||
type: 'anime' | 'manga' | 'novels'
|
type: 'anime' | 'manga' | 'novels'
|
||||||
): Promise<{ id: string; path: string; folderName: string }> {
|
): Promise<{ id: string; path: string; folderName: string }> {
|
||||||
const existing = await queryOne(
|
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 = ?`,
|
WHERE matched_id = ? AND matched_source = 'anilist' AND type = ?`,
|
||||||
[anilistId, type],
|
[anilistId, type],
|
||||||
'local_library'
|
'local_library'
|
||||||
@@ -85,12 +83,16 @@ async function getOrCreateEntry(
|
|||||||
|
|
||||||
await ensureDirectory(folderPath);
|
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();
|
const now = Date.now();
|
||||||
|
|
||||||
await run(
|
await run(
|
||||||
`INSERT INTO local_entries (id, type, path, folder_name, matched_id, matched_source, last_scan)
|
`INSERT OR IGNORE INTO local_entries
|
||||||
VALUES (?, ?, ?, ?, ?, 'anilist', ?)`,
|
(id, type, path, folder_name, matched_id, matched_source, last_scan)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 'anilist', ?)`,
|
||||||
[entryId, type, folderPath, safeName, anilistId, now],
|
[entryId, type, folderPath, safeName, anilistId, now],
|
||||||
'local_library'
|
'local_library'
|
||||||
);
|
);
|
||||||
@@ -103,112 +105,182 @@ async function getOrCreateEntry(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function downloadAnimeEpisode(params: AnimeDownloadParams) {
|
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 entry = await getOrCreateEntry(anilistId, 'anime');
|
||||||
|
|
||||||
const existingFile = await queryOne(
|
const exists = await queryOne(
|
||||||
`SELECT id FROM local_files WHERE entry_id = ? AND unit_number = ?`,
|
`SELECT id FROM local_files WHERE entry_id = ? AND unit_number = ?`,
|
||||||
[entry.id, episodeNumber],
|
[entry.id, episodeNumber],
|
||||||
'local_library'
|
'local_library'
|
||||||
);
|
);
|
||||||
|
if (exists) return { status: 'ALREADY_EXISTS', entry_id: entry.id, episode: episodeNumber };
|
||||||
|
|
||||||
if (existingFile) {
|
const outputPath = path.join(entry.path, `Episode_${episodeNumber.toString().padStart(2, '0')}.mkv`);
|
||||||
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 tempDir = path.join(entry.path, '.temp');
|
const tempDir = path.join(entry.path, '.temp');
|
||||||
await ensureDirectory(tempDir);
|
await ensureDirectory(tempDir);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let inputArgs: string[] = [];
|
|
||||||
let videoInput = streamUrl;
|
let videoInput = streamUrl;
|
||||||
|
let audioInputs: string[] = [];
|
||||||
|
|
||||||
if (streamUrl.includes('.m3u8')) {
|
const isMaster = (params as any).is_master === true;
|
||||||
if (quality) {
|
|
||||||
const tempM3u8 = path.join(tempDir, 'stream.m3u8');
|
|
||||||
await downloadFile(streamUrl, tempM3u8);
|
|
||||||
const content = fs.readFileSync(tempM3u8, 'utf8');
|
|
||||||
|
|
||||||
const qualities = content.match(/RESOLUTION=\d+x(\d+)/g) || [];
|
if (isMaster) {
|
||||||
const targetHeight = quality.replace('p', '');
|
|
||||||
const targetLine = content.split('\n').find(line =>
|
|
||||||
line.includes(`RESOLUTION=`) && line.includes(`x${targetHeight}`)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (targetLine) {
|
const variant = (params as any).variant;
|
||||||
const nextLine = content.split('\n')[content.split('\n').indexOf(targetLine) + 1];
|
const audios = (params as any).audio;
|
||||||
if (nextLine && !nextLine.startsWith('#')) {
|
|
||||||
const baseUrl = streamUrl.substring(0, streamUrl.lastIndexOf('/') + 1);
|
|
||||||
videoInput = nextLine.startsWith('http') ? nextLine : baseUrl + nextLine;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 subFiles: string[] = [];
|
||||||
|
if (subtitles?.length) {
|
||||||
const subtitleFiles: string[] = [];
|
|
||||||
if (subtitles && subtitles.length > 0) {
|
|
||||||
for (let i = 0; i < subtitles.length; i++) {
|
for (let i = 0; i < subtitles.length; i++) {
|
||||||
const sub = subtitles[i];
|
const ext = subtitles[i].url.endsWith('.vtt') ? 'vtt' : 'srt';
|
||||||
const subPath = path.join(tempDir, `subtitle_${i}.${sub.url.endsWith('.vtt') ? 'vtt' : 'srt'}`);
|
const p = path.join(tempDir, `sub_${i}.${ext}`);
|
||||||
await downloadFile(sub.url, subPath);
|
await downloadFile(subtitles[i].url, p);
|
||||||
subtitleFiles.push(subPath);
|
subFiles.push(p);
|
||||||
inputArgs.push('-i', subPath);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let ffmpegArgs = [
|
const args = [
|
||||||
...inputArgs,
|
'-protocol_whitelist', 'file,http,https,tcp,tls,crypto',
|
||||||
'-map', '0:v',
|
'-allowed_extensions', 'ALL',
|
||||||
'-map', '0:a',
|
'-f', 'hls',
|
||||||
'-c:v', 'copy',
|
'-extension_picky', '0',
|
||||||
'-c:a', 'copy'
|
'-i', videoInput
|
||||||
];
|
];
|
||||||
|
|
||||||
for (let i = 0; i < subtitleFiles.length; i++) {
|
audioInputs.forEach(audioUrl => {
|
||||||
ffmpegArgs.push('-map', `${i + 1}:s`);
|
args.push(
|
||||||
ffmpegArgs.push(`-metadata:s:s:${i}`, `language=${subtitles![i].language}`);
|
'-protocol_whitelist', 'file,http,https,tcp,tls,crypto',
|
||||||
}
|
'-allowed_extensions', 'ALL',
|
||||||
|
'-f', 'hls',
|
||||||
|
'-extension_picky', '0',
|
||||||
|
'-i', audioUrl
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
if (subtitleFiles.length > 0) {
|
subFiles.forEach(f => args.push('-i', f));
|
||||||
ffmpegArgs.push('-c:s', 'copy');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (chapters && chapters.length > 0) {
|
let chaptersInputIndex = -1;
|
||||||
const metadataFile = path.join(tempDir, 'chapters.txt');
|
if (chapters?.length) {
|
||||||
let chapterContent = ';FFMETADATA1\n';
|
const meta = path.join(tempDir, 'chapters.txt');
|
||||||
|
let txt = ';FFMETADATA1\n';
|
||||||
for (const chapter of chapters) {
|
for (const c of chapters) {
|
||||||
const startMs = Math.floor(chapter.start_time * 1000);
|
txt += `[CHAPTER]\nTIMEBASE=1/1000\nSTART=${c.start_time * 1000 | 0}\nEND=${c.end_time * 1000 | 0}\ntitle=${c.title}\n`;
|
||||||
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`;
|
|
||||||
}
|
}
|
||||||
|
fs.writeFileSync(meta, txt);
|
||||||
fs.writeFileSync(metadataFile, chapterContent);
|
args.push('-i', meta);
|
||||||
ffmpegArgs.push('-i', metadataFile);
|
chaptersInputIndex = 1 + audioInputs.length + subFiles.length;
|
||||||
ffmpegArgs.push('-map_metadata', `${inputArgs.length / 2}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ffmpegArgs.push(outputPath);
|
args.push('-map', '0:v:0');
|
||||||
|
|
||||||
const command = `${FFMPEG_PATH} ${ffmpegArgs.join(' ')}`;
|
if (audioInputs.length > 0) {
|
||||||
await execPromise(command, { maxBuffer: 1024 * 1024 * 100 });
|
|
||||||
|
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 });
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
|
||||||
@@ -234,14 +306,11 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) {
|
|||||||
path: outputPath
|
path: outputPath
|
||||||
};
|
};
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (e: any) {
|
||||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
if (fs.existsSync(outputPath)) {
|
if (fs.existsSync(outputPath)) fs.unlinkSync(outputPath);
|
||||||
fs.unlinkSync(outputPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
const err = new Error('DOWNLOAD_FAILED');
|
const err = new Error('DOWNLOAD_FAILED');
|
||||||
(err as any).details = error.message;
|
(err as any).details = e.message;
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,20 +17,52 @@ type MatchBody = {
|
|||||||
matched_id: number | null;
|
matched_id: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type DownloadAnimeBody = {
|
type DownloadAnimeBody =
|
||||||
|
| {
|
||||||
anilist_id: number;
|
anilist_id: number;
|
||||||
episode_number: number;
|
episode_number: number;
|
||||||
stream_url: string;
|
stream_url: string; // media playlist FINAL
|
||||||
quality?: string;
|
is_master?: false;
|
||||||
subtitles?: Array<{
|
subtitles?: {
|
||||||
language: string;
|
language: string;
|
||||||
url: string;
|
url: string;
|
||||||
}>;
|
}[];
|
||||||
chapters?: Array<{
|
chapters?: {
|
||||||
title: string;
|
title: string;
|
||||||
start_time: number;
|
start_time: number;
|
||||||
end_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 = {
|
type DownloadBookBody = {
|
||||||
@@ -212,17 +244,30 @@ export async function getPage(request: FastifyRequest, reply: FastifyReply) {
|
|||||||
return reply.status(400).send();
|
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) {
|
export async function downloadAnime(request: FastifyRequest<{ Body: DownloadAnimeBody }>, reply: FastifyReply) {
|
||||||
try {
|
try {
|
||||||
const {
|
const {
|
||||||
anilist_id,
|
anilist_id,
|
||||||
episode_number,
|
episode_number,
|
||||||
stream_url,
|
stream_url,
|
||||||
quality,
|
is_master,
|
||||||
subtitles,
|
subtitles,
|
||||||
chapters
|
chapters
|
||||||
} = request.body;
|
} = request.body;
|
||||||
|
|
||||||
|
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',
|
||||||
@@ -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,
|
anilistId: anilist_id,
|
||||||
episodeNumber: episode_number,
|
episodeNumber: episode_number,
|
||||||
streamUrl: stream_url,
|
streamUrl: proxyUrl,
|
||||||
quality,
|
subtitles: proxiedSubs,
|
||||||
subtitles,
|
|
||||||
chapters
|
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') {
|
if (result.status === 'ALREADY_EXISTS') {
|
||||||
return reply.status(409).send(result);
|
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' });
|
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') {
|
if (err.message === 'DOWNLOAD_FAILED') {
|
||||||
return reply.status(500).send({ error: 'DOWNLOAD_FAILED', details: err.details });
|
return reply.status(500).send({ error: 'DOWNLOAD_FAILED', details: err.details });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,14 @@ const AnimePlayer = (function() {
|
|||||||
epTitle: null,
|
epTitle: null,
|
||||||
prevBtn: null,
|
prevBtn: null,
|
||||||
nextBtn: null,
|
nextBtn: null,
|
||||||
mpvBtn: null
|
mpvBtn: null,
|
||||||
|
downloadBtn: null,
|
||||||
|
downloadModal: null,
|
||||||
|
dlQualityList: null,
|
||||||
|
dlAudioList: null,
|
||||||
|
dlSubsList: null,
|
||||||
|
dlConfirmBtn: null,
|
||||||
|
dlCancelBtn: null
|
||||||
};
|
};
|
||||||
|
|
||||||
function init(animeId, initialSource, isLocal, animeData) {
|
function init(animeId, initialSource, isLocal, animeData) {
|
||||||
@@ -57,8 +64,46 @@ const AnimePlayer = (function() {
|
|||||||
els.video = document.getElementById('player');
|
els.video = document.getElementById('player');
|
||||||
els.loader = document.getElementById('player-loading');
|
els.loader = document.getElementById('player-loading');
|
||||||
els.loaderText = document.getElementById('player-loading-text');
|
els.loaderText = document.getElementById('player-loading-text');
|
||||||
|
els.downloadBtn = document.getElementById('download-btn');
|
||||||
|
if (els.downloadBtn) {
|
||||||
|
els.downloadBtn.addEventListener('click', downloadEpisode);
|
||||||
|
}
|
||||||
|
els.downloadModal = document.getElementById('download-modal');
|
||||||
|
els.dlQualityList = document.getElementById('dl-quality-list');
|
||||||
|
els.dlAudioList = document.getElementById('dl-audio-list');
|
||||||
|
els.dlSubsList = document.getElementById('dl-subs-list');
|
||||||
|
els.dlConfirmBtn = document.getElementById('confirm-dl-btn');
|
||||||
|
els.dlCancelBtn = document.getElementById('cancel-dl-btn');
|
||||||
|
|
||||||
|
const closeDlModalBtn = document.getElementById('close-download-modal');
|
||||||
|
|
||||||
|
if (els.dlConfirmBtn) els.dlConfirmBtn.onclick = executeDownload;
|
||||||
|
if (els.dlCancelBtn) els.dlCancelBtn.onclick = () => els.downloadModal.style.display = 'none';
|
||||||
|
if (closeDlModalBtn) closeDlModalBtn.onclick = () => els.downloadModal.style.display = 'none';
|
||||||
|
const closeModal = () => {
|
||||||
|
if (els.downloadModal) {
|
||||||
|
els.downloadModal.classList.remove('show');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
|
||||||
|
if(!els.downloadModal.classList.contains('show')) {
|
||||||
|
els.downloadModal.style.display = 'none';
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (els.dlCancelBtn) els.dlCancelBtn.onclick = closeModal;
|
||||||
|
|
||||||
|
if (closeDlModalBtn) closeDlModalBtn.onclick = closeModal;
|
||||||
els.mpvBtn = document.getElementById('mpv-btn');
|
els.mpvBtn = document.getElementById('mpv-btn');
|
||||||
|
if (els.downloadModal) {
|
||||||
|
els.downloadModal.addEventListener('click', (e) => {
|
||||||
|
|
||||||
|
if (e.target === els.downloadModal) {
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
if (els.mpvBtn) els.mpvBtn.addEventListener('click', openInMPV);
|
if (els.mpvBtn) els.mpvBtn.addEventListener('click', openInMPV);
|
||||||
|
|
||||||
els.serverSelect = document.getElementById('server-select');
|
els.serverSelect = document.getElementById('server-select');
|
||||||
@@ -183,6 +228,11 @@ const AnimePlayer = (function() {
|
|||||||
|
|
||||||
_currentEpisode = targetEp;
|
_currentEpisode = targetEp;
|
||||||
|
|
||||||
|
if (els.downloadBtn) {
|
||||||
|
els.downloadBtn.style.display = _isLocal ? 'none' : 'flex';
|
||||||
|
resetDownloadButtonIcon();
|
||||||
|
}
|
||||||
|
|
||||||
if(els.epTitle) els.epTitle.innerText = `Episode ${targetEp}`;
|
if(els.epTitle) els.epTitle.innerText = `Episode ${targetEp}`;
|
||||||
if(els.prevBtn) els.prevBtn.disabled = (_currentEpisode <= 1);
|
if(els.prevBtn) els.prevBtn.disabled = (_currentEpisode <= 1);
|
||||||
if(els.nextBtn) els.nextBtn.disabled = (_currentEpisode >= _totalEpisodes);
|
if(els.nextBtn) els.nextBtn.disabled = (_currentEpisode >= _totalEpisodes);
|
||||||
@@ -215,6 +265,256 @@ const AnimePlayer = (function() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function downloadEpisode() {
|
||||||
|
if (!_rawVideoData || !_rawVideoData.url) {
|
||||||
|
alert("Stream not loaded yet.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isInFullscreen = document.fullscreenElement || document.webkitFullscreenElement;
|
||||||
|
|
||||||
|
if (isInFullscreen) {
|
||||||
|
try {
|
||||||
|
if (document.exitFullscreen) {
|
||||||
|
await document.exitFullscreen();
|
||||||
|
} else if (document.webkitExitFullscreen) {
|
||||||
|
await document.webkitExitFullscreen();
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("Error al salir de fullscreen:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isM3U8 = hlsInstance && hlsInstance.levels && hlsInstance.levels.length > 0;
|
||||||
|
const hasMultipleAudio = hlsInstance && hlsInstance.audioTracks && hlsInstance.audioTracks.length > 1;
|
||||||
|
|
||||||
|
const hasSubs = _currentSubtitles && _currentSubtitles.length > 0;
|
||||||
|
|
||||||
|
if (isM3U8 || hasMultipleAudio || hasSubs) {
|
||||||
|
|
||||||
|
await new Promise(resolve => requestAnimationFrame(resolve));
|
||||||
|
openDownloadModal();
|
||||||
|
} else {
|
||||||
|
|
||||||
|
executeDownload(null, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDownloadModal() {
|
||||||
|
if(!els.downloadModal) {
|
||||||
|
console.error("Modal element not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
els.dlQualityList.innerHTML = '';
|
||||||
|
els.dlAudioList.innerHTML = '';
|
||||||
|
els.dlSubsList.innerHTML = '';
|
||||||
|
|
||||||
|
let showQuality = false;
|
||||||
|
let showAudio = false;
|
||||||
|
let showSubs = false;
|
||||||
|
|
||||||
|
if (hlsInstance && hlsInstance.levels && hlsInstance.levels.length > 0) {
|
||||||
|
showQuality = true;
|
||||||
|
|
||||||
|
const levels = hlsInstance.levels.map((l, index) => ({...l, originalIndex: index}))
|
||||||
|
.sort((a, b) => b.height - a.height);
|
||||||
|
|
||||||
|
levels.forEach((level, i) => {
|
||||||
|
const isSelected = i === 0;
|
||||||
|
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'dl-item';
|
||||||
|
div.innerHTML = `
|
||||||
|
<input type="radio" name="dl-quality" value="${level.originalIndex}" ${isSelected ? 'checked' : ''}>
|
||||||
|
<span>${level.height}p</span>
|
||||||
|
<span class="tag-info">${(level.bitrate / 1000000).toFixed(1)} Mbps</span>
|
||||||
|
`;
|
||||||
|
div.onclick = (e) => {
|
||||||
|
if(e.target.tagName !== 'INPUT') div.querySelector('input').checked = true;
|
||||||
|
};
|
||||||
|
els.dlQualityList.appendChild(div);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.getElementById('dl-quality-section').style.display = showQuality ? 'block' : 'none';
|
||||||
|
|
||||||
|
if (hlsInstance && hlsInstance.audioTracks && hlsInstance.audioTracks.length > 0) {
|
||||||
|
showAudio = true;
|
||||||
|
hlsInstance.audioTracks.forEach((track, index) => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'dl-item';
|
||||||
|
|
||||||
|
const isCurrent = hlsInstance.audioTrack === index;
|
||||||
|
|
||||||
|
div.innerHTML = `
|
||||||
|
<input type="checkbox" name="dl-audio" value="${index}" checked>
|
||||||
|
<span>${track.name || track.lang || `Audio ${index+1}`}</span>
|
||||||
|
<span class="tag-info">${track.lang || 'unk'}</span>
|
||||||
|
`;
|
||||||
|
div.onclick = (e) => {
|
||||||
|
if(e.target.tagName !== 'INPUT') {
|
||||||
|
const cb = div.querySelector('input');
|
||||||
|
cb.checked = !cb.checked;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
els.dlAudioList.appendChild(div);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.getElementById('dl-audio-section').style.display = showAudio ? 'block' : 'none';
|
||||||
|
|
||||||
|
if (_currentSubtitles && _currentSubtitles.length > 0) {
|
||||||
|
showSubs = true;
|
||||||
|
_currentSubtitles.forEach((sub, index) => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'dl-item';
|
||||||
|
div.innerHTML = `
|
||||||
|
<input type="checkbox" name="dl-subs" value="${index}" checked>
|
||||||
|
<span>${sub.label || sub.language || 'Unknown'}</span>
|
||||||
|
`;
|
||||||
|
div.onclick = (e) => {
|
||||||
|
if(e.target.tagName !== 'INPUT') {
|
||||||
|
const cb = div.querySelector('input');
|
||||||
|
cb.checked = !cb.checked;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
els.dlSubsList.appendChild(div);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.getElementById('dl-subs-section').style.display = showSubs ? 'block' : 'none';
|
||||||
|
|
||||||
|
els.downloadModal.style.display = 'flex';
|
||||||
|
|
||||||
|
els.downloadModal.offsetHeight;
|
||||||
|
els.downloadModal.classList.add('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeDownload(e, skipModal = false) {
|
||||||
|
if(els.downloadModal) {
|
||||||
|
els.downloadModal.classList.remove('show');
|
||||||
|
setTimeout(() => els.downloadModal.style.display = 'none', 300);
|
||||||
|
}
|
||||||
|
const btn = els.downloadBtn;
|
||||||
|
const originalBtnContent = btn.innerHTML;
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = `<div class="spinner" style="width:18px; height:18px; border-width:2px;"></div>`;
|
||||||
|
|
||||||
|
let body = {
|
||||||
|
anilist_id: parseInt(_animeId),
|
||||||
|
episode_number: parseInt(_currentEpisode),
|
||||||
|
stream_url: _rawVideoData.url,
|
||||||
|
headers: _rawVideoData.headers || {},
|
||||||
|
chapters: _skipIntervals.map(i => ({
|
||||||
|
title: i.type === 'op' ? 'Opening' : 'Ending',
|
||||||
|
start_time: i.startTime,
|
||||||
|
end_time: i.endTime
|
||||||
|
})),
|
||||||
|
subtitles: []
|
||||||
|
};
|
||||||
|
|
||||||
|
if (skipModal) {
|
||||||
|
|
||||||
|
if (_currentSubtitles) {
|
||||||
|
body.subtitles = _currentSubtitles.map(sub => ({
|
||||||
|
language: sub.label || 'Unknown',
|
||||||
|
url: sub.src
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
|
||||||
|
const selectedSubs = Array.from(els.dlSubsList.querySelectorAll('input:checked'));
|
||||||
|
body.subtitles = selectedSubs.map(cb => {
|
||||||
|
const i = parseInt(cb.value);
|
||||||
|
return {
|
||||||
|
language: _currentSubtitles[i].label || 'Unknown',
|
||||||
|
url: _currentSubtitles[i].src
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const isQualityVisible = document.getElementById('dl-quality-section').style.display !== 'none';
|
||||||
|
|
||||||
|
if (isQualityVisible && hlsInstance && hlsInstance.levels) {
|
||||||
|
body.is_master = true;
|
||||||
|
|
||||||
|
const qualityInput = document.querySelector('input[name="dl-quality"]:checked');
|
||||||
|
const qualityIndex = qualityInput ? parseInt(qualityInput.value) : 0;
|
||||||
|
const level = hlsInstance.levels[qualityIndex];
|
||||||
|
|
||||||
|
if (level) {
|
||||||
|
body.variant = {
|
||||||
|
resolution: level.width ? `${level.width}x${level.height}` : '1920x1080',
|
||||||
|
bandwidth: level.bitrate,
|
||||||
|
codecs: level.attrs ? level.attrs.CODECS : '',
|
||||||
|
playlist_url: level.url
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const audioInputs = document.querySelectorAll('input[name="dl-audio"]:checked');
|
||||||
|
if (audioInputs.length > 0 && hlsInstance.audioTracks) {
|
||||||
|
body.audio = Array.from(audioInputs).map(input => {
|
||||||
|
const i = parseInt(input.value);
|
||||||
|
const track = hlsInstance.audioTracks[i];
|
||||||
|
return {
|
||||||
|
group: track.groupId || 'audio',
|
||||||
|
language: track.lang || 'unk',
|
||||||
|
name: track.name || `Audio ${i}`,
|
||||||
|
playlist_url: track.url
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
const res = await fetch('/api/library/download/anime', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': token ? `Bearer ${token}` : ''
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (res.status === 200) {
|
||||||
|
|
||||||
|
btn.innerHTML = `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#22c55e" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>`;
|
||||||
|
} else if (res.status === 409) {
|
||||||
|
|
||||||
|
btn.innerHTML = `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fbbf24" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg>`;
|
||||||
|
} else {
|
||||||
|
|
||||||
|
console.error("Download Error:", data);
|
||||||
|
btn.innerHTML = `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>`;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Request failed:", err);
|
||||||
|
btn.innerHTML = `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>`;
|
||||||
|
} finally {
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (btn) {
|
||||||
|
btn.disabled = false;
|
||||||
|
resetDownloadButtonIcon();
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetDownloadButtonIcon() {
|
||||||
|
if (!els.downloadBtn) return;
|
||||||
|
els.downloadBtn.innerHTML = `
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||||
|
<polyline points="7 10 12 15 17 10"></polyline>
|
||||||
|
<line x1="12" y1="15" x2="12" y2="3"></line>
|
||||||
|
</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
function closePlayer() {
|
function closePlayer() {
|
||||||
if (plyrInstance) plyrInstance.destroy();
|
if (plyrInstance) plyrInstance.destroy();
|
||||||
if (hlsInstance) hlsInstance.destroy();
|
if (hlsInstance) hlsInstance.destroy();
|
||||||
@@ -508,40 +808,34 @@ const AnimePlayer = (function() {
|
|||||||
const controls = plyrEl.querySelector('.plyr__controls');
|
const controls = plyrEl.querySelector('.plyr__controls');
|
||||||
if (!controls || controls.querySelector('#quality-control-wrapper')) return;
|
if (!controls || controls.querySelector('#quality-control-wrapper')) return;
|
||||||
|
|
||||||
// 1. Crear el Wrapper
|
|
||||||
const wrapper = document.createElement('div');
|
const wrapper = document.createElement('div');
|
||||||
wrapper.className = 'plyr__controls__item plyr__custom-select-wrapper';
|
wrapper.className = 'plyr__controls__item plyr__custom-select-wrapper';
|
||||||
wrapper.id = 'quality-control-wrapper';
|
wrapper.id = 'quality-control-wrapper';
|
||||||
|
|
||||||
// 2. Crear el Botón Visual (Fake)
|
|
||||||
const btn = document.createElement('div');
|
const btn = document.createElement('div');
|
||||||
btn.className = 'plyr__custom-control-btn';
|
btn.className = 'plyr__custom-control-btn';
|
||||||
// Icono + Texto Inicial
|
|
||||||
btn.innerHTML = `${ICONS.settings} <span class="label-text">Auto</span>`;
|
btn.innerHTML = `${ICONS.settings} <span class="label-text">Auto</span>`;
|
||||||
|
|
||||||
// 3. Crear el Select Real (Invisible)
|
|
||||||
const select = document.createElement('select');
|
const select = document.createElement('select');
|
||||||
select.className = 'plyr__sr-only-select'; // Clase auxiliar si quieres depurar, sino usa el CSS wrapper
|
select.className = 'plyr__sr-only-select';
|
||||||
|
|
||||||
// Opción AUTO
|
|
||||||
const autoOpt = document.createElement('option');
|
const autoOpt = document.createElement('option');
|
||||||
autoOpt.value = -1;
|
autoOpt.value = -1;
|
||||||
autoOpt.textContent = 'Auto';
|
autoOpt.textContent = 'Auto';
|
||||||
select.appendChild(autoOpt);
|
select.appendChild(autoOpt);
|
||||||
|
|
||||||
// Opciones de Niveles
|
|
||||||
levels.forEach((l, i) => {
|
levels.forEach((l, i) => {
|
||||||
const opt = document.createElement('option');
|
const opt = document.createElement('option');
|
||||||
opt.value = i;
|
opt.value = i;
|
||||||
opt.textContent = `${l.height}p`; // Texto que sale en el dropdown nativo
|
opt.textContent = `${l.height}p`;
|
||||||
|
|
||||||
select.appendChild(opt);
|
select.appendChild(opt);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sincronizar estado inicial
|
|
||||||
select.value = hls.currentLevel;
|
select.value = hls.currentLevel;
|
||||||
updateLabel(select.value);
|
updateLabel(select.value);
|
||||||
|
|
||||||
// Evento Change
|
|
||||||
select.onchange = () => {
|
select.onchange = () => {
|
||||||
hls.currentLevel = Number(select.value);
|
hls.currentLevel = Number(select.value);
|
||||||
updateLabel(select.value);
|
updateLabel(select.value);
|
||||||
@@ -551,7 +845,7 @@ const AnimePlayer = (function() {
|
|||||||
const index = Number(val);
|
const index = Number(val);
|
||||||
let text = 'Auto';
|
let text = 'Auto';
|
||||||
if (index !== -1 && levels[index]) {
|
if (index !== -1 && levels[index]) {
|
||||||
// Solo el número + p (ej: 720p)
|
|
||||||
text = `${levels[index].height}p`;
|
text = `${levels[index].height}p`;
|
||||||
}
|
}
|
||||||
btn.innerHTML = `<span class="label-text">${text}</span>`;
|
btn.innerHTML = `<span class="label-text">${text}</span>`;
|
||||||
@@ -560,8 +854,6 @@ const AnimePlayer = (function() {
|
|||||||
wrapper.appendChild(select);
|
wrapper.appendChild(select);
|
||||||
wrapper.appendChild(btn);
|
wrapper.appendChild(btn);
|
||||||
|
|
||||||
// Insertar en controles Plyr (antes del botón de pantalla completa o ajustes)
|
|
||||||
// Insertamos antes del 5º elemento (usualmente settings o fullscreen)
|
|
||||||
const insertIndex = controls.children.length > 4 ? 4 : controls.children.length - 1;
|
const insertIndex = controls.children.length > 4 ? 4 : controls.children.length - 1;
|
||||||
controls.insertBefore(wrapper, controls.children[insertIndex]);
|
controls.insertBefore(wrapper, controls.children[insertIndex]);
|
||||||
}
|
}
|
||||||
@@ -573,17 +865,14 @@ const AnimePlayer = (function() {
|
|||||||
const controls = plyrEl.querySelector('.plyr__controls');
|
const controls = plyrEl.querySelector('.plyr__controls');
|
||||||
if (!controls || controls.querySelector('#audio-control-wrapper')) return;
|
if (!controls || controls.querySelector('#audio-control-wrapper')) return;
|
||||||
|
|
||||||
// 1. Wrapper
|
|
||||||
const wrapper = document.createElement('div');
|
const wrapper = document.createElement('div');
|
||||||
wrapper.className = 'plyr__controls__item plyr__custom-select-wrapper';
|
wrapper.className = 'plyr__controls__item plyr__custom-select-wrapper';
|
||||||
wrapper.id = 'audio-control-wrapper';
|
wrapper.id = 'audio-control-wrapper';
|
||||||
|
|
||||||
// 2. Botón Visual
|
|
||||||
const btn = document.createElement('div');
|
const btn = document.createElement('div');
|
||||||
btn.className = 'plyr__custom-control-btn';
|
btn.className = 'plyr__custom-control-btn';
|
||||||
btn.innerHTML = `<span class="label-text">Audio 1</span>`;
|
btn.innerHTML = `<span class="label-text">Audio 1</span>`;
|
||||||
|
|
||||||
// 3. Select Invisible
|
|
||||||
const select = document.createElement('select');
|
const select = document.createElement('select');
|
||||||
|
|
||||||
hls.audioTracks.forEach((t, i) => {
|
hls.audioTracks.forEach((t, i) => {
|
||||||
@@ -605,10 +894,8 @@ const AnimePlayer = (function() {
|
|||||||
const index = Number(val);
|
const index = Number(val);
|
||||||
const track = hls.audioTracks[index];
|
const track = hls.audioTracks[index];
|
||||||
|
|
||||||
// Priorizamos el idioma (lang), luego el nombre
|
|
||||||
let rawText = track.lang || track.name || `A${index + 1}`;
|
let rawText = track.lang || track.name || `A${index + 1}`;
|
||||||
|
|
||||||
// Tomamos solo las 2 primeras letras y las pasamos a Mayúsculas
|
|
||||||
let shortText = rawText.substring(0, 2).toUpperCase();
|
let shortText = rawText.substring(0, 2).toUpperCase();
|
||||||
|
|
||||||
btn.querySelector('.label-text').innerText = shortText;
|
btn.querySelector('.label-text').innerText = shortText;
|
||||||
@@ -617,7 +904,6 @@ const AnimePlayer = (function() {
|
|||||||
wrapper.appendChild(select);
|
wrapper.appendChild(select);
|
||||||
wrapper.appendChild(btn);
|
wrapper.appendChild(btn);
|
||||||
|
|
||||||
// Insertar antes del selector de calidad si existe, o en la posición 4
|
|
||||||
const qualityWrapper = controls.querySelector('#quality-control-wrapper');
|
const qualityWrapper = controls.querySelector('#quality-control-wrapper');
|
||||||
if(qualityWrapper) {
|
if(qualityWrapper) {
|
||||||
controls.insertBefore(wrapper, qualityWrapper);
|
controls.insertBefore(wrapper, qualityWrapper);
|
||||||
|
|||||||
@@ -60,6 +60,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
|
<button class="btn-icon-glass" id="download-btn" title="Download Episode" style="display: none;">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||||
|
<polyline points="7 10 12 15 17 10"></polyline>
|
||||||
|
<line x1="12" y1="15" x2="12" y2="3"></line>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<div class="settings-group">
|
<div class="settings-group">
|
||||||
<div class="sd-toggle" id="sd-toggle" data-state="sub">
|
<div class="sd-toggle" id="sd-toggle" data-state="sub">
|
||||||
<div class="sd-bg"></div>
|
<div class="sd-bg"></div>
|
||||||
@@ -82,6 +89,34 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="modal-overlay" id="download-modal">
|
||||||
|
<div class="modal-content download-settings-content">
|
||||||
|
<button class="modal-close" id="close-download-modal">✕</button>
|
||||||
|
<h2 class="modal-title">Download Settings</h2>
|
||||||
|
|
||||||
|
<div class="download-sections-wrapper">
|
||||||
|
<div id="dl-quality-section" class="dl-section" style="display:none;">
|
||||||
|
<h3>Video Quality</h3>
|
||||||
|
<div class="dl-list" id="dl-quality-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="dl-audio-section" class="dl-section" style="display:none;">
|
||||||
|
<h3>Audio Tracks</h3>
|
||||||
|
<div class="dl-list" id="dl-audio-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="dl-subs-section" class="dl-section">
|
||||||
|
<h3>Subtitles</h3>
|
||||||
|
<div class="dl-list" id="dl-subs-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dl-actions">
|
||||||
|
<button class="btn-cancel" id="cancel-dl-btn">Cancel</button>
|
||||||
|
<button class="btn-confirm" id="confirm-dl-btn">Start Download</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="content-container" id="main-content">
|
<div class="content-container" id="main-content">
|
||||||
|
|||||||
@@ -599,4 +599,321 @@ body.stop-scrolling {
|
|||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-settings-content {
|
||||||
|
background: #1a1a1a;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 500px;
|
||||||
|
max-height: 85vh;
|
||||||
|
border: 1px solid #333;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-sections-wrapper {
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 5px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dl-section {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dl-section h3 {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--brand-color-light);
|
||||||
|
margin-bottom: 0.8rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.1);
|
||||||
|
padding-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dl-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dl-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
background: rgba(255,255,255,0.05);
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dl-item:hover {
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dl-item input[type="radio"],
|
||||||
|
.dl-item input[type="checkbox"] {
|
||||||
|
accent-color: var(--brand-color);
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dl-item span {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #eee;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dl-item .tag-info {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #aaa;
|
||||||
|
background: rgba(0,0,0,0.3);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dl-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px solid rgba(255,255,255,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid #444;
|
||||||
|
color: #ccc;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-confirm {
|
||||||
|
background: var(--brand-color);
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
padding: 8px 20px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn-confirm:hover {
|
||||||
|
background: #7c3aed;
|
||||||
|
}
|
||||||
|
/* =========================================
|
||||||
|
MODAL DE DESCARGAS - REDISEÑO "GLASS"
|
||||||
|
========================================= */
|
||||||
|
|
||||||
|
#download-modal {
|
||||||
|
position: fixed !important;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
background: rgba(0, 0, 0, 0.6); /* Fondo oscurecido */
|
||||||
|
backdrop-filter: blur(8px); /* Desenfoque del fondo */
|
||||||
|
z-index: 2147483647 !important; /* Encima de todo */
|
||||||
|
display: none; /* Controlado por JS/Clases */
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
pointer-events: none; /* Por defecto no bloquea, JS lo activa */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Estado visible activado por JS */
|
||||||
|
#download-modal.show {
|
||||||
|
display: flex !important;
|
||||||
|
opacity: 1 !important;
|
||||||
|
pointer-events: auto !important;
|
||||||
|
visibility: visible !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-settings-content {
|
||||||
|
background: rgba(20, 20, 20, 0.85); /* Fondo semitransparente oscuro */
|
||||||
|
width: 90%;
|
||||||
|
max-width: 480px;
|
||||||
|
max-height: 85vh;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 16px; /* Bordes más redondeados como anime.css */
|
||||||
|
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.7), 0 0 0 1px rgba(255,255,255,0.05);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 24px;
|
||||||
|
position: relative;
|
||||||
|
transform: scale(0.95);
|
||||||
|
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
color: #fff;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#download-modal.show .download-settings-content {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Botón de cerrar (X) */
|
||||||
|
.download-settings-content .modal-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-settings-content .modal-close:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
margin: 0 0 20px 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 800;
|
||||||
|
background: linear-gradient(to right, #fff, rgba(255,255,255,0.5));
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Wrappers internos */
|
||||||
|
.download-sections-wrapper {
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 8px; /* Espacio para scrollbar */
|
||||||
|
margin-bottom: 20px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar personalizado fino */
|
||||||
|
.download-sections-wrapper::-webkit-scrollbar { width: 6px; }
|
||||||
|
.download-sections-wrapper::-webkit-scrollbar-track { background: rgba(255,255,255,0.02); }
|
||||||
|
.download-sections-wrapper::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.2); border-radius: 10px; }
|
||||||
|
.download-sections-wrapper::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.4); }
|
||||||
|
|
||||||
|
.dl-section { margin-bottom: 1.5rem; }
|
||||||
|
|
||||||
|
.dl-section h3 {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--brand-color-light); /* Morado claro */
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.dl-section h3::after {
|
||||||
|
content: '';
|
||||||
|
flex: 1;
|
||||||
|
height: 1px;
|
||||||
|
background: linear-gradient(to right, rgba(139, 92, 246, 0.3), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dl-list { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
|
||||||
|
/* Items de la lista */
|
||||||
|
.dl-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dl-item:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border-color: rgba(255, 255, 255, 0.1);
|
||||||
|
transform: translateX(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inputs nativos con color de marca */
|
||||||
|
.dl-item input[type="radio"],
|
||||||
|
.dl-item input[type="checkbox"] {
|
||||||
|
accent-color: var(--brand-color);
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dl-item span {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: rgba(255,255,255,0.9);
|
||||||
|
flex: 1;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dl-item .tag-info {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: #ccc;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: 600;
|
||||||
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pie del modal (Botones) */
|
||||||
|
.dl-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.btn-cancel:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: white;
|
||||||
|
border-color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-confirm {
|
||||||
|
background: var(--brand-color);
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
padding: 10px 24px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
box-shadow: 0 4px 15px rgba(139, 92, 246, 0.3);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.btn-confirm:hover {
|
||||||
|
background: #7c3aed; /* Un tono más oscuro del brand */
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 20px rgba(139, 92, 246, 0.4);
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user