Portadas HD de lo seguido o leído; sección Colas con descargas, reescalado y biblioteca en tarjetas

- Portadas: se reescalan (×4/×2) solo las de mangas en historial o seguidos; caché en data/covers/.
- Colas (antes Descargas): cola de descargas y de reescalado HD con progreso, tiempo restante,
  reintentar/detener, recortes y portadas pendientes; biblioteca descargada en tarjetas verticales.
- Refresco de Colas sin parpadeo: solo se actualiza lo que cambia.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 16:31:18 -06:00
co-authored by Claude Opus 5.5
parent 36ff53cad4
commit 9086896a89
8 changed files with 352 additions and 51 deletions
+125 -34
View File
@@ -37,6 +37,7 @@ const ICONS = {
pages: '<rect x="2" y="5" width="9" height="14" rx="1.5"/><rect x="13" y="5" width="9" height="14" rx="1.5"/>',
scissors: '<circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M20 4 8.1 15.9M14.5 14.5 20 20M8.1 8.1 12 12"/>',
image: '<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-5-5L5 21"/>',
queue: '<path d="M3 6h13M3 12h13M3 18h9"/><path d="m17 15 3 3-3 3"/><path d="M20 18h-6"/>',
sparkles: '<path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8z"/><path d="M19 15l.8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8z"/>',
}
const icon = name => `<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${ICONS[name]}</svg>`
@@ -56,6 +57,8 @@ const FILTERS = {
vivid: ['Colores vivos', 'saturate(1.45) contrast(1.05)'],
}
const DL_ACTIVE = ['queued', 'downloading']
const plural = (n, w) => `${n} ${w}${n === 1 ? '' : 's'}`
const fmtDur = sec => sec < 90 ? '≈ 1 min' : sec < 3600 ? `≈ ${Math.round(sec / 60)} min` : `≈ ${Math.floor(sec / 3600)} h ${Math.round((sec % 3600) / 60)} min`
const fmtSize = b => (b >= 1e9 ? `${(b / 1e9).toFixed(1)} GB` : b >= 1e6 ? `${Math.round(b / 1e6)} MB` : `${Math.round((b || 0) / 1e3)} KB`)
// Progreso global contando la fracción del capítulo en curso.
function downloadPct(d) {
@@ -140,7 +143,8 @@ async function router() {
if (parts[0] === 'manga') return await viewManga(parts[1])
if (parts[0] === 'read') return await viewReader(parts[1], parts[2], Number(params.get('page') || 0) - 1)
if (parts[0] === 'history') return await viewHistory()
if (parts[0] === 'downloads') return await viewDownloads()
if (parts[0] === 'downloads') return (location.hash = '#/queues') // antiguo nombre
if (parts[0] === 'queues') return await viewQueues()
if (parts[0] === 'stats') return await viewStats()
if (parts[0] === 'clips') return await viewClips(params)
if (parts[0] === 'settings') return viewSettings()
@@ -359,8 +363,6 @@ async function viewManga(id, langOverride) {
<div id="selbar" hidden></div>`
const secHead = (ic, title, status) => `<div class="psec-head"><div class="psec-title">${icon(ic)} ${title}</div>${status ? `<div class="psec-status muted">${status}</div>` : ''}</div>`
const plural = (n, w) => `${n} ${w}${n === 1 ? '' : 's'}`
const fmtDur = sec => sec < 90 ? '≈ 1 min' : sec < 3600 ? `≈ ${Math.round(sec / 60)} min` : `≈ ${Math.floor(sec / 3600)} h ${Math.round((sec % 3600) / 60)} min`
// --- Selector de capítulos compartido por "Copia local" (kind 'dl') y "Reescalado HD" (kind 'hd'):
// todo lo pendiente (solo HD), un rango o una selección hecha en la lista de abajo.
@@ -1356,38 +1358,127 @@ async function viewHistory() {
}
// ---------- Descargas ----------
async function viewDownloads() {
if (cleanup) { cleanup(); cleanup = null }
const list = await api('/api/downloads')
if (location.hash !== '#/downloads') return
const total = list.reduce((n, d) => n + (d.size || 0), 0)
app.innerHTML = `<h1>Descargas</h1>
<p class="muted">Se guardan en <code>/zdata/media/mangas</code> como CBZ (compatibles con Kavita, Komga, Mihon…). Los capítulos descargados se leen desde el disco.${list.length ? ` Total: <b>${fmtSize(total)}</b>.` : ''}</p>
${list.length ? '' : '<p class="muted">Aún no has descargado nada. Usa “⬇ Descargar” en la ficha de un manga.</p>'}
<div class="history">${list.map(d => {
const active = DL_ACTIVE.includes(d.status)
return `<div class="cont">
<a class="info" href="#/manga/${d.manga_id}"><b>${esc(d.title)}</b>
<div class="muted">${esc(downloadLabel(d))}${active ? ` · ${Math.floor(downloadPct(d))}%` : ''} · ${fmtSize(d.size)} · mangas/${esc(d.folder)}</div>
${active ? `<div class="muted" style="font-size:.8rem">${downloadDetail(d)}</div>` : ''}
${d.status === 'error' ? `<div class="error" style="font-size:.8rem">${esc(d.error || '')}</div>` : ''}
<div class="bar dlbar"><i style="width:${downloadPct(d).toFixed(1)}%"></i></div></a>
<div class="row" style="flex:none">
${active ? `<button class="btn small" data-cancel="${esc(d.manga_id)}">${icon('stop')} Cancelar</button>` : ''}
<button class="btn small danger" data-del="${esc(d.manga_id)}" data-title="${esc(d.title)}" data-size="${d.size || 0}">${icon('trash')} Eliminar</button>
</div></div>`
}).join('')}</div>`
app.querySelectorAll('[data-cancel]').forEach(b => b.addEventListener('click', async () => { await api(`/api/download/${b.dataset.cancel}/cancel`, { method: 'POST' }); viewDownloads() }))
app.querySelectorAll('[data-del]').forEach(b => b.addEventListener('click', async () => {
if (!confirm(`¿Eliminar la copia local de «${b.dataset.title}» (${fmtSize(Number(b.dataset.size))})?\nTu historial de lectura no se toca.`)) return
await api(`/api/download/${b.dataset.del}`, { method: 'DELETE' })
toast('Copia local eliminada')
viewDownloads()
}))
if (list.some(d => DL_ACTIVE.includes(d.status))) {
const t = setTimeout(() => location.hash === '#/downloads' && viewDownloads(), 2000)
cleanup = () => clearTimeout(t)
// Actualiza `from` para que quede igual que `to` tocando solo lo que difiere. Los hijos con data-key se
// emparejan por clave (una tarjeta que cambia de posición conserva su <img> y no recarga la portada).
function morph(from, to, childrenOnly = false) {
if (!childrenOnly) {
if (from.nodeType !== to.nodeType || from.nodeName !== to.nodeName) { from.replaceWith(to); return to }
if (from.nodeType !== 1) { if (from.nodeValue !== to.nodeValue) from.nodeValue = to.nodeValue; return from }
for (const { name } of [...from.attributes]) if (!to.hasAttribute(name) && name !== 'data-failed') from.removeAttribute(name)
for (const { name, value } of [...to.attributes]) {
if (name === 'src' && from.dataset.failed) continue // portada que no cargó: se queda el icono
if (from.getAttribute(name) !== value) from.setAttribute(name, value)
}
}
const byKey = new Map([...from.children].filter(n => n.dataset.key).map(n => [n.dataset.key, n]))
const kids = [...to.childNodes]
kids.forEach((nk, i) => {
const key = nk.nodeType === 1 ? nk.dataset.key : null
const at = from.childNodes[i]
let old = key ? byKey.get(key) : at && !(at.nodeType === 1 && at.dataset.key) ? at : null
if (key) byKey.delete(key)
const node = old ? morph(old, nk) : nk
if (from.childNodes[i] !== node) from.insertBefore(node, from.childNodes[i] || null)
})
while (from.childNodes.length > kids.length) from.lastChild.remove()
return from
}
async function viewQueues() {
if (cleanup) { cleanup(); cleanup = null }
const here = () => location.hash === '#/queues'
if (!$('#qwrap')) app.innerHTML = '<div class="loading">Cargando…</div>'
const [q, list] = await Promise.all([api('/api/queues'), api('/api/downloads')])
if (!here()) return
const up = q.upscale
const library = list.filter(d => !DL_ACTIVE.includes(d.status))
const hdQueued = up.groups.reduce((n, g) => n + g.queued, 0)
const total = list.reduce((n, d) => n + (d.size || 0), 0)
const extra = [
q.clips.queued + q.clips.running ? `<a href="#/clips">${icon('scissors')} ${plural(q.clips.queued + q.clips.running, 'recorte')} reescalándose</a>` : '',
q.clips.errors ? `<a href="#/clips" class="error">${plural(q.clips.errors, 'recorte')} con error</a>` : '',
q.covers ? `<span>${icon('image')} ${plural(q.covers, 'portada')} en cola</span>` : '',
].filter(Boolean)
// Tarjeta vertical (como en Historial): portada, barra de progreso, título, detalles y acciones.
const qcard = ({ key, id, cover, badge, pct, title, lines, actions = '', del = '' }) => `
<div class="card hcard qcard" data-key="${esc(key)}">
<a href="#/manga/${id}"><img class="cover" loading="lazy" src="${esc(cover || '/icon.svg')}" alt="" onerror="this.onerror=null;this.dataset.failed=1;this.src='/icon.svg'"></a>
${badge ? `<span class="qpos corner">${badge}</span>` : ''}${del}
<div class="bar" ${pct == null ? 'style="visibility:hidden"' : ''}><i style="width:${(pct || 0).toFixed(1)}%"></i></div>
<a class="t" href="#/manga/${id}">${esc(title)}</a>
<div class="sub">${lines}</div>
${actions ? `<div class="qact">${actions}</div>` : ''}
</div>`
const html = `<h1>Colas</h1>
<section class="qsec">
<h2>${icon('download')} Descargas ${q.downloads.length ? `<span class="badge">${q.downloads.length}</span>` : ''}</h2>
${q.downloads.length ? `<div class="grid qgrid">${q.downloads.map((d, i) => qcard({
key: `dl:${d.manga_id}`, id: d.manga_id, cover: d.cover, title: d.title, pct: downloadPct(d),
badge: d.status === 'downloading' ? icon('download') : i + 1,
lines: `<div>${esc(downloadLabel(d))} · ${Math.floor(downloadPct(d))}%</div>
${d.status === 'downloading' ? `<div>${downloadDetail(d)}</div>` : ''}${d.scope === 'some' ? '<div>Capítulos elegidos</div>' : ''}`,
actions: `<button class="btn small" data-cancel="${esc(d.manga_id)}">${icon('stop')} Cancelar</button>`,
})).join('')}</div>` : '<p class="muted">No hay descargas en curso. Se añaden desde la ficha de un manga.</p>'}
</section>
<section class="qsec">
<h2>${icon('sparkles')} Reescalado HD ${hdQueued ? `<span class="badge">${hdQueued}</span>` : ''}</h2>
${up.available ? '' : '<p class="muted">El reescalado no está disponible en este servidor (falta Real-ESRGAN o la GPU).</p>'}
${up.groups.length ? `${hdQueued ? `<p class="muted small">Un capítulo cada vez, en orden de llegada · ${up.secPerPage} s por página · quedan ${fmtDur(up.eta)}</p>` : ''}
<div class="grid qgrid">${up.groups.map((g, i) => {
const cur = up.current?.mangaId === g.mangaId ? up.current : null
const rest = g.queued - (cur ? 1 : 0)
const next = g.next.length ? ` (sig.: ${g.next.map(n => esc(n ?? '?')).join(', ')}${rest > g.next.length ? '…' : ''})` : ''
return qcard({
key: `hd:${g.mangaId}`, id: g.mangaId, cover: g.cover, title: g.title,
pct: cur?.total ? (cur.pages / cur.total) * 100 : null,
badge: cur ? icon('sparkles') : g.queued ? i + 1 : '!',
lines: `${cur ? `<div>Cap. ${esc(cur.num ?? '?')} · pág. ${cur.pages}/${cur.total}</div>` : ''}
${g.queued ? `<div>${rest ? `${cur ? `${rest} más` : plural(rest, 'capítulo')} en cola${next}` : 'Último de la cola'}</div><div>${fmtDur(g.eta)}</div>` : ''}
${g.errors.length ? `<div class="error" title="${esc(g.errors.map(e => `Cap. ${e.num ?? '?'}: ${e.error || 'error'}`).join('\n'))}">${g.errors.length === 1 ? `Cap. ${esc(g.errors[0].num ?? '?')}: ${esc(g.errors[0].error || 'error')}` : `${g.errors.length} con error (caps. ${g.errors.slice(0, 6).map(e => esc(e.num ?? '?')).join(', ')}${g.errors.length > 6 ? '…' : ''})`}</div>` : ''}`,
actions: `${g.errors.length ? `<button class="btn small" data-retry="${esc(g.mangaId)}" data-ids="${esc(g.errors.map(e => e.chapterId).join(' '))}">${icon('refresh')} Reintentar</button>` : ''}
${g.queued ? `<button class="btn small" data-hdstop="${esc(g.mangaId)}" title="Vacía su cola y desactiva el reescalado automático">${icon('stop')} Detener</button>` : ''}`,
})
}).join('')}</div>` : up.available ? '<p class="muted">No hay capítulos esperando. Se reescalan desde la ficha de un manga descargado o desde el lector.</p>' : ''}
${extra.length ? `<div class="facts muted">${extra.join('')}</div>` : ''}
</section>
<section class="qsec">
<h2>${icon('book')} Biblioteca descargada</h2>
<p class="muted small">CBZ en <code>/zdata/media/mangas</code> (compatibles con Kavita, Komga, Mihon…); se leen desde el disco.${list.length ? ` Total: <b>${fmtSize(total)}</b>.` : ''}</p>
${library.length ? `<div class="grid">${library.map(d => qcard({
key: `lib:${d.manga_id}`, id: d.manga_id, cover: d.cover, title: d.title,
pct: d.total ? Math.min(100, (d.done / d.total) * 100) : null,
lines: `<div>${esc(downloadLabel(d))}</div><div>${fmtSize(d.size)}</div>
${d.status === 'error' ? `<div class="error" title="${esc(d.error || '')}">Con errores</div>` : ''}`,
del: `<button class="hdel" data-del="${esc(d.manga_id)}" data-title="${esc(d.title)}" data-size="${d.size || 0}" title="Eliminar la copia local (mangas/${esc(d.folder)})" aria-label="Eliminar la copia local">${icon('trash')}</button>`,
})).join('')}</div>` : `<p class="muted">${list.length ? 'Todo lo descargado está aún en la cola.' : 'Aún no has descargado nada. Usa “Descargar” en la ficha de un manga.'}</p>`}
</section>`
// Refresco sin parpadeo: se actualiza solo lo que cambia (las portadas no se vuelven a cargar).
let wrap = $('#qwrap')
if (!wrap) {
app.innerHTML = `<div id="qwrap">${html}</div>`
wrap = $('#qwrap')
// Delegación: los botones se crean y actualizan en cada refresco.
const act = async (fn, msg) => { try { await fn(); if (msg) toast(msg) } catch (err) { toast(err.message) } viewQueues() }
wrap.addEventListener('click', e => {
const b = e.target.closest('button')
if (!b) return
if (b.dataset.cancel) act(() => api(`/api/download/${b.dataset.cancel}/cancel`, { method: 'POST' }), 'Descarga cancelada')
else if (b.dataset.hdstop) act(() => api(`/api/upscale/${b.dataset.hdstop}/cancel`, { method: 'POST' }), 'Reescalado detenido')
else if (b.dataset.retry) act(() => api(`/api/upscale/${b.dataset.retry}`, { method: 'POST', body: { chapterIds: b.dataset.ids.split(' ') } }), 'Vuelto a la cola')
else if (b.dataset.del) {
if (!confirm(`¿Eliminar la copia local de «${b.dataset.title}» (${fmtSize(Number(b.dataset.size))})?\nTu historial de lectura no se toca.`)) return
act(() => api(`/api/download/${b.dataset.del}`, { method: 'DELETE' }), 'Copia local eliminada')
}
})
} else {
const tpl = document.createElement('div')
tpl.innerHTML = html
morph(wrap, tpl, true)
}
// Refresco: rápido mientras algo avanza, lento si no (por si llega algo nuevo).
const busy = q.downloads.length || up.current || up.groups.some(g => g.queued) || q.clips.queued + q.clips.running || q.covers
const t = setTimeout(() => here() && viewQueues(), busy ? 2000 : 15000)
cleanup = () => clearTimeout(t)
}
// ---------- Estadísticas ----------