Reescalado HD, ficha reorganizada y mejoras del lector
- 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 <carpeta>/.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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
+20
-4
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
`<Título>/.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://<ip-del-servidor>: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.
|
||||
|
||||
+11
-5
@@ -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)
|
||||
}
|
||||
|
||||
+2
-2
@@ -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))
|
||||
|
||||
+276
-42
@@ -31,6 +31,10 @@ const ICONS = {
|
||||
chart: '<path d="M3 3v18h18"/><path d="M7 16v-4M12 16V8M17 16v-7"/>',
|
||||
zoomOut: '<circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5M8 11h6"/>',
|
||||
send: '<path d="M22 2 11 13"/><path d="M22 2 15 22l-4-9-9-4z"/>',
|
||||
dots: '<circle cx="5" cy="12" r="1.2"/><circle cx="12" cy="12" r="1.2"/><circle cx="19" cy="12" r="1.2"/>',
|
||||
x: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
|
||||
scroll: '<rect x="6" y="2" width="12" height="9" rx="1.5"/><rect x="6" y="13" width="12" height="9" rx="1.5"/>',
|
||||
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"/>',
|
||||
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>`
|
||||
@@ -298,8 +302,9 @@ async function viewManga(id, langOverride) {
|
||||
if (cleanup) { cleanup(); cleanup = null }
|
||||
app.innerHTML = '<div class="loading">Cargando…</div>'
|
||||
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 = `<a class="btn primary" href="${readUrl(id, first.id)}">${icon('book')} Empezar a leer</a>`
|
||||
|
||||
const menuItems = [
|
||||
isLc ? '' : `<a href="${altSearch}">${icon('search')} Buscar en LeerCapitulo</a>`,
|
||||
progress || reads.length ? `<button id="drop" class="danger">${icon('stopRead')} Dejar de leer…</button>` : '',
|
||||
].join('')
|
||||
|
||||
app.innerHTML = `
|
||||
<div class="detail">
|
||||
<img class="cover" src="${esc(manga.cover || '/icon.svg')}" alt="">
|
||||
<div>
|
||||
<div class="detail-body">
|
||||
<h1>${esc(manga.title)}</h1>
|
||||
<div class="muted">${esc(manga.authors.join(', '))}${manga.year ? ` · ${manga.year}` : ''}${manga.status ? ` · ${esc(manga.status)}` : ''}</div>
|
||||
<div class="tags">${manga.tags.map(t => (t.id ? `<a class="tag" href="${tagHref(manga.source, t)}">${esc(t.name)}</a>` : `<span class="tag">${esc(t.name ?? t)}</span>`)).join('')}</div>
|
||||
<p class="desc">${esc(manga.description)}</p>
|
||||
<div class="row">${mainBtn}
|
||||
<div class="actions">${mainBtn}
|
||||
<button class="btn" id="follow"></button>
|
||||
${progress || reads.length ? `<button class="btn danger" id="drop">${icon('stopRead')} Dejar de leer</button>` : ''}
|
||||
${isLc ? '' : `<select id="lang">${langOpts.map(l => `<option value="${esc(l)}" ${l === lang ? 'selected' : ''}>${esc(LANGS[l] || l)}</option>`).join('')}</select>
|
||||
<a class="btn" href="${altSearch}">${icon('search')} Buscar en LeerCapitulo</a>`}
|
||||
${menuItems ? `<details class="menu"><summary class="btn" title="Más opciones" aria-label="Más opciones">${icon('dots')}</summary><div class="menu-list">${menuItems}</div></details>` : ''}
|
||||
</div>
|
||||
<div class="muted" style="font-size:.85rem;margin-top:6px">Fuente: ${esc(SOURCES[manga.source])}${download ? ` · Carpeta: mangas/${esc(download.folder)}` : ''}</div>
|
||||
<div class="facts muted"><span>Fuente: ${esc(SOURCES[manga.source])}</span>${download ? `<span>Carpeta: mangas/${esc(download.folder)}</span>` : ''}</div>
|
||||
${offline ? '<p class="error">Sin conexión con la fuente: mostrando solo los capítulos descargados.</p>' : ''}
|
||||
<div id="dl" class="dlbox"></div>
|
||||
<p class="error" id="dlerr" style="font-size:.85rem"></p>
|
||||
<div class="libpanel"><div class="psec" id="dl"></div><div class="psec" id="hd" hidden></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="list-head">
|
||||
<h2>Capítulos <span class="muted">(${chapters.length})</span>${newSet.size ? ` <span class="badge">${newSet.size} ${newSet.size === 1 ? 'nuevo' : 'nuevos'}</span>` : ''}</h2>
|
||||
<div class="row">
|
||||
${isLc ? '' : `<select id="lang" title="Idioma de los capítulos">${langOpts.map(l => `<option value="${esc(l)}" ${l === lang ? 'selected' : ''}>${esc(LANGS[l] || l)}</option>`).join('')}</select>`}
|
||||
${chapters.length > 1 ? `<button class="btn small" id="order">${icon('sort')} Invertir orden</button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<h2>Capítulos <span class="muted">(${chapters.length})</span>${newSet.size ? ` <span class="badge">${newSet.size} ${newSet.size === 1 ? 'nuevo' : 'nuevos'}</span>` : ''}
|
||||
${chapters.length > 1 ? `<button class="btn small" id="order" style="float:right">${icon('sort')} Invertir orden</button>` : ''}</h2>
|
||||
${chapters.length ? '<ul class="chapters"></ul>' : isLc ? '<p class="muted">Este manga no tiene capítulos.</p>' : `<p class="muted">No hay capítulos legibles en ${esc(LANGS[lang] || lang)} (MangaDex retira algunos por licencia).
|
||||
Prueba a <a class="tag" href="${altSearch}">buscarlo en LeerCapitulo</a>.
|
||||
${chapterLangs.length ? `Disponibles en: ${chapterLangs.map(l => `<a class="tag" href="#" data-lang="${esc(l)}">${esc(LANGS[l] || l)}</a>`).join(' ')}` : 'No hay capítulos en ningún idioma.'}</p>`}`
|
||||
// Botón de descarga con sondeo del progreso mientras está activa.
|
||||
${chapterLangs.length ? `Disponibles en: ${chapterLangs.map(l => `<a class="tag" href="#" data-lang="${esc(l)}">${esc(LANGS[l] || l)}</a>`).join(' ')}` : 'No hay capítulos en ningún idioma.'}</p>`}
|
||||
<div id="selbar" hidden></div>`
|
||||
|
||||
// --- Copia local (descarga) con sondeo del progreso mientras está activa.
|
||||
let dlTimer = null
|
||||
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 renderDl = d => {
|
||||
const box = $('#dl')
|
||||
if (!box) return
|
||||
const active = d && DL_ACTIVE.includes(d.status)
|
||||
const delBtn = d ? `<button class="btn small danger" id="dldel">${icon('trash')} Eliminar copia local${d.size ? ` (${fmtSize(d.size)})` : ''}</button>` : ''
|
||||
const bar = d && (active || d.done < d.total) ? `<div class="bar dlbar"><i style="width:${downloadPct(d).toFixed(1)}%"></i></div>` : ''
|
||||
let html
|
||||
if (!d || d.status === 'cancelled') html = `<div class="row"><button class="btn" id="dlgo">${icon('download')} Descargar${d ? ` (llevas ${d.done}/${d.total})` : ''}</button>${delBtn}</div>${bar}`
|
||||
else if (active) html = `<div class="row"><span>${downloadLabel(d)} · ${Math.floor(downloadPct(d))}%</span><button class="btn small" id="dlcancel">${icon('stop')} Cancelar</button>${delBtn}</div>${bar}<div class="muted" style="font-size:.8rem">${downloadDetail(d)}</div>`
|
||||
else html = `<div class="row"><span class="muted row" style="gap:4px">${icon('check')} ${downloadLabel(d)}</span><button class="btn small" id="dlgo" title="Descarga los capítulos que falten">${icon('refresh')} ${d.status === 'error' ? 'Reintentar' : 'Buscar nuevos'}</button>${delBtn}</div>${bar}`
|
||||
box.innerHTML = offline ? (d ? `<span class="muted">${downloadLabel(d)}</span>` : '') : html
|
||||
$('#dlerr').textContent = d?.status === 'error' ? d.error || '' : ''
|
||||
const partial = d && d.done < d.total
|
||||
const bar = d && (active || partial) ? `<div class="bar dlbar"><i style="width:${downloadPct(d).toFixed(1)}%"></i></div>` : ''
|
||||
const delBtn = d ? `<button class="btn small danger push" id="dldel">${icon('trash')} Eliminar copia${d.size ? ` · ${fmtSize(d.size)}` : ''}</button>` : ''
|
||||
let status = '', body = '', actions = ''
|
||||
if (!d) {
|
||||
body = '<div class="note muted">Guarda los capítulos en CBZ en el servidor para leerlos sin conexión (y poder reescalarlos a HD).</div>'
|
||||
actions = `<button class="btn small" id="dlgo">${icon('download')} Descargar manga</button>`
|
||||
} else if (active) {
|
||||
status = `${downloadLabel(d)} · ${Math.floor(downloadPct(d))}%`
|
||||
body = `${bar}<div class="note muted">${downloadDetail(d)}</div>`
|
||||
actions = `<button class="btn small" id="dlcancel">${icon('stop')} Cancelar</button>${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 ? `<div class="error note">${esc(d.error)}</div>` : '')
|
||||
actions = `<button class="btn small" id="dlgo" title="Descarga los capítulos que falten">${icon(d.status === 'cancelled' ? 'download' : 'refresh')} ${d.status === 'error' ? 'Reintentar' : d.status === 'cancelled' ? 'Continuar descarga' : 'Buscar capítulos nuevos'}</button>${delBtn}`
|
||||
}
|
||||
box.innerHTML = secHead('download', 'Copia local', status) + body + (offline ? '' : `<div class="psec-actions">${actions}</div>`)
|
||||
$('#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 = `<span>${ids.length ? estimate(ids) : 'Ningún capítulo marcado'}</span>
|
||||
<button class="btn small primary" id="selrun" ${ids.length ? '' : 'disabled'}>${icon('sparkles')} Reescalar</button>
|
||||
<button class="btn small" id="selexit" title="Salir de la selección">${icon('x')}</button>`
|
||||
$('#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) => `<option value="${c.id}" ${sel ? 'selected' : ''}>${esc(c.num ? `Cap. ${c.num}` : 'Oneshot')}${hdSet.has(c.id) ? ' (HD)' : ''}</option>`
|
||||
const from = cand[0] || dls[0], to = cand[Math.min(cand.length, 10) - 1] || dls.at(-1)
|
||||
box.innerHTML = `
|
||||
<div class="seg" role="tablist">
|
||||
<button data-hm="pending" class="${hdForm === 'pending' ? 'on' : ''}">Pendientes</button>
|
||||
<button data-hm="range" class="${hdForm === 'range' ? 'on' : ''}">Rango</button>
|
||||
<button data-hm="pick" class="${hdForm === 'pick' ? 'on' : ''}">Elegir</button>
|
||||
</div>
|
||||
${hdForm === 'range' ? `<div class="row range"><label>Desde <select id="hdfrom">${dls.map(c => opt(c, c === from)).join('')}</select></label>
|
||||
<label>hasta <select id="hdto">${dls.map(c => opt(c, c === to)).join('')}</select></label></div>` : ''}
|
||||
${hdForm === 'pick' ? '<div class="note muted">Marca los capítulos en la lista de abajo. Mayús + clic marca un tramo entero.</div>' : ''}
|
||||
${hdForm === 'pending' ? `<div class="note muted">Todos los capítulos descargados que aún no están en HD.</div>
|
||||
<label class="check"><input type="checkbox" id="hdautoform" ${hdInfo?.auto ? 'checked' : ''}> Reescalar también los que se descarguen en el futuro</label>` : ''}
|
||||
<div class="row"><b id="hdsum" class="sum"></b></div>
|
||||
<div class="psec-actions"><button class="btn small primary" id="hdrun">${icon('sparkles')} Reescalar</button><button class="btn small" id="hdclose">Cerrar</button></div>`
|
||||
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 = `<div class="bar dlbar"><i style="width:${pct.toFixed(1)}%"></i></div>
|
||||
<div class="note muted">${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)}</div>`
|
||||
} else if (!u.hd) {
|
||||
body = `<div class="note muted">Mejora la nitidez con IA (Real-ESRGAN ×2). Unos ${u.secPerPage} s por página; el original no se toca.</div>`
|
||||
}
|
||||
if (u.error) body += `<div class="error note">Falló algún capítulo: ${esc(u.error)}</div>`
|
||||
const canAdd = hdCandidates().length > 0
|
||||
const actions = [
|
||||
canAdd ? `<button class="btn small ${hdForm ? 'on' : ''}" id="hdopen" aria-expanded="${!!hdForm}">${icon('sparkles')} ${active ? 'Añadir capítulos…' : 'Reescalar…'}</button>` : '',
|
||||
active ? `<button class="btn small" id="hdcancel">${icon('stop')} Detener</button>` : '',
|
||||
u.hd ? `<button class="btn small danger push" id="hddel">${icon('trash')} Quitar HD</button>` : '',
|
||||
].join('')
|
||||
// El formulario se conserva entre sondeos: solo se repinta la parte de arriba.
|
||||
if (!$('#hdtop')) box.innerHTML = '<div id="hdtop" class="psec-grid"></div><div id="hdform" class="hdform" hidden></div>'
|
||||
$('#hdtop').innerHTML = secHead('sparkles', 'Reescalado HD', status) + body + (actions ? `<div class="psec-actions">${actions}</div>` : '')
|
||||
+ (u.auto ? '<div class="note muted">Los capítulos que se descarguen también se reescalan.</div>' : '')
|
||||
$('#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)) ? '<span class="badge">NUEVO</span> ' : '')
|
||||
+ (dlSet.has(c.id) ? `<span title="Descargado">${icon('download')}</span> ` : '')
|
||||
+ (hdSet.has(c.id) ? '<span class="hd" title="Reescalado a HD">HD</span> '
|
||||
: hdJobs[c.id] ? `<span class="hd pending" title="${hdJobs[c.id] === 'error' ? 'Falló el reescalado' : 'En cola para HD'}">HD${hdJobs[c.id] === 'error' ? '!' : '…'}</span> ` : '')
|
||||
+ (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 `<li><label class="pick ${ok ? '' : 'off'}" title="${ok ? '' : dlSet.has(c.id) ? 'Ya está en HD o en cola' : 'No está descargado'}">
|
||||
<input type="checkbox" data-i="${i}" data-cid="${c.id}" ${ok ? '' : 'disabled'} ${picked.has(c.id) ? 'checked' : ''}>
|
||||
<span class="t">${esc(chapLabel(c))}</span><span class="meta">${chapMeta(c, r, done)}</span></label></li>`
|
||||
}
|
||||
const cls = progress?.chapter_id === c.id ? 'current' : done ? 'read' : ''
|
||||
const meta = (newSet.has(String(c.num ?? c.id)) ? '<span class="badge">NUEVO</span> ' : '') + (dlSet.has(c.id) ? `<span title="Descargado">${icon('download')}</span> ` : '') + (r ? (done ? 'Leído' : `pág. ${r.page + 1}/${r.total_pages}`) : esc(c.group))
|
||||
return `<li><a class="${cls}" href="${readUrl(id, c.id, r && !done ? r.page : (done ? 0 : null))}"><span>${esc(chapLabel(c))}</span><span class="meta">${meta}</span></a></li>`
|
||||
return `<li><a class="${cls}" href="${readUrl(id, c.id, r && !done ? r.page : (done ? 0 : null))}"><span>${esc(chapLabel(c))}</span><span class="meta">${chapMeta(c, r, done)}</span></a></li>`
|
||||
}).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) {
|
||||
<a class="btn small" href="#/manga/${mangaId}" title="Volver a la ficha">${icon('back')}</a>
|
||||
<div class="ttl"><b>${esc(manga.title)}</b></div>
|
||||
<select id="chap" style="max-width:40vw">${chapOpts}</select>
|
||||
<button class="btn small" id="modequick"></button>
|
||||
<button class="btn small" id="ropts" title="Opciones de lectura">${icon('settings')}</button>
|
||||
</div>
|
||||
<div class="rpanel" hidden>
|
||||
<select id="mode" title="Modo de lectura (se recuerda para este manga)">${Object.entries(READ_MODES).map(([k, v]) => `<option value="${k}">${v}</option>`).join('')}</select>
|
||||
<select id="fit" title="Ajuste (se recuerda para este manga)"><option value="height">Ajustar alto</option><option value="width">Ajustar ancho</option></select>
|
||||
<select id="dir" title="Dirección (se recuerda para este manga)"><option value="ltr">Izq → Der</option><option value="rtl">Der → Izq</option></select>
|
||||
<label class="row muted" id="gapwrap" style="font-size:.85rem" title="Deja un espacio entre páginas en modo vertical (se recuerda para este manga)"><input type="checkbox" id="gap"> Separar páginas</label>
|
||||
<select id="filter" title="Filtro">${Object.entries(FILTERS).map(([k, [label]]) => `<option value="${k}">${label}</option>`).join('')}</select>
|
||||
<label class="row muted" style="font-size:.85rem" title="Brillo">☀ <input type="range" id="bright" min="40" max="120" step="5" style="width:90px"></label>
|
||||
${manga.source === 'lc' ? '' : '<label class="row muted" style="font-size:.85rem"><input type="checkbox" id="saver"> Ahorro datos</label>'}
|
||||
<p class="muted">Modo, ajuste y dirección se recuerdan para este manga. Zoom: pellizca o toca dos veces en el centro (Ctrl+rueda o + / − en PC).</p>
|
||||
${manga.source === 'lc' || chap.local ? '' : '<label class="row muted" style="font-size:.85rem"><input type="checkbox" id="saver"> Ahorro datos</label>'}
|
||||
${chap.hdAvailable ? '<label class="row muted" style="font-size:.85rem" title="Usar la versión reescalada de este capítulo"><input type="checkbox" id="hd"> Versión HD</label>'
|
||||
: chap.local && info.upscale?.available ? `<button class="btn small" id="hdone" title="Reescala este capítulo con IA en segundo plano (unos minutos)">${icon('sparkles')} Reescalar a HD</button>` : ''}
|
||||
<p class="muted">Modo, ajuste, dirección y separación se recuerdan para este manga. ${icon('scroll')} en la barra de arriba cambia entre páginas y vertical. Zoom: pellizca o toca dos veces en el centro (Ctrl+rueda o + / − en PC).</p>
|
||||
</div>
|
||||
<div id="pages"></div>
|
||||
<div class="rbar bottom">
|
||||
@@ -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 = `<div class="chapter-end"><p class="muted">Fin del capítulo ${esc(chapter.num ?? '')}</p>
|
||||
${next ? `<a class="btn primary" href="${readUrl(mangaId, next.id)}">${icon('next')} Siguiente: ${esc(chapLabel(next))}</a>` : '<p>No hay más capítulos disponibles.</p>'}
|
||||
<a class="btn" href="#/manga/${mangaId}">${icon('back')} Volver a la ficha</a></div>`
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
+19
-9
@@ -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()) {
|
||||
|
||||
+228
@@ -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 <carpeta>/.hd/<mismo nombre>.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 }
|
||||
}
|
||||
@@ -24,6 +24,12 @@ services:
|
||||
- ./data:/app/data
|
||||
# Mangas descargados: <Título>/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.
|
||||
|
||||
Reference in New Issue
Block a user