Files
manga-reader/app/upscale.js
T

229 lines
12 KiB
JavaScript

// 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)
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
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, available }
}