added download monitor
This commit is contained in:
@@ -23,6 +23,8 @@ type DownloadStatus = {
|
||||
error?: string;
|
||||
startedAt: number;
|
||||
completedAt?: number;
|
||||
folderName?: string;
|
||||
fileName?: string;
|
||||
};
|
||||
|
||||
const activeDownloads = new Map<string, DownloadStatus>();
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -1779,11 +1779,19 @@ const AnimePlayer = (function() {
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = `<div class="spinner" style="width:18px; height:18px; border-width:2px;"></div>`;
|
||||
|
||||
// --- 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,
|
||||
|
||||
@@ -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 = `<svg width="12" height="12" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/></svg>`;
|
||||
|
||||
// ESTRUCTURA NUEVA: Más plana para permitir Flexbox horizontal
|
||||
return `
|
||||
<div class="dl-item compact">
|
||||
<div class="dl-left-col">
|
||||
<div class="dl-filename" title="${fileName}">${fileName}</div>
|
||||
<div class="dl-folder" title="${folderName}">${folderIcon} ${folderName}</div>
|
||||
</div>
|
||||
|
||||
<div class="dl-right-col">
|
||||
<div class="dl-meta-info">
|
||||
<span class="dl-speed">${isCompleted ? '' : speed}</span>
|
||||
<span class="dl-status-text ${status}">${statusText}</span>
|
||||
</div>
|
||||
<div class="dl-progress-track">
|
||||
<div class="dl-progress-fill ${status}" style="width: ${progress}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).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
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -84,6 +84,22 @@
|
||||
|
||||
<div id="section-local" class="tab-section">
|
||||
|
||||
<div id="downloads-monitor" class="downloads-monitor">
|
||||
<div class="monitor-header">
|
||||
<div class="monitor-title">
|
||||
<span class="pulse-dot"></span>
|
||||
<h3>Download Manager</h3>
|
||||
</div>
|
||||
<div class="monitor-stats">
|
||||
<span id="dl-stat-active">0 Active</span>
|
||||
<span class="divider">•</span>
|
||||
<span id="dl-stat-failed">0 Failed</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="downloads-list-container" class="monitor-list">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar local-toolbar">
|
||||
<div class="search-box">
|
||||
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
|
||||
|
||||
Reference in New Issue
Block a user