Estante 3D: mueble, lomos, logos y vista previa de tomos

This commit is contained in:
2026-11-06 09:28:23 -06:00
parent 66502409fe
commit bda8800a89
19 changed files with 1649 additions and 15 deletions
File diff suppressed because one or more lines are too long
+97
View File
@@ -0,0 +1,97 @@
// Detector de rostros de anime, en un Worker para no trabar el estante.
// Evalúa en JavaScript la cascada LBP «lbpcascade_animeface» de nagadomi: https://github.com/nagadomi/lbpcascade_animeface
// La cascada (animeface-cascade.json) es MIT, (C) 2011 nagadomi@nurs.or.jp; el aviso completo va en el propio archivo.
const integral = (gray, w, h) => {
const I = new Int32Array((w + 1) * (h + 1))
for (let y = 0; y < h; y++) {
let row = 0
for (let x = 0; x < w; x++) {
row += gray[y * w + x]
I[(y + 1) * (w + 1) + x + 1] = I[y * (w + 1) + x + 1] + row
}
}
return I
}
function equalize(gray) {
const hist = new Int32Array(256), n = gray.length
for (let i = 0; i < n; i++) hist[gray[i]]++
let i = 0
while (i < 255 && !hist[i]) i++
const out = new Uint8Array(n), first = hist[i]
if (first === n) return out.fill(i)
const scale = 255 / (n - first), lut = new Uint8Array(256)
let sum = 0
for (i++; i < 256; i++) { sum += hist[i]; lut[i] = Math.max(0, Math.min(255, Math.round(sum * scale))) }
for (let k = 0; k < n; k++) out[k] = lut[gray[k]]
return out
}
function shrink(gray, w, h, W, H) { // promedio por área, como INTER_LINEAR al reducir poco
const out = new Uint8Array(W * H), fx = w / W, fy = h / H
for (let y = 0; y < H; y++) {
const sy = (y + 0.5) * fy - 0.5, y0 = Math.max(0, Math.floor(sy)), y1 = Math.min(h - 1, y0 + 1), ty = sy - Math.floor(sy)
for (let x = 0; x < W; x++) {
const sx = (x + 0.5) * fx - 0.5, x0 = Math.max(0, Math.floor(sx)), x1 = Math.min(w - 1, x0 + 1), tx = sx - Math.floor(sx)
const a = gray[y0 * w + x0] * (1 - tx) + gray[y0 * w + x1] * tx, b = gray[y1 * w + x0] * (1 - tx) + gray[y1 * w + x1] * tx
out[y * W + x] = a * (1 - ty) + b * ty + 0.5
}
}
return out
}
function scan(cascade, gray, W, H, step, emit) {
const I = integral(gray, W, H), S = W + 1, { stages, features, w: cw, h: ch } = cascade
for (let wy = 0; wy + ch <= H; wy += step) {
for (let wx = 0; wx + cw <= W; wx += step) {
let pass = true
for (let s = 0; s < stages.length && pass; s++) {
const [limit, weak] = stages[s]
let sum = 0
for (let q = 0; q < weak.length; q++) {
const [f, subset, leaf] = weak[q], r = features[f], fx = wx + r[0], fy = wy + r[1], bw = r[2], bh = r[3]
const a = fy * S + fx, y1 = a + bh * S, y2 = a + 2 * bh * S, y3 = a + 3 * bh * S
const p0 = I[a], p1 = I[a + bw], p2 = I[a + 2 * bw], p3 = I[a + 3 * bw]
const p4 = I[y1], p5 = I[y1 + bw], p6 = I[y1 + 2 * bw], p7 = I[y1 + 3 * bw]
const p8 = I[y2], p9 = I[y2 + bw], p10 = I[y2 + 2 * bw], p11 = I[y2 + 3 * bw]
const p12 = I[y3], p13 = I[y3 + bw], p14 = I[y3 + 2 * bw], p15 = I[y3 + 3 * bw]
const c = p10 - p9 - p6 + p5
const code = ((p5 - p4 - p1 + p0 >= c) << 7) | ((p6 - p5 - p2 + p1 >= c) << 6) | ((p7 - p6 - p3 + p2 >= c) << 5) | ((p11 - p10 - p7 + p6 >= c) << 4) | ((p15 - p14 - p11 + p10 >= c) << 3) | ((p14 - p13 - p10 + p9 >= c) << 2) | ((p13 - p12 - p9 + p8 >= c) << 1) | (p9 - p8 - p5 + p4 >= c)
sum += leaf[subset[code >> 5] & (1 << (code & 31)) ? 0 : 1]
}
if (sum < limit) pass = false
}
if (pass) emit(wx, wy)
}
}
}
// Agrupa ventanas parecidas, como groupRectangles de OpenCV.
function group(rects, minNeighbors, eps = 0.2) {
const parent = rects.map((_, i) => i), find = i => (parent[i] === i ? i : (parent[i] = find(parent[i])))
const close = (a, b) => { const d = eps * 0.5 * (Math.min(a.w, b.w) + Math.min(a.h, b.h)); return Math.abs(a.x - b.x) <= d && Math.abs(a.y - b.y) <= d && Math.abs(a.x + a.w - b.x - b.w) <= d && Math.abs(a.y + a.h - b.y - b.h) <= d }
for (let i = 0; i < rects.length; i++) for (let j = 0; j < i; j++) if (close(rects[i], rects[j])) parent[find(i)] = find(j)
const by = new Map()
rects.forEach((r, i) => { const k = find(i); const g = by.get(k) || { x: 0, y: 0, w: 0, h: 0, n: 0 }; g.x += r.x; g.y += r.y; g.w += r.w; g.h += r.h; g.n++; by.set(k, g) })
const out = [...by.values()].filter(g => g.n > minNeighbors).map(g => ({ x: Math.round(g.x / g.n), y: Math.round(g.y / g.n), w: Math.round(g.w / g.n), h: Math.round(g.h / g.n), n: g.n }))
// Quita los que quedan dentro de otro mayor con más apoyos.
return out.filter(a => !out.some(b => b !== a && b.n >= a.n && a.x >= b.x - b.w * 0.2 && a.y >= b.y - b.h * 0.2 && a.x + a.w <= b.x + b.w * 1.2 && a.y + a.h <= b.y + b.h * 1.2 && b.w > a.w))
}
/** data: RGBA. Devuelve los rostros en píxeles de la imagen, con `n` = ventanas que los apoyan (más es más fiable). */
function detectFaces(cascade, data, w, h, { scale = 1.1, minNeighbors = 5, minSize = 24 } = {}) {
const raw = new Uint8Array(w * h)
for (let i = 0; i < raw.length; i++) raw[i] = data[i * 4] * 0.299 + data[i * 4 + 1] * 0.587 + data[i * 4 + 2] * 0.114 + 0.5
const gray = equalize(raw), rects = []
for (let f = 1; ; f *= scale) {
const size = Math.round(cascade.w * f)
const W = Math.round(w / f), H = Math.round(h / f)
if (W < cascade.w || H < cascade.h) break
if (size < minSize) continue
scan(cascade, f === 1 ? gray : shrink(gray, w, h, W, H), W, H, f > 2 ? 1 : 2, (x, y) => rects.push({ x: Math.round(x * f), y: Math.round(y * f), w: size, h: size }))
}
return group(rects, minNeighbors)
}
let cascade = null
self.onmessage = async ({ data: { id, pixels, w, h } }) => {
try {
cascade ||= await fetch(`/animeface-cascade.json${self.location.search}`).then(r => { if (!r.ok) throw new Error(r.status); return r.json() })
self.postMessage({ id, faces: detectFaces(cascade, new Uint8ClampedArray(pixels), w, h, { minNeighbors: 3 }) })
} catch (e) { self.postMessage({ id, faces: null }) }
}
+123 -5
View File
@@ -364,6 +364,7 @@ window.addEventListener('beforeinstallprompt', e => { e.preventDefault(); instal
// ---------- Router ---------- // ---------- Router ----------
let prevHash = '', curHash = '' // para volver atrás sin pasar por el lector let prevHash = '', curHash = '' // para volver atrás sin pasar por el lector
let shelfTrip = null // { mangaId } mientras se lee algo abierto desde el estante: libro 3D y vuelta al estante
async function router() { async function router() {
if (location.hash !== curHash) { prevHash = curHash; curHash = location.hash } if (location.hash !== curHash) { prevHash = curHash; curHash = location.hash }
if (cleanup) { cleanup(); cleanup = null } if (cleanup) { cleanup(); cleanup = null }
@@ -371,6 +372,7 @@ async function router() {
const [pathPart, query] = location.hash.slice(1).split('?') const [pathPart, query] = location.hash.slice(1).split('?')
const parts = (pathPart || '/').split('/').filter(Boolean) const parts = (pathPart || '/').split('/').filter(Boolean)
const params = new URLSearchParams(query || '') const params = new URLSearchParams(query || '')
if (parts[0] !== 'read' && parts[0] !== 'shelf') shelfTrip = null
document.querySelectorAll('#topbar nav a').forEach(a => a.classList.toggle('active', a.getAttribute('href') === `#/${parts[0] ?? ''}`)) document.querySelectorAll('#topbar nav a').forEach(a => a.classList.toggle('active', a.getAttribute('href') === `#/${parts[0] ?? ''}`))
if (!me) { if (!me) {
@@ -386,6 +388,7 @@ async function router() {
if (parts[0] === 'read') return await viewReader(parts[1], parts[2], Number(params.get('page') || 0) - 1) if (parts[0] === 'read') return await viewReader(parts[1], parts[2], Number(params.get('page') || 0) - 1)
if (parts[0] === 'history') return (location.hash = '#/library') // antiguo nombre if (parts[0] === 'history') return (location.hash = '#/library') // antiguo nombre
if (parts[0] === 'library') return await viewLibrary(params) if (parts[0] === 'library') return await viewLibrary(params)
if (parts[0] === 'shelf') return await viewShelf()
if (parts[0] === 'downloads') return (location.hash = '#/queues') // antiguo nombre if (parts[0] === 'downloads') return (location.hash = '#/queues') // antiguo nombre
if (parts[0] === 'queues') return await viewQueues() if (parts[0] === 'queues') return await viewQueues()
if (parts[0] === 'stats') return await viewStats() if (parts[0] === 'stats') return await viewStats()
@@ -2102,7 +2105,7 @@ async function viewReader(mangaId, chapterId, startPage) {
app.innerHTML = ` app.innerHTML = `
<div class="reader"> <div class="reader">
<div class="rbar top"> <div class="rbar top">
<a class="btn small" href="#/manga/${mangaId}" title="Volver a la ficha">${icon('back')}</a> <a class="btn small" href="${shelfTrip?.mangaId === mangaId ? '#/shelf' : `#/manga/${mangaId}`}" title="${shelfTrip?.mangaId === mangaId ? 'Volver al estante' : 'Volver a la ficha'}">${icon('back')}</a>
<div class="ttl"><b>${esc(manga.title)}</b></div> <div class="ttl"><b>${esc(manga.title)}</b></div>
<select id="chap" style="max-width:40vw">${chapOpts}</select> <select id="chap" style="max-width:40vw">${chapOpts}</select>
<button class="btn small" id="clipbtn" ${isBook ? 'hidden' : ''} title="Recortar un panel (se guarda en Recortes y se mejora a HD)">${icon('scissors')}</button> <button class="btn small" id="clipbtn" ${isBook ? 'hidden' : ''} title="Recortar un panel (se guarda en Recortes y se mejora a HD)">${icon('scissors')}</button>
@@ -3067,7 +3070,8 @@ async function viewReader(mangaId, chapterId, startPage) {
} }
// --- Libro 3D: otro módulo dibuja el libro; el lector sigue llevando la página, el progreso y los gestos. // --- 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 fromShelf = shelfTrip?.mangaId === mangaId // desde el estante se lee en 3D aunque el ajuste diga otra cosa (sin guardarlo)
const b3dWanted = () => B3D_OK && (!!settings.book3d || fromShelf) && isPaged() && settings.fit !== 'width'
const b3dOn = () => b3dWanted() && !atEnd const b3dOn = () => b3dWanted() && !atEnd
const b3dPaper = () => { const b3dPaper = () => {
const hex = book ? (BOOK_PAPERS[bookSettings().paper] || BOOK_PAPERS.light).bg : '#efeadf' const hex = book ? (BOOK_PAPERS[bookSettings().paper] || BOOK_PAPERS.light).bg : '#efeadf'
@@ -3817,6 +3821,120 @@ const libCard = ({ href, cover, title, sub, pct, badge, del }) => `<a class="car
<div class="t">${esc(title)}</div> <div class="t">${esc(title)}</div>
${sub ? `<div class="sub">${sub}</div>` : ''} ${sub ? `<div class="sub">${sub}</div>` : ''}
</a>` </a>`
// ---------- Estante 3D ----------
const SHELF_OK = 'WebGL2RenderingContext' in window // también con ratón o teclado: a diferencia del lector, no depende de los gestos táctiles
// Foto del último fotograma del estante, encima de todo mientras el lector se monta; se desvanece cuando el libro 3D ya está dibujado.
function handoffCover(src) {
document.querySelector('.handoff')?.remove()
const img = Object.assign(document.createElement('img'), { className: 'handoff', src, alt: '' })
document.body.append(img)
const t0 = performance.now()
const fade = () => { img.classList.add('out'); setTimeout(() => img.remove(), 450) }
const wait = () => {
if (!img.isConnected) return
const ready = document.querySelector('#b3d:not([hidden]) canvas')
if (ready) return setTimeout(fade, 350) // las texturas de la tapa tardan un instante
if (performance.now() - t0 > 2500 || !location.hash.startsWith('#/read/')) return fade()
setTimeout(wait, 60)
}
wait()
}
async function viewShelf() {
if (!SHELF_OK) {
app.innerHTML = `<h1>Estante 3D</h1><p class="muted">Este navegador no tiene WebGL2.</p><p><a class="btn" href="#/library">${icon('library')} Volver a la librería</a></p>`
return
}
document.body.classList.add('reading') // sin la barra de arriba
app.innerHTML = `<div id="shelf3d"></div><a class="btn small shelfback" href="#/library">${icon('back')} Librería</a><p class="shelfnote" id="shelfnote">Colocando los tomos…</p>
<section class="shelfpeek" id="shelfpeek" hidden aria-live="polite"></section>`
const note = $('#shelfnote'), card = $('#shelfpeek')
const focusOn = shelfTrip?.mangaId || null // al volver del lector, con ese tomo a la vista
shelfTrip = null
let shelf = null, gone = false
const onKey = e => { if (e.key === 'Escape') shelf?.unpeek() }
document.addEventListener('keydown', onKey)
cleanup = () => { gone = true; document.removeEventListener('keydown', onKey); shelf?.destroy(); shelf = null }
// Vistazo: lo que se sabe del tomo sacado, junto a él.
const readOf = info => {
const { work, vol, state } = info
if (state === 'reading' && work.chapterId) return readUrl(work.id, work.chapterId, work.page || 0)
if (vol.chapters?.length) return readUrl(work.id, vol.chapters[0])
return work.chapterId ? readUrl(work.id, work.chapterId, work.page || 0) : `#/manga/${work.id}`
}
// A la mesa: el estante posa el tomo como el lector lo mostrará y una foto del último fotograma tapa el cambio.
let leaving = false
const toTable = info => {
if (leaving) return
const href = readOf(info)
const go = snap => {
if (gone) return
if (href.startsWith('#/read/')) shelfTrip = { mangaId: info.work.id }
if (snap) handoffCover(snap)
location.hash = href
}
leaving = true
if (!href.startsWith('#/read/') || !shelf?.fly(go)) go(null)
}
const showPeek = info => {
if (!info) { card.classList.remove('on'); clearTimeout(card.hideTimer); card.hideTimer = setTimeout(() => { if (!card.classList.contains('on')) card.hidden = true }, 250); return }
const { work, vol, state } = info, n = vol.chapters?.length || 0, at = n && work.chapterId ? vol.chapters.indexOf(work.chapterId) : -1
const facts = [vol.vol ? `Tomo ${esc(vol.vol)}` : '', n ? `${n} ${n === 1 ? 'capítulo' : 'capítulos'}` : '', vol.pages ? `${vol.pages} páginas` : ''].filter(Boolean).join(' · ')
const pct = state === 'read' ? 100 : at >= 0 ? Math.round((at / n) * 100) : 0
const where = state === 'read' ? 'Leído' : state === 'reading' ? (at >= 0 ? `Vas por el capítulo ${at + 1} de ${n} de este tomo` : 'Lo estás leyendo') : 'Sin empezar'
card.innerHTML = `<button class="peekx" type="button" aria-label="Devolver al estante">${icon('x')}</button>
<h2 class="ptitle">${esc(work.title)}</h2>${facts ? `<p class="psub">${facts}</p>` : ''}
<div class="pbar"><i style="width:${pct}%"></i></div><p class="pwhere">${where}</p>
<div class="pacts"><a class="btn primary" id="peekread" href="${readOf(info)}">${icon('book')} ${state === 'reading' ? 'Seguir leyendo' : 'Leer'}</a><a class="btn" href="#/manga/${work.id}">Ver ficha</a></div>`
card.querySelector('.peekx').addEventListener('click', () => shelf?.unpeek())
card.querySelector('#peekread').addEventListener('click', e => { e.preventDefault(); toTable(info) })
clearTimeout(card.hideTimer)
card.hidden = false
requestAnimationFrame(() => card.classList.add('on'))
}
const ver = name => document.querySelector(`meta[name=${name}-version]`)?.content || ''
try {
const [{ works }, lib, mod] = await Promise.all([api('/api/shelf'), import(`/book3d.js?v=${ver('b3d')}`), import(`/shelf3d.js?v=${ver('shelf')}`)])
if (gone) return
if (!works.length) { note.textContent = 'Aún no hay nada en tu biblioteca: los tomos de lo que leas, sigas o descargues aparecerán aquí.'; return }
shelf = mod.createShelf3D($('#shelf3d'), lib, {
works,
still: matchMedia('(prefers-reduced-motion: reduce)').matches,
onPeek: showPeek,
onOpen: info => toTable(info), // tocar el tomo sacado lo lleva a la mesa
table: me?.tableV ? `/api/table?v=${me.tableV}` : null,
// Un tomo sin portada saca el arte del lomo de sus primeras páginas: la 0 suele ser de créditos.
pagesOf: chapterId => api(`/api/chapter/${chapterId}?saver=0&hd=0`).then(d => (d?.pages || []).slice(1, 6), () => []),
})
if (!shelf) throw new Error('sin WebGL2')
if (focusOn) shelf.focus(focusOn)
// Los tomos llegan aparte, de pocos en pocos: la primera vez pueden tardar por las fuentes externas.
let next = 0, pending = works.length
const worker = async () => {
while (next < works.length && !gone) {
const w = works[next++]
const r = await api(`/api/volumes/${w.id}?lang=${encodeURIComponent(w.lang || 'es')}`).catch(() => null)
if (gone) return
if (r?.volumes?.length) shelf.setVolumes(w.id, r.volumes)
if (--pending === 0) note.hidden = true
}
}
for (let k = 0; k < 3; k++) worker()
// Logos para el título de los lomos: la primera vez el servidor los busca en Wikimedia Commons, sin prisa.
let nextLogo = 0
const logoWorker = async () => {
while (nextLogo < works.length && !gone) {
const w = works[nextLogo++]
const r = await api(`/api/logo/${w.id}?lang=${encodeURIComponent(w.lang || 'es')}`).catch(() => null)
if (gone) return
if (r?.logo) shelf.setLogo(w.id, r.logo)
}
}
for (let k = 0; k < 2; k++) logoWorker()
} catch (e) {
if (!gone) note.textContent = `No se pudo abrir el estante: ${e.message}`
}
}
async function viewLibrary(params) { async function viewLibrary(params) {
const tab = params.get('tab') || 'reading' const tab = params.get('tab') || 'reading'
app.innerHTML = '<div class="loading">Cargando…</div>' app.innerHTML = '<div class="loading">Cargando…</div>'
@@ -3837,7 +3955,7 @@ async function viewLibrary(params) {
...lists.map(l => [`list-${l.id}`, l.name, 'bookmark', contentMode() === 'both' ? l.count : 0]), // el total de la lista incluye lo filtrado ...lists.map(l => [`list-${l.id}`, l.name, 'bookmark', contentMode() === 'both' ? l.count : 0]), // el total de la lista incluye lo filtrado
] ]
const curList = tab.startsWith('list-') ? lists.find(l => `list-${l.id}` === tab) : null const curList = tab.startsWith('list-') ? lists.find(l => `list-${l.id}` === tab) : null
app.innerHTML = `<h1>Librería</h1> app.innerHTML = `<div class="row libhead"><h1>Librería</h1>${SHELF_OK ? `<a class="btn small" href="#/shelf" id="toshelf">${icon('library')} Estante 3D</a>` : ''}</div>
<div class="tabs libtabs">${tabs.map(([k, label, ic, n]) => `<button data-tab="${k}" class="${k === tab ? 'on' : ''}">${icon(ic)} ${esc(label)}${n ? ` <span class="muted">${n}</span>` : ''}</button>`).join('')} <div class="tabs libtabs">${tabs.map(([k, label, ic, n]) => `<button data-tab="${k}" class="${k === tab ? 'on' : ''}">${icon(ic)} ${esc(label)}${n ? ` <span class="muted">${n}</span>` : ''}</button>`).join('')}
${online ? `<button id="newlist" title="Crear una lista">${icon('plus')} Nueva lista</button>` : ''}</div> ${online ? `<button id="newlist" title="Crear una lista">${icon('plus')} Nueva lista</button>` : ''}</div>
${curList ? `<div class="row lhead"><span class="muted small">Lista propia · añade mangas desde su ficha con ${icon('bookmark')} Listas</span> ${curList ? `<div class="row lhead"><span class="muted small">Lista propia · añade mangas desde su ficha con ${icon('bookmark')} Listas</span>
@@ -4046,7 +4164,7 @@ async function viewQueues() {
</section> </section>
<section class="qsec"> <section class="qsec">
<h2>${icon('book')} Biblioteca descargada</h2> <h2>${icon('book')} Biblioteca descargada</h2>
<p class="muted small">CBZ en <code>/zdata/media/mangas</code> (compatibles con Kavita, Komga, Mihon…); se leen desde el disco.${list.length ? ` Total: <b>${fmtSize(total)}</b>.` : ''}</p> <p class="muted small">CBZ en <code>./mangas</code> (compatibles con Kavita, Komga, Mihon…); se leen desde el disco.${list.length ? ` Total: <b>${fmtSize(total)}</b>.` : ''}</p>
${library.length ? `<div class="grid">${library.map(d => qcard({ ${library.length ? `<div class="grid">${library.map(d => qcard({
key: `lib:${d.manga_id}`, id: d.manga_id, cover: d.cover, title: d.title, key: `lib:${d.manga_id}`, id: d.manga_id, cover: d.cover, title: d.title,
pct: d.total ? Math.min(100, (d.done / d.total) * 100) : null, pct: d.total ? Math.min(100, (d.done / d.total) * 100) : null,
@@ -4269,7 +4387,7 @@ function viewSettings() {
Si un manga tiene copia local, los capítulos nuevos se descargan automáticamente. Además puedes recibir una notificación:</p> Si un manga tiene copia local, los capítulos nuevos se descargan automáticamente. Además puedes recibir una notificación:</p>
<form id="notify" style="display:grid;gap:8px"> <form id="notify" style="display:grid;gap:8px">
<b style="font-size:.9rem">ntfy</b> <b style="font-size:.9rem">ntfy</b>
<input type="text" name="ntfyUrl" placeholder="Servidor, p. ej. http://192.168.1.141:4546" autocomplete="off"> <input type="text" name="ntfyUrl" placeholder="Servidor, p. ej. http://localhost:4546" autocomplete="off">
<input type="text" name="ntfyTopic" placeholder="Tema, p. ej. mangas" autocomplete="off"> <input type="text" name="ntfyTopic" placeholder="Tema, p. ej. mangas" autocomplete="off">
<input type="password" name="ntfyToken" placeholder="Token (tk_…) o usuario:contraseña" autocomplete="new-password"> <input type="password" name="ntfyToken" placeholder="Token (tk_…) o usuario:contraseña" autocomplete="new-password">
<b style="font-size:.9rem">Telegram <span class="muted" style="font-weight:normal">(opcional)</span></b> <b style="font-size:.9rem">Telegram <span class="muted" style="font-weight:normal">(opcional)</span></b>
+7 -6
View File
@@ -115,9 +115,9 @@ export function fitCamera(points, aspect, marginX, marginY) {
// Colores de la portada para la contraportada y sus botones. // Colores de la portada para la contraportada y sus botones.
const luma = c => { const f = v => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4); return 0.2126 * f(c[0]) + 0.7152 * f(c[1]) + 0.0722 * f(c[2]) } const luma = c => { const f = v => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4); return 0.2126 * f(c[0]) + 0.7152 * f(c[1]) + 0.0722 * f(c[2]) }
const contrast = (a, b) => { const x = luma(a), y = luma(b); return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05) } const contrast = (a, b) => { const x = luma(a), y = luma(b); return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05) }
const inkOn = c => (contrast(c, [1, 1, 1]) >= contrast(c, [0.07, 0.06, 0.05]) ? [1, 1, 1] : [0.07, 0.06, 0.05]) export const inkOn = c => (contrast(c, [1, 1, 1]) >= contrast(c, [0.07, 0.06, 0.05]) ? [1, 1, 1] : [0.07, 0.06, 0.05])
const mixColor = (a, b, t) => a.map((n, k) => n + (b[k] - n) * t) const mixColor = (a, b, t) => a.map((n, k) => n + (b[k] - n) * t)
const css = c => `rgb(${c.map(n => Math.round(n * 255)).join(' ')})` export const css = c => `rgb(${c.map(n => Math.round(n * 255)).join(' ')})`
// Color dominante y color de acento: el acento es el que más destaca sobre el dominante, sin ser una mota de la imagen. // Color dominante y color de acento: el acento es el que más destaca sobre el dominante, sin ser una mota de la imagen.
export function coverPalette(data) { export function coverPalette(data) {
const boxes = new Map() const boxes = new Map()
@@ -143,7 +143,7 @@ export function coverPalette(data) {
} }
// Madera generada: se repite sin que se note el corte. // Madera generada: se repite sin que se note el corte.
function woodCanvas() { export function woodCanvas() {
const S = 1024, PLANKS = 5, canvas = Object.assign(document.createElement('canvas'), { width: S, height: S }), g = canvas.getContext('2d') const S = 1024, PLANKS = 5, canvas = Object.assign(document.createElement('canvas'), { width: S, height: S }), g = canvas.getContext('2d')
let seed = 7 let seed = 7
const rnd = () => (seed = (seed * 16807) % 2147483647) / 2147483647 const rnd = () => (seed = (seed * 16807) % 2147483647) / 2147483647
@@ -243,7 +243,7 @@ void main() {
}` }`
// Junta rejillas en una malla: pos(3) normal(3) uv(2). // Junta rejillas en una malla: pos(3) normal(3) uv(2).
class Shape { export class Shape {
constructor() { this.v = []; this.i = [] } constructor() { this.v = []; this.i = [] }
grid(cols, rows, at) { grid(cols, rows, at) {
const base = this.v.length / 8 const base = this.v.length / 8
@@ -305,8 +305,9 @@ function rimShape(pts, y0, y1, thick) {
// El sensor: los ejes de la pantalla a partir de la orientación del dispositivo. La inclinación se desvanece con el tiempo. // El sensor: los ejes de la pantalla a partir de la orientación del dispositivo. La inclinación se desvanece con el tiempo.
// MAX_DEG: lo que hay que girar el dispositivo para llegar al tope. ORBIT_*: cuánto rodea entonces la cámara al libro, en grados. // MAX_DEG: lo que hay que girar el dispositivo para llegar al tope. ORBIT_*: cuánto rodea entonces la cámara al libro, en grados.
const MAX_DEG = 15, FADE_S = 2.5, QUIET = 0.6, ORBIT_TILT = 7, ORBIT_YAW = 10, ORBIT_BACK = 0.06 export const MAX_DEG = 15
function createTilt(onChange) { const FADE_S = 2.5, QUIET = 0.6, ORBIT_TILT = 7, ORBIT_YAW = 10, ORBIT_BACK = 0.06
export function createTilt(onChange) {
const target = { x: 0, y: 0 } const target = { x: 0, y: 0 }
let listening = false, gotOrientation = false, lastT = 0, lastOri = null, lastFade = 0, askOnTouch = null let listening = false, gotOrientation = false, lastT = 0, lastOri = null, lastFade = 0, askOnTouch = null
const clamp = v => Math.max(-MAX_DEG, Math.min(MAX_DEG, v)) const clamp = v => Math.max(-MAX_DEG, Math.min(MAX_DEG, v))
+3
View File
@@ -0,0 +1,3 @@
Fuentes manuscritas de las notas del estante, de Google Fonts (solo el subconjunto latino):
Caveat, Patrick Hand, Kalam, Gochi Hand, Indie Flower y Shadows Into Light: SIL Open Font License 1.1 (https://openfontlicense.org).
Permanent Marker: Apache License 2.0 (https://www.apache.org/licenses/LICENSE-2.0).
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
View File
@@ -4,6 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="sw-version" content=""> <meta name="sw-version" content="">
<meta name="b3d-version" content=""> <meta name="b3d-version" content="">
<meta name="shelf-version" content="">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#14121a"> <meta name="theme-color" content="#14121a">
<title>Manga Reader</title> <title>Manga Reader</title>
File diff suppressed because it is too large Load Diff
+21
View File
@@ -460,6 +460,11 @@ body.reading #netbanner { display: none; }
@media (max-width: 640px) { .kv { grid-template-columns: 1fr; gap: 0; } .kv dd { margin-bottom: 8px; } .banner { height: 120px; } } @media (max-width: 640px) { .kv { grid-template-columns: 1fr; gap: 0; } .kv dd { margin-bottom: 8px; } .banner { height: 120px; } }
.chapters .meta .cdate.approx { opacity: .7; } .chapters .meta .cdate.approx { opacity: .7; }
/* Libro 3D: el canvas dibuja el libro y #pages sigue debajo de los dedos, invisible, para los gestos. */ /* Libro 3D: el canvas dibuja el libro y #pages sigue debajo de los dedos, invisible, para los gestos. */
#shelf3d { position: fixed; inset: 0; z-index: 0; touch-action: none; background: #17100b; }
#shelf3d canvas { display: block; width: 100%; height: 100%; }
.shelfback { position: fixed; z-index: 2; top: calc(10px + env(safe-area-inset-top)); left: calc(10px + env(safe-area-inset-left)); }
.shelfnote { position: fixed; z-index: 2; left: 50%; bottom: calc(14px + env(safe-area-inset-bottom)); transform: translateX(-50%); margin: 0; padding: 6px 12px; max-width: calc(100vw - 32px); border-radius: 8px; background: rgb(0 0 0 / .62); color: #fff; font-size: .85rem; text-align: center; }
.libhead { justify-content: space-between; align-items: center; }
#b3d { position: fixed; inset: 0; z-index: 0; } #b3d { position: fixed; inset: 0; z-index: 0; }
#b3d[hidden] { display: none; } #b3d[hidden] { display: none; }
#b3d canvas { display: block; width: 100%; height: 100%; } #b3d canvas { display: block; width: 100%; height: 100%; }
@@ -481,3 +486,19 @@ body.reading #netbanner { display: none; }
.reader.b3d.b3dcrop { background: #000; } .reader.b3d.b3dcrop { background: #000; }
.reader.b3d.b3dcrop > #b3d { display: none; } .reader.b3d.b3dcrop > #b3d { display: none; }
.reader.b3d.b3dcrop > #pages img { visibility: visible; } .reader.b3d.b3dcrop > #pages img { visibility: visible; }
/* Vistazo del estante: junto al tomo sacado, a la derecha en apaisado y abajo en vertical */
.shelfpeek { position: fixed; z-index: 3; right: calc(4vw + env(safe-area-inset-right)); top: 50%; width: min(38vw, 380px); transform: translate(12px, -50%); opacity: 0; transition: opacity .22s, transform .22s; padding: 18px 18px 16px; border-radius: 14px; background: rgb(20 14 10 / .78); -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px); color: #f4ede4; box-shadow: 0 10px 30px rgb(0 0 0 / .4); }
.shelfpeek.on { opacity: 1; transform: translate(0, -50%); }
.shelfpeek .ptitle { margin: 0 34px 4px 0; font-size: 1.2rem; line-height: 1.25; }
.shelfpeek .psub, .shelfpeek .pwhere { margin: 4px 0; color: #cdbfae; font-size: .9rem; }
.shelfpeek .pbar { height: 4px; margin: 12px 0 6px; border-radius: 2px; background: rgb(255 255 255 / .16); overflow: hidden; }
.shelfpeek .pbar i { display: block; height: 100%; background: var(--accent); }
.shelfpeek .pacts { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; }
.shelfpeek .pacts .btn { flex: 1 1 auto; justify-content: center; }
.shelfpeek .peekx { position: absolute; top: 10px; right: 10px; width: 32px; height: 32px; display: grid; place-items: center; border: 0; border-radius: 50%; background: rgb(255 255 255 / .1); color: inherit; cursor: pointer; }
@media (orientation: portrait) {
.shelfpeek { left: calc(12px + env(safe-area-inset-left)); right: calc(12px + env(safe-area-inset-right)); top: auto; bottom: calc(12px + env(safe-area-inset-bottom)); width: auto; transform: translateY(12px); }
.shelfpeek.on { transform: none; }
}
.handoff { position: fixed; inset: 0; z-index: 60; width: 100vw; height: 100vh; object-fit: cover; pointer-events: none; transition: opacity .35s ease; }
.handoff.out { opacity: 0; }
+2 -2
View File
@@ -2,7 +2,7 @@
// dispositivo" (caché OFFLINE, la llena app.js) se leen sin red. // dispositivo" (caché OFFLINE, la llena app.js) se leen sin red.
const SHELL = 'shell-v1' const SHELL = 'shell-v1'
const OFFLINE = 'offline-v1' const OFFLINE = 'offline-v1'
const SHELL_FILES = ['/', '/app.js', '/style.css', '/book3d.js', '/icon.svg', '/manifest.webmanifest', '/icon-192.png', '/icon-512.png'] const SHELL_FILES = ['/', '/app.js', '/style.css', '/book3d.js', '/shelf3d.js', '/animeface.js', '/animeface-cascade.json', '/fonts/caveat.woff2', '/fonts/patrickhand.woff2', '/fonts/kalam.woff2', '/fonts/gochihand.woff2', '/fonts/indieflower.woff2', '/fonts/permanentmarker.woff2', '/fonts/shadows.woff2', '/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 // 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). // enlaza (?v=…); cache: 'reload' para no copiar lo que tenga la caché HTTP (un proxy puede alargarla horas).
@@ -21,7 +21,7 @@ self.addEventListener('install', e => {
}) })
self.addEventListener('activate', e => { self.addEventListener('activate', e => {
e.waitUntil(caches.keys() e.waitUntil(caches.keys()
.then(keys => Promise.all(keys.filter(k => k !== SHELL && k !== OFFLINE).map(k => caches.delete(k)))) .then(keys => Promise.all(keys.filter(k => k !== SHELL && k !== OFFLINE && !k.startsWith('shelf-')) // shelf-*: lo que guarda el estante (shelf3d.js).map(k => caches.delete(k))))
.then(() => self.clients.claim())) .then(() => self.clients.claim()))
}) })
+8 -1
View File
@@ -17,6 +17,7 @@ import { setupConnectivity } from './connectivity.js'
import { setupMeta } from './meta.js' import { setupMeta } from './meta.js'
import { setupVolumes } from './volumes.js' import { setupVolumes } from './volumes.js'
import { setupCredits } from './credits.js' import { setupCredits } from './credits.js'
import { setupShelf } from './shelf.js'
import { setupBooks, isBookId } from './books.js' import { setupBooks, isBookId } from './books.js'
const PORT = Number(process.env.PORT || 3352) const PORT = Number(process.env.PORT || 3352)
@@ -834,7 +835,7 @@ const requireUser = req => {
return u return u
} }
const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', '.webmanifest': 'application/manifest+json', '.png': 'image/png' } const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', '.webmanifest': 'application/manifest+json', '.png': 'image/png', '.json': 'application/json', '.woff2': 'font/woff2', '.txt': 'text/plain; charset=utf-8' }
// index.html enlaza app.js y style.css con ?v=<hash del contenido>: si un proxy delante cambia el // index.html enlaza app.js y style.css con ?v=<hash del contenido>: si un proxy delante cambia el
// no-cache por una caché larga (Nginx Proxy Manager con «Cache Assets»), cada versión nueva tiene otra // no-cache por una caché larga (Nginx Proxy Manager con «Cache Assets»), cada versión nueva tiene otra
// URL y el navegador no se queda con la vieja. Los archivos no cambian mientras el contenedor vive. // URL y el navegador no se queda con la vieja. Los archivos no cambian mientras el contenedor vive.
@@ -843,6 +844,7 @@ const INDEX_HTML = fs.readFileSync(path.join(PUBLIC_DIR, 'index.html'), 'utf8')
.replace('src="/app.js"', `src="/app.js?v=${assetVersion('app.js')}"`) .replace('src="/app.js"', `src="/app.js?v=${assetVersion('app.js')}"`)
.replace('href="/style.css"', `href="/style.css?v=${assetVersion('style.css')}"`) .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="b3d-version" content="">', `<meta name="b3d-version" content="${assetVersion('book3d.js')}">`)
.replace('<meta name="shelf-version" content="">', `<meta name="shelf-version" content="${assetVersion('shelf3d.js')}${assetVersion('animeface.js')}">`)
.replace('<meta name="sw-version" content="">', `<meta name="sw-version" content="${assetVersion('sw.js')}">`) .replace('<meta name="sw-version" content="">', `<meta name="sw-version" content="${assetVersion('sw.js')}">`)
function serveStatic(res, pathname) { function serveStatic(res, pathname) {
let file = path.join(PUBLIC_DIR, path.normalize(pathname).replace(/^(\.\.[/\\])+/, '')) let file = path.join(PUBLIC_DIR, path.normalize(pathname).replace(/^(\.\.[/\\])+/, ''))
@@ -924,6 +926,7 @@ async function route(req, res) {
if (p.startsWith('/api/bans') && security.route(p, method, req, res, json) !== false) return 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 features.route(p, method, q, req, res, user)) !== false) return
if ((await credits.route(p, method, req, res, user)) !== false) return if ((await credits.route(p, method, req, res, user)) !== false) return
if (shelf.route(p, method, res, user) !== false) return
if ((await upscale.route(p, method, req, res)) !== false) { 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. // 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]) 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])
@@ -996,6 +999,9 @@ async function route(req, res) {
if ((m = /^\/api\/volumes\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'GET') { 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) }) return json(res, 200, { volumes: isBookId(m[1]) ? [] : await volumes.list(m[1], lang) })
} }
if ((m = /^\/api\/logo\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'GET') {
return json(res, 200, { logo: isBookId(m[1]) ? null : await volumes.logo(m[1], lang).catch(() => null) })
}
if ((m = /^\/api\/tome\/([^/]+)\/([^/]+)\/(front|back)$/.exec(p)) && method === 'GET') { if ((m = /^\/api\/tome\/([^/]+)\/([^/]+)\/(front|back)$/.exec(p)) && method === 'GET') {
let vol let vol
try { vol = decodeURIComponent(m[2]) } catch { throw new HttpError(404, 'Cubierta no encontrada') } try { vol = decodeURIComponent(m[2]) } catch { throw new HttpError(404, 'Cubierta no encontrada') }
@@ -1170,6 +1176,7 @@ const library = setupLibrary({ db, HttpError, json, readBody, MANGA_ID, CHAPTER_
net.whenOnline(() => pumpDownloads()) net.whenOnline(() => pumpDownloads())
const meta = setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, net, decode, imgUrl, UA, prepareBanner }) 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 credits = setupCredits({ db, HttpError, json, readBody, MANGA_ID, CHAPTER_ID })
const shelf = setupShelf({ db, json, isBookId, downloadCover, MY_DOWNLOADS })
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 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 }) const clips = setupClips({ db, DATA_DIR, HttpError, json, readBody, MANGA_ID, CHAPTER_ID, upscale })
+36
View File
@@ -0,0 +1,36 @@
// Estante 3D: las obras de un usuario que pueden ir en el mueble. Los tomos de cada una se piden aparte (/api/volumes), porque pueden tardar.
import { hasNoVolumes } from './volumes.js'
export function setupShelf({ db, json, isBookId, downloadCover, MY_DOWNLOADS }) {
function works(userId) {
const out = new Map()
const add = (id, fields) => {
const w = out.get(id) || { id, title: '', cover: null, last: 0, chapterId: null, page: 0, lang: null }
w.title ||= fields.title || ''
w.cover ||= fields.cover || null
w.last = Math.max(w.last, fields.at || 0)
if (fields.chapterId) Object.assign(w, { chapterId: fields.chapterId, page: fields.page || 0, lang: fields.lang || null })
out.set(id, w)
}
for (const r of db.prepare('SELECT manga_id, manga_title, cover, chapter_id, page, lang, updated_at FROM progress WHERE user_id = ?').all(userId)) {
add(r.manga_id, { title: r.manga_title, cover: r.cover, chapterId: r.chapter_id, page: r.page, lang: r.lang, at: r.updated_at })
}
for (const r of db.prepare('SELECT manga_id, title, cover, last_new_at, created_at FROM follows WHERE user_id = ? AND active = 1').all(userId)) {
add(r.manga_id, { title: r.title, cover: r.cover, at: r.last_new_at || r.created_at })
}
for (const r of db.prepare(`SELECT manga_id, title, folder, updated_at, json_extract(meta, '$.manga.cover') meta_cover FROM downloads WHERE ${MY_DOWNLOADS}`).all(userId)) {
add(r.manga_id, { title: r.title, cover: downloadCover(r.manga_id, r.folder, r.meta_cover), at: r.updated_at })
}
for (const r of db.prepare('SELECT i.manga_id, i.title, i.cover, i.added_at FROM list_items i JOIN lists l ON l.id = i.list_id WHERE l.user_id = ?').all(userId)) {
add(r.manga_id, { title: r.title, cover: r.cover, at: r.added_at })
}
const typeOf = db.prepare('SELECT type FROM manga_meta WHERE manga_id = ?')
return [...out.values()].filter(w => !isBookId(w.id) && !hasNoVolumes(typeOf.get(w.id)?.type)).sort((a, b) => b.last - a.last)
}
function route(p, method, res, user) {
if (p !== '/api/shelf' || method !== 'GET') return false
json(res, 200, { works: works(user.id) })
return true
}
return { route, works }
}
+65 -1
View File
@@ -454,6 +454,28 @@ export function workTitle(title) {
/** Manhwa y manhua no se publican por tomos: se leen por capítulos. */ /** 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 hasNoVolumes = (type, original) => ['manhwa', 'manhua', 'webtoon'].includes(String(type || '').toLowerCase()) || ['ko', 'zh', 'zh-hk'].includes(original)
// Palabras de un título para comparar con nombres de archivo: sin tildes ni vocales largas («Sōsō» y «Sousou» son «soso»).
const LOGO_STOP = new Set(['the', 'and', 'wa', 'no', 'ga', 'wo', 'ni', 'de', 'to'])
const LOGO_EXTRA = new Set(['logo', 'logotype', 'wordmark', 'manga', 'anime', 'series', 'title', 'english', 'japanese', 'png', 'svg', 'webp', 'file', 'the', 'and', 'tv', 'version', 'new', 'old', 'int', 'international', 'with', 'colour', 'color', 'gradient', 'variant', 'alt', 'alternative', 'text', 'black', 'white', 'transparent', 'vector', 'official'])
export const logoWords = s => String(s).normalize('NFD').replace(/\p{M}/gu, '').toLowerCase().replace(/ou/g, 'o').replace(/uu/g, 'u').replace(/oo/g, 'o').split(/[^\p{L}\p{N}]+/u).filter(w => w.length >= 2 && !LOGO_STOP.has(w) && !/^\d+$/.test(w))
/** El archivo que mejor encaja con algún nombre de la obra: tiene «logo», casi todas las palabras del nombre y nada más que relleno. */
export function pickLogo(files, names) {
let best = null, bestScore = 0
files.forEach((file, i) => {
if (!/\.(png|svg|webp)$/i.test(file)) return
const fw = logoWords(file.replace(/^File:/, ''))
if (!fw.includes('logo')) return
names.forEach((name, k) => {
const nw = logoWords(name)
if (!nw.length || nw.join('').length < 4) return
const hit = nw.filter(w => fw.includes(w)).length / nw.length
const stray = fw.filter(w => !nw.includes(w) && !LOGO_EXTRA.has(w))
const score = hit + (k ? 0 : 0.05) - i * 0.001 // el nombre principal manda
if (hit >= 0.66 && !stray.length && score > bestScore) { best = file; bestScore = score }
})
})
return best
}
export const wikiKey = t => String(t).toLowerCase().replace(/×/g, 'x').replace(/[^\p{L}\p{N}]/gu, '') 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 }) { export function setupVolumes({ md, sourceOf, fetchMangaFull, stored, mdMatches, muVolumes, similar, miSearch, imgUrl, DATA_DIR, UA, HttpError, net }) {
@@ -642,6 +664,44 @@ export function setupVolumes({ md, sourceOf, fetchMangaFull, stored, mdMatches,
return inflight.get(key) return inflight.get(key)
} }
// ---------- Logo de la obra (Wikimedia Commons), para el título del lomo ----------
const COMMONS = 'https://commons.wikimedia.org/w/api.php'
let commonsRest = 0 // si Commons pide calma, no se le pregunta nada en un rato (y no se guarda el fallo)
async function commonsGet(params) {
if (Date.now() < commonsRest) throw new HttpError(503, 'Wikimedia Commons pidió esperar')
await turn()
net.guard()
const r = await fetch(`${COMMONS}?${new URLSearchParams({ ...params, format: 'json', formatversion: '2', maxlag: '5' })}`, { headers: { 'User-Agent': UA }, signal: AbortSignal.timeout(30000) }).catch(() => { net.failed(); return null })
const text = r?.ok ? await r.text() : ''
if (r?.status === 429 || (r?.ok && !text.startsWith('{')) || /"code":\s*"maxlag"/.test(text)) commonsRest = Date.now() + 10 * 60e3
const data = text.startsWith('{') ? JSON.parse(text) : null
if (!data || data.error) throw new HttpError(502, 'No se pudo consultar Wikimedia Commons')
return data
}
async function commonsLogo(names) {
const queries = [...new Set([...names.slice(0, 3), ...names.slice(0, 1).flatMap(n => logoWords(n).sort((a, b) => b.length - a.length).slice(0, 1))])]
for (const q of queries) {
const res = await commonsGet({ action: 'query', list: 'search', srnamespace: '6', srsearch: `${q} logo`, srlimit: '15' })
const best = pickLogo((res.query?.search || []).map(h => h.title), names)
if (!best) continue
const info = await commonsGet({ action: 'query', titles: best, prop: 'imageinfo', iiprop: 'url|size|mime', iiurlwidth: '960' }) // Wikimedia solo sirve miniaturas de ciertos anchos (960 sí, 800 no)
const ii = info.query?.pages?.[0]?.imageinfo?.[0]
if (ii && ii.width >= ii.height * 1.2) return ii.thumburl || ii.url || null // un logotipo es apaisado
}
return null
}
// Una vez por obra; lo no encontrado se vuelve a buscar a la semana.
function logo(mangaId, lang) {
return stored(`logo:${mangaId}`, v => (v ? 60 : 7) * DAY, async () => {
const { manga } = await fetchMangaFull(mangaId, lang)
if (hasNoVolumes(manga.type)) return null
const ids = sourceOf(mangaId) === 'md' ? [mangaId] : (await mdMatches(mangaId).catch(() => [])).slice(0, 1)
const data = ids.length ? await mdVolumes(ids[0], lang).catch(() => null) : null
const names = [...new Set([workTitle(manga.title), manga.title, ...(data?.enTitles || []), ...(data?.roTitles || [])])].filter(Boolean)
return commonsLogo(names)
}, true)
}
// ---------- API ---------- // ---------- API ----------
async function list(mangaId, lang) { async function list(mangaId, lang) {
const { manga, chapters } = await fetchMangaFull(mangaId, lang) const { manga, chapters } = await fetchMangaFull(mangaId, lang)
@@ -680,12 +740,16 @@ export function setupVolumes({ md, sourceOf, fetchMangaFull, stored, mdMatches,
if (!groups.has(vol)) groups.set(vol, []) if (!groups.has(vol)) groups.set(vol, [])
groups.get(vol).push(chapters[i].id) groups.get(vol).push(chapters[i].id)
}) })
// Páginas del tomo, para el grosor del lomo en el estante. Si falta algún capítulo por contar, no se da.
const pagesOf = ids => { let n = 0; for (const id of ids) { const p = chapters.find(c => c.id === id)?.pages; if (!(p > 0)) return null; n += p } return n }
return [...groups.keys()].sort(volOrder).map(vol => { return [...groups.keys()].sort(volOrder).map(vol => {
const cover = data?.covers[vol] const cover = data?.covers[vol]
return { return {
vol, vol,
chapters: groups.get(vol), chapters: groups.get(vol),
pages: pagesOf(groups.get(vol)),
thumb: cover ? imgUrl(`https://uploads.mangadex.org/covers/${mdId}/${cover.file}.256.jpg`) : null, thumb: cover ? imgUrl(`https://uploads.mangadex.org/covers/${mdId}/${cover.file}.256.jpg`) : null,
art: cover ? imgUrl(`https://uploads.mangadex.org/covers/${mdId}/${cover.file}.512.jpg`) : null, // para sacar el arte del lomo
front: mdId && VOL.test(vol) ? `/api/tome/${mdId}/${encodeURIComponent(vol)}/front` : 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, back: mdId && VOL.test(vol) ? `/api/tome/${mdId}/${encodeURIComponent(vol)}/back` : null,
} }
@@ -702,5 +766,5 @@ export function setupVolumes({ md, sourceOf, fetchMangaFull, stored, mdMatches,
res.writeHead(200, { 'Content-Type': imageKind(head) || 'application/octet-stream', 'Cache-Control': 'private, max-age=2592000', 'X-Content-Type-Options': 'nosniff' }) 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) fs.createReadStream(file).pipe(res)
} }
return { list, sendFace } return { list, sendFace, logo: async (mangaId, lang) => { const u = await logo(mangaId, lang); return u ? imgUrl(u) : null } }
} }