Modo libro 3D, tomos con cubiertas reales y ocultar créditos

Libro 3D (app/public/book3d.js): lector WebGL2 con mesa de madera, cubierta,
hojas que giran, cámara real y giroscopio, ahora con más recorrido alrededor
del libro.

Tomos (app/volumes.js): agrupa los capítulos en un solo libro con portada y
contraportada reales. El reparto sale de Wikipedia, MangaDex, M440 y
MangaUpdates, contrastados entre sí. Lo que ninguna fuente sitúa se estima, de
modo que solo quedan sueltos los capítulos del final, y los fuera de orden se
recolocan. Manhwa y manhua no tienen tomos.

Ocultar créditos (app/credits.js): botón por obra en la ficha. Al leer por
tomos oculta, sin borrarlas, la página cero y las dos últimas de cada capítulo
si se repiten en otros capítulos de la obra.

HANDOFF-LIBRO3D.md recoge el estado, las decisiones y las pruebas.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-06 09:28:15 -06:00
co-authored by Claude Opus 5.5
parent bb1f3ff8e4
commit 66502409fe
10 changed files with 2430 additions and 58 deletions
+7
View File
@@ -4,7 +4,14 @@ data/*
# Descargas cuando se usan dentro del proyecto (MANGA_DIR_HOST por defecto)
mangas/*
!mangas/.keep
# Configuración real de cada servidor (la plantilla sí se versiona)
.env
.env.*
!.env.example
node_modules/
# Documentos internos de traspaso (se guardan solo en el servidor)
HANDOFF-LIBRO3D.md
# Basura del sistema y registros
*.log
.DS_Store
Thumbs.db
+92
View File
@@ -0,0 +1,92 @@
// Créditos de los traductores: cada grupo repite casi la misma página en sus capítulos, y las de historia no se repiten.
// El navegador resume la página 0 y las 2 últimas de cada capítulo (el servidor no sabe decodificar imágenes) y aquí se
// comparan con las del resto de la obra. Solo se ocultan al leer por tomos, y nunca se borra nada.
const SIDE = 16
const SIG_BYTES = SIDE * SIDE
const SAME = 0.03 // diferencia media: los créditos quedan por debajo del 2% y la historia muy por encima
const SAME_SHAPE = 0.03
const MIN_PAGES = 5
const STRIP = 0.45 // tira larga: los créditos vienen pegados a la historia y no se puede quitar uno sin la otra
const PLAIN = 0.03 // página casi lisa (en blanco, negra): no son créditos, aunque se repitan
const decode = s => Buffer.from(String(s || ''), 'base64')
const spread = b => {
const mean = b.reduce((a, x) => a + x, 0) / b.length
return Math.sqrt(b.reduce((a, x) => a + (x - mean) ** 2, 0) / b.length) / 255
}
const difference = (a, b) => {
let sum = 0
for (let i = 0; i < SIG_BYTES; i++) sum += Math.abs(a[i] - b[i])
return sum / SIG_BYTES / 255
}
/** Qué páginas repetidas tiene cada capítulo pedido. rows: [{ id, n, pages: { índice: { s, ar } } }], de toda la obra. */
export function repeatedPages(rows, ids) {
const all = rows.flatMap(r => Object.entries(r.pages).map(([i, p]) => ({ id: r.id, i: Number(i), ar: p.ar, sig: decode(p.s) })))
.filter(p => p.sig.length === SIG_BYTES)
const out = {}
for (const r of rows) {
if (!ids.includes(r.id) || r.n < MIN_PAGES) continue
const hidden = []
for (const p of all) {
if (p.id !== r.id || p.ar < STRIP || spread(p.sig) < PLAIN) continue
if (![0, r.n - 2, r.n - 1].includes(p.i)) continue
if (all.some(o => o.id !== r.id && Math.abs(o.ar - p.ar) < SAME_SHAPE && difference(o.sig, p.sig) < SAME)) hidden.push(p.i)
}
if (hidden.length) out[r.id] = hidden.sort((a, b) => a - b)
}
return out
}
export function setupCredits({ db, HttpError, json, readBody, MANGA_ID, CHAPTER_ID }) {
db.exec(`
CREATE TABLE IF NOT EXISTS page_sigs (
manga_id TEXT NOT NULL,
chapter_id TEXT NOT NULL,
n INTEGER NOT NULL,
pages TEXT NOT NULL,
PRIMARY KEY (manga_id, chapter_id)
);
CREATE TABLE IF NOT EXISTS hide_credits (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
manga_id TEXT NOT NULL,
PRIMARY KEY (user_id, manga_id)
);`)
const enabled = (userId, mangaId) => !!db.prepare('SELECT 1 FROM hide_credits WHERE user_id = ? AND manga_id = ?').get(userId, mangaId)
function validPages(pages, n) {
const entries = Object.entries(pages || {})
if (!entries.length || entries.length > 3) return false
return entries.every(([i, p]) => [0, n - 2, n - 1].includes(Number(i)) && typeof p?.ar === 'number' && p.ar > 0 && decode(p.s).length === SIG_BYTES)
}
async function route(p, method, req, res, user) {
let m
if ((m = /^\/api\/credits\/([^/]+)\/option$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'POST') {
const { on } = await readBody(req)
if (on) db.prepare('INSERT OR IGNORE INTO hide_credits (user_id, manga_id) VALUES (?, ?)').run(user.id, m[1])
else db.prepare('DELETE FROM hide_credits WHERE user_id = ? AND manga_id = ?').run(user.id, m[1])
json(res, 200, { hideCredits: !!on })
return true
}
if ((m = /^\/api\/credits\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'POST') {
const body = await readBody(req)
const wanted = Array.isArray(body.chapters) ? body.chapters.slice(0, 60) : []
if (!wanted.every(c => CHAPTER_ID.test(c?.id || '') && Number.isInteger(c?.n) && c.n > 0 && c.n < 100000)) throw new HttpError(400, 'Capítulos inválidos')
const save = db.prepare('INSERT OR REPLACE INTO page_sigs (manga_id, chapter_id, n, pages) VALUES (?, ?, ?, ?)')
for (const c of wanted) {
const pages = body.sigs?.[c.id]
if (pages && validPages(pages, c.n)) save.run(m[1], c.id, c.n, JSON.stringify(pages))
}
const rows = db.prepare('SELECT chapter_id AS id, n, pages FROM page_sigs WHERE manga_id = ?').all(m[1]).map(r => ({ id: r.id, n: r.n, pages: JSON.parse(r.pages) }))
const known = new Map(rows.map(r => [r.id, r.n]))
// Un capítulo vuelto a subir puede tener otras páginas: su resumen ya no vale.
const missing = wanted.filter(c => known.get(c.id) !== c.n).map(c => c.id)
const current = rows.filter(r => wanted.every(c => c.id !== r.id || c.n === r.n))
json(res, 200, { missing, hidden: repeatedPages(current, wanted.map(c => c.id).filter(id => !missing.includes(id))) })
return true
}
return false
}
return { route, enabled }
}
+27 -1
View File
@@ -12,6 +12,7 @@
// 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.
import { parseMuVolumes, workTitle } from './volumes.js'
const DAY = 24 * 3600e3
const INFO_TTL = v => (v?.found ? 30 * DAY : 3 * DAY)
const SYNC_EVERY = 3600e3
@@ -119,6 +120,31 @@ export function setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, n
}, wait)
}
// Las entradas de MangaDex con el título de la obra, en el orden en que las devuelve (primero la más parecida).
function mdMatches(mangaId) {
return stored(`mdworks:${mangaId}`, v => (v.length ? 30 * DAY : 3 * DAY), async () => {
const { manga } = await fetchMangaFull(mangaId, 'es')
const title = workTitle(manga.title)
const r = await md('/manga', { title, limit: 10, 'contentRating[]': MD_RATINGS }, 6 * 3600e3)
return (r?.data || []).filter(m => mdTitles(m).some(t => similar(t, title))).map(m => m.id)
}, true)
}
// Capítulo -> tomo según las publicaciones de MangaUpdates. Sirve para las obras licenciadas, que MangaDex deja sin capítulos.
async function muVolumes(mangaId) {
const l = await links(mangaId, true)
if (!l.mu) return {}
return stored(`mumap:${mangaId}`, v => (Object.keys(v).length ? 7 * DAY : 3 * DAY), async () => {
const rows = []
for (let page = 1; page <= 20; page++) {
const r = await mu('/releases/search', { search: String(l.mu), search_type: 'series', perpage: 100, page })
rows.push(...(r?.results || []).map(x => x.record))
if (!r || page * 100 >= r.total_hits) break
}
return parseMuVolumes(rows)
}, true)
}
// ---------- Ficha ----------
const AL_FIELDS = `id idMal siteUrl title{romaji english native} synonyms description(asHtml:true) format status
startDate{year month day} endDate{year month day} chapters volumes countryOfOrigin isAdult
@@ -287,5 +313,5 @@ export function setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, n
return true
}
return { route, links, info, cachedInfo, dates }
return { route, links, mdMatches, muVolumes, similar, stored, info, cachedInfo, dates }
}
+438 -49
View File
@@ -681,7 +681,7 @@ async function viewManga(id, langOverride) {
if (cleanup) { cleanup(); cleanup = null }
app.innerHTML = '<div class="loading">Cargando…</div>'
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 = [], info: savedInfo = null, clipBanner = null } = await api(`/api/manga/${id}?lang=${encodeURIComponent(lang)}`)
const { manga, chapters, reads, progress, chapterLangs, offline, downloaded, hideCredits: creditsOn, 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
@@ -732,6 +732,7 @@ async function viewManga(id, langOverride) {
<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>
<button class="btn" id="hidecred" hidden></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}
@@ -1172,6 +1173,23 @@ async function viewManga(id, langOverride) {
b.hidden = offline || isBook
}
renderFollow()
// Solo con tomos: es donde se ocultan. Manhwa y manhua no tienen, y se leen como siempre.
let hideCredits = !!creditsOn
function renderCredits() {
const b = $('#hidecred')
if (!b) return
b.innerHTML = `${icon(hideCredits ? 'check' : 'scissors')} Ocultar créditos`
b.classList.toggle('on', hideCredits)
b.setAttribute('aria-pressed', hideCredits)
b.title = 'Al leer por tomos, salta las páginas de créditos repetidas (la primera y las 2 últimas de cada capítulo). No se borra nada.'
b.hidden = offline || !vols.length
}
$('#hidecred').addEventListener('click', async () => {
const r = await api(`/api/credits/${id}/option`, { method: 'POST', body: { on: !hideCredits } })
hideCredits = r.hideCredits
renderCredits()
toast(hideCredits ? 'Se ocultarán los créditos al leer por tomos' : 'Se mostrarán todas las páginas')
})
$('#follow').addEventListener('click', async () => {
const r = await api(`/api/follow/${id}`, isFollowing ? { method: 'DELETE' } : { method: 'POST', body: { title: manga.title, cover: manga.cover, lang } })
isFollowing = r.following
@@ -1251,13 +1269,59 @@ async function viewManga(id, langOverride) {
const d = new Date(e.t).toISOString().slice(0, 10)
return `<span class="cdate${e.approx ? ' approx' : ''}" title="${e.approx ? 'Fecha estimada (entre capítulos con fecha conocida)' : 'Primera publicación conocida'}">${e.approx ? '≈ ' : ''}${fmtDate(d)}</span>`
}
// Tomos: llegan aparte, para no hacer esperar a la ficha. Los plegados se recuerdan mientras se está en ella.
let vols = [], volOf = new Map(), folded = new Set()
if (!isBook) api(`/api/volumes/${id}?lang=${encodeURIComponent(lang)}`).then(r => {
if (!r.volumes?.length || !ul?.isConnected) return
vols = r.volumes
volOf = new Map(vols.flatMap(v => v.chapters.map(c => [c, v])))
const open = volOf.get(progress?.chapter_id) || vols[0]
folded = new Set(vols.filter(v => v !== open).map(v => v.vol))
renderList()
renderCredits()
}, () => {})
const volHead = v => {
if (!v) return '<li class="volhead"><div class="voltoggle static"><b>Sin tomo</b></div></li>'
const inVol = chapters.filter(c => volOf.get(c.id) === v)
const isDone = c => { const r = readMap.get(c.id); return r && r.total_pages && r.page >= r.total_pages - 1 }
const read = inVol.filter(isDone).length, from = inVol.find(c => !isDone(c)) || inVol[0], r = readMap.get(from.id)
return `<li class="volhead"><button class="voltoggle" data-vol="${esc(v.vol)}" aria-expanded="${!folded.has(v.vol)}">
${v.thumb ? `<img src="${esc(v.thumb)}" alt="" loading="lazy">` : ''}<span><b>Tomo ${esc(v.vol)}</b><span class="meta">${plural(inVol.length, 'capítulo')}${read ? ` · ${read} leídos` : ''}</span></span>
</button><a class="btn small" href="${readUrl(id, from.id, r && !isDone(from) ? r.page : 0)}">${icon('book')} Leer tomo</a></li>`
}
ul?.addEventListener('click', e => {
const b = e.target.closest('button.voltoggle')
if (!b) return
folded.has(b.dataset.vol) ? folded.delete(b.dataset.vol) : folded.add(b.dataset.vol)
renderList()
})
function renderList() {
if (!ul) return
const list = desc ? [...chapters].reverse() : chapters
const picking = sel?.mode === 'pick'
const pickable = new Set(picking ? KINDS[sel.kind].candidates().map(c => c.id) : [])
pickable = new Set(picking ? KINDS[sel.kind].candidates().map(c => c.id) : [])
ul.classList.toggle('picking', picking)
ul.innerHTML = list.map((c, i) => {
// Cada tomo, junto: si un capítulo quedó en medio de otros, no se repite la cabecera.
const seen = new Set()
const grouped = !vols.length ? list : list.flatMap(c => {
const v = volOf.get(c.id)
if (!v) return [c]
if (seen.has(v)) return []
seen.add(v)
return list.filter(x => volOf.get(x.id) === v)
})
let curVol
ul.innerHTML = grouped.map((c, i) => {
const v = volOf.get(c.id) || null
const head = vols.length && v !== curVol ? volHead(v) : ''
curVol = v
if (v && !picking && folded.has(v.vol)) return head
return head + chapterItem(c, i)
}).join('')
}
let pickable = new Set()
function chapterItem(c, i) {
const picking = sel?.mode === 'pick'
const r = readMap.get(c.id)
const done = r && r.total_pages && r.page >= r.total_pages - 1
if (picking) {
@@ -1268,7 +1332,6 @@ async function viewManga(id, langOverride) {
}
const cls = progress?.chapter_id === c.id ? 'current' : done ? 'read' : ''
return `<li><a class="${cls}" href="${readUrl(id, c.id, r && !done ? r.page : (done ? 0 : null))}"><span>${esc(chapLabel(c))}</span><span class="meta">${chapMeta(c, r, done)}</span></a></li>`
}).join('')
}
// Selección en la lista (Mayús + clic marca el tramo desde el último marcado).
ul?.addEventListener('click', e => {
@@ -1295,6 +1358,7 @@ async function viewManga(id, langOverride) {
}
// ---------- Lector ----------
const B3D_OK = matchMedia('(pointer: coarse)').matches && 'WebGL2RenderingContext' in window
const READ_MODES = { paged: 'Una página', double: 'Doble página', vertical: 'Vertical' }
const PAGE_ANIMS = { flip: 'Pasar hoja', slide: 'Deslizar', fade: 'Fundido', none: 'Sin animación' }
// ---------- Sonido al pasar página ----------
@@ -1804,6 +1868,130 @@ async function createBook(text, mangaId, size) {
return book
}
// Resumen de una página para compararla con las de otros capítulos: 16×16 en grises y su proporción.
async function pageSig(url) {
const res = await fetch(url)
if (!res.ok) throw new Error('Sin imagen')
const bmp = await createImageBitmap(await res.blob())
const ar = bmp.width / bmp.height
// A la mitad cada vez, hasta 64×64: una reducción grande de golpe da resultados distintos en Safari y en Chrome.
let from = bmp, w = bmp.width, h = bmp.height, cv, cx
while (w > 64 || h > 64 || w !== h) {
w = Math.max(64, w >> 1)
h = Math.max(64, h >> 1)
cv = document.createElement('canvas')
cv.width = w
cv.height = h
cx = cv.getContext('2d', { willReadFrequently: w === 64 && h === 64 })
cx.imageSmoothingQuality = 'high'
cx.drawImage(from, 0, 0, w, h)
from = cv
}
bmp.close()
const d = cx.getImageData(0, 0, 64, 64).data
const out = new Uint8Array(256)
for (let y = 0; y < 16; y++) {
for (let x = 0; x < 16; x++) {
let sum = 0
for (let j = 0; j < 4; j++) {
for (let i = 0; i < 4; i++) {
const k = ((y * 4 + j) * 64 + x * 4 + i) * 4
sum += 0.299 * d[k] + 0.587 * d[k + 1] + 0.114 * d[k + 2]
}
}
out[y * 16 + x] = Math.round(sum / 16)
}
}
return { s: btoa(String.fromCharCode(...out)), ar: Math.round(ar * 1000) / 1000 }
}
// Qué páginas de cada capítulo del tomo son créditos: la 0 y las 2 últimas, si se repiten en otros capítulos de la obra.
async function creditPages(mangaId, parts) {
const chapters = parts.filter(x => x.urls.length >= 5).map(x => ({ id: x.id, n: x.urls.length }))
if (!chapters.length) return {}
const ask = sigs => api(`/api/credits/${mangaId}`, { method: 'POST', body: { chapters, sigs } })
const first = await ask()
if (!first.missing.length) return first.hidden
const jobs = parts.filter(x => first.missing.includes(x.id))
const sigs = {}
let next = 0
await Promise.all(Array.from({ length: 3 }, async () => {
while (next < jobs.length) {
const x = jobs[next++], n = x.urls.length
try {
const pages = {}
for (const i of [0, n - 2, n - 1]) pages[i] = await pageSig(x.urls[i])
sigs[x.id] = pages
} catch {} // se volverá a intentar la próxima vez
}
}))
return (await ask(sigs)).hidden
}
// Las páginas de todos los capítulos del tomo de chapterId, o null si no tiene tomo o no se puede saber a tiempo.
async function loadTome(mangaId, lang, chapters, chapterId, chap, settings, hideCredits) {
const within = (p, ms) => Promise.race([p, new Promise(r => setTimeout(r, ms, null))])
const r = await within(api(`/api/volumes/${mangaId}?lang=${encodeURIComponent(lang)}`).catch(() => null), 20000) // la primera vez consulta varias fuentes
const vols = r?.volumes || []
const at = vols.findIndex(v => v.chapters.includes(chapterId))
if (at < 0) return null
const v = vols[at]
$('.reader .loading', app)?.replaceChildren(`Preparando el tomo ${v.vol}…`)
const query = `?saver=${settings.saver ? 1 : 0}&hd=${settings.hd === false ? 0 : 1}`
const want = chapters.map((c, i) => ({ c, i })).filter(x => v.chapters.includes(x.c.id))
const parts = new Array(want.length)
let nextJob = 0
await Promise.all(Array.from({ length: 4 }, async () => {
while (nextJob < want.length) {
const k = nextJob++, { c, i } = want[k]
const data = c.id === chapterId ? chap : await api(`/api/chapter/${c.id}${query}`).catch(() => null)
if (data?.pages?.length) parts[k] = { i, id: c.id, local: !!data.local, urls: data.pages }
}
}))
const ok = parts.filter(Boolean)
if (!ok.some(x => x.id === chapterId)) return null
const cover = v.front ? await within(new Promise(res => {
const im = new Image()
im.onload = () => res(im.naturalWidth > 0)
im.onerror = () => res(false)
im.src = v.front
}), 15000) : false
// Si tarda, esta vez se leen enteros; los resúmenes quedan guardados para la próxima.
const hidden = hideCredits ? (await within(creditPages(mangaId, ok).catch(() => null), 15000)) || {} : {}
const pages = cover ? [v.front] : []
const starts = new Set()
for (const x of ok) {
const out = new Set(hidden[x.id] || [])
x.keep = x.urls.map((_, j) => j).filter(j => !out.has(j))
x.start = pages.length
x.count = x.keep.length
starts.add(x.start)
pages.push(...x.keep.map(j => x.urls[j]))
}
const partOf = c => ok.find(x => x.i === c)
const tome = {
vol: v.vol, back: v.back, cover: !!cover, parts: ok, pages, starts,
has: c => !!partOf(c),
chapterAt: p => { let k = ok[0]; for (const x of ok) if (p >= x.start) k = x; return k.i },
chapterStart: c => partOf(c).start,
chapterPages: c => partOf(c).count,
// El progreso, la ruta y los recortes usan el número real de la página, con los créditos contados. Acabar la última
// visible es acabar el capítulo.
rawCount: c => partOf(c).urls.length,
rawPage: (c, local) => { const x = partOf(c); return local >= x.count - 1 ? x.urls.length - 1 : x.keep[local] },
localPage: (c, raw) => { const at = partOf(c).keep.findIndex(j => j >= raw); return at < 0 ? partOf(c).count - 1 : at },
// Las URL de MangaDex caducan a los 15 minutos, y un tomo se lee durante más tiempo.
async refresh(c) {
for (const x of ok) {
if (x.local || x.i < c || x.i > c + 1) continue
const data = await api(`/api/chapter/${x.id}${query}`).catch(() => null)
if (data?.pages?.length === x.urls.length) for (let j = 0; j < x.count; j++) pages[x.start + j] = data.pages[x.keep[j]]
}
},
}
return { tome, volOf: id => vols.find(x => x.chapters.includes(id)) || null }
}
async function viewReader(mangaId, chapterId, startPage) {
document.body.classList.add('reading')
app.innerHTML = '<div class="reader"><div class="loading">Cargando capítulo…</div></div>'
@@ -1823,9 +2011,9 @@ async function viewReader(mangaId, chapterId, startPage) {
const { manga, chapters } = info
const settings = { ...globalSettings, ...defaultPrefs(manga), ...(info.prefs || {}) }
const idx = chapters.findIndex(c => c.id === chapterId)
const chapter = chapters[idx] || { id: chapterId, num: info.progress?.chapter_num, title: '' }
const prev = idx > 0 ? chapters[idx - 1] : null
const next = idx >= 0 && idx < chapters.length - 1 ? chapters[idx + 1] : null
let chapter = chapters[idx] || { id: chapterId, num: info.progress?.chapter_num, title: '' }
let prev = idx > 0 ? chapters[idx - 1] : null
let next = idx >= 0 && idx < chapters.length - 1 ? chapters[idx + 1] : null
const isPaged = () => settings.mode !== 'vertical'
const isDouble = () => settings.mode === 'double' && window.innerWidth >= 700 && window.innerWidth > window.innerHeight
// Libro: las páginas salen de maquetar el texto para esta pantalla (con forma de página, para que nunca cuenten como anchas).
@@ -1844,7 +2032,23 @@ async function viewReader(mangaId, chapterId, startPage) {
if (location.hash !== opening) { book.release(); return }
book.busy = () => turning
}
const pages = chap.pages
// Tomo: sus capítulos, uno detrás de otro, forman un solo libro con su portada delante. La ruta y el progreso siguen
// siendo por capítulo. Sin datos del tomo, se lee el capítulo suelto.
let tome = null, nextVol = null
if (!isBook && settings.tomes !== false && idx >= 0) {
const opening = location.hash
const loaded = await loadTome(mangaId, lang, chapters, chapterId, chap, globalSettings, info.hideCredits)
if (location.hash !== opening) return
if (loaded) {
tome = loaded.tome
const first = tome.parts[0].i, last = tome.parts.at(-1).i
prev = first > 0 ? chapters[first - 1] : null
next = last < chapters.length - 1 ? chapters[last + 1] : null
nextVol = next && loaded.volOf(next.id) // lo que sigue puede ser un capítulo sin tomo
}
}
const seq = book || tome // las páginas cruzan varios capítulos
const pages = tome ? tome.pages : chap.pages
const pageSrc = book ? i => book.src(i) : i => pages[i]
const total = book ? book.count : pages.length
let wanted = startPage >= 0 ? startPage : chap.savedPage || 0
@@ -1858,6 +2062,10 @@ async function viewReader(mangaId, chapterId, startPage) {
if (bookResume?.mangaId === mangaId) wanted = book.pageAtWord(bookResume.word)
bookResume = null
}
if (tome) {
const local = tome.localPage(idx, Math.max(wanted, 0))
wanted = tome.cover && idx === tome.parts[0].i && local === 0 ? 0 : tome.chapterStart(idx) + local
}
let page = Math.min(Math.max(wanted, 0), total - 1)
let shown = [page] // páginas visibles (2 en doble página)
const lastShown = () => (isPaged() ? shown.at(-1) : page)
@@ -1866,12 +2074,13 @@ async function viewReader(mangaId, chapterId, startPage) {
let saveTimer = null
const bookPayload = (c, local, count) => ({
mangaId, chapterId: chapters[c].id, mangaTitle: manga.title, cover: manga.cover, chapterNum: chapters[c].num ?? null,
chapterTitle: chapters[c].title, lang: manga.languages?.[0] || 'es', page: local, totalPages: count, isLast: c === chapters.length - 1,
chapterTitle: chapters[c].title, lang: tome ? (manga.source !== 'md' ? 'es' : chapters[c].lang || lang) : manga.languages?.[0] || 'es', page: local, totalPages: count, isLast: c === chapters.length - 1,
})
const payload = () => {
if (!book) return mangaPayload()
const p = lastShown(), c = book.chapterAt(p)
return bookPayload(c, p - book.chapterStart(c), book.chapterPages(c))
if (!seq) return mangaPayload()
const p = lastShown(), c = seq.chapterAt(p)
const local = Math.max(0, p - seq.chapterStart(c))
return tome ? bookPayload(c, tome.rawPage(c, local), tome.rawCount(c)) : bookPayload(c, local, seq.chapterPages(c))
}
const mangaPayload = () => ({
mangaId, chapterId, mangaTitle: manga.title, cover: manga.cover, chapterNum: chapter.num ?? null,
@@ -1886,7 +2095,9 @@ async function viewReader(mangaId, chapterId, startPage) {
window.addEventListener('pagehide', beacon)
saveNow()
let curChap = book ? book.chapterAt(page) : idx
let curChap = seq ? seq.chapterAt(page) : idx
const tomeTimer = tome ? setInterval(() => tome.refresh(curChap), 10 * 60e3) : 0
const routePage = () => (tome ? tome.rawPage(curChap, Math.max(0, lastShown() - tome.chapterStart(curChap))) : lastShown())
const chapOpts = chapters.map((c, i) => `<option value="${c.id}" ${i === curChap ? 'selected' : ''}>${esc(chapLabel(c))}</option>`).join('')
app.innerHTML = `
<div class="reader">
@@ -1905,6 +2116,8 @@ async function viewReader(mangaId, chapterId, startPage) {
<select id="anim" title="Animación al pasar página (modos por página)">${Object.entries(PAGE_ANIMS).map(([k, v]) => `<option value="${k}">${v}</option>`).join('')}</select>
<select id="sfx" title="Sonido al pasar página (modos por página)">${Object.entries(SOUND_LEVELS).map(([k, [label]]) => `<option value="${k}">${label}</option>`).join('')}</select>
<select id="filter" title="Filtro">${Object.entries(FILTERS).map(([k, [label]]) => `<option value="${k}">${label}</option>`).join('')}</select>
${B3D_OK ? '<label class="row muted" style="font-size:.85rem" title="Muestra las páginas como un libro de verdad sobre una mesa"><input type="checkbox" id="b3dfx"> Libro 3D</label>' : ''}
${isBook ? '' : '<label class="row muted" style="font-size:.85rem" title="Lee los capítulos de un tomo como un solo libro, con su portada"><input type="checkbox" id="tomefx"> Por tomos</label>'}
<label class="row muted" style="font-size:.85rem" title="Brillo">☀ <input type="range" id="bright" min="40" max="120" step="5" style="width:90px"></label>
${manga.source !== 'md' || chap.local ? '' : '<label class="row muted" style="font-size:.85rem"><input type="checkbox" id="saver"> Ahorro datos</label>'}
${chap.hdAvailable ? '<label class="row muted" style="font-size:.85rem" title="Usar la versión reescalada de este capítulo"><input type="checkbox" id="hd"> Versión HD</label>'
@@ -1916,16 +2129,18 @@ async function viewReader(mangaId, chapterId, startPage) {
${OFFLINE_OK && !isBook ? `<button class="btn small" id="offone" ${offHas(chapterId) ? 'disabled' : ''} title="Guarda las páginas en este dispositivo para leerlas sin internet">${icon('phone')} ${offHas(chapterId) ? 'En el dispositivo' : 'Guardar sin conexión'}</button>` : ''}
<p class="muted">${isBook ? 'Modo y dirección se recuerdan para este libro; la letra y el papel, en este dispositivo.' : 'Modo, ajuste y dirección se recuerdan para este manga.'} ${icon('scroll')} en la barra de arriba cambia entre páginas y vertical. Zoom: pellizca o toca dos veces en el centro (Ctrl+rueda o + / − en PC).</p>
</div>
<div id="b3d" hidden></div>
<div id="pages"></div>
<div class="rbar bottom">
${book ? `<a class="btn small" id="bprev" href="#">${icon('prev')} Cap. ant.</a>` : prev ? `<a class="btn small" href="${readUrl(mangaId, prev.id)}">${icon('prev')} Cap. ant.</a>` : ''}
${seq ? `<a class="btn small" id="bprev" href="#">${icon('prev')} Cap. ant.</a>` : prev ? `<a class="btn small" href="${readUrl(mangaId, prev.id)}">${icon('prev')} Cap. ant.</a>` : ''}
<span id="pnum" class="muted"></span>
<input type="range" id="slider" min="1" max="${total}" value="${page + 1}">
<button class="btn small" id="zoomreset" hidden title="Quitar zoom">${icon('zoomOut')}</button>
${book ? `<a class="btn small chnext" id="bnext" href="#">Cap. sig. ${icon('next')}</a>` : next ? `<a class="btn small chnext" href="${readUrl(mangaId, next.id)}">Cap. sig. ${icon('next')}</a>` : ''}
${seq ? `<a class="btn small chnext" id="bnext" href="#">Cap. sig. ${icon('next')}</a>` : next ? `<a class="btn small chnext" href="${readUrl(mangaId, next.id)}">Cap. sig. ${icon('next')}</a>` : ''}
</div>
</div>`
const reader = $('.reader', app)
let b3d = null, b3dLoading = false // el libro 3D, si está activo
const box = $('#pages', app)
const slider = $('#slider', app)
$('#mode').value = settings.mode
@@ -1944,14 +2159,15 @@ async function viewReader(mangaId, chapterId, startPage) {
const applyFilter = () => {
const f = [FILTERS[settings.filter]?.[1] || '', settings.brightness != 100 ? `brightness(${settings.brightness / 100})` : ''].join(' ').trim()
box.style.setProperty('--page-filter', f || 'none')
b3d?.setFilter(f)
reader.classList.toggle('bluelight', settings.filter === 'blue')
}
applyFilter()
if ($('#saver')) $('#saver').checked = settings.saver
if ($('#hd')) $('#hd').checked = settings.hd !== false
const endBlock = book ? `<div class="chapter-end"><p class="muted">Fin del libro</p>
<a class="btn" href="#/manga/${mangaId}">${icon('back')} Volver a la ficha</a></div>` : `<div class="chapter-end"><p class="muted">Fin del capítulo ${esc(chapter.num ?? '')}</p>
${next ? `<a class="btn primary" href="${readUrl(mangaId, next.id)}">${icon('next')} Siguiente: ${esc(chapLabel(next))}</a>` : '<p>No hay más capítulos disponibles.</p>'}
<a class="btn" href="#/manga/${mangaId}">${icon('back')} Volver a la ficha</a></div>` : `<div class="chapter-end"><p class="muted">${tome ? `Fin del tomo ${esc(tome.vol)}` : `Fin del capítulo ${esc(chapter.num ?? '')}`}</p>
${next ? `<a class="btn primary" href="${readUrl(mangaId, next.id, nextVol ? 0 : null)}">${icon('next')} Siguiente: ${nextVol ? `Tomo ${esc(nextVol.vol)}` : esc(chapLabel(next))}</a>` : '<p>No hay más capítulos disponibles.</p>'}
<a class="btn" href="#/manga/${mangaId}">${icon('back')} Volver a la ficha</a></div>`
if (!info.prefs && !store.get(`autoPrefs:${mangaId}`)) {
@@ -1969,28 +2185,31 @@ async function viewReader(mangaId, chapterId, startPage) {
}
const syncBookChapter = () => {
const p = lastShown()
if (book) {
book.trim(p)
if (!isPaged()) {
box.querySelectorAll('img[src]').forEach(im => {
if (!book.near(Number(im.dataset.i), p)) { im.removeAttribute('src'); im.dataset.lazy = '1' }
})
}
const c = book.chapterAt(p)
}
const c = seq.chapterAt(p)
if (c !== curChap) {
if (c > curChap && Math.abs(p - lastBookPage) <= 2) {
for (let j = curChap; j < c; j++) {
const count = book.chapterPages(j)
const count = tome ? tome.rawCount(j) : seq.chapterPages(j)
api('/api/progress', { method: 'POST', body: bookPayload(j, count - 1, count) }).catch(() => {})
}
}
curChap = c
if (tome) { chapterId = chapters[c].id; chapter = chapters[c] }
history.replaceState(null, '', readUrl(mangaId, chapters[c].id))
}
lastBookPage = p
syncBookNav()
}
const setPageLabel = () => {
if (book) syncBookChapter()
if (seq) syncBookChapter()
$('#pnum').textContent = `${shown.length > 1 ? `${shown[0] + 1}-${shown[1] + 1}` : lastShown() + 1} / ${total}`
slider.value = lastShown() + 1
}
@@ -2015,7 +2234,8 @@ async function viewReader(mangaId, chapterId, startPage) {
// ancha ocupa las dos caras.
function spreadOf(i) {
for (let s = 0; s < total;) {
const g = s === 0 || wide.has(s) || wide.has(s + 1) || s + 1 >= total ? [s] : [s, s + 1]
// En un tomo, la primera página de cada capítulo no empieza pareja: así las parejas de dentro son las de siempre.
const g = s === 0 || tome?.starts.has(s) || wide.has(s) || wide.has(s + 1) || s + 1 >= total ? [s] : [s, s + 1]
if (g.includes(i)) return g
s += g.length
}
@@ -2054,7 +2274,7 @@ async function viewReader(mangaId, chapterId, startPage) {
const spread = spreadOf(i)
return !isDouble() && spread.length === 2 && spread[0] === i ? (rtl ? 'l' : 'r') : rtl ? 'r' : 'l'
}
const refreshGutters = () => box.querySelectorAll('.stage:not(.leaving) img').forEach((im, n) => { im.dataset.gutter = gutterOf(Number(im.dataset.i), n) })
const refreshGutters = () => { box.querySelectorAll('.stage:not(.leaving) img').forEach((im, n) => { im.dataset.gutter = gutterOf(Number(im.dataset.i), n) }); if (b3dOn()) b3d?.show(shown) }
// Vista de una página con «pasar hoja»: entre las dos caras de una pareja no se gira la hoja, se recorre con la vista.
function isPan(old) {
const imgs = old.querySelectorAll('img')
@@ -2077,7 +2297,7 @@ async function viewReader(mangaId, chapterId, startPage) {
stage.classList.toggle('two', shown.length === 2)
stage.innerHTML = shown.map((i, n) => `<img data-i="${i}" data-gutter="${gutterOf(i, n)}" src="${pageSrc(i)}" alt="" draggable="false">`).join('')
const imgs = [...stage.querySelectorAll('img')]
if ($('.stage', box) && dir && !wantDrag && !imgs.every(imgReady)) {
if ($('.stage', box) && dir && !wantDrag && !imgs.every(imgReady) && !b3dOn()) { // el libro 3D no espera: la página llega a su malla cuando esté
waitTimer = setTimeout(() => box.classList.add('waiting'), 200)
const touchedAt = grabY // el toque ya habrá acabado cuando llegue la página
const arrived = Promise.all(imgs.map(im => (im.decode ? im.decode() : new Promise(r => { im.onload = im.onerror = r })).catch(() => {})))
@@ -2110,8 +2330,11 @@ async function viewReader(mangaId, chapterId, startPage) {
})
stage.classList.toggle('loading', pending.length > 0)
// Al arrastrar, el sonido sale al soltar (si la página llega a pasar).
if (old && dir && !wantDrag && !isPan(old)) turnSound(settings.sfx, turnPan(dir))
if (old && dir && settings.anim !== 'none' && zoom.s === 1) turnPage(old, stage, dir, wantDrag)
if (dir && !wantDrag && (old ? !isPan(old) : b3dOn())) turnSound(settings.sfx, turnPan(dir))
if (b3dOn()) {
box.replaceChildren(stage)
syncB3d(settings.anim === 'flip' && zoom.s === 1 ? dir : 0, wantDrag)
} else if (old && dir && settings.anim !== 'none' && zoom.s === 1) turnPage(old, stage, dir, wantDrag)
else { box.replaceChildren(stage) }
stage.querySelectorAll('img').forEach(img => img.addEventListener('load', () => {
const i = Number(img.dataset.i)
@@ -2372,7 +2595,7 @@ async function viewReader(mangaId, chapterId, startPage) {
// giro daría un tirón. Una página que no llegó a subirse se sube al girar.
function scheduleLeafTextures(delay = 300) {
clearTimeout(leafTimer)
if (!leafGLOff && settings.anim === 'flip' && isPaged() && !box.classList.contains('fit-width')) leafTimer = setTimeout(nextLeafTexture, delay)
if (!leafGLOff && settings.anim === 'flip' && isPaged() && !box.classList.contains('fit-width') && !b3dWanted()) leafTimer = setTimeout(nextLeafTexture, delay)
}
function nextLeafTexture() {
if (turning) return scheduleLeafTextures(400)
@@ -2794,7 +3017,7 @@ async function viewReader(mangaId, chapterId, startPage) {
turning = { anims, cleanup }
anims[0].finished.then(() => { if (turning?.anims === anims) { turning = null; cleanup() } }, () => {})
}
function showEnd() {
function showEnd(dir = 0) {
finishTurn()
showSeq++ // descarta una página que estuviera esperando a cargar
clearTimeout(waitTimer)
@@ -2802,12 +3025,14 @@ async function viewReader(mangaId, chapterId, startPage) {
atEnd = true
resetZoom()
box.innerHTML = endBlock
reader.classList.remove('hide-ui')
if (!b3dWanted()) reader.classList.remove('hide-ui') // en el libro 3D el final se ve en la contraportada, sin barras encima
else if (dir && !wantDrag && settings.anim === 'flip') turnSound(settings.sfx, turnPan(dir))
syncB3d(settings.anim === 'flip' ? dir : 0, wantDrag)
}
function goTo(n, fromScroll = false, force = false, dir = 0) {
if (isPaged()) {
if (n >= total) return showEnd()
if (n >= total) return showEnd(dir)
n = Math.min(Math.max(n, 0), total - 1)
const g = groupOf(n)
const changed = atEnd || g[0] !== shown[0] || g.length !== shown.length
@@ -2816,7 +3041,7 @@ async function viewReader(mangaId, chapterId, startPage) {
shown = g
page = g[0]
setPageLabel()
if (changed || force || !$('.stage img', box)) showPaged(changed && !wasEnd ? dir : 0)
if (changed || force || !$('.stage img', box)) showPaged(changed && (!wasEnd || b3dWanted()) ? dir : 0)
if (changed) scheduleSave()
return
}
@@ -2830,7 +3055,7 @@ async function viewReader(mangaId, chapterId, startPage) {
}
const forward = () => { if (!atEnd) goTo(shown.at(-1) + 1, false, false, 1) }
const backward = () => {
if (atEnd) { box.innerHTML = ''; goTo(total - 1, false, true); return }
if (atEnd) { box.innerHTML = ''; goTo(total - 1, false, true, -1); return }
if (page > 0) goTo(groupOf(page - 1)[0], false, false, -1)
}
@@ -2841,6 +3066,67 @@ async function viewReader(mangaId, chapterId, startPage) {
$('.rbar.bottom').classList.toggle('rtl', rtl)
}
// --- Libro 3D: otro módulo dibuja el libro; el lector sigue llevando la página, el progreso y los gestos.
const b3dWanted = () => B3D_OK && !!settings.book3d && isPaged() && settings.fit !== 'width'
const b3dOn = () => b3dWanted() && !atEnd
const b3dPaper = () => {
const hex = book ? (BOOK_PAPERS[bookSettings().paper] || BOOK_PAPERS.light).bg : '#efeadf'
return [1, 3, 5].map(n => parseInt(hex.slice(n, n + 2), 16) / 255)
}
// El final del capítulo va sobre la contraportada, con sus colores, y la sigue cuando el libro se mueve.
function placeEnd(at, colors) {
const el = $('.chapter-end', box)
if (!el) return
el.classList.toggle('on', !!at)
if (!at) return
const o = box.getBoundingClientRect()
el.style.width = `${at.width}px`
el.style.height = `${at.height}px`
el.style.transform = `translate(${at.x - o.left}px, ${at.y - o.top}px)`
if (colors) for (const [k, v] of Object.entries(colors)) el.style.setProperty(`--end-${k}`, v)
}
function syncB3d(dir = 0, drag = false) {
const on = b3dWanted(), host = $('#b3d', reader)
if (!host) return
reader.classList.toggle('b3d', on)
host.hidden = !on
if (!b3dWanted()) {
b3d?.destroy()
b3d = null
return
}
// Las hojas del lector normal no se usan aquí: que no ocupen memoria dos veces.
clearTimeout(leafTimer)
for (const tex of leafTextures.values()) leafGL?.gl.deleteTexture(tex)
leafTextures.clear()
if (b3d) {
if (!on) return
const ctl = atEnd ? b3d.showBack(dir, drag) : b3d.show(shown, dir, drag)
if (ctl) turning = ctl
return
}
if (b3dLoading) return
b3dLoading = true
import(`/book3d.js?v=${document.querySelector('meta[name=b3d-version]')?.content || ''}`).then(m => {
b3dLoading = false
if (!b3dWanted() || !box.isConnected) return
b3d = m.createBook3D(host, {
total, src: pageSrc, whenReady: book ? (i, cb) => book.whenReady(i, cb) : null, spreadOf, isWide: i => wide.has(i),
dir: () => settings.dir, paper: b3dPaper, still: reduceMotion, table: me?.tableV ? `/api/table?v=${me.tableV}` : null, back: tome?.back || null, double: isDouble, onTurnEnd: ctl => { if (turning === ctl) turning = null }, onBack: placeEnd,
})
if (!b3d) throw new Error('sin WebGL2')
applyFilter()
syncB3d()
}).catch(err => {
console.warn('Libro 3D:', err)
b3dLoading = false
settings.book3d = false
saveGlobal()
if ($('#b3dfx', reader)) $('#b3dfx', reader).checked = false
syncB3d()
toast('El libro 3D no está disponible en este dispositivo')
})
}
function render() {
observer?.disconnect()
const keep = lastShown()
@@ -2849,6 +3135,7 @@ async function viewReader(mangaId, chapterId, startPage) {
box.className = isPaged() ? `paged ${settings.fit === 'width' ? 'fit-width' : ''} ${isDouble() ? 'double' : ''}` : `vertical fit-${settings.fit}`
renderModeQuick()
box.dataset.dir = settings.dir
syncB3d()
// En der → izq la barra de progreso también avanza de derecha a izquierda (en vertical no aplica).
syncDirUi()
$('#dir').hidden = !isPaged()
@@ -2898,6 +3185,7 @@ async function viewReader(mangaId, chapterId, startPage) {
const st = stageEl()
if (st) st.style.transform = zoom.s === 1 ? '' : `translate(${zoom.x}px, ${zoom.y}px) scale(${zoom.s})`
box.classList.toggle('zoomed', zoom.s > 1)
if (b3dWanted()) b3d?.setZoom(zoom.s, zoom.x, zoom.y)
$('#zoomreset').hidden = zoom.s === 1
}
function clampPan() {
@@ -2930,7 +3218,7 @@ async function viewReader(mangaId, chapterId, startPage) {
lastTap = dbl ? { t: 0, x: 0, y: 0 } : { t: now, x: cx, y: cy }
const xr = cx / window.innerWidth
const center = xr > 0.3 && xr < 0.7
if (dbl && (center || zoom.s > 1)) {
if (dbl && !atEnd && (center || zoom.s > 1)) {
clearTimeout(uiTimer)
if (zoom.s > 1) resetZoom()
else zoomAt(2.5, ...local(cx, cy))
@@ -2943,15 +3231,24 @@ async function viewReader(mangaId, chapterId, startPage) {
try { (xr >= 0.7) === (settings.dir === 'ltr') ? forward() : backward() } finally { grabY = null }
}
box.addEventListener('pointerdown', e => {
if (!isPaged() || atEnd || e.target.closest('a,button,select,input')) return
if (!isPaged() || (atEnd && !b3dWanted()) || e.target.closest('a,button,select,input')) return
// Un primer dedo con otros aún apuntados: sus «levantar» nunca llegaron. Sin esto, cada toque contaría como pellizco para siempre.
if (e.isPrimary && pts.size) {
pts.clear()
tap = null
if (gesture?.type === 'turn') finishTurn()
gesture = null
}
if (gesture?.type === 'turn') return // otro dedo mientras se pasa la página
if (e.pointerType === 'mouse' && e.button !== 0) return
if (settings.sfx !== 'off') sfxUnlock()
pts.set(e.pointerId, { x: e.clientX, y: e.clientY })
if (pts.size === 1) {
tap = { x: e.clientX, y: e.clientY, t: Date.now(), moved: false }
tap = { x: e.clientX, y: e.clientY, t: e.timeStamp, moved: false } // la hora del evento: un tirón no debe alargar el toque
gesture = zoom.s > 1 ? { type: 'pan', x: e.clientX, y: e.clientY } : null
if (gesture) box.setPointerCapture?.(e.pointerId)
} else if (pts.size === 2 && atEnd) {
tap = null // la contraportada no se amplía
} else if (pts.size === 2) {
const [a, b] = [...pts.values()]
gesture = { type: 'pinch', d: Math.hypot(a.x - b.x, a.y - b.y), mx: (a.x + b.x) / 2, my: (a.y + b.y) / 2 }
@@ -2976,14 +3273,14 @@ async function viewReader(mangaId, chapterId, startPage) {
const dx = e.clientX - tap.x, dy = e.clientY - tap.y
if (Math.abs(dx) > 14 && Math.abs(dx) > Math.abs(dy) * 1.3) {
const fwd = (dx < 0) === (settings.dir === 'ltr')
if (fwd ? shown.at(-1) + 1 < total : page > 0) {
const prev = [...shown]
if (fwd ? !atEnd && (shown.at(-1) + 1 < total || b3dWanted()) : page > 0 || atEnd) {
const prev = [...shown], wasEnd = atEnd
finishTurn()
wantDrag = true
grabY = tap.y
try { fwd ? forward() : backward() } finally { wantDrag = false; grabY = null }
if (turning?.move) {
gesture = { type: 'turn', ctl: turning, prev, dir: fwd ? 1 : -1, x0: tap.x, k: dx < 0 ? -1 : 1, d: Math.abs(dx), v: 0, t: e.timeStamp }
gesture = { type: 'turn', ctl: turning, prev, wasEnd, dir: fwd ? 1 : -1, x0: tap.x, k: dx < 0 ? -1 : 1, d: Math.abs(dx), v: 0, t: e.timeStamp }
turning.move(gesture.d / turning.range)
box.setPointerCapture?.(e.pointerId)
}
@@ -3024,6 +3321,9 @@ async function viewReader(mangaId, chapterId, startPage) {
page = g.prev[0]
setPageLabel()
scheduleSave()
if (g.wasEnd) return showEnd() // se quedó en la contraportada
atEnd = false
if (b3dOn()) showPaged() // el lector vuelve a la página de antes, sin animación
})
return
}
@@ -3031,11 +3331,11 @@ async function viewReader(mangaId, chapterId, startPage) {
const [p] = [...pts.values()]
gesture = { type: 'pan', x: p.x, y: p.y }
} else if (pts.size === 0) {
if (e.type === 'pointerup' && tap && !tap.moved && Date.now() - tap.t < 350) onTap(e.clientX, e.clientY)
if (e.type === 'pointerup' && tap && !tap.moved && e.timeStamp - tap.t < 350) onTap(e.clientX, e.clientY)
else if (e.type === 'pointerup' && tap?.moved && zoom.s === 1 && e.pointerType !== 'mouse') {
// Deslizar el dedo en horizontal pasa página (hacia la izquierda = adelante en izq→der).
const dx = e.clientX - tap.x, dy = e.clientY - tap.y
if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy) * 1.5 && Date.now() - tap.t < 800) ((dx < 0) === (settings.dir === 'ltr') ? forward : backward)()
if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy) * 1.5 && e.timeStamp - tap.t < 800) ((dx < 0) === (settings.dir === 'ltr') ? forward : backward)()
}
gesture = null
tap = null
@@ -3056,7 +3356,7 @@ async function viewReader(mangaId, chapterId, startPage) {
$('#zoomreset').addEventListener('click', resetZoom)
$('#ropts').addEventListener('click', () => { const p = $('.rpanel', reader); p.hidden = !p.hidden; $('#ropts').classList.toggle('on', !p.hidden) })
const saveGlobal = () => store.set('readerSettings', { saver: settings.saver, filter: settings.filter, brightness: settings.brightness, hd: settings.hd !== false, anim: settings.anim, sfx: settings.sfx })
const saveGlobal = () => store.set('readerSettings', { tomes: settings.tomes !== false, saver: settings.saver, filter: settings.filter, brightness: settings.brightness, hd: settings.hd !== false, anim: settings.anim, sfx: settings.sfx, book3d: !!settings.book3d })
const savePrefs = () => {
return api(`/api/prefs/${mangaId}`, { method: 'POST', body: { mode: settings.mode, fit: settings.fit, dir: settings.dir } })
.then(() => toast(`Guardado para este ${noun}`)).catch(err => toast(err.message))
@@ -3083,26 +3383,42 @@ async function viewReader(mangaId, chapterId, startPage) {
$('#sfx').addEventListener('change', e => { settings.sfx = e.target.value; saveGlobal(); if (settings.sfx !== 'off') turnSound(settings.sfx) })
$('#bright').addEventListener('input', e => { settings.brightness = Number(e.target.value); saveGlobal(); applyFilter() })
$('#bright').addEventListener('dblclick', e => { e.target.value = settings.brightness = 100; saveGlobal(); applyFilter() })
$('#saver')?.addEventListener('change', e => { settings.saver = e.target.checked; saveGlobal(); history.replaceState(null, '', readUrl(mangaId, chapterId, lastShown())); router() })
$('#hd')?.addEventListener('change', e => { settings.hd = e.target.checked; saveGlobal(); history.replaceState(null, '', readUrl(mangaId, chapterId, lastShown())); router() })
$('#saver')?.addEventListener('change', e => { settings.saver = e.target.checked; saveGlobal(); history.replaceState(null, '', readUrl(mangaId, chapterId, routePage())); router() })
$('#hd')?.addEventListener('change', e => { settings.hd = e.target.checked; saveGlobal(); history.replaceState(null, '', readUrl(mangaId, chapterId, routePage())); router() })
$('#hdone')?.addEventListener('click', async e => {
e.target.closest('button').disabled = true
try { await api(`/api/upscale/${mangaId}`, { method: 'POST', body: { chapterId } }); toast('En cola: estará en HD en unos minutos') } catch (err) { toast(err.message); e.target.closest('button').disabled = false }
})
if ($('#tomefx')) {
$('#tomefx').checked = settings.tomes !== false
$('#tomefx').addEventListener('change', e => { settings.tomes = e.target.checked; saveGlobal(); history.replaceState(null, '', readUrl(mangaId, chapterId, routePage())); router() })
}
// Un capítulo de este libro se alcanza pasando páginas; uno de otro tomo se abre aparte.
const inSeq = c => !!book || !!tome?.has(c)
$('#chap').addEventListener('change', e => {
if (book) goTo(book.chapterStart(chapters.findIndex(c => c.id === e.target.value)))
const c = chapters.findIndex(x => x.id === e.target.value)
if (seq && inSeq(c)) goTo(seq.chapterStart(c))
else location.hash = readUrl(mangaId, e.target.value)
})
if (book) {
$('#bnext').addEventListener('click', e => { e.preventDefault(); goTo(book.chapterStart(curChap + 1)) })
if (seq) {
$('#bnext').addEventListener('click', e => {
e.preventDefault()
if (inSeq(curChap + 1)) goTo(seq.chapterStart(curChap + 1))
else location.hash = readUrl(mangaId, chapters[curChap + 1].id)
})
$('#bprev').addEventListener('click', e => {
e.preventDefault()
// Al inicio del capítulo actual o, si ya se está ahí, al anterior que empiece en otra página.
let c = curChap
while (c > 0 && book.chapterStart(c) >= (shown[0] ?? page)) c--
goTo(book.chapterStart(c))
while (c > 0 && inSeq(c - 1) && seq.chapterStart(c) >= (shown[0] ?? page)) c--
if (seq.chapterStart(c) < (shown[0] ?? page)) goTo(seq.chapterStart(c))
else if (!book && tome.cover && c === tome.parts[0].i && (shown[0] ?? page) > 0) goTo(0)
else if (c > 0 && !inSeq(c - 1)) location.hash = readUrl(mangaId, chapters[c - 1].id)
else goTo(seq.chapterStart(c))
})
book.onChange = i => { box.querySelectorAll(`img[data-i="${i}"]`).forEach(im => { im.src = book.src(i) }) }
}
if (book) {
book.onChange = i => { box.querySelectorAll(`img[data-i="${i}"]`).forEach(im => { im.src = book.src(i) }); if (b3dOn()) b3d?.invalidate(i) }
}
$('#offone')?.addEventListener('click', e => {
const b = e.target.closest('button')
@@ -3127,11 +3443,13 @@ async function viewReader(mangaId, chapterId, startPage) {
crop.ov.remove()
window.removeEventListener('scroll', crop.draw)
crop = null
reader.classList.remove('b3dcrop')
$('#clipbtn')?.classList.remove('on')
}
function startCrop() {
if (crop) return endCrop()
if (atEnd || !box.querySelector('img')) return toast('Vuelve a una página para recortar')
reader.classList.add('b3dcrop') // para recortar se ve la página plana, como en el lector normal
reader.classList.remove('hide-ui')
$('.rpanel', reader).hidden = true
const ov = document.createElement('div')
@@ -3284,7 +3602,8 @@ async function viewReader(mangaId, chapterId, startPage) {
})
const blob = await new Promise(r => cv.toBlob(r, 'image/png'))
if (!blob) throw new Error('La zona es demasiado grande para este dispositivo')
const qs = new URLSearchParams({ mangaId, chapterId, page: parts[0].img.dataset.i, mangaTitle: manga.title, chapterLabel: chapLabel(chapter), cover: manga.cover || '' })
const at = Number(parts[0].img.dataset.i), from = tome ? chapters[tome.chapterAt(at)] : chapter
const qs = new URLSearchParams({ mangaId, chapterId: from.id, page: tome ? tome.rawPage(tome.chapterAt(at), Math.max(0, at - tome.chapterStart(tome.chapterAt(at)))) : at, mangaTitle: manga.title, chapterLabel: chapLabel(from), cover: manga.cover || '' })
const res = await fetch(`/api/clips?${qs}`, { method: 'POST', headers: { 'Content-Type': 'image/png' }, body: blob })
const data = await res.json().catch(() => ({}))
if (!res.ok) throw new Error(data.error || `Error ${res.status}`)
@@ -3305,6 +3624,7 @@ async function viewReader(mangaId, chapterId, startPage) {
const [cx, cy] = [box.clientWidth / 2, Math.min(box.clientHeight, window.innerHeight) / 2]
if (e.key === fwdKey || e.key === ' ') { e.preventDefault(); forward() }
else if (e.key === backKey) backward()
else if (atEnd) return
else if (e.key === '+' || e.key === '=') zoomAt(zoom.s * 1.25, cx, cy)
else if (e.key === '-') zoomAt(zoom.s / 1.25, cx, cy)
else if (e.key === '0') resetZoom()
@@ -3340,9 +3660,22 @@ async function viewReader(mangaId, chapterId, startPage) {
})
}
}
if ($('#b3dfx')) {
$('#b3dfx').checked = !!settings.book3d
$('#b3dfx').addEventListener('change', e => {
settings.book3d = e.target.checked
saveGlobal()
// iOS solo pide el permiso del sensor desde un toque, y este lo es. El resultado lo recoge el módulo al arrancar.
if (e.target.checked) (window.DeviceMotionEvent?.requestPermission || window.DeviceOrientationEvent?.requestPermission)?.call(window.DeviceMotionEvent?.requestPermission ? DeviceMotionEvent : DeviceOrientationEvent)?.catch?.(() => {})
render()
})
}
render()
cleanup = () => {
b3d?.destroy()
b3d = null
book?.release()
clearInterval(tomeTimer)
freeLeafGL()
endCrop()
if (saveTimer) saveNow()
@@ -3877,6 +4210,24 @@ async function viewStats() {
}
// ---------- Ajustes ----------
// Cualquier imagen que el navegador sepa abrir, reducida a un tamaño que la tableta pueda usar como textura.
async function tableJpeg(file) {
const url = URL.createObjectURL(file)
try {
const im = new Image()
await new Promise((ok, fail) => { im.onload = ok; im.onerror = () => fail(new Error('Este dispositivo no puede abrir esa imagen')); im.src = url })
const k = Math.min(1, 2048 / Math.max(im.naturalWidth, im.naturalHeight))
const c = Object.assign(document.createElement('canvas'), { width: Math.max(1, Math.round(im.naturalWidth * k)), height: Math.max(1, Math.round(im.naturalHeight * k)) })
const g = c.getContext('2d')
g.fillStyle = '#4a2e19' // lo transparente queda del color de la mesa
g.fillRect(0, 0, c.width, c.height)
g.imageSmoothingQuality = 'high'
g.drawImage(im, 0, 0, c.width, c.height)
const blob = await new Promise(r => c.toBlob(r, 'image/jpeg', 0.88))
if (!blob) throw new Error('No se pudo preparar la imagen')
return blob
} finally { URL.revokeObjectURL(url) }
}
function viewSettings() {
app.innerHTML = `<h1>Ajustes</h1>
<div class="settings">
@@ -3887,6 +4238,13 @@ function viewSettings() {
<section><b>${icon('book')} Qué mostrar</b>
<p class="muted" style="margin:0;font-size:.88rem">Elige qué aparece en el inicio y en tu librería. No se borra nada: lo oculto vuelve al cambiar este ajuste.</p>
<select id="contentmode">${[['both', 'Manga y libros'], ['manga', 'Solo manga'], ['books', 'Solo libros']].map(([k, v]) => `<option value="${k}" ${k === contentMode() ? 'selected' : ''}>${v}</option>`).join('')}</select></section>
<section><b>${icon('book')} Libro 3D: fondo de la mesa</b>
<p class="muted" style="margin:0;font-size:.88rem">Pon cualquier imagen en lugar de la madera. Se guarda en tu cuenta y se usa en todos tus dispositivos.</p>
<img id="tableprev" class="tableprev" alt="Fondo actual de la mesa" hidden>
<div class="row" style="flex-wrap:wrap;gap:8px">
<label class="btn small">${icon('image')} Elegir imagen<input type="file" id="tablefile" accept="image/*" hidden></label>
<button class="btn small danger" id="tabledel" hidden>${icon('trash')} Volver a la madera</button>
</div></section>
<section><b>Cambiar contraseña</b>
<form id="pw" style="display:grid;gap:8px">
<input type="password" name="old" placeholder="Contraseña actual" autocomplete="current-password" required>
@@ -3921,6 +4279,37 @@ function viewSettings() {
<div class="row"><button class="btn primary">${icon('check')} Guardar</button><button type="button" class="btn" id="ntest">${icon('send')} Enviar prueba</button></div>
</form></section>
</div>`
const showTable = () => {
const prev = $('#tableprev')
if (!prev) return
prev.hidden = !me.tableV
if (me.tableV) prev.src = `/api/table?v=${me.tableV}`
else prev.removeAttribute('src')
$('#tabledel').hidden = !me.tableV
}
showTable()
$('#tablefile').addEventListener('change', async e => {
const f = e.target.files[0]
e.target.value = ''
if (!f) return
try {
const blob = await tableJpeg(f)
const res = await fetch('/api/table', { method: 'POST', headers: { 'Content-Type': blob.type }, body: blob })
const data = await res.json().catch(() => ({}))
if (!res.ok) throw new Error(data.error || `Error ${res.status}`)
me.tableV = data.tableV
showTable()
toast('Fondo guardado')
} catch (err) { toast(err.message) }
})
$('#tabledel').addEventListener('click', async () => {
try {
await api('/api/table', { method: 'DELETE' })
me.tableV = 0
showTable()
toast('La mesa vuelve a ser de madera')
} catch (err) { toast(err.message) }
})
api('/api/notify').then(n => {
const f = $('#notify')
if (!f) return
+1065
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -3,6 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="sw-version" content="">
<meta name="b3d-version" content="">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#14121a">
<title>Manga Reader</title>
+31
View File
@@ -79,6 +79,13 @@ input:focus, select:focus { border-color: var(--accent); }
.chapters a:hover { background: var(--surface-2); }
.chapters a.read { opacity: .5; }
.chapters a.current { outline: 1px solid var(--accent); opacity: 1; }
.chapters .volhead { display: flex; align-items: center; gap: 8px; margin-top: 6px; }
.chapters .voltoggle { flex: 1; min-width: 0; display: flex; align-items: center; gap: 12px; padding: 8px; border: 0; border-radius: 8px; background: var(--surface-2); color: inherit; font: inherit; text-align: left; cursor: pointer; }
.chapters .voltoggle.static { cursor: default; padding: 10px 12px; }
.chapters .voltoggle img { width: 44px; height: 63px; object-fit: cover; border-radius: 3px; flex: none; }
.chapters .voltoggle > span { display: grid; gap: 2px; }
.chapters .voltoggle .meta { text-align: left; }
.chapters .volhead > .btn { flex: none; }
.chapters .meta { font-size: .8rem; color: var(--muted); text-align: right; flex: none; }
.history { display: grid; gap: 10px; }
@@ -86,6 +93,8 @@ input:focus, select:focus { border-color: var(--accent); }
.settings { max-width: 480px; display: grid; gap: 16px; }
.settings section { background: var(--surface); padding: 16px; border-radius: var(--radius); display: grid; gap: 10px; }
.tableprev { width: 100%; max-height: 180px; object-fit: cover; border-radius: var(--radius); }
.tableprev[hidden] { display: none; }
.loading { padding: 40px; text-align: center; color: var(--muted); }
@@ -450,3 +459,25 @@ body.reading #netbanner { display: none; }
.tag.hot { color: var(--accent); }
@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; }
/* Libro 3D: el canvas dibuja el libro y #pages sigue debajo de los dedos, invisible, para los gestos. */
#b3d { position: fixed; inset: 0; z-index: 0; }
#b3d[hidden] { display: none; }
#b3d canvas { display: block; width: 100%; height: 100%; }
.reader.b3d { background: #4a2e19; }
.reader.b3d > #pages { position: relative; z-index: 1; background: transparent; }
.reader.b3d > #pages img { visibility: hidden; }
/* Los dedos caen siempre en #pages, que no se quita. Si cayeran en la etapa, al pasar de página el «levantar» no llegaría al lector. */
.reader.b3d > #pages > * { pointer-events: none; }
/* El final del capítulo, sobre la contraportada y con sus colores. */
.reader.b3d .chapter-end { position: absolute; left: 0; top: 0; padding: 10%; align-content: center; gap: 12px; color: var(--end-ink, #fff); filter: var(--page-filter, none);
opacity: 0; visibility: hidden; transition: opacity .3s, visibility .3s; will-change: transform; }
.reader.b3d .chapter-end.on { opacity: 1; visibility: visible; }
.reader.b3d .chapter-end p { margin: 0; padding: 3px 12px; border-radius: 6px; background: var(--end-surface, transparent); color: var(--end-ink, #fff); font-weight: 600; }
.reader.b3d .chapter-end .btn { pointer-events: auto; width: 100%; max-width: 320px; min-height: 44px; white-space: normal; text-align: center;
background: var(--end-surface, #222); color: var(--end-ink, #fff); border-color: var(--end-line, #888); box-shadow: 0 2px 6px rgba(0, 0, 0, .35); }
.reader.b3d .chapter-end .btn.primary { background: var(--end-accent, #e50); color: var(--end-accentInk, #fff); border-color: var(--end-accent, #e50); }
.reader.b3d .chapter-end .btn:focus-visible { outline: 2px solid var(--end-ink, #fff); outline-offset: 2px; }
/* Para recortar se ve la página plana. */
.reader.b3d.b3dcrop { background: #000; }
.reader.b3d.b3dcrop > #b3d { display: none; }
.reader.b3d.b3dcrop > #pages img { visibility: visible; }
+1 -1
View File
@@ -2,7 +2,7 @@
// dispositivo" (caché OFFLINE, la llena app.js) se leen sin red.
const SHELL = 'shell-v1'
const OFFLINE = 'offline-v1'
const SHELL_FILES = ['/', '/app.js', '/style.css', '/icon.svg', '/manifest.webmanifest', '/icon-192.png', '/icon-512.png']
const SHELL_FILES = ['/', '/app.js', '/style.css', '/book3d.js', '/icon.svg', '/manifest.webmanifest', '/icon-192.png', '/icon-512.png']
// Se guarda la portada tal como la sirve el servidor y justo las versiones de app.js y style.css que
// enlaza (?v=…); cache: 'reload' para no copiar lo que tenga la caché HTTP (un proxy puede alargarla horas).
+57 -2
View File
@@ -15,6 +15,8 @@ import { setupSecurity, safeFetch } from './security.js'
import { setupM440, MI_BASE } from './m440.js'
import { setupConnectivity } from './connectivity.js'
import { setupMeta } from './meta.js'
import { setupVolumes } from './volumes.js'
import { setupCredits } from './credits.js'
import { setupBooks, isBookId } from './books.js'
const PORT = Number(process.env.PORT || 3352)
@@ -771,6 +773,45 @@ function json(res, status, body, headers = {}) {
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', ...headers })
res.end(JSON.stringify(body))
}
// Fondo de la mesa del libro 3D, por usuario. El navegador lo reduce antes de subirlo; aquí solo se aceptan estos formatos, mirando el contenido.
const TABLE_DIR = path.join(DATA_DIR, 'tables'), TABLE_MAX = 24 * 1024 * 1024
const tableType = b => {
if (b.length < 16) return null
if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'image/jpeg'
if (b.readUInt32BE(0) === 0x89504e47) return 'image/png'
if (b.toString('latin1', 0, 4) === 'RIFF' && b.toString('latin1', 8, 12) === 'WEBP') return 'image/webp'
return null
}
fs.mkdirSync(TABLE_DIR, { recursive: true })
const tableFile = id => path.join(TABLE_DIR, `${Number(id)}.img`)
const tableVersion = id => { try { return Math.round(fs.statSync(tableFile(id)).mtimeMs) } catch { return 0 } }
async function tableRoute(method, req, res, user, q) {
const file = tableFile(user.id)
if (method === 'GET') {
let buf
try { buf = fs.readFileSync(file) } catch { throw new HttpError(404, 'Sin fondo') }
res.writeHead(200, { 'Content-Type': tableType(buf) || 'application/octet-stream', 'Content-Length': buf.length, 'Cache-Control': q.get('v') ? 'private, max-age=31536000, immutable' : 'private, no-cache' })
return res.end(buf)
}
if (method === 'DELETE') {
fs.rmSync(file, { force: true })
return json(res, 200, { tableV: 0 })
}
if (method !== 'POST') throw new HttpError(405, 'Método no permitido')
let size = 0
const chunks = []
for await (const c of req) {
size += c.length
if (size > TABLE_MAX) throw new HttpError(413, 'La imagen es demasiado grande')
chunks.push(c)
}
const buf = Buffer.concat(chunks)
if (!tableType(buf)) throw new HttpError(400, 'La imagen debe ser JPEG, PNG o WebP')
const tmp = `${file}.tmp`
fs.writeFileSync(tmp, buf)
fs.renameSync(tmp, file)
return json(res, 200, { tableV: tableVersion(user.id) })
}
async function readBody(req) {
let size = 0
const chunks = []
@@ -801,6 +842,7 @@ const assetVersion = name => crypto.createHash('sha256').update(fs.readFileSync(
const INDEX_HTML = fs.readFileSync(path.join(PUBLIC_DIR, 'index.html'), 'utf8')
.replace('src="/app.js"', `src="/app.js?v=${assetVersion('app.js')}"`)
.replace('href="/style.css"', `href="/style.css?v=${assetVersion('style.css')}"`)
.replace('<meta name="b3d-version" content="">', `<meta name="b3d-version" content="${assetVersion('book3d.js')}">`)
.replace('<meta name="sw-version" content="">', `<meta name="sw-version" content="${assetVersion('sw.js')}">`)
function serveStatic(res, pathname) {
let file = path.join(PUBLIC_DIR, path.normalize(pathname).replace(/^(\.\.[/\\])+/, ''))
@@ -836,7 +878,8 @@ async function route(req, res) {
// --- Auth ---
if (p === '/api/me' && method === 'GET') {
const userCount = db.prepare('SELECT COUNT(*) n FROM users').get().n
return json(res, 200, { user: getUser(req), signup: ALLOW_SIGNUP || userCount === 0 })
const u = getUser(req)
return json(res, 200, { user: u && { ...u, tableV: tableVersion(u.id) }, signup: ALLOW_SIGNUP || userCount === 0 })
}
if ((p === '/api/login' || p === '/api/register') && method === 'POST') {
rateLimit(ip)
@@ -873,12 +916,14 @@ async function route(req, res) {
// Todo lo demás requiere sesión.
const user = requireUser(req)
if (p === '/api/table') return tableRoute(method, req, res, user, q)
// Un libro subido solo existe para quien lo subió, en cualquier ruta y parámetro que lo nombre.
let rawUrl = req.url
try { rawUrl = decodeURIComponent(req.url) } catch {}
for (const [ub] of rawUrl.matchAll(/ub~[0-9a-f]{32}/g)) if (!books.owns(user.id, ub)) throw new HttpError(404, 'No encontrado')
if (p.startsWith('/api/bans') && security.route(p, method, req, res, json) !== false) return
if ((await features.route(p, method, q, req, res, user)) !== false) return
if ((await credits.route(p, method, req, res, user)) !== false) return
if ((await upscale.route(p, method, req, res)) !== false) {
// Pedir el reescalado (o el automático) lo pone en la biblioteca de quien lo pide.
if (method === 'POST' && (m = /^\/api\/upscale\/([^/]+)(\/auto)?$/.exec(p)) && db.prepare('SELECT 1 FROM downloads WHERE manga_id = ?').get(m[1])) linkDownload(user.id, m[1])
@@ -948,6 +993,14 @@ async function route(req, res) {
}
let m
if ((m = /^\/api\/volumes\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'GET') {
return json(res, 200, { volumes: isBookId(m[1]) ? [] : await volumes.list(m[1], lang) })
}
if ((m = /^\/api\/tome\/([^/]+)\/([^/]+)\/(front|back)$/.exec(p)) && method === 'GET') {
let vol
try { vol = decodeURIComponent(m[2]) } catch { throw new HttpError(404, 'Cubierta no encontrada') }
return volumes.sendFace(res, m[1], vol, m[3])
}
if ((m = /^\/api\/manga\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'GET') {
let manga, chapters, offline = false
try {
@@ -966,7 +1019,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]), info: isBookId(m[1]) ? null : meta.cachedInfo(m[1]), clipBanner: clips.bannerFor(user.id, m[1]), ...features.mangaExtras(user.id, manga) })
return json(res, 200, { manga, chapters, lang, reads, progress, chapterLangs, offline, downloaded, hideCredits: credits.enabled(user.id, m[1]), 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: isBookId(m[1]) ? null : meta.cachedInfo(m[1]), clipBanner: clips.bannerFor(user.id, m[1]), ...features.mangaExtras(user.id, manga) })
}
if (p === '/api/books' && method === 'POST') return json(res, 200, await books.upload(req, user))
if ((m = /^\/api\/books\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && m[1].startsWith('ub~') && method === 'DELETE') {
@@ -1116,6 +1169,8 @@ const library = setupLibrary({ db, HttpError, json, readBody, MANGA_ID, CHAPTER_
// Al volver internet: reanudar las descargas en cola.
net.whenOnline(() => pumpDownloads())
const meta = setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, net, decode, imgUrl, UA, prepareBanner })
const credits = setupCredits({ db, HttpError, json, readBody, MANGA_ID, CHAPTER_ID })
const volumes = setupVolumes({ md, sourceOf, fetchMangaFull, stored: meta.stored, mdMatches: meta.mdMatches, muVolumes: meta.muVolumes, similar: meta.similar, miSearch: q => mi.search(q), imgUrl, DATA_DIR, UA, HttpError, net })
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
+706
View File
@@ -0,0 +1,706 @@
// Tomos: qué capítulos forman cada tomo y sus cubiertas originales.
// - Reparto, capítulo a capítulo: lista de Wikipedia, MangaDex (la edición con más datos), MangaUpdates y M440. Ninguna basta: MangaDex
// pierde los capítulos licenciados y M440 mete lo reciente en un tomo cajón. Cada una se limpia y se contrasta con las demás.
// - Portada: /cover de MangaDex, a tamaño original (prefiere la edición japonesa).
// - Contraportada: la Biblioteca Nacional de Japón (NDL) da el ISBN de cada tomo y Amazon sirve la imagen por ISBN.
// Si la edición japonesa no la tiene, se prueba la inglesa (ISBN por Open Library).
// Lo descargado queda en <DATA_DIR>/tomes/<id de MangaDex>/<tomo>-<cara>.img.
import fs from 'node:fs'
import path from 'node:path'
import crypto from 'node:crypto'
const DAY = 24 * 3600e3
const NDL = 'https://ndlsearch.ndl.go.jp/api/opensearch'
const OPEN_LIBRARY = 'https://openlibrary.org/search.json'
const AMAZON = 'https://m.media-amazon.com/images/P'
const WIKIPEDIA = 'https://en.wikipedia.org/w/api.php'
const MAX_IMAGE = 30 * 1024 * 1024
const MIN_IMAGE = 8000 // por debajo son el píxel transparente o una miniatura de Amazon
const VOL = /^[\p{L}\p{N}._-]{1,16}$/u
const MD_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
// "01" -> "1", "1.50" -> "1.5", "Vol. 3" no es un número y se queda igual.
export const volKey = v => {
const s = String(v ?? '').trim()
if (!s || s.toLowerCase() === 'none') return null
return /^\d+(\.\d+)?$/.test(s) ? String(parseFloat(s)) : s
}
const numKey = n => { const f = parseFloat(n); return Number.isFinite(f) ? String(f) : null }
const volOrder = (a, b) => (parseFloat(a) || Infinity) - (parseFloat(b) || Infinity) || String(a).localeCompare(String(b))
// Título comparable: sin signos, espacios ni diferencias de anchura.
export const titleKey = s => String(s || '').normalize('NFKC').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '')
export function ndlVolume(s) {
const m = /^(?:vol\.?|第)?\s*(\d{1,4})\s*巻?$/i.exec(String(s || '').normalize('NFKC').trim())
return m ? String(parseInt(m[1], 10)) : null
}
export function isbn10(raw) {
const s = String(raw || '').replace(/[^0-9Xx]/g, '').toUpperCase()
if (/^\d{9}[\dX]$/.test(s)) return s
if (!/^978\d{10}$/.test(s)) return null
const body = s.slice(3, 12)
let sum = 0
for (let i = 0; i < 9; i++) sum += (10 - i) * Number(body[i])
const c = (11 - (sum % 11)) % 11
return body + (c === 10 ? 'X' : String(c))
}
const xmlText = s => String(s || '').replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/g, "'").trim()
// De los registros de la NDL, los tomos de esta obra: mismo título exacto, tomo numérico, clasificado como manga y del
// autor con más tomos. Si el primer tomo sale muy lejos del año de la obra, es otra obra con el mismo título.
export function pickIsbns(xmlPages, titles, year) {
const keys = new Set(titles.map(titleKey).filter(Boolean))
const groups = new Map()
for (const xml of xmlPages) {
for (const [, it] of xml.matchAll(/<item>([\s\S]*?)<\/item>/g)) {
if (!keys.has(titleKey(xmlText(/<dc:title>([\s\S]*?)<\/dc:title>/.exec(it)?.[1])))) continue
const vol = ndlVolume(xmlText(/<dcndl:volume>([\s\S]*?)<\/dcndl:volume>/.exec(it)?.[1]))
if (!vol) continue
const isbns = [...it.matchAll(/xsi:type="dcndl:ISBN">([^<]+)</g)].map(m => isbn10(m[1])).filter(Boolean)
if (!isbns.length) continue
const author = titleKey(xmlText(/<author>([\s\S]*?)<\/author>/.exec(it)?.[1]).split(/[, ]/)[0]) || '?'
const when = parseInt(/<dc:date[^>]*>(\d{4})/.exec(it)?.[1], 10) || null
if (!groups.has(author)) groups.set(author, new Map())
const g = groups.get(author)
if (!g.has(vol)) g.set(vol, { isbns: [], when })
const e = g.get(vol)
for (const i of isbns) if (!e.isbns.includes(i)) e.isbns.push(i)
if (when && (!e.when || when < e.when)) e.when = when
}
}
const firstYear = g => Math.min(...[...g.values()].map(e => e.when || Infinity))
const best = [...groups.values()]
.filter(g => !year || !Number.isFinite(firstYear(g)) || Math.abs(firstYear(g) - year) <= 3)
.sort((a, b) => b.size - a.size)[0]
if (!best) return {}
return Object.fromEntries([...best].map(([v, e]) => [v, e.isbns]))
}
// Open Library: solo el título exacto seguido del tomo («Berserk of Gluttony, Vol. 1» no es Berserk) y solo ISBN en inglés.
// Ediciones de varios tomos y otros libros: su contraportada no es la de este tomo. One Piece "Vol. 1 (Romance Dawn /
// Buggy the Clown / Don't Get Fooled Again)" es en realidad el ómnibus East Blue 1-2-3.
const SEVERAL_VOLUMES = /omnibus|ómnibus|\d\s*-?\s*in\s*-?\s*\d|\d\s*[-–&]\s*\d|\s\/\s|box(ed)? set|collection|coloring|how to draw/
export function pickEnglishIsbns(docs, titles, vol) {
const out = []
for (const title of titles) {
const esc = titleLower(title).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const re = new RegExp(`^${esc}[,:]?\\s*(?:vol(?:ume)?\\.?\\s*)?0*${vol}(?:\\D|$)`)
for (const d of docs) {
if (!re.test(titleLower(d.title)) || SEVERAL_VOLUMES.test(titleLower(d.title))) continue
for (const x of d.isbn || []) {
const i = isbn10(x)
if (i && /^[01]/.test(i) && !out.includes(i)) out.push(i)
}
}
}
return out
}
const titleLower = s => String(s || '').normalize('NFKC').toLowerCase().replace(/\s+/g, ' ').trim()
// Una por tomo: la japonesa, si no la del idioma de lectura, luego la inglesa y luego cualquiera.
export function pickCovers(records, lang) {
const rank = l => { const i = ['ja', lang, 'en'].indexOf(l); return i < 0 ? 9 : i }
const out = {}
for (const r of records) {
const vol = volKey(r.attributes?.volume), file = r.attributes?.fileName, l = r.attributes?.locale
if (!vol || !file) continue
if (!out[vol] || rank(l) < rank(out[vol].locale)) out[vol] = { file, locale: l }
}
return out
}
function imageKind(b) {
if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'image/jpeg'
if (b.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return 'image/png'
if (b.subarray(0, 4).toString('latin1') === 'RIFF' && b.subarray(8, 12).toString('latin1') === 'WEBP') return 'image/webp'
return null
}
/** MangaDex trae errores de quien subió cada capítulo: el 232 en el tomo 1 de Chainsaw Man, por ejemplo.
* Devuelve los números que quedan lejos del resto de su tomo. Con menos de 3 no hay con qué comparar. */
export function strayNums(nums) {
const known = nums.map(parseFloat).filter(Number.isFinite).sort((a, b) => a - b)
if (known.length < 3) return new Set()
const middle = a => (a[(a.length - 1) >> 1] + a[a.length >> 1]) / 2
const center = middle(known)
const spread = middle(known.map(n => Math.abs(n - center)).sort((a, b) => a - b))
const limit = Math.max(4 * spread, 5)
const far = known.filter(n => Math.abs(n - center) > limit)
return new Set(far.length > Math.max(1, known.length * 0.15) ? [] : far) // varios lejos: es la numeración de la obra (Berserk)
}
/** Un mapa capítulo -> tomo sin lo que no es fiable: capítulos fuera de sitio y tomos cajón, mucho mayores que los demás. */
export function cleanVolMap(map) {
const byVol = new Map()
for (const [n, v] of Object.entries(map)) byVol.set(v, [...(byVol.get(v) || []), n])
const whole = nums => new Set(nums.map(n => Math.floor(parseFloat(n)))).size // el 2 y el 2.2 son un capítulo partido
const sizes = [...byVol.values()].map(whole).sort((a, b) => a - b)
const usual = sizes[(sizes.length - 1) >> 1]
const out = {}
for (const [v, nums] of byVol) {
const size = whole(nums)
if (size >= 40 || (size >= 20 && (sizes.length === 1 || size > 3 * usual))) continue
const stray = /^\d/.test(v) ? strayNums(nums) : new Set()
for (const n of nums) if (!stray.has(parseFloat(n))) out[n] = v
}
return out
}
/** ¿Dos fuentes dan el mismo reparto donde coinciden? Si numeran distinto (Berserk en Wikipedia), no. */
export function volMapsAgree(a, b) {
const shared = Object.keys(a).filter(n => n in b)
if (shared.length < 5) return true
return shared.filter(n => a[n] === b[n]).length / shared.length >= 0.6
}
/** Junta las fuentes por orden de preferencia; cada una solo rellena lo que falta. */
export function mergeVolMaps({ wiki = {}, md = {}, mu = {}, mi = {} }) {
const merged = {}
// M440 acierta más que las anotaciones de MangaUpdates, salvo si le falta un tomo en medio: suele haberlo unido al anterior.
const shown = new Set(Object.values(mi).map(Number))
const inMu = new Set(Object.values(mu).map(Number))
const miHasHole = [...inMu].some(v => !shown.has(v) && v < Math.max(...shown))
for (const src of [volMapsAgree(wiki, md) ? wiki : {}, md, ...(miHasHole ? [mu, mi] : [mi, mu])]) {
if (!volMapsAgree(src, merged)) continue
for (const [n, v] of Object.entries(src)) if (!(n in merged)) merged[n] = v
}
return merged
}
/** Publicaciones de MangaUpdates: cada grupo anota, a veces, el tomo de lo que publica ("35a", "12-14"). Si discrepan, gana la mayoría. */
export function parseMuVolumes(rows) {
const votes = {}
for (const { volume, chapter } of rows) {
const vol = /^\s*0*([1-9]\d{0,3})\s*$/.exec(String(volume ?? ''))?.[1]
if (!vol) continue
const ch = String(chapter ?? '').trim()
const one = /^(\d+(?:\.\d+)?)[a-z]?$/i.exec(ch)
const range = /^(\d+(?:\.\d+)?)[a-z]?\s*-\s*(\d+(?:\.\d+)?)[a-z]?$/i.exec(ch)
const nums = one ? [parseFloat(one[1])] : []
if (range) {
const [lo, hi] = [parseFloat(range[1]), parseFloat(range[2])]
if (hi >= lo && hi - lo < 50) {
nums.push(lo, hi)
for (let k = Math.ceil(lo); k <= hi; k++) nums.push(k)
}
}
for (const n of new Set(nums)) {
const count = (votes[n] ||= {})
count[vol] = (count[vol] || 0) + 1
}
}
const map = {}
for (const [n, count] of Object.entries(votes)) map[n] = Object.keys(count).reduce((a, b) => (count[b] > count[a] ? b : a))
return map
}
/** Lo aprovechable de las anotaciones de MangaUpdates: sin lo que rompe el orden de los tomos. Si no incluyen el tomo 1, solo valen
* si cubren al menos una cuarta parte de los capítulos (si no, son anotaciones sueltas). */
export function orderedMuMap(map, total = 0) {
const nums = Object.keys(map).sort((a, b) => parseFloat(a) - parseFloat(b))
const vol = nums.map(n => parseInt(map[n], 10))
// La subsecuencia no decreciente más larga.
const len = vol.map(() => 1), from = vol.map(() => -1)
let end = 0
vol.forEach((v, i) => {
for (let k = 0; k < i; k++) if (vol[k] <= v && len[k] + 1 > len[i]) { len[i] = len[k] + 1; from[i] = k }
if (len[i] > len[end]) end = i
})
const out = {}
for (let i = nums.length ? end : -1; i >= 0; i = from[i]) out[nums[i]] = map[nums[i]]
const size = Object.keys(out).length
return Object.values(out).includes('1') || (total && size >= total / 4) ? out : {}
}
/** MangaUpdates apunta el tomo de unos pocos capítulos. Se completa lo que queda claro entre dos de ellos:
* antes del primero, si es el tomo 1; entre dos del mismo tomo; y tras un extra (5.5, el cierre de su tomo) si después viene el tomo siguiente. */
export function fillVolGaps(map, nums) {
const anchors = Object.keys(map).map(parseFloat).sort((a, b) => a - b)
const out = { ...map }
for (const key of nums) {
if (key == null || key in map) continue
const n = parseFloat(key)
const prev = anchors.filter(a => a < n).at(-1)
const next = anchors.find(a => a > n)
if (next === undefined) continue
const after = map[next]
if (prev === undefined) {
if (after === '1' && n >= 1) out[key] = after // el capítulo 0 no suele ser parte del tomo
continue
}
const before = map[prev]
if (before === after ? next - prev <= 5 : !Number.isInteger(prev) && parseInt(after, 10) === parseInt(before, 10) + 1 && next - prev <= 15) out[key] = after
}
return out
}
export const ownVolMap = chapters => {
const map = {}
for (const c of chapters) {
const n = numKey(c.num), v = volKey(c.volume)
if (n && v && !(n in map)) map[n] = v
}
return map
}
/** Numeración que vuelve a empezar en cada tomo: el número no identifica el capítulo y solo vale su propio tomo. */
export function numbersRestart(chapters) {
const seen = new Map()
let repeated = 0
for (const c of chapters) {
const n = numKey(c.num), v = volKey(c.volume)
if (!n || !v) continue
if (seen.has(n) && seen.get(n) !== v) repeated++
else seen.set(n, v)
}
return repeated >= 3
}
/** El tomo de cada capítulo, en el orden de la lista (de menor a mayor). Un extra (7.5) va con el capítulo al que sigue,
* y lo que no tiene tomo entre dos del mismo se queda con ellos, para que el libro no se salte nada. */
export function assignVolumes(chapters, map = {}) {
const vols = chapters.map(c => map[numKey(c.num)] ?? null)
vols.forEach((v, i) => {
const n = parseFloat(chapters[i].num)
if (!v && i && vols[i - 1] && !Number.isInteger(n) && Math.floor(n) === Math.floor(parseFloat(chapters[i - 1].num))) vols[i] = vols[i - 1]
})
let last = -1
vols.forEach((v, i) => {
if (!v) return
if (last >= 0 && vols[last] === v && i - last <= 4) for (let k = last + 1; k < i; k++) vols[k] = v // un hueco grande no se sabe qué tiene
last = i
})
return vols
}
/** Quita el tomo a los capítulos mal etiquetados: un tramo corto cuyo tomo rompe el orden, y sin él los de alrededor quedan en orden
* (T14, T13, T15). Si no se arregla al quitarlo, es que la obra numera así: Berserk recorre los tomos 1-5 con el prólogo (0.01-0.2) y vuelve al T1 con el capítulo 1. */
export function dropMisplaced(vols, maxRun = 3) {
const out = [...vols]
if (!vols.every(v => !v || /^\d+$/.test(v))) return out
const runsOf = () => {
const runs = []
out.forEach((v, i) => {
if (!v) return
if (runs.at(-1)?.v === parseInt(v, 10)) runs.at(-1).at.push(i)
else runs.push({ v: parseInt(v, 10), at: [i] })
})
return runs
}
// De uno en uno y primero el más corto: al quitar un tramo, sus vecinos dejan de parecer mal puestos.
for (;;) {
const runs = runsOf()
const wrong = runs.filter((r, j) => {
if (r.at.length > maxRun) return false
const [a, b] = [runs[j - 1], runs[j + 1]]
if (a && b) return a.v <= b.v && (r.v < a.v || r.v > b.v)
const near = a || b
return near && r.at.length < near.at.length && (a ? r.v < a.v : r.v > b.v)
})
if (!wrong.length) return out
const hit = wrong.reduce((x, y) => (y.at.length < x.at.length ? y : x))
for (const i of hit.at) out[i] = null
}
}
/** Estima el tomo de lo que ninguna fuente sitúa: solo pueden quedar sin tomo los capítulos del final que no quepan en un tomo conocido.
* known: los tomos que se sabe que existen (tienen portada), aunque no se sepa qué capítulos llevan. */
export function placeLoose(chapters, vols, known = []) {
const out = dropMisplaced(vols)
const isNum = v => /^\d+$/.test(v ?? '')
const floorOf = i => Math.floor(parseFloat(chapters[i].num))
// Un corte no separa un capítulo de sus partes o su extra (5 y 5.5).
const cut = i => { while (i > 0 && i < chapters.length && floorOf(i) === floorOf(i - 1)) i++; return i }
const spread = (from, to, names) => {
let start = from
names.forEach((name, j) => {
const end = j === names.length - 1 ? to : Math.min(to, cut(from + Math.round((to - from) * (j + 1) / names.length)))
for (let i = start; i < end; i++) out[i] = name
start = Math.max(start, end)
})
}
const last = known.filter(isNum).reduce((a, v) => Math.max(a, parseInt(v, 10)), 0)
const assigned = out.flatMap((v, i) => (v ? [i] : []))
if (!assigned.length) {
if (last && chapters.length >= last * 2) spread(0, chapters.length, Array.from({ length: last }, (_, i) => String(i + 1)))
return out
}
const count = {}
for (const i of assigned) count[out[i]] = (count[out[i]] || 0) + 1
const sizes = Object.values(count).sort((a, b) => a - b)
const usual = Math.max(1, sizes[(sizes.length - 1) >> 1])
const between = (a, b) => { // los tomos que aún no tienen capítulos en ese tramo de números
const free = []
for (let k = a + 1; k < b; k++) if (!count[k]) free.push(String(k))
return free
}
// Antes del primer tomo.
const first = assigned[0]
if (first) {
const head = out[first]
const free = isNum(head) ? between(0, parseInt(head, 10)) : []
const n = Math.min(free.length, Math.round(first / usual))
if (n) spread(0, first, free.slice(-n))
else for (let i = 0; i < first; i++) out[i] = head
}
// Entre dos tomos.
for (let a = 0; a < assigned.length - 1; a++) {
const [l, r] = [assigned[a], assigned[a + 1]]
if (r - l < 2) continue
const [left, right] = [out[l], out[r]]
const free = isNum(left) && isNum(right) ? between(parseInt(left, 10), parseInt(right, 10)) : []
if (free.length) {
// Se igualan también los dos tomos de los lados: suelen venir de datos parciales (T1 con 4 capítulos, T3 con 1).
const per = (count[left] + count[right] + r - l - 1) / (free.length + 2)
const from = cut(l + 1 + Math.max(0, Math.round(per - count[left])))
const to = Math.max(from, r - Math.max(0, Math.round(per - count[right])))
for (let i = l + 1; i < from; i++) out[i] = left
for (let i = to; i < r; i++) out[i] = right
spread(from, to, free)
count[left] += from - l - 1
count[right] += r - to
continue
}
if (left === right || !isNum(left) || !isNum(right) || parseInt(right, 10) < parseInt(left, 10)) {
for (let i = l + 1; i < r; i++) out[i] = left
continue
}
// Dos tomos seguidos: el corte que más los iguala. Si hay un extra, cierra tomo: se corta justo después.
const whole = []
for (let i = l + 1; i <= r; i++) if (floorOf(i) !== floorOf(i - 1)) whole.push(i)
if (!whole.length) whole.push(r) // todo es del capítulo del tomo de la izquierda
const closes = whole.filter(i => i > l + 1 && !Number.isInteger(parseFloat(chapters[i - 1].num)))
const score = i => Math.abs((count[left] + i - l - 1) - (count[right] + r - i))
const at = (closes.length ? closes : whole).reduce((x, y) => (score(y) < score(x) ? y : x))
for (let i = l + 1; i < r; i++) out[i] = i < at ? left : right
count[left] += at - l - 1
count[right] += r - at
}
// Tras el último: solo en tomos que existen. Lo que no quepa se queda sin tomo.
const end = assigned.at(-1)
const free = isNum(out[end]) ? between(parseInt(out[end], 10), last + 1) : []
const rest = chapters.length - end - 1
if (free.length && rest) {
const fits = rest <= free.length * usual * 1.3
spread(end + 1, fits ? chapters.length : cut(end + 1 + free.length * usual), free)
}
return out
}
/** Cuántos elementos tiene {{Numbered list|start=N|a|b|c}}: las barras de primer nivel, sin contar las de sus plantillas. */
function numberedListCount(text, from) {
let depth = 0, bars = 0
for (let i = from; i < text.length - 1; i++) {
const two = text.slice(i, i + 2)
if (two === '{{' || two === '[[') { depth++; i++ } else if (two === '}}' || two === ']]') {
if (--depth < 0) break
i++
} else if (text[i] === '|' && depth === 0) bars++
}
return bars
}
/** Listas de capítulos de Wikipedia ({{Graphic novel list}}). Cada página numera a su manera: {{Numbered list|start=N}},
* "* 001. Título", "*Mission: 1–5" o una lista sin números, que sigue la cuenta. Las obras derivadas, que vuelven a
* empezar por el tomo 1, se ignoran. */
export function parseWikiVolumes(text) {
const map = {}, seen = new Set()
let next = 1
for (const block of text.split(/\{\{\s*Graphic novel list(?![\w/ ]*(?:header|\/))/i).slice(1)) {
const vol = volKey(/\|\s*VolumeNumber\s*=\s*([^\n|<]*)/i.exec(block)?.[1])
if (!vol) continue
if (seen.has(vol)) break
seen.add(vol)
const fields = [...block.matchAll(/\|\s*ChapterList(?:Col\d)?\s*=/gi)]
if (!fields.length) continue
const add = n => { map[String(n)] = vol; next = Math.max(next, n + 1) }
for (const f of fields) {
const start = f.index + f[0].length
const end = block.slice(start).search(/\n\s*\|\s*(?:ChapterList|LicensedTitle|Summary|LineColor|OriginalRelDate|OriginalISBN|LicensedRelDate|LicensedISBN|Volume\w*|Title)\w*\s*=/i)
const body = block.slice(start, end < 0 ? undefined : start + end)
const numbered = [...body.matchAll(/\{\{\s*(?:Numbered|Ordered) list\s*\|\s*start\s*=\s*(\d+)/gi)]
if (numbered.length) {
for (const m of numbered) {
const first = parseInt(m[1], 10)
for (let k = 0, c = numberedListCount(body, m.index + m[0].length); k < c; k++) add(first + k)
}
continue
}
const range = /^\s*\*?\s*(?:[\p{L} ]+:)?\s*(\d+)\s*[–-]\s*(\d+)\s*$/mu.exec(body)
if (range) {
for (let n = parseInt(range[1], 10), e = parseInt(range[2], 10); n <= e && e - n < 500; n++) add(n)
continue
}
for (const line of body.split('\n')) {
if (!/^\s*[*#]/.test(line)) continue
const own = /^\s*[*#]+\s*(?:Chapter\s*)?(\d+)\s*[.:]/i.exec(line)
add(own ? parseInt(own[1], 10) : next)
}
}
}
return map
}
/** El título de la obra sin la edición: "One Piece Full Color" es One Piece, con los mismos tomos. */
export function workTitle(title) {
const t = String(title || '').trim()
const base = t
.replace(/\s*[([{]\s*(?:(?:full|fan|official|digital(?:ly)?|a todo|a|todo)\s+)?colou?r(?:ed|ized|eado)?(?:\s+edition)?\s*[)\]}]\s*$/i, '')
.replace(/\s*[-–:]?\s*(?:full|fan|official|digital(?:ly)?)\s+colou?r(?:ed|ized)?(?:\s+edition)?\s*$/i, '')
.replace(/\s*[-–:]?\s*(?:a todo color|a color)\s*$/i, '')
return base.length >= 3 ? base : t
}
/** Manhwa y manhua no se publican por tomos: se leen por capítulos. */
export const hasNoVolumes = (type, original) => ['manhwa', 'manhua', 'webtoon'].includes(String(type || '').toLowerCase()) || ['ko', 'zh', 'zh-hk'].includes(original)
export const wikiKey = t => String(t).toLowerCase().replace(/×/g, 'x').replace(/[^\p{L}\p{N}]/gu, '')
export function setupVolumes({ md, sourceOf, fetchMangaFull, stored, mdMatches, muVolumes, similar, miSearch, imgUrl, DATA_DIR, UA, HttpError, net }) {
const DIR = path.join(DATA_DIR, 'tomes')
const fetchImage = async url => {
net.guard()
const r = await fetch(url, { headers: { 'User-Agent': UA }, redirect: 'follow', signal: AbortSignal.timeout(30000) }).catch(() => { net.failed(); return null })
if (!r) throw new HttpError(502, 'No se pudo descargar la cubierta')
if (r.status >= 500 || r.status === 429) throw new HttpError(502, 'La fuente de la cubierta no responde')
if (!r.ok) return null
const b = Buffer.from(await r.arrayBuffer())
return b.length >= MIN_IMAGE && b.length <= MAX_IMAGE && imageKind(b) ? b : null
}
// ---------- Lo que dice MangaDex de la obra ----------
async function mdVolumes(mdId, lang) {
return stored(`volumes:${mdId}:${lang || ''}`, DAY, async () => {
const [agg, mangaData] = await Promise.all([
md(`/manga/${mdId}/aggregate`, {}, 30 * 60e3),
md(`/manga/${mdId}`, {}, 30 * 60e3),
])
const byNum = {}
const vols = Object.values(agg.volumes || {}).sort((a, b) => volOrder(a.volume, b.volume))
for (const v of vols) {
const vol = volKey(v.volume)
if (!vol) continue
const inVol = Object.values(v.chapters || {})
const stray = /^\d/.test(vol) ? strayNums(inVol.map(c => c.chapter)) : new Set()
for (const c of inVol) {
const n = numKey(c.chapter)
if (n && !byNum[n] && !stray.has(parseFloat(n))) byNum[n] = vol
}
}
const records = []
for (let offset = 0; offset < 1000; offset += 100) {
const r = await md('/cover', { 'manga[]': [mdId], limit: 100, offset, 'order[volume]': 'asc' }, 30 * 60e3)
records.push(...r.data)
if (offset + 100 >= r.total || !r.data.length) break
}
const a = mangaData.data.attributes
const jaTitles = [a.title?.ja, ...(a.altTitles || []).map(t => t.ja)].filter(Boolean)
const enTitles = [a.title?.en, ...(a.altTitles || []).map(t => t.en)].filter(Boolean)
const roTitles = [a.title?.['ja-ro'], ...(a.altTitles || []).map(t => t['ja-ro'])].filter(Boolean)
return { byNum, covers: pickCovers(records, lang), jaTitles, enTitles, roTitles, year: a.year || null, original: a.originalLanguage || null }
}, true)
}
let gate = Promise.resolve()
const turn = () => { const p = gate.then(() => new Promise(r => setTimeout(r, 1100))); gate = p.catch(() => {}); return p } // la NDL y Wikipedia piden no insistir
// ---------- Otras fuentes del reparto ----------
async function wikiPages(titles) {
if (!titles.length) return []
await turn() // Wikipedia corta a quien va demasiado rápido
net.guard()
const url = `${WIKIPEDIA}?${new URLSearchParams({ action: 'query', prop: 'revisions', rvprop: 'content', rvslots: 'main', redirects: '1', format: 'json', formatversion: '2', titles: titles.join('|') })}`
const get = () => fetch(url, { headers: { 'User-Agent': UA }, signal: AbortSignal.timeout(30000) }).catch(() => { net.failed(); return null })
let r = await get()
if (r?.status === 429) { // límite pasajero: se espera una vez; el fallo no se guarda y se reintenta en la siguiente visita
await new Promise(res => setTimeout(res, 3000))
r = await get()
}
if (!r?.ok) throw new HttpError(502, 'No se pudo consultar Wikipedia')
return ((await r.json()).query?.pages || []).map(p => p.revisions?.[0]?.slots?.main?.content).filter(Boolean)
}
async function wikiSearch(names) {
await turn()
net.guard()
const url = `${WIKIPEDIA}?${new URLSearchParams({ action: 'query', list: 'search', srsearch: `intitle:list intitle:chapters ${names[0]}`, srlimit: '10', format: 'json', formatversion: '2' })}`
const r = await fetch(url, { headers: { 'User-Agent': UA }, signal: AbortSignal.timeout(30000) }).catch(() => { net.failed(); return null })
if (!r?.ok) throw new HttpError(502, 'No se pudo consultar Wikipedia')
const wanted = new Set(names.map(wikiKey))
return ((await r.json()).query?.search || []).map(x => x.title).filter(t => wanted.has(wikiKey(t.replace(/^List of /i, '').replace(/ (?:manga )?(?:chapters|volumes)$/i, '')))).slice(0, 3)
}
function wikiMap(mangaId, names) {
return stored(`wikimap:${mangaId}`, v => (Object.keys(v).length ? 30 * DAY : 7 * DAY), async () => {
const titles = names.slice(0, 8).flatMap(n => [`List of ${n} chapters`, `List of ${n} manga volumes`, `List of ${n} manga chapters`]).filter(t => !/[|#<>[\]{}]/.test(t))
let best = {}
let pages = await wikiPages(titles)
if (!pages.length) pages = await wikiPages(await wikiSearch(names)) // "SPY×FAMILY" es "Spy × Family" en Wikipedia
for (const text of pages) {
// One Piece y otras largas reparten la lista en subpáginas.
const subs = [...text.matchAll(/\{\{:(List of [^}|]+)\}\}/g)].map(m => m[1]).slice(0, 40)
let found = parseWikiVolumes(text)
for (let i = 0; i < subs.length; i += 10) {
for (const sub of await wikiPages(subs.slice(i, i + 10))) {
const part = parseWikiVolumes(sub)
found = { ...part, ...found }
}
}
if (Object.keys(found).length > Object.keys(best).length) best = found
}
return best
}, true)
}
function m440Map(mangaId, names) {
return stored(`mimap:${mangaId}`, DAY, async () => {
for (const name of names.slice(0, 2)) {
const hit = (await miSearch(name)).find(m => names.some(n => similar(m.title, n)))
if (hit) return ownVolMap((await fetchMangaFull(hit.id, 'es')).chapters)
}
return {}
}, true)
}
// ---------- ISBN de cada tomo ----------
async function ndlSearch(title) {
const pages = []
for (let idx = 1; idx <= 501; idx += 500) { // ONE PIECE tiene más de 600 registros
await turn()
net.guard()
const url = `${NDL}?${new URLSearchParams({ title, mediatype: 'books', cnt: '500', idx: String(idx) })}`
const r = await fetch(url, { headers: { 'User-Agent': UA }, signal: AbortSignal.timeout(30000) }).catch(() => { net.failed(); return null })
if (!r?.ok) throw new HttpError(502, 'No se pudo consultar la NDL')
const xml = await r.text()
pages.push(xml)
if (parseInt(/totalResults>(\d+)/.exec(xml)?.[1], 10) <= idx + 499) break
}
return pages
}
async function isbnsOf(mdId) {
return stored(`isbns:${mdId}`, v => (Object.keys(v).length ? 30 * DAY : 7 * DAY), async () => {
const d = await mdVolumes(mdId)
if (d.original !== 'ja' || !d.jaTitles.length) return {}
for (const t of d.jaTitles.slice(0, 3)) {
const found = pickIsbns(await ndlSearch(t), d.jaTitles, d.year)
if (Object.keys(found).length) return found
}
return {}
}, true)
}
async function englishIsbns(mdId, vol) {
const titles = (await mdVolumes(mdId)).enTitles.slice(0, 3)
if (!titles.length) return []
await turn()
net.guard()
const url = `${OPEN_LIBRARY}?${new URLSearchParams({ title: `${titles[0]}, Vol. ${vol}`, limit: '10', fields: 'title,isbn' })}`
const r = await fetch(url, { headers: { 'User-Agent': UA }, signal: AbortSignal.timeout(30000) }).catch(() => { net.failed(); return null })
if (!r?.ok) throw new HttpError(502, 'No se pudo consultar Open Library')
return pickEnglishIsbns((await r.json()).docs || [], titles, vol).slice(0, 4)
}
// ---------- Imágenes de un tomo ----------
async function fetchFace(mdId, vol, side) {
const volNum = /^\d+$/.test(vol) ? vol : null
if (side === 'front') {
const c = (await mdVolumes(mdId)).covers[vol]
if (c) {
const b = await fetchImage(`https://uploads.mangadex.org/covers/${mdId}/${c.file}`)
if (b) return b
}
}
if (!volNum) return null
for (const isbn of (await isbnsOf(mdId))[volNum] || []) {
const b = await fetchImage(`${AMAZON}/${isbn}.01.${side === 'back' ? 'BACK.' : ''}_SCRM_.jpg`)
if (b) return b
}
if (side === 'back') {
for (const isbn of await englishIsbns(mdId, volNum)) {
const b = await fetchImage(`${AMAZON}/${isbn}.01.BACK._SCRM_.jpg`)
if (b) return b
}
}
return null
}
const inflight = new Map()
function face(mdId, vol, side) {
const label = side === 'back' ? 'back2' : side // back2: las anteriores podían ser de un ómnibus
const file = path.join(DIR, mdId, `${crypto.createHash('sha1').update(vol).digest('hex').slice(0, 16)}-${label}.img`)
if (fs.existsSync(file)) return Promise.resolve(file)
const key = `tome:${mdId}:${vol}:${label}`
if (!inflight.has(key)) {
// Lo que no existe se recuerda un tiempo; un fallo de la red no, para reintentar.
inflight.set(key, stored(key, 14 * DAY, async () => {
const b = await fetchFace(mdId, vol, side)
if (!b) return { found: false }
fs.mkdirSync(path.dirname(file), { recursive: true })
const tmp = `${file}.${process.pid}.tmp`
fs.writeFileSync(tmp, b)
fs.renameSync(tmp, file)
return { found: true }
}, true).then(() => (fs.existsSync(file) ? file : null)).finally(() => inflight.delete(key)))
}
return inflight.get(key)
}
// ---------- API ----------
async function list(mangaId, lang) {
const { manga, chapters } = await fetchMangaFull(mangaId, lang)
if (hasNoVolumes(manga.type)) return []
const source = sourceOf(mangaId)
// Una obra puede estar varias veces en MangaDex (coloreada, por ejemplo): vale la que más datos tiene de estos capítulos.
const found = await mdMatches(mangaId).catch(() => [])
const ids = [...new Set(source === 'md' ? [mangaId, ...found] : found)].slice(0, 4)
const editions = (await Promise.all(ids.map(async id => ({ id, data: await mdVolumes(id, lang).catch(() => null) })))).filter(e => e.data)
const score = e => chapters.filter(c => e.data.byNum[numKey(c.num)]).length + Object.keys(e.data.covers).length
const best = editions.reduce((a, e) => (!a || score(e) > score(a) ? e : a), null)
if (hasNoVolumes(null, best?.data.original)) return [] // la fuente no siempre dice el tipo
const mdId = source === 'md' ? mangaId : best?.id || null
const data = editions.find(e => e.id === mdId)?.data || null
const own = cleanVolMap(ownVolMap(chapters))
let vols
if (numbersRestart(chapters)) vols = chapters.map(c => volKey(c.volume))
else {
const names = [...new Set([workTitle(manga.title), manga.title, ...(best?.data.enTitles || []), ...(best?.data.roTitles || [])])]
const [wiki, mi] = await Promise.all([
wikiMap(mangaId, names).catch(() => ({})),
source === 'mi' ? own : m440Map(mangaId, names).catch(() => ({})),
])
const byNum = cleanVolMap(best?.data.byNum || {})
const main = { wiki: cleanVolMap(wiki), md: source === 'md' ? { ...byNum, ...own } : byNum }
// Con Wikipedia y MangaDex casi completos, lo que falta son los capítulos aún sin tomo: no hace falta preguntar más.
const known = { ...main.wiki, ...main.md }
const missing = chapters.filter(c => !(numKey(c.num) in known)).length
const mu = missing > chapters.length * 0.1 ? fillVolGaps(orderedMuMap(cleanVolMap(await muVolumes(mangaId).catch(() => ({}))), chapters.length), chapters.map(c => numKey(c.num))) : {}
vols = assignVolumes(chapters, mergeVolMaps({ ...main, mu, mi: cleanVolMap(mi) }))
}
vols = placeLoose(chapters, vols, Object.keys(data?.covers || {}))
const groups = new Map()
vols.forEach((vol, i) => {
if (!vol) return
if (!groups.has(vol)) groups.set(vol, [])
groups.get(vol).push(chapters[i].id)
})
return [...groups.keys()].sort(volOrder).map(vol => {
const cover = data?.covers[vol]
return {
vol,
chapters: groups.get(vol),
thumb: cover ? imgUrl(`https://uploads.mangadex.org/covers/${mdId}/${cover.file}.256.jpg`) : null,
front: mdId && VOL.test(vol) ? `/api/tome/${mdId}/${encodeURIComponent(vol)}/front` : null,
back: mdId && VOL.test(vol) ? `/api/tome/${mdId}/${encodeURIComponent(vol)}/back` : null,
}
})
}
async function sendFace(res, mdId, vol, side) {
if (!MD_ID.test(mdId) || !VOL.test(vol) || !['front', 'back'].includes(side)) throw new HttpError(404, 'Cubierta no encontrada')
const file = await face(mdId, vol, side)
if (!file) throw new HttpError(404, 'Este tomo no tiene esa cubierta')
const head = Buffer.alloc(16)
const fd = fs.openSync(file, 'r')
fs.readSync(fd, head, 0, 16, 0)
fs.closeSync(fd)
res.writeHead(200, { 'Content-Type': imageKind(head) || 'application/octet-stream', 'Cache-Control': 'private, max-age=2592000', 'X-Content-Type-Options': 'nosniff' })
fs.createReadStream(file).pipe(res)
}
return { list, sendFace }
}