La ficha del manga incluye la info de la obra, menú lateral y banners reescalados
- Ficha e Info son una sola página: banner, puntuaciones, sinopsis, obras relacionadas y recomendaciones. - La info se guarda una vez por manga para todos los usuarios y se actualiza en segundo plano; los mangas seguidos la mantienen al día solos. Si una fuente falla, se conserva lo guardado. - Menú lateral (⋯) con seguir, listas, recortes, otras fuentes, descargas y datos de la obra. - Banners reescalados con Real-ESRGAN; si la obra no tiene, se usa un recorte del propio usuario. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+20
-9
@@ -460,14 +460,16 @@ const sourceOf = id => (id.startsWith('lc~') ? 'lc' : id.startsWith('mi~') ? 'mi
|
||||
const spanishOnly = id => sourceOf(id) !== 'md'
|
||||
|
||||
// Proxy de imágenes: MangaDex bloquea el hotlinking de portadas y así el navegador no depende de CDNs ajenos.
|
||||
// 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) {
|
||||
// isCover: portada a servir en HD; coverish: portada sin HD (caché corta por si luego se sigue el manga);
|
||||
// banner: imagen de fondo de la ficha, siempre en HD.
|
||||
async function proxyImage(req, res, u, s, isCover = false, coverish = false, banner = 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 hdKey = banner ? bannerKey(u) : isCover ? u : null
|
||||
if (hdKey && covers.sendCached(res, hdKey)) return
|
||||
// Los CDN de las fuentes fallan a ratos (520, conexión cortada, respuestas que no llegan): hasta 3
|
||||
// intentos con tiempo límite, para no dejar al navegador esperando para siempre.
|
||||
net.guard() // sin internet: fallar ya (la portada local entra como respaldo), sin reintentos
|
||||
@@ -477,11 +479,11 @@ async function proxyImage(req, res, u, s, isCover = false, coverish = false) {
|
||||
return r
|
||||
}).catch(e => { throw e instanceof HttpError ? e : new HttpError(502, `Imagen: ${e.message}`) })
|
||||
if (!r.ok || !r.body) throw new HttpError(502, `Imagen ${r.status}`)
|
||||
if (isCover) {
|
||||
if (hdKey) {
|
||||
const buf = Buffer.from(await r.arrayBuffer())
|
||||
res.writeHead(200, { 'Content-Type': r.headers.get('content-type') || 'image/jpeg', 'Cache-Control': covers.pendingCache() })
|
||||
res.writeHead(200, { 'Content-Type': r.headers.get('content-type') || 'image/jpeg', 'Cache-Control': covers.pendingCache(banner) })
|
||||
res.end(buf)
|
||||
return covers.enqueue(u, buf)
|
||||
return covers.enqueue(hdKey, buf, banner)
|
||||
}
|
||||
res.writeHead(200, {
|
||||
'Content-Type': r.headers.get('content-type') || 'image/jpeg',
|
||||
@@ -491,6 +493,15 @@ async function proxyImage(req, res, u, s, isCover = false, coverish = false) {
|
||||
res.end()
|
||||
}
|
||||
|
||||
const bannerKey = u => `banner:${u}`
|
||||
// Reescala un banner sin esperar a que alguien abra la ficha (recibe la URL del proxy, como se guarda).
|
||||
async function prepareBanner(proxyUrl) {
|
||||
const u = rawFromProxy(proxyUrl)
|
||||
if (!u || !covers.wants(bannerKey(u))) return
|
||||
const r = await fetchImage(new URL(u), AbortSignal.timeout(60e3))
|
||||
if (r.ok) covers.enqueue(bannerKey(u), Buffer.from(await r.arrayBuffer()), true)
|
||||
}
|
||||
|
||||
function fetchImage(url, signal) {
|
||||
net.guard()
|
||||
const host = new URL(url).hostname
|
||||
@@ -890,7 +901,7 @@ async function route(req, res) {
|
||||
if (p === '/api/img' && method === 'GET') {
|
||||
const mid = MANGA_ID.test(q.get('m') || '') ? q.get('m') : null
|
||||
try {
|
||||
return await proxyImage(req, res, q.get('u') || '', q.get('s'), !!mid && tracked(user.id, mid), q.has('m'))
|
||||
return await proxyImage(req, res, q.get('u') || '', q.get('s'), !!mid && tracked(user.id, mid), q.has('m'), q.has('b'))
|
||||
} catch (e) {
|
||||
// Portada sin conexión con la fuente: la cover.jpg de la copia local, si la hay.
|
||||
if (mid && !res.headersSent && e.status !== 403) {
|
||||
@@ -940,7 +951,7 @@ 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 !== 'md' || offline ? [] : await getChapterLangs(m[1]).catch(() => [])
|
||||
return json(res, 200, { manga, chapters, lang, reads, progress, chapterLangs, offline, downloaded, download: downloadInfo(m[1], true), upscale: upscale.info(m[1]), clips: clips.count(user.id, m[1]), lists: library.listsWith(user.id, m[1]), ...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]), clips: clips.count(user.id, m[1]), lists: library.listsWith(user.id, m[1]), info: meta.cachedInfo(m[1]), clipBanner: clips.bannerFor(user.id, m[1]), ...features.mangaExtras(user.id, manga) })
|
||||
}
|
||||
if ((m = /^\/api\/chapter\/([^/]+)$/.exec(p)) && CHAPTER_ID.test(m[1]) && method === 'GET') {
|
||||
const hdAvailable = !!upscale.hdFileOf(m[1])
|
||||
@@ -1075,7 +1086,7 @@ const covers = setupCovers({ DATA_DIR, upscale })
|
||||
const library = setupLibrary({ db, HttpError, json, readBody, MANGA_ID, CHAPTER_ID, fetchMangaFull, enqueueDownload, linkDownload, upscale, stats: features.stats, searchManga, mdCategories, net })
|
||||
// Al volver internet: reanudar las descargas en cola.
|
||||
net.whenOnline(() => pumpDownloads())
|
||||
const meta = setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, net, decode, imgUrl, UA })
|
||||
const meta = setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, net, decode, imgUrl, UA, prepareBanner })
|
||||
const clips = setupClips({ db, DATA_DIR, HttpError, json, readBody, MANGA_ID, CHAPTER_ID, upscale })
|
||||
|
||||
// Migración: lo descargado antes de que hubiera dueños pasa a quien lo lee, lo sigue o lo tiene en una
|
||||
|
||||
Reference in New Issue
Block a user