Manga Reader: lector web autoalojado con modo libro 3D y estante
This commit is contained in:
+280
@@ -0,0 +1,280 @@
|
||||
// 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'
|
||||
|
||||
export const BIN = process.env.UPSCALER_BIN || '/opt/realesrgan/realesrgan-ncnn-vulkan'
|
||||
export const MODELS = process.env.UPSCALER_MODELS || path.join(path.dirname(BIN), 'models')
|
||||
export 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)
|
||||
// WebP no admite más de 16383 px por lado: las tiras de webtoon muy altas se pasan al reescalarlas.
|
||||
const WEBP_MAX_SIDE = 16383
|
||||
// Ancho y alto de un PNG (cabecera IHDR).
|
||||
function pngSize(file) {
|
||||
const fd = fs.openSync(file, 'r')
|
||||
try {
|
||||
const b = Buffer.alloc(24)
|
||||
fs.readSync(fd, b, 0, 24, 0)
|
||||
return b.readUInt32BE(0) === 0x89504e47 ? { w: b.readUInt32BE(16), h: b.readUInt32BE(20) } : null
|
||||
} finally { fs.closeSync(fd) }
|
||||
}
|
||||
const HD_DIR = '.hd'
|
||||
const UPSCALABLE = /\.(jpe?g|png|webp)$/i // lo demás (gif, avif) se copia tal cual
|
||||
|
||||
export 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
|
||||
// Capítulos aún sin descargar que deben reescalarse en cuanto lleguen (descarga anticipada con HD).
|
||||
db.exec('CREATE TABLE IF NOT EXISTS upscale_wanted (chapter_id TEXT PRIMARY KEY, created_at INTEGER NOT NULL)')
|
||||
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`)
|
||||
// Demasiado grande para WebP: se reduce justo hasta el límite (sigue mejor que la original).
|
||||
const size = pngSize(png)
|
||||
const fit = size && Math.max(size.w, size.h) > WEBP_MAX_SIDE ? (size.h >= size.w ? ['-resize', '0', String(WEBP_MAX_SIDE)] : ['-resize', String(WEBP_MAX_SIDE), '0']) : []
|
||||
await run('cwebp', ['-quiet', '-q', String(WEBP_QUALITY), '-m', '4', '-mt', ...fit, 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,
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
function onChapterDownloaded(mangaId, chapterId) {
|
||||
const wanted = db.prepare('DELETE FROM upscale_wanted WHERE chapter_id = ?').run(chapterId).changes
|
||||
if (wanted || 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')
|
||||
// Reescalar estos capítulos: los descargados ya, el resto al terminar su descarga.
|
||||
function request(chapterIds) {
|
||||
const have = db.prepare('SELECT 1 FROM downloaded_chapters WHERE chapter_id = ?')
|
||||
const later = db.prepare('INSERT OR IGNORE INTO upscale_wanted (chapter_id, created_at) VALUES (?, ?)')
|
||||
const now = []
|
||||
for (const id of chapterIds) have.get(id) ? now.push(id) : later.run(id, Date.now())
|
||||
if (now.length) enqueue(now)
|
||||
}
|
||||
|
||||
return { info, hdFileOf, onChapterDownloaded, cancel, route, pump, available, queue, request }
|
||||
}
|
||||
Reference in New Issue
Block a user