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:
2026-11-01 21:33:03 -06:00
co-authored by Claude Opus 5.5
parent 41a87bc5ad
commit 53e5a0d40e
7 changed files with 289 additions and 145 deletions
+15 -3
View File
@@ -79,16 +79,28 @@ El primer usuario puede registrarse siempre; después el registro queda cerrado
## Info de la obra y fechas de los capítulos ## Info de la obra y fechas de los capítulos
El botón **Info** de cada manga muestra datos de [AniList](https://anilist.co) y La ficha de cada manga incluye datos de [AniList](https://anilist.co) y
[MangaUpdates](https://www.mangaupdates.com): autores, revista y editorial, fechas de publicación, tomos, [MangaUpdates](https://www.mangaupdates.com): autores, revista y editorial, fechas de publicación, tomos,
puntuaciones, rankings, adaptaciones al anime, obras relacionadas, recomendaciones y enlaces oficiales. puntuaciones, rankings, adaptaciones al anime, obras relacionadas, recomendaciones, enlaces oficiales y la
imagen de fondo de la obra.
La obra se identifica por los enlaces de MangaDex; si el manga viene de LeerCapitulo o M440, por su título La obra se identifica por los enlaces de MangaDex; si el manga viene de LeerCapitulo o M440, por su título
(solo si coincide, para no mostrar datos de otra obra). (solo si coincide, para no mostrar datos de otra obra).
Las fechas de la lista de capítulos son las de la **primera traducción conocida** de cada capítulo Las fechas de la lista de capítulos son las de la **primera traducción conocida** de cada capítulo
(MangaDex en cualquier idioma y MangaUpdates), que en series en curso es muy cercana a la de publicación; (MangaDex en cualquier idioma y MangaUpdates), que en series en curso es muy cercana a la de publicación;
los sitios en español suelen poner la fecha en que ellos lo subieron. Las marcadas con «≈» son estimadas los sitios en español suelen poner la fecha en que ellos lo subieron. Las marcadas con «≈» son estimadas
entre dos capítulos con fecha. Todo se guarda en la base de datos (ficha 7 días, fechas 1 día). entre dos capítulos con fecha.
Todo se guarda en la base de datos, una vez por manga y para todos los usuarios: lo guardado se muestra sin
consultar nada y, si es antiguo (info 30 días, fechas 1 día), se actualiza en segundo plano. Los mangas que
sigue algún usuario se completan y se mantienen al día solos, aunque nadie abra su ficha.
La imagen de fondo de la ficha se reescala con Real-ESRGAN (×4 si mide menos de 1000 px de ancho, ×2 si no). Si la obra
no tiene, se usa uno de tus recortes de ese manga; sin recortes, la ficha va sin imagen de fondo.
En la ficha solo quedan a la vista «Continuar/Empezar a leer» y «Capítulos». El botón **⋯** abre el menú lateral con
seguir, listas, recortes, buscar en otras fuentes, dejar de leer, la copia local, el reescalado HD, «En este dispositivo»
y los datos de la obra (autores, editorial, publicación…).
## Sin internet en el servidor ## Sin internet en el servidor
+11 -1
View File
@@ -98,6 +98,16 @@ export function setupClips({ db, DATA_DIR, HttpError, json, readBody, MANGA_ID,
} }
const count = (userId, mangaId) => db.prepare('SELECT COUNT(*) n FROM clips WHERE user_id = ? AND manga_id = ?').get(userId, mangaId).n const count = (userId, mangaId) => db.prepare('SELECT COUNT(*) n FROM clips WHERE user_id = ? AND manga_id = ?').get(userId, mangaId).n
// Banner de la ficha cuando la obra no tiene: un recorte del propio usuario, el más apaisado (es el que mejor
// llena la franja) y, si los hay, de entre los ya reescalados.
function bannerFor(userId, mangaId) {
const c = db.prepare(`SELECT * FROM clips WHERE user_id = ? AND manga_id = ?
ORDER BY status = 'done' DESC, CAST(width AS REAL) / height DESC, id DESC LIMIT 1`).get(userId, mangaId)
if (!c) return null
const v = view(c)
return v.hd || v.img
}
async function readImage(req) { async function readImage(req) {
let size = 0 let size = 0
const chunks = [] const chunks = []
@@ -178,5 +188,5 @@ export function setupClips({ db, DATA_DIR, HttpError, json, readBody, MANGA_ID,
const queueInfo = userId => db.prepare(`SELECT SUM(status = 'queued') queued, SUM(status = 'running') running, SUM(status = 'error') errors const queueInfo = userId => db.prepare(`SELECT SUM(status = 'queued') queued, SUM(status = 'running') running, SUM(status = 'error') errors
FROM clips WHERE user_id = ?`).get(userId) FROM clips WHERE user_id = ?`).get(userId)
return { route, count, pump, queueInfo } return { route, count, bannerFor, pump, queueInfo }
} }
+14 -8
View File
@@ -1,6 +1,7 @@
// Portadas en HD: cada portada que se sirve (de la fuente o la cover.jpg de una descarga) se encola para // Portadas en HD: cada portada que se sirve (de la fuente o la cover.jpg de una descarga) se encola para
// reescalarla con Real-ESRGAN (×4 si es pequeña, ×2 si no). Mientras tanto se sirve la original con // reescalarla con Real-ESRGAN (×4 si es pequeña, ×2 si no). Mientras tanto se sirve la original con
// caché corta; en cuanto existe la versión HD se sirve esa. Caché en <DATA_DIR>/covers/<hash>.webp. // caché corta; en cuanto existe la versión HD se sirve esa. Caché en <DATA_DIR>/covers/<hash>.webp.
// Los banners de la ficha pasan por lo mismo, pero se reescalan siempre: se ven a todo el ancho de la pantalla.
import fs from 'node:fs' import fs from 'node:fs'
import os from 'node:os' import os from 'node:os'
import path from 'node:path' import path from 'node:path'
@@ -11,6 +12,7 @@ const MAX_QUEUE = 300
const X4_MAX_SIDE = 400 const X4_MAX_SIDE = 400
const MAX_SIDE = 1600 // más grande ya no hace falta reescalarla const MAX_SIDE = 1600 // más grande ya no hace falta reescalarla
const WEBP_QUALITY = 88 const WEBP_QUALITY = 88
const BANNER_X4_MAX_WIDTH = 1000
// Ancho/alto de un JPEG, PNG o WebP sin decodificarlo (null si no se reconoce). // Ancho/alto de un JPEG, PNG o WebP sin decodificarlo (null si no se reconoce).
export function imageSize(b) { export function imageSize(b) {
@@ -53,23 +55,26 @@ export function setupCovers({ DATA_DIR, upscale }) {
return true return true
} }
// Cabecera de caché para la original mientras no hay HD (corta, para que el navegador pida luego la HD). // Cabecera de caché para la original mientras no hay HD (corta, para que el navegador pida luego la HD).
const pendingCache = () => (upscale.available() ? 'private, max-age=300' : 'private, max-age=604800, immutable') // Un banner no se guarda: en la siguiente visita ya se ve el reescalado.
const pendingCache = (banner = false) => (!upscale.available() ? 'private, max-age=604800, immutable' : banner ? 'no-store' : 'private, max-age=300')
function enqueue(key, buf) { // ¿Falta generar esta imagen? (para no descargarla si no hace falta)
if (!upscale.available() || pending.has(key) || failed.has(key) || queue.length >= MAX_QUEUE || fs.existsSync(fileOf(key))) return const wants = key => upscale.available() && !pending.has(key) && !failed.has(key) && queue.length < MAX_QUEUE && !fs.existsSync(fileOf(key))
function enqueue(key, buf, banner = false) {
if (!wants(key)) return
pending.add(key) pending.add(key)
queue.push({ key, buf }) queue.push({ key, buf, banner })
pump() pump()
} }
async function processOne({ key, buf }) { async function processOne({ key, buf, banner }) {
const size = imageSize(buf) const size = imageSize(buf)
if (!size) throw new Error('formato no reconocido') if (!size) throw new Error('formato no reconocido')
const work = fs.mkdtempSync(path.join(os.tmpdir(), 'cover-')) const work = fs.mkdtempSync(path.join(os.tmpdir(), 'cover-'))
try { try {
const out = path.join(work, 'out.png') const out = path.join(work, 'out.png')
const tmp = `${fileOf(key)}.tmp` const tmp = `${fileOf(key)}.tmp`
if (Math.max(size.w, size.h) > MAX_SIDE) { if (!banner && Math.max(size.w, size.h) > MAX_SIDE) {
// Ya es grande: solo se recomprime para no volver a intentarlo. // Ya es grande: solo se recomprime para no volver a intentarlo.
const inp = path.join(work, `in.${size.type}`) const inp = path.join(work, `in.${size.type}`)
fs.writeFileSync(inp, buf) fs.writeFileSync(inp, buf)
@@ -78,7 +83,8 @@ export function setupCovers({ DATA_DIR, upscale }) {
} else { } else {
const inp = path.join(work, `in.${size.type}`) const inp = path.join(work, `in.${size.type}`)
fs.writeFileSync(inp, buf) fs.writeFileSync(inp, buf)
const scale = Math.max(size.w, size.h) <= X4_MAX_SIDE && fs.existsSync(path.join(MODELS, `${MODEL}-x4.bin`)) ? 4 : 2 const small = banner ? size.w < BANNER_X4_MAX_WIDTH : Math.max(size.w, size.h) <= X4_MAX_SIDE
const scale = small && fs.existsSync(path.join(MODELS, `${MODEL}-x4.bin`)) ? 4 : 2
await run(BIN, ['-i', inp, '-o', out, '-m', MODELS, '-n', MODEL, '-s', String(scale), '-f', 'png']) await run(BIN, ['-i', inp, '-o', out, '-m', MODELS, '-n', MODEL, '-s', String(scale), '-f', 'png'])
await run('cwebp', ['-quiet', '-q', String(WEBP_QUALITY), '-m', '4', '-mt', out, '-o', tmp]) await run('cwebp', ['-quiet', '-q', String(WEBP_QUALITY), '-m', '4', '-mt', out, '-o', tmp])
} }
@@ -105,5 +111,5 @@ export function setupCovers({ DATA_DIR, upscale }) {
} }
} }
return { sendCached, enqueue, pendingCache, pendingCount: () => pending.size } return { sendCached, wants, enqueue, pendingCache, pendingCount: () => pending.size }
} }
+74 -34
View File
@@ -9,8 +9,12 @@
// misma para cientos). Se toma la primera fecha conocida de cada número de capítulo en cualquier // misma para cientos). Se toma la primera fecha conocida de cada número de capítulo en cualquier
// idioma (MangaDex) o grupo (MangaUpdates): es la fecha de la primera traducción, muy cercana a la de // idioma (MangaDex) o grupo (MangaUpdates): es la fecha de la primera traducción, muy cercana a la de
// publicación en series en curso. // publicación en series en curso.
// Todo se guarda en la base de datos (si una fuente falla se usa lo último que se tenía). // Todo se guarda en la base de datos, una vez por manga y para todos los usuarios: lo guardado se sirve sin
// consultar nada y, si es antiguo, se actualiza en segundo plano (si una fuente falla, se queda lo que había).
// Los mangas seguidos por alguien se mantienen siempre al día, aunque nadie abra su ficha.
const DAY = 24 * 3600e3 const DAY = 24 * 3600e3
const INFO_TTL = v => (v?.found ? 30 * DAY : 3 * DAY)
const SYNC_EVERY = 3600e3
const ANILIST = 'https://graphql.anilist.co' const ANILIST = 'https://graphql.anilist.co'
const MU_API = 'https://api.mangaupdates.com/v1' const MU_API = 'https://api.mangaupdates.com/v1'
const MD_RATINGS = ['safe', 'suggestive', 'erotica', 'pornographic'] const MD_RATINGS = ['safe', 'suggestive', 'erotica', 'pornographic']
@@ -34,29 +38,30 @@ const numKey = n => { const f = parseFloat(n); return Number.isFinite(f) ? Strin
const toText = (html, decode) => decode(String(html || '').replace(/\r/g, '').replace(/<br\s*\/?>\s*/gi, '\n').replace(/<\/p>/gi, '\n\n').replace(/<[^>]+>/g, '')) const toText = (html, decode) => decode(String(html || '').replace(/\r/g, '').replace(/<br\s*\/?>\s*/gi, '\n').replace(/<\/p>/gi, '\n\n').replace(/<[^>]+>/g, ''))
.split('\n').map(l => l.trim()).join('\n').replace(/\n{3,}/g, '\n\n').trim() .split('\n').map(l => l.trim()).join('\n').replace(/\n{3,}/g, '\n\n').trim()
export function setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, net, decode, imgUrl, UA }) { export function setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, net, decode, imgUrl, UA, prepareBanner }) {
db.exec(`CREATE TABLE IF NOT EXISTS meta_cache (key TEXT PRIMARY KEY, value TEXT NOT NULL, at INTEGER NOT NULL)`) db.exec(`CREATE TABLE IF NOT EXISTS meta_cache (key TEXT PRIMARY KEY, value TEXT NOT NULL, at INTEGER NOT NULL)`)
// Guardado en la base de datos con caducidad; ttl puede depender del valor (p. ej. "no encontrado" dura menos). // Guardado en la base de datos con caducidad; ttl puede depender del valor (p. ej. "no encontrado" dura menos).
// Si caducó se devuelve lo guardado y se actualiza en segundo plano; con wait se espera a la actualización.
const inflight = new Map() const inflight = new Map()
function stored(key, ttl, fn) { function stored(key, ttl, fn, wait = false) {
const row = db.prepare('SELECT value, at FROM meta_cache WHERE key = ?').get(key) const row = db.prepare('SELECT value, at FROM meta_cache WHERE key = ?').get(key)
const old = row ? JSON.parse(row.value) : undefined const old = row ? JSON.parse(row.value) : undefined
const life = v => (typeof ttl === 'function' ? ttl(v) : ttl) const life = v => (typeof ttl === 'function' ? ttl(v) : ttl)
if (row && Date.now() - row.at < life(old)) return Promise.resolve(old) if (row && Date.now() - row.at < life(old)) return Promise.resolve(old)
if (inflight.has(key)) return inflight.get(key) if (!inflight.has(key)) {
const p = (async () => { inflight.set(key, (async () => {
try { try {
const v = await fn() const v = await fn(old)
db.prepare('INSERT INTO meta_cache (key, value, at) VALUES (?, ?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value, at = excluded.at').run(key, JSON.stringify(v), Date.now()) db.prepare('INSERT INTO meta_cache (key, value, at) VALUES (?, ?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value, at = excluded.at').run(key, JSON.stringify(v), Date.now())
return v return v
} catch (e) { } catch (e) {
if (old !== undefined) return old if (old !== undefined) return old
throw e throw e
} }
})().finally(() => inflight.delete(key)) })().finally(() => inflight.delete(key)))
inflight.set(key, p) }
return p return old !== undefined && !wait ? Promise.resolve(old) : inflight.get(key)
} }
// Peticiones a AniList/MangaUpdates, de una en una con una pausa (los dos limitan la frecuencia). // Peticiones a AniList/MangaUpdates, de una en una con una pausa (los dos limitan la frecuencia).
@@ -87,7 +92,7 @@ export function setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, n
} }
const mdTitles = m => [...Object.values(m.attributes.title || {}), ...(m.attributes.altTitles || []).flatMap(t => Object.values(t))] const mdTitles = m => [...Object.values(m.attributes.title || {}), ...(m.attributes.altTitles || []).flatMap(t => Object.values(t))]
async function links(mangaId) { async function links(mangaId, wait = false) {
return stored(`links:${mangaId}`, v => (v?.md || v?.al || v?.mu ? 30 * DAY : 3 * DAY), async () => { return stored(`links:${mangaId}`, v => (v?.md || v?.al || v?.mu ? 30 * DAY : 3 * DAY), async () => {
if (sourceOf(mangaId) === 'md') { if (sourceOf(mangaId) === 'md') {
const d = await md(`/manga/${mangaId}`, {}, 30 * 60e3) const d = await md(`/manga/${mangaId}`, {}, 30 * 60e3)
@@ -96,18 +101,22 @@ export function setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, n
const { manga } = await fetchMangaFull(mangaId, 'es') const { manga } = await fetchMangaFull(mangaId, 'es')
const title = manga.title const title = manga.title
// MangaDex: el que tenga ese título (o alternativo); entre varios, el que más enlaces tenga. // MangaDex: el que tenga ese título (o alternativo); entre varios, el que más enlaces tenga.
const r = await md('/manga', { title, limit: 10, 'contentRating[]': MD_RATINGS }, 6 * 3600e3).catch(() => null) // Una fuente caída no significa "no encontrado": no se guarda y se reintenta.
let failed = false
const soft = p => p.catch(() => { failed = true; return null })
const r = await soft(md('/manga', { title, limit: 10, 'contentRating[]': MD_RATINGS }, 6 * 3600e3))
const hits = (r?.data || []).filter(m => mdTitles(m).some(t => similar(t, title))) const hits = (r?.data || []).filter(m => mdTitles(m).some(t => similar(t, title)))
hits.sort((a, b) => Object.keys(b.attributes.links || {}).length - Object.keys(a.attributes.links || {}).length) hits.sort((a, b) => Object.keys(b.attributes.links || {}).length - Object.keys(a.attributes.links || {}).length)
if (hits[0]) return { ...mdLinks(hits[0]), via: 'MangaDex', title: mdTitles(hits[0])[0] } if (hits[0]) return { ...mdLinks(hits[0]), via: 'MangaDex', title: mdTitles(hits[0])[0] }
const a = await anilist(`query($s:String){Page(perPage:6){media(search:$s,type:MANGA){id idMal title{romaji english native} synonyms}}}`, { s: title }).catch(() => null) const a = await soft(anilist(`query($s:String){Page(perPage:6){media(search:$s,type:MANGA){id idMal title{romaji english native} synonyms}}}`, { s: title }))
const am = a?.Page?.media?.find(m => [m.title.romaji, m.title.english, m.title.native, ...(m.synonyms || [])].some(t => similar(t, title))) const am = a?.Page?.media?.find(m => [m.title.romaji, m.title.english, m.title.native, ...(m.synonyms || [])].some(t => similar(t, title)))
if (am) return { md: null, al: am.id, mu: null, mal: am.idMal || null, via: 'AniList', title: am.title.romaji } if (am) return { md: null, al: am.id, mu: null, mal: am.idMal || null, via: 'AniList', title: am.title.romaji }
const s = await mu('/series/search', { search: title, perpage: 8 }).catch(() => null) const s = await soft(mu('/series/search', { search: title, perpage: 8 }))
const sm = s?.results?.find(x => similar(decode(x.record.title), title) || similar(decode(x.hit_title), title)) const sm = s?.results?.find(x => similar(decode(x.record.title), title) || similar(decode(x.hit_title), title))
if (sm) return { md: null, al: null, mu: sm.record.series_id, mal: null, via: 'MangaUpdates', title: decode(sm.record.title) } if (sm) return { md: null, al: null, mu: sm.record.series_id, mal: null, via: 'MangaUpdates', title: decode(sm.record.title) }
if (failed) throw new HttpError(502, 'No se pudo identificar la obra')
return {} return {}
}) }, wait)
} }
// ---------- Ficha ---------- // ---------- Ficha ----------
@@ -124,15 +133,18 @@ export function setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, n
const date = d => (d?.year ? [d.year, d.month, d.day].filter(Boolean).map((x, i) => (i ? String(x).padStart(2, '0') : x)).join('-') : null) const date = d => (d?.year ? [d.year, d.month, d.day].filter(Boolean).map((x, i) => (i ? String(x).padStart(2, '0') : x)).join('-') : null)
const cover = u => (u ? imgUrl(u) : null) const cover = u => (u ? imgUrl(u) : null)
async function info(mangaId) { async function info(mangaId, wait = false) {
const l = await links(mangaId) const l = await links(mangaId, wait)
return stored(`info:${mangaId}`, 7 * DAY, async () => { return stored(`info:${mangaId}`, INFO_TTL, async old => {
let failed = false
const [al, ms] = await Promise.all([ const [al, ms] = await Promise.all([
l.al ? anilist(`query($id:Int){Media(id:$id,type:MANGA){${AL_FIELDS}}}`, { id: l.al }) l.al ? anilist(`query($id:Int){Media(id:$id,type:MANGA){${AL_FIELDS}}}`, { id: l.al })
: l.mal ? anilist(`query($id:Int){Media(idMal:$id,type:MANGA){${AL_FIELDS}}}`, { id: l.mal }) : null, : l.mal ? anilist(`query($id:Int){Media(idMal:$id,type:MANGA){${AL_FIELDS}}}`, { id: l.mal }) : null,
l.mu ? mu(`/series/${l.mu}`) : null, l.mu ? mu(`/series/${l.mu}`) : null,
].map(p => Promise.resolve(p).catch(e => { console.warn('Info:', e.message); return null }))) ].map(p => Promise.resolve(p).catch(e => { console.warn('Info:', e.message); failed = true; return null })))
const a = al?.Media const a = al?.Media
// Con una fuente caída no se pisa lo que había ni se da la obra por no encontrada.
if (failed && (old?.found || (!a && !ms))) throw new HttpError(502, 'No se pudo consultar la información de la obra')
if (!a && !ms) return { found: false } if (!a && !ms) return { found: false }
const staff = (a?.staff?.edges || []).filter(e => /^(Story|Art|Original)/i.test(e.role)).map(e => ({ role: e.role, name: e.node.name.full, native: e.node.name.native })) const staff = (a?.staff?.edges || []).filter(e => /^(Story|Art|Original)/i.test(e.role)).map(e => ({ role: e.role, name: e.node.name.full, native: e.node.name.native }))
const muAuthors = (ms?.authors || []).map(x => ({ role: x.type === 'Artist' ? 'Art' : 'Story', name: decode(x.name) })) const muAuthors = (ms?.authors || []).map(x => ({ role: x.type === 'Artist' ? 'Art' : 'Story', name: decode(x.name) }))
@@ -181,14 +193,24 @@ export function setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, n
].filter(x => x && /^https?:\/\//.test(x.url)).slice(0, 14), ].filter(x => x && /^https?:\/\//.test(x.url)).slice(0, 14),
matched: { via: l.via || null, title: l.title || null }, matched: { via: l.via || null, title: l.title || null },
} }
}) }, wait)
}
// La ficha guardada, sin esperar a ninguna fuente (null si aún no hay). Si caducó, se actualiza por detrás.
function cachedInfo(mangaId) {
const row = db.prepare('SELECT value FROM meta_cache WHERE key = ?').get(`info:${mangaId}`)
if (!row) return null
info(mangaId).catch(() => {})
return JSON.parse(row.value)
} }
// ---------- Fechas de los capítulos ---------- // ---------- Fechas de los capítulos ----------
async function dates(mangaId) { async function dates(mangaId, wait = false) {
const l = await links(mangaId) const l = await links(mangaId, wait)
if (!l.md && !l.mu) return { dates: {}, via: [] } if (!l.md && !l.mu) return { dates: {}, via: [] }
return stored(`dates:${mangaId}`, DAY, async () => { return stored(`dates:${mangaId}`, DAY, async old => {
let failed = false
const soft = p => p.catch(() => { failed = true; return null })
const out = {} const out = {}
const via = [] const via = []
const add = (n, d) => { const add = (n, d) => {
@@ -200,10 +222,10 @@ export function setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, n
if (l.md) { if (l.md) {
let n = 0 let n = 0
for (let offset = 0; offset < 10000; offset += 500) { for (let offset = 0; offset < 10000; offset += 500) {
const d = await md(`/manga/${l.md}/feed`, { const d = await soft(md(`/manga/${l.md}/feed`, {
limit: 500, offset, 'order[chapter]': 'asc', 'contentRating[]': MD_RATINGS, limit: 500, offset, 'order[chapter]': 'asc', 'contentRating[]': MD_RATINGS,
includeExternalUrl: 1, includeEmptyPages: 1, includeFuturePublishAt: 0, includeUnavailable: 1, includeExternalUrl: 1, includeEmptyPages: 1, includeFuturePublishAt: 0, includeUnavailable: 1,
}, 3600e3).catch(() => null) }, 3600e3))
for (const c of d?.data || []) { add(c.attributes.chapter, c.attributes.publishAt || c.attributes.createdAt); n++ } for (const c of d?.data || []) { add(c.attributes.chapter, c.attributes.publishAt || c.attributes.createdAt); n++ }
if (!d || offset + 500 >= d.total) break if (!d || offset + 500 >= d.total) break
} }
@@ -212,7 +234,7 @@ export function setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, n
if (l.mu) { if (l.mu) {
let n = 0 let n = 0
for (let page = 1; page <= 40; page++) { for (let page = 1; page <= 40; page++) {
const r = await mu('/releases/search', { search: String(l.mu), search_type: 'series', perpage: 100, page, orderby: 'date', asc: 'asc' }).catch(() => null) const r = await soft(mu('/releases/search', { search: String(l.mu), search_type: 'series', perpage: 100, page, orderby: 'date', asc: 'asc' }))
for (const x of r?.results || []) { for (const x of r?.results || []) {
const ch = String(x.record.chapter || '') const ch = String(x.record.chapter || '')
const d = x.record.release_date const d = x.record.release_date
@@ -229,23 +251,41 @@ export function setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, n
} }
if (n) via.push('MangaUpdates') if (n) via.push('MangaUpdates')
} }
if (failed && (old?.via?.length || !Object.keys(out).length)) throw new HttpError(502, 'No se pudieron consultar las fechas')
return { dates: out, via } return { dates: out, via }
}) }, wait)
} }
// ---------- Mangas seguidos: siempre con su información ----------
// Cada hora se completa lo que falte o haya caducado, de uno en uno (también lo seguido al empezar a leer),
// y se reescalan los banners guardados que aún no lo estén.
let syncing = false
async function syncFollowed() {
if (syncing || !net.online()) return
syncing = true
try {
for (const { manga_id: id } of db.prepare('SELECT DISTINCT manga_id FROM follows WHERE active = 1').all()) {
if (!net.online()) break
await info(id, true).catch(e => console.warn('Info de seguidos', id, e.message))
await dates(id, true).catch(e => console.warn('Fechas de seguidos', id, e.message))
}
for (const { value } of db.prepare("SELECT value FROM meta_cache WHERE key LIKE 'info:%'").all()) {
const { banner } = JSON.parse(value)
if (banner) await prepareBanner(banner).catch(e => console.warn('Banner', e.message))
}
} finally { syncing = false }
}
setTimeout(syncFollowed, 60e3).unref()
setInterval(syncFollowed, SYNC_EVERY).unref()
net.whenOnline(syncFollowed)
async function route(p, method, res) { async function route(p, method, res) {
const m = /^\/api\/meta\/([^/]+)(\/dates)?$/.exec(p) const m = /^\/api\/meta\/([^/]+)(\/dates)?$/.exec(p)
if (!m || method !== 'GET') return false if (!m || method !== 'GET') return false
const id = decodeURIComponent(m[1]) const id = decodeURIComponent(m[1])
if (!net.online()) throw new HttpError(503, 'El servidor no tiene conexión a internet: la información extra no está disponible.') json(res, 200, await (m[2] ? dates(id) : info(id)))
if (m[2]) json(res, 200, await dates(id))
else {
// La ficha de la fuente (título, sinopsis en español, portada) va aparte de lo externo.
const [src, ext] = await Promise.all([fetchMangaFull(id, 'es').then(r => r.manga, () => null), info(id)])
json(res, 200, { ...ext, source: src && { title: src.title, description: src.description, cover: src.cover, authors: src.authors, source: src.source } })
}
return true return true
} }
return { route, links, info, dates } return { route, links, info, cachedInfo, dates }
} }
+112 -67
View File
@@ -44,7 +44,6 @@ const ICONS = {
phone: '<rect x="6" y="2" width="12" height="20" rx="2.5"/><path d="M11 18h2"/>', phone: '<rect x="6" y="2" width="12" height="20" rx="2.5"/><path d="M11 18h2"/>',
plus: '<path d="M12 5v14M5 12h14"/>', plus: '<path d="M12 5v14M5 12h14"/>',
pencil: '<path d="M4 20h4L19 9l-4-4L4 16z"/><path d="m13.5 6.5 4 4"/>', pencil: '<path d="M4 20h4L19 9l-4-4L4 16z"/><path d="m13.5 6.5 4 4"/>',
info: '<circle cx="12" cy="12" r="9"/><path d="M12 11v6"/><path d="M12 7.5h.01"/>',
sparkles: '<path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8z"/><path d="M19 15l.8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8z"/>', sparkles: '<path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8z"/><path d="M19 15l.8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8z"/>',
} }
const icon = name => `<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${ICONS[name]}</svg>` const icon = name => `<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${ICONS[name]}</svg>`
@@ -368,7 +367,7 @@ async function router() {
try { try {
if (parts[0] === 'login') return (location.hash = '#/') if (parts[0] === 'login') return (location.hash = '#/')
if (parts[0] === 'manga') return await viewManga(parts[1]) if (parts[0] === 'manga') return await viewManga(parts[1])
if (parts[0] === 'info') return await viewInfo(parts[1]) if (parts[0] === 'info') return (location.hash = `#/manga/${parts[1]}`) // la ficha ya incluye la info
if (parts[0] === 'read') return await viewReader(parts[1], parts[2], Number(params.get('page') || 0) - 1) if (parts[0] === 'read') return await viewReader(parts[1], parts[2], Number(params.get('page') || 0) - 1)
if (parts[0] === 'history') return (location.hash = '#/library') // antiguo nombre if (parts[0] === 'history') return (location.hash = '#/library') // antiguo nombre
if (parts[0] === 'library') return await viewLibrary(params) if (parts[0] === 'library') return await viewLibrary(params)
@@ -588,7 +587,7 @@ async function viewHome(params) {
} }
// ---------- Ficha del manga ---------- // ---------- Ficha del manga ----------
// ---------- Info: datos de la obra de AniList y MangaUpdates ---------- // ---------- Datos de la obra de AniList y MangaUpdates (parte de la ficha) ----------
const AL_FORMAT = { MANGA: 'Manga', NOVEL: 'Novela ligera', ONE_SHOT: 'Oneshot', Manga: 'Manga', Manhwa: 'Manhwa', Manhua: 'Manhua' } const AL_FORMAT = { MANGA: 'Manga', NOVEL: 'Novela ligera', ONE_SHOT: 'Oneshot', Manga: 'Manga', Manhwa: 'Manhwa', Manhua: 'Manhua' }
const AL_STATUS = { FINISHED: 'Terminado', RELEASING: 'En publicación', NOT_YET_RELEASED: 'Sin estrenar', CANCELLED: 'Cancelado', HIATUS: 'En pausa' } const AL_STATUS = { FINISHED: 'Terminado', RELEASING: 'En publicación', NOT_YET_RELEASED: 'Sin estrenar', CANCELLED: 'Cancelado', HIATUS: 'En pausa' }
const AL_COUNTRY = { JP: 'Japón', KR: 'Corea del Sur', CN: 'China', TW: 'Taiwán' } const AL_COUNTRY = { JP: 'Japón', KR: 'Corea del Sur', CN: 'China', TW: 'Taiwán' }
@@ -597,74 +596,55 @@ const AL_REL = { SEQUEL: 'Secuela', PREQUEL: 'Precuela', SIDE_STORY: 'Historia p
const AL_ANIME = { TV: 'Serie TV', TV_SHORT: 'Serie corta', MOVIE: 'Película', OVA: 'OVA', ONA: 'ONA', SPECIAL: 'Especial', MUSIC: 'Vídeo musical' } const AL_ANIME = { TV: 'Serie TV', TV_SHORT: 'Serie corta', MOVIE: 'Película', OVA: 'OVA', ONA: 'ONA', SPECIAL: 'Especial', MUSIC: 'Vídeo musical' }
const fmtFuzzy = d => (!d ? '' : d.length === 4 ? d : d.length === 7 ? new Date(`${d}-15T12:00:00`).toLocaleDateString('es', { month: 'long', year: 'numeric' }) : fmtDate(d)) const fmtFuzzy = d => (!d ? '' : d.length === 4 ? d : d.length === 7 ? new Date(`${d}-15T12:00:00`).toLocaleDateString('es', { month: 'long', year: 'numeric' }) : fmtDate(d))
async function viewInfo(id) { // Lo que la ficha pinta a partir de la información guardada de la obra (d.found).
if (cleanup) { cleanup(); cleanup = null } function infoParts(d, manga) {
app.innerHTML = '<div class="loading">Buscando información de la obra…</div>'
const d = await api(`/api/meta/${id}`)
const src = d.source || {}
const back = `<a class="btn small" href="#/manga/${id}">${icon('back')} Volver</a>`
if (!d.found) {
app.innerHTML = `<div class="info-page">${back}<h1>${esc(src.title || '')}</h1>
<p class="muted">No se encontró esta obra en AniList ni en MangaUpdates. Puede tener otro título allí: prueba a buscarla en
<a class="tag" href="https://anilist.co/search/manga?search=${encodeURIComponent(src.title || '')}" target="_blank" rel="noopener">AniList</a>.</p></div>`
return
}
const row = (k, v) => (v ? `<dt>${k}</dt><dd>${v}</dd>` : '') const row = (k, v) => (v ? `<dt>${k}</dt><dd>${v}</dd>` : '')
const list = a => a.map(esc).join(', ')
const t = d.titles const t = d.titles
const people = d.staff.reduce((m, p) => m.set(p.name, [...(m.get(p.name) || []), AL_ROLE(p.role)]), new Map()) const people = d.staff.reduce((m, p) => m.set(p.name, [...(m.get(p.name) || []), AL_ROLE(p.role)]), new Map())
const dates = d.start ? `${fmtFuzzy(d.start)}${d.end ? ` – ${fmtFuzzy(d.end)}` : d.status === 'RELEASING' ? ' – actualidad' : ''}` : '' const dates = d.start ? `${fmtFuzzy(d.start)}${d.end ? ` – ${fmtFuzzy(d.end)}` : d.status === 'RELEASING' ? ' – actualidad' : ''}` : ''
const size = [d.volumes ? `${d.volumes} tomos` : '', d.chapters ? `${d.chapters} capítulos` : d.latestChapter ? `${d.latestChapter} capítulos (hasta ahora)` : ''].filter(Boolean).join(' · ') const size = [d.volumes ? `${d.volumes} tomos` : '', d.chapters ? `${d.chapters} capítulos` : d.latestChapter ? `${d.latestChapter} capítulos (hasta ahora)` : ''].filter(Boolean).join(' · ')
const ranks = d.rankings.map(r => `#${r.rank} ${r.type === 'RATED' ? 'mejor valorado' : 'más popular'}${r.allTime ? ' de siempre' : ` de ${r.year}`}`) const ranks = d.rankings.map(r => `#${r.rank} ${r.type === 'RATED' ? 'mejor valorado' : 'más popular'}${r.allTime ? ' de siempre' : ` de ${r.year}`}`)
const search = title => `#/?q=${encodeURIComponent(title)}` const cardOf = r => `<a class="card" href="#/?q=${encodeURIComponent(r.title)}" title="Buscar «${esc(r.title)}»">
const cardOf = r => `<a class="card" href="${search(r.title)}" title="Buscar «${esc(r.title)}»">
<img class="cover" loading="lazy" src="${esc(r.cover || '/icon.svg')}" alt="" data-fallback> <img class="cover" loading="lazy" src="${esc(r.cover || '/icon.svg')}" alt="" data-fallback>
<div class="t">${esc(r.title)}</div><div class="sub">${esc([r.relation ? AL_REL[r.relation] || r.relation : '', r.year || '', r.score ? `${r.score}%` : ''].filter(Boolean).join(' · '))}</div></a>` <div class="t">${esc(r.title)}</div><div class="sub">${esc([r.relation ? AL_REL[r.relation] || r.relation : '', r.year || '', r.score ? `${r.score}%` : ''].filter(Boolean).join(' · '))}</div></a>`
const srcDesc = (src.description || '').trim() const seen = new Set([manga.title.toLowerCase()])
app.innerHTML = ` const altTitles = [t.english, t.romaji, t.native].filter(x => x && !seen.has(x.toLowerCase()) && seen.add(x.toLowerCase()))
<div class="info-page"> const srcDesc = (manga.description || '').trim()
${d.banner ? `<div class="banner" style="background-image:url('${esc(d.banner)}')"></div>` : ''} return {
<div class="row info-top">${back}</div> banner: d.banner,
<div class="detail"> sub: altTitles.join(' · '),
<img class="cover" src="${esc(d.cover || src.cover || '/icon.svg')}" alt="" data-fallback> scores: `<div class="scores">
<div class="detail-body">
<h1>${esc(t.english || t.romaji || src.title)}</h1>
<div class="muted">${esc([t.romaji !== (t.english || t.romaji) ? t.romaji : '', t.native].filter(Boolean).join(' · '))}</div>
<div class="scores">
${d.score != null ? `<div class="score"><b>${d.score}%</b><span>AniList</span></div>` : ''} ${d.score != null ? `<div class="score"><b>${d.score}%</b><span>AniList</span></div>` : ''}
${d.muScore != null ? `<div class="score"><b>${Number(d.muScore).toFixed(1)}</b><span>MangaUpdates${d.muVotes ? ` · ${fmtNum(d.muVotes)} votos` : ''}</span></div>` : ''} ${d.muScore != null ? `<div class="score"><b>${Number(d.muScore).toFixed(1)}</b><span>MangaUpdates${d.muVotes ? ` · ${fmtNum(d.muVotes)} votos` : ''}</span></div>` : ''}
${d.popularity ? `<div class="score"><b>${fmtNum(d.popularity)}</b><span>lectores en AniList</span></div>` : ''} ${d.popularity ? `<div class="score"><b>${fmtNum(d.popularity)}</b><span>lectores en AniList</span></div>` : ''}
</div> </div>
${ranks.length ? `<div class="tags">${ranks.map(r => `<span class="tag hot">${esc(r)}</span>`).join('')}</div>` : ''} ${ranks.length ? `<div class="tags">${ranks.map(r => `<span class="tag hot">${esc(r)}</span>`).join('')}</div>` : ''}`,
<dl class="kv"> kv: `${people.size ? [...people].map(([name, roles]) => row(esc(roles.join(' y ')), esc(name))).join('') : row('Autor', esc(manga.authors.join(', ')))}
${[...people].map(([name, roles]) => row(esc(roles.join(' y ')), esc(name))).join('')}
${row('Revista', d.magazines.map(m => esc(m.name) + (m.publisher ? ` <span class="muted">(${esc(m.publisher)})</span>` : '')).join(', '))} ${row('Revista', d.magazines.map(m => esc(m.name) + (m.publisher ? ` <span class="muted">(${esc(m.publisher)})</span>` : '')).join(', '))}
${row('Editorial', d.publishers.filter(p => p.type === 'Original').map(p => esc(p.name)).join(', '))} ${row('Editorial', d.publishers.filter(p => p.type === 'Original').map(p => esc(p.name)).join(', '))}
${row('En inglés', d.publishers.filter(p => p.type === 'English').map(p => esc(p.name)).join(', '))} ${row('En inglés', d.publishers.filter(p => p.type === 'English').map(p => esc(p.name)).join(', '))}
${row('Tipo', esc([AL_FORMAT[d.format] || d.format, AL_COUNTRY[d.country]].filter(Boolean).join(' · ')))} ${row('Tipo', esc([AL_FORMAT[d.format] || d.format, AL_COUNTRY[d.country]].filter(Boolean).join(' · ')))}
${row('Publicación', esc(dates))} ${row('Publicación', esc(dates || manga.year || ''))}
${row('Estado', esc([AL_STATUS[d.status], d.statusDetail].filter(Boolean).join(' · ')))} ${row('Estado', esc([AL_STATUS[d.status], d.statusDetail].filter(Boolean).join(' · ') || manga.status || ''))}
${row('Extensión', esc(size))} ${row('Extensión', esc(size))}
${row('Anime', [...d.adaptations.map(a => esc(`${AL_ANIME[a.format] || a.format || 'Anime'}${a.year ? ` (${a.year})` : ''}`)), d.anime ? `<span class="muted">adapta: ${esc(d.anime.start)}${d.anime.end ? ` → ${esc(d.anime.end)}` : ''}</span>` : ''].filter(Boolean).join('<br>'))} ${row('Anime', [...d.adaptations.map(a => esc(`${AL_ANIME[a.format] || a.format || 'Anime'}${a.year ? ` (${a.year})` : ''}`)), d.anime ? `<span class="muted">adapta: ${esc(d.anime.start)}${d.anime.end ? ` → ${esc(d.anime.end)}` : ''}</span>` : ''].filter(Boolean).join('<br>'))}`,
</dl> genres: d.genres.map(g => `<span class="tag">${esc(g)}</span>`).join(''),
<div class="tags">${d.genres.map(g => `<span class="tag">${esc(g)}</span>`).join('')}${d.tags.map(g => `<span class="tag soft">${esc(g)}</span>`).join('')}</div> softTags: d.tags.map(g => `<span class="tag soft">${esc(g)}</span>`).join(''),
</div> synopsis: `${srcDesc ? `<h2>Sinopsis</h2><p class="desc long">${esc(srcDesc)}</p>` : ''}
</div> ${d.description ? (srcDesc ? `<details class="more"><summary>Sinopsis de AniList (en inglés)</summary><p class="desc long">${esc(d.description)}</p></details>` : `<h2>Sinopsis <span class="muted">(en inglés)</span></h2><p class="desc long">${esc(d.description)}</p>`) : ''}`,
${srcDesc ? `<h2>Sinopsis</h2><p class="desc long">${esc(srcDesc)}</p>` : ''} extra: `${t.other.length ? `<h2>Otros títulos</h2><p class="muted">${t.other.map(esc).join(', ')}</p>` : ''}
${d.description ? (srcDesc ? `<details class="more"><summary>Sinopsis de AniList (en inglés)</summary><p class="desc long">${esc(d.description)}</p></details>` : `<h2>Sinopsis <span class="muted">(en inglés)</span></h2><p class="desc long">${esc(d.description)}</p>`) : ''} ${d.related.length ? `<h2>Obras relacionadas</h2><div class="hrow">${d.related.map(cardOf).join('')}</div>` : ''}
${t.other.length ? `<h2>Otros títulos</h2><p class="muted">${list(t.other)}</p>` : ''} ${d.recommendations.length ? `<h2>Si te gusta, prueba</h2><div class="hrow">${d.recommendations.map(cardOf).join('')}</div>` : ''}
${d.related.length ? `<h2>Obras relacionadas</h2><div class="grid">${d.related.map(cardOf).join('')}</div>` : ''}
${d.recommendations.length ? `<h2>Si te gusta, prueba</h2><div class="grid">${d.recommendations.map(cardOf).join('')}</div>` : ''}
${d.links.length ? `<h2>Enlaces</h2><div class="tags">${d.links.map(l => `<a class="tag" href="${esc(l.url)}" target="_blank" rel="noopener noreferrer">${esc(l.site)}</a>`).join('')}</div>` : ''} ${d.links.length ? `<h2>Enlaces</h2><div class="tags">${d.links.map(l => `<a class="tag" href="${esc(l.url)}" target="_blank" rel="noopener noreferrer">${esc(l.site)}</a>`).join('')}</div>` : ''}
<p class="muted note">Datos de AniList y MangaUpdates${d.matched?.title ? ` (identificado por título como «${esc(d.matched.title)}»)` : ''}. Las fechas de los capítulos son las de la primera traducción conocida.</p> <p class="muted note">Datos de AniList y MangaUpdates${d.matched?.title ? ` (identificado por título como «${esc(d.matched.title)}»)` : ''}. Las fechas de los capítulos son las de la primera traducción conocida.</p>`,
</div>` }
} }
async function viewManga(id, langOverride) { async function viewManga(id, langOverride) {
if (cleanup) { cleanup(); cleanup = null } if (cleanup) { cleanup(); cleanup = null }
app.innerHTML = '<div class="loading">Cargando…</div>' app.innerHTML = '<div class="loading">Cargando…</div>'
const lang = langOverride || store.get(`lang:${id}`, me.lang) const lang = langOverride || store.get(`lang:${id}`, me.lang)
const { manga, chapters, reads, progress, chapterLangs, offline, downloaded, download, upscale, following, clips: clipCount = 0, lists: inLists = [], newKeys = [] } = await api(`/api/manga/${id}?lang=${encodeURIComponent(lang)}`) const { manga, chapters, reads, progress, chapterLangs, offline, downloaded, download, upscale, following, clips: clipCount = 0, lists: inLists = [], newKeys = [], info: savedInfo = null, clipBanner = null } = await api(`/api/manga/${id}?lang=${encodeURIComponent(lang)}`)
const dlSet = new Set(downloaded) const dlSet = new Set(downloaded)
const newSet = new Set(newKeys.map(String)) const newSet = new Set(newKeys.map(String))
let isFollowing = following let isFollowing = following
@@ -683,30 +663,43 @@ async function viewManga(id, langOverride) {
if (first && !mainBtn) mainBtn = `<a class="btn primary" href="${readUrl(id, first.id)}">${icon('book')} Empezar a leer</a>` if (first && !mainBtn) mainBtn = `<a class="btn primary" href="${readUrl(id, first.id)}">${icon('book')} Empezar a leer</a>`
const menuItems = [ const menuItems = [
manga.source === 'lc' ? '' : `<a href="${altSearch}">${icon('search')} Buscar en LeerCapitulo</a>`, manga.source === 'lc' ? '' : `<a class="btn" href="${altSearch}">${icon('search')} Buscar en LeerCapitulo</a>`,
manga.source === 'mi' ? '' : `<a href="${altSearchMi}">${icon('search')} Buscar en M440</a>`, manga.source === 'mi' ? '' : `<a class="btn" href="${altSearchMi}">${icon('search')} Buscar en M440</a>`,
progress || reads.length ? `<button id="drop" class="danger">${icon('stopRead')} Dejar de leer…</button>` : '', progress || reads.length ? `<button id="drop" class="btn danger">${icon('stopRead')} Dejar de leer…</button>` : '',
].join('') ].join('')
const srcTags = manga.tags.map(t => (t.id ? `<a class="tag" href="${tagHref(manga.source, t)}">${esc(t.name)}</a>` : `<span class="tag">${esc(t.name ?? t)}</span>`)).join('')
const srcSub = `${esc(manga.authors.join(', '))}${manga.year ? ` · ${manga.year}` : ''}${manga.status ? ` · ${esc(manga.status)}` : ''}`
app.innerHTML = ` app.innerHTML = `
<div class="banner" id="mbanner" hidden></div>
<div class="detail"> <div class="detail">
<img class="cover" src="${esc(manga.cover || '/icon.svg')}" alt=""> <img class="cover" id="mcover" src="${esc(manga.cover || '/icon.svg')}" alt="">
<div class="detail-body"> <div class="detail-body">
<h1>${esc(manga.title)}</h1> <h1>${esc(manga.title)}</h1>
<div class="muted">${esc(manga.authors.join(', '))}${manga.year ? ` · ${manga.year}` : ''}${manga.status ? ` · ${esc(manga.status)}` : ''}</div> <div class="muted" id="msub">${srcSub}</div>
<div class="tags">${manga.tags.map(t => (t.id ? `<a class="tag" href="${tagHref(manga.source, t)}">${esc(t.name)}</a>` : `<span class="tag">${esc(t.name ?? t)}</span>`)).join('')}</div> <div id="mscores"></div>
<p class="desc">${esc(manga.description)}</p>
<div class="actions">${mainBtn} <div class="actions">${mainBtn}
<button class="btn" id="follow"></button> ${chapters.length ? `<button class="btn" id="tochap" title="Ir a la lista de capítulos">${icon('queue')} Capítulos</button>` : ''}
${offline ? '' : `<a class="btn" href="#/info/${id}" title="Autor, revista, puntuación, adaptaciones…">${icon('info')} Info</a>`} <button class="btn" id="libbtn" title="Más opciones" aria-label="Más opciones"></button>
${offline ? '' : `<details class="menu" id="lmenu"><summary class="btn" id="lbtn" title="Añadir a tus listas">${icon('bookmark')} Listas</summary><div class="menu-list" id="lmlist"><div class="muted note">Cargando…</div></div></details>`}
${menuItems ? `<details class="menu"><summary class="btn" title="Más opciones" aria-label="Más opciones">${icon('dots')}</summary><div class="menu-list">${menuItems}</div></details>` : ''}
</div> </div>
<div class="facts muted"><span>Fuente: ${esc(SOURCES[manga.source])}</span>${download ? `<span>Carpeta: mangas/${esc(download.folder)}</span>` : ''}${clipCount ? `<a href="#/clips?manga=${encodeURIComponent(id)}">${icon('scissors')} ${clipCount} recorte${clipCount > 1 ? 's' : ''}</a>` : ''}</div>
${offline ? '<p class="error">Sin conexión con la fuente: mostrando solo los capítulos descargados.</p>' : ''} ${offline ? '<p class="error">Sin conexión con la fuente: mostrando solo los capítulos descargados.</p>' : ''}
<div class="info-block" id="msyn"></div>
<div class="tags" id="mtags"></div>
</div>
</div>
<div class="info-block" id="mextra"></div>
<div class="drawer-back" id="libback" hidden></div>
<aside class="drawer" id="libdrawer" aria-label="Más opciones" inert>
<div class="drawer-head"><b>Opciones</b><button class="btn small" id="libclose" title="Cerrar" aria-label="Cerrar">${icon('x')}</button></div>
<div class="drawer-opts">
<button class="btn" id="follow"></button>
${offline ? '' : `<details class="menu" id="lmenu"><summary class="btn" id="lbtn" title="Añadir a tus listas">${icon('bookmark')} Listas</summary><div class="menu-list" id="lmlist"><div class="muted note">Cargando…</div></div></details>`}
${clipCount ? `<a class="btn" href="#/clips?manga=${encodeURIComponent(id)}">${icon('scissors')} ${clipCount} recorte${clipCount > 1 ? 's' : ''}</a>` : ''}
${menuItems}
</div>
<div class="libpanel"><div class="psec" id="dl"></div><div class="psec" id="hd" hidden></div><div class="psec" id="off" hidden></div></div> <div class="libpanel"><div class="psec" id="dl"></div><div class="psec" id="hd" hidden></div><div class="psec" id="off" hidden></div></div>
</div> <div class="libpanel" id="mdata"><div class="psec"><div class="psec-title">${icon('book')} Datos de la obra</div><dl class="kv" id="mkv"></dl></div></div>
</div> </aside>
<div class="list-head"> <div class="list-head">
<h2>Capítulos <span class="muted">(${chapters.length})</span>${newSet.size ? ` <span class="badge">${newSet.size} ${newSet.size === 1 ? 'nuevo' : 'nuevos'}</span>` : ''}</h2> <h2>Capítulos <span class="muted">(${chapters.length})</span>${newSet.size ? ` <span class="badge">${newSet.size} ${newSet.size === 1 ? 'nuevo' : 'nuevos'}</span>` : ''}</h2>
<div class="row"> <div class="row">
@@ -719,6 +712,54 @@ async function viewManga(id, langOverride) {
${chapterLangs.length ? `Disponibles en: ${chapterLangs.map(l => `<a class="tag" href="#" data-lang="${esc(l)}">${esc(LANGS[l] || l)}</a>`).join(' ')}` : 'No hay capítulos en ningún idioma.'}</p>`} ${chapterLangs.length ? `Disponibles en: ${chapterLangs.map(l => `<a class="tag" href="#" data-lang="${esc(l)}">${esc(LANGS[l] || l)}</a>`).join(' ')}` : 'No hay capítulos en ningún idioma.'}</p>`}
<div id="selbar" hidden></div>` <div id="selbar" hidden></div>`
// --- Información de la obra: la guardada llega con la ficha; si aún no hay, se pide y se pinta al llegar.
const synBox = $('#msyn')
const paintInfo = d => {
if (!synBox.isConnected) return // ya se cambió de página
const parts = d?.found ? infoParts(d, manga) : null
const banner = $('#mbanner')
// El de AniList (&b=1: reescalado) o, si no tiene, uno de tus recortes; sin ninguno, nada.
const bannerUrl = parts?.banner ? `${parts.banner}&b=1` : clipBanner
banner.hidden = !bannerUrl
banner.style.backgroundImage = bannerUrl ? `url("${bannerUrl}")` : ''
if (!manga.cover && d?.cover) $('#mcover').src = d.cover
$('#msub').innerHTML = parts ? esc(parts.sub) : srcSub
$('#mscores').innerHTML = parts?.scores || ''
const folder = download ? `<dt>Carpeta</dt><dd>mangas/${esc(download.folder)}</dd>` : ''
$('#mkv').innerHTML = (parts?.kv || '') + folder
$('#mdata').hidden = !parts && !folder
$('#mtags').innerHTML = (srcTags || parts?.genres || '') + (parts?.softTags || '')
const srcDesc = (manga.description || '').trim()
synBox.innerHTML = parts?.synopsis || (srcDesc ? `<h2>Sinopsis</h2><p class="desc long">${esc(srcDesc)}</p>` : '')
$('#mextra').innerHTML = parts?.extra || (d ? `<p class="muted note">No se encontró esta obra en AniList ni en MangaUpdates. Puede tener otro título allí: prueba a buscarla en
<a class="tag" href="https://anilist.co/search/manga?search=${encodeURIComponent(manga.title)}" target="_blank" rel="noopener">AniList</a>.</p>` : '')
}
paintInfo(savedInfo)
if (!savedInfo) {
$('#mextra').innerHTML = '<p class="muted note">Buscando información de la obra…</p>'
api(`/api/meta/${id}`).then(paintInfo, () => { if (synBox.isConnected) $('#mextra').innerHTML = '' })
}
// --- Menú lateral (botón ⋯): seguir, listas, recortes, otras fuentes, copia local, reescalado HD, este dispositivo y datos de la obra.
const openLib = open => {
$('#libdrawer').classList.toggle('open', open)
$('#libdrawer').inert = !open
$('#libback').hidden = !open
}
// El ⋯ avisa con un punto si hay algo en curso (descarga, reescalado o guardado en el dispositivo).
const libBusy = {}
const markLib = (kind, busy) => {
libBusy[kind] = busy
$('#libbtn').innerHTML = `${icon('dots')}${Object.values(libBusy).some(Boolean) ? ' <span class="libbusy" title="En curso"></span>' : ''}`
}
const toChapters = () => $('.list-head')?.scrollIntoView({ behavior: 'smooth' })
$('#libbtn').addEventListener('click', () => openLib(true))
$('#libclose').addEventListener('click', () => openLib(false))
$('#libback').addEventListener('click', () => openLib(false))
$('#tochap')?.addEventListener('click', toChapters)
const onLibKey = e => { if (e.key === 'Escape') openLib(false) }
document.addEventListener('keydown', onLibKey)
const secHead = (ic, title, status) => `<div class="psec-head"><div class="psec-title">${icon(ic)} ${title}</div>${status ? `<div class="psec-status muted">${status}</div>` : ''}</div>` const secHead = (ic, title, status) => `<div class="psec-head"><div class="psec-title">${icon(ic)} ${title}</div>${status ? `<div class="psec-status muted">${status}</div>` : ''}</div>`
// --- Selector de capítulos compartido por "Copia local" (kind 'dl') y "Reescalado HD" (kind 'hd'): // --- Selector de capítulos compartido por "Copia local" (kind 'dl') y "Reescalado HD" (kind 'hd'):
@@ -823,6 +864,7 @@ async function viewManga(id, langOverride) {
if (prevKind && prevKind !== next?.kind) renderSelForm(prevKind) // cerrar el otro formulario if (prevKind && prevKind !== next?.kind) renderSelForm(prevKind) // cerrar el otro formulario
if (next) renderSelForm(next.kind) if (next) renderSelForm(next.kind)
if (wasPick !== (next?.mode === 'pick') || (wasPick && prevKind !== next?.kind)) renderList() if (wasPick !== (next?.mode === 'pick') || (wasPick && prevKind !== next?.kind)) renderList()
if (next?.mode === 'pick' && !wasPick) { openLib(false); toChapters() } // se marca en la lista: hay que verla
$('#dlopen')?.classList.toggle('on', sel?.kind === 'dl') $('#dlopen')?.classList.toggle('on', sel?.kind === 'dl')
$('#hdopen')?.classList.toggle('on', sel?.kind === 'hd') $('#hdopen')?.classList.toggle('on', sel?.kind === 'hd')
$('#offopen')?.classList.toggle('on', sel?.kind === 'off') $('#offopen')?.classList.toggle('on', sel?.kind === 'off')
@@ -867,6 +909,7 @@ async function viewManga(id, langOverride) {
const box = $('#dl') const box = $('#dl')
if (!box) return if (!box) return
const active = d && DL_ACTIVE.includes(d.status) const active = d && DL_ACTIVE.includes(d.status)
markLib('dl', !!active)
const some = d?.scope === 'some' const some = d?.scope === 'some'
const partial = d && d.done < d.total const partial = d && d.done < d.total
const bar = d && (active || partial) ? `<div class="bar dlbar"><i style="width:${downloadPct(d).toFixed(1)}%"></i></div>` : '' const bar = d && (active || partial) ? `<div class="bar dlbar"><i style="width:${downloadPct(d).toFixed(1)}%"></i></div>` : ''
@@ -941,6 +984,7 @@ async function viewManga(id, langOverride) {
hdInfo = u hdInfo = u
const show = !!(u?.available && u.downloaded && !offline) const show = !!(u?.available && u.downloaded && !offline)
box.hidden = !show box.hidden = !show
markLib('hd', show && (!!u.current || Object.values(u.jobs).some(s => s !== 'error')))
if (show) { if (show) {
const queued = Object.values(u.jobs).filter(s => s !== 'error').length const queued = Object.values(u.jobs).filter(s => s !== 'error').length
const active = !!u.current || queued > 0 const active = !!u.current || queued > 0
@@ -991,6 +1035,7 @@ async function viewManga(id, langOverride) {
const bytes = mine.reduce((n, [, c]) => n + (c.bytes || 0), 0) const bytes = mine.reduce((n, [, c]) => n + (c.bytes || 0), 0)
const run = offState.running?.mangaId === id ? offState.running : null const run = offState.running?.mangaId === id ? offState.running : null
const queued = offState.queue.filter(q => q.mangaId === id).length const queued = offState.queue.filter(q => q.mangaId === id).length
markLib('off', !!(run || queued))
box.hidden = false box.hidden = false
// Siguientes 3 sin guardar a partir de donde vas (o desde el principio). // Siguientes 3 sin guardar a partir de donde vas (o desde el principio).
const from = Math.max(0, chapters.findIndex(c => c.id === progress?.chapter_id)) const from = Math.max(0, chapters.findIndex(c => c.id === progress?.chapter_id))
@@ -1088,10 +1133,10 @@ async function viewManga(id, langOverride) {
const back = prevHash && !prevHash.startsWith('#/read/') && !prevHash.startsWith(`#/manga/${id}`) && prevHash !== '#/login' const back = prevHash && !prevHash.startsWith('#/read/') && !prevHash.startsWith(`#/manga/${id}`) && prevHash !== '#/login'
location.hash = back ? prevHash : '#/' location.hash = back ? prevHash : '#/'
}) })
// Cerrar el menú ⋯ al pulsar fuera. // Cerrar el desplegable de listas al pulsar fuera.
const closeMenu = e => app.querySelectorAll('.menu[open]').forEach(menu => { if (!menu.contains(e.target)) menu.open = false }) const closeMenu = e => app.querySelectorAll('.menu[open]').forEach(menu => { if (!menu.contains(e.target)) menu.open = false })
document.addEventListener('click', closeMenu) document.addEventListener('click', closeMenu)
cleanup = () => { clearTimeout(dlTimer); clearTimeout(hdTimer); document.removeEventListener('click', closeMenu); offState.listeners.delete(onOff) } cleanup = () => { clearTimeout(dlTimer); clearTimeout(hdTimer); document.removeEventListener('click', closeMenu); document.removeEventListener('keydown', onLibKey); offState.listeners.delete(onOff) }
app.querySelectorAll('[data-lang]').forEach(a => a.addEventListener('click', e => { e.preventDefault(); store.set(`lang:${id}`, a.dataset.lang); viewManga(id, a.dataset.lang) })) app.querySelectorAll('[data-lang]').forEach(a => a.addEventListener('click', e => { e.preventDefault(); store.set(`lang:${id}`, a.dataset.lang); viewManga(id, a.dataset.lang) }))
const ul = $('.chapters', app) const ul = $('.chapters', app)
@@ -1179,7 +1224,7 @@ async function viewManga(id, langOverride) {
updateSummary() updateSummary()
}) })
renderList() renderList()
if (!offline && serverOnline) api(`/api/meta/${id}/dates`).then(r => { extDates = r.dates || {}; if (Object.keys(extDates).length) { buildDates(); renderList() } }, () => {}) api(`/api/meta/${id}/dates`).then(r => { extDates = r.dates || {}; if (Object.keys(extDates).length) { buildDates(); renderList() } }, () => {})
renderDl(download) renderDl(download)
renderHd(upscale) renderHd(upscale)
renderOff() renderOff()
@@ -2341,7 +2386,7 @@ async function viewLibrary(params) {
sub: `${plural(m.chapters.length, 'capítulo')} · ${fmtSize(m.bytes)}<br>${esc(last.map(c => c.num ?? '·').slice(0, 8).join(', '))}${last.length > 8 ? '…' : ''}`, sub: `${plural(m.chapters.length, 'capítulo')} · ${fmtSize(m.bytes)}<br>${esc(last.map(c => c.num ?? '·').slice(0, 8).join(', '))}${last.length > 8 ? '…' : ''}`,
del: { attrs: `data-deldev="${m.mangaId}"`, title: 'Borrar del dispositivo' }, del: { attrs: `data-deldev="${m.mangaId}"`, title: 'Borrar del dispositivo' },
}) })
}), 'No hay nada guardado en este dispositivo. En la ficha de un manga, «En este dispositivo» guarda capítulos para leerlos sin internet.') }), 'No hay nada guardado en este dispositivo. En la ficha de un manga, «⋯ → En este dispositivo» guarda capítulos para leerlos sin internet.')
} else if (curList) { } else if (curList) {
const l = await api(`/api/lists/${curList.id}`).catch(err => (toast(err.message), { items: [] })) const l = await api(`/api/lists/${curList.id}`).catch(err => (toast(err.message), { items: [] }))
body.innerHTML = grid(l.items.map(it => libCard({ body.innerHTML = grid(l.items.map(it => libCard({
@@ -2475,7 +2520,7 @@ async function viewQueues() {
${rest ? `<div>${cur ? `${rest} más` : plural(rest, 'capítulo')} en cola</div>` : ''}`, ${rest ? `<div>${cur ? `${rest} más` : plural(rest, 'capítulo')} en cola</div>` : ''}`,
actions: `<button class="btn small" data-devcancel="${esc(g.mangaId)}">${icon('stop')} Cancelar</button>`, actions: `<button class="btn small" data-devcancel="${esc(g.mangaId)}">${icon('stop')} Cancelar</button>`,
}) })
}).join('')}</div>` : '<p class="muted">No se está guardando nada. Se añade desde la ficha («En este dispositivo») o desde el lector.</p>'}` }).join('')}</div>` : '<p class="muted">No se está guardando nada. Se añade desde la ficha («⋯ → En este dispositivo») o desde el lector.</p>'}`
} }
const html = q.offline ? `<h1>Colas</h1><p class="error">${esc(q.offline)}: no se pueden ver las colas del servidor.</p> const html = q.offline ? `<h1>Colas</h1><p class="error">${esc(q.offline)}: no se pueden ver las colas del servidor.</p>
<section class="qsec" id="devq">${devHtml()}</section>` : `<h1>Colas</h1> <section class="qsec" id="devq">${devHtml()}</section>` : `<h1>Colas</h1>
@@ -2696,7 +2741,7 @@ function viewSettings() {
<label class="row muted" style="font-size:.88rem"><input type="checkbox" id="pfhd"> Reescalarlos también a HD</label> <label class="row muted" style="font-size:.88rem"><input type="checkbox" id="pfhd"> Reescalarlos también a HD</label>
</div></section> </div></section>
<section><b>${icon('phone')} App y lectura sin conexión</b> <section><b>${icon('phone')} App y lectura sin conexión</b>
${OFFLINE_OK ? `<p class="muted" style="margin:0;font-size:.88rem">Capítulos guardados en este dispositivo: <b id="offsum">…</b>. Se guardan desde la ficha de cada manga («En este dispositivo») o desde el lector.</p> ${OFFLINE_OK ? `<p class="muted" style="margin:0;font-size:.88rem">Capítulos guardados en este dispositivo: <b id="offsum">…</b>. Se guardan desde la ficha de cada manga («⋯ → En este dispositivo») o desde el lector.</p>
<div class="row" style="flex-wrap:wrap;gap:8px"><a class="btn small" href="#/library?tab=device">${icon('library')} Ver lo guardado</a> <div class="row" style="flex-wrap:wrap;gap:8px"><a class="btn small" href="#/library?tab=device">${icon('library')} Ver lo guardado</a>
<button class="btn small danger" id="offclear">${icon('trash')} Borrar todo del dispositivo</button> <button class="btn small danger" id="offclear">${icon('trash')} Borrar todo del dispositivo</button>
<button class="btn small" id="install" hidden>${icon('download')} Instalar como app</button></div>` <button class="btn small" id="install" hidden>${icon('download')} Instalar como app</button></div>`
+28 -8
View File
@@ -380,13 +380,33 @@ body.reading #netbanner { display: none; }
/* Info de la obra */ /* Info de la obra */
.chapters .meta .cdate { white-space: nowrap; } .chapters .meta .cdate { white-space: nowrap; }
.info-page { position: relative; } .banner { height: 180px; margin: -16px -16px 16px; background: center / cover no-repeat; mask-image: linear-gradient(#000 40%, transparent); -webkit-mask-image: linear-gradient(#000 40%, transparent); opacity: .7; }
.info-page .banner { height: 180px; margin: -16px -16px 16px; background: center / cover no-repeat; mask-image: linear-gradient(#000 40%, transparent); -webkit-mask-image: linear-gradient(#000 40%, transparent); opacity: .7; } .banner[hidden] { display: none; }
.info-page .info-top { margin-bottom: 14px; } .info-block h2 { margin: 26px 0 10px; font-size: 1.1rem; }
.info-page .banner + .info-top { position: absolute; top: 12px; left: 0; } .detail-body .info-block h2:first-child { margin-top: 18px; }
.info-page h2 { margin: 26px 0 10px; font-size: 1.1rem; } .detail-body .info-block + .tags { margin-top: 14px; }
.info-page .desc.long { max-height: none; } .drawer .kv { margin: 0; }
.info-page .more summary { cursor: pointer; color: var(--muted); margin-top: 14px; } .info-block .desc.long { max-height: none; }
.info-block .more summary { cursor: pointer; color: var(--muted); margin-top: 14px; }
.info-block > .note { margin-top: 16px; }
.kv:empty, #mscores:empty { display: none; }
.hrow { display: grid; grid-auto-flow: column; grid-auto-columns: 130px; gap: 12px; justify-content: start; overflow-x: auto; padding-bottom: 8px; overscroll-behavior-x: contain; }
.list-head { scroll-margin-top: 64px; }
/* Menú lateral de la ficha (botón ⋯) */
.drawer { position: fixed; top: 0; right: 0; bottom: 0; z-index: 35; width: min(440px, 100%); padding: 14px 14px max(14px, env(safe-area-inset-bottom)); display: grid; align-content: start; gap: 12px;
overflow-y: auto; overscroll-behavior: contain; background: var(--bg); border-left: 1px solid var(--surface-2); box-shadow: -10px 0 28px rgba(0,0,0,.45);
transform: translateX(100%); visibility: hidden; transition: transform .22s ease, visibility .22s; }
.drawer.open { transform: none; visibility: visible; }
.drawer-head { display: flex; justify-content: space-between; align-items: center; font-size: 1.05rem; padding-top: env(safe-area-inset-top); }
.drawer-opts { display: grid; gap: 8px; }
.drawer-opts .btn { width: 100%; justify-content: flex-start; box-sizing: border-box; }
.drawer-opts .menu > summary { padding-left: 16px; padding-right: 16px; }
.drawer-opts .menu-list { position: static; margin-top: 6px; box-shadow: none; min-width: 0; }
.drawer .libpanel { margin-top: 0; max-width: none; min-width: 0; }
.drawer-back { position: fixed; inset: 0; z-index: 34; background: rgba(0,0,0,.5); touch-action: none; }
.drawer-back[hidden] { display: none; }
.libbusy { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); animation: libpulse 1.2s ease-in-out infinite; }
@keyframes libpulse { 50% { opacity: .25; } }
.scores { display: flex; flex-wrap: wrap; gap: 10px; margin: 12px 0 6px; } .scores { display: flex; flex-wrap: wrap; gap: 10px; margin: 12px 0 6px; }
.score { background: var(--surface); border-radius: 10px; padding: 8px 12px; display: grid; } .score { background: var(--surface); border-radius: 10px; padding: 8px 12px; display: grid; }
.score b { font-size: 1.25rem; } .score b { font-size: 1.25rem; }
@@ -396,5 +416,5 @@ body.reading #netbanner { display: none; }
.kv dd { margin: 0; min-width: 0; overflow-wrap: anywhere; } .kv dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
.tag.soft { background: transparent; border: 1px solid var(--surface-2); } .tag.soft { background: transparent; border: 1px solid var(--surface-2); }
.tag.hot { color: var(--accent); } .tag.hot { color: var(--accent); }
@media (max-width: 640px) { .kv { grid-template-columns: 1fr; gap: 0; } .kv dd { margin-bottom: 8px; } .info-page .banner { height: 120px; } } @media (max-width: 640px) { .kv { grid-template-columns: 1fr; gap: 0; } .kv dd { margin-bottom: 8px; } .banner { height: 120px; } }
.chapters .meta .cdate.approx { opacity: .7; } .chapters .meta .cdate.approx { opacity: .7; }
+20 -9
View File
@@ -460,14 +460,16 @@ const sourceOf = id => (id.startsWith('lc~') ? 'lc' : id.startsWith('mi~') ? 'mi
const spanishOnly = id => sourceOf(id) !== 'md' 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. // 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). // 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) { // 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 let url
try { url = new URL(u) } catch { throw new HttpError(400, 'URL inválida') } try { url = new URL(u) } catch { throw new HttpError(400, 'URL inválida') }
const expected = sign(u) 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 (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 (!/^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 // 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. // 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 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 return r
}).catch(e => { throw e instanceof HttpError ? e : new HttpError(502, `Imagen: ${e.message}`) }) }).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 (!r.ok || !r.body) throw new HttpError(502, `Imagen ${r.status}`)
if (isCover) { if (hdKey) {
const buf = Buffer.from(await r.arrayBuffer()) 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) res.end(buf)
return covers.enqueue(u, buf) return covers.enqueue(hdKey, buf, banner)
} }
res.writeHead(200, { res.writeHead(200, {
'Content-Type': r.headers.get('content-type') || 'image/jpeg', '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() 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) { function fetchImage(url, signal) {
net.guard() net.guard()
const host = new URL(url).hostname const host = new URL(url).hostname
@@ -890,7 +901,7 @@ async function route(req, res) {
if (p === '/api/img' && method === 'GET') { if (p === '/api/img' && method === 'GET') {
const mid = MANGA_ID.test(q.get('m') || '') ? q.get('m') : null const mid = MANGA_ID.test(q.get('m') || '') ? q.get('m') : null
try { 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) { } catch (e) {
// Portada sin conexión con la fuente: la cover.jpg de la copia local, si la hay. // Portada sin conexión con la fuente: la cover.jpg de la copia local, si la hay.
if (mid && !res.headersSent && e.status !== 403) { 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 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 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(() => []) 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') { if ((m = /^\/api\/chapter\/([^/]+)$/.exec(p)) && CHAPTER_ID.test(m[1]) && method === 'GET') {
const hdAvailable = !!upscale.hdFileOf(m[1]) 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 }) 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. // Al volver internet: reanudar las descargas en cola.
net.whenOnline(() => pumpDownloads()) 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 }) 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 // Migración: lo descargado antes de que hubiera dueños pasa a quien lo lee, lo sigue o lo tiene en una