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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/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 }
|
||||
}
|
||||
Reference in New Issue
Block a user