From bc84a9f6fe5676bcefb04580cd57cf00e970c17e Mon Sep 17 00:00:00 2001 From: Nino Date: Sat, 31 Oct 2026 10:51:29 -0600 Subject: [PATCH] Reescalado HD, ficha reorganizada y mejoras del lector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reescalado HD con Real-ESRGAN (ncnn-vulkan, GPU por /dev/dri): cola persistente, todo lo pendiente, un rango o capítulos elegidos en la lista, con estimación de tiempo. La versión HD va a /.hd/ sin tocar el original; marca HD en la lista de capítulos y casilla "Versión HD" en el lector. - Imagen Debian slim (glibc + Mesa Vulkan + cwebp); compose con /dev/dri y RENDER_GID. - Ficha: acciones principales + menú ⋯, panel "Biblioteca local" (copia local y HD), idioma junto a la lista de capítulos. - Lector: la barra de progreso avanza de derecha a izquierda en der → izq; botón rápido páginas ⇄ vertical; en vertical "Ajustar alto" y "Separar páginas" para mangas. Co-Authored-By: Claude Opus 5.5 --- .env.example | 3 + Dockerfile | 24 +++- README.md | 14 +- app/cbz.js | 16 ++- app/features.js | 4 +- app/public/app.js | 318 +++++++++++++++++++++++++++++++++++++------ app/public/style.css | 68 +++++++++ app/server.js | 28 ++-- app/upscale.js | 228 +++++++++++++++++++++++++++++++ docker-compose.yml | 6 + 10 files changed, 645 insertions(+), 64 deletions(-) create mode 100644 app/upscale.js diff --git a/.env.example b/.env.example index b5e494d..4f5caff 100644 --- a/.env.example +++ b/.env.example @@ -17,5 +17,8 @@ PUBLIC_URL=http://192.168.1.141:3352 # Registro de cuentas nuevas (con la base vacía, el primer usuario siempre puede registrarse). ALLOW_SIGNUP=false +# Grupo "render" del host, para usar la GPU en el reescalado HD (getent group render | cut -d: -f3). +RENDER_GID=992 + TZ=America/El_Salvador CHECK_INTERVAL_HOURS=3 diff --git a/Dockerfile b/Dockerfile index 7f55891..4ccfbd9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,27 @@ +# Real-ESRGAN (ncnn-vulkan) para el reescalado HD. Solo se guarda el modelo de anime ×2. +FROM debian:trixie-slim AS esrgan +ARG ESRGAN_URL=https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.5.0/realesrgan-ncnn-vulkan-20220424-ubuntu.zip +ARG ESRGAN_SHA256=e5aa6eb131234b87c0c51f82b89390f5e3e642b7b70f2b9bbe95b6a285a40c96 +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl unzip && rm -rf /var/lib/apt/lists/* +RUN curl -fsSL -o /tmp/esr.zip "$ESRGAN_URL" \ + && echo "$ESRGAN_SHA256 /tmp/esr.zip" | sha256sum -c - \ + && mkdir -p /opt/realesrgan/models && cd /tmp && unzip -q esr.zip -d esr \ + && install -m 755 esr/realesrgan-ncnn-vulkan /opt/realesrgan/ \ + && cp esr/models/realesr-animevideov3-x2.* /opt/realesrgan/models/ + # Versión fijada: node:sqlite aún es experimental y una actualización silenciosa podría romperlo. -FROM node:24.22.2-alpine3.24 -ENV NODE_ENV=production NODE_NO_WARNINGS=1 -RUN apk add --no-cache tzdata +# Debian (no Alpine): Real-ESRGAN necesita glibc y los controladores Vulkan de Mesa. +FROM node:24.22.2-trixie-slim +ENV NODE_ENV=production NODE_NO_WARNINGS=1 \ + UPSCALER_BIN=/opt/realesrgan/realesrgan-ncnn-vulkan \ + MESA_SHADER_CACHE_DIR=/tmp/mesa-cache +RUN apt-get update && apt-get install -y --no-install-recommends tzdata libvulkan1 mesa-vulkan-drivers libgomp1 webp \ + && rm -rf /var/lib/apt/lists/* +COPY --from=esrgan /opt/realesrgan /opt/realesrgan WORKDIR /app COPY app/ ./ # Usuario por defecto; docker-compose.yml lo sustituye por PUID:PGID. USER node EXPOSE 3352 -HEALTHCHECK --interval=60s --timeout=5s --start-period=20s CMD wget -qO- http://127.0.0.1:3352/api/me >/dev/null || exit 1 +HEALTHCHECK --interval=60s --timeout=5s --start-period=20s CMD node -e "fetch('http://127.0.0.1:3352/api/me').then(r=>process.exit(r.status<500?0:1),()=>process.exit(1))" CMD ["node", "server.js"] diff --git a/README.md b/README.md index 0bc57c6..419442b 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,13 @@ Lector web de manga autoalojado, con las dos fuentes de o vertical. Modo, ajuste y dirección se recuerdan por manga (por defecto: manhwa/manhua en vertical, manga por página de derecha a izquierda). Zoom con pellizco, doble toque en el centro, Ctrl+rueda o + / − / 0. +- **Reescalado HD**: en la ficha de un manga descargado, "Reescalar a HD" pasa sus capítulos por + Real-ESRGAN (`realesr-animevideov3` ×2, en la GPU por Vulkan) y guarda la versión HD en + `/.hd/` (carpeta oculta: el CBZ original no se toca y Kavita/Komga no ven duplicados). + Con el manga marcado, los capítulos que se descarguen después también se reescalan. La lista 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). - **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`). @@ -34,8 +41,8 @@ docker compose up -d --build # http://:3352 ``` -El contenedor no depende del sistema operativo: imagen Node fijada (`node:24.22.2-alpine3.24`), -sin dependencias npm, y todo lo propio de la máquina está en `.env` (ver `.env.example`). +El contenedor no depende del sistema operativo: imagen Node fijada (`node:24.22.2-trixie-slim`, +con Mesa Vulkan y Real-ESRGAN verificado por SHA-256), sin dependencias npm, y todo lo propio de la máquina está en `.env` (ver `.env.example`). Sin `.env` arranca igual: puerto 3352 en todas las interfaces y descargas en `./mangas`. El primer usuario puede registrarse siempre; después el registro queda cerrado salvo @@ -59,6 +66,9 @@ id -u; id -g # si no son 1000, ponlos en PUID/PGID del .env docker compose up -d --build ``` +Para el reescalado HD el host necesita `/dev/dri` (GPU Intel/AMD con Mesa) y `RENDER_GID` en `.env` +con el grupo `render`. Sin GPU, quita `devices`/`group_add` del compose: todo lo demás funciona igual. + Si el usuario del host no coincide con PUID/PGID, el contenedor no arranca y el log dice exactamente qué `chown` hacer. Si la carpeta de mangas no existe, Docker la crea como root: créala antes (`mkdir -p` con tu usuario) para que las descargas funcionen. diff --git a/app/cbz.js b/app/cbz.js index aadd443..b36e0ef 100644 --- a/app/cbz.js +++ b/app/cbz.js @@ -98,6 +98,15 @@ export function imageType(name) { return { jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', webp: 'image/webp', gif: 'image/gif', avif: 'image/avif' }[ext] || 'application/octet-stream' } +// Lee una entrada completa del CBZ a memoria. +export function readCbzEntry(file, entry) { + if (entry.method !== 0 && entry.method !== 8) throw new Error(`Compresión ZIP no soportada (${entry.method})`) + const fd = fs.openSync(file, 'r') + const buf = Buffer.alloc(entry.size) + try { fs.readSync(fd, buf, 0, entry.size, entry.start) } finally { fs.closeSync(fd) } + return entry.method === 8 ? zlib.inflateRawSync(buf) : buf +} + // Envía una entrada del CBZ por la respuesta HTTP. export function sendCbzEntry(res, file, entry) { const headers = { 'Content-Type': imageType(entry.name), 'Cache-Control': 'private, max-age=604800' } @@ -106,10 +115,7 @@ export function sendCbzEntry(res, file, entry) { fs.createReadStream(file, { start: entry.start, end: entry.start + entry.size - 1 }).pipe(res) return } - if (entry.method !== 8) throw new Error(`Compresión ZIP no soportada (${entry.method})`) - const fd = fs.openSync(file, 'r') - const buf = Buffer.alloc(entry.size) - try { fs.readSync(fd, buf, 0, entry.size, entry.start) } finally { fs.closeSync(fd) } + const data = readCbzEntry(file, entry) res.writeHead(200, headers) - res.end(zlib.inflateRawSync(buf)) + res.end(data) } diff --git a/app/features.js b/app/features.js index 79addcb..d0d0d56 100644 --- a/app/features.js +++ b/app/features.js @@ -334,9 +334,9 @@ export function setupFeatures({ db, fetchMangaFull, enqueueDownload, downloadInf } if ((m = /^\/api\/prefs\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'POST') { const b = await readBody(req) - if (!MODES.includes(b.mode) || !FITS.includes(b.fit) || !DIRS.includes(b.dir)) throw new HttpError(400, 'Preferencias inválidas') + if (!MODES.includes(b.mode) || !FITS.includes(b.fit) || !DIRS.includes(b.dir) || (b.gap != null && typeof b.gap !== 'boolean')) throw new HttpError(400, 'Preferencias inválidas') db.prepare('INSERT OR REPLACE INTO manga_prefs (user_id, manga_id, prefs) VALUES (?, ?, ?)') - .run(user.id, m[1], JSON.stringify({ mode: b.mode, fit: b.fit, dir: b.dir })) + .run(user.id, m[1], JSON.stringify({ mode: b.mode, fit: b.fit, dir: b.dir, ...(b.gap != null ? { gap: b.gap } : {}) })) return json(res, 200, { ok: true }) } if (p === '/api/stats' && method === 'GET') return json(res, 200, stats(user.id)) diff --git a/app/public/app.js b/app/public/app.js index a3ee451..fffc2fd 100644 --- a/app/public/app.js +++ b/app/public/app.js @@ -31,6 +31,10 @@ const ICONS = { chart: '', zoomOut: '', send: '', + dots: '', + x: '', + scroll: '', + pages: '', sparkles: '', } const icon = name => `` @@ -298,8 +302,9 @@ async function viewManga(id, langOverride) { if (cleanup) { cleanup(); cleanup = null } app.innerHTML = '
Cargando…
' const lang = langOverride || store.get(`lang:${id}`, me.lang) - const { manga, chapters, reads, progress, chapterLangs, offline, downloaded, download, following, newKeys = [] } = await api(`/api/manga/${id}?lang=${encodeURIComponent(lang)}`) + const { manga, chapters, reads, progress, chapterLangs, offline, downloaded, download, upscale, following, newKeys = [] } = await api(`/api/manga/${id}?lang=${encodeURIComponent(lang)}`) const dlSet = new Set(downloaded) + let hdSet = new Set(upscale?.chapters || []), hdJobs = upscale?.jobs || {} const newSet = new Set(newKeys.map(String)) let isFollowing = following const readMap = new Map(reads.map(r => [r.chapter_id, r])) @@ -315,52 +320,71 @@ async function viewManga(id, langOverride) { } if (first && !mainBtn) mainBtn = `${icon('book')} Empezar a leer` + const menuItems = [ + isLc ? '' : `${icon('search')} Buscar en LeerCapitulo`, + progress || reads.length ? `` : '', + ].join('') + app.innerHTML = `
-
+

