diff --git a/desktop/src/api/local/download.service.ts b/desktop/src/api/local/download.service.ts index 0139cca..b27014f 100644 --- a/desktop/src/api/local/download.service.ts +++ b/desktop/src/api/local/download.service.ts @@ -23,6 +23,8 @@ type DownloadStatus = { error?: string; startedAt: number; completedAt?: number; + folderName?: string; + fileName?: string; }; const activeDownloads = new Map(); @@ -50,6 +52,7 @@ type AnimeDownloadParams = { quality?: string; subtitles?: Array<{ language: string; url: string }>; chapters?: Array<{ title: string; start_time: number; end_time: number }>; + totalDuration?: number; }; type BookDownloadParams = { @@ -137,10 +140,12 @@ async function getOrCreateEntry( } export async function downloadAnimeEpisode(params: AnimeDownloadParams) { - const { anilistId, episodeNumber, streamUrl, subtitles, chapters } = params; + const { anilistId, episodeNumber, streamUrl, subtitles, chapters, totalDuration } = params; + + const entry: any = await getOrCreateEntry(anilistId, 'anime'); + const fileName = `Episode_${episodeNumber.toString().padStart(2, '0')}.mkv`; const downloadId = crypto.randomUUID(); - activeDownloads.set(downloadId, { id: downloadId, type: 'anime', @@ -148,11 +153,11 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) { unitNumber: episodeNumber, status: 'pending', progress: 0, - startedAt: Date.now() + startedAt: Date.now(), + folderName: entry.folderName, + fileName: fileName }); - const entry = await getOrCreateEntry(anilistId, 'anime'); - const exists = await queryOne( `SELECT id FROM local_files WHERE entry_id = ? AND unit_number = ?`, [entry.id, episodeNumber], @@ -314,11 +319,21 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) { const speedMatch = text.match(/speed=(\S+)/); if (timeMatch || speedMatch) { - updateDownloadProgress(downloadId, { - timeElapsed: timeMatch?.[1], - speed: speedMatch?.[1] - }); + const updates: any = {}; + + if (timeMatch) updates.timeElapsed = timeMatch[1]; + if (speedMatch) updates.speed = speedMatch[1]; + + if (timeMatch && totalDuration && totalDuration > 0) { + const elapsedSeconds = parseFFmpegTime(timeMatch[1]); + updates.progress = Math.min( + 99, + Math.round((elapsedSeconds / totalDuration) * 100) + ); + } + updateDownloadProgress(downloadId, updates); } + }); ff.on('error', (error) => reject(error)); @@ -381,6 +396,12 @@ export async function downloadAnimeEpisode(params: AnimeDownloadParams) { export async function downloadBookChapter(params: BookDownloadParams) { const { anilistId, chapterNumber, format, content, images } = params; + const type = format === 'manga' ? 'manga' : 'novels'; + const entry = await getOrCreateEntry(anilistId, type); + + const ext = format === 'manga' ? 'cbz' : 'epub'; + const fileName = `Chapter_${chapterNumber.toString().padStart(3, '0')}.${ext}`; + const downloadId = crypto.randomUUID(); activeDownloads.set(downloadId, { @@ -390,12 +411,11 @@ export async function downloadBookChapter(params: BookDownloadParams) { unitNumber: chapterNumber, status: 'pending', progress: 0, - startedAt: Date.now() + startedAt: Date.now(), + folderName: entry.folderName, + fileName: fileName }); - const type = format === 'manga' ? 'manga' : 'novels'; - const entry = await getOrCreateEntry(anilistId, type); - const existingFile = await queryOne( `SELECT id FROM local_files WHERE entry_id = ? AND unit_number = ?`, [entry.id, chapterNumber], @@ -531,4 +551,15 @@ ${content} (err as any).details = error.message; throw err; } +} + +function parseFFmpegTime(timeStr: string): number { + const parts = timeStr.split(':'); + if (parts.length < 3) return 0; + + const h = parseFloat(parts[0]) || 0; + const m = parseFloat(parts[1]) || 0; + const s = parseFloat(parts[2]) || 0; + + return (h * 3600) + (m * 60) + s; } \ No newline at end of file diff --git a/desktop/src/api/local/local.controller.ts b/desktop/src/api/local/local.controller.ts index 432a6e4..af5bd49 100644 --- a/desktop/src/api/local/local.controller.ts +++ b/desktop/src/api/local/local.controller.ts @@ -28,6 +28,7 @@ type DownloadAnimeBody = language: string; url: string; }[]; + duration?: number; chapters?: { title: string; start_time: number; @@ -38,6 +39,7 @@ type DownloadAnimeBody = anilist_id: number; episode_number: number; stream_url: string; + duration?: number; is_master: true; variant: { resolution: string; @@ -256,6 +258,7 @@ export async function downloadAnime(request: FastifyRequest<{ Body: DownloadAnim anilist_id, episode_number, stream_url, + duration, is_master, subtitles, chapters @@ -283,7 +286,8 @@ export async function downloadAnime(request: FastifyRequest<{ Body: DownloadAnim episodeNumber: episode_number, streamUrl: proxyUrl, subtitles: proxiedSubs, - chapters + chapters, + totalDuration: duration }; if (is_master === true) { diff --git a/desktop/src/scripts/anime/player.js b/desktop/src/scripts/anime/player.js index 89ccb81..163b419 100644 --- a/desktop/src/scripts/anime/player.js +++ b/desktop/src/scripts/anime/player.js @@ -1779,11 +1779,19 @@ const AnimePlayer = (function() { btn.disabled = true; btn.innerHTML = `
`; + // --- CAMBIO AQUÍ: Calcular duración del video actual --- + let totalDuration = 0; + if (els.video && isFinite(els.video.duration) && els.video.duration > 0) { + totalDuration = Math.floor(els.video.duration); + } + // ------------------------------------------------------- + let body = { anilist_id: parseInt(_animeId), episode_number: parseInt(_currentEpisode), stream_url: _rawVideoData.url, headers: _rawVideoData.headers || {}, + duration: totalDuration, // <--- ENVIAMOS LA DURACIÓN chapters: _skipIntervals.map(i => ({ title: i.type === 'op' ? 'Opening' : 'Ending', start_time: i.startTime, diff --git a/desktop/src/scripts/profile.js b/desktop/src/scripts/profile.js index 417210e..6a2e3a7 100644 --- a/desktop/src/scripts/profile.js +++ b/desktop/src/scripts/profile.js @@ -388,6 +388,95 @@ const DashboardApp = { Library: { tempMatchContext: null, + pollInterval: null, + updateDownloadStatus: async function() { + try { + const res = await fetch(`${API_BASE}/library/downloads/status`, { + headers: window.AuthUtils.getSimpleAuthHeaders() + }); + + if (!res.ok) return; + + const data = await res.json(); + this.renderDownloadMonitor(data); + + // Si hay descargas completadas nuevas, podríamos recargar la lista de archivos + // (Opcional: lógica para detectar cambios y llamar a loadContent) + + } catch (e) { + console.error("Error polling downloads:", e); + } + }, + + renderDownloadMonitor: function(data) { + const monitor = document.getElementById('downloads-monitor'); + const listContainer = document.getElementById('downloads-list-container'); + const activeCountEl = document.getElementById('dl-stat-active'); + + // Datos por defecto + const downloads = data.downloads || { list: [], active: 0, failed: 0 }; + + // Actualizar contadores cabecera + if(activeCountEl) activeCountEl.textContent = `${downloads.active} Active / ${downloads.list.length} Total`; + + // Ocultar si vacío + if (downloads.list.length === 0) { + monitor.classList.add('hidden'); + return; + } + + monitor.classList.remove('hidden'); + + listContainer.innerHTML = downloads.list.map(item => { + const fileName = item.fileName || `Unknown_File_${item.unitNumber}`; + const folderName = item.folderName || 'Unsorted'; + const status = item.status || 'pending'; + const progress = item.progress || 0; + const speed = item.speed || '0 KB/s'; + + const isCompleted = status === 'completed'; + const isFailed = status === 'failed'; + + let statusText = `${progress}%`; + if (isCompleted) statusText = 'Done'; + if (isFailed) statusText = 'Failed'; + + const folderIcon = ``; + + // ESTRUCTURA NUEVA: Más plana para permitir Flexbox horizontal + return ` +
+
+
${fileName}
+
${folderIcon} ${folderName}
+
+ +
+
+ ${isCompleted ? '' : speed} + ${statusText} +
+
+
+
+
+
+ `; + }).join(''); + }, + + startPolling: function() { + if (this.pollInterval) clearInterval(this.pollInterval); + this.updateDownloadStatus(); // Primera llamada inmediata + this.pollInterval = setInterval(() => this.updateDownloadStatus(), 2000); // Cada 2 segundos + }, + + stopPolling: function() { + if (this.pollInterval) { + clearInterval(this.pollInterval); + this.pollInterval = null; + } + }, loadStats: async function() { const types = ['anime', 'manga', 'novels']; const elements = { 'anime': 'local-anime-count', 'manga': 'local-manga-count', 'novels': 'local-novel-count' }; @@ -584,6 +673,7 @@ const DashboardApp = { tabs.forEach(tab => { tab.addEventListener('click', () => { + // Gestión de clases activas tabs.forEach(t => t.classList.remove('active')); tab.classList.add('active'); @@ -593,9 +683,13 @@ const DashboardApp = { if (sec.id === targetId) sec.classList.add('active'); }); + // Lógica específica por pestaña if (tab.dataset.target === 'local') { DashboardApp.Library.loadStats(); - DashboardApp.Library.loadContent('anime'); + DashboardApp.Library.loadContent(DashboardApp.State.currentLocalType || 'anime'); + DashboardApp.Library.startPolling(); // <--- INICIAR POLLING + } else { + DashboardApp.Library.stopPolling(); // <--- DETENER POLLING AL SALIR } }); }); diff --git a/desktop/views/css/profile.css b/desktop/views/css/profile.css index bd758b7..9c05817 100644 --- a/desktop/views/css/profile.css +++ b/desktop/views/css/profile.css @@ -795,4 +795,144 @@ display: flex; justify-content: flex-end; gap: 1rem; +} + +/* ========================================= + DOWNLOAD MANAGER (COMPACTO) + ========================================= */ +.downloads-monitor { + background: var(--color-bg-elevated, #18181b); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 12px; + margin-bottom: 2rem; + overflow: hidden; + box-shadow: 0 4px 20px rgba(0,0,0,0.2); +} + +.monitor-header { + background: rgba(255, 255, 255, 0.03); + padding: 0.8rem 1.2rem; /* Header más delgado */ + border-bottom: 1px solid rgba(255, 255, 255, 0.05); + display: flex; + justify-content: space-between; + align-items: center; +} + +.monitor-title h3 { + margin: 0; + font-size: 0.9rem; + color: white; + display: flex; align-items: center; gap: 0.6rem; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.monitor-list { + padding: 0; + max-height: 250px; + overflow-y: auto; +} + +/* --- ITEM COMPACTO --- */ +.dl-item.compact { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.6rem 1.2rem; /* Mucho menos padding vertical */ + border-bottom: 1px solid rgba(255,255,255,0.05); + background: transparent; + transition: background 0.2s; + gap: 1.5rem; +} + +.dl-item.compact:last-child { border-bottom: none; } +.dl-item.compact:hover { background: rgba(255,255,255,0.02); } + +/* Columna Izquierda: Nombres */ +.dl-left-col { + display: flex; + flex-direction: column; + justify-content: center; + overflow: hidden; + flex: 1; /* Ocupa el espacio disponible */ + min-width: 0; /* Permite truncar texto */ +} + +.dl-filename { + font-size: 0.9rem; + font-weight: 600; + color: #f4f4f5; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-family: 'Consolas', monospace; + margin-bottom: 2px; +} + +.dl-folder { + font-size: 0.75rem; + color: #71717a; + display: flex; + align-items: center; + gap: 0.4rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.dl-folder svg { + color: var(--color-primary, #8b5cf6); + opacity: 0.8; +} + +/* Columna Derecha: Progreso y Stats */ +.dl-right-col { + width: 40%; /* Ancho fijo para la zona de progreso */ + max-width: 300px; + min-width: 150px; + display: flex; + flex-direction: column; + justify-content: center; + gap: 4px; +} + +.dl-meta-info { + display: flex; + justify-content: space-between; + font-size: 0.7rem; + color: #a1a1aa; + font-weight: 500; +} + +.dl-status-text.completed { color: #4ade80; } +.dl-status-text.failed { color: #ef4444; } + +/* Barra de progreso más fina */ +.dl-progress-track { + width: 100%; + height: 4px; /* Barra delgada */ + background: rgba(255,255,255,0.08); + border-radius: 2px; + overflow: hidden; +} + +.dl-progress-fill { + height: 100%; + background: var(--color-primary, #8b5cf6); + border-radius: 2px; + transition: width 0.3s ease-out; + width: 0%; +} + +.dl-progress-fill.completed { background: #4ade80; } +.dl-progress-fill.failed { background: #ef4444; } + +/* Responsive para móviles */ +@media (max-width: 600px) { + .dl-item.compact { + flex-direction: column; + align-items: stretch; + gap: 0.8rem; + } + .dl-right-col { width: 100%; max-width: none; } } \ No newline at end of file diff --git a/desktop/views/profile.html b/desktop/views/profile.html index 1d7fc78..21b327e 100644 --- a/desktop/views/profile.html +++ b/desktop/views/profile.html @@ -97,6 +97,22 @@
+
+
+
+ +

Download Manager

+
+
+ 0 Active + + 0 Failed +
+
+
+
+
+