diff --git a/app/books.js b/app/books.js new file mode 100644 index 0000000..4b0edee --- /dev/null +++ b/app/books.js @@ -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~", capítulo "gb~~". +// Título, autores e idioma salen del propio EPUB, así que abrir un libro solo necesita el archivo. +// En /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 = /||<[?!][^>]*>|<(\/?)([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 (
) 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
+ 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(/]/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 = /]*(?:epub:)?type\s*=\s*["']toc["'][^>]*>([\s\S]*?)<\/nav>/i.exec(xml)?.[1] + if (nav) { + const out = [] + for (const m of nav.matchAll(/]*)>([\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 = /]*\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(/]*>\s*]*>\s*]*>([\s\S]*?)<\/text>\s*<\/navLabel>\s*]*)\/?>/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(/]*)>/i.exec(container)?.[1] || '', 'full-path') + if (!opfPath) throw new Error('EPUB sin paquete') + const opf = read(opfPath) + const dc = name => clean(new RegExp(`]*>([\\s\\S]*?)`, 'i').exec(opf)?.[1] || '') + const manifest = new Map() + for (const m of opf.matchAll(/]*)>/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(/]*)>/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(/]*>([\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]*?)`).exec(xml)?.[1] || '') + function parseFeed(xml) { + const out = [] + for (const [entry] of xml.matchAll(/[\s\S]*?<\/entry>/g)) { + const n = /[^<]*\/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 } +} diff --git a/app/cbz.js b/app/cbz.js index b36e0ef..a03c701 100644 --- a/app/cbz.js +++ b/app/cbz.js @@ -56,8 +56,14 @@ export function writeCbz(file, entries) { 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) { + 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') try { const { size } = fs.fstatSync(fd) @@ -65,7 +71,7 @@ export function readCbzIndex(file) { const tail = Buffer.alloc(tailLen) fs.readSync(fd, tail, 0, tailLen, size - tailLen) 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 cdSize = tail.readUInt32LE(eocd + 12) const cdOffset = tail.readUInt32LE(eocd + 16) @@ -76,18 +82,18 @@ export function readCbzIndex(file) { for (let p = 0, i = 0; i < count; i++) { const method = cd.readUInt16LE(p + 10) const compSize = cd.readUInt32LE(p + 20) + const unzipped = cd.readUInt32LE(p + 24) const nameLen = cd.readUInt16LE(p + 28) const extraLen = cd.readUInt16LE(p + 30) const commentLen = cd.readUInt16LE(p + 32) const localOffset = cd.readUInt32LE(p + 42) const name = cd.toString('utf8', p + 46, p + 46 + nameLen) p += 46 + nameLen + extraLen + commentLen - if (!IMAGE_EXT.test(name) || name.startsWith('__MACOSX')) continue fs.readSync(fd, lh, 0, 30, localOffset) 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 { 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' } -// Lee una entrada completa del CBZ a memoria. -export function readCbzEntry(file, entry) { +// Lee una entrada completa del CBZ a memoria. maxBytes corta una entrada que al descomprimirse crezca de más. +export function readCbzEntry(file, entry, maxBytes) { if (entry.method !== 0 && entry.method !== 8) throw new Error(`Compresión ZIP no soportada (${entry.method})`) const fd = fs.openSync(file, 'r') const buf = Buffer.alloc(entry.size) 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. diff --git a/app/features.js b/app/features.js index aa780ca..02bbb60 100644 --- a/app/features.js +++ b/app/features.js @@ -218,6 +218,7 @@ export function setupFeatures({ db, fetchMangaFull, enqueueDownload, downloadInf 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) } + 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). 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, diff --git a/app/public/app.js b/app/public/app.js index 3f189d5..eecb9b6 100644 --- a/app/public/app.js +++ b/app/public/app.js @@ -50,8 +50,14 @@ const icon = name => ` wantsId(f.manga_id)) } 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` if (!fol.follows.length) return '' @@ -484,7 +491,8 @@ async function viewHomeOffline(params) {

${icon('download')} Descargado en el servidor

Cargando…
` - 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 cont = hist.filter(h => !h.finished && have.has(h.manga_id)) if (cont.length) $('#cont').innerHTML = `

Continuar leyendo

${cont.slice(0, 12).map(continueCard).join('')}
` @@ -517,7 +525,9 @@ async function viewHome(params) { const tag = params.get('tag') || '' const tagName = params.get('tagName') || tag 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' if (mode === 'cats' && NO_CATS.has(source)) mode = 'popular' store.set('source', source) @@ -525,12 +535,12 @@ async function viewHome(params) { app.innerHTML = `
-
${Object.entries(SOURCES).map(([k, v]) => ``).join('')}
+
${shownSources.map(([k, v]) => ``).join('')}
${q && !tag ? `

Resultados para “${esc(q)}”

` : `
- + ${NO_CATS.has(source) ? '' : ``}
`} ${tag ? `

Categoría: ${esc(tagName)} ✕${q ? ` · “${esc(q)}”` : ''}

` : ''}
Cargando…
@@ -548,10 +558,10 @@ async function viewHome(params) { if (!q && !tag && !showCats) { Promise.all([api('/api/history'), api('/api/follows')]).then(([hist, fol]) => { if (!$('#cont')) return - $('#cont').innerHTML = `
${newsSection(fol)}
` + (hist = hist.filter(h => !h.finished), hist.length ? `

Continuar leyendo

${hist.slice(0, 12).map(continueCard).join('')}
` : '') + $('#cont').innerHTML = `
${newsSection(fol)}
` + (hist = hist.filter(h => !h.finished && wantsId(h.manga_id)), hist.length ? `

Continuar leyendo

${hist.slice(0, 12).map(continueCard).join('')}
` : '') bindNews(fol) }).catch(() => {}) - loadRecs() + if (contentMode() !== 'books') loadRecs() // las recomendaciones son de manga } 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). - 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() let page = 1 const load = async () => { @@ -650,6 +660,7 @@ async function viewManga(id, langOverride) { let isFollowing = following 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 isBook = isBookId(id) const langOpts = [...new Set([lang, me.lang, ...chapterLangs, ...manga.languages])].filter(Boolean) const altSearch = `#/?source=lc&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 = `${icon('book')} Empezar a leer` const menuItems = [ - manga.source === 'lc' ? '' : `${icon('search')} Buscar en LeerCapitulo`, - manga.source === 'mi' ? '' : `${icon('search')} Buscar en M440`, + manga.source === 'lc' || isBook ? '' : `${icon('search')} Buscar en LeerCapitulo`, + manga.source === 'mi' || isBook ? '' : `${icon('search')} Buscar en M440`, progress || reads.length ? `` : '', ].join('') @@ -697,7 +708,7 @@ async function viewManga(id, langOverride) { ${clipCount ? `${icon('scissors')} ${clipCount} recorte${clipCount > 1 ? 's' : ''}` : ''} ${menuItems} -
+
${icon('book')} Datos de la obra
@@ -735,7 +746,7 @@ async function viewManga(id, langOverride) { AniList.

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

Buscando información de la obra…

' 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.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.hidden = offline + b.hidden = offline || isBook } renderFollow() $('#follow').addEventListener('click', async () => { @@ -1224,7 +1235,7 @@ async function viewManga(id, langOverride) { updateSummary() }) 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) renderHd(upscale) 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). 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 (manga.type === 'Manga') return { mode: 'paged', fit: 'height', dir: 'rtl' } 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' } } +// ---------- 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) { document.body.classList.add('reading') app.innerHTML = '
Cargando capítulo…
' @@ -1353,17 +1770,54 @@ async function viewReader(mangaId, chapterId, startPage) { const chapter = chapters[idx] || { id: chapterId, num: info.progress?.chapter_num, title: '' } const prev = idx > 0 ? chapters[idx - 1] : null const next = idx >= 0 && idx < chapters.length - 1 ? chapters[idx + 1] : null - 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 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 = '
Preparando el libro…
' + 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) // Guardado del progreso (con debounce y al salir de la página). 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, 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" @@ -1376,21 +1830,22 @@ async function viewReader(mangaId, chapterId, startPage) { window.addEventListener('pagehide', beacon) saveNow() - const chapOpts = chapters.map(c => ``).join('') + let curChap = book ? book.chapterAt(page) : idx + const chapOpts = chapters.map((c, i) => ``).join('') app.innerHTML = `
${icon('back')}
${esc(manga.title)}
- +
- ${prev ? `${icon('prev')} Cap. ant.` : ''} + ${book ? `${icon('prev')} Cap. ant.` : prev ? `${icon('prev')} Cap. ant.` : ''} - ${next ? `Cap. sig. ${icon('next')}` : ''} + ${book ? `Cap. sig. ${icon('next')}` : next ? `Cap. sig. ${icon('next')}` : ''}
` const reader = $('.reader', app) @@ -1434,7 +1893,8 @@ async function viewReader(mangaId, chapterId, startPage) { applyFilter() if ($('#saver')) $('#saver').checked = settings.saver if ($('#hd')) $('#hd').checked = settings.hd !== false - const endBlock = `

Fin del capítulo ${esc(chapter.num ?? '')}

+ const endBlock = book ? `` : `

Fin del capítulo ${esc(chapter.num ?? '')}

${next ? `${icon('next')} Siguiente: ${esc(chapLabel(next))}` : '

No hay más capítulos disponibles.

'} ${icon('back')} Volver a la ficha
` @@ -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á.`) } + // 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 = () => { + if (book) syncBookChapter() $('#pnum').textContent = `${shown.length > 1 ? `${shown[0] + 1}-${shown[1] + 1}` : lastShown() + 1} / ${total}` slider.value = lastShown() + 1 } let observer = null 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. const wide = new Set() @@ -1479,9 +1980,10 @@ async function viewReader(mangaId, chapterId, startPage) { wide.add(i) 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) + 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) scheduleLeafTextures() @@ -1517,7 +2019,7 @@ async function viewReader(mangaId, chapterId, startPage) { const stage = document.createElement('div') stage.className = 'stage' stage.classList.toggle('two', shown.length === 2) - stage.innerHTML = shown.map((i, n) => ``).join('') + stage.innerHTML = shown.map((i, n) => ``).join('') const imgs = [...stage.querySelectorAll('img')] if ($('.stage', box) && dir && !wantDrag && !imgs.every(imgReady)) { waitTimer = setTimeout(() => box.classList.add('waiting'), 200) @@ -2100,7 +2602,7 @@ async function viewReader(mangaId, chapterId, startPage) { const under = [] const neighbour = (i, onHingeSide) => { 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 w = fit ? im.naturalWidth * fit : rect.w, h = fit ? im.naturalHeight * fit : rect.h im.className = 'leafkeep' @@ -2286,6 +2788,7 @@ async function viewReader(mangaId, chapterId, startPage) { function render() { observer?.disconnect() const keep = lastShown() + if (book && sizeChanged()) return reopenBook() // cambiar a doble página cambia el tamaño de página resetZoom() box.className = isPaged() ? `paged ${settings.fit === 'width' ? 'fit-width' : ''} ${isDouble() ? 'double' : ''}` : `vertical fit-${settings.fit}` renderModeQuick() @@ -2302,10 +2805,16 @@ async function viewReader(mangaId, chapterId, startPage) { } page = keep shown = [keep] - box.innerHTML = pages.map((src, i) => ``).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) => ``).join('') + endBlock box.querySelectorAll('img').forEach(img => img.addEventListener('load', () => img.setAttribute('data-loaded', ''), { once: true })) 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' }) // No seguir el scroll hasta haber saltado a la página guardada, para no pisarla con la 0. let ready = false @@ -2317,7 +2826,7 @@ async function viewReader(mangaId, chapterId, startPage) { imgs.forEach(img => { lazy.observe(img); observer.observe(img) }) const lazyDisconnect = observer.disconnect.bind(observer) 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(() => { imgs[page]?.scrollIntoView() 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 savePrefs = () => { - 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)) + return api(`/api/prefs/${mangaId}`, { method: 'POST', body: { mode: settings.mode, fit: settings.fit, dir: settings.dir } }) + .then(() => toast(`Guardado para este ${noun}`)).catch(err => toast(err.message)) } - $('#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', () => { settings.mode = isPaged() ? 'vertical' : lastPaged - savePrefs() - render() + savePrefsThenRender() }) $('#fit').addEventListener('change', e => { settings.fit = e.target.value; savePrefs(); render() }) $('#dir').addEventListener('change', e => { @@ -2523,7 +3033,21 @@ async function viewReader(mangaId, chapterId, startPage) { 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 } }) - $('#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 => { const b = e.target.closest('button') b.disabled = true @@ -2742,6 +3266,7 @@ async function viewReader(mangaId, chapterId, startPage) { const onResize = () => { clearTimeout(resizeTimer) resizeTimer = setTimeout(() => { + if (book && sizeChanged()) return reopenBook() if (!isPaged() || atEnd) return box.classList.toggle('double', isDouble()) goTo(lastShown(), false, true) @@ -2749,8 +3274,19 @@ async function viewReader(mangaId, chapterId, startPage) { } 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() cleanup = () => { + book?.release() freeLeafGL() endCrop() if (saveTimer) saveNow() @@ -2900,16 +3436,16 @@ async function viewLibrary(params) { : [null, null, []] if (!location.hash.startsWith('#/library')) return const online = !!hist - const dev = OFFLINE_OK ? offByManga() : [] - const reading = (hist || []).filter(h => !h.finished) - const finished = (hist || []).filter(h => h.finished) - const follows = fol?.follows || [] + const dev = OFFLINE_OK ? offByManga().filter(m => wantsId(m.mangaId)) : [] + const reading = (hist || []).filter(h => !h.finished && wantsId(h.manga_id)) + const finished = (hist || []).filter(h => h.finished && wantsId(h.manga_id)) + const follows = (fol?.follows || []).filter(f => wantsId(f.manga_id)) const tabs = [ ['reading', 'Leyendo', 'book', reading.length], ['following', 'Siguiendo', 'bell', follows.length], ['finished', 'Terminados', 'check', finished.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 app.innerHTML = `

Librería

@@ -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.') } else if (curList) { const l = await api(`/api/lists/${curList.id}`).catch(err => (toast(err.message), { items: [] })) - body.innerHTML = grid(l.items.map(it => libCard({ + 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)}`, 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».') @@ -3292,6 +3828,9 @@ function viewSettings() {
Idioma preferido de los capítulos
+
${icon('book')} Qué mostrar +

Elige qué aparece en el inicio y en tu librería. No se borra nada: lo oculto vuelve al cambiar este ajuste.

+
Cambiar contraseña
@@ -3382,6 +3921,14 @@ function viewSettings() { }) $('#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') }) + $('#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 => { e.preventDefault() try { diff --git a/app/security.js b/app/security.js index 046c356..adad7f0 100644 --- a/app/security.js +++ b/app/security.js @@ -128,7 +128,8 @@ async function hostIsPublic(hostname) { const hit = dnsCache.get(hostname) if (hit && hit.exp > Date.now()) return hit.ok 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 }) if (dnsCache.size > 1000) dnsCache.delete(dnsCache.keys().next().value) return ok diff --git a/app/server.js b/app/server.js index cbe2cc7..494710e 100644 --- a/app/server.js +++ b/app/server.js @@ -15,6 +15,7 @@ import { setupSecurity, safeFetch } from './security.js' import { setupM440, MI_BASE } from './m440.js' import { setupConnectivity } from './connectivity.js' import { setupMeta } from './meta.js' +import { setupBooks, isBookId } from './books.js' const PORT = Number(process.env.PORT || 3352) 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) { const m = /(?:^|;\s*)sid=([^;]+)/.exec(req.headers.cookie || '') 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 } +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) ---------- const cache = new Map() 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 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. -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' // 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) return src === 'lc' ? lcManga(mangaId) : src === 'mi' ? mi.manga(mangaId) + : src === 'gb' ? books.manga(mangaId) : { 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') } } -const MANGA_ID = /^([0-9a-f-]{36}|lc~[a-z0-9]+~[a-z0-9-]+|mi~[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._-]+)$/ +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._-]+|gb~\d{1,7}~\d{1,5})$/ const LANG = /^[a-z]{2}(-[a-z]{2})?$/ // 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 = ? @@ -877,7 +881,7 @@ async function route(req, res) { 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 (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') 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) { 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') } @@ -919,12 +927,13 @@ async function route(req, res) { const lang = LANG.test(q.get('lang') || '') ? q.get('lang') : user.lang 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') { const page = Math.min(Math.max(Number(q.get('page')) | 0, 1), 500) const tag = q.get('tag') || '' 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') === 'lc') return json(res, 200, await lcSearch((q.get('q') || '').slice(0, 100), q.get('mode'), tag, page)) 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 progress = db.prepare('SELECT * FROM progress WHERE user_id = ? AND manga_id = ?').get(user.id, m[1]) ?? null const chapterLangs = chapters.length || manga.source !== 'md' || offline ? [] : await getChapterLangs(m[1]).catch(() => []) - return json(res, 200, { manga, chapters, lang, reads, progress, chapterLangs, offline, downloaded, download: downloadInfo(m[1], true), upscale: upscale.info(m[1]), clips: clips.count(user.id, m[1]), lists: library.listsWith(user.id, m[1]), info: 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 (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 hd = hdAvailable && q.get('hd') !== '0' 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]) }) } 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 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 @@ -1066,7 +1082,7 @@ async function route(req, res) { .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) 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 }) } 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 books = setupBooks({ DATA_DIR, cached, HttpError, coverUrl, UA, net, safeFetch }) 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 covers = setupCovers({ DATA_DIR, upscale })