Reescalado HD, ficha reorganizada y mejoras del lector
- Reescalado HD con Real-ESRGAN (ncnn-vulkan, GPU por /dev/dri): cola persistente, todo lo pendiente, un rango o capítulos elegidos en la lista, con estimación de tiempo. La versión HD va a <carpeta>/.hd/ sin tocar el original; marca HD en la lista de capítulos y casilla "Versión HD" en el lector. - Imagen Debian slim (glibc + Mesa Vulkan + cwebp); compose con /dev/dri y RENDER_GID. - Ficha: acciones principales + menú ⋯, panel "Biblioteca local" (copia local y HD), idioma junto a la lista de capítulos. - Lector: la barra de progreso avanza de derecha a izquierda en der → izq; botón rápido páginas ⇄ vertical; en vertical "Ajustar alto" y "Separar páginas" para mangas. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+228
@@ -0,0 +1,228 @@
|
||||
// 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'
|
||||
|
||||
const BIN = process.env.UPSCALER_BIN || '/opt/realesrgan/realesrgan-ncnn-vulkan'
|
||||
const MODELS = process.env.UPSCALER_MODELS || path.join(path.dirname(BIN), 'models')
|
||||
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
|
||||
|
||||
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 }
|
||||
}
|
||||
Reference in New Issue
Block a user