From 9086896a89a4bdfe4e2804b2213c42fb41d5b408 Mon Sep 17 00:00:00 2001 From: Nino Date: Sat, 31 Oct 2026 16:31:18 -0600 Subject: [PATCH] =?UTF-8?q?Portadas=20HD=20de=20lo=20seguido=20o=20le?= =?UTF-8?q?=C3=ADdo;=20secci=C3=B3n=20Colas=20con=20descargas,=20reescalad?= =?UTF-8?q?o=20y=20biblioteca=20en=20tarjetas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- README.md | 9 ++- app/clips.js | 4 +- app/covers.js | 109 +++++++++++++++++++++++++++++ app/public/app.js | 159 +++++++++++++++++++++++++++++++++--------- app/public/index.html | 2 +- app/public/style.css | 14 ++++ app/server.js | 78 +++++++++++++++++---- app/upscale.js | 28 +++++++- 8 files changed, 352 insertions(+), 51 deletions(-) create mode 100644 app/covers.js diff --git a/README.md b/README.md index 52914d2..9013ca5 100644 --- a/README.md +++ b/README.md @@ -29,10 +29,17 @@ Lector web de manga autoalojado, con las dos fuentes de capítulos marca **HD**, el lector usa la versión HD (se puede desactivar en sus ajustes) y desde allí también se reescala un capítulo suelto. En una Intel HD 630 tarda ~13 s por página y cada página ocupa ~2,5× más (WebP calidad 85). +- **Portadas HD**: las portadas de los mangas que sigues o tienes en el historial se reescalan solas en + segundo plano (×4 las pequeñas, ×2 el resto; ~4 s cada una) y desde entonces se sirven en HD. Las + de búsquedas y populares se quedan como están. Caché en `data/covers/` (se puede borrar: se regeneran). - **Recortes**: en el lector, ✂ y arrastrar sobre un panel lo guarda en "Recortes" con su manga, capítulo y página ("Ir a la página" lleva directo allí). Se recorta del original y solo el recorte se reescala con Real-ESRGAN (×4, o ×2 si ya mide más de 1200 px; unos segundos). Se guardan en `data/clips/` (`.png` original y `-hd.webp`), con nota opcional y descarga. +- **Colas** (antes "Descargas"): descargas en curso y en espera, cola de reescalado HD por manga + (capítulo y página en curso, siguientes, tiempo restante, errores con "Reintentar", "Detener"), + recortes y portadas pendientes, y debajo la biblioteca descargada con su tamaño; todo en tarjetas + verticales con portada, como el historial. - **Estadísticas**: rachas, capítulos y páginas por semana, calendario de actividad, géneros favoritos y lo más leído. - Las imágenes pasan por el servidor (`/api/img`), solo con URLs firmadas por él (clave en `data/secret`). @@ -58,7 +65,7 @@ El primer usuario puede registrarse siempre; después el registro queda cerrado |---|---|---| | Código | este repositorio | se recrea con `git clone` | | `.env` | raíz del proyecto (no está en git) | rutas, puerto, PUID/PGID, URL pública | -| `data/` | raíz del proyecto (no está en git) | `manga.db` (+ `-wal`/`-shm`): usuarios, historial, estadísticas, seguimiento, ajustes; `secret`: clave del proxy de imágenes; `clips/`: recortes | +| `data/` | raíz del proyecto (no está en git) | `manga.db` (+ `-wal`/`-shm`): usuarios, historial, estadísticas, seguimiento, ajustes; `secret`: clave del proxy de imágenes; `clips/`: recortes; `covers/`: portadas HD (caché) | | Mangas descargados | `MANGA_DIR_HOST` (aquí `/zdata/media/mangas`) | CBZ + portadas | Pasos en el sistema nuevo: diff --git a/app/clips.js b/app/clips.js index d3a0505..5f070a9 100644 --- a/app/clips.js +++ b/app/clips.js @@ -176,5 +176,7 @@ export function setupClips({ db, DATA_DIR, HttpError, json, readBody, MANGA_ID, return false } - return { route, count, pump } + const queueInfo = userId => db.prepare(`SELECT SUM(status = 'queued') queued, SUM(status = 'running') running, SUM(status = 'error') errors + FROM clips WHERE user_id = ?`).get(userId) + return { route, count, pump, queueInfo } } diff --git a/app/covers.js b/app/covers.js new file mode 100644 index 0000000..f627c6d --- /dev/null +++ b/app/covers.js @@ -0,0 +1,109 @@ +// Portadas en HD: cada portada que se sirve (de la fuente o la cover.jpg de una descarga) se encola para +// reescalarla con Real-ESRGAN (×4 si es pequeña, ×2 si no). Mientras tanto se sirve la original con +// caché corta; en cuanto existe la versión HD se sirve esa. Caché en /covers/.webp. +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import crypto from 'node:crypto' +import { BIN, MODELS, MODEL, run } from './upscale.js' + +const MAX_QUEUE = 300 +const X4_MAX_SIDE = 400 +const MAX_SIDE = 1600 // más grande ya no hace falta reescalarla +const WEBP_QUALITY = 88 + +// Ancho/alto de un JPEG, PNG o WebP sin decodificarlo (null si no se reconoce). +export function imageSize(b) { + if (b.length > 24 && b.readUInt32BE(0) === 0x89504e47) return { type: 'png', w: b.readUInt32BE(16), h: b.readUInt32BE(20) } + if (b.length > 30 && b.toString('latin1', 0, 4) === 'RIFF' && b.toString('latin1', 8, 12) === 'WEBP') { + const f = b.toString('latin1', 12, 16) + if (f === 'VP8 ') return { type: 'webp', w: b.readUInt16LE(26) & 0x3fff, h: b.readUInt16LE(28) & 0x3fff } + if (f === 'VP8L') { const n = b.readUInt32LE(21); return { type: 'webp', w: (n & 0x3fff) + 1, h: ((n >> 14) & 0x3fff) + 1 } } + if (f === 'VP8X') return { type: 'webp', w: b.readUIntLE(24, 3) + 1, h: b.readUIntLE(27, 3) + 1 } + return null + } + if (b[0] === 0xff && b[1] === 0xd8) { + for (let i = 2; i + 9 < b.length;) { + if (b[i] !== 0xff) { i++; continue } + const marker = b[i + 1] + if (marker === 0xd8 || (marker >= 0xd0 && marker <= 0xd7) || marker === 0x01 || marker === 0xff) { i += marker === 0xff ? 1 : 2; continue } + const len = b.readUInt16BE(i + 2) + if (marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker)) return { type: 'jpg', w: b.readUInt16BE(i + 7), h: b.readUInt16BE(i + 5) } + i += 2 + len + } + } + return null +} + +export function setupCovers({ DATA_DIR, upscale }) { + const DIR = path.join(DATA_DIR, 'covers') + fs.mkdirSync(DIR, { recursive: true }) + const fileOf = key => path.join(DIR, `${crypto.createHash('sha256').update(key).digest('hex').slice(0, 32)}.webp`) + const queue = [] // { key, buf } + const pending = new Set() + const failed = new Set() // no reintentar hasta reiniciar + + // Si ya hay versión HD, la envía y devuelve true. + function sendCached(res, key) { + if (!upscale.available()) return false + const f = fileOf(key) + if (!fs.existsSync(f)) return false + res.writeHead(200, { 'Content-Type': 'image/webp', 'Cache-Control': 'private, max-age=2592000, immutable' }) + fs.createReadStream(f).pipe(res) + return true + } + // Cabecera de caché para la original mientras no hay HD (corta, para que el navegador pida luego la HD). + const pendingCache = () => (upscale.available() ? 'private, max-age=300' : 'private, max-age=604800, immutable') + + function enqueue(key, buf) { + if (!upscale.available() || pending.has(key) || failed.has(key) || queue.length >= MAX_QUEUE || fs.existsSync(fileOf(key))) return + pending.add(key) + queue.push({ key, buf }) + pump() + } + + async function processOne({ key, buf }) { + const size = imageSize(buf) + if (!size) throw new Error('formato no reconocido') + const work = fs.mkdtempSync(path.join(os.tmpdir(), 'cover-')) + try { + const out = path.join(work, 'out.png') + const tmp = `${fileOf(key)}.tmp` + if (Math.max(size.w, size.h) > MAX_SIDE) { + // Ya es grande: solo se recomprime para no volver a intentarlo. + const inp = path.join(work, `in.${size.type}`) + fs.writeFileSync(inp, buf) + if (size.type === 'webp') fs.copyFileSync(inp, tmp) + else await run('cwebp', ['-quiet', '-q', String(WEBP_QUALITY), inp, '-o', tmp]) + } else { + const inp = path.join(work, `in.${size.type}`) + fs.writeFileSync(inp, buf) + const scale = Math.max(size.w, size.h) <= X4_MAX_SIDE && fs.existsSync(path.join(MODELS, `${MODEL}-x4.bin`)) ? 4 : 2 + await run(BIN, ['-i', inp, '-o', out, '-m', MODELS, '-n', MODEL, '-s', String(scale), '-f', 'png']) + await run('cwebp', ['-quiet', '-q', String(WEBP_QUALITY), '-m', '4', '-mt', out, '-o', tmp]) + } + fs.renameSync(tmp, fileOf(key)) + } finally { + fs.rmSync(work, { recursive: true, force: true }) + } + } + + let pumping = false + async function pump() { + if (pumping) return + pumping = true + try { + let n = 0 + const t = Date.now() + while (queue.length) { + const job = queue.shift() + try { await processOne(job); n++ } catch (e) { failed.add(job.key); console.warn('Portada HD:', e.message) } finally { pending.delete(job.key) } + } + if (n) console.log(`Portadas HD: ${n} en ${Math.round((Date.now() - t) / 1000)} s`) + } finally { + pumping = false + } + } + + return { sendCached, enqueue, pendingCache, pendingCount: () => pending.size } +} diff --git a/app/public/app.js b/app/public/app.js index 1f65c4f..0ea07cc 100644 --- a/app/public/app.js +++ b/app/public/app.js @@ -37,6 +37,7 @@ const ICONS = { pages: '', scissors: '', image: '', + queue: '', sparkles: '', } const icon = name => `` @@ -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) { ` const secHead = (ic, title, status) => `
${icon(ic)} ${title}
${status ? `
${status}
` : ''}
` - 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 = `

