Files
manga-reader/app/covers.js
T
ninoandClaude Opus 5.5 9086896a89 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>
2026-10-31 16:31:18 -06:00

110 lines
4.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 }
}