Files
manga-reader/app/clips.js
T
ninoandClaude Opus 5.5 53e5a0d40e La ficha del manga incluye la info de la obra, menú lateral y banners reescalados
- Ficha e Info son una sola página: banner, puntuaciones, sinopsis, obras relacionadas y recomendaciones.
- La info se guarda una vez por manga para todos los usuarios y se actualiza en segundo plano; los mangas
  seguidos la mantienen al día solos. Si una fuente falla, se conserva lo guardado.
- Menú lateral (⋯) con seguir, listas, recortes, otras fuentes, descargas y datos de la obra.
- Banners reescalados con Real-ESRGAN; si la obra no tiene, se usa un recorte del propio usuario.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 21:33:03 -06:00

193 lines
9.6 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.
// Recortes: trozos de página (un panel, una viñeta) que el usuario guarda desde el lector, con la
// referencia al manga/capítulo/página de donde salen. Cada recorte se reescala solo (Real-ESRGAN ×4,
// o ×2 si ya es grande) en segundo plano. Los archivos van a <DATA_DIR>/clips/: <id>.png (original
// tal como se recortó) y <id>-hd.webp (reescalado).
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { BIN, MODELS, MODEL, run } from './upscale.js'
const MAX_BYTES = 25 * 1024 * 1024
const MAX_SIDE = 4000
const X4_MAX_SIDE = 1200 // a partir de aquí ×2: ×4 daría imágenes enormes sin ganar detalle
const WEBP_QUALITY = 90
// Ancho y alto de un PNG (cabecera IHDR), o null si no es un PNG.
function pngSize(buf) {
if (buf.length < 24 || buf.readUInt32BE(0) !== 0x89504e47 || buf.toString('latin1', 12, 16) !== 'IHDR') return null
return { w: buf.readUInt32BE(16), h: buf.readUInt32BE(20) }
}
export function setupClips({ db, DATA_DIR, HttpError, json, readBody, MANGA_ID, CHAPTER_ID, upscale }) {
const DIR = path.join(DATA_DIR, 'clips')
fs.mkdirSync(DIR, { recursive: true })
db.exec(`
CREATE TABLE IF NOT EXISTS clips (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
manga_id TEXT NOT NULL,
manga_title TEXT NOT NULL,
cover TEXT,
chapter_id TEXT NOT NULL,
chapter_label TEXT,
page INTEGER NOT NULL, -- 0-based
width INTEGER NOT NULL,
height INTEGER NOT NULL,
scale INTEGER, -- 2 o 4 cuando ya está reescalado
note TEXT,
status TEXT NOT NULL, -- queued | running | done | error | off (sin GPU)
error TEXT,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_clips_user ON clips(user_id, created_at);
`)
db.prepare("UPDATE clips SET status = 'queued' WHERE status = 'running'").run() // reinicio a mitad
const file = (id, hd) => path.join(DIR, hd ? `${id}-hd.webp` : `${id}.png`)
const hasX4 = () => fs.existsSync(path.join(MODELS, `${MODEL}-x4.bin`))
async function upscaleClip(clip) {
const scale = Math.max(clip.width, clip.height) <= X4_MAX_SIDE && hasX4() ? 4 : 2
const work = fs.mkdtempSync(path.join(os.tmpdir(), 'clip-'))
try {
const out = path.join(work, 'out.png')
await run(BIN, ['-i', file(clip.id), '-o', out, '-m', MODELS, '-n', MODEL, '-s', String(scale), '-f', 'png'])
const tmp = path.join(DIR, `${clip.id}-hd.tmp.webp`)
await run('cwebp', ['-quiet', '-q', String(WEBP_QUALITY), '-m', '4', '-mt', out, '-o', tmp])
if (!db.prepare('SELECT 1 FROM clips WHERE id = ?').get(clip.id)) return fs.rmSync(tmp, { force: true }) // borrado mientras tanto
fs.renameSync(tmp, file(clip.id, true))
db.prepare("UPDATE clips SET status = 'done', scale = ?, error = NULL WHERE id = ?").run(scale, clip.id)
} finally {
fs.rmSync(work, { recursive: true, force: true })
}
}
let pumping = false
async function pump() {
if (pumping || !upscale.available()) return
pumping = true
try {
for (;;) {
const clip = db.prepare("SELECT * FROM clips WHERE status = 'queued' ORDER BY id LIMIT 1").get()
if (!clip) break
db.prepare("UPDATE clips SET status = 'running' WHERE id = ?").run(clip.id)
const t = Date.now()
try {
await upscaleClip(clip)
console.log(`Recorte ${clip.id} (${clip.width}×${clip.height}) reescalado en ${((Date.now() - t) / 1000).toFixed(1)} s`)
} catch (e) {
console.warn('Recorte', clip.id, e.message)
db.prepare("UPDATE clips SET status = 'error', error = ? WHERE id = ?").run(e.message.slice(0, 500), clip.id)
}
}
} finally {
pumping = false
}
}
setTimeout(pump, 3000) // tras arrancar (cwebp se comprueba de forma asíncrona)
const view = c => ({
id: c.id, mangaId: c.manga_id, mangaTitle: c.manga_title, cover: c.cover, chapterId: c.chapter_id, chapterLabel: c.chapter_label,
page: c.page, width: c.width, height: c.height, scale: c.scale, note: c.note, status: c.status, error: c.error, createdAt: c.created_at,
img: `/api/clips/${c.id}/img`, hd: c.status === 'done' ? `/api/clips/${c.id}/img?hd=1` : null,
})
const own = (userId, id) => {
const c = db.prepare('SELECT * FROM clips WHERE id = ? AND user_id = ?').get(id, userId)
if (!c) throw new HttpError(404, 'Recorte no encontrado')
return c
}
const count = (userId, mangaId) => db.prepare('SELECT COUNT(*) n FROM clips WHERE user_id = ? AND manga_id = ?').get(userId, mangaId).n
// Banner de la ficha cuando la obra no tiene: un recorte del propio usuario, el más apaisado (es el que mejor
// llena la franja) y, si los hay, de entre los ya reescalados.
function bannerFor(userId, mangaId) {
const c = db.prepare(`SELECT * FROM clips WHERE user_id = ? AND manga_id = ?
ORDER BY status = 'done' DESC, CAST(width AS REAL) / height DESC, id DESC LIMIT 1`).get(userId, mangaId)
if (!c) return null
const v = view(c)
return v.hd || v.img
}
async function readImage(req) {
let size = 0
const chunks = []
for await (const c of req) {
size += c.length
if (size > MAX_BYTES) throw new HttpError(413, 'El recorte es demasiado grande')
chunks.push(c)
}
return Buffer.concat(chunks)
}
async function route(p, method, q, req, res, user) {
if (p === '/api/clips' && method === 'GET') {
const mangaId = q.get('manga')
const rows = mangaId
? db.prepare('SELECT * FROM clips WHERE user_id = ? AND manga_id = ? ORDER BY id DESC').all(user.id, mangaId)
: db.prepare('SELECT * FROM clips WHERE user_id = ? ORDER BY id DESC').all(user.id)
const mangas = db.prepare(`SELECT manga_id id, manga_title title, COUNT(*) n, MAX(id) last FROM clips WHERE user_id = ?
GROUP BY manga_id ORDER BY last DESC`).all(user.id)
return json(res, 200, { clips: rows.map(view), mangas, available: upscale.available() }), true
}
if (p === '/api/clips' && method === 'POST') {
// Imagen PNG en el cuerpo; la referencia va en la query.
const mangaId = q.get('mangaId') || '', chapterId = q.get('chapterId') || '', page = Number(q.get('page'))
if (!MANGA_ID.test(mangaId) || !CHAPTER_ID.test(chapterId) || !Number.isInteger(page) || page < 0 || page > 5000) throw new HttpError(400, 'Referencia inválida')
const buf = await readImage(req)
const dim = pngSize(buf)
if (!dim) throw new HttpError(400, 'El recorte debe ser PNG')
if (dim.w < 16 || dim.h < 16 || dim.w > MAX_SIDE || dim.h > MAX_SIDE) throw new HttpError(400, `El recorte debe medir entre 16 y ${MAX_SIDE} px por lado`)
const cover = (q.get('cover') || '').slice(0, 2000)
const r = db.prepare(`INSERT INTO clips (user_id, manga_id, manga_title, cover, chapter_id, chapter_label, page, width, height, status, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
user.id, mangaId, (q.get('mangaTitle') || '').slice(0, 300) || 'Sin título', cover.startsWith('/api/') ? cover : null, chapterId,
(q.get('chapterLabel') || '').slice(0, 300), page, dim.w, dim.h, upscale.available() ? 'queued' : 'off', Date.now())
const id = Number(r.lastInsertRowid)
fs.writeFileSync(file(id), buf)
pump()
return json(res, 200, view(own(user.id, id))), true
}
let m
if ((m = /^\/api\/clips\/(\d+)\/img$/.exec(p)) && method === 'GET') {
const c = own(user.id, Number(m[1]))
const hd = q.get('hd') === '1' && c.status === 'done'
const f = file(c.id, hd)
if (!fs.existsSync(f)) throw new HttpError(404, 'Imagen no encontrada')
const name = `${c.manga_title} - ${c.chapter_label || 'cap'} - p${c.page + 1}${hd ? ' HD' : ''}`.replace(/[\\/:*?"<>|]+/g, '_')
res.writeHead(200, {
'Content-Type': hd ? 'image/webp' : 'image/png', 'Cache-Control': 'private, max-age=31536000, immutable',
...(q.get('dl') === '1' ? { 'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(name)}.${hd ? 'webp' : 'png'}` } : {}),
})
fs.createReadStream(f).pipe(res)
return true
}
if ((m = /^\/api\/clips\/(\d+)$/.exec(p))) {
const c = own(user.id, Number(m[1]))
if (method === 'GET') return json(res, 200, view(c)), true
if (method === 'PATCH') {
const b = await readBody(req)
if (b.note !== undefined) db.prepare('UPDATE clips SET note = ? WHERE id = ?').run(String(b.note).slice(0, 1000) || null, c.id)
return json(res, 200, view(own(user.id, c.id))), true
}
if (method === 'DELETE') {
db.prepare('DELETE FROM clips WHERE id = ?').run(c.id)
fs.rmSync(file(c.id), { force: true })
fs.rmSync(file(c.id, true), { force: true })
return json(res, 200, { ok: true }), true
}
}
if ((m = /^\/api\/clips\/(\d+)\/retry$/.exec(p)) && method === 'POST') {
const c = own(user.id, Number(m[1]))
if (!upscale.available()) throw new HttpError(503, 'El reescalado no está disponible (falta Real-ESRGAN o la GPU en el contenedor)')
if (c.status === 'error' || c.status === 'off') db.prepare("UPDATE clips SET status = 'queued', error = NULL WHERE id = ?").run(c.id)
pump()
return json(res, 200, view(own(user.id, c.id))), true
}
return false
}
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, bannerFor, pump, queueInfo }
}