Libros: Wikisource, EPUB propios y lectura sin conexión
Wikisource en español como segunda fuente, con el EPUB que genera ws-export y portadas generadas con el título. Cada usuario puede subir sus EPUB, visibles solo para él en cualquier ruta. Los archivos manipulados (ZIP bombs, cabeceras falsas) se rechazan. Un libro se puede guardar entero en el dispositivo para leerlo sin conexión. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -62,6 +62,20 @@ Lector web de manga autoalojado, con las fuentes de
|
||||
favoritos y lo más leído.
|
||||
- Las imágenes pasan por el servidor (`/api/img`), solo con URLs firmadas por él (clave en `data/secret`).
|
||||
|
||||
## Libros de texto
|
||||
|
||||
Además del manga, lee libros en EPUB con el mismo lector (hoja que gira, sombra, hendidura, sonido):
|
||||
|
||||
- **Project Gutenberg**: dominio público, con búsqueda en su catálogo OPDS.
|
||||
- **Wikisource en español**: novelas, con el EPUB que genera ws-export.
|
||||
- **Mis libros**: los EPUB que sube cada usuario (hasta 100 MB), visibles solo para quien los sube.
|
||||
|
||||
El servidor convierte el EPUB en una lista cerrada de bloques (`app/books.js`), sin pasar HTML del libro al
|
||||
navegador, y guarda el resultado en `data/books/`. El navegador maqueta el libro entero como texto continuo según su
|
||||
pantalla y dibuja cada página en un canvas. La letra, los márgenes y el papel se eligen en el lector, por dispositivo.
|
||||
Un libro se puede guardar entero para leerlo sin conexión. En Ajustes → «Qué mostrar», cada usuario elige ver manga,
|
||||
libros o ambos.
|
||||
|
||||
## Uso
|
||||
|
||||
```bash
|
||||
|
||||
+173
-21
@@ -2,23 +2,32 @@
|
||||
// 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.
|
||||
// Título, autores e idioma salen del propio EPUB, así que abrir un libro solo necesita el archivo. Orígenes:
|
||||
// - Project Gutenberg, con su catálogo OPDS. IDs: libro "gb~<n>", capítulo "gb~<n>~<i>".
|
||||
// - Wikisource en español, con el EPUB que genera ws-export. IDs: "ws~<título en base64url>", "ws~<título>~<i>".
|
||||
// - Los EPUB que sube cada usuario, solo visibles para él. IDs: "ub~<32 hex>", "ub~<32 hex>~<i>".
|
||||
// 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 crypto from 'node:crypto'
|
||||
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 WS_API = 'https://es.wikisource.org/w/api.php'
|
||||
const WS_EXPORT = 'https://ws-export.wmcloud.org/'
|
||||
const WS_MIN_WORDS = 2000 // sin subpáginas, lo que no llega a esto es un poema o un texto suelto, no un libro
|
||||
const OPDS_PAGE = 25
|
||||
const GB_NAME = 'Project Gutenberg'
|
||||
const WS_NAME = 'Wikisource'
|
||||
// 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 PARSER_VERSION = 3
|
||||
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 MAX_UNZIPPED_TOTAL = 256 * 1024 * 1024 // todo lo que se lee del libro: lo que pase de aquí es una ZIP bomb
|
||||
const MAX_UPLOADS_PER_USER = 500
|
||||
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: '×' }
|
||||
@@ -42,7 +51,7 @@ function resolveHref(base, href) {
|
||||
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
|
||||
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) {
|
||||
@@ -216,11 +225,15 @@ 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')
|
||||
let unzipped = 0
|
||||
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 buf = readCbzEntry(file, e, MAX_UNZIPPED_ENTRY)
|
||||
unzipped += buf.length // lo real: la cabecera del ZIP puede mentir
|
||||
if (unzipped > MAX_UNZIPPED_TOTAL) throw new Error('EPUB demasiado grande al descomprimir')
|
||||
return buf
|
||||
}
|
||||
const read = name => readBuf(name).toString('utf8').replace(/^/, '')
|
||||
|
||||
@@ -285,6 +298,10 @@ export function parseEpub(file) {
|
||||
if (carry) merged.push(carry)
|
||||
const out = merged
|
||||
if (!out.length) throw new Error('EPUB sin texto')
|
||||
// Portada: EPUB 3 la marca en el manifiesto y EPUB 2 con un <meta>, que a veces no apunta a una imagen.
|
||||
const coverId = /<meta\b(?=[^>]*\bname\s*=\s*["']cover["'])[^>]*\bcontent\s*=\s*["']([^"']+)["']/i.exec(opf)?.[1]
|
||||
const coverItem = [...manifest.values()].find(it => /\bcover-image\b/.test(it.props)) || manifest.get(coverId)
|
||||
const cover = coverItem ? imageOf(coverItem.path) : null
|
||||
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) {
|
||||
@@ -298,35 +315,65 @@ export function parseEpub(file) {
|
||||
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,
|
||||
images, cover, chapters: out,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Fuente y caché ----------
|
||||
export function setupBooks({ DATA_DIR, cached, HttpError, coverUrl, UA, net, safeFetch }) {
|
||||
// Portada generada para lo que no trae una: el título sobre un color que sale del propio título.
|
||||
function titleCover(title, author) {
|
||||
const esc = t => String(t).replace(/[<>&"']/g, c => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' }[c]))
|
||||
const wrap = (t, width, max) => {
|
||||
const lines = []
|
||||
let cur = ''
|
||||
for (const w of t.split(/\s+/)) {
|
||||
if (cur && cur.length + w.length + 1 > width) { lines.push(cur); cur = w } else cur = cur ? `${cur} ${w}` : w
|
||||
}
|
||||
if (cur) lines.push(cur)
|
||||
if (lines.length > max) { lines.length = max; lines[max - 1] += '…' }
|
||||
return lines.map(l => l.slice(0, width + 6))
|
||||
}
|
||||
const hue = [...String(title)].reduce((n, c) => (n * 31 + c.codePointAt(0)) % 360, 7)
|
||||
const lines = wrap(title, 15, 6)
|
||||
const by = author ? wrap(author, 22, 2) : []
|
||||
const text = (t, y, size, weight, fill) => `<text x="200" y="${y}" font-family="Georgia,'Times New Roman',serif" font-size="${size}" font-weight="${weight}" text-anchor="middle" fill="${fill}">${esc(t)}</text>`
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 600" width="400" height="600"><rect width="400" height="600" fill="hsl(${hue} 38% 24%)"/><rect x="18" y="18" width="364" height="564" fill="none" stroke="hsl(${hue} 45% 72%)" stroke-width="2"/>${lines.map((l, i) => text(l, 150 + i * 48, 38, 'bold', '#f6f1e6')).join('')}${by.map((l, i) => text(l, 500 + i * 30, 23, 'normal', `hsl(${hue} 45% 80%)`)).join('')}</svg>`
|
||||
}
|
||||
|
||||
export function setupBooks({ db, 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`)
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS user_books (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
authors TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_books_user ON user_books(user_id, created_at);`)
|
||||
// Un título de Wikisource puede ser largo para nombre de archivo.
|
||||
const fileKey = id => (id.startsWith('ws~') ? `ws-${crypto.createHash('sha256').update(id).digest('hex').slice(0, 32)}` : id.replace('~', '-'))
|
||||
const epubFile = id => path.join(DIR, `${fileKey(id)}.epub`)
|
||||
const jsonFile = id => path.join(DIR, `${fileKey(id)}.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) {
|
||||
async function fetchSource(url, timeout, name) {
|
||||
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}`)
|
||||
if (r.status === 404) throw new HttpError(404, `Ese libro no está en ${name}`)
|
||||
if (r.status < 500 && r.status !== 429) throw new HttpError(502, `${name} 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.')
|
||||
console.warn(`${name} ${url}: intento ${i}: ${why}`)
|
||||
if (i >= 3) throw new HttpError(502, `${name} no responde ahora mismo. Prueba en un rato.`)
|
||||
await new Promise(r => setTimeout(r, 800 * i))
|
||||
}
|
||||
}
|
||||
@@ -353,7 +400,93 @@ export function setupBooks({ DATA_DIR, cached, HttpError, coverUrl, UA, net, saf
|
||||
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()))
|
||||
return cached(`gb:${qs}`, 30 * 60e3, async () => parseFeed(await (await fetchSource(`${GUTENBERG}/ebooks/search.opds/?${qs}`, 15e3, GB_NAME)).text()))
|
||||
}
|
||||
|
||||
// ---------- Wikisource ----------
|
||||
const wsId = title => `ws~${Buffer.from(title).toString('base64url')}`
|
||||
const wsTitle = id => Buffer.from(id.slice(3), 'base64url').toString('utf8')
|
||||
const wsCard = root => {
|
||||
const id = wsId(root)
|
||||
// "Realidad (Pérez Galdós)": la obra y, entre paréntesis, de quién es o de qué año.
|
||||
const m = /^(.+?)\s*\(([^()]+)\)$/.exec(root)
|
||||
return { id, source: 'ws', title: m ? m[1] : root, cover: `/api/book/${id}/cover`, sub: m ? m[2] : undefined }
|
||||
}
|
||||
const wsQuery = async params => {
|
||||
const qs = new URLSearchParams({ action: 'query', format: 'json', ...params })
|
||||
return (await fetchSource(`${WS_API}?${qs}`, 20e3, WS_NAME)).json()
|
||||
}
|
||||
async function wsList(q, mode) {
|
||||
const text = q.trim()
|
||||
if (text) {
|
||||
const d = await wsQuery({ list: 'search', srsearch: text, srnamespace: '0', srlimit: '200', srprop: 'wordcount' })
|
||||
// Una novela es una página índice corta con muchas subpáginas ("Obra/12"): cuenta la obra, una vez.
|
||||
const works = new Map()
|
||||
for (const r of d.query?.search || []) {
|
||||
const root = r.title.split('/')[0]
|
||||
const w = works.get(root) || { root, chapters: 0, words: 0 }
|
||||
if (r.title.includes('/')) w.chapters++
|
||||
else w.words = r.wordcount
|
||||
works.set(root, w)
|
||||
}
|
||||
return [...works.values()].filter(w => w.chapters || w.words >= WS_MIN_WORDS).map(w => w.root)
|
||||
}
|
||||
const d = await wsQuery({ list: 'categorymembers', cmtitle: 'Categoría:Novelas', cmnamespace: '0', cmlimit: '500', ...(mode === 'latest' ? { cmsort: 'timestamp', cmdir: 'desc' } : {}) })
|
||||
return (d.query?.categorymembers || []).map(m => m.title)
|
||||
}
|
||||
async function wsSearch(q, mode, page) {
|
||||
const list = await cached(`ws:${mode === 'latest' ? 'latest' : 'popular'}:${q.trim().toLowerCase()}`, 30 * 60e3, () => wsList(q, mode))
|
||||
const start = (Math.max(1, page | 0) - 1) * OPDS_PAGE
|
||||
return list.filter(t => Buffer.byteLength(t) <= 280).slice(start, start + OPDS_PAGE).map(wsCard)
|
||||
}
|
||||
|
||||
// ---------- Libros subidos ----------
|
||||
const owns = (userId, id) => !!db.prepare('SELECT 1 FROM user_books WHERE id = ? AND user_id = ?').get(id, userId)
|
||||
const ubCard = r => ({ id: r.id, source: 'ub', title: r.title, cover: `/api/book/${r.id}/cover`, sub: JSON.parse(r.authors).join(', ') || undefined })
|
||||
function ubList(userId, q) {
|
||||
const fold = t => String(t).toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '')
|
||||
const words = fold(q).split(/\s+/).filter(Boolean)
|
||||
return db.prepare('SELECT * FROM user_books WHERE user_id = ? ORDER BY created_at DESC').all(userId)
|
||||
.filter(r => words.every(w => fold(`${r.title} ${r.authors}`).includes(w))).map(ubCard)
|
||||
}
|
||||
async function upload(req, user) {
|
||||
if (db.prepare('SELECT COUNT(*) n FROM user_books WHERE user_id = ?').get(user.id).n >= MAX_UPLOADS_PER_USER) throw new HttpError(400, `Se admiten hasta ${MAX_UPLOADS_PER_USER} libros por usuario`)
|
||||
let size = 0
|
||||
const chunks = []
|
||||
for await (const c of req) {
|
||||
size += c.length
|
||||
if (size > MAX_EPUB_BYTES) throw new HttpError(413, 'El libro es demasiado grande (máximo 100 MB)')
|
||||
chunks.push(c)
|
||||
}
|
||||
if (!size) throw new HttpError(400, 'No se recibió ningún archivo')
|
||||
const data = Buffer.concat(chunks)
|
||||
// El mismo archivo subido dos veces por la misma persona es el mismo libro.
|
||||
const id = `ub~${crypto.createHash('sha256').update(`${user.id}:`).update(data).digest('hex').slice(0, 32)}`
|
||||
const tmp = path.join(DIR, `up-${crypto.randomBytes(8).toString('hex')}.part`)
|
||||
fs.writeFileSync(tmp, data)
|
||||
let book
|
||||
try { book = parseEpub(tmp) } catch (e) {
|
||||
fs.rmSync(tmp, { force: true })
|
||||
throw new HttpError(400, `No se pudo leer ese archivo como EPUB: ${String(e.message).slice(0, 120)}`)
|
||||
}
|
||||
fs.renameSync(tmp, epubFile(id))
|
||||
fs.writeFileSync(jsonFile(id), JSON.stringify(book))
|
||||
remember(id, book)
|
||||
db.prepare(`INSERT INTO user_books (id, user_id, title, authors, created_at) VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT (id) DO UPDATE SET created_at = excluded.created_at`)
|
||||
.run(id, user.id, (book.title || 'Sin título').slice(0, 300), JSON.stringify(book.authors.map(authorName).slice(0, 10)), Date.now())
|
||||
return ubCard(db.prepare('SELECT * FROM user_books WHERE id = ?').get(id))
|
||||
}
|
||||
function removeUpload(id, userId) {
|
||||
db.prepare('DELETE FROM user_books WHERE id = ? AND user_id = ?').run(id, userId)
|
||||
db.prepare('DELETE FROM progress WHERE user_id = ? AND manga_id = ?').run(userId, id)
|
||||
db.prepare('DELETE FROM chapter_reads WHERE user_id = ? AND manga_id = ?').run(userId, id)
|
||||
db.prepare('DELETE FROM manga_prefs WHERE user_id = ? AND manga_id = ?').run(userId, id)
|
||||
db.prepare('DELETE FROM list_items WHERE manga_id = ? AND list_id IN (SELECT id FROM lists WHERE user_id = ?)').run(id, userId)
|
||||
memory.delete(id)
|
||||
packed.delete(id)
|
||||
fs.rmSync(epubFile(id), { force: true })
|
||||
fs.rmSync(jsonFile(id), { force: true })
|
||||
}
|
||||
|
||||
// Una sola descarga y conversión a la vez por libro.
|
||||
@@ -366,7 +499,10 @@ export function setupBooks({ DATA_DIR, cached, HttpError, coverUrl, UA, net, saf
|
||||
return book
|
||||
}
|
||||
async function downloadEpub(id) {
|
||||
const r = await gutenberg(`${GUTENBERG}/ebooks/${id.slice(3)}.epub3.images`, 120e3)
|
||||
if (id.startsWith('ub~')) throw new HttpError(404, 'Ese libro ya no está')
|
||||
const r = id.startsWith('ws~')
|
||||
? await fetchSource(`${WS_EXPORT}?${new URLSearchParams({ lang: 'es', page: wsTitle(id), format: 'epub-3' })}`, 120e3, WS_NAME)
|
||||
: await fetchSource(`${GUTENBERG}/ebooks/${id.slice(3)}.epub3.images`, 120e3, GB_NAME)
|
||||
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')
|
||||
@@ -401,16 +537,17 @@ export function setupBooks({ DATA_DIR, cached, HttpError, coverUrl, UA, net, saf
|
||||
|
||||
async function manga(id) {
|
||||
const book = await loadBook(id)
|
||||
const source = id.slice(0, 2)
|
||||
return {
|
||||
manga: {
|
||||
id, source: 'gb', type: 'Libro',
|
||||
title: book.title || `Libro ${id.slice(3)}`,
|
||||
id, source, type: 'Libro',
|
||||
title: book.title || (source === 'ws' ? wsCard(wsTitle(id)).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),
|
||||
cover: source === 'gb' ? coverOf(id.slice(3), id) : `/api/book/${id}/cover`,
|
||||
},
|
||||
chapters: book.chapters.map((c, i) => ({ id: `${id}~${i}`, num: String(i + 1), volume: null, title: c.title, pages: null, group: '', publishAt: null })),
|
||||
}
|
||||
@@ -447,5 +584,20 @@ export function setupBooks({ DATA_DIR, cached, HttpError, coverUrl, UA, net, saf
|
||||
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 }
|
||||
async function sendCover(res, id) {
|
||||
if (id.startsWith('ub~')) {
|
||||
const book = await loadBook(id)
|
||||
if (book.cover != null) return sendImage(res, id, book.cover)
|
||||
const r = db.prepare('SELECT title, authors FROM user_books WHERE id = ?').get(id)
|
||||
return sendSvg(res, titleCover(r?.title || book.title || 'Sin título', book.authors.map(authorName)[0]))
|
||||
}
|
||||
if (!id.startsWith('ws~')) throw new HttpError(404, 'Sin portada')
|
||||
const card = wsCard(wsTitle(id)) // en un listado: sin descargar el libro
|
||||
sendSvg(res, titleCover(card.title, card.sub))
|
||||
}
|
||||
const sendSvg = (res, svg) => {
|
||||
res.writeHead(200, { 'Content-Type': 'image/svg+xml; charset=utf-8', 'Cache-Control': 'private, max-age=604800', 'X-Content-Type-Options': 'nosniff' })
|
||||
res.end(svg)
|
||||
}
|
||||
return { search, wsSearch, ubList, upload, removeUpload, owns, manga, chapter, sendText, sendImage, sendCover }
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ export function readZipIndex(file) {
|
||||
const count = tail.readUInt16LE(eocd + 10)
|
||||
const cdSize = tail.readUInt32LE(eocd + 12)
|
||||
const cdOffset = tail.readUInt32LE(eocd + 16)
|
||||
if (cdSize > size) throw new Error('ZIP inválido')
|
||||
const cd = Buffer.alloc(cdSize)
|
||||
fs.readSync(fd, cd, 0, cdSize, cdOffset)
|
||||
const entries = []
|
||||
@@ -87,6 +88,7 @@ export function readZipIndex(file) {
|
||||
const extraLen = cd.readUInt16LE(p + 30)
|
||||
const commentLen = cd.readUInt16LE(p + 32)
|
||||
const localOffset = cd.readUInt32LE(p + 42)
|
||||
if (compSize > size) throw new Error('ZIP inválido')
|
||||
const name = cd.toString('utf8', p + 46, p + 46 + nameLen)
|
||||
p += 46 + nameLen + extraLen + commentLen
|
||||
fs.readSync(fd, lh, 0, 30, localOffset)
|
||||
|
||||
+66
-10
@@ -50,9 +50,9 @@ const icon = name => `<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="cu
|
||||
// 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>` })
|
||||
|
||||
const SOURCES = { lc: 'LeerCapitulo (español)', mi: 'M440 (español)', md: 'MangaDex (multi-idioma)', gb: 'Project Gutenberg (libros)' }
|
||||
const NO_CATS = new Set(['mi', 'gb']) // fuentes sin listado por categorías
|
||||
const BOOK_SOURCES = new Set(['gb'])
|
||||
const SOURCES = { lc: 'LeerCapitulo (español)', mi: 'M440 (español)', md: 'MangaDex (multi-idioma)', gb: 'Project Gutenberg (libros)', ws: 'Wikisource (libros en español)', ub: 'Mis libros (EPUB subidos)' }
|
||||
const NO_CATS = new Set(['mi', 'gb', 'ws', 'ub']) // fuentes sin listado por categorías
|
||||
const BOOK_SOURCES = new Set(['gb', 'ws', 'ub'])
|
||||
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'
|
||||
@@ -279,7 +279,16 @@ async function offSaveInto(cache, it, saved, signal) {
|
||||
const res = await fetch(chapUrl, { signal })
|
||||
if (!res.ok) throw new Error(`Error ${res.status}`)
|
||||
const data = await res.clone().json()
|
||||
await cache.put(chapUrl, res)
|
||||
if (data.text) {
|
||||
// Libro: lo que se guarda es su texto y sus imágenes; la lista queda en la copia, para poder borrarlos después.
|
||||
const textUrl = `/api/book/${it.mangaId}/text`
|
||||
const tres = await fetch(textUrl, { signal })
|
||||
if (!tres.ok) throw new Error(`Error ${tres.status}`)
|
||||
const text = await tres.json()
|
||||
const images = new Set(text.chapters.flatMap(c => c.blocks).filter(b => b.k === 'img').map(b => `/api/book/${it.mangaId}/img/${b.n}`))
|
||||
data.pages = [textUrl, ...images]
|
||||
await cache.put(chapUrl, new Response(JSON.stringify(data), { headers: { 'Content-Type': 'application/json' } }))
|
||||
} else await cache.put(chapUrl, res)
|
||||
saved.push(chapUrl)
|
||||
const mUrl = `/api/manga/${it.mangaId}?lang=${encodeURIComponent(it.lang || '')}`
|
||||
const mres = await fetch(mUrl).catch(() => null)
|
||||
@@ -538,7 +547,8 @@ async function viewHome(params) {
|
||||
<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>
|
||||
<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">
|
||||
${source === 'ub' ? `<div class="row" style="margin:10px 0;flex-wrap:wrap;gap:10px"><label class="btn primary" id="ubpick">${icon('plus')} Subir EPUB<input type="file" id="ubfile" accept=".epub,application/epub+zip" multiple hidden></label><span class="muted" style="font-size:.85rem">Solo tú puedes verlos. Máximo 100 MB por libro.</span></div>` : ''}
|
||||
${source === 'ub' ? (q ? `<h2>Resultados para “${esc(q)}”</h2>` : '') : 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="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>`}
|
||||
@@ -564,6 +574,23 @@ async function viewHome(params) {
|
||||
if (contentMode() !== 'books') loadRecs() // las recomendaciones son de manga
|
||||
}
|
||||
|
||||
$('#ubfile')?.addEventListener('change', async e => {
|
||||
const files = [...e.target.files]
|
||||
const label = $('#ubpick')
|
||||
label.classList.add('disabled')
|
||||
let added = 0
|
||||
for (const [i, f] of files.entries()) {
|
||||
toast(`Subiendo ${files.length > 1 ? `${i + 1} de ${files.length}: ` : ''}${f.name}…`)
|
||||
try {
|
||||
const r = await fetch('/api/books', { method: 'POST', headers: { 'Content-Type': 'application/epub+zip' }, body: f })
|
||||
const d = await r.json().catch(() => ({}))
|
||||
if (!r.ok) throw new Error(d.error || `Error ${r.status}`)
|
||||
added++
|
||||
} catch (err) { toast(`${f.name}: ${err.message}`) }
|
||||
}
|
||||
label.classList.remove('disabled')
|
||||
if (added) { toast(added === 1 ? 'Libro añadido' : `${added} libros añadidos`); router() }
|
||||
})
|
||||
const res = $('#results')
|
||||
if (showCats) {
|
||||
const cats = await api(`/api/categories?source=${source}`)
|
||||
@@ -574,7 +601,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' || BOOK_SOURCES.has(source) || !!tag || (source === 'mi' && mode === 'latest' && !q)
|
||||
const pageable = source === 'md' || source === 'gb' || source === 'ws' || !!tag || (source === 'mi' && mode === 'latest' && !q)
|
||||
const seen = new Set()
|
||||
let page = 1
|
||||
const load = async () => {
|
||||
@@ -582,7 +609,7 @@ async function viewHome(params) {
|
||||
const list = (await api(`/api/search?${qs}`)).filter(m => !seen.has(m.id) && seen.add(m.id))
|
||||
if (page === 1) {
|
||||
res.className = list.length ? 'grid' : 'loading'
|
||||
res.innerHTML = list.length ? '' : 'Sin resultados.'
|
||||
res.innerHTML = list.length ? '' : source === 'ub' && !q ? 'Aún no has subido ningún libro. Pulsa «Subir EPUB» para añadir el primero.' : 'Sin resultados.'
|
||||
}
|
||||
res.insertAdjacentHTML('beforeend', list.map(mangaCard).join(''))
|
||||
$('#more').hidden = !pageable || list.length < 20
|
||||
@@ -676,6 +703,7 @@ async function viewManga(id, langOverride) {
|
||||
const menuItems = [
|
||||
manga.source === 'lc' || isBook ? '' : `<a class="btn" href="${altSearch}">${icon('search')} Buscar en LeerCapitulo</a>`,
|
||||
manga.source === 'mi' || isBook ? '' : `<a class="btn" href="${altSearchMi}">${icon('search')} Buscar en M440</a>`,
|
||||
manga.source === 'ub' ? `<button id="ubdel" class="btn danger">${icon('trash')} Eliminar libro</button>` : '',
|
||||
progress || reads.length ? `<button id="drop" class="btn danger">${icon('stopRead')} Dejar de leer…</button>` : '',
|
||||
].join('')
|
||||
|
||||
@@ -708,7 +736,7 @@ async function viewManga(id, langOverride) {
|
||||
${clipCount ? `<a class="btn" href="#/clips?manga=${encodeURIComponent(id)}">${icon('scissors')} ${clipCount} recorte${clipCount > 1 ? 's' : ''}</a>` : ''}
|
||||
${menuItems}
|
||||
</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" ${isBook && !OFFLINE_OK ? 'style="display:none"' : ''}><div class="psec" id="dl" ${isBook ? 'style="display:none"' : ''}></div><div class="psec" id="hd" hidden ${isBook ? 'style="display:none"' : ''}></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>
|
||||
</aside>
|
||||
<div class="list-head">
|
||||
@@ -1048,6 +1076,20 @@ async function viewManga(id, langOverride) {
|
||||
const queued = offState.queue.filter(q => q.mangaId === id).length
|
||||
markLib('off', !!(run || queued))
|
||||
box.hidden = false
|
||||
if (isBook) {
|
||||
// Un libro se guarda entero: el texto de todos sus capítulos y sus imágenes.
|
||||
const status = mine.length ? `${icon('check')} Guardado · ${fmtSize(bytes)}` : ''
|
||||
const note = run ? `Guardando… ${run.done}/${run.total || '?'}` : queued ? 'En cola…' : mine.length ? 'Se puede leer sin internet.' : 'Guarda el libro en este móvil o PC para leerlo sin internet.'
|
||||
box.innerHTML = `${secHead('phone', 'En este dispositivo', status)}<div class="note muted">${note}</div><div class="psec-actions">
|
||||
${mine.length ? `<button class="btn small danger" id="offdel">${icon('trash')} Borrar del dispositivo</button>` : run || queued ? '' : `<button class="btn small" id="offbook">${icon('phone')} Guardar libro</button>`}</div>`
|
||||
$('#offbook')?.addEventListener('click', () => offEnqueue([{ mangaId: id, mangaTitle: manga.title, cover: manga.cover, lang: manga.languages?.[0] || 'es', chapterId: chapters[0].id, label: 'Libro completo', num: null }]))
|
||||
$('#offdel')?.addEventListener('click', async () => {
|
||||
if (!confirm(`¿Borrar «${manga.title}» de este dispositivo (${fmtSize(bytes)})?`)) return
|
||||
await offRemove(mine.map(([cid]) => cid))
|
||||
toast('Borrado del dispositivo')
|
||||
})
|
||||
return
|
||||
}
|
||||
// Siguientes 3 sin guardar a partir de donde vas (o desde el principio).
|
||||
const from = Math.max(0, chapters.findIndex(c => c.id === progress?.chapter_id))
|
||||
const nextIds = chapters.slice(from).filter(c => !offHas(c.id)).slice(0, 3)
|
||||
@@ -1136,6 +1178,15 @@ async function viewManga(id, langOverride) {
|
||||
renderFollow()
|
||||
toast(isFollowing ? 'Te avisaremos de los capítulos nuevos' : 'Ya no sigues este manga')
|
||||
})
|
||||
$('#ubdel')?.addEventListener('click', async () => {
|
||||
if (!confirm(`¿Eliminar «${manga.title}»?\nSe borrará el archivo del servidor y tu progreso de lectura.`)) return
|
||||
try {
|
||||
await api(`/api/books/${id}`, { method: 'DELETE' })
|
||||
if (OFFLINE_OK) await offRemove(Object.entries(offIdx()).filter(([, c]) => c.mangaId === id).map(([cid]) => cid))
|
||||
toast('Libro eliminado')
|
||||
location.hash = '#/?source=ub'
|
||||
} catch (err) { toast(err.message) }
|
||||
})
|
||||
$('#drop')?.addEventListener('click', async () => {
|
||||
if (!confirm(`¿Dejar de leer «${manga.title}»?\nSe quitará del historial, se borrará el progreso de sus capítulos y dejarás de seguirlo.`)) return
|
||||
await api(`/api/history/${id}`, { method: 'DELETE' })
|
||||
@@ -1762,7 +1813,12 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
const globalSettings = { saver: false, filter: 'none', brightness: 100, anim: reduceMotion ? 'fade' : 'flip', sfx: 'mid', ...store.get('readerSettings', {}) }
|
||||
const [info, chap] = await Promise.all([
|
||||
api(`/api/manga/${mangaId}?lang=${encodeURIComponent(lang)}`),
|
||||
api(`/api/chapter/${chapterId}?saver=${globalSettings.saver ? 1 : 0}&hd=${globalSettings.hd === false ? 0 : 1}`),
|
||||
api(`/api/chapter/${chapterId}?saver=${globalSettings.saver ? 1 : 0}&hd=${globalSettings.hd === false ? 0 : 1}`).catch(err => {
|
||||
// Un libro guardado se guarda entero: sin conexión, cualquier capítulo abre con el texto del dispositivo.
|
||||
if (!isBookId(mangaId) || !OFFLINE_OK || !Object.values(offIdx()).some(c => c.mangaId === mangaId)) throw err
|
||||
const pos = store.get(`bookPos:${mangaId}`)
|
||||
return { text: true, pages: [], savedPage: pos?.chapterId === chapterId ? pos.page : 0, savedTotal: pos?.chapterId === chapterId ? pos.totalPages : 0 }
|
||||
}),
|
||||
])
|
||||
const { manga, chapters } = info
|
||||
const settings = { ...globalSettings, ...defaultPrefs(manga), ...(info.prefs || {}) }
|
||||
@@ -1822,7 +1878,7 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
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"
|
||||
})
|
||||
const saveNow = () => { clearTimeout(saveTimer); saveTimer = null; const p = payload(); api('/api/progress', { method: 'POST', body: p }).catch(() => queueProgress(p)) }
|
||||
const saveNow = () => { clearTimeout(saveTimer); saveTimer = null; const p = payload(); if (isBook) store.set(`bookPos:${mangaId}`, { chapterId: p.chapterId, page: p.page, totalPages: p.totalPages }); api('/api/progress', { method: 'POST', body: p }).catch(() => queueProgress(p)) }
|
||||
const scheduleSave = () => { clearTimeout(saveTimer); saveTimer = setTimeout(saveNow, 700) }
|
||||
const beacon = () => { if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; navigator.onLine ? navigator.sendBeacon('/api/progress', JSON.stringify(payload())) : queueProgress(payload()) } }
|
||||
const onVis = () => document.visibilityState === 'hidden' && beacon()
|
||||
|
||||
+1
-1
@@ -74,7 +74,7 @@ self.addEventListener('fetch', e => {
|
||||
if (p.startsWith('/api/manga/')) return e.respondWith(networkFirst(req, OFFLINE, { store: 'existing', ignoreSearch: true }))
|
||||
if (p.startsWith('/api/chapter/')) return e.respondWith(networkFirst(req, OFFLINE, { store: false, ignoreSearch: true }))
|
||||
// Imágenes: si están guardadas, sin red (más rápido y sin gastar datos).
|
||||
if (p.startsWith('/api/local/') || p.startsWith('/api/img') || p.startsWith('/api/local-cover/')) {
|
||||
if (p.startsWith('/api/local/') || p.startsWith('/api/img') || p.startsWith('/api/local-cover/') || p.startsWith('/api/book/')) {
|
||||
e.respondWith(caches.open(OFFLINE).then(c => c.match(req)).then(hit => hit || fetch(req)))
|
||||
}
|
||||
})
|
||||
|
||||
+18
-6
@@ -459,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' : id.startsWith('gb~') ? 'gb' : 'md')
|
||||
const sourceOf = id => (id.startsWith('lc~') ? 'lc' : id.startsWith('mi~') ? 'mi' : /^(gb|ws|ub)~/.test(id) ? id.slice(0, 2) : '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.
|
||||
@@ -542,7 +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)
|
||||
: isBookId(mangaId) ? books.manga(mangaId)
|
||||
: { manga: await getManga(mangaId, lang), chapters: await getChapters(mangaId, lang) }
|
||||
}
|
||||
|
||||
@@ -781,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._-]+|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 MANGA_ID = /^([0-9a-f-]{36}|lc~[a-z0-9]+~[a-z0-9-]+|mi~[a-z0-9._-]+|gb~\d{1,7}|ws~[A-Za-z0-9_-]{1,400}|ub~[0-9a-f]{32})$/
|
||||
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}|ws~[A-Za-z0-9_-]{1,400}~\d{1,5}|ub~[0-9a-f]{32}~\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 = ?
|
||||
@@ -873,6 +873,10 @@ async function route(req, res) {
|
||||
|
||||
// Todo lo demás requiere sesión.
|
||||
const user = requireUser(req)
|
||||
// Un libro subido solo existe para quien lo subió, en cualquier ruta y parámetro que lo nombre.
|
||||
let rawUrl = req.url
|
||||
try { rawUrl = decodeURIComponent(req.url) } catch {}
|
||||
for (const [ub] of rawUrl.matchAll(/ub~[0-9a-f]{32}/g)) if (!books.owns(user.id, ub)) throw new HttpError(404, 'No encontrado')
|
||||
if (p.startsWith('/api/bans') && security.route(p, method, req, res, json) !== false) return
|
||||
if ((await features.route(p, method, q, req, res, user)) !== false) return
|
||||
if ((await upscale.route(p, method, req, res)) !== false) {
|
||||
@@ -927,12 +931,14 @@ 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' || q.get('source') === 'gb' ? [] : await mdCategories())
|
||||
return json(res, 200, !net.online() ? [] : q.get('source') === 'lc' ? await lcCategories() : ['mi', 'gb', 'ws', 'ub'].includes(q.get('source')) ? [] : 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 (q.get('source') === 'ub') return json(res, 200, books.ubList(user.id, (q.get('q') || '').slice(0, 100))) // no necesita internet
|
||||
if (!net.online()) return json(res, 200, localSearch(q.get('q') || ''))
|
||||
if (q.get('source') === 'ws') return json(res, 200, await books.wsSearch((q.get('q') || '').slice(0, 100), q.get('mode'), page))
|
||||
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))
|
||||
@@ -962,6 +968,12 @@ async function route(req, res) {
|
||||
const chapterLangs = chapters.length || manga.source !== 'md' || offline ? [] : await getChapterLangs(m[1]).catch(() => [])
|
||||
return json(res, 200, { manga, chapters, lang, reads, progress, chapterLangs, offline, downloaded, download: downloadInfo(m[1], true), upscale: upscale.info(m[1]), clips: clips.count(user.id, m[1]), lists: library.listsWith(user.id, m[1]), info: isBookId(m[1]) ? null : meta.cachedInfo(m[1]), clipBanner: clips.bannerFor(user.id, m[1]), ...features.mangaExtras(user.id, manga) })
|
||||
}
|
||||
if (p === '/api/books' && method === 'POST') return json(res, 200, await books.upload(req, user))
|
||||
if ((m = /^\/api\/books\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && m[1].startsWith('ub~') && method === 'DELETE') {
|
||||
books.removeUpload(m[1], user.id)
|
||||
return json(res, 200, { ok: true })
|
||||
}
|
||||
if ((m = /^\/api\/book\/([^/]+)\/cover$/.exec(p)) && MANGA_ID.test(m[1]) && isBookId(m[1]) && method === 'GET') return books.sendCover(res, m[1])
|
||||
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') {
|
||||
@@ -1096,7 +1108,7 @@ async function route(req, res) {
|
||||
}
|
||||
|
||||
const security = setupSecurity({ db })
|
||||
const books = setupBooks({ DATA_DIR, cached, HttpError, coverUrl, UA, net, safeFetch })
|
||||
const books = setupBooks({ db, 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 })
|
||||
|
||||
Reference in New Issue
Block a user