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:
+65
-13
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user