Libros de texto: fuente Project Gutenberg y lectura continua

Los EPUB se convierten en el servidor en una lista cerrada de bloques y el
navegador los maqueta en páginas según su pantalla. Cada página se dibuja en
un canvas y llega al lector como una imagen más, así que hoja, sombra,
hendidura y sonido no cambian.

El libro se lee de corrido: cada capítulo empieza debajo del anterior y el
progreso se sigue guardando por capítulo. El catálogo sale del OPDS de
gutenberg.org, con reintentos.

Ajustes: mostrar solo manga, solo libros o ambos, por usuario.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-02 21:02:29 -06:00
co-authored by Claude Opus 5.5
parent 5776389ebb
commit be19e35156
6 changed files with 1089 additions and 66 deletions
+451
View File
@@ -0,0 +1,451 @@
// Libros de texto (EPUB). El EPUB se convierte aquí en una lista cerrada de bloques: al navegador nunca llega
// HTML del libro, y es él quien maqueta los bloques en páginas según su pantalla (ver maquetado en app.js).
// bloque: { k: 'p' | 'q' | 'h' | 'hr' | 'img', l?: nivel de título, n?: imagen, c?: centrado, v?: verso,
// r?: [[texto, estilo]] } estilo: 1 cursiva, 2 negrita (sumables)
// Fuente en línea: Project Gutenberg, con su propio catálogo OPDS. IDs: libro "gb~<n>", capítulo "gb~<n>~<i>".
// Título, autores e idioma salen del propio EPUB, así que abrir un libro solo necesita el archivo.
// En <DATA_DIR>/books/ se guardan el EPUB (de ahí salen las imágenes) y el libro ya convertido.
import fs from 'node:fs'
import zlib from 'node:zlib'
import path from 'node:path'
import { readZipIndex, readCbzEntry, imageType } from './cbz.js'
export const isBookId = id => /^(gb|ws|ub)~/.test(id || '')
const GUTENBERG = 'https://www.gutenberg.org'
const OPDS_PAGE = 25
// El buscador de Gutenberg filtra por el nombre del idioma, no por el código.
const LANG_NAMES = { es: 'spanish', en: 'english', fr: 'french', pt: 'portuguese', it: 'italian', de: 'german', ru: 'russian', ja: 'japanese', zh: 'chinese', ko: 'korean', ca: 'catalan', nl: 'dutch', pl: 'polish' }
const PARSER_VERSION = 2
const MAX_EPUB_BYTES = 100 * 1024 * 1024
const MAX_UNZIPPED_ENTRY = 64 * 1024 * 1024 // un XHTML o una imagen
const MAX_UNZIPPED_TOTAL = 512 * 1024 * 1024 // todo el libro: lo que pase de aquí es una ZIP bomb
const BOOK_IMAGE = /\.(jpe?g|png|gif|webp)$/i
const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', mdash: '—', ndash: '–', hellip: '…', laquo: '«', raquo: '»', ldquo: '“', rdquo: '”', lsquo: '‘', rsquo: '’', iexcl: '¡', iquest: '¿', aacute: 'á', eacute: 'é', iacute: 'í', oacute: 'ó', uacute: 'ú', ntilde: 'ñ', uuml: 'ü', Aacute: 'Á', Eacute: 'É', Iacute: 'Í', Oacute: 'Ó', Uacute: 'Ú', Ntilde: 'Ñ', Uuml: 'Ü', agrave: 'à', egrave: 'è', igrave: 'ì', ograve: 'ò', ugrave: 'ù', ccedil: 'ç', shy: '', ensp: ' ', emsp: ' ', thinsp: ' ', zwnj: '', zwj: '', copy: '©', deg: '°', ordm: 'º', ordf: 'ª', sect: '§', para: '¶', middot: '·', dagger: '†', Dagger: '‡', bull: '•', times: '×' }
const entities = s => s.replace(/&(#x?[0-9a-f]+|[a-z0-9]+);/gi, (all, e) => {
if (e[0] !== '#') return ENTITIES[e] ?? all
const n = e[1].toLowerCase() === 'x' ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10)
return n > 0 && n <= 0x10ffff ? String.fromCodePoint(n) : ''
})
const attr = (attrs, name) => {
const m = new RegExp(`(?:^|\\s)${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, 'i').exec(attrs)
return m ? entities(m[1] ?? m[2]) : null
}
// Ruta dentro del ZIP de un enlace relativo a un archivo del libro.
function resolveHref(base, href) {
let rel = href.split('#')[0]
try { rel = decodeURIComponent(rel) } catch {}
return path.posix.normalize(path.posix.join(path.posix.dirname(base), rel)).replace(/^\.\//, '')
}
// ---------- XHTML a bloques ----------
const SKIP = new Set(['head', 'script', 'style', 'template', 'noscript', 'iframe', 'object', 'embed', 'audio', 'video', 'math', 'select', 'form', 'nav'])
const BLOCK = new Set(['p', 'div', 'section', 'article', 'aside', 'header', 'footer', 'figure', 'figcaption', 'main', 'body', 'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'table', 'thead', 'tbody', 'tfoot', 'tr', 'caption', 'pre', 'address', 'center', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote'])
const VOID = new Set(['br', 'hr', 'img', 'image', 'meta', 'link', 'input', 'col', 'area', 'base', 'wbr'])
const TOKEN = /<!--[\s\S]*?-->|<!\[CDATA\[[\s\S]*?\]\]>|<[?!][^>]*>|<(\/?)([a-zA-Z][a-zA-Z0-9:_-]*)((?:[^>"']|"[^"]*"|'[^']*')*)>/g
// onAnchor(id) se llama con cada id del documento; devuelve true si ahí empieza un capítulo.
function xhtmlToBlocks(xml, file, imageOf, onBlock, onAnchor) {
let skip = 0, skipTag = null
let italic = 0, bold = 0, quote = 0, center = 0, verse = 0
const stack = [] // etiquetas de bloque con estilo, para deshacerlo al cerrarlas
let runs = [], heading = 0
const flush = () => {
// Espacios del código fuente fuera; los saltos de línea reales (<br>) son \n.
const out = []
let atStart = true
for (const [text, style] of runs) {
let t = text === '\n' ? text : text.replace(/[ \t\r\n\f]+/g, ' ') // el \n solo viene de un <br>
if (atStart) t = t.replace(/^ +/, '')
if (!t) continue
atStart = t.endsWith('\n')
const last = out.at(-1)
if (last && last[1] === style) last[0] += t
else out.push([t, style])
}
while (out.length) {
const last = out.at(-1)
last[0] = last[0].replace(/[ \n]+$/, '')
if (last[0]) break
out.pop()
}
runs = []
if (out.length && out.some(([t]) => t.trim())) {
const b = heading ? { k: 'h', l: heading, r: out } : { k: quote ? 'q' : 'p', r: out }
if (center || heading) b.c = 1
if (verse || out.some(([t]) => t.includes('\n'))) b.v = 1
onBlock(b)
}
}
const body = xml.search(/<body[\s>]/i)
let pos = body >= 0 ? body : 0
TOKEN.lastIndex = pos
for (let m; (m = TOKEN.exec(xml));) {
if (!skip && m.index > pos) {
const text = entities(xml.slice(pos, m.index))
if (text) runs.push([text, (italic ? 1 : 0) + (bold ? 2 : 0)])
}
pos = TOKEN.lastIndex
const tag = m[2]?.toLowerCase().replace(/^.*:/, '')
if (!tag) { // comentario, DOCTYPE; CDATA se pierde a propósito (no es texto de libro)
continue
}
const closing = m[1] === '/'
const rawAttrs = m[3] || ''
const selfClosed = /\/\s*$/.test(rawAttrs)
if (skip) {
if (tag === skipTag) {
if (closing) { if (--skip === 0) skipTag = null } else if (!selfClosed) skip++
}
continue
}
if (closing) {
if (BLOCK.has(tag)) {
flush()
const top = stack.findLastIndex(s => s.tag === tag)
if (top >= 0) {
for (const s of stack.splice(top)) {
quote -= s.quote; center -= s.center; verse -= s.verse; italic -= s.italic; bold -= s.bold
if (s.heading) heading = 0
}
}
} else if (tag === 'i' || tag === 'em' || tag === 'cite' || tag === 'dfn') italic = Math.max(0, italic - 1)
else if (tag === 'b' || tag === 'strong') bold = Math.max(0, bold - 1)
continue
}
const id = attr(rawAttrs, 'id') ?? (tag === 'a' ? attr(rawAttrs, 'name') : null)
if (id && onAnchor(id, flush)) { /* el capítulo nuevo ya vació lo pendiente */ }
const cls = (attr(rawAttrs, 'class') || '').toLowerCase()
const type = (attr(rawAttrs, 'epub:type') || '').toLowerCase()
// Números de página de la edición impresa: ruido entre el texto.
if (SKIP.has(tag) || /\bpage-?num(ber)?\b|\bpageno\b/.test(cls) || type.includes('pagebreak') || attr(rawAttrs, 'hidden') !== null) {
if (!selfClosed && !VOID.has(tag)) { skip = 1; skipTag = tag }
continue
}
if (tag === 'br') { runs.push(['\n', 0]); continue }
if (tag === 'hr') { flush(); onBlock({ k: 'hr' }); continue }
if (tag === 'img' || tag === 'image') {
const src = attr(rawAttrs, 'src') ?? attr(rawAttrs, 'xlink:href') ?? attr(rawAttrs, 'href')
const n = src != null && !/^[a-z]+:/i.test(src) ? imageOf(resolveHref(file, src)) : null
if (n != null) { flush(); onBlock({ k: 'img', n }) }
continue
}
if (BLOCK.has(tag)) {
flush()
const style = attr(rawAttrs, 'style') || ''
const s = {
tag,
quote: tag === 'blockquote' ? 1 : 0,
center: tag === 'center' || /\bcent(er|re)\b/.test(cls) || /text-align\s*:\s*center/i.test(style) ? 1 : 0,
verse: tag === 'pre' || /\b(verse|poem|poetry|stanza|line-?block)\b/.test(cls) ? 1 : 0,
italic: 0, bold: 0, heading: 0,
}
if (/^h[1-6]$/.test(tag)) { heading = Number(tag[1]); s.heading = 1 }
quote += s.quote; center += s.center; verse += s.verse
if (!selfClosed) stack.push(s)
else { quote -= s.quote; center -= s.center; verse -= s.verse; if (s.heading) heading = 0 }
continue
}
if (selfClosed) continue
if (tag === 'i' || tag === 'em' || tag === 'cite' || tag === 'dfn') italic++
else if (tag === 'b' || tag === 'strong') bold++
else if (tag === 'td' || tag === 'th') { if (runs.length) runs.push([' ', 0]) }
}
if (!skip && pos < xml.length) {
const text = entities(xml.slice(pos).replace(/<[^>]*$/, ''))
if (text.trim()) runs.push([text, 0])
}
flush()
}
// Ancho y alto de una imagen PNG, GIF, JPEG o WebP, por su cabecera.
function imageSize(b) {
if (b.length > 24 && b.readUInt32BE(0) === 0x89504e47) return { w: b.readUInt32BE(16), h: b.readUInt32BE(20) }
if (b.length > 10 && b.toString('latin1', 0, 3) === 'GIF') return { w: b.readUInt16LE(6), h: b.readUInt16LE(8) }
if (b.length > 4 && b[0] === 0xff && b[1] === 0xd8) {
for (let p = 2; p + 9 < b.length;) {
if (b[p] !== 0xff) { p++; continue }
const m = b[p + 1]
if (m >= 0xc0 && m <= 0xcf && m !== 0xc4 && m !== 0xc8 && m !== 0xcc) return { w: b.readUInt16BE(p + 7), h: b.readUInt16BE(p + 5) }
if (m === 0xff) { p++; continue }
p += 2 + b.readUInt16BE(p + 2)
}
}
if (b.length > 30 && b.toString('latin1', 0, 4) === 'RIFF' && b.toString('latin1', 8, 12) === 'WEBP') {
const kind = b.toString('latin1', 12, 16)
if (kind === 'VP8X') return { w: b.readUIntLE(24, 3) + 1, h: b.readUIntLE(27, 3) + 1 }
if (kind === 'VP8 ') return { w: b.readUInt16LE(26) & 0x3fff, h: b.readUInt16LE(28) & 0x3fff }
if (kind === 'VP8L') { const v = b.readUInt32LE(21); return { w: (v & 0x3fff) + 1, h: ((v >> 14) & 0x3fff) + 1 } }
}
return {}
}
// ---------- EPUB ----------
// Índice del libro en orden: [{ file, anchor, title }]. EPUB 3 (nav) o EPUB 2 (toc.ncx).
function readToc(read, opf, manifest) {
const navItem = [...manifest.values()].find(it => /\bnav\b/.test(it.props))
if (navItem) {
const xml = read(navItem.path)
const nav = /<nav\b[^>]*(?:epub:)?type\s*=\s*["']toc["'][^>]*>([\s\S]*?)<\/nav>/i.exec(xml)?.[1]
if (nav) {
const out = []
for (const m of nav.matchAll(/<a\b([^>]*)>([\s\S]*?)<\/a>/gi)) {
const href = attr(m[1], 'href')
if (!href) continue
out.push({ file: resolveHref(navItem.path, href), anchor: href.split('#')[1] || null, title: clean(m[2]) })
}
if (out.length) return out
}
}
const spineToc = /<spine\b[^>]*\btoc\s*=\s*["']([^"']+)["']/i.exec(opf)?.[1]
const ncx = manifest.get(spineToc) || [...manifest.values()].find(it => /ncx/.test(it.type))
if (!ncx) return []
const xml = read(ncx.path)
const out = []
// Solo la etiqueta y el contenido propios de cada entrada: los navPoint se anidan.
for (const m of xml.matchAll(/<navPoint\b[^>]*>\s*<navLabel\b[^>]*>\s*<text\b[^>]*>([\s\S]*?)<\/text>\s*<\/navLabel>\s*<content\b([^>]*)\/?>/gi)) {
const src = attr(m[2], 'src')
if (src) out.push({ file: resolveHref(ncx.path, src), anchor: src.split('#')[1] || null, title: clean(m[1]) })
}
return out
}
const clean = s => entities(String(s).replace(/<[^>]*>/g, ' ')).replace(/\s+/g, ' ').trim()
export function parseEpub(file) {
const index = readZipIndex(file)
const entries = new Map(index.map(e => [e.name, e]))
if (index.reduce((n, e) => n + e.unzipped, 0) > MAX_UNZIPPED_TOTAL) throw new Error('EPUB demasiado grande al descomprimir')
const readBuf = name => {
const e = entries.get(name)
if (!e) throw new Error(`Falta ${name} en el EPUB`)
if (e.unzipped > MAX_UNZIPPED_ENTRY) throw new Error('Archivo demasiado grande dentro del EPUB')
return readCbzEntry(file, e, MAX_UNZIPPED_ENTRY)
}
const read = name => readBuf(name).toString('utf8').replace(/^/, '')
const container = read('META-INF/container.xml')
const opfPath = attr(/<rootfile\b([^>]*)>/i.exec(container)?.[1] || '', 'full-path')
if (!opfPath) throw new Error('EPUB sin paquete')
const opf = read(opfPath)
const dc = name => clean(new RegExp(`<dc:${name}\\b[^>]*>([\\s\\S]*?)</dc:${name}>`, 'i').exec(opf)?.[1] || '')
const manifest = new Map()
for (const m of opf.matchAll(/<item\b([^>]*)>/gi)) {
const id = attr(m[1], 'id'), href = attr(m[1], 'href')
if (id && href) manifest.set(id, { path: resolveHref(opfPath, href), type: attr(m[1], 'media-type') || '', props: attr(m[1], 'properties') || '' })
}
const spine = [...opf.matchAll(/<itemref\b([^>]*)>/gi)].map(m => manifest.get(attr(m[1], 'idref'))).filter(it => it && /x?html/i.test(it.type))
if (!spine.length) throw new Error('EPUB sin contenido')
let toc = []
try { toc = readToc(read, opf, manifest) } catch {}
const images = []
const imageIdx = new Map()
const imageOf = name => {
const e = entries.get(name)
if (!e || !BOOK_IMAGE.test(name) || e.unzipped > MAX_UNZIPPED_ENTRY) return null
if (!imageIdx.has(name)) { imageIdx.set(name, images.length); images.push(name) }
return imageIdx.get(name)
}
// Las entradas del índice que apuntan a cada archivo. Un capítulo empieza en su ancla, o en el archivo si no tiene.
const targets = new Map()
for (const t of toc) {
if (!targets.has(t.file)) targets.set(t.file, new Map())
targets.get(t.file).set(t.anchor, t.title)
}
const chapters = []
const start = title => chapters.push({ title, blocks: [] })
for (const item of spine) {
const here = targets.get(item.path)
if (here?.has(null) || (!toc.length)) start(here?.get(null) || '')
if (!chapters.length) start('')
xhtmlToBlocks(read(item.path), item.path, imageOf,
b => chapters.at(-1).blocks.push(b),
(id, flush) => {
if (!here?.has(id)) return false
flush()
start(here.get(id))
here.delete(id) // un id repetido no abre otro capítulo
return true
})
}
// Limpieza: fuera los vacíos; un capítulo que solo trae su título ("Parte primera") se une al siguiente.
const merged = []
let carry = null
for (const c of chapters) {
if (!c.blocks.length) continue
const blocks = carry ? [...carry.blocks, ...c.blocks] : c.blocks
const title = c.title || carry?.title || ''
carry = null
if (blocks.every(b => b.k === 'h' || b.k === 'hr') && c !== chapters.at(-1)) { carry = { title, blocks }; continue }
merged.push({ title, blocks })
}
if (carry) merged.push(carry)
const out = merged
if (!out.length) throw new Error('EPUB sin texto')
out.forEach((c, i) => { if (!c.title) c.title = `Sección ${i + 1}` })
// El navegador maqueta el libro entero sin cargar las imágenes: necesita saber su tamaño.
for (const c of out) {
for (const b of c.blocks) {
if (b.k !== 'img') continue
try { Object.assign(b, imageSize(readBuf(images[b.n]))) } catch {}
}
}
const year = parseInt(dc('date'), 10)
return {
version: PARSER_VERSION,
title: dc('title'), authors: [...opf.matchAll(/<dc:creator\b[^>]*>([\s\S]*?)<\/dc:creator>/gi)].map(m => clean(m[1])).filter(Boolean),
language: dc('language').slice(0, 2).toLowerCase(), description: dc('description'), year: year > 0 && year < 2100 ? year : null,
images, chapters: out,
}
}
// ---------- Fuente y caché ----------
export function setupBooks({ DATA_DIR, cached, HttpError, coverUrl, UA, net, safeFetch }) {
const DIR = path.join(DATA_DIR, 'books')
fs.mkdirSync(DIR, { recursive: true })
const epubFile = id => path.join(DIR, `${id.replace('~', '-')}.epub`)
const jsonFile = id => path.join(DIR, `${id.replace('~', '-')}.json`)
// Hasta 3 intentos: un fallo de red o un 5xx de Gutenberg suele durar poco. El motivo real queda en el log.
async function gutenberg(url, timeout) {
for (let i = 1; ; i++) {
net.guard()
let why
try {
const r = await safeFetch(url, { headers: { 'User-Agent': UA }, signal: AbortSignal.timeout(timeout) })
if (r.ok) return r
if (r.status === 404) throw new HttpError(404, 'Ese libro no está en Project Gutenberg')
if (r.status < 500 && r.status !== 429) throw new HttpError(502, `Project Gutenberg respondió ${r.status}`)
why = `HTTP ${r.status}`
} catch (e) {
if (e instanceof HttpError) throw e
why = e.cause?.code || e.cause?.message || e.message
net.failed()
}
console.warn(`Gutenberg ${url}: intento ${i}: ${why}`)
if (i >= 3) throw new HttpError(502, 'Project Gutenberg no responde ahora mismo. Prueba en un rato.')
await new Promise(r => setTimeout(r, 800 * i))
}
}
const coverOf = (n, id) => coverUrl(`${GUTENBERG}/cache/epub/${n}/pg${n}.cover.medium.jpg`, id)
// "Cervantes Saavedra, Miguel de" -> "Miguel de Cervantes Saavedra"
const authorName = a => a.split(', ').reverse().join(' ')
const entryText = (xml, tag) => clean(new RegExp(`<${tag}\\b[^>]*>([\\s\\S]*?)</${tag}>`).exec(xml)?.[1] || '')
function parseFeed(xml) {
const out = []
for (const [entry] of xml.matchAll(/<entry>[\s\S]*?<\/entry>/g)) {
const n = /<id>[^<]*\/ebooks\/(\d+)\.opds<\/id>/.exec(entry)?.[1]
if (!n) continue // "Ordenar por…" y otras entradas de navegación
const id = `gb~${n}`
// El buscador añade el idioma al título: "Las Fábulas de Esopo (Spanish)".
const title = entryText(entry, 'title').replace(/\s*\([A-Z][a-z]+\)$/, '')
out.push({ id, source: 'gb', title, cover: coverOf(n, id), sub: entryText(entry, 'content') || undefined })
}
return out
}
// lang del usuario ("es-la") a idioma del catálogo ("spanish").
async function search(q, mode, page, lang) {
const name = LANG_NAMES[String(lang || 'es').slice(0, 2)]
const text = q.trim().replace(/\s+/g, ' ')
const qs = new URLSearchParams({ query: [text, name && `l.${name}`].filter(Boolean).join(' '), sort_order: mode === 'latest' && !text ? 'release_date' : 'downloads' })
const start = (Math.max(1, page | 0) - 1) * OPDS_PAGE + 1
if (start > 1) qs.set('start_index', String(start))
return cached(`gb:${qs}`, 30 * 60e3, async () => parseFeed(await (await gutenberg(`${GUTENBERG}/ebooks/search.opds/?${qs}`, 15e3)).text()))
}
// Una sola descarga y conversión a la vez por libro.
const loading = new Map()
const memory = new Map() // últimos libros convertidos
function remember(id, book) {
memory.delete(id)
memory.set(id, book)
if (memory.size > 4) memory.delete(memory.keys().next().value)
return book
}
async function downloadEpub(id) {
const r = await gutenberg(`${GUTENBERG}/ebooks/${id.slice(3)}.epub3.images`, 120e3)
if (Number(r.headers.get('content-length')) > MAX_EPUB_BYTES) throw new HttpError(502, 'El libro es demasiado grande')
const buf = Buffer.from(await r.arrayBuffer())
if (buf.length > MAX_EPUB_BYTES) throw new HttpError(502, 'El libro es demasiado grande')
const tmp = `${epubFile(id)}.part`
fs.writeFileSync(tmp, buf)
fs.renameSync(tmp, epubFile(id))
}
function loadBook(id) {
const hit = memory.get(id)
if (hit) return Promise.resolve(remember(id, hit))
if (loading.has(id)) return loading.get(id)
const p = (async () => {
try {
const saved = JSON.parse(fs.readFileSync(jsonFile(id), 'utf8'))
if (saved.version === PARSER_VERSION) return remember(id, saved)
} catch {}
if (!fs.existsSync(epubFile(id))) await downloadEpub(id)
let book
try { book = parseEpub(epubFile(id)) } catch (e) {
console.warn(`EPUB ${id}: ${e.message}`)
fs.rmSync(epubFile(id), { force: true }) // descarga mala: que el siguiente intento la repita
throw new HttpError(502, 'No se pudo leer el EPUB de este libro')
}
const tmp = `${jsonFile(id)}.part`
fs.writeFileSync(tmp, JSON.stringify(book))
fs.renameSync(tmp, jsonFile(id))
return remember(id, book)
})().finally(() => loading.delete(id))
loading.set(id, p)
return p
}
async function manga(id) {
const book = await loadBook(id)
return {
manga: {
id, source: 'gb', type: 'Libro',
title: book.title || `Libro ${id.slice(3)}`,
description: book.description || '',
status: null, year: null, // la fecha del EPUB es la de la edición digital, no la de la obra
tags: [],
authors: book.authors.map(authorName),
languages: book.language ? [book.language] : [],
cover: coverOf(id.slice(3), id),
},
chapters: book.chapters.map((c, i) => ({ id: `${id}~${i}`, num: String(i + 1), volume: null, title: c.title, pages: null, group: '', publishAt: null })),
}
}
async function chapter(chapterId) {
const cut = chapterId.lastIndexOf('~')
const book = await loadBook(chapterId.slice(0, cut))
const c = book.chapters[Number(chapterId.slice(cut + 1))]
if (!c) throw new HttpError(404, 'Ese capítulo no existe')
return { title: c.title }
}
// El libro entero (lo maqueta el navegador), comprimido una sola vez: son megas de texto.
const packed = new Map()
async function sendText(req, res, id) {
const book = await loadBook(id)
let hit = packed.get(id)
if (hit?.book !== book) {
hit = { book, gz: zlib.gzipSync(JSON.stringify({ title: book.title, chapters: book.chapters })) }
packed.delete(id)
packed.set(id, hit)
if (packed.size > 4) packed.delete(packed.keys().next().value)
}
const headers = { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'private, no-cache', Vary: 'Accept-Encoding' }
if (/\bgzip\b/.test(req.headers['accept-encoding'] || '')) { res.writeHead(200, { ...headers, 'Content-Encoding': 'gzip' }); return res.end(hit.gz) }
res.writeHead(200, headers)
res.end(zlib.gunzipSync(hit.gz))
}
async function sendImage(res, id, n) {
const book = await loadBook(id)
const name = book.images[n]
const entry = name && readZipIndex(epubFile(id)).find(e => e.name === name)
if (!entry) throw new HttpError(404, 'Imagen no encontrada')
const data = readCbzEntry(epubFile(id), entry, MAX_UNZIPPED_ENTRY)
res.writeHead(200, { 'Content-Type': imageType(name), 'Cache-Control': 'private, max-age=604800', 'X-Content-Type-Options': 'nosniff' })
res.end(data)
}
return { search, manga, chapter, sendText, sendImage }
}
+14 -8
View File
@@ -56,8 +56,14 @@ export function writeCbz(file, entries) {
fs.renameSync(tmp, file) fs.renameSync(tmp, file)
} }
// Devuelve las imágenes del CBZ ordenadas por nombre: [{ name, method, start, size }] // Devuelve las imágenes del CBZ ordenadas por nombre: [{ name, method, start, size, unzipped }]
export function readCbzIndex(file) { export function readCbzIndex(file) {
return readZipIndex(file).filter(e => IMAGE_EXT.test(e.name) && !e.name.startsWith('__MACOSX'))
.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }))
}
// Todas las entradas del ZIP, en el orden del archivo (también lo usan los EPUB).
export function readZipIndex(file) {
const fd = fs.openSync(file, 'r') const fd = fs.openSync(file, 'r')
try { try {
const { size } = fs.fstatSync(fd) const { size } = fs.fstatSync(fd)
@@ -65,7 +71,7 @@ export function readCbzIndex(file) {
const tail = Buffer.alloc(tailLen) const tail = Buffer.alloc(tailLen)
fs.readSync(fd, tail, 0, tailLen, size - tailLen) fs.readSync(fd, tail, 0, tailLen, size - tailLen)
const eocd = tail.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06])) const eocd = tail.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06]))
if (eocd < 0) throw new Error('CBZ inválido') if (eocd < 0) throw new Error('ZIP inválido')
const count = tail.readUInt16LE(eocd + 10) const count = tail.readUInt16LE(eocd + 10)
const cdSize = tail.readUInt32LE(eocd + 12) const cdSize = tail.readUInt32LE(eocd + 12)
const cdOffset = tail.readUInt32LE(eocd + 16) const cdOffset = tail.readUInt32LE(eocd + 16)
@@ -76,18 +82,18 @@ export function readCbzIndex(file) {
for (let p = 0, i = 0; i < count; i++) { for (let p = 0, i = 0; i < count; i++) {
const method = cd.readUInt16LE(p + 10) const method = cd.readUInt16LE(p + 10)
const compSize = cd.readUInt32LE(p + 20) const compSize = cd.readUInt32LE(p + 20)
const unzipped = cd.readUInt32LE(p + 24)
const nameLen = cd.readUInt16LE(p + 28) const nameLen = cd.readUInt16LE(p + 28)
const extraLen = cd.readUInt16LE(p + 30) const extraLen = cd.readUInt16LE(p + 30)
const commentLen = cd.readUInt16LE(p + 32) const commentLen = cd.readUInt16LE(p + 32)
const localOffset = cd.readUInt32LE(p + 42) const localOffset = cd.readUInt32LE(p + 42)
const name = cd.toString('utf8', p + 46, p + 46 + nameLen) const name = cd.toString('utf8', p + 46, p + 46 + nameLen)
p += 46 + nameLen + extraLen + commentLen p += 46 + nameLen + extraLen + commentLen
if (!IMAGE_EXT.test(name) || name.startsWith('__MACOSX')) continue
fs.readSync(fd, lh, 0, 30, localOffset) fs.readSync(fd, lh, 0, 30, localOffset)
const start = localOffset + 30 + lh.readUInt16LE(26) + lh.readUInt16LE(28) const start = localOffset + 30 + lh.readUInt16LE(26) + lh.readUInt16LE(28)
entries.push({ name, method, start, size: compSize }) entries.push({ name, method, start, size: compSize, unzipped })
} }
return entries.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true })) return entries
} finally { } finally {
fs.closeSync(fd) fs.closeSync(fd)
} }
@@ -98,13 +104,13 @@ export function imageType(name) {
return { jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', webp: 'image/webp', gif: 'image/gif', avif: 'image/avif' }[ext] || 'application/octet-stream' return { jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', webp: 'image/webp', gif: 'image/gif', avif: 'image/avif' }[ext] || 'application/octet-stream'
} }
// Lee una entrada completa del CBZ a memoria. // Lee una entrada completa del CBZ a memoria. maxBytes corta una entrada que al descomprimirse crezca de más.
export function readCbzEntry(file, entry) { export function readCbzEntry(file, entry, maxBytes) {
if (entry.method !== 0 && entry.method !== 8) throw new Error(`Compresión ZIP no soportada (${entry.method})`) if (entry.method !== 0 && entry.method !== 8) throw new Error(`Compresión ZIP no soportada (${entry.method})`)
const fd = fs.openSync(file, 'r') const fd = fs.openSync(file, 'r')
const buf = Buffer.alloc(entry.size) const buf = Buffer.alloc(entry.size)
try { fs.readSync(fd, buf, 0, entry.size, entry.start) } finally { fs.closeSync(fd) } try { fs.readSync(fd, buf, 0, entry.size, entry.start) } finally { fs.closeSync(fd) }
return entry.method === 8 ? zlib.inflateRawSync(buf) : buf return entry.method === 8 ? zlib.inflateRawSync(buf, maxBytes ? { maxOutputLength: maxBytes } : {}) : buf
} }
// Envía una entrada del CBZ por la respuesta HTTP. // Envía una entrada del CBZ por la respuesta HTTP.
+1
View File
@@ -218,6 +218,7 @@ export function setupFeatures({ db, fetchMangaFull, enqueueDownload, downloadInf
if (total && page >= total - 1) { if (total && page >= total - 1) {
db.prepare('INSERT OR IGNORE INTO chapter_done (user_id, chapter_id, manga_id, day) VALUES (?, ?, ?, ?)').run(userId, b.chapterId, b.mangaId, day) db.prepare('INSERT OR IGNORE INTO chapter_done (user_id, chapter_id, manga_id, day) VALUES (?, ?, ?, ?)').run(userId, b.chapterId, b.mangaId, day)
} }
if (/^(gb|ws|ub)~/.test(b.mangaId)) return // un libro no tiene capítulos nuevos que seguir
// Seguir automáticamente lo que se empieza a leer (salvo que se haya dejado de seguir). // Seguir automáticamente lo que se empieza a leer (salvo que se haya dejado de seguir).
db.prepare('INSERT OR IGNORE INTO follows (user_id, manga_id, title, cover, lang, created_at) VALUES (?, ?, ?, ?, ?, ?)') db.prepare('INSERT OR IGNORE INTO follows (user_id, manga_id, title, cover, lang, created_at) VALUES (?, ?, ?, ?, ?, ?)')
.run(userId, b.mangaId, String(b.mangaTitle || '').slice(0, 300), b.cover ? String(b.cover).slice(0, 1000) : null, .run(userId, b.mangaId, String(b.mangaTitle || '').slice(0, 300), b.cover ? String(b.cover).slice(0, 1000) : null,
+596 -49
View File
@@ -50,8 +50,14 @@ const icon = name => `<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="cu
// Iconos del menú superior (index.html). // Iconos del menú superior (index.html).
document.querySelectorAll('#topbar nav a[data-icon]').forEach(a => { a.innerHTML = `${icon(a.dataset.icon)}<span>${a.textContent}</span>` }) document.querySelectorAll('#topbar nav a[data-icon]').forEach(a => { a.innerHTML = `${icon(a.dataset.icon)}<span>${a.textContent}</span>` })
const SOURCES = { lc: 'LeerCapitulo (español)', mi: 'M440 (español)', md: 'MangaDex (multi-idioma)' } const SOURCES = { lc: 'LeerCapitulo (español)', mi: 'M440 (español)', md: 'MangaDex (multi-idioma)', gb: 'Project Gutenberg (libros)' }
const NO_CATS = new Set(['mi']) // fuentes sin listado por categorías const NO_CATS = new Set(['mi', 'gb']) // fuentes sin listado por categorías
const BOOK_SOURCES = new Set(['gb'])
const isBookId = id => /^(gb|ws|ub)~/.test(id || '')
// Ajuste del usuario: ver solo manga, solo libros o ambos. Solo filtra lo que se ofrece; un enlace directo sigue abriendo.
const contentMode = () => me?.content_mode || 'both'
const wantsId = id => contentMode() === 'both' || (contentMode() === 'books') === isBookId(id)
const visibleSources = () => Object.entries(SOURCES).filter(([k]) => contentMode() === 'both' || (contentMode() === 'books') === BOOK_SOURCES.has(k))
// Filtros de imagen del lector (CSS filter); el brillo se suma aparte. // Filtros de imagen del lector (CSS filter); el brillo se suma aparte.
const FILTERS = { const FILTERS = {
none: ['Sin filtro', ''], none: ['Sin filtro', ''],
@@ -431,6 +437,7 @@ function continueCard(h) {
} }
// Sección "Novedades": mangas seguidos con capítulos nuevos. // Sección "Novedades": mangas seguidos con capítulos nuevos.
function newsSection(fol) { function newsSection(fol) {
fol = { ...fol, follows: fol.follows.filter(f => wantsId(f.manga_id)) }
const fresh = fol.follows.filter(f => f.newCount > 0) const fresh = fol.follows.filter(f => f.newCount > 0)
const status = fol.checking ? 'Comprobando…' : fol.lastCheckAt ? `Última comprobación ${timeAgo(fol.lastCheckAt)}` : `Se comprueba cada ${fol.intervalHours} h` const status = fol.checking ? 'Comprobando…' : fol.lastCheckAt ? `Última comprobación ${timeAgo(fol.lastCheckAt)}` : `Se comprueba cada ${fol.intervalHours} h`
if (!fol.follows.length) return '' if (!fol.follows.length) return ''
@@ -484,7 +491,8 @@ async function viewHomeOffline(params) {
<h2>${icon('download')} Descargado en el servidor</h2> <h2>${icon('download')} Descargado en el servidor</h2>
<form class="search"><input type="search" name="q" placeholder="Buscar en lo descargado…" value="${esc(params.get('q') || '')}" autocomplete="off"></form> <form class="search"><input type="search" name="q" placeholder="Buscar en lo descargado…" value="${esc(params.get('q') || '')}" autocomplete="off"></form>
<div id="results" class="loading">Cargando…</div>` <div id="results" class="loading">Cargando…</div>`
const [list, hist] = await Promise.all([api('/api/search?q='), api('/api/history').catch(() => [])]) const [all, hist] = await Promise.all([api('/api/search?q='), api('/api/history').catch(() => [])])
const list = all.filter(m => wantsId(m.id))
const have = new Set(list.map(m => m.id)) const have = new Set(list.map(m => m.id))
const cont = hist.filter(h => !h.finished && have.has(h.manga_id)) const cont = hist.filter(h => !h.finished && have.has(h.manga_id))
if (cont.length) $('#cont').innerHTML = `<h2>Continuar leyendo</h2><div class="continue">${cont.slice(0, 12).map(continueCard).join('')}</div>` if (cont.length) $('#cont').innerHTML = `<h2>Continuar leyendo</h2><div class="continue">${cont.slice(0, 12).map(continueCard).join('')}</div>`
@@ -517,7 +525,9 @@ async function viewHome(params) {
const tag = params.get('tag') || '' const tag = params.get('tag') || ''
const tagName = params.get('tagName') || tag const tagName = params.get('tagName') || tag
const anyLang = params.get('any') === '1' const anyLang = params.get('any') === '1'
const source = SOURCES[params.get('source')] ? params.get('source') : store.get('source', 'lc') const shownSources = visibleSources()
const isShown = k => shownSources.some(([s]) => s === k)
const source = isShown(params.get('source')) ? params.get('source') : isShown(store.get('source', 'lc')) ? store.get('source', 'lc') : shownSources[0][0]
let mode = tag ? 'cats' : params.get('mode') || 'popular' let mode = tag ? 'cats' : params.get('mode') || 'popular'
if (mode === 'cats' && NO_CATS.has(source)) mode = 'popular' if (mode === 'cats' && NO_CATS.has(source)) mode = 'popular'
store.set('source', source) store.set('source', source)
@@ -525,12 +535,12 @@ async function viewHome(params) {
app.innerHTML = ` app.innerHTML = `
<section id="cont"></section> <section id="cont"></section>
<section id="recs"></section> <section id="recs"></section>
<div class="tabs" id="sources">${Object.entries(SOURCES).map(([k, v]) => `<button data-s="${k}" class="${k === source ? 'on' : ''}" title="${esc(v)}">${esc(v.replace(/\s*\(.*\)$/, ''))}</button>`).join('')}</div> <div class="tabs" id="sources">${shownSources.map(([k, v]) => `<button data-s="${k}" class="${k === source ? 'on' : ''}" title="${esc(v)}">${esc(v.replace(/\s*\(.*\)$/, ''))}</button>`).join('')}</div>
<form class="search"><input type="search" name="q" placeholder="Buscar ${tag ? `en «${esc(tagName)}»` : `en ${esc(SOURCES[source])}`}…" value="${esc(q)}"><button class="btn primary">${icon('search')}<span class="lbl">Buscar</span></button></form> <form class="search"><input type="search" name="q" placeholder="Buscar ${tag ? `en «${esc(tagName)}»` : `en ${esc(SOURCES[source])}`}…" value="${esc(q)}"><button class="btn primary">${icon('search')}<span class="lbl">Buscar</span></button></form>
<label class="muted row" style="font-size:.88rem" ${source === 'md' ? '' : 'hidden'}><input type="checkbox" name="any" ${anyLang ? 'checked' : ''}> Incluir mangas sin traducción a ${esc(LANGS[me.lang] || me.lang)}</label> <label class="muted row" style="font-size:.88rem" ${source === 'md' ? '' : 'hidden'}><input type="checkbox" name="any" ${anyLang ? 'checked' : ''}> Incluir mangas sin traducción a ${esc(LANGS[me.lang] || me.lang)}</label>
${q && !tag ? `<h2>Resultados para “${esc(q)}”</h2>` : `<div class="tabs" id="modes"> ${q && !tag ? `<h2>Resultados para “${esc(q)}”</h2>` : `<div class="tabs" id="modes">
<button data-m="popular" class="${mode === 'popular' ? 'on' : ''}">${icon('flame')} Populares</button> <button data-m="popular" class="${mode === 'popular' ? 'on' : ''}">${icon('flame')} Populares</button>
<button data-m="latest" class="${mode === 'latest' ? 'on' : ''}">${icon('clock')} Actualizados recientemente</button> <button data-m="latest" class="${mode === 'latest' ? 'on' : ''}">${icon('clock')} ${BOOK_SOURCES.has(source) ? 'Añadidos recientemente' : 'Actualizados recientemente'}</button>
${NO_CATS.has(source) ? '' : `<button data-m="cats" class="${mode === 'cats' ? 'on' : ''}">${icon('grid')} Categorías</button>`}</div>`} ${NO_CATS.has(source) ? '' : `<button data-m="cats" class="${mode === 'cats' ? 'on' : ''}">${icon('grid')} Categorías</button>`}</div>`}
${tag ? `<h2>Categoría: <a class="tag on" href="#/?source=${source}&mode=cats" title="Quitar filtro">${esc(tagName)} ✕</a>${q ? ` · “${esc(q)}”` : ''}</h2>` : ''} ${tag ? `<h2>Categoría: <a class="tag on" href="#/?source=${source}&mode=cats" title="Quitar filtro">${esc(tagName)} ✕</a>${q ? ` · “${esc(q)}”` : ''}</h2>` : ''}
<div id="results" class="loading">Cargando…</div> <div id="results" class="loading">Cargando…</div>
@@ -548,10 +558,10 @@ async function viewHome(params) {
if (!q && !tag && !showCats) { if (!q && !tag && !showCats) {
Promise.all([api('/api/history'), api('/api/follows')]).then(([hist, fol]) => { Promise.all([api('/api/history'), api('/api/follows')]).then(([hist, fol]) => {
if (!$('#cont')) return if (!$('#cont')) return
$('#cont').innerHTML = `<div id="news">${newsSection(fol)}</div>` + (hist = hist.filter(h => !h.finished), hist.length ? `<h2>Continuar leyendo</h2><div class="continue">${hist.slice(0, 12).map(continueCard).join('')}</div>` : '') $('#cont').innerHTML = `<div id="news">${newsSection(fol)}</div>` + (hist = hist.filter(h => !h.finished && wantsId(h.manga_id)), hist.length ? `<h2>Continuar leyendo</h2><div class="continue">${hist.slice(0, 12).map(continueCard).join('')}</div>` : '')
bindNews(fol) bindNews(fol)
}).catch(() => {}) }).catch(() => {})
loadRecs() if (contentMode() !== 'books') loadRecs() // las recomendaciones son de manga
} }
const res = $('#results') const res = $('#results')
@@ -564,7 +574,7 @@ async function viewHome(params) {
} }
// Paginación: MangaDex siempre; LeerCapitulo solo en categorías; M440 en "Actualizados" (sus otros listados no paginan). // Paginación: MangaDex siempre; LeerCapitulo solo en categorías; M440 en "Actualizados" (sus otros listados no paginan).
const pageable = source === 'md' || !!tag || (source === 'mi' && mode === 'latest' && !q) const pageable = source === 'md' || BOOK_SOURCES.has(source) || !!tag || (source === 'mi' && mode === 'latest' && !q)
const seen = new Set() const seen = new Set()
let page = 1 let page = 1
const load = async () => { const load = async () => {
@@ -650,6 +660,7 @@ async function viewManga(id, langOverride) {
let isFollowing = following let isFollowing = following
const readMap = new Map(reads.map(r => [r.chapter_id, r])) const readMap = new Map(reads.map(r => [r.chapter_id, r]))
const isLc = manga.source !== 'md' // sitios en español sin selector de idioma const isLc = manga.source !== 'md' // sitios en español sin selector de idioma
const isBook = isBookId(id)
const langOpts = [...new Set([lang, me.lang, ...chapterLangs, ...manga.languages])].filter(Boolean) const langOpts = [...new Set([lang, me.lang, ...chapterLangs, ...manga.languages])].filter(Boolean)
const altSearch = `#/?source=lc&q=${encodeURIComponent(manga.title)}` const altSearch = `#/?source=lc&q=${encodeURIComponent(manga.title)}`
const altSearchMi = `#/?source=mi&q=${encodeURIComponent(manga.title)}` const altSearchMi = `#/?source=mi&q=${encodeURIComponent(manga.title)}`
@@ -663,8 +674,8 @@ async function viewManga(id, langOverride) {
if (first && !mainBtn) mainBtn = `<a class="btn primary" href="${readUrl(id, first.id)}">${icon('book')} Empezar a leer</a>` if (first && !mainBtn) mainBtn = `<a class="btn primary" href="${readUrl(id, first.id)}">${icon('book')} Empezar a leer</a>`
const menuItems = [ const menuItems = [
manga.source === 'lc' ? '' : `<a class="btn" href="${altSearch}">${icon('search')} Buscar en LeerCapitulo</a>`, manga.source === 'lc' || isBook ? '' : `<a class="btn" href="${altSearch}">${icon('search')} Buscar en LeerCapitulo</a>`,
manga.source === 'mi' ? '' : `<a class="btn" href="${altSearchMi}">${icon('search')} Buscar en M440</a>`, manga.source === 'mi' || isBook ? '' : `<a class="btn" href="${altSearchMi}">${icon('search')} Buscar en M440</a>`,
progress || reads.length ? `<button id="drop" class="btn danger">${icon('stopRead')} Dejar de leer…</button>` : '', progress || reads.length ? `<button id="drop" class="btn danger">${icon('stopRead')} Dejar de leer…</button>` : '',
].join('') ].join('')
@@ -697,7 +708,7 @@ async function viewManga(id, langOverride) {
${clipCount ? `<a class="btn" href="#/clips?manga=${encodeURIComponent(id)}">${icon('scissors')} ${clipCount} recorte${clipCount > 1 ? 's' : ''}</a>` : ''} ${clipCount ? `<a class="btn" href="#/clips?manga=${encodeURIComponent(id)}">${icon('scissors')} ${clipCount} recorte${clipCount > 1 ? 's' : ''}</a>` : ''}
${menuItems} ${menuItems}
</div> </div>
<div class="libpanel"><div class="psec" id="dl"></div><div class="psec" id="hd" hidden></div><div class="psec" id="off" hidden></div></div> <div class="libpanel" ${isBook ? 'style="display:none"' : ''}><div class="psec" id="dl"></div><div class="psec" id="hd" hidden></div><div class="psec" id="off" hidden></div></div>
<div class="libpanel" id="mdata"><div class="psec"><div class="psec-title">${icon('book')} Datos de la obra</div><dl class="kv" id="mkv"></dl></div></div> <div class="libpanel" id="mdata"><div class="psec"><div class="psec-title">${icon('book')} Datos de la obra</div><dl class="kv" id="mkv"></dl></div></div>
</aside> </aside>
<div class="list-head"> <div class="list-head">
@@ -735,7 +746,7 @@ async function viewManga(id, langOverride) {
<a class="tag" href="https://anilist.co/search/manga?search=${encodeURIComponent(manga.title)}" target="_blank" rel="noopener">AniList</a>.</p>` : '') <a class="tag" href="https://anilist.co/search/manga?search=${encodeURIComponent(manga.title)}" target="_blank" rel="noopener">AniList</a>.</p>` : '')
} }
paintInfo(savedInfo) paintInfo(savedInfo)
if (!savedInfo) { if (!savedInfo && !isBook) {
$('#mextra').innerHTML = '<p class="muted note">Buscando información de la obra…</p>' $('#mextra').innerHTML = '<p class="muted note">Buscando información de la obra…</p>'
api(`/api/meta/${id}`).then(paintInfo, () => { if (synBox.isConnected) $('#mextra').innerHTML = '' }) api(`/api/meta/${id}`).then(paintInfo, () => { if (synBox.isConnected) $('#mextra').innerHTML = '' })
} }
@@ -1116,7 +1127,7 @@ async function viewManga(id, langOverride) {
b.innerHTML = isFollowing ? `${icon('bell')} Siguiendo` : `${icon('bellOff')} Seguir` b.innerHTML = isFollowing ? `${icon('bell')} Siguiendo` : `${icon('bellOff')} Seguir`
b.classList.toggle('on', isFollowing) b.classList.toggle('on', isFollowing)
b.title = isFollowing ? 'Recibes avisos de capítulos nuevos. Pulsa para dejar de seguir.' : 'Avisarme cuando salgan capítulos nuevos' b.title = isFollowing ? 'Recibes avisos de capítulos nuevos. Pulsa para dejar de seguir.' : 'Avisarme cuando salgan capítulos nuevos'
b.hidden = offline b.hidden = offline || isBook
} }
renderFollow() renderFollow()
$('#follow').addEventListener('click', async () => { $('#follow').addEventListener('click', async () => {
@@ -1224,7 +1235,7 @@ async function viewManga(id, langOverride) {
updateSummary() updateSummary()
}) })
renderList() renderList()
api(`/api/meta/${id}/dates`).then(r => { extDates = r.dates || {}; if (Object.keys(extDates).length) { buildDates(); renderList() } }, () => {}) if (!isBook) api(`/api/meta/${id}/dates`).then(r => { extDates = r.dates || {}; if (Object.keys(extDates).length) { buildDates(); renderList() } }, () => {})
renderDl(download) renderDl(download)
renderHd(upscale) renderHd(upscale)
renderOff() renderOff()
@@ -1330,12 +1341,418 @@ function turnSound(level, pan = 0) {
} }
// Ajustes por defecto según el tipo de obra (se usan hasta que el usuario cambie algo en ese manga). // Ajustes por defecto según el tipo de obra (se usan hasta que el usuario cambie algo en ese manga).
function defaultPrefs(manga) { function defaultPrefs(manga) {
if (manga.type === 'Libro') return { mode: 'paged', fit: 'height', dir: 'ltr' }
if (['Manhwa', 'Manhua', 'Webtoon'].includes(manga.type)) return { mode: 'vertical', fit: 'width', dir: 'ltr' } if (['Manhwa', 'Manhua', 'Webtoon'].includes(manga.type)) return { mode: 'vertical', fit: 'width', dir: 'ltr' }
if (manga.type === 'Manga') return { mode: 'paged', fit: 'height', dir: 'rtl' } if (manga.type === 'Manga') return { mode: 'paged', fit: 'height', dir: 'rtl' }
const legacy = store.get('readerSettings', {}) const legacy = store.get('readerSettings', {})
return { mode: READ_MODES[legacy.mode] ? legacy.mode : 'paged', fit: legacy.fit === 'width' ? 'width' : 'height', dir: legacy.dir === 'rtl' ? 'rtl' : 'ltr' } return { mode: READ_MODES[legacy.mode] ? legacy.mode : 'paged', fit: legacy.fit === 'width' ? 'width' : 'height', dir: legacy.dir === 'rtl' ? 'rtl' : 'ltr' }
} }
// ---------- Libros: el libro entero se maqueta como un texto continuo (cada capítulo empieza justo debajo del
// anterior) en páginas del tamaño de la pantalla, y cada página se dibuja en un canvas. Al lector le llega como
// una imagen más, así que hoja, sombra, hendidura y sonido no cambian.
const BOOK_DEFAULTS = { size: 100, font: 'serif', margin: 'mid', paper: 'light' }
const BOOK_FONTS = { serif: "Georgia, 'Iowan Old Style', 'Noto Serif', 'Times New Roman', serif", sans: "system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif" }
const BOOK_PAPERS = { light: { bg: '#fbf9f3', fg: '#22201c', dim: '#8b867b' }, sepia: { bg: '#f3e9d3', fg: '#3a2f21', dim: '#8f7f66' }, dark: { bg: '#1b1a18', fg: '#d8d4ca', dim: '#7c786f' } }
const BOOK_MARGINS = { small: 0.055, mid: 0.085, large: 0.12 }
const BOOK_KEEP_PAGES = 16 // páginas dibujadas que se conservan a cada lado de la actual
const bookSettings = () => ({ ...BOOK_DEFAULTS, ...store.get('bookSettings', {}) })
let bookResume = null // al volver a maquetar (rotar, cambiar la letra): seguir en la misma palabra
let bookCache = null // { id, text } el último libro, para no pedirlo otra vez al reabrir
let bookLayoutCache = null // { key, layout } y su maquetado, para esta pantalla y esta letra
// El libro como una sola lista de bloques. Cada capítulo lleva su división y su título, aunque el texto no lo traiga.
function bookFlatten(chapters) {
const flat = [], chapBlock = []
chapters.forEach((c, i) => {
chapBlock.push(flat.length)
if (i > 0) flat.push({ k: 'div' })
if (c.blocks[0]?.k !== 'h' && !/^Sección \d+$/.test(c.title)) flat.push({ k: 'h', l: 2, r: [[c.title, 0]] })
for (const b of c.blocks) flat.push(b)
})
return { flat, chapBlock }
}
// Una primera pasada solo busca dónde empieza cada página; lo que se dibuja se calcula al pedir esa página. Así un
// libro de miles de páginas no ocupa memoria con la posición de cada palabra.
function makeBookLayout(text, W, H, opts) {
const { flat, chapBlock } = bookFlatten(text.chapters)
const measure = document.createElement('canvas').getContext('2d')
const family = BOOK_FONTS[opts.font] || BOOK_FONTS.serif
const px = Math.min(22, Math.max(15, W / 24)) * (opts.size / 100)
const lineH = Math.round(px * 1.45)
const marginX = Math.round(W * (BOOK_MARGINS[opts.margin] || BOOK_MARGINS.mid))
const top = Math.round(Math.max(px * 2.6, 30)), bottom = Math.round(H - Math.max(px * 2.6, 30))
const cw = W - marginX * 2
const widths = new Map()
const fontOf = (size, style, forceBold) => `${style & 1 ? 'italic ' : ''}${style & 2 || forceBold ? 'bold ' : ''}${size}px ${family}`
const widthOf = (t, font) => {
const key = `${font}|${t}`
let w = widths.get(key)
if (w === undefined) {
if (measure.font !== font) measure.font = font
w = measure.measureText(t).width
widths.set(key, w)
}
return w
}
// Un bloque en líneas: cada línea son cajas (palabras, que pueden cambiar de estilo por dentro).
function breakLines(runs, width, indent, size, forceBold) {
const spaceW = widthOf(' ', fontOf(size, 0, forceBold))
const lines = []
let line = { boxes: [], w: 0, hard: false }
let box = null, space = false
const room = () => width - (lines.length ? 0 : indent)
const endLine = hard => { line.hard = hard; lines.push(line); line = { boxes: [], w: 0, hard: false } }
const place = b => {
if (line.boxes.length && line.w + spaceW + b.w > room()) endLine(false)
line.w += (line.boxes.length ? spaceW : 0) + b.w
line.boxes.push(b)
}
const flushBox = () => {
if (!box) return
// Una palabra más ancha que la línea se corta por letras.
if (box.w > room() && box.frags.length === 1) {
const { font } = box.frags[0]
let cur = ''
for (const ch of Array.from(box.frags[0].t)) {
if (cur && widthOf(cur + ch, font) > width) {
place({ frags: [{ t: cur, font, w: widthOf(cur, font) }], w: widthOf(cur, font) })
endLine(false)
cur = ''
}
cur += ch
}
if (cur) place({ frags: [{ t: cur, font, w: widthOf(cur, font) }], w: widthOf(cur, font) })
} else place(box)
box = null
}
for (const [t0, style] of runs) {
const font = fontOf(size, style, forceBold)
for (const m of t0.matchAll(/\n| +|[^ \n]+/g)) {
const t = m[0]
if (t === '\n') { flushBox(); space = false; endLine(true) }
else if (t[0] === ' ') { flushBox(); space = true }
else {
if (space) { flushBox(); space = false }
const w = widthOf(t, font)
box ??= { frags: [], w: 0 }
box.frags.push({ t, font, w })
box.w += w
}
}
}
flushBox()
if (line.boxes.length) endLine(true)
return { lines, spaceW }
}
const headSize = b => Math.round(px * ({ 1: 1.5, 2: 1.35, 3: 1.2 }[b.l] || 1.08))
const headLines = b => { const size = headSize(b), height = Math.round(size * 1.3); return { ...breakLines(b.r, cw, 0, size, true), height } }
const pages = [{ block: 0, line: 0, word: 0, after: true }]
const chapStarts = [0]
// Recorre el libro desde el inicio de una página. Sin items, anota dónde empieza cada página; con items, recoge lo
// que hay que dibujar en esa página y termina.
function* walk(cp, items) {
let y = top, used = false, words = cp.word, after = cp.after
const newPage = (block, line) => {
if (items) return true
pages.push({ block, line, word: words, after })
y = top
used = false
return false
}
const remaining = () => bottom - y
const placeLine = (l, { x, width, indent, center, justify, spaceW, height }) => {
if (items) {
const avail = width - (center ? 0 : indent)
let gap = spaceW
if (justify && !l.hard && l.boxes.length > 1) {
const stretched = spaceW + (avail - l.w) / (l.boxes.length - 1)
if (stretched < spaceW + px * 1.2) gap = stretched // una línea muy suelta se queda sin justificar
}
let cx = center ? x + (width - l.w) / 2 : x + indent
const segs = []
for (const b of l.boxes) {
for (const f of b.frags) { segs.push({ t: f.t, font: f.font, x: cx }); cx += f.w }
cx += gap
}
items.push({ line: segs, y: y + height * 0.78 })
}
y += height
words += l.boxes.length
used = true
}
for (let b = cp.block; b < flat.length; b++) {
if (!items && !(b & 127)) yield
const block = flat[b]
const from = b === cp.block ? cp.line : 0
if (block.k === 'div') {
// División de capítulo: el capítulo sigue en la misma página si caben la línea, su título y algo de texto.
const nx = flat[b + 1]
let need = lineH * 5
if (nx?.k === 'h') { const h = headLines(nx); need += h.lines.length * h.height }
if (used && remaining() < need && newPage(b, 0)) return
if (!items) chapStarts.push(pages.length - 1)
if (used) {
y += lineH * 1.2
items?.push({ rule: true, y })
y += lineH * 0.6
}
after = true
continue
}
if (block.k === 'hr') {
if (remaining() < lineH * 3 && used && newPage(b, 0)) return
y += lineH * 0.7
items?.push({ rule: true, y })
y += lineH * 0.8
used = true
after = true
continue
}
if (block.k === 'img') {
if (!block.w || !block.h) continue
const fit = Math.min(cw / block.w, (bottom - top) / block.h, 2)
const w = block.w * fit, h = block.h * fit
if (h > remaining() && used && newPage(b, 0)) return
items?.push({ n: block.n, x: (W - w) / 2, y, w, h })
y += h + lineH * 0.5
used = true
after = true
continue
}
if (block.k === 'h') {
const { lines, spaceW, height } = headLines(block)
if (!from) {
const need = lines.length * height + lineH * 3 + (used ? lineH * 1.2 : 0)
if (used && remaining() < need && newPage(b, 0)) return // el título no se queda solo al pie
if (used) y += lineH * 1.2
}
for (let i = from; i < lines.length; i++) {
if (remaining() < height && used && newPage(b, i)) return
placeLine(lines[i], { x: marginX, width: cw, indent: 0, center: true, justify: false, spaceW, height })
}
y += lineH * 0.8
after = true
continue
}
const quote = block.k === 'q'
const verse = !!block.v
const center = !!block.c
const inset = quote ? Math.round(px * 1.4) : 0
const width = cw - inset * 2
const indent = !after && !quote && !verse && !center ? Math.round(px * 1.3) : 0
const { lines, spaceW } = breakLines(block.r, width, indent, px, false)
if (quote && used && !from) y += lineH * 0.3
for (let i = from; i < lines.length;) {
const fits = Math.floor(remaining() / lineH)
let k = Math.min(fits, lines.length - i)
if (!verse) {
// Ni una primera línea sola al pie ni una última línea sola arriba de la página siguiente.
if (i === 0 && k === 1 && lines.length > 1) k = 0
else if (i + k === lines.length - 1) k = k >= 2 ? k - 1 : 0
}
if (k <= 0) {
if (!used) k = Math.max(1, Math.min(fits, lines.length - i)) // no cabe ni en una página vacía: no dar vueltas
else { if (newPage(b, i)) return; continue }
}
for (let n = 0; n < k; n++) placeLine(lines[i + n], { x: marginX + inset, width, indent: i + n === 0 ? indent : 0, center, justify: !verse && !center, spaceW, height: lineH })
i += k
if (i < lines.length && newPage(b, i)) return
}
if (quote) y += lineH * 0.3
after = quote
}
}
return {
pages, chapStarts, chapBlock, flat, top, cw, family, footPx: Math.max(11, Math.round(px * 0.68)),
// Reparte el libro cediendo el paso de vez en cuando, para que la pantalla no se quede congelada.
async build() {
let t = performance.now()
for (const _ of walk(pages[0], null)) {
if (performance.now() - t > 40) { await new Promise(r => setTimeout(r)); t = performance.now() }
}
},
capture(i) {
const items = []
const run = walk(pages[i], items)
while (!run.next().done);
return items
},
}
}
async function loadBookText(mangaId) {
if (bookCache?.id === mangaId) return bookCache.text
const text = await api(`/api/book/${mangaId}/text`)
bookCache = { id: mangaId, text }
return text
}
async function createBook(text, mangaId, size) {
const opts = bookSettings()
const colors = BOOK_PAPERS[opts.paper] || BOOK_PAPERS.light
const { w: W, h: H } = size
const key = JSON.stringify([mangaId, W, H, opts])
let layout = bookLayoutCache?.key === key ? bookLayoutCache.layout : null
if (!layout) {
layout = makeBookLayout(text, W, H, opts)
await layout.build()
bookLayoutCache = { key, layout }
}
const { pages, chapStarts, chapBlock, flat } = layout
const dpr = Math.min(window.devicePixelRatio || 1, 3, 4096 / Math.max(W, H))
const canvas = document.createElement('canvas')
canvas.width = Math.round(W * dpr)
canvas.height = Math.round(H * dpr)
const ctx = canvas.getContext('2d', { alpha: false })
const urls = new Map()
const waiting = new Map()
const queue = []
const images = new Map() // n -> { im, ready }
let released = false, timer = 0
const imageOf = n => {
let e = images.get(n)
if (!e) {
const im = new Image()
e = { im, ok: false, ready: new Promise(r => { im.onload = () => { e.ok = im.naturalWidth > 0; e.failed = !e.ok; r() }; im.onerror = () => { e.failed = true; r() } }) }
images.set(n, e)
im.src = `/api/book/${mangaId}/img/${n}`
}
return e
}
const chapterOfBlock = block => {
let lo = 0, hi = chapBlock.length - 1
while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (chapBlock[mid] <= block) lo = mid; else hi = mid - 1 }
return lo
}
function draw(i, items) {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.fillStyle = colors.bg
ctx.fillRect(0, 0, W, H)
ctx.textBaseline = 'alphabetic'
ctx.textAlign = 'left'
ctx.fillStyle = colors.fg
let font = ''
for (const it of items) {
if (it.line) {
for (const s of it.line) {
if (s.font !== font) { ctx.font = font = s.font }
ctx.fillText(s.t, s.x, it.y)
}
} else if (it.rule) {
ctx.fillStyle = colors.dim
ctx.fillRect(W / 2 - W * 0.08, it.y, W * 0.16, 1)
ctx.fillStyle = colors.fg
} else {
const e = imageOf(it.n)
if (e.ok) ctx.drawImage(e.im, it.x, it.y, it.w, it.h)
}
}
// Título del capítulo arriba (salvo donde empieza) y número abajo: guías de lectura, no parte del texto.
ctx.fillStyle = colors.dim
ctx.textAlign = 'center'
ctx.font = `${layout.footPx}px ${layout.family}`
const p = pages[i]
const c = chapterOfBlock(p.block)
const title = text.chapters[c].title
const atStart = p.line === 0 && (p.block === chapBlock[c] || (p.block === chapBlock[c] + 1 && flat[p.block].k === 'h'))
if (!atStart && title && !/^Sección \d+$/.test(title)) {
let t = title
if (ctx.measureText(t).width > layout.cw) {
while (t && ctx.measureText(`${t}…`).width > layout.cw) t = t.slice(0, -1)
t += '…'
}
ctx.fillText(t, W / 2, layout.top - layout.footPx * 1.2)
}
ctx.fillText(`${i + 1} / ${pages.length}`, W / 2, H - layout.footPx * 1.4)
}
const pendingImages = items => items.filter(it => it.n != null && !images.get(it.n)?.ok && !images.get(it.n)?.failed).map(it => imageOf(it.n).ready)
const settle = (i, url) => {
if (!urls.has(i)) urls.set(i, url)
for (const cb of waiting.get(i) || []) cb(urls.get(i))
waiting.delete(i)
}
const forget = i => {
const u = urls.get(i)
if (u?.startsWith('blob:')) URL.revokeObjectURL(u)
urls.delete(i)
}
async function tick() {
timer = 0
if (released || !queue.length) return
if (book.busy?.()) { timer = setTimeout(tick, 150); return } // no dibujar mientras gira la hoja
const i = queue.shift()
if (urls.has(i)) { settle(i, urls.get(i)); timer = setTimeout(tick); return }
const items = layout.capture(i)
timer = -1 // ocupado: que nadie arranque otra vuelta mientras llegan las imágenes
await Promise.all(pendingImages(items))
if (released) return
draw(i, items)
canvas.toBlob(blob => {
if (released) return
if (blob && !urls.has(i)) settle(i, URL.createObjectURL(blob))
else settle(i, urls.get(i) ?? book.src(i))
timer = setTimeout(tick, 16)
}, 'image/png')
}
const book = {
count: pages.length, W, H, busy: null, onChange: null,
// Síncrono: la página hace falta ya (la URL no cambia una vez entregada; identifica su textura). Si aún no han
// llegado sus imágenes, se dibuja sin ellas y se avisa para cambiarla al llegar.
src(i) {
if (urls.has(i)) return urls.get(i)
const items = layout.capture(i)
const pending = pendingImages(items)
draw(i, items)
urls.set(i, canvas.toDataURL('image/png'))
if (waiting.has(i)) settle(i, urls.get(i))
if (pending.length) {
Promise.all(pending).then(() => {
if (released || !urls.has(i)) return
urls.delete(i)
book.onChange?.(i)
})
}
return urls.get(i)
},
// Asíncrono: se dibuja cuando haya un momento libre, en el orden en que se pidió.
whenReady(i, cb) {
if (urls.has(i)) return cb(urls.get(i))
if (!waiting.has(i)) { waiting.set(i, []); queue.push(i) }
waiting.get(i).push(cb)
if (!timer) timer = setTimeout(tick)
},
// Lo dibujado lejos de la página actual se suelta; si se vuelve, se dibuja otra vez.
trim(center) {
for (const i of [...urls.keys()]) if (Math.abs(i - center) > BOOK_KEEP_PAGES) forget(i)
},
near: (i, center) => Math.abs(i - center) <= BOOK_KEEP_PAGES,
wordAt: i => pages[Math.min(i, pages.length - 1)]?.word ?? 0,
pageAtWord(word) {
let lo = 0, hi = pages.length - 1
while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (pages[mid].word <= word) lo = mid; else hi = mid - 1 }
return lo
},
chapterStart: c => chapStarts[c] ?? 0,
// Si varios capítulos empiezan en una página, cuenta el último: ahí es donde sigue la lectura.
chapterAt(p) {
let lo = 0, hi = chapStarts.length - 1
while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (chapStarts[mid] <= p) lo = mid; else hi = mid - 1 }
return lo
},
chapterPages: c => Math.max(1, (chapStarts[c + 1] ?? pages.length) - chapStarts[c]),
release() {
released = true
clearTimeout(timer)
for (const i of [...urls.keys()]) forget(i)
canvas.width = canvas.height = 0
},
}
return book
}
async function viewReader(mangaId, chapterId, startPage) { async function viewReader(mangaId, chapterId, startPage) {
document.body.classList.add('reading') document.body.classList.add('reading')
app.innerHTML = '<div class="reader"><div class="loading">Cargando capítulo…</div></div>' app.innerHTML = '<div class="reader"><div class="loading">Cargando capítulo…</div></div>'
@@ -1353,17 +1770,54 @@ async function viewReader(mangaId, chapterId, startPage) {
const chapter = chapters[idx] || { id: chapterId, num: info.progress?.chapter_num, title: '' } const chapter = chapters[idx] || { id: chapterId, num: info.progress?.chapter_num, title: '' }
const prev = idx > 0 ? chapters[idx - 1] : null const prev = idx > 0 ? chapters[idx - 1] : null
const next = idx >= 0 && idx < chapters.length - 1 ? chapters[idx + 1] : null const next = idx >= 0 && idx < chapters.length - 1 ? chapters[idx + 1] : null
const pages = chap.pages
const total = pages.length
let page = Math.min(Math.max(startPage >= 0 ? startPage : chap.savedPage || 0, 0), total - 1)
let shown = [page] // páginas visibles (2 en doble página)
const isPaged = () => settings.mode !== 'vertical' const isPaged = () => settings.mode !== 'vertical'
const isDouble = () => settings.mode === 'double' && window.innerWidth >= 700 && window.innerWidth > window.innerHeight 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).
const isBook = !!chap.text
const noun = isBook ? 'libro' : 'manga'
const bookSize = () => {
const vw = window.innerWidth, vh = window.innerHeight
return { w: Math.floor(Math.min(isDouble() ? vw / 2 : vw, vh * (vh < 600 ? 1.05 : 0.8))), h: vh }
}
let book = null
if (isBook) {
settings.fit = 'height'
const opening = location.hash
app.innerHTML = '<div class="reader"><div class="loading">Preparando el libro…</div></div>'
book = await createBook(await loadBookText(mangaId), mangaId, bookSize())
if (location.hash !== opening) { book.release(); return }
book.busy = () => turning
}
const 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
if (book) {
// El progreso de un libro se guarda por capítulo. Con otra pantalla o tamaño de letra un capítulo tiene otro
// número de páginas: ir a la equivalente.
const c = Math.max(idx, 0)
const pagesHere = book.chapterPages(c)
if (chap.savedTotal > 1 && chap.savedTotal !== pagesHere) wanted = Math.round((wanted / (chap.savedTotal - 1)) * (pagesHere - 1))
wanted = book.chapterStart(c) + Math.min(wanted, pagesHere - 1)
if (bookResume?.mangaId === mangaId) wanted = book.pageAtWord(bookResume.word)
bookResume = null
}
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) const lastShown = () => (isPaged() ? shown.at(-1) : page)
// Guardado del progreso (con debounce y al salir de la página). // Guardado del progreso (con debounce y al salir de la página).
let saveTimer = null let saveTimer = null
const payload = () => ({ 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,
})
const payload = () => {
if (!book) return mangaPayload()
const p = lastShown(), c = book.chapterAt(p)
return bookPayload(c, p - book.chapterStart(c), book.chapterPages(c))
}
const mangaPayload = () => ({
mangaId, chapterId, mangaTitle: manga.title, cover: manga.cover, chapterNum: chapter.num ?? null, mangaId, chapterId, mangaTitle: manga.title, cover: manga.cover, chapterNum: chapter.num ?? null,
chapterTitle: chapter.title, lang: manga.source !== 'md' ? 'es' : chapter.lang || lang, page: lastShown(), totalPages: total, chapterTitle: chapter.title, lang: manga.source !== 'md' ? 'es' : chapter.lang || lang, page: lastShown(), totalPages: total,
isLast: idx >= 0 && !next, // al terminarlo, el manga sale de "Continuar leyendo" isLast: idx >= 0 && !next, // al terminarlo, el manga sale de "Continuar leyendo"
@@ -1376,21 +1830,22 @@ async function viewReader(mangaId, chapterId, startPage) {
window.addEventListener('pagehide', beacon) window.addEventListener('pagehide', beacon)
saveNow() saveNow()
const chapOpts = chapters.map(c => `<option value="${c.id}" ${c.id === chapterId ? 'selected' : ''}>${esc(chapLabel(c))}</option>`).join('') let curChap = book ? book.chapterAt(page) : idx
const chapOpts = chapters.map((c, i) => `<option value="${c.id}" ${i === curChap ? 'selected' : ''}>${esc(chapLabel(c))}</option>`).join('')
app.innerHTML = ` app.innerHTML = `
<div class="reader"> <div class="reader">
<div class="rbar top"> <div class="rbar top">
<a class="btn small" href="#/manga/${mangaId}" title="Volver a la ficha">${icon('back')}</a> <a class="btn small" href="#/manga/${mangaId}" title="Volver a la ficha">${icon('back')}</a>
<div class="ttl"><b>${esc(manga.title)}</b></div> <div class="ttl"><b>${esc(manga.title)}</b></div>
<select id="chap" style="max-width:40vw">${chapOpts}</select> <select id="chap" style="max-width:40vw">${chapOpts}</select>
<button class="btn small" id="clipbtn" title="Recortar un panel (se guarda en Recortes y se mejora a HD)">${icon('scissors')}</button> <button class="btn small" id="clipbtn" ${isBook ? 'hidden' : ''} title="Recortar un panel (se guarda en Recortes y se mejora a HD)">${icon('scissors')}</button>
<button class="btn small" id="modequick"></button> <button class="btn small" id="modequick"></button>
<button class="btn small" id="ropts" title="Opciones de lectura">${icon('settings')}</button> <button class="btn small" id="ropts" title="Opciones de lectura">${icon('settings')}</button>
</div> </div>
<div class="rpanel" hidden> <div class="rpanel" hidden>
<select id="mode" title="Modo de lectura (se recuerda para este manga)">${Object.entries(READ_MODES).map(([k, v]) => `<option value="${k}">${v}</option>`).join('')}</select> <select id="mode" title="Modo de lectura (se recuerda para este ${noun})">${Object.entries(READ_MODES).map(([k, v]) => `<option value="${k}">${v}</option>`).join('')}</select>
<select id="fit" title="Ajuste (se recuerda para este manga)"><option value="height">Ajustar alto</option><option value="width">Ajustar ancho</option></select> <select id="fit" ${isBook ? 'hidden' : ''} title="Ajuste (se recuerda para este manga)"><option value="height">Ajustar alto</option><option value="width">Ajustar ancho</option></select>
<select id="dir" title="Dirección (se recuerda para este manga)"><option value="ltr">Izq → Der</option><option value="rtl">Der → Izq</option></select> <select id="dir" title="Dirección (se recuerda para este ${noun})"><option value="ltr">Izq → Der</option><option value="rtl">Der → Izq</option></select>
<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="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="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> <select id="filter" title="Filtro">${Object.entries(FILTERS).map(([k, [label]]) => `<option value="${k}">${label}</option>`).join('')}</select>
@@ -1398,16 +1853,20 @@ async function viewReader(mangaId, chapterId, startPage) {
${manga.source !== 'md' || chap.local ? '' : '<label class="row muted" style="font-size:.85rem"><input type="checkbox" id="saver"> Ahorro datos</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>' ${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>'
: chap.local && info.upscale?.available ? `<button class="btn small" id="hdone" title="Reescala este capítulo con IA en segundo plano (unos minutos)">${icon('sparkles')} Reescalar a HD</button>` : ''} : chap.local && info.upscale?.available ? `<button class="btn small" id="hdone" title="Reescala este capítulo con IA en segundo plano (unos minutos)">${icon('sparkles')} Reescalar a HD</button>` : ''}
${OFFLINE_OK ? `<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>` : ''} ${isBook ? `<select id="bsize" title="Tamaño de letra">${[80, 90, 100, 110, 120, 135, 150, 175].map(n => `<option value="${n}">Letra ${n}%</option>`).join('')}</select>
<p class="muted">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> <select id="bfont" title="Tipo de letra"><option value="serif">Con remates</option><option value="sans">Sin remates</option></select>
<select id="bmargin" title="Márgenes"><option value="small">Margen pequeño</option><option value="mid">Margen medio</option><option value="large">Margen grande</option></select>
<select id="bpaper" title="Papel"><option value="light">Papel claro</option><option value="sepia">Papel sepia</option><option value="dark">Papel oscuro</option></select>` : ''}
${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>
<div id="pages"></div> <div id="pages"></div>
<div class="rbar bottom"> <div class="rbar bottom">
${prev ? `<a class="btn small" href="${readUrl(mangaId, prev.id)}">${icon('prev')} Cap. ant.</a>` : ''} ${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>` : ''}
<span id="pnum" class="muted"></span> <span id="pnum" class="muted"></span>
<input type="range" id="slider" min="1" max="${total}" value="${page + 1}"> <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> <button class="btn small" id="zoomreset" hidden title="Quitar zoom">${icon('zoomOut')}</button>
${next ? `<a class="btn small chnext" href="${readUrl(mangaId, next.id)}">Cap. sig. ${icon('next')}</a>` : ''} ${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>` : ''}
</div> </div>
</div>` </div>`
const reader = $('.reader', app) const reader = $('.reader', app)
@@ -1434,7 +1893,8 @@ async function viewReader(mangaId, chapterId, startPage) {
applyFilter() applyFilter()
if ($('#saver')) $('#saver').checked = settings.saver if ($('#saver')) $('#saver').checked = settings.saver
if ($('#hd')) $('#hd').checked = settings.hd !== false if ($('#hd')) $('#hd').checked = settings.hd !== false
const endBlock = `<div class="chapter-end"><p class="muted">Fin del capítulo ${esc(chapter.num ?? '')}</p> 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>'} ${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>` <a class="btn" href="#/manga/${mangaId}">${icon('back')} Volver a la ficha</a></div>`
@@ -1443,12 +1903,53 @@ async function viewReader(mangaId, chapterId, startPage) {
if (manga.type) toast(`${manga.type}: ${READ_MODES[settings.mode].toLowerCase()}${settings.mode === 'vertical' ? '' : `, ${settings.dir === 'rtl' ? 'der → izq' : 'izq → der'}`}. Lo que cambies se recordará.`) if (manga.type) toast(`${manga.type}: ${READ_MODES[settings.mode].toLowerCase()}${settings.mode === 'vertical' ? '' : `, ${settings.dir === 'rtl' ? 'der → izq' : 'izq → der'}`}. Lo que cambies se recordará.`)
} }
// Libro: al cruzar a otro capítulo leyendo, los que quedan atrás cuentan como leídos; a uno lejano (la barra, el
// selector) se salta sin marcar nada.
let lastBookPage = page
const syncBookNav = () => {
$('#bprev').style.display = curChap > 0 || (shown[0] ?? page) > 0 ? '' : 'none'
$('#bnext').style.display = curChap < chapters.length - 1 ? '' : 'none'
$('#chap').value = chapters[curChap].id
}
const syncBookChapter = () => {
const p = lastShown()
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)
if (c !== curChap) {
if (c > curChap && Math.abs(p - lastBookPage) <= 2) {
for (let j = curChap; j < c; j++) {
const count = book.chapterPages(j)
api('/api/progress', { method: 'POST', body: bookPayload(j, count - 1, count) }).catch(() => {})
}
}
curChap = c
history.replaceState(null, '', readUrl(mangaId, chapters[c].id))
}
lastBookPage = p
syncBookNav()
}
const setPageLabel = () => { const setPageLabel = () => {
if (book) syncBookChapter()
$('#pnum').textContent = `${shown.length > 1 ? `${shown[0] + 1}-${shown[1] + 1}` : lastShown() + 1} / ${total}` $('#pnum').textContent = `${shown.length > 1 ? `${shown[0] + 1}-${shown[1] + 1}` : lastShown() + 1} / ${total}`
slider.value = lastShown() + 1 slider.value = lastShown() + 1
} }
let observer = null let observer = null
let atEnd = false let atEnd = false
// Libro: ¿la pantalla ya pide otro tamaño de página? La barra del navegador móvil al aparecer no cuenta.
const sizeChanged = () => {
const s = bookSize()
return s.w !== book.W || Math.abs(s.h - book.H) > 120
}
function reopenBook() {
bookResume = { mangaId, word: book.wordAt(shown[0] ?? page) }
history.replaceState(null, '', readUrl(mangaId, chapters[curChap].id))
router()
}
// --- Doble página: la 1.ª va sola (portada) y las apaisadas (ya dobles) también. // --- Doble página: la 1.ª va sola (portada) y las apaisadas (ya dobles) también.
const wide = new Set() const wide = new Set()
@@ -1479,9 +1980,10 @@ async function viewReader(mangaId, chapterId, startPage) {
wide.add(i) wide.add(i)
refreshGutters() // una página ancha cambia las parejas de las páginas de delante refreshGutters() // una página ancha cambia las parejas de las páginas de delante
} }
im.src = pages[i]
im.decode?.().catch(() => {})
preloaded.set(i, im) preloaded.set(i, im)
const load = src => { im.src = src; im.decode?.().catch(() => {}) }
if (book) book.whenReady(i, load) // un libro dibuja sus páginas poco a poco, sin frenar la lectura
else load(pages[i])
} }
for (const i of preloaded.keys()) if (i < shown[0] - 4 || i > shown.at(-1) + 10) preloaded.delete(i) for (const i of preloaded.keys()) if (i < shown[0] - 4 || i > shown.at(-1) + 10) preloaded.delete(i)
scheduleLeafTextures() scheduleLeafTextures()
@@ -1517,7 +2019,7 @@ async function viewReader(mangaId, chapterId, startPage) {
const stage = document.createElement('div') const stage = document.createElement('div')
stage.className = 'stage' stage.className = 'stage'
stage.classList.toggle('two', shown.length === 2) stage.classList.toggle('two', shown.length === 2)
stage.innerHTML = shown.map((i, n) => `<img data-i="${i}" data-gutter="${gutterOf(i, n)}" src="${pages[i]}" alt="" draggable="false">`).join('') 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')] const imgs = [...stage.querySelectorAll('img')]
if ($('.stage', box) && dir && !wantDrag && !imgs.every(imgReady)) { if ($('.stage', box) && dir && !wantDrag && !imgs.every(imgReady)) {
waitTimer = setTimeout(() => box.classList.add('waiting'), 200) waitTimer = setTimeout(() => box.classList.add('waiting'), 200)
@@ -2100,7 +2602,7 @@ async function viewReader(mangaId, chapterId, startPage) {
const under = [] const under = []
const neighbour = (i, onHingeSide) => { const neighbour = (i, onHingeSide) => {
if (i < 0 || i >= total || wide.has(i)) return if (i < 0 || i >= total || wide.has(i)) return
const im = preloaded.get(i) || Object.assign(new Image(), { src: pages[i] }) const im = preloaded.get(i) || Object.assign(new Image(), { src: pageSrc(i) })
const fit = imgReady(im) ? Math.min(box.clientWidth / im.naturalWidth, box.clientHeight / im.naturalHeight) : 0 const fit = imgReady(im) ? Math.min(box.clientWidth / im.naturalWidth, box.clientHeight / im.naturalHeight) : 0
const w = fit ? im.naturalWidth * fit : rect.w, h = fit ? im.naturalHeight * fit : rect.h const w = fit ? im.naturalWidth * fit : rect.w, h = fit ? im.naturalHeight * fit : rect.h
im.className = 'leafkeep' im.className = 'leafkeep'
@@ -2286,6 +2788,7 @@ async function viewReader(mangaId, chapterId, startPage) {
function render() { function render() {
observer?.disconnect() observer?.disconnect()
const keep = lastShown() const keep = lastShown()
if (book && sizeChanged()) return reopenBook() // cambiar a doble página cambia el tamaño de página
resetZoom() resetZoom()
box.className = isPaged() ? `paged ${settings.fit === 'width' ? 'fit-width' : ''} ${isDouble() ? 'double' : ''}` : `vertical fit-${settings.fit}` box.className = isPaged() ? `paged ${settings.fit === 'width' ? 'fit-width' : ''} ${isDouble() ? 'double' : ''}` : `vertical fit-${settings.fit}`
renderModeQuick() renderModeQuick()
@@ -2302,10 +2805,16 @@ async function viewReader(mangaId, chapterId, startPage) {
} }
page = keep page = keep
shown = [keep] shown = [keep]
box.innerHTML = pages.map((src, i) => `<img data-i="${i}" ${i < page + 3 ? `src="${src}"` : `data-src="${src}"`} alt="">`).join('') + endBlock // Las páginas de un libro tienen tamaño conocido: no hace falta cargar las anteriores para saltar a la guardada.
const near = i => (book ? Math.abs(i - page) <= 2 : i < page + 3)
box.innerHTML = Array.from({ length: total }, (_, i) => `<img data-i="${i}" ${near(i) ? `src="${pageSrc(i)}"` : 'data-lazy="1"'} ${book ? `style="aspect-ratio:${book.W}/${book.H}"` : ''} alt="">`).join('') + endBlock
box.querySelectorAll('img').forEach(img => img.addEventListener('load', () => img.setAttribute('data-loaded', ''), { once: true })) box.querySelectorAll('img').forEach(img => img.addEventListener('load', () => img.setAttribute('data-loaded', ''), { once: true }))
const lazy = new IntersectionObserver(entries => entries.forEach(e => { const lazy = new IntersectionObserver(entries => entries.forEach(e => {
if (e.isIntersecting && e.target.dataset.src) { e.target.src = e.target.dataset.src; delete e.target.dataset.src } if (!e.isIntersecting || !e.target.dataset.lazy) return
delete e.target.dataset.lazy
const i = Number(e.target.dataset.i)
if (book) book.whenReady(i, url => { if (!e.target.dataset.lazy) e.target.src = url })
else e.target.src = pageSrc(i)
}), { rootMargin: '2000px 0px' }) }), { rootMargin: '2000px 0px' })
// No seguir el scroll hasta haber saltado a la página guardada, para no pisarla con la 0. // No seguir el scroll hasta haber saltado a la página guardada, para no pisarla con la 0.
let ready = false let ready = false
@@ -2317,7 +2826,7 @@ async function viewReader(mangaId, chapterId, startPage) {
imgs.forEach(img => { lazy.observe(img); observer.observe(img) }) imgs.forEach(img => { lazy.observe(img); observer.observe(img) })
const lazyDisconnect = observer.disconnect.bind(observer) const lazyDisconnect = observer.disconnect.bind(observer)
observer.disconnect = () => { lazyDisconnect(); lazy.disconnect() } observer.disconnect = () => { lazyDisconnect(); lazy.disconnect() }
const before = imgs.slice(0, page + 1).map(img => img.complete ? null : new Promise(r => { img.addEventListener('load', r, { once: true }); img.addEventListener('error', r, { once: true }) })) const before = (book ? [] : imgs.slice(0, page + 1)).map(img => img.complete ? null : new Promise(r => { img.addEventListener('load', r, { once: true }); img.addEventListener('error', r, { once: true }) }))
Promise.race([Promise.all(before), new Promise(r => setTimeout(r, 8000))]).then(() => { Promise.race([Promise.all(before), new Promise(r => setTimeout(r, 8000))]).then(() => {
imgs[page]?.scrollIntoView() imgs[page]?.scrollIntoView()
lastY = window.scrollY lastY = window.scrollY
@@ -2493,14 +3002,15 @@ async function viewReader(mangaId, chapterId, startPage) {
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', { saver: settings.saver, filter: settings.filter, brightness: settings.brightness, hd: settings.hd !== false, anim: settings.anim, sfx: settings.sfx })
const savePrefs = () => { const savePrefs = () => {
api(`/api/prefs/${mangaId}`, { method: 'POST', body: { mode: settings.mode, fit: settings.fit, dir: settings.dir } }) return api(`/api/prefs/${mangaId}`, { method: 'POST', body: { mode: settings.mode, fit: settings.fit, dir: settings.dir } })
.then(() => toast('Guardado para este manga')).catch(err => toast(err.message)) .then(() => toast(`Guardado para este ${noun}`)).catch(err => toast(err.message))
} }
$('#mode').addEventListener('change', e => { settings.mode = e.target.value; if (isPaged()) lastPaged = settings.mode; savePrefs(); render() }) // En un libro, volver a maquetar reabre el lector: antes hay que esperar a que el modo nuevo esté guardado.
const savePrefsThenRender = () => { const saved = savePrefs(); if (book) saved.finally(render); else render() }
$('#mode').addEventListener('change', e => { settings.mode = e.target.value; if (isPaged()) lastPaged = settings.mode; savePrefsThenRender() })
$('#modequick').addEventListener('click', () => { $('#modequick').addEventListener('click', () => {
settings.mode = isPaged() ? 'vertical' : lastPaged settings.mode = isPaged() ? 'vertical' : lastPaged
savePrefs() savePrefsThenRender()
render()
}) })
$('#fit').addEventListener('change', e => { settings.fit = e.target.value; savePrefs(); render() }) $('#fit').addEventListener('change', e => { settings.fit = e.target.value; savePrefs(); render() })
$('#dir').addEventListener('change', e => { $('#dir').addEventListener('change', e => {
@@ -2523,7 +3033,21 @@ async function viewReader(mangaId, chapterId, startPage) {
e.target.closest('button').disabled = true 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 } 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 }
}) })
$('#chap').addEventListener('change', e => { location.hash = readUrl(mangaId, e.target.value) }) $('#chap').addEventListener('change', e => {
if (book) goTo(book.chapterStart(chapters.findIndex(c => c.id === e.target.value)))
else location.hash = readUrl(mangaId, e.target.value)
})
if (book) {
$('#bnext').addEventListener('click', e => { e.preventDefault(); goTo(book.chapterStart(curChap + 1)) })
$('#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))
})
book.onChange = i => { box.querySelectorAll(`img[data-i="${i}"]`).forEach(im => { im.src = book.src(i) }) }
}
$('#offone')?.addEventListener('click', e => { $('#offone')?.addEventListener('click', e => {
const b = e.target.closest('button') const b = e.target.closest('button')
b.disabled = true b.disabled = true
@@ -2742,6 +3266,7 @@ async function viewReader(mangaId, chapterId, startPage) {
const onResize = () => { const onResize = () => {
clearTimeout(resizeTimer) clearTimeout(resizeTimer)
resizeTimer = setTimeout(() => { resizeTimer = setTimeout(() => {
if (book && sizeChanged()) return reopenBook()
if (!isPaged() || atEnd) return if (!isPaged() || atEnd) return
box.classList.toggle('double', isDouble()) box.classList.toggle('double', isDouble())
goTo(lastShown(), false, true) goTo(lastShown(), false, true)
@@ -2749,8 +3274,19 @@ async function viewReader(mangaId, chapterId, startPage) {
} }
window.addEventListener('resize', onResize) window.addEventListener('resize', onResize)
if (book) {
const opts = bookSettings()
for (const [id, key] of [['bsize', 'size'], ['bfont', 'font'], ['bmargin', 'margin'], ['bpaper', 'paper']]) {
$(`#${id}`).value = String(opts[key])
$(`#${id}`).addEventListener('change', e => {
store.set('bookSettings', { ...bookSettings(), [key]: key === 'size' ? Number(e.target.value) : e.target.value })
reopenBook()
})
}
}
render() render()
cleanup = () => { cleanup = () => {
book?.release()
freeLeafGL() freeLeafGL()
endCrop() endCrop()
if (saveTimer) saveNow() if (saveTimer) saveNow()
@@ -2900,16 +3436,16 @@ async function viewLibrary(params) {
: [null, null, []] : [null, null, []]
if (!location.hash.startsWith('#/library')) return if (!location.hash.startsWith('#/library')) return
const online = !!hist const online = !!hist
const dev = OFFLINE_OK ? offByManga() : [] const dev = OFFLINE_OK ? offByManga().filter(m => wantsId(m.mangaId)) : []
const reading = (hist || []).filter(h => !h.finished) const reading = (hist || []).filter(h => !h.finished && wantsId(h.manga_id))
const finished = (hist || []).filter(h => h.finished) const finished = (hist || []).filter(h => h.finished && wantsId(h.manga_id))
const follows = fol?.follows || [] const follows = (fol?.follows || []).filter(f => wantsId(f.manga_id))
const tabs = [ const tabs = [
['reading', 'Leyendo', 'book', reading.length], ['reading', 'Leyendo', 'book', reading.length],
['following', 'Siguiendo', 'bell', follows.length], ['following', 'Siguiendo', 'bell', follows.length],
['finished', 'Terminados', 'check', finished.length], ['finished', 'Terminados', 'check', finished.length],
...(OFFLINE_OK && (dev.length || tab === 'device') ? [['device', 'En el dispositivo', 'phone', dev.length]] : []), ...(OFFLINE_OK && (dev.length || tab === 'device') ? [['device', 'En el dispositivo', 'phone', dev.length]] : []),
...lists.map(l => [`list-${l.id}`, l.name, 'bookmark', l.count]), ...lists.map(l => [`list-${l.id}`, l.name, 'bookmark', contentMode() === 'both' ? l.count : 0]), // el total de la lista incluye lo filtrado
] ]
const curList = tab.startsWith('list-') ? lists.find(l => `list-${l.id}` === tab) : null const curList = tab.startsWith('list-') ? lists.find(l => `list-${l.id}` === tab) : null
app.innerHTML = `<h1>Librería</h1> app.innerHTML = `<h1>Librería</h1>
@@ -2952,7 +3488,7 @@ async function viewLibrary(params) {
}), 'No hay nada guardado en este dispositivo. En la ficha de un manga, «⋯ → En este dispositivo» guarda capítulos para leerlos sin internet.') }), 'No hay nada guardado en este dispositivo. En la ficha de un manga, «⋯ → En este dispositivo» guarda capítulos para leerlos sin internet.')
} else if (curList) { } else if (curList) {
const l = await api(`/api/lists/${curList.id}`).catch(err => (toast(err.message), { items: [] })) const l = await api(`/api/lists/${curList.id}`).catch(err => (toast(err.message), { items: [] }))
body.innerHTML = grid(l.items.map(it => libCard({ body.innerHTML = grid(l.items.filter(it => wantsId(it.manga_id)).map(it => libCard({
href: `#/manga/${it.manga_id}`, cover: it.cover, title: it.title, sub: `Añadido ${timeAgo(it.added_at)}`, href: `#/manga/${it.manga_id}`, cover: it.cover, title: it.title, sub: `Añadido ${timeAgo(it.added_at)}`,
del: { attrs: `data-unlist="${it.manga_id}"`, title: 'Quitar de la lista', icon: 'x' }, del: { attrs: `data-unlist="${it.manga_id}"`, title: 'Quitar de la lista', icon: 'x' },
})), 'Esta lista está vacía. Añade mangas desde su ficha con el botón «Listas».') })), 'Esta lista está vacía. Añade mangas desde su ficha con el botón «Listas».')
@@ -3292,6 +3828,9 @@ function viewSettings() {
<button class="btn" id="logout">${icon('logout')} Cerrar sesión</button></section> <button class="btn" id="logout">${icon('logout')} Cerrar sesión</button></section>
<section><b>Idioma preferido de los capítulos</b> <section><b>Idioma preferido de los capítulos</b>
<select id="lang">${Object.entries(LANGS).map(([k, v]) => `<option value="${k}" ${k === me.lang ? 'selected' : ''}>${v}</option>`).join('')}</select></section> <select id="lang">${Object.entries(LANGS).map(([k, v]) => `<option value="${k}" ${k === me.lang ? 'selected' : ''}>${v}</option>`).join('')}</select></section>
<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>Cambiar contraseña</b> <section><b>Cambiar contraseña</b>
<form id="pw" style="display:grid;gap:8px"> <form id="pw" style="display:grid;gap:8px">
<input type="password" name="old" placeholder="Contraseña actual" autocomplete="current-password" required> <input type="password" name="old" placeholder="Contraseña actual" autocomplete="current-password" required>
@@ -3382,6 +3921,14 @@ function viewSettings() {
}) })
$('#logout').addEventListener('click', async () => { await api('/api/logout', { method: 'POST' }); me = null; location.hash = '#/login'; router() }) $('#logout').addEventListener('click', async () => { await api('/api/logout', { method: 'POST' }); me = null; location.hash = '#/login'; router() })
$('#lang').addEventListener('change', async e => { await api('/api/settings', { method: 'POST', body: { lang: e.target.value } }); me.lang = e.target.value; toast('Idioma guardado') }) $('#lang').addEventListener('change', async e => { await api('/api/settings', { method: 'POST', body: { lang: e.target.value } }); me.lang = e.target.value; toast('Idioma guardado') })
$('#contentmode').addEventListener('change', async e => {
const prev = contentMode()
try {
await api('/api/settings', { method: 'POST', body: { contentMode: e.target.value } })
me.content_mode = e.target.value
toast('Guardado')
} catch (err) { e.target.value = prev; toast(err.message) }
})
$('#pw').addEventListener('submit', async e => { $('#pw').addEventListener('submit', async e => {
e.preventDefault() e.preventDefault()
try { try {
+2 -1
View File
@@ -128,7 +128,8 @@ async function hostIsPublic(hostname) {
const hit = dnsCache.get(hostname) const hit = dnsCache.get(hostname)
if (hit && hit.exp > Date.now()) return hit.ok if (hit && hit.exp > Date.now()) return hit.ok
const addrs = await dns.lookup(hostname, { all: true }).catch(() => []) const addrs = await dns.lookup(hostname, { all: true }).catch(() => [])
const ok = addrs.length > 0 && addrs.every(a => !isPrivateIp(a.address)) if (!addrs.length) return false // fallo de DNS: puede ser pasajero, no se recuerda
const ok = addrs.every(a => !isPrivateIp(a.address))
dnsCache.set(hostname, { ok, exp: Date.now() + 5 * 60e3 }) dnsCache.set(hostname, { ok, exp: Date.now() + 5 * 60e3 })
if (dnsCache.size > 1000) dnsCache.delete(dnsCache.keys().next().value) if (dnsCache.size > 1000) dnsCache.delete(dnsCache.keys().next().value)
return ok return ok
+25 -8
View File
@@ -15,6 +15,7 @@ import { setupSecurity, safeFetch } from './security.js'
import { setupM440, MI_BASE } from './m440.js' import { setupM440, MI_BASE } from './m440.js'
import { setupConnectivity } from './connectivity.js' import { setupConnectivity } from './connectivity.js'
import { setupMeta } from './meta.js' import { setupMeta } from './meta.js'
import { setupBooks, isBookId } from './books.js'
const PORT = Number(process.env.PORT || 3352) const PORT = Number(process.env.PORT || 3352)
const DATA_DIR = process.env.DATA_DIR || '/app/data' const DATA_DIR = process.env.DATA_DIR || '/app/data'
@@ -162,10 +163,12 @@ const DUMMY_HASH = hashPassword(crypto.randomBytes(16).toString('hex'))
function getUser(req) { function getUser(req) {
const m = /(?:^|;\s*)sid=([^;]+)/.exec(req.headers.cookie || '') const m = /(?:^|;\s*)sid=([^;]+)/.exec(req.headers.cookie || '')
if (!m) return null if (!m) return null
return db.prepare(`SELECT u.id, u.username, u.lang FROM sessions s JOIN users u ON u.id = s.user_id return db.prepare(`SELECT u.id, u.username, u.lang, u.content_mode FROM sessions s JOIN users u ON u.id = s.user_id
WHERE s.token = ? AND s.expires_at > ?`).get(m[1], Date.now()) ?? null WHERE s.token = ? AND s.expires_at > ?`).get(m[1], Date.now()) ?? null
} }
if (!db.prepare('PRAGMA table_info(users)').all().some(c => c.name === 'content_mode')) db.exec("ALTER TABLE users ADD COLUMN content_mode TEXT NOT NULL DEFAULT 'both'")
// ---------- Cliente MangaDex (adaptado de Tanko src/servers/mangadex.ts) ---------- // ---------- Cliente MangaDex (adaptado de Tanko src/servers/mangadex.ts) ----------
const cache = new Map() const cache = new Map()
async function cached(key, ttl, fn) { async function cached(key, ttl, fn) {
@@ -456,7 +459,7 @@ const lcPages = async chapterId => (await lcPageUrls(chapterId)).map(imgUrl)
const mi = setupM440({ cached, httpError: (status, message) => new HttpError(status, message), coverUrl, decode, BROWSER_UA, net }) const mi = setupM440({ cached, httpError: (status, message) => new HttpError(status, message), coverUrl, decode, BROWSER_UA, net })
const miPages = async chapterId => (await mi.pageUrls(chapterId)).map(imgUrl) const miPages = async chapterId => (await mi.pageUrls(chapterId)).map(imgUrl)
// Fuente según el ID: 'lc' y 'mi' son sitios en español; lo demás es MangaDex. // Fuente según el ID: 'lc' y 'mi' son sitios en español; lo demás es MangaDex.
const sourceOf = id => (id.startsWith('lc~') ? 'lc' : id.startsWith('mi~') ? 'mi' : 'md') const sourceOf = id => (id.startsWith('lc~') ? 'lc' : id.startsWith('mi~') ? 'mi' : id.startsWith('gb~') ? 'gb' : 'md')
const spanishOnly = id => sourceOf(id) !== 'md' const spanishOnly = id => sourceOf(id) !== 'md'
// Proxy de imágenes: MangaDex bloquea el hotlinking de portadas y así el navegador no depende de CDNs ajenos. // Proxy de imágenes: MangaDex bloquea el hotlinking de portadas y así el navegador no depende de CDNs ajenos.
@@ -539,6 +542,7 @@ async function fetchMangaFull(mangaId, lang) {
const src = sourceOf(mangaId) const src = sourceOf(mangaId)
return src === 'lc' ? lcManga(mangaId) return src === 'lc' ? lcManga(mangaId)
: src === 'mi' ? mi.manga(mangaId) : src === 'mi' ? mi.manga(mangaId)
: src === 'gb' ? books.manga(mangaId)
: { manga: await getManga(mangaId, lang), chapters: await getChapters(mangaId, lang) } : { manga: await getManga(mangaId, lang), chapters: await getChapters(mangaId, lang) }
} }
@@ -777,8 +781,8 @@ async function readBody(req) {
} }
try { return JSON.parse(Buffer.concat(chunks).toString() || '{}') } catch { throw new HttpError(400, 'JSON inválido') } try { return JSON.parse(Buffer.concat(chunks).toString() || '{}') } catch { throw new HttpError(400, 'JSON inválido') }
} }
const MANGA_ID = /^([0-9a-f-]{36}|lc~[a-z0-9]+~[a-z0-9-]+|mi~[a-z0-9._-]+)$/ const MANGA_ID = /^([0-9a-f-]{36}|lc~[a-z0-9]+~[a-z0-9-]+|mi~[a-z0-9._-]+|gb~\d{1,7})$/
const CHAPTER_ID = /^([0-9a-f-]{36}|lc~[a-z0-9]+~[a-z0-9-]+~[0-9a-z.-]+|mi~[a-z0-9._-]+~[a-z0-9._-]+)$/ const CHAPTER_ID = /^([0-9a-f-]{36}|lc~[a-z0-9]+~[a-z0-9-]+~[0-9a-z.-]+|mi~[a-z0-9._-]+~[a-z0-9._-]+|gb~\d{1,7}~\d{1,5})$/
const LANG = /^[a-z]{2}(-[a-z]{2})?$/ const LANG = /^[a-z]{2}(-[a-z]{2})?$/
// Manga en el historial o en seguidos del usuario (sus portadas se reescalan). // Manga en el historial o en seguidos del usuario (sus portadas se reescalan).
const tracked = (userId, mangaId) => !!db.prepare(`SELECT 1 FROM progress WHERE user_id = ? AND manga_id = ? const tracked = (userId, mangaId) => !!db.prepare(`SELECT 1 FROM progress WHERE user_id = ? AND manga_id = ?
@@ -877,7 +881,7 @@ async function route(req, res) {
return return
} }
if (p.startsWith('/api/clips') && (await clips.route(p, method, q, req, res, user)) !== false) return if (p.startsWith('/api/clips') && (await clips.route(p, method, q, req, res, user)) !== false) return
if (p.startsWith('/api/meta/') && MANGA_ID.test(decodeURIComponent(p.split('/')[3] || '')) && (await meta.route(p, method, res)) !== false) return if (p.startsWith('/api/meta/') && MANGA_ID.test(decodeURIComponent(p.split('/')[3] || '')) && !isBookId(decodeURIComponent(p.split('/')[3] || '')) && (await meta.route(p, method, res)) !== false) return
if (/^\/api\/(lists|prefetch|recommendations)\b/.test(p) && (await library.route(p, method, q, req, res, user)) !== false) return if (/^\/api\/(lists|prefetch|recommendations)\b/.test(p) && (await library.route(p, method, q, req, res, user)) !== false) return
if (p === '/api/settings' && method === 'POST') { if (p === '/api/settings' && method === 'POST') {
@@ -886,6 +890,10 @@ async function route(req, res) {
if (!LANG.test(body.lang)) throw new HttpError(400, 'Idioma inválido') if (!LANG.test(body.lang)) throw new HttpError(400, 'Idioma inválido')
db.prepare('UPDATE users SET lang = ? WHERE id = ?').run(body.lang, user.id) db.prepare('UPDATE users SET lang = ? WHERE id = ?').run(body.lang, user.id)
} }
if (body.contentMode !== undefined) {
if (!['both', 'manga', 'books'].includes(body.contentMode)) throw new HttpError(400, 'Valor inválido')
db.prepare('UPDATE users SET content_mode = ? WHERE id = ?').run(body.contentMode, user.id)
}
if (body.newPassword !== undefined) { if (body.newPassword !== undefined) {
const row = db.prepare('SELECT pass_hash FROM users WHERE id = ?').get(user.id) const row = db.prepare('SELECT pass_hash FROM users WHERE id = ?').get(user.id)
if (!verifyPassword(String(body.oldPassword || '').slice(0, 256), row.pass_hash)) { security.fail(ip, 'contraseña actual incorrecta'); throw new HttpError(401, 'Contraseña actual incorrecta') } if (!verifyPassword(String(body.oldPassword || '').slice(0, 256), row.pass_hash)) { security.fail(ip, 'contraseña actual incorrecta'); throw new HttpError(401, 'Contraseña actual incorrecta') }
@@ -919,12 +927,13 @@ async function route(req, res) {
const lang = LANG.test(q.get('lang') || '') ? q.get('lang') : user.lang const lang = LANG.test(q.get('lang') || '') ? q.get('lang') : user.lang
if (p === '/api/categories' && method === 'GET') { if (p === '/api/categories' && method === 'GET') {
return json(res, 200, !net.online() ? [] : q.get('source') === 'lc' ? await lcCategories() : q.get('source') === 'mi' ? [] : await mdCategories()) return json(res, 200, !net.online() ? [] : q.get('source') === 'lc' ? await lcCategories() : q.get('source') === 'mi' || q.get('source') === 'gb' ? [] : await mdCategories())
} }
if (p === '/api/search' && method === 'GET') { if (p === '/api/search' && method === 'GET') {
const page = Math.min(Math.max(Number(q.get('page')) | 0, 1), 500) const page = Math.min(Math.max(Number(q.get('page')) | 0, 1), 500)
const tag = q.get('tag') || '' const tag = q.get('tag') || ''
if (!net.online()) return json(res, 200, localSearch(q.get('q') || '')) if (!net.online()) return json(res, 200, localSearch(q.get('q') || ''))
if (q.get('source') === 'gb') return json(res, 200, await books.search((q.get('q') || '').slice(0, 100), q.get('mode'), page, user.lang))
if (q.get('source') === 'mi') return json(res, 200, await mi.search((q.get('q') || '').slice(0, 100), q.get('mode'), page)) if (q.get('source') === 'mi') return json(res, 200, await mi.search((q.get('q') || '').slice(0, 100), q.get('mode'), page))
if (q.get('source') === 'lc') return json(res, 200, await lcSearch((q.get('q') || '').slice(0, 100), q.get('mode'), tag, page)) if (q.get('source') === 'lc') return json(res, 200, await lcSearch((q.get('q') || '').slice(0, 100), q.get('mode'), tag, page))
const anyLang = q.get('anyLang') === '1' const anyLang = q.get('anyLang') === '1'
@@ -951,9 +960,15 @@ async function route(req, res) {
const reads = db.prepare('SELECT chapter_id, page, total_pages FROM chapter_reads WHERE user_id = ? AND manga_id = ?').all(user.id, m[1]) const reads = db.prepare('SELECT chapter_id, page, total_pages FROM chapter_reads WHERE user_id = ? AND manga_id = ?').all(user.id, m[1])
const progress = db.prepare('SELECT * FROM progress WHERE user_id = ? AND manga_id = ?').get(user.id, m[1]) ?? null const progress = db.prepare('SELECT * FROM progress WHERE user_id = ? AND manga_id = ?').get(user.id, m[1]) ?? null
const chapterLangs = chapters.length || manga.source !== 'md' || offline ? [] : await getChapterLangs(m[1]).catch(() => []) const chapterLangs = chapters.length || manga.source !== 'md' || offline ? [] : await getChapterLangs(m[1]).catch(() => [])
return json(res, 200, { manga, chapters, lang, reads, progress, chapterLangs, offline, downloaded, download: downloadInfo(m[1], true), upscale: upscale.info(m[1]), clips: clips.count(user.id, m[1]), lists: library.listsWith(user.id, m[1]), info: 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, 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 ((m = /^\/api\/book\/([^/]+)\/text$/.exec(p)) && MANGA_ID.test(m[1]) && isBookId(m[1]) && method === 'GET') return books.sendText(req, res, m[1])
if ((m = /^\/api\/book\/([^/]+)\/img\/(\d{1,5})$/.exec(p)) && MANGA_ID.test(m[1]) && isBookId(m[1]) && method === 'GET') return books.sendImage(res, m[1], Number(m[2]))
if ((m = /^\/api\/chapter\/([^/]+)$/.exec(p)) && CHAPTER_ID.test(m[1]) && method === 'GET') { if ((m = /^\/api\/chapter\/([^/]+)$/.exec(p)) && CHAPTER_ID.test(m[1]) && method === 'GET') {
if (isBookId(m[1])) {
const read = db.prepare('SELECT page, total_pages FROM chapter_reads WHERE user_id = ? AND chapter_id = ?').get(user.id, m[1])
return json(res, 200, { text: true, ...(await books.chapter(m[1])), pages: [], savedPage: read?.page ?? 0, savedTotal: read?.total_pages ?? 0, local: false, hd: false, hdAvailable: false })
}
const hdAvailable = !!upscale.hdFileOf(m[1]) const hdAvailable = !!upscale.hdFileOf(m[1])
const hd = hdAvailable && q.get('hd') !== '0' const hd = hdAvailable && q.get('hd') !== '0'
const local = localChapter(m[1], hd) const local = localChapter(m[1], hd)
@@ -1013,6 +1028,7 @@ async function route(req, res) {
return json(res, 200, d && { ...d, shared: sharedWith(user.id, m[1]) }) return json(res, 200, d && { ...d, shared: sharedWith(user.id, m[1]) })
} }
if ((m = /^\/api\/download\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'POST') { if ((m = /^\/api\/download\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'POST') {
if (isBookId(m[1])) throw new HttpError(400, 'Los libros no se descargan como capítulos')
const b = await readBody(req) const b = await readBody(req)
const dlLang = spanishOnly(m[1]) ? 'es' : LANG.test(b.lang || '') ? b.lang : user.lang const dlLang = spanishOnly(m[1]) ? 'es' : LANG.test(b.lang || '') ? b.lang : user.lang
const title = String(b.title || '').slice(0, 300) || (await fetchMangaFull(m[1], dlLang)).manga.title const title = String(b.title || '').slice(0, 300) || (await fetchMangaFull(m[1], dlLang)).manga.title
@@ -1066,7 +1082,7 @@ async function route(req, res) {
.run(user.id, b.chapterId, b.mangaId, page, total, now) .run(user.id, b.chapterId, b.mangaId, page, total, now)
if (db.prepare('SELECT 1 FROM downloads WHERE manga_id = ?').get(b.mangaId)) linkDownload(user.id, b.mangaId) if (db.prepare('SELECT 1 FROM downloads WHERE manga_id = ?').get(b.mangaId)) linkDownload(user.id, b.mangaId)
features.onProgress(user.id, b, page, total, prevPage) features.onProgress(user.id, b, page, total, prevPage)
library.onProgress(user.id, b, page, total) if (!isBookId(b.mangaId)) library.onProgress(user.id, b, page, total)
return json(res, 200, { ok: true }) return json(res, 200, { ok: true })
} }
if ((m = /^\/api\/history\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'DELETE') { if ((m = /^\/api\/history\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'DELETE') {
@@ -1080,6 +1096,7 @@ async function route(req, res) {
} }
const security = setupSecurity({ db }) const security = setupSecurity({ db })
const books = setupBooks({ DATA_DIR, cached, HttpError, coverUrl, UA, net, safeFetch })
const features = setupFeatures({ db, fetchMangaFull, enqueueDownload, downloadInfo, HttpError, json, readBody, MANGA_ID, net }) const features = setupFeatures({ db, fetchMangaFull, enqueueDownload, downloadInfo, HttpError, json, readBody, MANGA_ID, net })
const upscale = setupUpscale({ db, MANGA_DIR, HttpError, json, readBody, MANGA_ID }) const upscale = setupUpscale({ db, MANGA_DIR, HttpError, json, readBody, MANGA_ID })
const covers = setupCovers({ DATA_DIR, upscale }) const covers = setupCovers({ DATA_DIR, upscale })