${esc(manga.title)}

${esc(manga.authors.join(', '))}${manga.year ? ` · ${manga.year}` : ''}${manga.status ? ` · ${esc(manga.status)}` : ''}
${manga.tags.map(t => (t.id ? `${esc(t.name)}` : `${esc(t.name ?? t)}`)).join('')}

${esc(manga.description)}

-
${mainBtn} +
${mainBtn} - ${progress || reads.length ? `` : ''} - ${isLc ? '' : ` - ${icon('search')} Buscar en LeerCapitulo`} + ${menuItems ? `` : ''}
-
Fuente: ${esc(SOURCES[manga.source])}${download ? ` · Carpeta: mangas/${esc(download.folder)}` : ''}
+
Fuente: ${esc(SOURCES[manga.source])}${download ? `Carpeta: mangas/${esc(download.folder)}` : ''}
${offline ? '

Sin conexión con la fuente: mostrando solo los capítulos descargados.

' : ''} -
-

+
+
+
+
+

Capítulos (${chapters.length})${newSet.size ? ` ${newSet.size} ${newSet.size === 1 ? 'nuevo' : 'nuevos'}` : ''}

+
+ ${isLc ? '' : ``} + ${chapters.length > 1 ? `` : ''}
-

Capítulos (${chapters.length})${newSet.size ? ` ${newSet.size} ${newSet.size === 1 ? 'nuevo' : 'nuevos'}` : ''} - ${chapters.length > 1 ? `` : ''}

${chapters.length ? '
    ' : isLc ? '

    Este manga no tiene capítulos.

    ' : `

    No hay capítulos legibles en ${esc(LANGS[lang] || lang)} (MangaDex retira algunos por licencia). Prueba a buscarlo en LeerCapitulo. - ${chapterLangs.length ? `Disponibles en: ${chapterLangs.map(l => `${esc(LANGS[l] || l)}`).join(' ')}` : 'No hay capítulos en ningún idioma.'}

    `}` - // Botón de descarga con sondeo del progreso mientras está activa. + ${chapterLangs.length ? `Disponibles en: ${chapterLangs.map(l => `${esc(LANGS[l] || l)}`).join(' ')}` : 'No hay capítulos en ningún idioma.'}

    `} + ` + + // --- Copia local (descarga) con sondeo del progreso mientras está activa. let dlTimer = null + const secHead = (ic, title, status) => `
    ${icon(ic)} ${title}
    ${status ? `
    ${status}
    ` : ''}
    ` const renderDl = d => { const box = $('#dl') if (!box) return const active = d && DL_ACTIVE.includes(d.status) - const delBtn = d ? `` : '' - const bar = d && (active || d.done < d.total) ? `
    ` : '' - let html - if (!d || d.status === 'cancelled') html = `
    ${delBtn}
    ${bar}` - else if (active) html = `
    ${downloadLabel(d)} · ${Math.floor(downloadPct(d))}%${delBtn}
    ${bar}
    ${downloadDetail(d)}
    ` - else html = `
    ${icon('check')} ${downloadLabel(d)}${delBtn}
    ${bar}` - box.innerHTML = offline ? (d ? `${downloadLabel(d)}` : '') : html - $('#dlerr').textContent = d?.status === 'error' ? d.error || '' : '' + const partial = d && d.done < d.total + const bar = d && (active || partial) ? `
    ` : '' + const delBtn = d ? `` : '' + let status = '', body = '', actions = '' + if (!d) { + body = '
    Guarda los capítulos en CBZ en el servidor para leerlos sin conexión (y poder reescalarlos a HD).
    ' + actions = `` + } else if (active) { + status = `${downloadLabel(d)} · ${Math.floor(downloadPct(d))}%` + body = `${bar}
    ${downloadDetail(d)}
    ` + actions = `${delBtn}` + } else { + status = d.status === 'done' ? `${icon('check')} ${d.done} capítulos${d.size ? ` · ${fmtSize(d.size)}` : ''}` : downloadLabel(d) + body = bar + (d.status === 'error' && d.error ? `
    ${esc(d.error)}
    ` : '') + actions = `${delBtn}` + } + box.innerHTML = secHead('download', 'Copia local', status) + body + (offline ? '' : `
    ${actions}
    `) $('#dlgo')?.addEventListener('click', async () => { renderDl(await api(`/api/download/${id}`, { method: 'POST', body: { lang, title: manga.title } })) toast(`Descargando en mangas/${manga.title}`) }) $('#dlcancel')?.addEventListener('click', async () => renderDl(await api(`/api/download/${id}/cancel`, { method: 'POST' }))) $('#dldel')?.addEventListener('click', async () => { - if (!confirm(`¿Eliminar la copia local de «${manga.title}»?\nSe borrará la carpeta mangas/${d.folder}${d.size ? ` (${fmtSize(d.size)})` : ''}.\nTu historial de lectura no se toca.`)) return + if (!confirm(`¿Eliminar la copia local de «${manga.title}»?\nSe borrará la carpeta mangas/${d.folder}${d.size ? ` (${fmtSize(d.size)})` : ''}, incluida la versión HD.\nTu historial de lectura no se toca.`)) return await api(`/api/download/${id}`, { method: 'DELETE' }) toast('Copia local eliminada') viewManga(id, langOverride) @@ -369,12 +393,159 @@ async function viewManga(id, langOverride) { if (active) dlTimer = setTimeout(async () => { const next = await api(`/api/download/${id}`).catch(() => d) if (!$('#dl')) return - if (next && next.done !== d.done) { const fresh = await api(`/api/manga/${id}?lang=${encodeURIComponent(lang)}`).catch(() => null); fresh?.downloaded.forEach(x => dlSet.add(x)); renderList(); if (fresh?.download) next.size = fresh.download.size } + if (next && next.done !== d.done) { + const fresh = await api(`/api/manga/${id}?lang=${encodeURIComponent(lang)}`).catch(() => null) + fresh?.downloaded.forEach(x => dlSet.add(x)) + if (fresh?.download) next.size = fresh.download.size + if (fresh?.upscale) renderHd(fresh.upscale) + renderList() + } renderDl(next) }, 2000) } - renderDl(download) - cleanup = () => clearTimeout(dlTimer) + + // --- Reescalado HD: todo lo pendiente, un rango o una selección hecha en la lista. + let hdTimer = null + let hdInfo = upscale + let hdForm = null // null (cerrado) | 'pending' | 'range' | 'pick' + const picked = new Set() + let lastPick = null + 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` + // Descargados, sin HD y sin estar ya en cola (los fallidos sí, para reintentar). En orden de lectura. + const hdCandidates = () => chapters.filter(c => dlSet.has(c.id) && !hdSet.has(c.id) && (!hdJobs[c.id] || hdJobs[c.id] === 'error')) + const estimate = ids => { + const pages = ids.reduce((n, cid) => n + (hdInfo?.pages?.[cid] || 0), 0) + return `${ids.length} capítulo${ids.length === 1 ? '' : 's'} · ${pages} págs. · ${fmtDur(pages * (hdInfo?.secPerPage || 13))}` + } + const formIds = () => { + const cand = hdCandidates() + if (hdForm === 'pending') return cand.map(c => c.id) + if (hdForm === 'pick') return cand.filter(c => picked.has(c.id)).map(c => c.id) + if (hdForm === 'range') { + const dls = chapters.filter(c => dlSet.has(c.id)) + let a = dls.findIndex(c => c.id === $('#hdfrom')?.value), b = dls.findIndex(c => c.id === $('#hdto')?.value) + if (a < 0 || b < 0) return [] + if (a > b) [a, b] = [b, a] + const inRange = new Set(dls.slice(a, b + 1).map(c => c.id)) + return cand.filter(c => inRange.has(c.id)).map(c => c.id) + } + return [] + } + const updateSummary = () => { + const ids = formIds() + const sum = $('#hdsum') + if (sum) sum.textContent = ids.length ? estimate(ids) : hdForm === 'pick' ? 'Marca capítulos en la lista de abajo.' : 'No hay capítulos pendientes en esa selección.' + if ($('#hdrun')) $('#hdrun').disabled = !ids.length + const bar = $('#selbar') + if (bar) { + bar.hidden = hdForm !== 'pick' + if (hdForm === 'pick') bar.innerHTML = `${ids.length ? estimate(ids) : 'Ningún capítulo marcado'} + + ` + $('#selrun')?.addEventListener('click', runHd) + $('#selexit')?.addEventListener('click', () => setForm(null)) + } + } + async function runHd() { + const ids = formIds() + if (!ids.length) return + try { + const u = await api(`/api/upscale/${id}`, { method: 'POST', body: { chapterIds: ids } }) + toast(`${ids.length} capítulo${ids.length === 1 ? '' : 's'} en cola para HD`) + picked.clear() + setForm(null) + renderHd(u) + } catch (err) { toast(err.message) } + } + function setForm(mode) { + const wasPick = hdForm === 'pick' + hdForm = mode + renderHdForm() + if (wasPick !== (mode === 'pick')) renderList() + updateSummary() + } + function renderHdForm() { + const box = $('#hdform') + if (!box) return + box.hidden = !hdForm + if (!hdForm) { box.innerHTML = ''; return } + const dls = chapters.filter(c => dlSet.has(c.id)) + const cand = hdCandidates() + const opt = (c, sel) => `` + const from = cand[0] || dls[0], to = cand[Math.min(cand.length, 10) - 1] || dls.at(-1) + box.innerHTML = ` +
    + + + +
    + ${hdForm === 'range' ? `
    +
    ` : ''} + ${hdForm === 'pick' ? '
    Marca los capítulos en la lista de abajo. Mayús + clic marca un tramo entero.
    ' : ''} + ${hdForm === 'pending' ? `
    Todos los capítulos descargados que aún no están en HD.
    + ` : ''} +
    +
    ` + box.querySelectorAll('[data-hm]').forEach(b => b.addEventListener('click', () => setForm(b.dataset.hm))) + $('#hdfrom')?.addEventListener('change', updateSummary) + $('#hdto')?.addEventListener('change', updateSummary) + $('#hdclose').addEventListener('click', () => setForm(null)) + $('#hdrun').addEventListener('click', async () => { + if (hdForm === 'pending' && $('#hdautoform')?.checked !== !!hdInfo?.auto) await api(`/api/upscale/${id}/auto`, { method: 'POST', body: { auto: $('#hdautoform').checked } }).catch(() => {}) + runHd() + }) + } + const renderHd = u => { + const box = $('#hd') + if (!box) return + clearTimeout(hdTimer) + hdInfo = u + const show = !!(u?.available && u.downloaded && !offline) + box.hidden = !show + if (show) { + const queued = Object.values(u.jobs).filter(s => s !== 'error').length + const active = !!u.current || queued > 0 + const c = u.current + const status = `${u.hd} de ${u.downloaded} capítulos${u.size ? ` · ${fmtSize(u.size)}` : ''}` + const remaining = Object.entries(u.jobs).filter(([, st]) => st !== 'error').map(([cid]) => cid) + let body = '' + if (active) { + const pct = c?.total ? (c.pages / c.total) * 100 : 0 + body = `
    +
    ${c ? `Cap. ${esc(c.num ?? '?')} · pág. ${c.pages}/${c.total}` : 'Esperando turno…'}${queued > 1 ? ` · ${queued - 1} más en cola` : ''} · faltan ${fmtDur(remaining.reduce((n, cid) => n + (u.pages?.[cid] || 0), 0) * u.secPerPage - (c?.pages || 0) * u.secPerPage)}
    ` + } else if (!u.hd) { + body = `
    Mejora la nitidez con IA (Real-ESRGAN ×2). Unos ${u.secPerPage} s por página; el original no se toca.
    ` + } + if (u.error) body += `
    Falló algún capítulo: ${esc(u.error)}
    ` + const canAdd = hdCandidates().length > 0 + const actions = [ + canAdd ? `` : '', + active ? `` : '', + u.hd ? `` : '', + ].join('') + // El formulario se conserva entre sondeos: solo se repinta la parte de arriba. + if (!$('#hdtop')) box.innerHTML = '
    ' + $('#hdtop').innerHTML = secHead('sparkles', 'Reescalado HD', status) + body + (actions ? `
    ${actions}
    ` : '') + + (u.auto ? '
    Los capítulos que se descarguen también se reescalan.
    ' : '') + $('#hdopen')?.addEventListener('click', () => setForm(hdForm ? null : 'pending')) + $('#hdcancel')?.addEventListener('click', async () => { + if (!confirm('¿Detener el reescalado?\nSe vacía la cola de este manga; lo ya reescalado se queda.')) return + renderHd(await api(`/api/upscale/${id}/cancel`, { method: 'POST' })) + }) + $('#hddel')?.addEventListener('click', async () => { + if (!confirm(`¿Quitar la versión HD de «${manga.title}»?\nSe borra mangas/${download?.folder ?? ''}/.hd${u.size ? ` (${fmtSize(u.size)})` : ''}; los capítulos originales se quedan.`)) return + renderHd(await api(`/api/upscale/${id}`, { method: 'DELETE' })) + toast('Versión HD eliminada') + }) + if (active) hdTimer = setTimeout(async () => { const next = await api(`/api/upscale/${id}`).catch(() => null); if (next && $('#hd')) renderHd(next) }, 3000) + } + const changed = (u?.chapters?.length ?? 0) !== hdSet.size || JSON.stringify(u?.jobs || {}) !== JSON.stringify(hdJobs) + hdSet = new Set(u?.chapters || []); hdJobs = u?.jobs || {} + if (changed) { renderList(); if (hdForm && hdForm !== 'pick') renderHdForm(); updateSummary() } + if (hdForm && !hdCandidates().length) setForm(null) + } + cleanup = () => { clearTimeout(dlTimer); clearTimeout(hdTimer) } + const renderFollow = () => { const b = $('#follow') b.innerHTML = isFollowing ? `${icon('bell')} Siguiendo` : `${icon('bellOff')} Seguir` @@ -395,23 +566,57 @@ async function viewManga(id, langOverride) { toast('Quitado del historial') viewManga(id, langOverride) }) + // Cerrar el menú ⋯ al pulsar fuera. + const menu = $('.menu', app) + const closeMenu = e => { if (menu && !menu.contains(e.target)) menu.open = false } + document.addEventListener('click', closeMenu) + cleanup = () => { clearTimeout(dlTimer); clearTimeout(hdTimer); document.removeEventListener('click', closeMenu) } app.querySelectorAll('[data-lang]').forEach(a => a.addEventListener('click', e => { e.preventDefault(); store.set(`lang:${id}`, a.dataset.lang); viewManga(id, a.dataset.lang) })) const ul = $('.chapters', app) let desc = store.get('chapterOrderDesc', true) - const renderList = () => { + const chapMeta = (c, r, done) => (newSet.has(String(c.num ?? c.id)) ? 'NUEVO ' : '') + + (dlSet.has(c.id) ? `${icon('download')} ` : '') + + (hdSet.has(c.id) ? 'HD ' + : hdJobs[c.id] ? `HD${hdJobs[c.id] === 'error' ? '!' : '…'} ` : '') + + (r ? (done ? 'Leído' : `pág. ${r.page + 1}/${r.total_pages}`) : esc(c.group)) + function renderList() { if (!ul) return const list = desc ? [...chapters].reverse() : chapters - ul.innerHTML = list.map(c => { + const pickable = new Set(hdForm === 'pick' ? hdCandidates().map(c => c.id) : []) + ul.classList.toggle('picking', hdForm === 'pick') + ul.innerHTML = list.map((c, i) => { const r = readMap.get(c.id) const done = r && r.total_pages && r.page >= r.total_pages - 1 + if (hdForm === 'pick') { + const ok = pickable.has(c.id) + return `
  • ` + } const cls = progress?.chapter_id === c.id ? 'current' : done ? 'read' : '' - const meta = (newSet.has(String(c.num ?? c.id)) ? 'NUEVO ' : '') + (dlSet.has(c.id) ? `${icon('download')} ` : '') + (r ? (done ? 'Leído' : `pág. ${r.page + 1}/${r.total_pages}`) : esc(c.group)) - return `
  • ${esc(chapLabel(c))}${meta}
  • ` + return `
  • ${esc(chapLabel(c))}${chapMeta(c, r, done)}
  • ` }).join('') } + // Selección para HD (Mayús + clic marca el tramo desde el último marcado). + ul?.addEventListener('click', e => { + const box = e.target.closest('input[data-cid]') + if (!box) return + const i = Number(box.dataset.i) + if (e.shiftKey && lastPick != null) { + const [a, b] = [Math.min(lastPick, i), Math.max(lastPick, i)] + ul.querySelectorAll('input[data-cid]:not(:disabled)').forEach(x => { + const k = Number(x.dataset.i) + if (k >= a && k <= b) { x.checked = box.checked; box.checked ? picked.add(x.dataset.cid) : picked.delete(x.dataset.cid) } + }) + } else box.checked ? picked.add(box.dataset.cid) : picked.delete(box.dataset.cid) + lastPick = i + updateSummary() + }) + renderDl(download) renderList() - $('#order')?.addEventListener('click', () => { desc = !desc; store.set('chapterOrderDesc', desc); renderList() }) + renderHd(upscale) + $('#order')?.addEventListener('click', () => { desc = !desc; lastPick = null; store.set('chapterOrderDesc', desc); renderList() }) $('#lang')?.addEventListener('change', e => { store.set(`lang:${id}`, e.target.value); viewManga(id, e.target.value) }) } @@ -419,10 +624,11 @@ async function viewManga(id, langOverride) { const READ_MODES = { paged: 'Una página', double: 'Doble página', vertical: 'Vertical' } // Ajustes por defecto según el tipo de obra (se usan hasta que el usuario cambie algo en ese manga). function defaultPrefs(manga) { - if (['Manhwa', 'Manhua', 'Webtoon'].includes(manga.type)) return { mode: 'vertical', fit: 'width', dir: 'ltr' } - if (manga.type === 'Manga') return { mode: 'paged', fit: 'height', dir: 'rtl' } + // gap: separación entre páginas en vertical (los webtoon son una tira continua; los mangas, páginas sueltas). + if (['Manhwa', 'Manhua', 'Webtoon'].includes(manga.type)) return { mode: 'vertical', fit: 'width', dir: 'ltr', gap: false } + if (manga.type === 'Manga') return { mode: 'paged', fit: 'height', dir: 'rtl', gap: true } const legacy = store.get('readerSettings', {}) - return { mode: READ_MODES[legacy.mode] ? legacy.mode : 'paged', fit: legacy.fit === 'width' ? 'width' : 'height', dir: legacy.dir === 'rtl' ? 'rtl' : 'ltr' } + return { mode: READ_MODES[legacy.mode] ? legacy.mode : 'paged', fit: legacy.fit === 'width' ? 'width' : 'height', dir: legacy.dir === 'rtl' ? 'rtl' : 'ltr', gap: true } } async function viewReader(mangaId, chapterId, startPage) { @@ -433,7 +639,7 @@ async function viewReader(mangaId, chapterId, startPage) { const globalSettings = { saver: false, filter: 'none', brightness: 100, ...store.get('readerSettings', {}) } const [info, chap] = await Promise.all([ api(`/api/manga/${mangaId}?lang=${encodeURIComponent(lang)}`), - api(`/api/chapter/${chapterId}?saver=${globalSettings.saver ? 1 : 0}`), + api(`/api/chapter/${chapterId}?saver=${globalSettings.saver ? 1 : 0}&hd=${globalSettings.hd === false ? 0 : 1}`), ]) const { manga, chapters } = info const settings = { ...globalSettings, ...defaultPrefs(manga), ...(info.prefs || {}) } @@ -471,16 +677,20 @@ async function viewReader(mangaId, chapterId, startPage) { ${icon('back')}
    ${esc(manga.title)}
    +
    @@ -497,6 +707,15 @@ async function viewReader(mangaId, chapterId, startPage) { $('#mode').value = settings.mode $('#fit').value = settings.fit $('#dir').value = settings.dir + $('#gap').checked = !!settings.gap + // Botón rápido páginas ⇄ vertical (vuelve al último modo por páginas usado). + let lastPaged = settings.mode === 'double' ? 'double' : 'paged' + function renderModeQuick() { + const b = $('#modequick') + b.innerHTML = icon(isPaged() ? 'scroll' : 'pages') + b.title = isPaged() ? 'Leer en vertical (desplazamiento continuo)' : 'Leer por páginas' + $('#mode').value = settings.mode + } $('#filter').value = FILTERS[settings.filter] ? settings.filter : 'none' $('#bright').value = settings.brightness const applyFilter = () => { @@ -505,6 +724,7 @@ async function viewReader(mangaId, chapterId, startPage) { } applyFilter() if ($('#saver')) $('#saver').checked = settings.saver + if ($('#hd')) $('#hd').checked = settings.hd !== false const endBlock = `

    Fin del capítulo ${esc(chapter.num ?? '')}

    ${next ? `${icon('next')} Siguiente: ${esc(chapLabel(next))}` : '

    No hay más capítulos disponibles.

    '} ${icon('back')} Volver a la ficha
    ` @@ -597,10 +817,13 @@ async function viewReader(mangaId, chapterId, startPage) { observer?.disconnect() const keep = lastShown() resetZoom() - box.className = isPaged() ? `paged ${settings.fit === 'width' ? 'fit-width' : ''} ${isDouble() ? 'double' : ''}` : 'vertical' + box.className = isPaged() ? `paged ${settings.fit === 'width' ? 'fit-width' : ''} ${isDouble() ? 'double' : ''}` : `vertical fit-${settings.fit} ${settings.gap ? 'gap' : ''}` + renderModeQuick() box.dataset.dir = settings.dir - $('#fit').hidden = !isPaged() + // En der → izq la barra de progreso también avanza de derecha a izquierda (en vertical no aplica). + slider.dir = isPaged() && settings.dir === 'rtl' ? 'rtl' : 'ltr' $('#dir').hidden = !isPaged() + $('#gapwrap').hidden = isPaged() if (isPaged()) { box.innerHTML = '' atEnd = false @@ -743,18 +966,29 @@ async function viewReader(mangaId, chapterId, startPage) { $('#zoomreset').addEventListener('click', resetZoom) $('#ropts').addEventListener('click', () => { const p = $('.rpanel', reader); p.hidden = !p.hidden; $('#ropts').classList.toggle('on', !p.hidden) }) - const saveGlobal = () => store.set('readerSettings', { saver: settings.saver, filter: settings.filter, brightness: settings.brightness }) + const saveGlobal = () => store.set('readerSettings', { saver: settings.saver, filter: settings.filter, brightness: settings.brightness, hd: settings.hd !== false }) const savePrefs = () => { - api(`/api/prefs/${mangaId}`, { method: 'POST', body: { mode: settings.mode, fit: settings.fit, dir: settings.dir } }) + api(`/api/prefs/${mangaId}`, { method: 'POST', body: { mode: settings.mode, fit: settings.fit, dir: settings.dir, gap: !!settings.gap } }) .then(() => toast('Guardado para este manga')).catch(err => toast(err.message)) } - $('#mode').addEventListener('change', e => { settings.mode = e.target.value; savePrefs(); render() }) + $('#mode').addEventListener('change', e => { settings.mode = e.target.value; if (isPaged()) lastPaged = settings.mode; savePrefs(); render() }) + $('#modequick').addEventListener('click', () => { + settings.mode = isPaged() ? 'vertical' : lastPaged + savePrefs() + render() + }) + $('#gap').addEventListener('change', e => { settings.gap = e.target.checked; savePrefs(); render() }) $('#fit').addEventListener('change', e => { settings.fit = e.target.value; savePrefs(); render() }) - $('#dir').addEventListener('change', e => { settings.dir = e.target.value; box.dataset.dir = settings.dir; savePrefs() }) + $('#dir').addEventListener('change', e => { settings.dir = e.target.value; box.dataset.dir = settings.dir; slider.dir = settings.dir; savePrefs() }) $('#filter').addEventListener('change', e => { settings.filter = e.target.value; saveGlobal(); applyFilter() }) $('#bright').addEventListener('input', e => { settings.brightness = Number(e.target.value); saveGlobal(); applyFilter() }) $('#bright').addEventListener('dblclick', e => { e.target.value = settings.brightness = 100; saveGlobal(); applyFilter() }) $('#saver')?.addEventListener('change', e => { settings.saver = e.target.checked; saveGlobal(); history.replaceState(null, '', readUrl(mangaId, chapterId, lastShown())); router() }) + $('#hd')?.addEventListener('change', e => { settings.hd = e.target.checked; saveGlobal(); history.replaceState(null, '', readUrl(mangaId, chapterId, lastShown())); router() }) + $('#hdone')?.addEventListener('click', async e => { + e.target.closest('button').disabled = true + try { await api(`/api/upscale/${mangaId}`, { method: 'POST', body: { chapterId } }); toast('En cola: estará en HD en unos minutos') } catch (err) { toast(err.message); e.target.closest('button').disabled = false } + }) $('#chap').addEventListener('change', e => { location.hash = readUrl(mangaId, e.target.value) }) slider.addEventListener('input', () => goTo(Number(slider.value) - 1)) diff --git a/app/public/style.css b/app/public/style.css index 0517efd..7b268e7 100644 --- a/app/public/style.css +++ b/app/public/style.css @@ -108,6 +108,10 @@ input:focus, select:focus { border-color: var(--accent); } .vertical { display: flex; flex-direction: column; align-items: center; } .vertical img { width: 100%; max-width: 900px; min-height: 300px; background: #111; } .vertical img[data-loaded] { min-height: 0; } +/* Vertical para mangas: cada página entera en pantalla y separadas */ +.vertical.fit-height img { width: auto; max-width: 100%; max-height: 100vh; max-height: 100dvh; } +.vertical.fit-height img:not([data-loaded]) { width: min(100%, 640px); } +.vertical.gap { gap: 14px; } .chapter-end { padding: 40px 16px 120px; text-align: center; display: grid; gap: 12px; justify-items: center; } #toast { position: fixed; bottom: 80px; left: 50%; transform: translateX(-50%); z-index: 50; background: var(--surface-2); @@ -155,6 +159,8 @@ a.tag:hover, .tag.on { background: var(--accent); color: var(--accent-ink); } .paged.zoomed { cursor: grab; } /* Insignias y botón Seguir */ +.hd { display: inline-block; font-size: .65rem; font-weight: 800; letter-spacing: .03em; padding: 0 5px; border-radius: 4px; border: 1px solid var(--accent); color: var(--accent); vertical-align: middle; } +.hd.pending { border-style: dashed; color: var(--muted); border-color: var(--muted); } .badge { display: inline-block; font-size: .72rem; font-weight: 700; padding: 1px 7px; border-radius: 999px; background: var(--accent); color: var(--accent-ink); vertical-align: middle; } .btn.on { border-color: var(--accent); color: var(--accent); } h2 .ic { color: var(--accent); } @@ -203,3 +209,65 @@ table.data td:not(:first-child), table.data th:not(:first-child) { text-align: r .rpanel p { margin: 0; font-size: .78rem; } .reader.hide-ui .rpanel { display: none; } .chart .axis { font-size: 12px; } + +/* Ficha: acciones, menú ⋯ y panel de biblioteca local */ +.detail-body { min-width: 0; } +.actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; } +.btn.on { border-color: var(--accent); color: var(--accent); } +.facts { display: flex; flex-wrap: wrap; gap: 2px 16px; font-size: .85rem; margin-top: 10px; } +.menu { position: relative; } +.menu > summary { list-style: none; padding-left: 12px; padding-right: 12px; } +.menu > summary::-webkit-details-marker { display: none; } +.menu[open] > summary { background: var(--surface-2); } +.menu-list { position: absolute; left: 0; top: calc(100% + 6px); z-index: 15; min-width: 230px; display: grid; padding: 6px; + background: var(--surface); border: 1px solid var(--surface-2); border-radius: var(--radius); box-shadow: 0 10px 28px rgba(0,0,0,.45); } +.menu-list a, .menu-list button { display: flex; align-items: center; gap: 10px; padding: 9px 10px; border: 0; border-radius: 8px; + background: none; text-align: left; cursor: pointer; white-space: nowrap; } +.menu-list a:hover, .menu-list button:hover { background: var(--surface-2); } +.menu-list .danger { color: var(--danger); } + +.libpanel { margin-top: 20px; max-width: 640px; background: var(--surface); border: 1px solid var(--surface-2); border-radius: 12px; } +.psec, .psec-grid { display: grid; gap: 10px; } +.psec { padding: 14px 16px; } +.psec + .psec { border-top: 1px solid var(--surface-2); } +.psec-head { display: flex; justify-content: space-between; align-items: baseline; flex-wrap: wrap; gap: 4px 16px; } +.psec-title { display: flex; align-items: center; gap: 8px; font-weight: 600; } +.psec-title .ic { color: var(--accent); } +.psec-status { font-size: .85rem; display: inline-flex; align-items: center; gap: 4px; } +.psec-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; } +.psec-actions .push { margin-left: auto; } +.note { font-size: .83rem; margin: 0; } +.psec .dlbar { height: 6px; } +.check { display: flex; align-items: center; gap: 8px; font-size: .85rem; color: var(--muted); cursor: pointer; } +input[type=checkbox] { accent-color: var(--accent); width: 16px; height: 16px; margin: 0; flex: none; } +.btn:disabled { opacity: .45; cursor: not-allowed; filter: none; } + +.hdform { display: grid; gap: 12px; padding: 12px; border-radius: 10px; background: var(--bg); border: 1px solid var(--surface-2); } +.seg { display: inline-flex; flex-wrap: wrap; gap: 2px; padding: 3px; justify-self: start; border-radius: 10px; background: var(--surface); border: 1px solid var(--surface-2); } +.seg button { border: 0; background: none; padding: 5px 12px; border-radius: 8px; cursor: pointer; color: var(--muted); font-size: .88rem; } +.seg button.on { background: var(--surface-2); color: var(--text); } +.range label { display: inline-flex; align-items: center; gap: 8px; font-size: .9rem; } +.range select { padding: 6px 10px; } +.sum { font-size: .9rem; font-weight: 600; } + +.list-head { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 8px 12px; margin: 28px 0 12px; } +.list-head h2 { margin: 0; } +.list-head select { padding: 5px 10px; font-size: .88rem; } +.chapters label.pick { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border-radius: 8px; background: var(--surface); cursor: pointer; user-select: none; } +.chapters label.pick:hover { background: var(--surface-2); } +.chapters label.pick .t { flex: 1; min-width: 0; } +.chapters label.pick.off { opacity: .4; cursor: default; } +.chapters label.pick:has(input:checked) { outline: 1px solid var(--accent); } +#selbar { position: fixed; left: 50%; bottom: 16px; transform: translateX(-50%); z-index: 25; display: flex; align-items: center; gap: 10px; + max-width: calc(100% - 32px); padding: 8px 8px 8px 16px; border-radius: 12px; background: var(--surface-2); border: 1px solid #3a3548; + box-shadow: 0 10px 28px rgba(0,0,0,.5); font-size: .9rem; } +#selbar span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.chapters.picking { padding-bottom: 72px; } +@media (max-width: 640px) { + .actions .btn.primary { flex: 1 1 100%; } + .libpanel { max-width: none; } + .psec-actions .push { margin-left: 0; } + .seg { display: grid; grid-template-columns: repeat(3, 1fr); justify-self: stretch; } + #selbar { left: 12px; right: 12px; transform: none; max-width: none; } + #selbar span { flex: 1; white-space: normal; font-size: .82rem; line-height: 1.3; } +} diff --git a/app/server.js b/app/server.js index 0b52a1d..bad43f2 100644 --- a/app/server.js +++ b/app/server.js @@ -7,6 +7,7 @@ import crypto from 'node:crypto' import { DatabaseSync } from 'node:sqlite' import { writeCbz, readCbzIndex, sendCbzEntry, imageType } from './cbz.js' import { setupFeatures } from './features.js' +import { setupUpscale } from './upscale.js' const PORT = Number(process.env.PORT || 3352) const DATA_DIR = process.env.DATA_DIR || '/app/data' @@ -487,6 +488,7 @@ async function downloadChapter(dl, manga, c, dir) { } db.prepare(`INSERT OR REPLACE INTO downloaded_chapters (chapter_id, manga_id, num, title, file, pages, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`) .run(c.id, dl.manga_id, c.num ?? null, c.title || '', file, urls.length, Date.now()) + upscale.onChapterDownloaded(dl.manga_id, c.id) } const dlUpdate = (mangaId, fields) => { @@ -504,13 +506,16 @@ function mangaDirOf(folder) { } function dirSize(dir) { let total = 0 - try { for (const f of fs.readdirSync(dir)) total += fs.statSync(path.join(dir, f)).size } catch {} + try { + for (const f of fs.readdirSync(dir, { withFileTypes: true })) total += f.isDirectory() ? dirSize(path.join(dir, f.name)) : fs.statSync(path.join(dir, f.name)).size + } catch {} return total } function deleteDownload(mangaId) { const dl = db.prepare('SELECT folder FROM downloads WHERE manga_id = ?').get(mangaId) if (!dl) return false const dir = mangaDirOf(dl.folder) + upscale.cancel(mangaId) db.prepare('DELETE FROM downloads WHERE manga_id = ?').run(mangaId) // borra también downloaded_chapters (CASCADE) dlCurrent.delete(mangaId) fs.rmSync(dir, { recursive: true, force: true }) @@ -589,10 +594,11 @@ function enqueueDownload(mangaId, title, lang) { } const cbzIndexCache = new Map() -function localChapter(chapterId) { - const row = db.prepare(`SELECT c.file, d.folder FROM downloaded_chapters c JOIN downloads d ON d.manga_id = c.manga_id WHERE c.chapter_id = ?`).get(chapterId) +// hd: usar la versión reescalada si existe. +function localChapter(chapterId, hd = false) { + const row = db.prepare(`SELECT c.file, c.hd_file, d.folder FROM downloaded_chapters c JOIN downloads d ON d.manga_id = c.manga_id WHERE c.chapter_id = ?`).get(chapterId) if (!row) return null - const file = path.join(MANGA_DIR, row.folder, row.file) + const file = (hd && upscale.hdFileOf(chapterId)) || path.join(MANGA_DIR, row.folder, row.file) let st try { st = fs.statSync(file) } catch { return null } const key = `${file}:${st.mtimeMs}` @@ -697,6 +703,7 @@ async function route(req, res) { // Todo lo demás requiere sesión. const user = requireUser(req) if ((await features.route(p, method, q, req, res, user)) !== false) return + if ((await upscale.route(p, method, req, res)) !== false) return if (p === '/api/settings' && method === 'POST') { const body = await readBody(req) @@ -748,18 +755,20 @@ async function route(req, res) { const reads = db.prepare('SELECT chapter_id, page, total_pages FROM chapter_reads WHERE user_id = ? AND manga_id = ?').all(user.id, m[1]) const progress = db.prepare('SELECT * FROM progress WHERE user_id = ? AND manga_id = ?').get(user.id, m[1]) ?? null const chapterLangs = chapters.length || manga.source === 'lc' || offline ? [] : await getChapterLangs(m[1]).catch(() => []) - return json(res, 200, { manga, chapters, lang, reads, progress, chapterLangs, offline, downloaded, download: downloadInfo(m[1], true), ...features.mangaExtras(user.id, manga) }) + return json(res, 200, { manga, chapters, lang, reads, progress, chapterLangs, offline, downloaded, download: downloadInfo(m[1], true), upscale: upscale.info(m[1]), ...features.mangaExtras(user.id, manga) }) } if ((m = /^\/api\/chapter\/([^/]+)$/.exec(p)) && CHAPTER_ID.test(m[1]) && method === 'GET') { - const local = localChapter(m[1]) + const hdAvailable = !!upscale.hdFileOf(m[1]) + const hd = hdAvailable && q.get('hd') !== '0' + const local = localChapter(m[1], hd) const pages = local - ? local.entries.map((_, i) => `/api/local/${m[1]}/${i}`) + ? local.entries.map((_, i) => `/api/local/${m[1]}/${i}${hd ? '?hd=1' : ''}`) : m[1].startsWith('lc~') ? await lcPages(m[1]) : await getPages(m[1], q.get('saver') === '1') const read = db.prepare('SELECT page FROM chapter_reads WHERE user_id = ? AND chapter_id = ?').get(user.id, m[1]) - return json(res, 200, { pages, savedPage: read?.page ?? 0, local: !!local }) + return json(res, 200, { pages, savedPage: read?.page ?? 0, local: !!local, hd, hdAvailable }) } if ((m = /^\/api\/local\/([^/]+)\/(\d+)$/.exec(p)) && CHAPTER_ID.test(m[1]) && method === 'GET') { - const local = localChapter(m[1]) + const local = localChapter(m[1], q.get('hd') === '1') const entry = local?.entries[Number(m[2])] if (!entry) throw new HttpError(404, 'Página no descargada') return sendCbzEntry(res, local.file, entry) @@ -841,6 +850,7 @@ async function route(req, res) { } const features = setupFeatures({ db, fetchMangaFull, enqueueDownload, downloadInfo, HttpError, json, readBody, MANGA_ID }) +const upscale = setupUpscale({ db, MANGA_DIR, HttpError, json, readBody, MANGA_ID }) // Migración: portadas guardadas antes de firmar las URLs del proxy. for (const row of db.prepare("SELECT user_id, manga_id, cover FROM progress WHERE cover LIKE '/api/img?u=%' AND cover NOT LIKE '%&s=%'").all()) { diff --git a/app/upscale.js b/app/upscale.js new file mode 100644 index 0000000..359038d --- /dev/null +++ b/app/upscale.js @@ -0,0 +1,228 @@ +// Reescalado (HD) de capítulos descargados con Real-ESRGAN (ncnn-vulkan, GPU por /dev/dri). +// El original no se toca: la versión HD va a /.hd/.cbz (oculta para Komga/Kavita). +// Una cola persistente en SQLite procesa un capítulo cada vez. +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { spawn } from 'node:child_process' +import { writeCbz, readCbzIndex, readCbzEntry } from './cbz.js' + +const BIN = process.env.UPSCALER_BIN || '/opt/realesrgan/realesrgan-ncnn-vulkan' +const MODELS = process.env.UPSCALER_MODELS || path.join(path.dirname(BIN), 'models') +const MODEL = process.env.UPSCALER_MODEL || 'realesr-animevideov3' +const SCALE = Number(process.env.UPSCALER_SCALE || 2) +const WEBP_QUALITY = Number(process.env.UPSCALER_QUALITY || 85) +const HD_DIR = '.hd' +const UPSCALABLE = /\.(jpe?g|png|webp)$/i // lo demás (gif, avif) se copia tal cual + +function run(cmd, args, onSpawn) { + return new Promise((resolve, reject) => { + const p = spawn(cmd, args, { stdio: ['ignore', 'ignore', 'pipe'] }) + onSpawn?.(p) + let err = '' + p.stderr.on('data', d => { err = (err + d).slice(-2000) }) + p.on('error', reject) + p.on('close', (code, signal) => (code === 0 ? resolve() : reject(new Error(signal ? `interrumpido (${signal})` : `${path.basename(cmd)} salió con ${code}: ${err.trim().split('\n').filter(l => !/^\d+(\.\d+)?%$/.test(l)).slice(-2).join(' ')}`)))) + }) +} + +export function setupUpscale({ db, MANGA_DIR, HttpError, json, readBody, MANGA_ID }) { + const cols = t => db.prepare(`PRAGMA table_info(${t})`).all().map(c => c.name) + if (!cols('downloaded_chapters').includes('hd_file')) db.exec('ALTER TABLE downloaded_chapters ADD COLUMN hd_file TEXT') + if (!cols('downloads').includes('hd_auto')) db.exec('ALTER TABLE downloads ADD COLUMN hd_auto INTEGER NOT NULL DEFAULT 0') + db.exec(` + CREATE TABLE IF NOT EXISTS upscale_jobs ( + chapter_id TEXT PRIMARY KEY REFERENCES downloaded_chapters(chapter_id) ON DELETE CASCADE, + manga_id TEXT NOT NULL, + status TEXT NOT NULL, -- queued | running | error + error TEXT, + created_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_upscale_manga ON upscale_jobs(manga_id); + `) + db.prepare("UPDATE upscale_jobs SET status = 'queued' WHERE status = 'running'").run() // reinicio a mitad + db.exec('CREATE TABLE IF NOT EXISTS kv (k TEXT PRIMARY KEY, v TEXT)') + // Segundos por página medidos (media móvil), para estimar cuánto tardará una cola. + let secPerPage = Number(db.prepare("SELECT v FROM kv WHERE k = 'hd_sec_per_page'").get()?.v) || 13 + + let hasCwebp = false + const available = () => fs.existsSync(BIN) && fs.existsSync(path.join(MODELS, `${MODEL}-x${SCALE}.bin`)) && fs.existsSync('/dev/dri') && hasCwebp + run('cwebp', ['-version']).then(() => { hasCwebp = true; if (available()) pump() }, () => {}) + + // Capítulo en curso (solo en memoria). + let current = null // { chapterId, mangaId, num, pages, total, proc } + + const chapterRow = chapterId => db.prepare(`SELECT c.chapter_id, c.manga_id, c.num, c.file, c.hd_file, d.folder FROM downloaded_chapters c + JOIN downloads d ON d.manga_id = c.manga_id WHERE c.chapter_id = ?`).get(chapterId) + + async function upscaleChapter(job) { + const row = chapterRow(job.chapter_id) + if (!row) return + const dir = path.join(MANGA_DIR, row.folder) + const src = path.join(dir, row.file) + const entries = readCbzIndex(src) + current = { chapterId: row.chapter_id, mangaId: row.manga_id, num: row.num, pages: 0, total: entries.length, proc: null } + const work = fs.mkdtempSync(path.join(os.tmpdir(), 'hd-')) + try { + const inDir = path.join(work, 'in'), outDir = path.join(work, 'out') + fs.mkdirSync(inDir); fs.mkdirSync(outDir) + const names = entries.map((e, i) => { + const ext = e.name.split('.').pop().toLowerCase() + const name = `${String(i + 1).padStart(4, '0')}.${ext}` + fs.writeFileSync(path.join(inDir, name), readCbzEntry(src, e)) + return name + }) + // Real-ESRGAN procesa la carpeta entera; el progreso se mide contando las salidas. + const poll = setInterval(() => { try { current.pages = fs.readdirSync(outDir).length } catch {} }, 1500) + try { + await run(BIN, ['-i', inDir, '-o', outDir, '-m', MODELS, '-n', MODEL, '-s', String(SCALE), '-f', 'png'], p => { current.proc = p }) + } finally { clearInterval(poll) } + if (!db.prepare('SELECT 1 FROM upscale_jobs WHERE chapter_id = ?').get(row.chapter_id)) return // cancelado + const out = [] + for (const name of names) { + const base = name.replace(/\.[^.]+$/, '') + const png = path.join(outDir, `${base}.png`) + if (UPSCALABLE.test(name) && fs.existsSync(png)) { + const webp = path.join(work, `${base}.webp`) + await run('cwebp', ['-quiet', '-q', String(WEBP_QUALITY), '-m', '4', '-mt', png, '-o', webp]) + out.push({ name: `${base}.webp`, data: fs.readFileSync(webp) }) + fs.rmSync(webp); fs.rmSync(png) + } else { + out.push({ name, data: fs.readFileSync(path.join(inDir, name)) }) // sin reescalar + } + } + if (!chapterRow(row.chapter_id)) return // la descarga se borró mientras tanto + fs.mkdirSync(path.join(dir, HD_DIR), { recursive: true }) + const hdFile = path.join(HD_DIR, row.file) + writeCbz(path.join(dir, hdFile), out) + const ok = db.prepare('UPDATE downloaded_chapters SET hd_file = ? WHERE chapter_id = ?').run(hdFile, row.chapter_id).changes + if (!ok) fs.rmSync(path.join(dir, hdFile), { force: true }) // la descarga se borró mientras tanto + return entries.length + } finally { + fs.rmSync(work, { recursive: true, force: true }) + current = null + } + } + + let pumping = false + async function pump() { + if (pumping || !available()) return + pumping = true + try { + for (;;) { + const job = db.prepare("SELECT * FROM upscale_jobs WHERE status = 'queued' ORDER BY created_at, rowid LIMIT 1").get() + if (!job) break + db.prepare("UPDATE upscale_jobs SET status = 'running' WHERE chapter_id = ?").run(job.chapter_id) + const t = Date.now() + try { + const pages = await upscaleChapter(job) + db.prepare('DELETE FROM upscale_jobs WHERE chapter_id = ?').run(job.chapter_id) + const secs = (Date.now() - t) / 1000 + if (pages) { + secPerPage = secPerPage * 0.7 + (secs / pages) * 0.3 + db.prepare("INSERT INTO kv (k, v) VALUES ('hd_sec_per_page', ?) ON CONFLICT (k) DO UPDATE SET v = excluded.v").run(secPerPage.toFixed(2)) + } + console.log(`HD: ${job.chapter_id} en ${Math.round(secs)} s (${pages || 0} págs.)`) + } catch (e) { + console.warn('HD', job.chapter_id, e.message) + db.prepare("UPDATE upscale_jobs SET status = 'error', error = ? WHERE chapter_id = ?").run(e.message.slice(0, 500), job.chapter_id) + } + } + } finally { + pumping = false + } + } + + function enqueue(chapterIds) { + const ins = db.prepare(`INSERT INTO upscale_jobs (chapter_id, manga_id, status, created_at) + SELECT chapter_id, manga_id, 'queued', ? FROM downloaded_chapters WHERE chapter_id = ? AND hd_file IS NULL + ON CONFLICT (chapter_id) DO UPDATE SET status = 'queued', error = NULL WHERE status = 'error'`) + const now = Date.now() + let n = 0 + for (const id of chapterIds) n += ins.run(now, id).changes + pump() + return n + } + + function cancel(mangaId) { + db.prepare('DELETE FROM upscale_jobs WHERE manga_id = ?').run(mangaId) + if (current?.mangaId === mangaId) current.proc?.kill('SIGTERM') + } + + // Ruta absoluta del CBZ HD de un capítulo, o null. + function hdFileOf(chapterId) { + const row = chapterRow(chapterId) + if (!row?.hd_file) return null + const file = path.join(MANGA_DIR, row.folder, row.hd_file) + return fs.existsSync(file) ? file : null + } + + function info(mangaId) { + const dl = db.prepare('SELECT folder, hd_auto FROM downloads WHERE manga_id = ?').get(mangaId) + if (!dl) return { available: available() } + const rows = db.prepare('SELECT chapter_id, pages, hd_file FROM downloaded_chapters WHERE manga_id = ?').all(mangaId) + const jobs = db.prepare('SELECT chapter_id, status, error FROM upscale_jobs WHERE manga_id = ?').all(mangaId) + let size = 0 + try { for (const f of fs.readdirSync(path.join(MANGA_DIR, dl.folder, HD_DIR))) size += fs.statSync(path.join(MANGA_DIR, dl.folder, HD_DIR, f)).size } catch {} + const cur = current?.mangaId === mangaId ? { chapterId: current.chapterId, num: current.num, pages: current.pages, total: current.total } : null + return { + available: available(), auto: !!dl.hd_auto, downloaded: rows.length, hd: rows.filter(r => r.hd_file).length, size, current: cur, + chapters: rows.filter(r => r.hd_file).map(r => r.chapter_id), + pages: Object.fromEntries(rows.map(r => [r.chapter_id, r.pages])), // para estimar tiempos + secPerPage: Math.round(secPerPage * 10) / 10, + jobs: Object.fromEntries(jobs.map(j => [j.chapter_id, j.status])), + error: (j => j && `Cap. ${db.prepare('SELECT num FROM downloaded_chapters WHERE chapter_id = ?').get(j.chapter_id)?.num ?? '?'}: ${j.error}`)(jobs.find(j => j.status === 'error')) || null, + } + } + + // Llamado al terminar de descargar un capítulo: si el manga está marcado como HD, encolarlo. + function onChapterDownloaded(mangaId, chapterId) { + if (db.prepare('SELECT hd_auto FROM downloads WHERE manga_id = ?').get(mangaId)?.hd_auto) enqueue([chapterId]) + } + + async function route(p, method, req, res) { + const m = /^\/api\/upscale\/([^/]+)(\/cancel|\/auto)?$/.exec(p) + if (!m || !MANGA_ID.test(m[1])) return false + const mangaId = m[1] + if (method === 'GET' && !m[2]) return json(res, 200, info(mangaId)), true + if (method === 'POST' && m[2] === '/auto') { + const b = await readBody(req) + db.prepare('UPDATE downloads SET hd_auto = ? WHERE manga_id = ?').run(b.auto ? 1 : 0, mangaId) + return json(res, 200, info(mangaId)), true + } + if (method === 'POST' && m[2]) { + cancel(mangaId) + db.prepare('UPDATE downloads SET hd_auto = 0 WHERE manga_id = ?').run(mangaId) + return json(res, 200, info(mangaId)), true + } + if (method === 'POST') { + if (!available()) throw new HttpError(503, 'El reescalado no está disponible (falta Real-ESRGAN o la GPU en el contenedor)') + const b = await readBody(req) + let ids + if (b.chapterId) ids = [String(b.chapterId)] + else if (Array.isArray(b.chapterIds)) ids = b.chapterIds.slice(0, 5000).map(String) // selección o rango + else { + // Todo el manga: lo descargado y, en adelante, lo que se descargue. + db.prepare('UPDATE downloads SET hd_auto = 1 WHERE manga_id = ?').run(mangaId) + ids = db.prepare('SELECT chapter_id FROM downloaded_chapters WHERE manga_id = ? ORDER BY CAST(num AS REAL), created_at').all(mangaId).map(r => r.chapter_id) + } + if (!db.prepare('SELECT 1 FROM downloads WHERE manga_id = ?').get(mangaId)) throw new HttpError(404, 'Este manga no está descargado') + enqueue(ids) + return json(res, 200, info(mangaId)), true + } + if (method === 'DELETE') { + // Quitar la versión HD (el original se queda). + cancel(mangaId) + db.prepare('UPDATE downloads SET hd_auto = 0 WHERE manga_id = ?').run(mangaId) + const dl = db.prepare('SELECT folder FROM downloads WHERE manga_id = ?').get(mangaId) + if (dl) fs.rmSync(path.join(MANGA_DIR, dl.folder, HD_DIR), { recursive: true, force: true }) + db.prepare('UPDATE downloaded_chapters SET hd_file = NULL WHERE manga_id = ?').run(mangaId) + return json(res, 200, info(mangaId)), true + } + return false + } + + if (!fs.existsSync(BIN)) console.log('HD: Real-ESRGAN no instalado, reescalado desactivado') + else if (!fs.existsSync('/dev/dri')) console.log('HD: sin /dev/dri en el contenedor, reescalado desactivado') + return { info, hdFileOf, onChapterDownloaded, cancel, route, pump } +} diff --git a/docker-compose.yml b/docker-compose.yml index 2027be8..c346dcf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,6 +24,12 @@ services: - ./data:/app/data # Mangas descargados: /Capitulo NNNN.cbz + cover.jpg - ${MANGA_DIR_HOST:-./mangas}:/mangas + # GPU para el reescalado HD (Real-ESRGAN por Vulkan). Sin /dev/dri en el host, borra estas líneas + # y el reescalado queda desactivado. RENDER_GID = grupo "render" del host (getent group render). + devices: + - /dev/dri:/dev/dri + group_add: + - "${RENDER_GID:-render}" ports: # 0.0.0.0 no depende de que la IP de la LAN o de Tailscale existan al arrancar. # Para limitarlo a una interfaz: BIND_IP=192.168.1.141 en .env.