Portadas HD de lo seguido o leído; sección Colas con descargas, reescalado y biblioteca en tarjetas
- Portadas: se reescalan (×4/×2) solo las de mangas en historial o seguidos; caché en data/covers/. - Colas (antes Descargas): cola de descargas y de reescalado HD con progreso, tiempo restante, reintentar/detener, recortes y portadas pendientes; biblioteca descargada en tarjetas verticales. - Refresco de Colas sin parpadeo: solo se actualiza lo que cambia. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -29,10 +29,17 @@ Lector web de manga autoalojado, con las dos fuentes de
|
|||||||
capítulos marca **HD**, el lector usa la versión HD (se puede desactivar en sus ajustes) y desde
|
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
|
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).
|
página ocupa ~2,5× más (WebP calidad 85).
|
||||||
|
- **Portadas HD**: las portadas de los mangas que sigues o tienes en el historial se reescalan solas en
|
||||||
|
segundo plano (×4 las pequeñas, ×2 el resto; ~4 s cada una) y desde entonces se sirven en HD. Las
|
||||||
|
de búsquedas y populares se quedan como están. Caché en `data/covers/` (se puede borrar: se regeneran).
|
||||||
- **Recortes**: en el lector, ✂ y arrastrar sobre un panel lo guarda en "Recortes" con su manga,
|
- **Recortes**: en el lector, ✂ y arrastrar sobre un panel lo guarda en "Recortes" con su manga,
|
||||||
capítulo y página ("Ir a la página" lleva directo allí). Se recorta del original y solo el recorte se
|
capítulo y página ("Ir a la página" lleva directo allí). Se recorta del original y solo el recorte se
|
||||||
reescala con Real-ESRGAN (×4, o ×2 si ya mide más de 1200 px; unos segundos). Se guardan en
|
reescala con Real-ESRGAN (×4, o ×2 si ya mide más de 1200 px; unos segundos). Se guardan en
|
||||||
`data/clips/` (`<id>.png` original y `<id>-hd.webp`), con nota opcional y descarga.
|
`data/clips/` (`<id>.png` original y `<id>-hd.webp`), con nota opcional y descarga.
|
||||||
|
- **Colas** (antes "Descargas"): descargas en curso y en espera, cola de reescalado HD por manga
|
||||||
|
(capítulo y página en curso, siguientes, tiempo restante, errores con "Reintentar", "Detener"),
|
||||||
|
recortes y portadas pendientes, y debajo la biblioteca descargada con su tamaño; todo en tarjetas
|
||||||
|
verticales con portada, como el historial.
|
||||||
- **Estadísticas**: rachas, capítulos y páginas por semana, calendario de actividad, géneros
|
- **Estadísticas**: rachas, capítulos y páginas por semana, calendario de actividad, géneros
|
||||||
favoritos y lo más leído.
|
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`).
|
- Las imágenes pasan por el servidor (`/api/img`), solo con URLs firmadas por él (clave en `data/secret`).
|
||||||
@@ -58,7 +65,7 @@ El primer usuario puede registrarse siempre; después el registro queda cerrado
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Código | este repositorio | se recrea con `git clone` |
|
| Código | este repositorio | se recrea con `git clone` |
|
||||||
| `.env` | raíz del proyecto (no está en git) | rutas, puerto, PUID/PGID, URL pública |
|
| `.env` | raíz del proyecto (no está en git) | rutas, puerto, PUID/PGID, URL pública |
|
||||||
| `data/` | raíz del proyecto (no está en git) | `manga.db` (+ `-wal`/`-shm`): usuarios, historial, estadísticas, seguimiento, ajustes; `secret`: clave del proxy de imágenes; `clips/`: recortes |
|
| `data/` | raíz del proyecto (no está en git) | `manga.db` (+ `-wal`/`-shm`): usuarios, historial, estadísticas, seguimiento, ajustes; `secret`: clave del proxy de imágenes; `clips/`: recortes; `covers/`: portadas HD (caché) |
|
||||||
| Mangas descargados | `MANGA_DIR_HOST` (aquí `/zdata/media/mangas`) | CBZ + portadas |
|
| Mangas descargados | `MANGA_DIR_HOST` (aquí `/zdata/media/mangas`) | CBZ + portadas |
|
||||||
|
|
||||||
Pasos en el sistema nuevo:
|
Pasos en el sistema nuevo:
|
||||||
|
|||||||
+3
-1
@@ -176,5 +176,7 @@ export function setupClips({ db, DATA_DIR, HttpError, json, readBody, MANGA_ID,
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
return { route, count, pump }
|
const queueInfo = userId => db.prepare(`SELECT SUM(status = 'queued') queued, SUM(status = 'running') running, SUM(status = 'error') errors
|
||||||
|
FROM clips WHERE user_id = ?`).get(userId)
|
||||||
|
return { route, count, pump, queueInfo }
|
||||||
}
|
}
|
||||||
|
|||||||
+109
@@ -0,0 +1,109 @@
|
|||||||
|
// Portadas en HD: cada portada que se sirve (de la fuente o la cover.jpg de una descarga) se encola para
|
||||||
|
// reescalarla con Real-ESRGAN (×4 si es pequeña, ×2 si no). Mientras tanto se sirve la original con
|
||||||
|
// caché corta; en cuanto existe la versión HD se sirve esa. Caché en <DATA_DIR>/covers/<hash>.webp.
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import os from 'node:os'
|
||||||
|
import path from 'node:path'
|
||||||
|
import crypto from 'node:crypto'
|
||||||
|
import { BIN, MODELS, MODEL, run } from './upscale.js'
|
||||||
|
|
||||||
|
const MAX_QUEUE = 300
|
||||||
|
const X4_MAX_SIDE = 400
|
||||||
|
const MAX_SIDE = 1600 // más grande ya no hace falta reescalarla
|
||||||
|
const WEBP_QUALITY = 88
|
||||||
|
|
||||||
|
// Ancho/alto de un JPEG, PNG o WebP sin decodificarlo (null si no se reconoce).
|
||||||
|
export function imageSize(b) {
|
||||||
|
if (b.length > 24 && b.readUInt32BE(0) === 0x89504e47) return { type: 'png', w: b.readUInt32BE(16), h: b.readUInt32BE(20) }
|
||||||
|
if (b.length > 30 && b.toString('latin1', 0, 4) === 'RIFF' && b.toString('latin1', 8, 12) === 'WEBP') {
|
||||||
|
const f = b.toString('latin1', 12, 16)
|
||||||
|
if (f === 'VP8 ') return { type: 'webp', w: b.readUInt16LE(26) & 0x3fff, h: b.readUInt16LE(28) & 0x3fff }
|
||||||
|
if (f === 'VP8L') { const n = b.readUInt32LE(21); return { type: 'webp', w: (n & 0x3fff) + 1, h: ((n >> 14) & 0x3fff) + 1 } }
|
||||||
|
if (f === 'VP8X') return { type: 'webp', w: b.readUIntLE(24, 3) + 1, h: b.readUIntLE(27, 3) + 1 }
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (b[0] === 0xff && b[1] === 0xd8) {
|
||||||
|
for (let i = 2; i + 9 < b.length;) {
|
||||||
|
if (b[i] !== 0xff) { i++; continue }
|
||||||
|
const marker = b[i + 1]
|
||||||
|
if (marker === 0xd8 || (marker >= 0xd0 && marker <= 0xd7) || marker === 0x01 || marker === 0xff) { i += marker === 0xff ? 1 : 2; continue }
|
||||||
|
const len = b.readUInt16BE(i + 2)
|
||||||
|
if (marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker)) return { type: 'jpg', w: b.readUInt16BE(i + 7), h: b.readUInt16BE(i + 5) }
|
||||||
|
i += 2 + len
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setupCovers({ DATA_DIR, upscale }) {
|
||||||
|
const DIR = path.join(DATA_DIR, 'covers')
|
||||||
|
fs.mkdirSync(DIR, { recursive: true })
|
||||||
|
const fileOf = key => path.join(DIR, `${crypto.createHash('sha256').update(key).digest('hex').slice(0, 32)}.webp`)
|
||||||
|
const queue = [] // { key, buf }
|
||||||
|
const pending = new Set()
|
||||||
|
const failed = new Set() // no reintentar hasta reiniciar
|
||||||
|
|
||||||
|
// Si ya hay versión HD, la envía y devuelve true.
|
||||||
|
function sendCached(res, key) {
|
||||||
|
if (!upscale.available()) return false
|
||||||
|
const f = fileOf(key)
|
||||||
|
if (!fs.existsSync(f)) return false
|
||||||
|
res.writeHead(200, { 'Content-Type': 'image/webp', 'Cache-Control': 'private, max-age=2592000, immutable' })
|
||||||
|
fs.createReadStream(f).pipe(res)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Cabecera de caché para la original mientras no hay HD (corta, para que el navegador pida luego la HD).
|
||||||
|
const pendingCache = () => (upscale.available() ? 'private, max-age=300' : 'private, max-age=604800, immutable')
|
||||||
|
|
||||||
|
function enqueue(key, buf) {
|
||||||
|
if (!upscale.available() || pending.has(key) || failed.has(key) || queue.length >= MAX_QUEUE || fs.existsSync(fileOf(key))) return
|
||||||
|
pending.add(key)
|
||||||
|
queue.push({ key, buf })
|
||||||
|
pump()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processOne({ key, buf }) {
|
||||||
|
const size = imageSize(buf)
|
||||||
|
if (!size) throw new Error('formato no reconocido')
|
||||||
|
const work = fs.mkdtempSync(path.join(os.tmpdir(), 'cover-'))
|
||||||
|
try {
|
||||||
|
const out = path.join(work, 'out.png')
|
||||||
|
const tmp = `${fileOf(key)}.tmp`
|
||||||
|
if (Math.max(size.w, size.h) > MAX_SIDE) {
|
||||||
|
// Ya es grande: solo se recomprime para no volver a intentarlo.
|
||||||
|
const inp = path.join(work, `in.${size.type}`)
|
||||||
|
fs.writeFileSync(inp, buf)
|
||||||
|
if (size.type === 'webp') fs.copyFileSync(inp, tmp)
|
||||||
|
else await run('cwebp', ['-quiet', '-q', String(WEBP_QUALITY), inp, '-o', tmp])
|
||||||
|
} else {
|
||||||
|
const inp = path.join(work, `in.${size.type}`)
|
||||||
|
fs.writeFileSync(inp, buf)
|
||||||
|
const scale = Math.max(size.w, size.h) <= X4_MAX_SIDE && fs.existsSync(path.join(MODELS, `${MODEL}-x4.bin`)) ? 4 : 2
|
||||||
|
await run(BIN, ['-i', inp, '-o', out, '-m', MODELS, '-n', MODEL, '-s', String(scale), '-f', 'png'])
|
||||||
|
await run('cwebp', ['-quiet', '-q', String(WEBP_QUALITY), '-m', '4', '-mt', out, '-o', tmp])
|
||||||
|
}
|
||||||
|
fs.renameSync(tmp, fileOf(key))
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(work, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let pumping = false
|
||||||
|
async function pump() {
|
||||||
|
if (pumping) return
|
||||||
|
pumping = true
|
||||||
|
try {
|
||||||
|
let n = 0
|
||||||
|
const t = Date.now()
|
||||||
|
while (queue.length) {
|
||||||
|
const job = queue.shift()
|
||||||
|
try { await processOne(job); n++ } catch (e) { failed.add(job.key); console.warn('Portada HD:', e.message) } finally { pending.delete(job.key) }
|
||||||
|
}
|
||||||
|
if (n) console.log(`Portadas HD: ${n} en ${Math.round((Date.now() - t) / 1000)} s`)
|
||||||
|
} finally {
|
||||||
|
pumping = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { sendCached, enqueue, pendingCache, pendingCount: () => pending.size }
|
||||||
|
}
|
||||||
+125
-34
@@ -37,6 +37,7 @@ const ICONS = {
|
|||||||
pages: '<rect x="2" y="5" width="9" height="14" rx="1.5"/><rect x="13" y="5" width="9" height="14" rx="1.5"/>',
|
pages: '<rect x="2" y="5" width="9" height="14" rx="1.5"/><rect x="13" y="5" width="9" height="14" rx="1.5"/>',
|
||||||
scissors: '<circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M20 4 8.1 15.9M14.5 14.5 20 20M8.1 8.1 12 12"/>',
|
scissors: '<circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M20 4 8.1 15.9M14.5 14.5 20 20M8.1 8.1 12 12"/>',
|
||||||
image: '<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-5-5L5 21"/>',
|
image: '<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-5-5L5 21"/>',
|
||||||
|
queue: '<path d="M3 6h13M3 12h13M3 18h9"/><path d="m17 15 3 3-3 3"/><path d="M20 18h-6"/>',
|
||||||
sparkles: '<path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8z"/><path d="M19 15l.8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8z"/>',
|
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>`
|
const icon = name => `<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${ICONS[name]}</svg>`
|
||||||
@@ -56,6 +57,8 @@ const FILTERS = {
|
|||||||
vivid: ['Colores vivos', 'saturate(1.45) contrast(1.05)'],
|
vivid: ['Colores vivos', 'saturate(1.45) contrast(1.05)'],
|
||||||
}
|
}
|
||||||
const DL_ACTIVE = ['queued', 'downloading']
|
const DL_ACTIVE = ['queued', 'downloading']
|
||||||
|
const plural = (n, w) => `${n} ${w}${n === 1 ? '' : 's'}`
|
||||||
|
const fmtDur = sec => sec < 90 ? '≈ 1 min' : sec < 3600 ? `≈ ${Math.round(sec / 60)} min` : `≈ ${Math.floor(sec / 3600)} h ${Math.round((sec % 3600) / 60)} min`
|
||||||
const fmtSize = b => (b >= 1e9 ? `${(b / 1e9).toFixed(1)} GB` : b >= 1e6 ? `${Math.round(b / 1e6)} MB` : `${Math.round((b || 0) / 1e3)} KB`)
|
const fmtSize = b => (b >= 1e9 ? `${(b / 1e9).toFixed(1)} GB` : b >= 1e6 ? `${Math.round(b / 1e6)} MB` : `${Math.round((b || 0) / 1e3)} KB`)
|
||||||
// Progreso global contando la fracción del capítulo en curso.
|
// Progreso global contando la fracción del capítulo en curso.
|
||||||
function downloadPct(d) {
|
function downloadPct(d) {
|
||||||
@@ -140,7 +143,8 @@ async function router() {
|
|||||||
if (parts[0] === 'manga') return await viewManga(parts[1])
|
if (parts[0] === 'manga') return await viewManga(parts[1])
|
||||||
if (parts[0] === 'read') return await viewReader(parts[1], parts[2], Number(params.get('page') || 0) - 1)
|
if (parts[0] === 'read') return await viewReader(parts[1], parts[2], Number(params.get('page') || 0) - 1)
|
||||||
if (parts[0] === 'history') return await viewHistory()
|
if (parts[0] === 'history') return await viewHistory()
|
||||||
if (parts[0] === 'downloads') return await viewDownloads()
|
if (parts[0] === 'downloads') return (location.hash = '#/queues') // antiguo nombre
|
||||||
|
if (parts[0] === 'queues') return await viewQueues()
|
||||||
if (parts[0] === 'stats') return await viewStats()
|
if (parts[0] === 'stats') return await viewStats()
|
||||||
if (parts[0] === 'clips') return await viewClips(params)
|
if (parts[0] === 'clips') return await viewClips(params)
|
||||||
if (parts[0] === 'settings') return viewSettings()
|
if (parts[0] === 'settings') return viewSettings()
|
||||||
@@ -359,8 +363,6 @@ async function viewManga(id, langOverride) {
|
|||||||
<div id="selbar" hidden></div>`
|
<div id="selbar" hidden></div>`
|
||||||
|
|
||||||
const secHead = (ic, title, status) => `<div class="psec-head"><div class="psec-title">${icon(ic)} ${title}</div>${status ? `<div class="psec-status muted">${status}</div>` : ''}</div>`
|
const secHead = (ic, title, status) => `<div class="psec-head"><div class="psec-title">${icon(ic)} ${title}</div>${status ? `<div class="psec-status muted">${status}</div>` : ''}</div>`
|
||||||
const plural = (n, w) => `${n} ${w}${n === 1 ? '' : 's'}`
|
|
||||||
const fmtDur = sec => sec < 90 ? '≈ 1 min' : sec < 3600 ? `≈ ${Math.round(sec / 60)} min` : `≈ ${Math.floor(sec / 3600)} h ${Math.round((sec % 3600) / 60)} min`
|
|
||||||
|
|
||||||
// --- Selector de capítulos compartido por "Copia local" (kind 'dl') y "Reescalado HD" (kind 'hd'):
|
// --- Selector de capítulos compartido por "Copia local" (kind 'dl') y "Reescalado HD" (kind 'hd'):
|
||||||
// todo lo pendiente (solo HD), un rango o una selección hecha en la lista de abajo.
|
// todo lo pendiente (solo HD), un rango o una selección hecha en la lista de abajo.
|
||||||
@@ -1356,39 +1358,128 @@ async function viewHistory() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Descargas ----------
|
// ---------- Descargas ----------
|
||||||
async function viewDownloads() {
|
// Actualiza `from` para que quede igual que `to` tocando solo lo que difiere. Los hijos con data-key se
|
||||||
if (cleanup) { cleanup(); cleanup = null }
|
// emparejan por clave (una tarjeta que cambia de posición conserva su <img> y no recarga la portada).
|
||||||
const list = await api('/api/downloads')
|
function morph(from, to, childrenOnly = false) {
|
||||||
if (location.hash !== '#/downloads') return
|
if (!childrenOnly) {
|
||||||
const total = list.reduce((n, d) => n + (d.size || 0), 0)
|
if (from.nodeType !== to.nodeType || from.nodeName !== to.nodeName) { from.replaceWith(to); return to }
|
||||||
app.innerHTML = `<h1>Descargas</h1>
|
if (from.nodeType !== 1) { if (from.nodeValue !== to.nodeValue) from.nodeValue = to.nodeValue; return from }
|
||||||
<p class="muted">Se guardan en <code>/zdata/media/mangas</code> como CBZ (compatibles con Kavita, Komga, Mihon…). Los capítulos descargados se leen desde el disco.${list.length ? ` Total: <b>${fmtSize(total)}</b>.` : ''}</p>
|
for (const { name } of [...from.attributes]) if (!to.hasAttribute(name) && name !== 'data-failed') from.removeAttribute(name)
|
||||||
${list.length ? '' : '<p class="muted">Aún no has descargado nada. Usa “⬇ Descargar” en la ficha de un manga.</p>'}
|
for (const { name, value } of [...to.attributes]) {
|
||||||
<div class="history">${list.map(d => {
|
if (name === 'src' && from.dataset.failed) continue // portada que no cargó: se queda el icono
|
||||||
const active = DL_ACTIVE.includes(d.status)
|
if (from.getAttribute(name) !== value) from.setAttribute(name, value)
|
||||||
return `<div class="cont">
|
|
||||||
<a class="info" href="#/manga/${d.manga_id}"><b>${esc(d.title)}</b>
|
|
||||||
<div class="muted">${esc(downloadLabel(d))}${active ? ` · ${Math.floor(downloadPct(d))}%` : ''} · ${fmtSize(d.size)} · mangas/${esc(d.folder)}</div>
|
|
||||||
${active ? `<div class="muted" style="font-size:.8rem">${downloadDetail(d)}</div>` : ''}
|
|
||||||
${d.status === 'error' ? `<div class="error" style="font-size:.8rem">${esc(d.error || '')}</div>` : ''}
|
|
||||||
<div class="bar dlbar"><i style="width:${downloadPct(d).toFixed(1)}%"></i></div></a>
|
|
||||||
<div class="row" style="flex:none">
|
|
||||||
${active ? `<button class="btn small" data-cancel="${esc(d.manga_id)}">${icon('stop')} Cancelar</button>` : ''}
|
|
||||||
<button class="btn small danger" data-del="${esc(d.manga_id)}" data-title="${esc(d.title)}" data-size="${d.size || 0}">${icon('trash')} Eliminar</button>
|
|
||||||
</div></div>`
|
|
||||||
}).join('')}</div>`
|
|
||||||
app.querySelectorAll('[data-cancel]').forEach(b => b.addEventListener('click', async () => { await api(`/api/download/${b.dataset.cancel}/cancel`, { method: 'POST' }); viewDownloads() }))
|
|
||||||
app.querySelectorAll('[data-del]').forEach(b => b.addEventListener('click', async () => {
|
|
||||||
if (!confirm(`¿Eliminar la copia local de «${b.dataset.title}» (${fmtSize(Number(b.dataset.size))})?\nTu historial de lectura no se toca.`)) return
|
|
||||||
await api(`/api/download/${b.dataset.del}`, { method: 'DELETE' })
|
|
||||||
toast('Copia local eliminada')
|
|
||||||
viewDownloads()
|
|
||||||
}))
|
|
||||||
if (list.some(d => DL_ACTIVE.includes(d.status))) {
|
|
||||||
const t = setTimeout(() => location.hash === '#/downloads' && viewDownloads(), 2000)
|
|
||||||
cleanup = () => clearTimeout(t)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const byKey = new Map([...from.children].filter(n => n.dataset.key).map(n => [n.dataset.key, n]))
|
||||||
|
const kids = [...to.childNodes]
|
||||||
|
kids.forEach((nk, i) => {
|
||||||
|
const key = nk.nodeType === 1 ? nk.dataset.key : null
|
||||||
|
const at = from.childNodes[i]
|
||||||
|
let old = key ? byKey.get(key) : at && !(at.nodeType === 1 && at.dataset.key) ? at : null
|
||||||
|
if (key) byKey.delete(key)
|
||||||
|
const node = old ? morph(old, nk) : nk
|
||||||
|
if (from.childNodes[i] !== node) from.insertBefore(node, from.childNodes[i] || null)
|
||||||
|
})
|
||||||
|
while (from.childNodes.length > kids.length) from.lastChild.remove()
|
||||||
|
return from
|
||||||
|
}
|
||||||
|
|
||||||
|
async function viewQueues() {
|
||||||
|
if (cleanup) { cleanup(); cleanup = null }
|
||||||
|
const here = () => location.hash === '#/queues'
|
||||||
|
if (!$('#qwrap')) app.innerHTML = '<div class="loading">Cargando…</div>'
|
||||||
|
const [q, list] = await Promise.all([api('/api/queues'), api('/api/downloads')])
|
||||||
|
if (!here()) return
|
||||||
|
const up = q.upscale
|
||||||
|
const library = list.filter(d => !DL_ACTIVE.includes(d.status))
|
||||||
|
const hdQueued = up.groups.reduce((n, g) => n + g.queued, 0)
|
||||||
|
const total = list.reduce((n, d) => n + (d.size || 0), 0)
|
||||||
|
const extra = [
|
||||||
|
q.clips.queued + q.clips.running ? `<a href="#/clips">${icon('scissors')} ${plural(q.clips.queued + q.clips.running, 'recorte')} reescalándose</a>` : '',
|
||||||
|
q.clips.errors ? `<a href="#/clips" class="error">${plural(q.clips.errors, 'recorte')} con error</a>` : '',
|
||||||
|
q.covers ? `<span>${icon('image')} ${plural(q.covers, 'portada')} en cola</span>` : '',
|
||||||
|
].filter(Boolean)
|
||||||
|
// Tarjeta vertical (como en Historial): portada, barra de progreso, título, detalles y acciones.
|
||||||
|
const qcard = ({ key, id, cover, badge, pct, title, lines, actions = '', del = '' }) => `
|
||||||
|
<div class="card hcard qcard" data-key="${esc(key)}">
|
||||||
|
<a href="#/manga/${id}"><img class="cover" loading="lazy" src="${esc(cover || '/icon.svg')}" alt="" onerror="this.onerror=null;this.dataset.failed=1;this.src='/icon.svg'"></a>
|
||||||
|
${badge ? `<span class="qpos corner">${badge}</span>` : ''}${del}
|
||||||
|
<div class="bar" ${pct == null ? 'style="visibility:hidden"' : ''}><i style="width:${(pct || 0).toFixed(1)}%"></i></div>
|
||||||
|
<a class="t" href="#/manga/${id}">${esc(title)}</a>
|
||||||
|
<div class="sub">${lines}</div>
|
||||||
|
${actions ? `<div class="qact">${actions}</div>` : ''}
|
||||||
|
</div>`
|
||||||
|
const html = `<h1>Colas</h1>
|
||||||
|
<section class="qsec">
|
||||||
|
<h2>${icon('download')} Descargas ${q.downloads.length ? `<span class="badge">${q.downloads.length}</span>` : ''}</h2>
|
||||||
|
${q.downloads.length ? `<div class="grid qgrid">${q.downloads.map((d, i) => qcard({
|
||||||
|
key: `dl:${d.manga_id}`, id: d.manga_id, cover: d.cover, title: d.title, pct: downloadPct(d),
|
||||||
|
badge: d.status === 'downloading' ? icon('download') : i + 1,
|
||||||
|
lines: `<div>${esc(downloadLabel(d))} · ${Math.floor(downloadPct(d))}%</div>
|
||||||
|
${d.status === 'downloading' ? `<div>${downloadDetail(d)}</div>` : ''}${d.scope === 'some' ? '<div>Capítulos elegidos</div>' : ''}`,
|
||||||
|
actions: `<button class="btn small" data-cancel="${esc(d.manga_id)}">${icon('stop')} Cancelar</button>`,
|
||||||
|
})).join('')}</div>` : '<p class="muted">No hay descargas en curso. Se añaden desde la ficha de un manga.</p>'}
|
||||||
|
</section>
|
||||||
|
<section class="qsec">
|
||||||
|
<h2>${icon('sparkles')} Reescalado HD ${hdQueued ? `<span class="badge">${hdQueued}</span>` : ''}</h2>
|
||||||
|
${up.available ? '' : '<p class="muted">El reescalado no está disponible en este servidor (falta Real-ESRGAN o la GPU).</p>'}
|
||||||
|
${up.groups.length ? `${hdQueued ? `<p class="muted small">Un capítulo cada vez, en orden de llegada · ${up.secPerPage} s por página · quedan ${fmtDur(up.eta)}</p>` : ''}
|
||||||
|
<div class="grid qgrid">${up.groups.map((g, i) => {
|
||||||
|
const cur = up.current?.mangaId === g.mangaId ? up.current : null
|
||||||
|
const rest = g.queued - (cur ? 1 : 0)
|
||||||
|
const next = g.next.length ? ` (sig.: ${g.next.map(n => esc(n ?? '?')).join(', ')}${rest > g.next.length ? '…' : ''})` : ''
|
||||||
|
return qcard({
|
||||||
|
key: `hd:${g.mangaId}`, id: g.mangaId, cover: g.cover, title: g.title,
|
||||||
|
pct: cur?.total ? (cur.pages / cur.total) * 100 : null,
|
||||||
|
badge: cur ? icon('sparkles') : g.queued ? i + 1 : '!',
|
||||||
|
lines: `${cur ? `<div>Cap. ${esc(cur.num ?? '?')} · pág. ${cur.pages}/${cur.total}</div>` : ''}
|
||||||
|
${g.queued ? `<div>${rest ? `${cur ? `${rest} más` : plural(rest, 'capítulo')} en cola${next}` : 'Último de la cola'}</div><div>${fmtDur(g.eta)}</div>` : ''}
|
||||||
|
${g.errors.length ? `<div class="error" title="${esc(g.errors.map(e => `Cap. ${e.num ?? '?'}: ${e.error || 'error'}`).join('\n'))}">${g.errors.length === 1 ? `Cap. ${esc(g.errors[0].num ?? '?')}: ${esc(g.errors[0].error || 'error')}` : `${g.errors.length} con error (caps. ${g.errors.slice(0, 6).map(e => esc(e.num ?? '?')).join(', ')}${g.errors.length > 6 ? '…' : ''})`}</div>` : ''}`,
|
||||||
|
actions: `${g.errors.length ? `<button class="btn small" data-retry="${esc(g.mangaId)}" data-ids="${esc(g.errors.map(e => e.chapterId).join(' '))}">${icon('refresh')} Reintentar</button>` : ''}
|
||||||
|
${g.queued ? `<button class="btn small" data-hdstop="${esc(g.mangaId)}" title="Vacía su cola y desactiva el reescalado automático">${icon('stop')} Detener</button>` : ''}`,
|
||||||
|
})
|
||||||
|
}).join('')}</div>` : up.available ? '<p class="muted">No hay capítulos esperando. Se reescalan desde la ficha de un manga descargado o desde el lector.</p>' : ''}
|
||||||
|
${extra.length ? `<div class="facts muted">${extra.join('')}</div>` : ''}
|
||||||
|
</section>
|
||||||
|
<section class="qsec">
|
||||||
|
<h2>${icon('book')} Biblioteca descargada</h2>
|
||||||
|
<p class="muted small">CBZ en <code>/zdata/media/mangas</code> (compatibles con Kavita, Komga, Mihon…); se leen desde el disco.${list.length ? ` Total: <b>${fmtSize(total)}</b>.` : ''}</p>
|
||||||
|
${library.length ? `<div class="grid">${library.map(d => qcard({
|
||||||
|
key: `lib:${d.manga_id}`, id: d.manga_id, cover: d.cover, title: d.title,
|
||||||
|
pct: d.total ? Math.min(100, (d.done / d.total) * 100) : null,
|
||||||
|
lines: `<div>${esc(downloadLabel(d))}</div><div>${fmtSize(d.size)}</div>
|
||||||
|
${d.status === 'error' ? `<div class="error" title="${esc(d.error || '')}">Con errores</div>` : ''}`,
|
||||||
|
del: `<button class="hdel" data-del="${esc(d.manga_id)}" data-title="${esc(d.title)}" data-size="${d.size || 0}" title="Eliminar la copia local (mangas/${esc(d.folder)})" aria-label="Eliminar la copia local">${icon('trash')}</button>`,
|
||||||
|
})).join('')}</div>` : `<p class="muted">${list.length ? 'Todo lo descargado está aún en la cola.' : 'Aún no has descargado nada. Usa “Descargar” en la ficha de un manga.'}</p>`}
|
||||||
|
</section>`
|
||||||
|
// Refresco sin parpadeo: se actualiza solo lo que cambia (las portadas no se vuelven a cargar).
|
||||||
|
let wrap = $('#qwrap')
|
||||||
|
if (!wrap) {
|
||||||
|
app.innerHTML = `<div id="qwrap">${html}</div>`
|
||||||
|
wrap = $('#qwrap')
|
||||||
|
// Delegación: los botones se crean y actualizan en cada refresco.
|
||||||
|
const act = async (fn, msg) => { try { await fn(); if (msg) toast(msg) } catch (err) { toast(err.message) } viewQueues() }
|
||||||
|
wrap.addEventListener('click', e => {
|
||||||
|
const b = e.target.closest('button')
|
||||||
|
if (!b) return
|
||||||
|
if (b.dataset.cancel) act(() => api(`/api/download/${b.dataset.cancel}/cancel`, { method: 'POST' }), 'Descarga cancelada')
|
||||||
|
else if (b.dataset.hdstop) act(() => api(`/api/upscale/${b.dataset.hdstop}/cancel`, { method: 'POST' }), 'Reescalado detenido')
|
||||||
|
else if (b.dataset.retry) act(() => api(`/api/upscale/${b.dataset.retry}`, { method: 'POST', body: { chapterIds: b.dataset.ids.split(' ') } }), 'Vuelto a la cola')
|
||||||
|
else if (b.dataset.del) {
|
||||||
|
if (!confirm(`¿Eliminar la copia local de «${b.dataset.title}» (${fmtSize(Number(b.dataset.size))})?\nTu historial de lectura no se toca.`)) return
|
||||||
|
act(() => api(`/api/download/${b.dataset.del}`, { method: 'DELETE' }), 'Copia local eliminada')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const tpl = document.createElement('div')
|
||||||
|
tpl.innerHTML = html
|
||||||
|
morph(wrap, tpl, true)
|
||||||
|
}
|
||||||
|
// Refresco: rápido mientras algo avanza, lento si no (por si llega algo nuevo).
|
||||||
|
const busy = q.downloads.length || up.current || up.groups.some(g => g.queued) || q.clips.queued + q.clips.running || q.covers
|
||||||
|
const t = setTimeout(() => here() && viewQueues(), busy ? 2000 : 15000)
|
||||||
|
cleanup = () => clearTimeout(t)
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- Estadísticas ----------
|
// ---------- Estadísticas ----------
|
||||||
const fmtNum = n => n.toLocaleString('es')
|
const fmtNum = n => n.toLocaleString('es')
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
<nav>
|
<nav>
|
||||||
<a href="#/" data-icon="home">Inicio</a>
|
<a href="#/" data-icon="home">Inicio</a>
|
||||||
<a href="#/history" data-icon="history">Historial</a>
|
<a href="#/history" data-icon="history">Historial</a>
|
||||||
<a href="#/downloads" data-icon="download">Descargas</a>
|
<a href="#/queues" data-icon="queue">Colas</a>
|
||||||
<a href="#/clips" data-icon="scissors">Recortes</a>
|
<a href="#/clips" data-icon="scissors">Recortes</a>
|
||||||
<a href="#/stats" data-icon="chart">Estadísticas</a>
|
<a href="#/stats" data-icon="chart">Estadísticas</a>
|
||||||
<a href="#/settings" data-icon="settings">Ajustes</a>
|
<a href="#/settings" data-icon="settings">Ajustes</a>
|
||||||
|
|||||||
@@ -314,3 +314,17 @@ body.noscroll { overflow: hidden; }
|
|||||||
.hcard:hover .hdel, .hdel:focus-visible { opacity: 1; }
|
.hcard:hover .hdel, .hdel:focus-visible { opacity: 1; }
|
||||||
.hdel:hover { color: var(--danger); }
|
.hdel:hover { color: var(--danger); }
|
||||||
@media (hover: none) { .hdel { opacity: 1; } }
|
@media (hover: none) { .hdel { opacity: 1; } }
|
||||||
|
|
||||||
|
/* Colas: tarjetas verticales como en Historial */
|
||||||
|
.qsec { margin-bottom: 26px; }
|
||||||
|
.qsec h2 { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.qcard { display: flex; flex-direction: column; }
|
||||||
|
.qcard > a { color: inherit; }
|
||||||
|
.qcard .sub { display: grid; gap: 1px; padding-bottom: 8px; }
|
||||||
|
.qcard .sub .error { font-size: .78rem; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
|
||||||
|
.qact { display: flex; flex-wrap: wrap; gap: 6px; padding: 0 10px 10px; margin-top: auto; }
|
||||||
|
.qact .btn { flex: 1; padding: 6px 8px; font-size: .8rem; }
|
||||||
|
.qpos { width: 26px; height: 26px; border-radius: 50%; display: grid; place-items: center; background: rgba(20, 18, 26, .85); color: var(--text); font-size: .8rem; font-weight: 700; }
|
||||||
|
.qpos .ic { color: var(--accent); width: 15px; height: 15px; }
|
||||||
|
.small { font-size: .82rem; }
|
||||||
|
@media (max-width: 640px) { .qgrid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); } }
|
||||||
|
|||||||
+63
-11
@@ -9,6 +9,7 @@ import { writeCbz, readCbzIndex, sendCbzEntry, imageType } from './cbz.js'
|
|||||||
import { setupFeatures } from './features.js'
|
import { setupFeatures } from './features.js'
|
||||||
import { setupUpscale } from './upscale.js'
|
import { setupUpscale } from './upscale.js'
|
||||||
import { setupClips } from './clips.js'
|
import { setupClips } from './clips.js'
|
||||||
|
import { setupCovers } from './covers.js'
|
||||||
|
|
||||||
const PORT = Number(process.env.PORT || 3352)
|
const PORT = Number(process.env.PORT || 3352)
|
||||||
const DATA_DIR = process.env.DATA_DIR || '/app/data'
|
const DATA_DIR = process.env.DATA_DIR || '/app/data'
|
||||||
@@ -160,6 +161,9 @@ async function cached(key, ttl, fn) {
|
|||||||
// Las imágenes pasan por /api/img firmadas con HMAC: el proxy solo descarga URLs generadas por este servidor.
|
// Las imágenes pasan por /api/img firmadas con HMAC: el proxy solo descarga URLs generadas por este servidor.
|
||||||
const sign = u => crypto.createHmac('sha256', SECRET).update(u).digest('base64url').slice(0, 22)
|
const sign = u => crypto.createHmac('sha256', SECRET).update(u).digest('base64url').slice(0, 22)
|
||||||
const imgUrl = u => `/api/img?u=${encodeURIComponent(u)}&s=${sign(u)}`
|
const imgUrl = u => `/api/img?u=${encodeURIComponent(u)}&s=${sign(u)}`
|
||||||
|
// Portadas: &m=<manga> deja al proxy servir (y generar) la versión reescalada si el manga está en el
|
||||||
|
// historial o en seguidos del usuario; las demás (búsquedas) se sirven tal cual.
|
||||||
|
const coverUrl = (u, mangaId) => `${imgUrl(u)}&m=${encodeURIComponent(mangaId)}`
|
||||||
|
|
||||||
async function md(pathname, params = {}, ttl = 5 * 60e3) {
|
async function md(pathname, params = {}, ttl = 5 * 60e3) {
|
||||||
const url = new URL(MD_API + pathname)
|
const url = new URL(MD_API + pathname)
|
||||||
@@ -187,7 +191,7 @@ function mangaSummary(m, lang) {
|
|||||||
if (alt) title = pick(alt, [lang, 'es-la', 'es'])
|
if (alt) title = pick(alt, [lang, 'es-la', 'es'])
|
||||||
const coverRel = m.relationships?.find(r => r.type === 'cover_art')
|
const coverRel = m.relationships?.find(r => r.type === 'cover_art')
|
||||||
const cover = coverRel?.attributes?.fileName
|
const cover = coverRel?.attributes?.fileName
|
||||||
? imgUrl(`https://uploads.mangadex.org/covers/${m.id}/${coverRel.attributes.fileName}.256.jpg`)
|
? coverUrl(`https://uploads.mangadex.org/covers/${m.id}/${coverRel.attributes.fileName}.512.jpg`, m.id)
|
||||||
: null
|
: null
|
||||||
return {
|
return {
|
||||||
id: m.id,
|
id: m.id,
|
||||||
@@ -310,7 +314,7 @@ async function lc(pathname, ttl = 10 * 60e3, asJson = false) {
|
|||||||
return asJson ? res.json() : res.text()
|
return asJson ? res.json() : res.text()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const lcCover = src => (src ? imgUrl(new URL(src, LC_BASE).toString()) : null)
|
const lcCover = (src, id) => (src ? coverUrl(new URL(src, LC_BASE).toString(), id) : null)
|
||||||
const lcId = uri => {
|
const lcId = uri => {
|
||||||
const m = /\/manga\/([a-z0-9]+)\/([a-z0-9-]+)\//.exec(uri || '')
|
const m = /\/manga\/([a-z0-9]+)\/([a-z0-9-]+)\//.exec(uri || '')
|
||||||
return m ? `lc~${m[1]}~${m[2]}` : null
|
return m ? `lc~${m[1]}~${m[2]}` : null
|
||||||
@@ -323,7 +327,7 @@ function lcParseCards(html) {
|
|||||||
const id = lcId(m[1])
|
const id = lcId(m[1])
|
||||||
if (!id || seen.has(id)) continue
|
if (!id || seen.has(id)) continue
|
||||||
seen.add(id)
|
seen.add(id)
|
||||||
out.push({ id, source: 'lc', title: decode(m[3]), cover: lcCover(m[2]) })
|
out.push({ id, source: 'lc', title: decode(m[3]), cover: lcCover(m[2], id) })
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
@@ -348,7 +352,7 @@ async function lcSearch(q, mode, tag, page = 1) {
|
|||||||
}
|
}
|
||||||
if (q) {
|
if (q) {
|
||||||
const list = await lc(`/search/?q=${encodeURIComponent(q)}`, 10 * 60e3, true)
|
const list = await lc(`/search/?q=${encodeURIComponent(q)}`, 10 * 60e3, true)
|
||||||
return list.map(e => ({ id: lcId(e.uri), source: 'lc', title: decode(e.name), cover: lcCover(e.cover_uri), sub: e.last_chapter_name }))
|
return list.map(e => ({ id: lcId(e.uri), source: 'lc', title: decode(e.name), cover: lcCover(e.cover_uri, lcId(e.uri)), sub: e.last_chapter_name }))
|
||||||
.filter(e => e.id)
|
.filter(e => e.id)
|
||||||
}
|
}
|
||||||
const html = await lc('/')
|
const html = await lc('/')
|
||||||
@@ -388,7 +392,7 @@ async function lcManga(id) {
|
|||||||
tags: [...html.matchAll(/class="badge [^"]*" href="\/manga\/\?(genre|theme)=([a-z0-9-]+)">([^<]+)</g)].map(m => ({ id: `${m[1]}:${m[2]}`, name: decode(m[3]) })).slice(0, 12),
|
tags: [...html.matchAll(/class="badge [^"]*" href="\/manga\/\?(genre|theme)=([a-z0-9-]+)">([^<]+)</g)].map(m => ({ id: `${m[1]}:${m[2]}`, name: decode(m[3]) })).slice(0, 12),
|
||||||
authors: fact('Autor') ? [fact('Autor')] : [],
|
authors: fact('Autor') ? [fact('Autor')] : [],
|
||||||
languages: ['es'],
|
languages: ['es'],
|
||||||
cover: lcCover(/class="lc-cover-lg">\s*<img src="([^"]+)"/.exec(html)?.[1]),
|
cover: lcCover(/class="lc-cover-lg">\s*<img src="([^"]+)"/.exec(html)?.[1], id),
|
||||||
}
|
}
|
||||||
return { manga, chapters }
|
return { manga, chapters }
|
||||||
}
|
}
|
||||||
@@ -403,17 +407,25 @@ async function lcPageUrls(chapterId) {
|
|||||||
const lcPages = async chapterId => (await lcPageUrls(chapterId)).map(imgUrl)
|
const lcPages = async chapterId => (await lcPageUrls(chapterId)).map(imgUrl)
|
||||||
|
|
||||||
// Proxy de imágenes: MangaDex bloquea el hotlinking de portadas y así el navegador no depende de CDNs ajenos.
|
// Proxy de imágenes: MangaDex bloquea el hotlinking de portadas y así el navegador no depende de CDNs ajenos.
|
||||||
async function proxyImage(req, res, u, s) {
|
// isCover: portada a servir en HD; coverish: portada sin HD (caché corta por si luego se sigue el manga).
|
||||||
|
async function proxyImage(req, res, u, s, isCover = false, coverish = false) {
|
||||||
let url
|
let url
|
||||||
try { url = new URL(u) } catch { throw new HttpError(400, 'URL inválida') }
|
try { url = new URL(u) } catch { throw new HttpError(400, 'URL inválida') }
|
||||||
const expected = sign(u)
|
const expected = sign(u)
|
||||||
if (typeof s !== 'string' || s.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected))) throw new HttpError(403, 'Firma inválida')
|
if (typeof s !== 'string' || s.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected))) throw new HttpError(403, 'Firma inválida')
|
||||||
if (!/^https?:$/.test(url.protocol) || /^(localhost|\d+\.\d+\.\d+\.\d+|\[.*\])$/.test(url.hostname)) throw new HttpError(403, 'Host no permitido')
|
if (!/^https?:$/.test(url.protocol) || /^(localhost|\d+\.\d+\.\d+\.\d+|\[.*\])$/.test(url.hostname)) throw new HttpError(403, 'Host no permitido')
|
||||||
|
if (isCover && covers.sendCached(res, u)) return
|
||||||
const r = await fetchImage(url)
|
const r = await fetchImage(url)
|
||||||
if (!r.ok || !r.body) throw new HttpError(502, `Imagen ${r.status}`)
|
if (!r.ok || !r.body) throw new HttpError(502, `Imagen ${r.status}`)
|
||||||
|
if (isCover) {
|
||||||
|
const buf = Buffer.from(await r.arrayBuffer())
|
||||||
|
res.writeHead(200, { 'Content-Type': r.headers.get('content-type') || 'image/jpeg', 'Cache-Control': covers.pendingCache() })
|
||||||
|
res.end(buf)
|
||||||
|
return covers.enqueue(u, buf)
|
||||||
|
}
|
||||||
res.writeHead(200, {
|
res.writeHead(200, {
|
||||||
'Content-Type': r.headers.get('content-type') || 'image/jpeg',
|
'Content-Type': r.headers.get('content-type') || 'image/jpeg',
|
||||||
'Cache-Control': 'private, max-age=604800, immutable',
|
'Cache-Control': coverish ? 'private, max-age=3600' : 'private, max-age=604800, immutable',
|
||||||
})
|
})
|
||||||
for await (const chunk of r.body) res.write(chunk)
|
for await (const chunk of r.body) res.write(chunk)
|
||||||
res.end()
|
res.end()
|
||||||
@@ -636,6 +648,14 @@ function localChapter(chapterId, hd = false) {
|
|||||||
}
|
}
|
||||||
return cbzIndexCache.get(key)
|
return cbzIndexCache.get(key)
|
||||||
}
|
}
|
||||||
|
// Portada de un manga descargado: la cover.jpg de su carpeta, la de la ficha guardada o la del historial.
|
||||||
|
// Las URLs del proxy se vuelven a firmar (con &m= para la versión HD) por si se guardaron con otra clave.
|
||||||
|
function downloadCover(mangaId, folder, metaCover) {
|
||||||
|
if (fs.existsSync(path.join(mangaDirOf(folder), 'cover.jpg'))) return `/api/local-cover/${mangaId}`
|
||||||
|
const c = metaCover || db.prepare('SELECT cover FROM progress WHERE manga_id = ? AND cover IS NOT NULL UNION ALL SELECT cover FROM follows WHERE manga_id = ? AND cover IS NOT NULL LIMIT 1').get(mangaId, mangaId)?.cover
|
||||||
|
const u = rawFromProxy(c)
|
||||||
|
return u ? coverUrl(u, mangaId) : c || null
|
||||||
|
}
|
||||||
const withCurrent = d => (d ? { ...d, current: ['queued', 'downloading'].includes(d.status) ? dlCurrent.get(d.manga_id) ?? null : null } : null)
|
const withCurrent = d => (d ? { ...d, current: ['queued', 'downloading'].includes(d.status) ? dlCurrent.get(d.manga_id) ?? null : null } : null)
|
||||||
const downloadInfo = (mangaId, size = false) => {
|
const downloadInfo = (mangaId, size = false) => {
|
||||||
const d = withCurrent(db.prepare('SELECT manga_id, title, folder, lang, status, scope, done, total, failed, error, updated_at FROM downloads WHERE manga_id = ?').get(mangaId))
|
const d = withCurrent(db.prepare('SELECT manga_id, title, folder, lang, status, scope, done, total, failed, error, updated_at FROM downloads WHERE manga_id = ?').get(mangaId))
|
||||||
@@ -665,6 +685,9 @@ async function readBody(req) {
|
|||||||
const MANGA_ID = /^([0-9a-f-]{36}|lc~[a-z0-9]+~[a-z0-9-]+)$/
|
const MANGA_ID = /^([0-9a-f-]{36}|lc~[a-z0-9]+~[a-z0-9-]+)$/
|
||||||
const CHAPTER_ID = /^([0-9a-f-]{36}|lc~[a-z0-9]+~[a-z0-9-]+~[0-9a-z.-]+)$/
|
const CHAPTER_ID = /^([0-9a-f-]{36}|lc~[a-z0-9]+~[a-z0-9-]+~[0-9a-z.-]+)$/
|
||||||
const LANG = /^[a-z]{2}(-[a-z]{2})?$/
|
const LANG = /^[a-z]{2}(-[a-z]{2})?$/
|
||||||
|
// Manga en el historial o en seguidos del usuario (sus portadas se reescalan).
|
||||||
|
const tracked = (userId, mangaId) => !!db.prepare(`SELECT 1 FROM progress WHERE user_id = ? AND manga_id = ?
|
||||||
|
UNION ALL SELECT 1 FROM follows WHERE user_id = ? AND manga_id = ? AND active = 1 LIMIT 1`).get(userId, mangaId, userId, mangaId)
|
||||||
const requireUser = req => {
|
const requireUser = req => {
|
||||||
const u = getUser(req)
|
const u = getUser(req)
|
||||||
if (!u) throw new HttpError(401, 'No autenticado')
|
if (!u) throw new HttpError(401, 'No autenticado')
|
||||||
@@ -750,7 +773,7 @@ async function route(req, res) {
|
|||||||
return json(res, 200, { ok: true })
|
return json(res, 200, { ok: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (p === '/api/img' && method === 'GET') return proxyImage(req, res, q.get('u') || '', q.get('s'))
|
if (p === '/api/img' && method === 'GET') return proxyImage(req, res, q.get('u') || '', q.get('s'), MANGA_ID.test(q.get('m') || '') && tracked(user.id, q.get('m')), q.has('m'))
|
||||||
|
|
||||||
const lang = LANG.test(q.get('lang') || '') ? q.get('lang') : user.lang
|
const lang = LANG.test(q.get('lang') || '') ? q.get('lang') : user.lang
|
||||||
|
|
||||||
@@ -807,14 +830,35 @@ async function route(req, res) {
|
|||||||
const dl = downloadInfo(m[1])
|
const dl = downloadInfo(m[1])
|
||||||
const file = dl && path.join(MANGA_DIR, dl.folder, 'cover.jpg')
|
const file = dl && path.join(MANGA_DIR, dl.folder, 'cover.jpg')
|
||||||
if (!file || !fs.existsSync(file)) throw new HttpError(404, 'Sin portada')
|
if (!file || !fs.existsSync(file)) throw new HttpError(404, 'Sin portada')
|
||||||
|
if (!tracked(user.id, m[1])) {
|
||||||
res.writeHead(200, { 'Content-Type': imageType(file), 'Cache-Control': 'private, max-age=86400' })
|
res.writeHead(200, { 'Content-Type': imageType(file), 'Cache-Control': 'private, max-age=86400' })
|
||||||
return fs.createReadStream(file).pipe(res)
|
return fs.createReadStream(file).pipe(res)
|
||||||
}
|
}
|
||||||
|
const key = `local:${file}:${fs.statSync(file).mtimeMs}`
|
||||||
|
if (covers.sendCached(res, key)) return
|
||||||
|
const buf = fs.readFileSync(file)
|
||||||
|
res.writeHead(200, { 'Content-Type': imageType(file), 'Cache-Control': covers.pendingCache() })
|
||||||
|
res.end(buf)
|
||||||
|
return covers.enqueue(key, buf)
|
||||||
|
}
|
||||||
|
|
||||||
// --- Descargas ---
|
// --- Descargas ---
|
||||||
|
if (p === '/api/queues' && method === 'GET') {
|
||||||
|
const downloads = db.prepare(`SELECT manga_id, title, folder, lang, status, scope, done, total, failed, error, updated_at, json_extract(meta, '$.manga.cover') meta_cover
|
||||||
|
FROM downloads WHERE status IN ('downloading', 'queued') ORDER BY status = 'downloading' DESC, created_at`).all()
|
||||||
|
.map(({ meta_cover, ...d }) => ({ ...withCurrent(d), cover: downloadCover(d.manga_id, d.folder, meta_cover) }))
|
||||||
|
const c = clips.queueInfo(user.id)
|
||||||
|
const up = upscale.queue()
|
||||||
|
for (const g of up.groups) {
|
||||||
|
const d = db.prepare(`SELECT folder, json_extract(meta, '$.manga.cover') meta_cover FROM downloads WHERE manga_id = ?`).get(g.mangaId)
|
||||||
|
g.cover = d ? downloadCover(g.mangaId, d.folder, d.meta_cover) : null
|
||||||
|
}
|
||||||
|
return json(res, 200, { downloads, upscale: up, clips: { queued: c.queued || 0, running: c.running || 0, errors: c.errors || 0 }, covers: covers.pendingCount() })
|
||||||
|
}
|
||||||
if (p === '/api/downloads' && method === 'GET') {
|
if (p === '/api/downloads' && method === 'GET') {
|
||||||
return json(res, 200, db.prepare('SELECT manga_id, title, folder, lang, status, done, total, failed, error, updated_at FROM downloads ORDER BY updated_at DESC').all()
|
return json(res, 200, db.prepare(`SELECT manga_id, title, folder, lang, status, done, total, failed, error, updated_at, json_extract(meta, '$.manga.cover') meta_cover
|
||||||
.map(d => ({ ...withCurrent(d), size: dirSize(mangaDirOf(d.folder)) })))
|
FROM downloads ORDER BY updated_at DESC`).all()
|
||||||
|
.map(({ meta_cover, ...d }) => ({ ...withCurrent(d), size: dirSize(mangaDirOf(d.folder)), cover: downloadCover(d.manga_id, d.folder, meta_cover) })))
|
||||||
}
|
}
|
||||||
if ((m = /^\/api\/download\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'GET') {
|
if ((m = /^\/api\/download\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'GET') {
|
||||||
return json(res, 200, downloadInfo(m[1], true))
|
return json(res, 200, downloadInfo(m[1], true))
|
||||||
@@ -883,12 +927,20 @@ async function route(req, res) {
|
|||||||
|
|
||||||
const features = setupFeatures({ db, fetchMangaFull, enqueueDownload, downloadInfo, HttpError, json, readBody, MANGA_ID })
|
const features = setupFeatures({ db, fetchMangaFull, enqueueDownload, downloadInfo, HttpError, json, readBody, MANGA_ID })
|
||||||
const upscale = setupUpscale({ db, MANGA_DIR, HttpError, json, readBody, MANGA_ID })
|
const upscale = setupUpscale({ db, MANGA_DIR, HttpError, json, readBody, MANGA_ID })
|
||||||
|
const covers = setupCovers({ DATA_DIR, upscale })
|
||||||
const clips = setupClips({ db, DATA_DIR, HttpError, json, readBody, MANGA_ID, CHAPTER_ID, upscale })
|
const clips = setupClips({ db, DATA_DIR, HttpError, json, readBody, MANGA_ID, CHAPTER_ID, upscale })
|
||||||
|
|
||||||
|
// Migración: portadas guardadas sin &m= (reescalado de portadas) y con la miniatura de 256 px de MangaDex.
|
||||||
|
for (const t of ['progress', 'follows', 'clips']) {
|
||||||
|
for (const row of db.prepare(`SELECT DISTINCT manga_id, cover FROM ${t} WHERE cover LIKE '/api/img?u=%&s=%' AND cover NOT LIKE '%&m=%'`).all()) {
|
||||||
|
const u = new URLSearchParams(row.cover.slice('/api/img?'.length)).get('u')
|
||||||
|
if (u) db.prepare(`UPDATE ${t} SET cover = ? WHERE cover = ? AND manga_id = ?`).run(coverUrl(u.replace(/(\/covers\/[^/]+\/[^/]+)\.256\.jpg$/, '$1.512.jpg'), row.manga_id), row.cover, row.manga_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
// Migración: portadas guardadas antes de firmar las URLs del proxy.
|
// 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()) {
|
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()) {
|
||||||
const u = new URLSearchParams(row.cover.slice('/api/img?'.length)).get('u')
|
const u = new URLSearchParams(row.cover.slice('/api/img?'.length)).get('u')
|
||||||
if (u) db.prepare('UPDATE progress SET cover = ? WHERE user_id = ? AND manga_id = ?').run(imgUrl(u), row.user_id, row.manga_id)
|
if (u) db.prepare('UPDATE progress SET cover = ? WHERE user_id = ? AND manga_id = ?').run(coverUrl(u, row.manga_id), row.user_id, row.manga_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
http.createServer(async (req, res) => {
|
http.createServer(async (req, res) => {
|
||||||
|
|||||||
+27
-1
@@ -175,6 +175,32 @@ export function setupUpscale({ db, MANGA_DIR, HttpError, json, readBody, MANGA_I
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cola global (página "Colas"): por manga, en el orden en que se procesan.
|
||||||
|
function queue() {
|
||||||
|
const rows = db.prepare(`SELECT j.chapter_id, j.manga_id, j.status, j.error, j.created_at, c.num, c.pages, d.title
|
||||||
|
FROM upscale_jobs j JOIN downloaded_chapters c ON c.chapter_id = j.chapter_id JOIN downloads d ON d.manga_id = j.manga_id
|
||||||
|
ORDER BY j.created_at, j.rowid`).all()
|
||||||
|
const groups = new Map()
|
||||||
|
for (const r of rows) {
|
||||||
|
let g = groups.get(r.manga_id)
|
||||||
|
if (!g) groups.set(r.manga_id, g = { mangaId: r.manga_id, title: r.title, queued: 0, pages: 0, errors: [], next: [] })
|
||||||
|
if (r.status === 'error') g.errors.push({ chapterId: r.chapter_id, num: r.num, error: r.error })
|
||||||
|
else {
|
||||||
|
g.queued++
|
||||||
|
g.pages += r.pages || 0
|
||||||
|
if (g.next.length < 5 && r.chapter_id !== current?.chapterId) g.next.push(r.num)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const cur = current && { mangaId: current.mangaId, chapterId: current.chapterId, num: current.num, pages: current.pages, total: current.total }
|
||||||
|
// Páginas que faltan (la del capítulo en curso cuenta solo lo que le queda).
|
||||||
|
for (const g of groups.values()) {
|
||||||
|
if (cur?.mangaId === g.mangaId) g.pages = Math.max(0, g.pages - (rows.find(r => r.chapter_id === cur.chapterId)?.pages || 0) + (cur.total - cur.pages))
|
||||||
|
g.eta = Math.round(g.pages * secPerPage)
|
||||||
|
}
|
||||||
|
const list = [...groups.values()].sort((a, b) => (b.mangaId === cur?.mangaId) - (a.mangaId === cur?.mangaId))
|
||||||
|
return { available: available(), current: cur, groups: list, secPerPage: Math.round(secPerPage * 10) / 10, eta: list.reduce((n, g) => n + g.eta, 0) }
|
||||||
|
}
|
||||||
|
|
||||||
// Llamado al terminar de descargar un capítulo: si el manga está marcado como HD, encolarlo.
|
// Llamado al terminar de descargar un capítulo: si el manga está marcado como HD, encolarlo.
|
||||||
function onChapterDownloaded(mangaId, chapterId) {
|
function onChapterDownloaded(mangaId, chapterId) {
|
||||||
if (db.prepare('SELECT hd_auto FROM downloads WHERE manga_id = ?').get(mangaId)?.hd_auto) enqueue([chapterId])
|
if (db.prepare('SELECT hd_auto FROM downloads WHERE manga_id = ?').get(mangaId)?.hd_auto) enqueue([chapterId])
|
||||||
@@ -224,5 +250,5 @@ export function setupUpscale({ db, MANGA_DIR, HttpError, json, readBody, MANGA_I
|
|||||||
|
|
||||||
if (!fs.existsSync(BIN)) console.log('HD: Real-ESRGAN no instalado, reescalado desactivado')
|
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')
|
else if (!fs.existsSync('/dev/dri')) console.log('HD: sin /dev/dri en el contenedor, reescalado desactivado')
|
||||||
return { info, hdFileOf, onChapterDownloaded, cancel, route, pump, available }
|
return { info, hdFileOf, onChapterDownloaded, cancel, route, pump, available, queue }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user