// 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 /.hd/.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 // 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`) 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, } } // 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 } }