Descargas

-

Se guardan en /zdata/media/mangas como CBZ (compatibles con Kavita, Komga, Mihon…). Los capítulos descargados se leen desde el disco.${list.length ? ` Total: ${fmtSize(total)}.` : ''}

- ${list.length ? '' : '

Aún no has descargado nada. Usa “⬇ Descargar” en la ficha de un manga.

'} -
${list.map(d => { - const active = DL_ACTIVE.includes(d.status) - return `` - }).join('')}
` - 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 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 = '
Cargando…
' + 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 ? `${icon('scissors')} ${plural(q.clips.queued + q.clips.running, 'recorte')} reescalándose` : '', + q.clips.errors ? `${plural(q.clips.errors, 'recorte')} con error` : '', + q.covers ? `${icon('image')} ${plural(q.covers, 'portada')} en cola` : '', + ].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 = '' }) => ` +
+ + ${badge ? `${badge}` : ''}${del} +
+ ${esc(title)} +
${lines}
+ ${actions ? `
${actions}
` : ''} +
` + const html = `

Colas

+
+

${icon('download')} Descargas ${q.downloads.length ? `${q.downloads.length}` : ''}

+ ${q.downloads.length ? `
${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: `
${esc(downloadLabel(d))} · ${Math.floor(downloadPct(d))}%
+ ${d.status === 'downloading' ? `
${downloadDetail(d)}
` : ''}${d.scope === 'some' ? '
Capítulos elegidos
' : ''}`, + actions: ``, + })).join('')}
` : '

No hay descargas en curso. Se añaden desde la ficha de un manga.

'} +
+
+

${icon('sparkles')} Reescalado HD ${hdQueued ? `${hdQueued}` : ''}

+ ${up.available ? '' : '

El reescalado no está disponible en este servidor (falta Real-ESRGAN o la GPU).

'} + ${up.groups.length ? `${hdQueued ? `

Un capítulo cada vez, en orden de llegada · ${up.secPerPage} s por página · quedan ${fmtDur(up.eta)}

` : ''} +
${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 ? `
Cap. ${esc(cur.num ?? '?')} · pág. ${cur.pages}/${cur.total}
` : ''} + ${g.queued ? `
${rest ? `${cur ? `${rest} más` : plural(rest, 'capítulo')} en cola${next}` : 'Último de la cola'}
${fmtDur(g.eta)}
` : ''} + ${g.errors.length ? `
${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 ? '…' : ''})`}
` : ''}`, + actions: `${g.errors.length ? `` : ''} + ${g.queued ? `` : ''}`, + }) + }).join('')}
` : up.available ? '

No hay capítulos esperando. Se reescalan desde la ficha de un manga descargado o desde el lector.

' : ''} + ${extra.length ? `
${extra.join('')}
` : ''} +
+
+

${icon('book')} Biblioteca descargada

+

CBZ en /zdata/media/mangas (compatibles con Kavita, Komga, Mihon…); se leen desde el disco.${list.length ? ` Total: ${fmtSize(total)}.` : ''}

+ ${library.length ? `
${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: `
${esc(downloadLabel(d))}
${fmtSize(d.size)}
+ ${d.status === 'error' ? `
Con errores
` : ''}`, + del: ``, + })).join('')}
` : `

${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.'}

`} +
` + // Refresco sin parpadeo: se actualiza solo lo que cambia (las portadas no se vuelven a cargar). + let wrap = $('#qwrap') + if (!wrap) { + app.innerHTML = `
${html}
` + 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 ---------- diff --git a/app/public/index.html b/app/public/index.html index 76e17f8..b66cb71 100644 --- a/app/public/index.html +++ b/app/public/index.html @@ -14,7 +14,7 @@