diff --git a/README.md b/README.md index 3ab68cf..041e917 100644 --- a/README.md +++ b/README.md @@ -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 -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, -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 (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 (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 -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 diff --git a/app/clips.js b/app/clips.js index 5f070a9..f2f3c36 100644 --- a/app/clips.js +++ b/app/clips.js @@ -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 + // 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) { let size = 0 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 FROM clips WHERE user_id = ?`).get(userId) - return { route, count, pump, queueInfo } + return { route, count, bannerFor, pump, queueInfo } } diff --git a/app/covers.js b/app/covers.js index f627c6d..09b66b9 100644 --- a/app/covers.js +++ b/app/covers.js @@ -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 // 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 /covers/.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 os from 'node:os' import path from 'node:path' @@ -11,6 +12,7 @@ const MAX_QUEUE = 300 const X4_MAX_SIDE = 400 const MAX_SIDE = 1600 // más grande ya no hace falta reescalarla 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). export function imageSize(b) { @@ -53,23 +55,26 @@ export function setupCovers({ DATA_DIR, upscale }) { return true } // 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) { - if (!upscale.available() || pending.has(key) || failed.has(key) || queue.length >= MAX_QUEUE || fs.existsSync(fileOf(key))) return + // ¿Falta generar esta imagen? (para no descargarla si no hace falta) + 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) - queue.push({ key, buf }) + queue.push({ key, buf, banner }) pump() } - async function processOne({ key, buf }) { + async function processOne({ key, buf, banner }) { const size = imageSize(buf) if (!size) throw new Error('formato no reconocido') const work = fs.mkdtempSync(path.join(os.tmpdir(), 'cover-')) try { const out = path.join(work, 'out.png') 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. const inp = path.join(work, `in.${size.type}`) fs.writeFileSync(inp, buf) @@ -78,7 +83,8 @@ export function setupCovers({ DATA_DIR, upscale }) { } else { const inp = path.join(work, `in.${size.type}`) 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('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 } } diff --git a/app/meta.js b/app/meta.js index c31bb1c..355827b 100644 --- a/app/meta.js +++ b/app/meta.js @@ -9,8 +9,12 @@ // 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 // 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 INFO_TTL = v => (v?.found ? 30 * DAY : 3 * DAY) +const SYNC_EVERY = 3600e3 const ANILIST = 'https://graphql.anilist.co' const MU_API = 'https://api.mangaupdates.com/v1' 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(/\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() -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)`) // 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() - 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 old = row ? JSON.parse(row.value) : undefined const life = v => (typeof ttl === 'function' ? ttl(v) : ttl) if (row && Date.now() - row.at < life(old)) return Promise.resolve(old) - if (inflight.has(key)) return inflight.get(key) - const p = (async () => { - try { - const v = await fn() - 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 - } catch (e) { - if (old !== undefined) return old - throw e - } - })().finally(() => inflight.delete(key)) - inflight.set(key, p) - return p + if (!inflight.has(key)) { + inflight.set(key, (async () => { + try { + 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()) + return v + } catch (e) { + if (old !== undefined) return old + throw e + } + })().finally(() => inflight.delete(key))) + } + 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). @@ -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))] - 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 () => { if (sourceOf(mangaId) === 'md') { 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 title = manga.title // 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))) 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] } - 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))) 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)) 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 {} - }) + }, wait) } // ---------- 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 cover = u => (u ? imgUrl(u) : null) - async function info(mangaId) { - const l = await links(mangaId) - return stored(`info:${mangaId}`, 7 * DAY, async () => { + async function info(mangaId, wait = false) { + const l = await links(mangaId, wait) + return stored(`info:${mangaId}`, INFO_TTL, async old => { + let failed = false const [al, ms] = await Promise.all([ 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.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 + // 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 } 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) })) @@ -181,14 +193,24 @@ export function setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, n ].filter(x => x && /^https?:\/\//.test(x.url)).slice(0, 14), 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 ---------- - async function dates(mangaId) { - const l = await links(mangaId) + async function dates(mangaId, wait = false) { + const l = await links(mangaId, wait) 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 via = [] const add = (n, d) => { @@ -200,10 +222,10 @@ export function setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, n if (l.md) { let n = 0 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, 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++ } if (!d || offset + 500 >= d.total) break } @@ -212,7 +234,7 @@ export function setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, n if (l.mu) { let n = 0 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 || []) { const ch = String(x.record.chapter || '') 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 (failed && (old?.via?.length || !Object.keys(out).length)) throw new HttpError(502, 'No se pudieron consultar las fechas') 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) { const m = /^\/api\/meta\/([^/]+)(\/dates)?$/.exec(p) if (!m || method !== 'GET') return false 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.') - 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 } }) - } + json(res, 200, await (m[2] ? dates(id) : info(id))) return true } - return { route, links, info, dates } + return { route, links, info, cachedInfo, dates } } diff --git a/app/public/app.js b/app/public/app.js index 1f06a74..8021c0b 100644 --- a/app/public/app.js +++ b/app/public/app.js @@ -44,7 +44,6 @@ const ICONS = { phone: '', plus: '', pencil: '', - info: '', sparkles: '', } const icon = name => `` @@ -368,7 +367,7 @@ async function router() { try { if (parts[0] === 'login') return (location.hash = '#/') 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] === 'history') return (location.hash = '#/library') // antiguo nombre if (parts[0] === 'library') return await viewLibrary(params) @@ -588,7 +587,7 @@ async function viewHome(params) { } // ---------- 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_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' } @@ -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 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) { - if (cleanup) { cleanup(); cleanup = null } - app.innerHTML = '
Buscando información de la obra…
' - const d = await api(`/api/meta/${id}`) - const src = d.source || {} - const back = `${icon('back')} Volver` - if (!d.found) { - app.innerHTML = `
${back}

${esc(src.title || '')}

-

No se encontró esta obra en AniList ni en MangaUpdates. Puede tener otro título allí: prueba a buscarla en - AniList.

` - return - } +// Lo que la ficha pinta a partir de la información guardada de la obra (d.found). +function infoParts(d, manga) { const row = (k, v) => (v ? `
${k}
${v}
` : '') - const list = a => a.map(esc).join(', ') 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 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 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 => ` + const cardOf = r => `
${esc(r.title)}
${esc([r.relation ? AL_REL[r.relation] || r.relation : '', r.year || '', r.score ? `${r.score}%` : ''].filter(Boolean).join(' · '))}
` - const srcDesc = (src.description || '').trim() - app.innerHTML = ` -
- ${d.banner ? `` : ''} -
${back}
-
- -
-

${esc(t.english || t.romaji || src.title)}

-
${esc([t.romaji !== (t.english || t.romaji) ? t.romaji : '', t.native].filter(Boolean).join(' · '))}
-
- ${d.score != null ? `
${d.score}%AniList
` : ''} - ${d.muScore != null ? `
${Number(d.muScore).toFixed(1)}MangaUpdates${d.muVotes ? ` · ${fmtNum(d.muVotes)} votos` : ''}
` : ''} - ${d.popularity ? `
${fmtNum(d.popularity)}lectores en AniList
` : ''} -
- ${ranks.length ? `
${ranks.map(r => `${esc(r)}`).join('')}
` : ''} -
- ${[...people].map(([name, roles]) => row(esc(roles.join(' y ')), esc(name))).join('')} - ${row('Revista', d.magazines.map(m => esc(m.name) + (m.publisher ? ` (${esc(m.publisher)})` : '')).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('Tipo', esc([AL_FORMAT[d.format] || d.format, AL_COUNTRY[d.country]].filter(Boolean).join(' · ')))} - ${row('Publicación', esc(dates))} - ${row('Estado', esc([AL_STATUS[d.status], d.statusDetail].filter(Boolean).join(' · ')))} - ${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 ? `adapta: ${esc(d.anime.start)}${d.anime.end ? ` → ${esc(d.anime.end)}` : ''}` : ''].filter(Boolean).join('
'))} -
-
${d.genres.map(g => `${esc(g)}`).join('')}${d.tags.map(g => `${esc(g)}`).join('')}
+ const seen = new Set([manga.title.toLowerCase()]) + const altTitles = [t.english, t.romaji, t.native].filter(x => x && !seen.has(x.toLowerCase()) && seen.add(x.toLowerCase())) + const srcDesc = (manga.description || '').trim() + return { + banner: d.banner, + sub: altTitles.join(' · '), + scores: `
+ ${d.score != null ? `
${d.score}%AniList
` : ''} + ${d.muScore != null ? `
${Number(d.muScore).toFixed(1)}MangaUpdates${d.muVotes ? ` · ${fmtNum(d.muVotes)} votos` : ''}
` : ''} + ${d.popularity ? `
${fmtNum(d.popularity)}lectores en AniList
` : ''}
-
- ${srcDesc ? `

Sinopsis

${esc(srcDesc)}

` : ''} - ${d.description ? (srcDesc ? `
Sinopsis de AniList (en inglés)

${esc(d.description)}

` : `

Sinopsis (en inglés)

${esc(d.description)}

`) : ''} - ${t.other.length ? `

Otros títulos

${list(t.other)}

` : ''} - ${d.related.length ? `

Obras relacionadas

${d.related.map(cardOf).join('')}
` : ''} - ${d.recommendations.length ? `

Si te gusta, prueba

${d.recommendations.map(cardOf).join('')}
` : ''} - ${d.links.length ? `

Enlaces

${d.links.map(l => `${esc(l.site)}`).join('')}
` : ''} -

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.

-
` + ${ranks.length ? `
${ranks.map(r => `${esc(r)}`).join('')}
` : ''}`, + kv: `${people.size ? [...people].map(([name, roles]) => row(esc(roles.join(' y ')), esc(name))).join('') : row('Autor', esc(manga.authors.join(', ')))} + ${row('Revista', d.magazines.map(m => esc(m.name) + (m.publisher ? ` (${esc(m.publisher)})` : '')).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('Tipo', esc([AL_FORMAT[d.format] || d.format, AL_COUNTRY[d.country]].filter(Boolean).join(' · ')))} + ${row('Publicación', esc(dates || manga.year || ''))} + ${row('Estado', esc([AL_STATUS[d.status], d.statusDetail].filter(Boolean).join(' · ') || manga.status || ''))} + ${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 ? `adapta: ${esc(d.anime.start)}${d.anime.end ? ` → ${esc(d.anime.end)}` : ''}` : ''].filter(Boolean).join('
'))}`, + genres: d.genres.map(g => `${esc(g)}`).join(''), + softTags: d.tags.map(g => `${esc(g)}`).join(''), + synopsis: `${srcDesc ? `

Sinopsis

${esc(srcDesc)}

` : ''} + ${d.description ? (srcDesc ? `
Sinopsis de AniList (en inglés)

${esc(d.description)}

` : `

Sinopsis (en inglés)

${esc(d.description)}

`) : ''}`, + extra: `${t.other.length ? `

Otros títulos

${t.other.map(esc).join(', ')}

` : ''} + ${d.related.length ? `

Obras relacionadas

${d.related.map(cardOf).join('')}
` : ''} + ${d.recommendations.length ? `

Si te gusta, prueba

${d.recommendations.map(cardOf).join('')}
` : ''} + ${d.links.length ? `

Enlaces

${d.links.map(l => `${esc(l.site)}`).join('')}
` : ''} +

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.

`, + } } async function viewManga(id, langOverride) { if (cleanup) { cleanup(); cleanup = null } app.innerHTML = '
Cargando…
' 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 newSet = new Set(newKeys.map(String)) let isFollowing = following @@ -683,30 +663,43 @@ async function viewManga(id, langOverride) { if (first && !mainBtn) mainBtn = `${icon('book')} Empezar a leer` const menuItems = [ - manga.source === 'lc' ? '' : `${icon('search')} Buscar en LeerCapitulo`, - manga.source === 'mi' ? '' : `${icon('search')} Buscar en M440`, - progress || reads.length ? `` : '', + manga.source === 'lc' ? '' : `${icon('search')} Buscar en LeerCapitulo`, + manga.source === 'mi' ? '' : `${icon('search')} Buscar en M440`, + progress || reads.length ? `` : '', ].join('') + const srcTags = manga.tags.map(t => (t.id ? `${esc(t.name)}` : `${esc(t.name ?? t)}`)).join('') + const srcSub = `${esc(manga.authors.join(', '))}${manga.year ? ` · ${manga.year}` : ''}${manga.status ? ` · ${esc(manga.status)}` : ''}` app.innerHTML = ` +
- +

${esc(manga.title)}

-
${esc(manga.authors.join(', '))}${manga.year ? ` · ${manga.year}` : ''}${manga.status ? ` · ${esc(manga.status)}` : ''}
-
${manga.tags.map(t => (t.id ? `${esc(t.name)}` : `${esc(t.name ?? t)}`)).join('')}
-

${esc(manga.description)}

+
${srcSub}
+
${mainBtn} - - ${offline ? '' : `${icon('info')} Info`} - ${offline ? '' : ``} - ${menuItems ? `` : ''} + ${chapters.length ? `` : ''} +
-
Fuente: ${esc(SOURCES[manga.source])}${download ? `Carpeta: mangas/${esc(download.folder)}` : ''}${clipCount ? `${icon('scissors')} ${clipCount} recorte${clipCount > 1 ? 's' : ''}` : ''}
${offline ? '

Sin conexión con la fuente: mostrando solo los capítulos descargados.

' : ''} -
+
+
+
+ +

Capítulos (${chapters.length})${newSet.size ? ` ${newSet.size} ${newSet.size === 1 ? 'nuevo' : 'nuevos'}` : ''}

@@ -719,6 +712,54 @@ async function viewManga(id, langOverride) { ${chapterLangs.length ? `Disponibles en: ${chapterLangs.map(l => `${esc(LANGS[l] || l)}`).join(' ')}` : 'No hay capítulos en ningún idioma.'}

`} ` + // --- 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 ? `
Carpeta
mangas/${esc(download.folder)}
` : '' + $('#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 ? `

Sinopsis

${esc(srcDesc)}

` : '') + $('#mextra').innerHTML = parts?.extra || (d ? `

No se encontró esta obra en AniList ni en MangaUpdates. Puede tener otro título allí: prueba a buscarla en + AniList.

` : '') + } + paintInfo(savedInfo) + if (!savedInfo) { + $('#mextra').innerHTML = '

Buscando información de la obra…

' + 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) ? ' ' : ''}` + } + 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) => `
${icon(ic)} ${title}
${status ? `
${status}
` : ''}
` // --- 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 (next) renderSelForm(next.kind) 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') $('#hdopen')?.classList.toggle('on', sel?.kind === 'hd') $('#offopen')?.classList.toggle('on', sel?.kind === 'off') @@ -867,6 +909,7 @@ async function viewManga(id, langOverride) { const box = $('#dl') if (!box) return const active = d && DL_ACTIVE.includes(d.status) + markLib('dl', !!active) const some = d?.scope === 'some' const partial = d && d.done < d.total const bar = d && (active || partial) ? `
` : '' @@ -941,6 +984,7 @@ async function viewManga(id, langOverride) { hdInfo = u const show = !!(u?.available && u.downloaded && !offline) box.hidden = !show + markLib('hd', show && (!!u.current || Object.values(u.jobs).some(s => s !== 'error'))) if (show) { const queued = Object.values(u.jobs).filter(s => s !== 'error').length 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 run = offState.running?.mangaId === id ? offState.running : null const queued = offState.queue.filter(q => q.mangaId === id).length + markLib('off', !!(run || queued)) box.hidden = false // 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)) @@ -1088,10 +1133,10 @@ async function viewManga(id, langOverride) { const back = prevHash && !prevHash.startsWith('#/read/') && !prevHash.startsWith(`#/manga/${id}`) && prevHash !== '#/login' 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 }) 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) })) const ul = $('.chapters', app) @@ -1179,7 +1224,7 @@ async function viewManga(id, langOverride) { updateSummary() }) 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) renderHd(upscale) renderOff() @@ -2341,7 +2386,7 @@ async function viewLibrary(params) { sub: `${plural(m.chapters.length, 'capítulo')} · ${fmtSize(m.bytes)}
${esc(last.map(c => c.num ?? '·').slice(0, 8).join(', '))}${last.length > 8 ? '…' : ''}`, 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) { const l = await api(`/api/lists/${curList.id}`).catch(err => (toast(err.message), { items: [] })) body.innerHTML = grid(l.items.map(it => libCard({ @@ -2475,7 +2520,7 @@ async function viewQueues() { ${rest ? `
${cur ? `${rest} más` : plural(rest, 'capítulo')} en cola
` : ''}`, actions: ``, }) - }).join('')}
` : '

No se está guardando nada. Se añade desde la ficha («En este dispositivo») o desde el lector.

'}` + }).join('')}
` : '

No se está guardando nada. Se añade desde la ficha («⋯ → En este dispositivo») o desde el lector.

'}` } const html = q.offline ? `

Colas

${esc(q.offline)}: no se pueden ver las colas del servidor.

${devHtml()}
` : `

Colas

@@ -2696,7 +2741,7 @@ function viewSettings() {
${icon('phone')} App y lectura sin conexión - ${OFFLINE_OK ? `

