From f612960bd26a8c6596b2cf77f3d2e0b73c3d6321 Mon Sep 17 00:00:00 2001 From: lenafx Date: Thu, 1 Jan 2026 16:58:01 +0100 Subject: [PATCH] download anime episodes choosing quality, audio, subs... --- desktop/src/api/local/download.service.ts | 251 +++++++++++------ desktop/src/api/local/local.controller.ts | 119 +++++++- desktop/src/scripts/anime/player.js | 326 ++++++++++++++++++++-- desktop/views/anime/anime.html | 41 ++- desktop/views/css/anime/player.css | 317 +++++++++++++++++++++ docker/src/api/local/download.service.ts | 251 +++++++++++------ docker/src/api/local/local.controller.ts | 119 +++++++- docker/src/scripts/anime/player.js | 326 ++++++++++++++++++++-- docker/views/anime/anime.html | 35 +++ docker/views/css/anime/player.css | 317 +++++++++++++++++++++ 10 files changed, 1848 insertions(+), 254 deletions(-) diff --git a/desktop/src/api/local/download.service.ts b/desktop/src/api/local/download.service.ts index c1266a5..b2f2a18 100644 --- a/desktop/src/api/local/download.service.ts +++ b/desktop/src/api/local/download.service.ts @@ -1,22 +1,20 @@ -import { getConfig as loadConfig } from '../../shared/config.js'; -import { queryOne, queryAll, run } from '../../shared/database.js'; +import { getConfig as loadConfig } from '../../shared/config'; +import { queryOne, queryAll, run } from '../../shared/database'; import { getAnimeById } from '../anime/anime.service'; import { getBookById } from '../books/books.service'; import crypto from 'crypto'; import fs from 'fs'; import path from 'path'; -import { exec } from 'child_process'; -import { promisify } from 'util'; import AdmZip from 'adm-zip'; +import { spawn } from 'child_process'; -const execPromise = promisify(exec); - -const FFMPEG_PATH = 'D:\\ffmpeg\\bin\\ffmpeg.exe'; // Hardcoded como pediste +const FFMPEG_PATH = 'D:\\ffmpeg\\bin\\ffmpeg.exe'; type AnimeDownloadParams = { anilistId: number; episodeNumber: number; streamUrl: string; + headers?: Record; quality?: string; subtitles?: Array<{ language: string; url: string }>; chapters?: Array<{ title: string; start_time: number; end_time: number }>; @@ -50,7 +48,7 @@ async function getOrCreateEntry( type: 'anime' | 'manga' | 'novels' ): Promise<{ id: string; path: string; folderName: string }> { const existing = await queryOne( - `SELECT id, path, folder_name FROM local_entries + `SELECT id, path, folder_name FROM local_entries WHERE matched_id = ? AND matched_source = 'anilist' AND type = ?`, [anilistId, type], 'local_library' @@ -85,12 +83,16 @@ async function getOrCreateEntry( await ensureDirectory(folderPath); - const entryId = crypto.createHash('sha1').update(folderPath).digest('hex'); + const entryId = crypto + .createHash('sha1') + .update(`anilist:${type}:${anilistId}`) + .digest('hex'); const now = Date.now(); await run( - `INSERT INTO local_entries (id, type, path, folder_name, matched_id, matched_source, last_scan) - VALUES (?, ?, ?, ?, ?, 'anilist', ?)`, + `INSERT OR IGNORE INTO local_entries + (id, type, path, folder_name, matched_id, matched_source, last_scan) + VALUES (?, ?, ?, ?, ?, 'anilist', ?)`, [entryId, type, folderPath, safeName, anilistId, now], 'local_library' ); @@ -103,112 +105,182 @@ async function getOrCreateEntry( } export async function downloadAnimeEpisode(params: AnimeDownloadParams) { - const { anilistId, episodeNumber, streamUrl, quality, subtitles, chapters } = params; + const { anilistId, episodeNumber, streamUrl, subtitles, chapters } = params; const entry = await getOrCreateEntry(anilistId, 'anime'); - const existingFile = await queryOne( + const exists = await queryOne( `SELECT id FROM local_files WHERE entry_id = ? AND unit_number = ?`, [entry.id, episodeNumber], 'local_library' ); + if (exists) return { status: 'ALREADY_EXISTS', entry_id: entry.id, episode: episodeNumber }; - if (existingFile) { - return { - status: 'ALREADY_EXISTS', - message: `Episode ${episodeNumber} already exists`, - entry_id: entry.id, - episode: episodeNumber - }; - } - - const outputFileName = `Episode_${episodeNumber.toString().padStart(2, '0')}.mkv`; - const outputPath = path.join(entry.path, outputFileName); + const outputPath = path.join(entry.path, `Episode_${episodeNumber.toString().padStart(2, '0')}.mkv`); const tempDir = path.join(entry.path, '.temp'); await ensureDirectory(tempDir); try { - let inputArgs: string[] = []; let videoInput = streamUrl; + let audioInputs: string[] = []; - if (streamUrl.includes('.m3u8')) { - if (quality) { - const tempM3u8 = path.join(tempDir, 'stream.m3u8'); - await downloadFile(streamUrl, tempM3u8); - const content = fs.readFileSync(tempM3u8, 'utf8'); + const isMaster = (params as any).is_master === true; - const qualities = content.match(/RESOLUTION=\d+x(\d+)/g) || []; - const targetHeight = quality.replace('p', ''); - const targetLine = content.split('\n').find(line => - line.includes(`RESOLUTION=`) && line.includes(`x${targetHeight}`) - ); + if (isMaster) { - if (targetLine) { - const nextLine = content.split('\n')[content.split('\n').indexOf(targetLine) + 1]; - if (nextLine && !nextLine.startsWith('#')) { - const baseUrl = streamUrl.substring(0, streamUrl.lastIndexOf('/') + 1); - videoInput = nextLine.startsWith('http') ? nextLine : baseUrl + nextLine; - } - } + const variant = (params as any).variant; + const audios = (params as any).audio; - fs.unlinkSync(tempM3u8); + if (!variant || !variant.playlist_url) { + throw new Error('VARIANT_REQUIRED_FOR_MASTER'); + } + + videoInput = variant.playlist_url; + + if (audios && audios.length > 0) { + audioInputs = audios.map((a: any) => a.playlist_url); } } - inputArgs = ['-i', videoInput]; - - const subtitleFiles: string[] = []; - if (subtitles && subtitles.length > 0) { + const subFiles: string[] = []; + if (subtitles?.length) { for (let i = 0; i < subtitles.length; i++) { - const sub = subtitles[i]; - const subPath = path.join(tempDir, `subtitle_${i}.${sub.url.endsWith('.vtt') ? 'vtt' : 'srt'}`); - await downloadFile(sub.url, subPath); - subtitleFiles.push(subPath); - inputArgs.push('-i', subPath); + const ext = subtitles[i].url.endsWith('.vtt') ? 'vtt' : 'srt'; + const p = path.join(tempDir, `sub_${i}.${ext}`); + await downloadFile(subtitles[i].url, p); + subFiles.push(p); } } - let ffmpegArgs = [ - ...inputArgs, - '-map', '0:v', - '-map', '0:a', - '-c:v', 'copy', - '-c:a', 'copy' + const args = [ + '-protocol_whitelist', 'file,http,https,tcp,tls,crypto', + '-allowed_extensions', 'ALL', + '-f', 'hls', + '-extension_picky', '0', + '-i', videoInput ]; - for (let i = 0; i < subtitleFiles.length; i++) { - ffmpegArgs.push('-map', `${i + 1}:s`); - ffmpegArgs.push(`-metadata:s:s:${i}`, `language=${subtitles![i].language}`); - } + audioInputs.forEach(audioUrl => { + args.push( + '-protocol_whitelist', 'file,http,https,tcp,tls,crypto', + '-allowed_extensions', 'ALL', + '-f', 'hls', + '-extension_picky', '0', + '-i', audioUrl + ); + }); - if (subtitleFiles.length > 0) { - ffmpegArgs.push('-c:s', 'copy'); - } + subFiles.forEach(f => args.push('-i', f)); - if (chapters && chapters.length > 0) { - const metadataFile = path.join(tempDir, 'chapters.txt'); - let chapterContent = ';FFMETADATA1\n'; - - for (const chapter of chapters) { - const startMs = Math.floor(chapter.start_time * 1000); - const endMs = Math.floor(chapter.end_time * 1000); - - chapterContent += '[CHAPTER]\n'; - chapterContent += `TIMEBASE=1/1000\n`; - chapterContent += `START=${startMs}\n`; - chapterContent += `END=${endMs}\n`; - chapterContent += `title=${chapter.title}\n`; + let chaptersInputIndex = -1; + if (chapters?.length) { + const meta = path.join(tempDir, 'chapters.txt'); + let txt = ';FFMETADATA1\n'; + for (const c of chapters) { + txt += `[CHAPTER]\nTIMEBASE=1/1000\nSTART=${c.start_time * 1000 | 0}\nEND=${c.end_time * 1000 | 0}\ntitle=${c.title}\n`; } - - fs.writeFileSync(metadataFile, chapterContent); - ffmpegArgs.push('-i', metadataFile); - ffmpegArgs.push('-map_metadata', `${inputArgs.length / 2}`); + fs.writeFileSync(meta, txt); + args.push('-i', meta); + chaptersInputIndex = 1 + audioInputs.length + subFiles.length; } - ffmpegArgs.push(outputPath); + args.push('-map', '0:v:0'); - const command = `${FFMPEG_PATH} ${ffmpegArgs.join(' ')}`; - await execPromise(command, { maxBuffer: 1024 * 1024 * 100 }); + if (audioInputs.length > 0) { + + audioInputs.forEach((_, i) => { + args.push('-map', `${i + 1}:a:0`); + + const audioInfo = (params as any).audio?.[i]; + if (audioInfo) { + const audioStreamIndex = i; + if (audioInfo.language) { + args.push(`-metadata:s:a:${audioStreamIndex}`, `language=${audioInfo.language}`); + } + if (audioInfo.name) { + args.push(`-metadata:s:a:${audioStreamIndex}`, `title=${audioInfo.name}`); + } + } + }); + } else { + + args.push('-map', '0:a:0?'); + } + + const subtitleStartIndex = 1 + audioInputs.length; + subFiles.forEach((_, i) => { + args.push('-map', `${subtitleStartIndex + i}:0`); + args.push(`-metadata:s:s:${i}`, `language=${subtitles![i].language}`); + }); + + if (chaptersInputIndex >= 0) { + args.push('-map_metadata', `${chaptersInputIndex}`); + } + + args.push('-c:v', 'copy'); + + args.push('-c:a', 'copy'); + + if (subFiles.length) { + args.push('-c:s', 'srt'); + + } + + args.push('-y'); + + args.push(outputPath); + + await new Promise((resolve, reject) => { + console.log('🎬 Iniciando descarga con FFmpeg...'); + console.log('📹 Video:', videoInput); + if (audioInputs.length > 0) { + console.log('🔊 Audio tracks:', audioInputs.length); + } + console.log('💾 Output:', outputPath); + console.log('Args:', args.join(' ')); + + const ff = spawn(FFMPEG_PATH, args, { + windowsHide: true, + stdio: ['ignore', 'pipe', 'pipe'] + }); + + let lastProgress = ''; + + ff.stdout.on('data', (data) => { + const text = data.toString(); + console.log('[stdout]', text); + }); + + ff.stderr.on('data', (data) => { + const text = data.toString(); + + if (text.includes('time=') || text.includes('speed=')) { + const timeMatch = text.match(/time=(\S+)/); + const speedMatch = text.match(/speed=(\S+)/); + if (timeMatch || speedMatch) { + lastProgress = `⏱️ Time: ${timeMatch?.[1] || 'N/A'} | Speed: ${speedMatch?.[1] || 'N/A'}`; + console.log(lastProgress); + } + } else { + console.log('[ffmpeg]', text); + } + }); + + ff.on('error', (error) => { + console.error('❌ Error al iniciar FFmpeg:', error); + reject(error); + }); + + ff.on('close', (code) => { + if (code === 0) { + console.log('✅ Descarga completada exitosamente'); + resolve(true); + } else { + console.error(`❌ FFmpeg terminó con código: ${code}`); + reject(new Error(`FFmpeg exited with code ${code}`)); + } + }); + }); fs.rmSync(tempDir, { recursive: true, force: true }); @@ -234,14 +306,11 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) { path: outputPath }; - } catch (error: any) { + } catch (e: any) { fs.rmSync(tempDir, { recursive: true, force: true }); - if (fs.existsSync(outputPath)) { - fs.unlinkSync(outputPath); - } - + if (fs.existsSync(outputPath)) fs.unlinkSync(outputPath); const err = new Error('DOWNLOAD_FAILED'); - (err as any).details = error.message; + (err as any).details = e.message; throw err; } } diff --git a/desktop/src/api/local/local.controller.ts b/desktop/src/api/local/local.controller.ts index 00f28e0..032d58f 100644 --- a/desktop/src/api/local/local.controller.ts +++ b/desktop/src/api/local/local.controller.ts @@ -17,20 +17,52 @@ type MatchBody = { matched_id: number | null; }; -type DownloadAnimeBody = { +type DownloadAnimeBody = + | { anilist_id: number; episode_number: number; - stream_url: string; - quality?: string; - subtitles?: Array<{ + stream_url: string; // media playlist FINAL + is_master?: false; + subtitles?: { language: string; url: string; - }>; - chapters?: Array<{ + }[]; + chapters?: { title: string; start_time: number; end_time: number; - }>; + }[]; +} + | { + anilist_id: number; + episode_number: number; + stream_url: string; // master.m3u8 + is_master: true; + + variant: { + resolution: string; + bandwidth?: number; + codecs?: string; + playlist_url: string; + }; + + audio?: { + group?: string; + language?: string; + name?: string; + playlist_url: string; + }[]; + + subtitles?: { + language: string; + url: string; + }[]; + + chapters?: { + title: string; + start_time: number; + end_time: number; + }[]; }; type DownloadBookBody = { @@ -212,17 +244,30 @@ export async function getPage(request: FastifyRequest, reply: FastifyReply) { return reply.status(400).send(); } +function buildProxyUrl(rawUrl: string, headers: Record) { + const params = new URLSearchParams({ url: rawUrl }); + + for (const [key, value] of Object.entries(headers)) { + params.set(key.toLowerCase(), value); + } + + return `http://localhost:54322/api/proxy?${params.toString()}`; +} + export async function downloadAnime(request: FastifyRequest<{ Body: DownloadAnimeBody }>, reply: FastifyReply) { try { const { anilist_id, episode_number, stream_url, - quality, + is_master, subtitles, chapters } = request.body; + const clientHeaders = (request.body as any).headers || {}; + + // Validación básica if (!anilist_id || !episode_number || !stream_url) { return reply.status(400).send({ error: 'MISSING_REQUIRED_FIELDS', @@ -230,14 +275,58 @@ export async function downloadAnime(request: FastifyRequest<{ Body: DownloadAnim }); } - const result = await downloadService.downloadAnimeEpisode({ + // Proxy del stream URL principal + const proxyUrl = buildProxyUrl(stream_url, clientHeaders); + console.log('Stream URL:', proxyUrl); + + // Proxy de subtítulos + const proxiedSubs = subtitles?.map(sub => ({ + ...sub, + url: buildProxyUrl(sub.url, clientHeaders) + })); + + // Preparar parámetros base + const downloadParams: any = { anilistId: anilist_id, episodeNumber: episode_number, - streamUrl: stream_url, - quality, - subtitles, + streamUrl: proxyUrl, + subtitles: proxiedSubs, chapters - }); + }; + + // Si es master playlist, agregar campos adicionales + if (is_master === true) { + const { variant, audio } = request.body as any; + + if (!variant || !variant.playlist_url) { + return reply.status(400).send({ + error: 'MISSING_VARIANT', + message: 'variant with playlist_url is required when is_master is true' + }); + } + + downloadParams.is_master = true; + + // Proxy del variant playlist + downloadParams.variant = { + ...variant, + playlist_url: buildProxyUrl(variant.playlist_url, clientHeaders) + }; + + // Proxy de audio tracks si existen + if (audio && audio.length > 0) { + downloadParams.audio = audio.map((a: any) => ({ + ...a, + playlist_url: buildProxyUrl(a.playlist_url, clientHeaders) + })); + } + + console.log('Master playlist detected'); + console.log('Variant:', downloadParams.variant.resolution); + console.log('Audio tracks:', downloadParams.audio?.length || 0); + } + + const result = await downloadService.downloadAnimeEpisode(downloadParams); if (result.status === 'ALREADY_EXISTS') { return reply.status(409).send(result); @@ -251,6 +340,10 @@ export async function downloadAnime(request: FastifyRequest<{ Body: DownloadAnim return reply.status(404).send({ error: 'ANIME_NOT_FOUND_IN_ANILIST' }); } + if (err.message === 'VARIANT_REQUIRED_FOR_MASTER') { + return reply.status(400).send({ error: 'VARIANT_REQUIRED_FOR_MASTER' }); + } + if (err.message === 'DOWNLOAD_FAILED') { return reply.status(500).send({ error: 'DOWNLOAD_FAILED', details: err.details }); } diff --git a/desktop/src/scripts/anime/player.js b/desktop/src/scripts/anime/player.js index f638b9b..16880ae 100644 --- a/desktop/src/scripts/anime/player.js +++ b/desktop/src/scripts/anime/player.js @@ -34,7 +34,14 @@ const AnimePlayer = (function() { epTitle: null, prevBtn: 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) { @@ -57,8 +64,46 @@ const AnimePlayer = (function() { els.video = document.getElementById('player'); els.loader = document.getElementById('player-loading'); 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'); + if (els.downloadModal) { + els.downloadModal.addEventListener('click', (e) => { + + if (e.target === els.downloadModal) { + closeModal(); + } + }); + } if (els.mpvBtn) els.mpvBtn.addEventListener('click', openInMPV); els.serverSelect = document.getElementById('server-select'); @@ -183,6 +228,11 @@ const AnimePlayer = (function() { _currentEpisode = targetEp; + if (els.downloadBtn) { + els.downloadBtn.style.display = _isLocal ? 'none' : 'flex'; + resetDownloadButtonIcon(); + } + if(els.epTitle) els.epTitle.innerText = `Episode ${targetEp}`; if(els.prevBtn) els.prevBtn.disabled = (_currentEpisode <= 1); 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 = ` + + ${level.height}p + ${(level.bitrate / 1000000).toFixed(1)} Mbps + `; + 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 = ` + + ${track.name || track.lang || `Audio ${index+1}`} + ${track.lang || 'unk'} + `; + 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 = ` + + ${sub.label || sub.language || 'Unknown'} + `; + 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 = `
`; + + 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 = ``; + } else if (res.status === 409) { + + btn.innerHTML = ``; + } else { + + console.error("Download Error:", data); + btn.innerHTML = ``; + } + } catch (err) { + console.error("Request failed:", err); + btn.innerHTML = ``; + } finally { + + setTimeout(() => { + if (btn) { + btn.disabled = false; + resetDownloadButtonIcon(); + } + }, 3000); + } + } + + function resetDownloadButtonIcon() { + if (!els.downloadBtn) return; + els.downloadBtn.innerHTML = ` + + + + + `; + } + function closePlayer() { if (plyrInstance) plyrInstance.destroy(); if (hlsInstance) hlsInstance.destroy(); @@ -508,40 +808,34 @@ const AnimePlayer = (function() { const controls = plyrEl.querySelector('.plyr__controls'); if (!controls || controls.querySelector('#quality-control-wrapper')) return; - // 1. Crear el Wrapper const wrapper = document.createElement('div'); wrapper.className = 'plyr__controls__item plyr__custom-select-wrapper'; wrapper.id = 'quality-control-wrapper'; - // 2. Crear el Botón Visual (Fake) const btn = document.createElement('div'); btn.className = 'plyr__custom-control-btn'; - // Icono + Texto Inicial + btn.innerHTML = `${ICONS.settings} Auto`; - // 3. Crear el Select Real (Invisible) 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'); autoOpt.value = -1; autoOpt.textContent = 'Auto'; select.appendChild(autoOpt); - // Opciones de Niveles levels.forEach((l, i) => { const opt = document.createElement('option'); opt.value = i; - opt.textContent = `${l.height}p`; // Texto que sale en el dropdown nativo + opt.textContent = `${l.height}p`; + select.appendChild(opt); }); - // Sincronizar estado inicial select.value = hls.currentLevel; updateLabel(select.value); - // Evento Change select.onchange = () => { hls.currentLevel = Number(select.value); updateLabel(select.value); @@ -551,7 +845,7 @@ const AnimePlayer = (function() { const index = Number(val); let text = 'Auto'; if (index !== -1 && levels[index]) { - // Solo el número + p (ej: 720p) + text = `${levels[index].height}p`; } btn.innerHTML = `${text}`; @@ -560,8 +854,6 @@ const AnimePlayer = (function() { wrapper.appendChild(select); 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; controls.insertBefore(wrapper, controls.children[insertIndex]); } @@ -573,17 +865,14 @@ const AnimePlayer = (function() { const controls = plyrEl.querySelector('.plyr__controls'); if (!controls || controls.querySelector('#audio-control-wrapper')) return; - // 1. Wrapper const wrapper = document.createElement('div'); wrapper.className = 'plyr__controls__item plyr__custom-select-wrapper'; wrapper.id = 'audio-control-wrapper'; - // 2. Botón Visual const btn = document.createElement('div'); btn.className = 'plyr__custom-control-btn'; btn.innerHTML = `Audio 1`; - // 3. Select Invisible const select = document.createElement('select'); hls.audioTracks.forEach((t, i) => { @@ -605,10 +894,8 @@ const AnimePlayer = (function() { const index = Number(val); const track = hls.audioTracks[index]; - // Priorizamos el idioma (lang), luego el nombre 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(); btn.querySelector('.label-text').innerText = shortText; @@ -617,7 +904,6 @@ const AnimePlayer = (function() { wrapper.appendChild(select); wrapper.appendChild(btn); - // Insertar antes del selector de calidad si existe, o en la posición 4 const qualityWrapper = controls.querySelector('#quality-control-wrapper'); if(qualityWrapper) { controls.insertBefore(wrapper, qualityWrapper); diff --git a/desktop/views/anime/anime.html b/desktop/views/anime/anime.html index 7f88f1d..df3fc46 100644 --- a/desktop/views/anime/anime.html +++ b/desktop/views/anime/anime.html @@ -73,13 +73,14 @@
+
-
Sub
@@ -101,6 +102,34 @@
+
diff --git a/desktop/views/css/anime/player.css b/desktop/views/css/anime/player.css index f8ce98d..d25c571 100644 --- a/desktop/views/css/anime/player.css +++ b/desktop/views/css/anime/player.css @@ -599,4 +599,321 @@ body.stop-scrolling { font-size: 10px; font-weight: 700; 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); } \ No newline at end of file diff --git a/docker/src/api/local/download.service.ts b/docker/src/api/local/download.service.ts index c1266a5..b2f2a18 100644 --- a/docker/src/api/local/download.service.ts +++ b/docker/src/api/local/download.service.ts @@ -1,22 +1,20 @@ -import { getConfig as loadConfig } from '../../shared/config.js'; -import { queryOne, queryAll, run } from '../../shared/database.js'; +import { getConfig as loadConfig } from '../../shared/config'; +import { queryOne, queryAll, run } from '../../shared/database'; import { getAnimeById } from '../anime/anime.service'; import { getBookById } from '../books/books.service'; import crypto from 'crypto'; import fs from 'fs'; import path from 'path'; -import { exec } from 'child_process'; -import { promisify } from 'util'; import AdmZip from 'adm-zip'; +import { spawn } from 'child_process'; -const execPromise = promisify(exec); - -const FFMPEG_PATH = 'D:\\ffmpeg\\bin\\ffmpeg.exe'; // Hardcoded como pediste +const FFMPEG_PATH = 'D:\\ffmpeg\\bin\\ffmpeg.exe'; type AnimeDownloadParams = { anilistId: number; episodeNumber: number; streamUrl: string; + headers?: Record; quality?: string; subtitles?: Array<{ language: string; url: string }>; chapters?: Array<{ title: string; start_time: number; end_time: number }>; @@ -50,7 +48,7 @@ async function getOrCreateEntry( type: 'anime' | 'manga' | 'novels' ): Promise<{ id: string; path: string; folderName: string }> { const existing = await queryOne( - `SELECT id, path, folder_name FROM local_entries + `SELECT id, path, folder_name FROM local_entries WHERE matched_id = ? AND matched_source = 'anilist' AND type = ?`, [anilistId, type], 'local_library' @@ -85,12 +83,16 @@ async function getOrCreateEntry( await ensureDirectory(folderPath); - const entryId = crypto.createHash('sha1').update(folderPath).digest('hex'); + const entryId = crypto + .createHash('sha1') + .update(`anilist:${type}:${anilistId}`) + .digest('hex'); const now = Date.now(); await run( - `INSERT INTO local_entries (id, type, path, folder_name, matched_id, matched_source, last_scan) - VALUES (?, ?, ?, ?, ?, 'anilist', ?)`, + `INSERT OR IGNORE INTO local_entries + (id, type, path, folder_name, matched_id, matched_source, last_scan) + VALUES (?, ?, ?, ?, ?, 'anilist', ?)`, [entryId, type, folderPath, safeName, anilistId, now], 'local_library' ); @@ -103,112 +105,182 @@ async function getOrCreateEntry( } export async function downloadAnimeEpisode(params: AnimeDownloadParams) { - const { anilistId, episodeNumber, streamUrl, quality, subtitles, chapters } = params; + const { anilistId, episodeNumber, streamUrl, subtitles, chapters } = params; const entry = await getOrCreateEntry(anilistId, 'anime'); - const existingFile = await queryOne( + const exists = await queryOne( `SELECT id FROM local_files WHERE entry_id = ? AND unit_number = ?`, [entry.id, episodeNumber], 'local_library' ); + if (exists) return { status: 'ALREADY_EXISTS', entry_id: entry.id, episode: episodeNumber }; - if (existingFile) { - return { - status: 'ALREADY_EXISTS', - message: `Episode ${episodeNumber} already exists`, - entry_id: entry.id, - episode: episodeNumber - }; - } - - const outputFileName = `Episode_${episodeNumber.toString().padStart(2, '0')}.mkv`; - const outputPath = path.join(entry.path, outputFileName); + const outputPath = path.join(entry.path, `Episode_${episodeNumber.toString().padStart(2, '0')}.mkv`); const tempDir = path.join(entry.path, '.temp'); await ensureDirectory(tempDir); try { - let inputArgs: string[] = []; let videoInput = streamUrl; + let audioInputs: string[] = []; - if (streamUrl.includes('.m3u8')) { - if (quality) { - const tempM3u8 = path.join(tempDir, 'stream.m3u8'); - await downloadFile(streamUrl, tempM3u8); - const content = fs.readFileSync(tempM3u8, 'utf8'); + const isMaster = (params as any).is_master === true; - const qualities = content.match(/RESOLUTION=\d+x(\d+)/g) || []; - const targetHeight = quality.replace('p', ''); - const targetLine = content.split('\n').find(line => - line.includes(`RESOLUTION=`) && line.includes(`x${targetHeight}`) - ); + if (isMaster) { - if (targetLine) { - const nextLine = content.split('\n')[content.split('\n').indexOf(targetLine) + 1]; - if (nextLine && !nextLine.startsWith('#')) { - const baseUrl = streamUrl.substring(0, streamUrl.lastIndexOf('/') + 1); - videoInput = nextLine.startsWith('http') ? nextLine : baseUrl + nextLine; - } - } + const variant = (params as any).variant; + const audios = (params as any).audio; - fs.unlinkSync(tempM3u8); + if (!variant || !variant.playlist_url) { + throw new Error('VARIANT_REQUIRED_FOR_MASTER'); + } + + videoInput = variant.playlist_url; + + if (audios && audios.length > 0) { + audioInputs = audios.map((a: any) => a.playlist_url); } } - inputArgs = ['-i', videoInput]; - - const subtitleFiles: string[] = []; - if (subtitles && subtitles.length > 0) { + const subFiles: string[] = []; + if (subtitles?.length) { for (let i = 0; i < subtitles.length; i++) { - const sub = subtitles[i]; - const subPath = path.join(tempDir, `subtitle_${i}.${sub.url.endsWith('.vtt') ? 'vtt' : 'srt'}`); - await downloadFile(sub.url, subPath); - subtitleFiles.push(subPath); - inputArgs.push('-i', subPath); + const ext = subtitles[i].url.endsWith('.vtt') ? 'vtt' : 'srt'; + const p = path.join(tempDir, `sub_${i}.${ext}`); + await downloadFile(subtitles[i].url, p); + subFiles.push(p); } } - let ffmpegArgs = [ - ...inputArgs, - '-map', '0:v', - '-map', '0:a', - '-c:v', 'copy', - '-c:a', 'copy' + const args = [ + '-protocol_whitelist', 'file,http,https,tcp,tls,crypto', + '-allowed_extensions', 'ALL', + '-f', 'hls', + '-extension_picky', '0', + '-i', videoInput ]; - for (let i = 0; i < subtitleFiles.length; i++) { - ffmpegArgs.push('-map', `${i + 1}:s`); - ffmpegArgs.push(`-metadata:s:s:${i}`, `language=${subtitles![i].language}`); - } + audioInputs.forEach(audioUrl => { + args.push( + '-protocol_whitelist', 'file,http,https,tcp,tls,crypto', + '-allowed_extensions', 'ALL', + '-f', 'hls', + '-extension_picky', '0', + '-i', audioUrl + ); + }); - if (subtitleFiles.length > 0) { - ffmpegArgs.push('-c:s', 'copy'); - } + subFiles.forEach(f => args.push('-i', f)); - if (chapters && chapters.length > 0) { - const metadataFile = path.join(tempDir, 'chapters.txt'); - let chapterContent = ';FFMETADATA1\n'; - - for (const chapter of chapters) { - const startMs = Math.floor(chapter.start_time * 1000); - const endMs = Math.floor(chapter.end_time * 1000); - - chapterContent += '[CHAPTER]\n'; - chapterContent += `TIMEBASE=1/1000\n`; - chapterContent += `START=${startMs}\n`; - chapterContent += `END=${endMs}\n`; - chapterContent += `title=${chapter.title}\n`; + let chaptersInputIndex = -1; + if (chapters?.length) { + const meta = path.join(tempDir, 'chapters.txt'); + let txt = ';FFMETADATA1\n'; + for (const c of chapters) { + txt += `[CHAPTER]\nTIMEBASE=1/1000\nSTART=${c.start_time * 1000 | 0}\nEND=${c.end_time * 1000 | 0}\ntitle=${c.title}\n`; } - - fs.writeFileSync(metadataFile, chapterContent); - ffmpegArgs.push('-i', metadataFile); - ffmpegArgs.push('-map_metadata', `${inputArgs.length / 2}`); + fs.writeFileSync(meta, txt); + args.push('-i', meta); + chaptersInputIndex = 1 + audioInputs.length + subFiles.length; } - ffmpegArgs.push(outputPath); + args.push('-map', '0:v:0'); - const command = `${FFMPEG_PATH} ${ffmpegArgs.join(' ')}`; - await execPromise(command, { maxBuffer: 1024 * 1024 * 100 }); + if (audioInputs.length > 0) { + + audioInputs.forEach((_, i) => { + args.push('-map', `${i + 1}:a:0`); + + const audioInfo = (params as any).audio?.[i]; + if (audioInfo) { + const audioStreamIndex = i; + if (audioInfo.language) { + args.push(`-metadata:s:a:${audioStreamIndex}`, `language=${audioInfo.language}`); + } + if (audioInfo.name) { + args.push(`-metadata:s:a:${audioStreamIndex}`, `title=${audioInfo.name}`); + } + } + }); + } else { + + args.push('-map', '0:a:0?'); + } + + const subtitleStartIndex = 1 + audioInputs.length; + subFiles.forEach((_, i) => { + args.push('-map', `${subtitleStartIndex + i}:0`); + args.push(`-metadata:s:s:${i}`, `language=${subtitles![i].language}`); + }); + + if (chaptersInputIndex >= 0) { + args.push('-map_metadata', `${chaptersInputIndex}`); + } + + args.push('-c:v', 'copy'); + + args.push('-c:a', 'copy'); + + if (subFiles.length) { + args.push('-c:s', 'srt'); + + } + + args.push('-y'); + + args.push(outputPath); + + await new Promise((resolve, reject) => { + console.log('🎬 Iniciando descarga con FFmpeg...'); + console.log('📹 Video:', videoInput); + if (audioInputs.length > 0) { + console.log('🔊 Audio tracks:', audioInputs.length); + } + console.log('💾 Output:', outputPath); + console.log('Args:', args.join(' ')); + + const ff = spawn(FFMPEG_PATH, args, { + windowsHide: true, + stdio: ['ignore', 'pipe', 'pipe'] + }); + + let lastProgress = ''; + + ff.stdout.on('data', (data) => { + const text = data.toString(); + console.log('[stdout]', text); + }); + + ff.stderr.on('data', (data) => { + const text = data.toString(); + + if (text.includes('time=') || text.includes('speed=')) { + const timeMatch = text.match(/time=(\S+)/); + const speedMatch = text.match(/speed=(\S+)/); + if (timeMatch || speedMatch) { + lastProgress = `⏱️ Time: ${timeMatch?.[1] || 'N/A'} | Speed: ${speedMatch?.[1] || 'N/A'}`; + console.log(lastProgress); + } + } else { + console.log('[ffmpeg]', text); + } + }); + + ff.on('error', (error) => { + console.error('❌ Error al iniciar FFmpeg:', error); + reject(error); + }); + + ff.on('close', (code) => { + if (code === 0) { + console.log('✅ Descarga completada exitosamente'); + resolve(true); + } else { + console.error(`❌ FFmpeg terminó con código: ${code}`); + reject(new Error(`FFmpeg exited with code ${code}`)); + } + }); + }); fs.rmSync(tempDir, { recursive: true, force: true }); @@ -234,14 +306,11 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) { path: outputPath }; - } catch (error: any) { + } catch (e: any) { fs.rmSync(tempDir, { recursive: true, force: true }); - if (fs.existsSync(outputPath)) { - fs.unlinkSync(outputPath); - } - + if (fs.existsSync(outputPath)) fs.unlinkSync(outputPath); const err = new Error('DOWNLOAD_FAILED'); - (err as any).details = error.message; + (err as any).details = e.message; throw err; } } diff --git a/docker/src/api/local/local.controller.ts b/docker/src/api/local/local.controller.ts index 00f28e0..032d58f 100644 --- a/docker/src/api/local/local.controller.ts +++ b/docker/src/api/local/local.controller.ts @@ -17,20 +17,52 @@ type MatchBody = { matched_id: number | null; }; -type DownloadAnimeBody = { +type DownloadAnimeBody = + | { anilist_id: number; episode_number: number; - stream_url: string; - quality?: string; - subtitles?: Array<{ + stream_url: string; // media playlist FINAL + is_master?: false; + subtitles?: { language: string; url: string; - }>; - chapters?: Array<{ + }[]; + chapters?: { title: string; start_time: number; end_time: number; - }>; + }[]; +} + | { + anilist_id: number; + episode_number: number; + stream_url: string; // master.m3u8 + is_master: true; + + variant: { + resolution: string; + bandwidth?: number; + codecs?: string; + playlist_url: string; + }; + + audio?: { + group?: string; + language?: string; + name?: string; + playlist_url: string; + }[]; + + subtitles?: { + language: string; + url: string; + }[]; + + chapters?: { + title: string; + start_time: number; + end_time: number; + }[]; }; type DownloadBookBody = { @@ -212,17 +244,30 @@ export async function getPage(request: FastifyRequest, reply: FastifyReply) { return reply.status(400).send(); } +function buildProxyUrl(rawUrl: string, headers: Record) { + const params = new URLSearchParams({ url: rawUrl }); + + for (const [key, value] of Object.entries(headers)) { + params.set(key.toLowerCase(), value); + } + + return `http://localhost:54322/api/proxy?${params.toString()}`; +} + export async function downloadAnime(request: FastifyRequest<{ Body: DownloadAnimeBody }>, reply: FastifyReply) { try { const { anilist_id, episode_number, stream_url, - quality, + is_master, subtitles, chapters } = request.body; + const clientHeaders = (request.body as any).headers || {}; + + // Validación básica if (!anilist_id || !episode_number || !stream_url) { return reply.status(400).send({ error: 'MISSING_REQUIRED_FIELDS', @@ -230,14 +275,58 @@ export async function downloadAnime(request: FastifyRequest<{ Body: DownloadAnim }); } - const result = await downloadService.downloadAnimeEpisode({ + // Proxy del stream URL principal + const proxyUrl = buildProxyUrl(stream_url, clientHeaders); + console.log('Stream URL:', proxyUrl); + + // Proxy de subtítulos + const proxiedSubs = subtitles?.map(sub => ({ + ...sub, + url: buildProxyUrl(sub.url, clientHeaders) + })); + + // Preparar parámetros base + const downloadParams: any = { anilistId: anilist_id, episodeNumber: episode_number, - streamUrl: stream_url, - quality, - subtitles, + streamUrl: proxyUrl, + subtitles: proxiedSubs, chapters - }); + }; + + // Si es master playlist, agregar campos adicionales + if (is_master === true) { + const { variant, audio } = request.body as any; + + if (!variant || !variant.playlist_url) { + return reply.status(400).send({ + error: 'MISSING_VARIANT', + message: 'variant with playlist_url is required when is_master is true' + }); + } + + downloadParams.is_master = true; + + // Proxy del variant playlist + downloadParams.variant = { + ...variant, + playlist_url: buildProxyUrl(variant.playlist_url, clientHeaders) + }; + + // Proxy de audio tracks si existen + if (audio && audio.length > 0) { + downloadParams.audio = audio.map((a: any) => ({ + ...a, + playlist_url: buildProxyUrl(a.playlist_url, clientHeaders) + })); + } + + console.log('Master playlist detected'); + console.log('Variant:', downloadParams.variant.resolution); + console.log('Audio tracks:', downloadParams.audio?.length || 0); + } + + const result = await downloadService.downloadAnimeEpisode(downloadParams); if (result.status === 'ALREADY_EXISTS') { return reply.status(409).send(result); @@ -251,6 +340,10 @@ export async function downloadAnime(request: FastifyRequest<{ Body: DownloadAnim return reply.status(404).send({ error: 'ANIME_NOT_FOUND_IN_ANILIST' }); } + if (err.message === 'VARIANT_REQUIRED_FOR_MASTER') { + return reply.status(400).send({ error: 'VARIANT_REQUIRED_FOR_MASTER' }); + } + if (err.message === 'DOWNLOAD_FAILED') { return reply.status(500).send({ error: 'DOWNLOAD_FAILED', details: err.details }); } diff --git a/docker/src/scripts/anime/player.js b/docker/src/scripts/anime/player.js index f638b9b..16880ae 100644 --- a/docker/src/scripts/anime/player.js +++ b/docker/src/scripts/anime/player.js @@ -34,7 +34,14 @@ const AnimePlayer = (function() { epTitle: null, prevBtn: 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) { @@ -57,8 +64,46 @@ const AnimePlayer = (function() { els.video = document.getElementById('player'); els.loader = document.getElementById('player-loading'); 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'); + if (els.downloadModal) { + els.downloadModal.addEventListener('click', (e) => { + + if (e.target === els.downloadModal) { + closeModal(); + } + }); + } if (els.mpvBtn) els.mpvBtn.addEventListener('click', openInMPV); els.serverSelect = document.getElementById('server-select'); @@ -183,6 +228,11 @@ const AnimePlayer = (function() { _currentEpisode = targetEp; + if (els.downloadBtn) { + els.downloadBtn.style.display = _isLocal ? 'none' : 'flex'; + resetDownloadButtonIcon(); + } + if(els.epTitle) els.epTitle.innerText = `Episode ${targetEp}`; if(els.prevBtn) els.prevBtn.disabled = (_currentEpisode <= 1); 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 = ` + + ${level.height}p + ${(level.bitrate / 1000000).toFixed(1)} Mbps + `; + 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 = ` + + ${track.name || track.lang || `Audio ${index+1}`} + ${track.lang || 'unk'} + `; + 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 = ` + + ${sub.label || sub.language || 'Unknown'} + `; + 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 = `
`; + + 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 = ``; + } else if (res.status === 409) { + + btn.innerHTML = ``; + } else { + + console.error("Download Error:", data); + btn.innerHTML = ``; + } + } catch (err) { + console.error("Request failed:", err); + btn.innerHTML = ``; + } finally { + + setTimeout(() => { + if (btn) { + btn.disabled = false; + resetDownloadButtonIcon(); + } + }, 3000); + } + } + + function resetDownloadButtonIcon() { + if (!els.downloadBtn) return; + els.downloadBtn.innerHTML = ` + + + + + `; + } + function closePlayer() { if (plyrInstance) plyrInstance.destroy(); if (hlsInstance) hlsInstance.destroy(); @@ -508,40 +808,34 @@ const AnimePlayer = (function() { const controls = plyrEl.querySelector('.plyr__controls'); if (!controls || controls.querySelector('#quality-control-wrapper')) return; - // 1. Crear el Wrapper const wrapper = document.createElement('div'); wrapper.className = 'plyr__controls__item plyr__custom-select-wrapper'; wrapper.id = 'quality-control-wrapper'; - // 2. Crear el Botón Visual (Fake) const btn = document.createElement('div'); btn.className = 'plyr__custom-control-btn'; - // Icono + Texto Inicial + btn.innerHTML = `${ICONS.settings} Auto`; - // 3. Crear el Select Real (Invisible) 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'); autoOpt.value = -1; autoOpt.textContent = 'Auto'; select.appendChild(autoOpt); - // Opciones de Niveles levels.forEach((l, i) => { const opt = document.createElement('option'); opt.value = i; - opt.textContent = `${l.height}p`; // Texto que sale en el dropdown nativo + opt.textContent = `${l.height}p`; + select.appendChild(opt); }); - // Sincronizar estado inicial select.value = hls.currentLevel; updateLabel(select.value); - // Evento Change select.onchange = () => { hls.currentLevel = Number(select.value); updateLabel(select.value); @@ -551,7 +845,7 @@ const AnimePlayer = (function() { const index = Number(val); let text = 'Auto'; if (index !== -1 && levels[index]) { - // Solo el número + p (ej: 720p) + text = `${levels[index].height}p`; } btn.innerHTML = `${text}`; @@ -560,8 +854,6 @@ const AnimePlayer = (function() { wrapper.appendChild(select); 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; controls.insertBefore(wrapper, controls.children[insertIndex]); } @@ -573,17 +865,14 @@ const AnimePlayer = (function() { const controls = plyrEl.querySelector('.plyr__controls'); if (!controls || controls.querySelector('#audio-control-wrapper')) return; - // 1. Wrapper const wrapper = document.createElement('div'); wrapper.className = 'plyr__controls__item plyr__custom-select-wrapper'; wrapper.id = 'audio-control-wrapper'; - // 2. Botón Visual const btn = document.createElement('div'); btn.className = 'plyr__custom-control-btn'; btn.innerHTML = `Audio 1`; - // 3. Select Invisible const select = document.createElement('select'); hls.audioTracks.forEach((t, i) => { @@ -605,10 +894,8 @@ const AnimePlayer = (function() { const index = Number(val); const track = hls.audioTracks[index]; - // Priorizamos el idioma (lang), luego el nombre 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(); btn.querySelector('.label-text').innerText = shortText; @@ -617,7 +904,6 @@ const AnimePlayer = (function() { wrapper.appendChild(select); wrapper.appendChild(btn); - // Insertar antes del selector de calidad si existe, o en la posición 4 const qualityWrapper = controls.querySelector('#quality-control-wrapper'); if(qualityWrapper) { controls.insertBefore(wrapper, qualityWrapper); diff --git a/docker/views/anime/anime.html b/docker/views/anime/anime.html index ee5b323..6744f76 100644 --- a/docker/views/anime/anime.html +++ b/docker/views/anime/anime.html @@ -60,6 +60,13 @@
+
@@ -82,6 +89,34 @@
+
diff --git a/docker/views/css/anime/player.css b/docker/views/css/anime/player.css index f8ce98d..d25c571 100644 --- a/docker/views/css/anime/player.css +++ b/docker/views/css/anime/player.css @@ -599,4 +599,321 @@ body.stop-scrolling { font-size: 10px; font-weight: 700; 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); } \ No newline at end of file