Portadas HD de lo seguido o leído; sección Colas con descargas, reescalado y biblioteca en tarjetas

- Portadas: se reescalan (×4/×2) solo las de mangas en historial o seguidos; caché en data/covers/.
- Colas (antes Descargas): cola de descargas y de reescalado HD con progreso, tiempo restante,
  reintentar/detener, recortes y portadas pendientes; biblioteca descargada en tarjetas verticales.
- Refresco de Colas sin parpadeo: solo se actualiza lo que cambia.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 16:31:18 -06:00
co-authored by Claude Opus 5.5
parent 36ff53cad4
commit 9086896a89
8 changed files with 352 additions and 51 deletions
+65 -13
View File
@@ -9,6 +9,7 @@ import { writeCbz, readCbzIndex, sendCbzEntry, imageType } from './cbz.js'
import { setupFeatures } from './features.js'
import { setupUpscale } from './upscale.js'
import { setupClips } from './clips.js'
import { setupCovers } from './covers.js'
const PORT = Number(process.env.PORT || 3352)
const DATA_DIR = process.env.DATA_DIR || '/app/data'
@@ -160,6 +161,9 @@ async function cached(key, ttl, fn) {
// Las imágenes pasan por /api/img firmadas con HMAC: el proxy solo descarga URLs generadas por este servidor.
const sign = u => crypto.createHmac('sha256', SECRET).update(u).digest('base64url').slice(0, 22)
const imgUrl = u => `/api/img?u=${encodeURIComponent(u)}&s=${sign(u)}`
// Portadas: &m=<manga> deja al proxy servir (y generar) la versión reescalada si el manga está en el
// historial o en seguidos del usuario; las demás (búsquedas) se sirven tal cual.
const coverUrl = (u, mangaId) => `${imgUrl(u)}&m=${encodeURIComponent(mangaId)}`
async function md(pathname, params = {}, ttl = 5 * 60e3) {
const url = new URL(MD_API + pathname)
@@ -187,7 +191,7 @@ function mangaSummary(m, lang) {
if (alt) title = pick(alt, [lang, 'es-la', 'es'])
const coverRel = m.relationships?.find(r => r.type === 'cover_art')
const cover = coverRel?.attributes?.fileName
? imgUrl(`https://uploads.mangadex.org/covers/${m.id}/${coverRel.attributes.fileName}.256.jpg`)
? coverUrl(`https://uploads.mangadex.org/covers/${m.id}/${coverRel.attributes.fileName}.512.jpg`, m.id)
: null
return {
id: m.id,
@@ -310,7 +314,7 @@ async function lc(pathname, ttl = 10 * 60e3, asJson = false) {
return asJson ? res.json() : res.text()
})
}
const lcCover = src => (src ? imgUrl(new URL(src, LC_BASE).toString()) : null)
const lcCover = (src, id) => (src ? coverUrl(new URL(src, LC_BASE).toString(), id) : null)
const lcId = uri => {
const m = /\/manga\/([a-z0-9]+)\/([a-z0-9-]+)\//.exec(uri || '')
return m ? `lc~${m[1]}~${m[2]}` : null
@@ -323,7 +327,7 @@ function lcParseCards(html) {
const id = lcId(m[1])
if (!id || seen.has(id)) continue
seen.add(id)
out.push({ id, source: 'lc', title: decode(m[3]), cover: lcCover(m[2]) })
out.push({ id, source: 'lc', title: decode(m[3]), cover: lcCover(m[2], id) })
}
return out
}
@@ -348,7 +352,7 @@ async function lcSearch(q, mode, tag, page = 1) {
}
if (q) {
const list = await lc(`/search/?q=${encodeURIComponent(q)}`, 10 * 60e3, true)
return list.map(e => ({ id: lcId(e.uri), source: 'lc', title: decode(e.name), cover: lcCover(e.cover_uri), sub: e.last_chapter_name }))
return list.map(e => ({ id: lcId(e.uri), source: 'lc', title: decode(e.name), cover: lcCover(e.cover_uri, lcId(e.uri)), sub: e.last_chapter_name }))
.filter(e => e.id)
}
const html = await lc('/')
@@ -388,7 +392,7 @@ async function lcManga(id) {
tags: [...html.matchAll(/class="badge [^"]*" href="\/manga\/\?(genre|theme)=([a-z0-9-]+)">([^<]+)</g)].map(m => ({ id: `${m[1]}:${m[2]}`, name: decode(m[3]) })).slice(0, 12),
authors: fact('Autor') ? [fact('Autor')] : [],
languages: ['es'],
cover: lcCover(/class="lc-cover-lg">\s*<img src="([^"]+)"/.exec(html)?.[1]),
cover: lcCover(/class="lc-cover-lg">\s*<img src="([^"]+)"/.exec(html)?.[1], id),
}
return { manga, chapters }
}
@@ -403,17 +407,25 @@ async function lcPageUrls(chapterId) {
const lcPages = async chapterId => (await lcPageUrls(chapterId)).map(imgUrl)
// Proxy de imágenes: MangaDex bloquea el hotlinking de portadas y así el navegador no depende de CDNs ajenos.
async function proxyImage(req, res, u, s) {
// isCover: portada a servir en HD; coverish: portada sin HD (caché corta por si luego se sigue el manga).
async function proxyImage(req, res, u, s, isCover = false, coverish = false) {
let url
try { url = new URL(u) } catch { throw new HttpError(400, 'URL inválida') }
const expected = sign(u)
if (typeof s !== 'string' || s.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected))) throw new HttpError(403, 'Firma inválida')
if (!/^https?:$/.test(url.protocol) || /^(localhost|\d+\.\d+\.\d+\.\d+|\[.*\])$/.test(url.hostname)) throw new HttpError(403, 'Host no permitido')
if (isCover && covers.sendCached(res, u)) return
const r = await fetchImage(url)
if (!r.ok || !r.body) throw new HttpError(502, `Imagen ${r.status}`)
if (isCover) {
const buf = Buffer.from(await r.arrayBuffer())
res.writeHead(200, { 'Content-Type': r.headers.get('content-type') || 'image/jpeg', 'Cache-Control': covers.pendingCache() })
res.end(buf)
return covers.enqueue(u, buf)
}
res.writeHead(200, {
'Content-Type': r.headers.get('content-type') || 'image/jpeg',
'Cache-Control': 'private, max-age=604800, immutable',
'Cache-Control': coverish ? 'private, max-age=3600' : 'private, max-age=604800, immutable',
})
for await (const chunk of r.body) res.write(chunk)
res.end()
@@ -636,6 +648,14 @@ function localChapter(chapterId, hd = false) {
}
return cbzIndexCache.get(key)
}
// Portada de un manga descargado: la cover.jpg de su carpeta, la de la ficha guardada o la del historial.
// Las URLs del proxy se vuelven a firmar (con &m= para la versión HD) por si se guardaron con otra clave.
function downloadCover(mangaId, folder, metaCover) {
if (fs.existsSync(path.join(mangaDirOf(folder), 'cover.jpg'))) return `/api/local-cover/${mangaId}`
const c = metaCover || db.prepare('SELECT cover FROM progress WHERE manga_id = ? AND cover IS NOT NULL UNION ALL SELECT cover FROM follows WHERE manga_id = ? AND cover IS NOT NULL LIMIT 1').get(mangaId, mangaId)?.cover
const u = rawFromProxy(c)
return u ? coverUrl(u, mangaId) : c || null
}
const withCurrent = d => (d ? { ...d, current: ['queued', 'downloading'].includes(d.status) ? dlCurrent.get(d.manga_id) ?? null : null } : null)
const downloadInfo = (mangaId, size = false) => {
const d = withCurrent(db.prepare('SELECT manga_id, title, folder, lang, status, scope, done, total, failed, error, updated_at FROM downloads WHERE manga_id = ?').get(mangaId))
@@ -665,6 +685,9 @@ async function readBody(req) {
const MANGA_ID = /^([0-9a-f-]{36}|lc~[a-z0-9]+~[a-z0-9-]+)$/
const CHAPTER_ID = /^([0-9a-f-]{36}|lc~[a-z0-9]+~[a-z0-9-]+~[0-9a-z.-]+)$/
const LANG = /^[a-z]{2}(-[a-z]{2})?$/
// Manga en el historial o en seguidos del usuario (sus portadas se reescalan).
const tracked = (userId, mangaId) => !!db.prepare(`SELECT 1 FROM progress WHERE user_id = ? AND manga_id = ?
UNION ALL SELECT 1 FROM follows WHERE user_id = ? AND manga_id = ? AND active = 1 LIMIT 1`).get(userId, mangaId, userId, mangaId)
const requireUser = req => {
const u = getUser(req)
if (!u) throw new HttpError(401, 'No autenticado')
@@ -750,7 +773,7 @@ async function route(req, res) {
return json(res, 200, { ok: true })
}
if (p === '/api/img' && method === 'GET') return proxyImage(req, res, q.get('u') || '', q.get('s'))
if (p === '/api/img' && method === 'GET') return proxyImage(req, res, q.get('u') || '', q.get('s'), MANGA_ID.test(q.get('m') || '') && tracked(user.id, q.get('m')), q.has('m'))
const lang = LANG.test(q.get('lang') || '') ? q.get('lang') : user.lang
@@ -807,14 +830,35 @@ async function route(req, res) {
const dl = downloadInfo(m[1])
const file = dl && path.join(MANGA_DIR, dl.folder, 'cover.jpg')
if (!file || !fs.existsSync(file)) throw new HttpError(404, 'Sin portada')
res.writeHead(200, { 'Content-Type': imageType(file), 'Cache-Control': 'private, max-age=86400' })
return fs.createReadStream(file).pipe(res)
if (!tracked(user.id, m[1])) {
res.writeHead(200, { 'Content-Type': imageType(file), 'Cache-Control': 'private, max-age=86400' })
return fs.createReadStream(file).pipe(res)
}
const key = `local:${file}:${fs.statSync(file).mtimeMs}`
if (covers.sendCached(res, key)) return
const buf = fs.readFileSync(file)
res.writeHead(200, { 'Content-Type': imageType(file), 'Cache-Control': covers.pendingCache() })
res.end(buf)
return covers.enqueue(key, buf)
}
// --- Descargas ---
if (p === '/api/queues' && method === 'GET') {
const downloads = db.prepare(`SELECT manga_id, title, folder, lang, status, scope, done, total, failed, error, updated_at, json_extract(meta, '$.manga.cover') meta_cover
FROM downloads WHERE status IN ('downloading', 'queued') ORDER BY status = 'downloading' DESC, created_at`).all()
.map(({ meta_cover, ...d }) => ({ ...withCurrent(d), cover: downloadCover(d.manga_id, d.folder, meta_cover) }))
const c = clips.queueInfo(user.id)
const up = upscale.queue()
for (const g of up.groups) {
const d = db.prepare(`SELECT folder, json_extract(meta, '$.manga.cover') meta_cover FROM downloads WHERE manga_id = ?`).get(g.mangaId)
g.cover = d ? downloadCover(g.mangaId, d.folder, d.meta_cover) : null
}
return json(res, 200, { downloads, upscale: up, clips: { queued: c.queued || 0, running: c.running || 0, errors: c.errors || 0 }, covers: covers.pendingCount() })
}
if (p === '/api/downloads' && method === 'GET') {
return json(res, 200, db.prepare('SELECT manga_id, title, folder, lang, status, done, total, failed, error, updated_at FROM downloads ORDER BY updated_at DESC').all()
.map(d => ({ ...withCurrent(d), size: dirSize(mangaDirOf(d.folder)) })))
return json(res, 200, db.prepare(`SELECT manga_id, title, folder, lang, status, done, total, failed, error, updated_at, json_extract(meta, '$.manga.cover') meta_cover
FROM downloads ORDER BY updated_at DESC`).all()
.map(({ meta_cover, ...d }) => ({ ...withCurrent(d), size: dirSize(mangaDirOf(d.folder)), cover: downloadCover(d.manga_id, d.folder, meta_cover) })))
}
if ((m = /^\/api\/download\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'GET') {
return json(res, 200, downloadInfo(m[1], true))
@@ -883,12 +927,20 @@ 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 })
const covers = setupCovers({ DATA_DIR, upscale })
const clips = setupClips({ db, DATA_DIR, HttpError, json, readBody, MANGA_ID, CHAPTER_ID, upscale })
// Migración: portadas guardadas sin &m= (reescalado de portadas) y con la miniatura de 256 px de MangaDex.
for (const t of ['progress', 'follows', 'clips']) {
for (const row of db.prepare(`SELECT DISTINCT manga_id, cover FROM ${t} WHERE cover LIKE '/api/img?u=%&s=%' AND cover NOT LIKE '%&m=%'`).all()) {
const u = new URLSearchParams(row.cover.slice('/api/img?'.length)).get('u')
if (u) db.prepare(`UPDATE ${t} SET cover = ? WHERE cover = ? AND manga_id = ?`).run(coverUrl(u.replace(/(\/covers\/[^/]+\/[^/]+)\.256\.jpg$/, '$1.512.jpg'), row.manga_id), row.cover, row.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()) {
const u = new URLSearchParams(row.cover.slice('/api/img?'.length)).get('u')
if (u) db.prepare('UPDATE progress SET cover = ? WHERE user_id = ? AND manga_id = ?').run(imgUrl(u), row.user_id, row.manga_id)
if (u) db.prepare('UPDATE progress SET cover = ? WHERE user_id = ? AND manga_id = ?').run(coverUrl(u, row.manga_id), row.user_id, row.manga_id)
}
http.createServer(async (req, res) => {