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:
2026-10-31 10:51:29 -06:00
co-authored by Claude Opus 5.5
parent 9e9139f43a
commit bc84a9f6fe
10 changed files with 645 additions and 64 deletions
+19 -9
View File
@@ -7,6 +7,7 @@ import crypto from 'node:crypto'
import { DatabaseSync } from 'node:sqlite'
import { writeCbz, readCbzIndex, sendCbzEntry, imageType } from './cbz.js'
import { setupFeatures } from './features.js'
import { setupUpscale } from './upscale.js'
const PORT = Number(process.env.PORT || 3352)
const DATA_DIR = process.env.DATA_DIR || '/app/data'
@@ -487,6 +488,7 @@ async function downloadChapter(dl, manga, c, dir) {
}
db.prepare(`INSERT OR REPLACE INTO downloaded_chapters (chapter_id, manga_id, num, title, file, pages, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`)
.run(c.id, dl.manga_id, c.num ?? null, c.title || '', file, urls.length, Date.now())
upscale.onChapterDownloaded(dl.manga_id, c.id)
}
const dlUpdate = (mangaId, fields) => {
@@ -504,13 +506,16 @@ function mangaDirOf(folder) {
}
function dirSize(dir) {
let total = 0
try { for (const f of fs.readdirSync(dir)) total += fs.statSync(path.join(dir, f)).size } catch {}
try {
for (const f of fs.readdirSync(dir, { withFileTypes: true })) total += f.isDirectory() ? dirSize(path.join(dir, f.name)) : fs.statSync(path.join(dir, f.name)).size
} catch {}
return total
}
function deleteDownload(mangaId) {
const dl = db.prepare('SELECT folder FROM downloads WHERE manga_id = ?').get(mangaId)
if (!dl) return false
const dir = mangaDirOf(dl.folder)
upscale.cancel(mangaId)
db.prepare('DELETE FROM downloads WHERE manga_id = ?').run(mangaId) // borra también downloaded_chapters (CASCADE)
dlCurrent.delete(mangaId)
fs.rmSync(dir, { recursive: true, force: true })
@@ -589,10 +594,11 @@ function enqueueDownload(mangaId, title, lang) {
}
const cbzIndexCache = new Map()
function localChapter(chapterId) {
const row = db.prepare(`SELECT c.file, d.folder FROM downloaded_chapters c JOIN downloads d ON d.manga_id = c.manga_id WHERE c.chapter_id = ?`).get(chapterId)
// hd: usar la versión reescalada si existe.
function localChapter(chapterId, hd = false) {
const row = db.prepare(`SELECT 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)
if (!row) return null
const file = path.join(MANGA_DIR, row.folder, row.file)
const file = (hd && upscale.hdFileOf(chapterId)) || path.join(MANGA_DIR, row.folder, row.file)
let st
try { st = fs.statSync(file) } catch { return null }
const key = `${file}:${st.mtimeMs}`
@@ -697,6 +703,7 @@ async function route(req, res) {
// Todo lo demás requiere sesión.
const user = requireUser(req)
if ((await features.route(p, method, q, req, res, user)) !== false) return
if ((await upscale.route(p, method, req, res)) !== false) return
if (p === '/api/settings' && method === 'POST') {
const body = await readBody(req)
@@ -748,18 +755,20 @@ async function route(req, res) {
const reads = db.prepare('SELECT chapter_id, page, total_pages FROM chapter_reads WHERE user_id = ? AND manga_id = ?').all(user.id, m[1])
const progress = db.prepare('SELECT * FROM progress WHERE user_id = ? AND manga_id = ?').get(user.id, m[1]) ?? null
const chapterLangs = chapters.length || manga.source === 'lc' || offline ? [] : await getChapterLangs(m[1]).catch(() => [])
return json(res, 200, { manga, chapters, lang, reads, progress, chapterLangs, offline, downloaded, download: downloadInfo(m[1], true), ...features.mangaExtras(user.id, manga) })
return json(res, 200, { manga, chapters, lang, reads, progress, chapterLangs, offline, downloaded, download: downloadInfo(m[1], true), upscale: upscale.info(m[1]), ...features.mangaExtras(user.id, manga) })
}
if ((m = /^\/api\/chapter\/([^/]+)$/.exec(p)) && CHAPTER_ID.test(m[1]) && method === 'GET') {
const local = localChapter(m[1])
const hdAvailable = !!upscale.hdFileOf(m[1])
const hd = hdAvailable && q.get('hd') !== '0'
const local = localChapter(m[1], hd)
const pages = local
? local.entries.map((_, i) => `/api/local/${m[1]}/${i}`)
? local.entries.map((_, i) => `/api/local/${m[1]}/${i}${hd ? '?hd=1' : ''}`)
: m[1].startsWith('lc~') ? await lcPages(m[1]) : await getPages(m[1], q.get('saver') === '1')
const read = db.prepare('SELECT page FROM chapter_reads WHERE user_id = ? AND chapter_id = ?').get(user.id, m[1])
return json(res, 200, { pages, savedPage: read?.page ?? 0, local: !!local })
return json(res, 200, { pages, savedPage: read?.page ?? 0, local: !!local, hd, hdAvailable })
}
if ((m = /^\/api\/local\/([^/]+)\/(\d+)$/.exec(p)) && CHAPTER_ID.test(m[1]) && method === 'GET') {
const local = localChapter(m[1])
const local = localChapter(m[1], q.get('hd') === '1')
const entry = local?.entries[Number(m[2])]
if (!entry) throw new HttpError(404, 'Página no descargada')
return sendCbzEntry(res, local.file, entry)
@@ -841,6 +850,7 @@ async function route(req, res) {
}
const features = setupFeatures({ db, fetchMangaFull, enqueueDownload, downloadInfo, HttpError, json, readBody, MANGA_ID })
const upscale = setupUpscale({ db, MANGA_DIR, HttpError, json, readBody, MANGA_ID })
// Migración: portadas guardadas antes de firmar las URLs del proxy.
for (const row of db.prepare("SELECT user_id, manga_id, cover FROM progress WHERE cover LIKE '/api/img?u=%' AND cover NOT LIKE '%&s=%'").all()) {