Capítulos guardados en este dispositivo: …. Se guardan desde la ficha de cada manga («En este dispositivo») o desde el lector.

+ ${OFFLINE_OK ? `

Capítulos guardados en este dispositivo: …. Se guardan desde la ficha de cada manga («⋯ → En este dispositivo») o desde el lector.

${icon('library')} Ver lo guardado
` diff --git a/app/public/style.css b/app/public/style.css index 49dc219..a9cbbe3 100644 --- a/app/public/style.css +++ b/app/public/style.css @@ -380,13 +380,33 @@ body.reading #netbanner { display: none; } /* Info de la obra */ .chapters .meta .cdate { white-space: nowrap; } -.info-page { position: relative; } -.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; } -.info-page .info-top { margin-bottom: 14px; } -.info-page .banner + .info-top { position: absolute; top: 12px; left: 0; } -.info-page h2 { margin: 26px 0 10px; font-size: 1.1rem; } -.info-page .desc.long { max-height: none; } -.info-page .more summary { cursor: pointer; color: var(--muted); margin-top: 14px; } +.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-block h2 { margin: 26px 0 10px; font-size: 1.1rem; } +.detail-body .info-block h2:first-child { margin-top: 18px; } +.detail-body .info-block + .tags { margin-top: 14px; } +.drawer .kv { margin: 0; } +.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; } .score { background: var(--surface); border-radius: 10px; padding: 8px 12px; display: grid; } .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; } .tag.soft { background: transparent; border: 1px solid var(--surface-2); } .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; } diff --git a/app/server.js b/app/server.js index b0ec36f..cbe2cc7 100644 --- a/app/server.js +++ b/app/server.js @@ -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