Author SHA1 Message Date
ninoandClaude Opus 5.5 311b548410 Quitar el laboratorio del giroscopio
Ya sirvió para calibrar createTilt con grabaciones del iPad mini 6 y el Pixel 10: fuera la página #/gyrolab, su botón en
Ajustes y la ruta /api/gyrolab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 21:12:38 -06:00
ninoandClaude Opus 5.5 8ae3594803 Estante 3D: entradas, orden A–Z, sin conexión, pósit y giroscopio calibrado
- Estante: entradas desde el menú, la librería y la ficha; orden reciente o A–Z; foto del estante, arte de lomos y portadas
  guardados en el dispositivo; sin conexión muestra solo lo guardado.
- Notas de 40 letras sin cortar palabras, lomo curvo, sombra en las portadas de frente y lomos con logo sin el título.
- Pósit cuadrado con el capítulo y la página por la que va el usuario; paso al lector sin pantalla de carga.
- Giroscopio: integra rotationRate (sin los saltos de β/γ con el dispositivo vertical), tope suave, menos sensibilidad al
  caminar, lado en horizontal según la gravedad, suavizado por tiempo y más rango en el estante y el libro.
- Laboratorio del giroscopio en Ajustes (temporal, solo desarrollo) para grabar los sensores.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 21:08:51 -06:00
nino 8b82cbbfdb Configuración de ejemplo genérica y datos internos fuera del repo 2026-11-06 09:28:26 -06:00
nino bda8800a89 Estante 3D: mueble, lomos, logos y vista previa de tomos 2026-11-06 09:28:23 -06:00
ninoandClaude Opus 5.5 66502409fe Modo libro 3D, tomos con cubiertas reales y ocultar créditos
Libro 3D (app/public/book3d.js): lector WebGL2 con mesa de madera, cubierta,
hojas que giran, cámara real y giroscopio, ahora con más recorrido alrededor
del libro.

Tomos (app/volumes.js): agrupa los capítulos en un solo libro con portada y
contraportada reales. El reparto sale de Wikipedia, MangaDex, M440 y
MangaUpdates, contrastados entre sí. Lo que ninguna fuente sitúa se estima, de
modo que solo quedan sueltos los capítulos del final, y los fuera de orden se
recolocan. Manhwa y manhua no tienen tomos.

Ocultar créditos (app/credits.js): botón por obra en la ficha. Al leer por
tomos oculta, sin borrarlas, la página cero y las dos últimas de cada capítulo
si se repiten en otros capítulos de la obra.

HANDOFF-LIBRO3D.md recoge el estado, las decisiones y las pruebas.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 09:28:15 -06:00
ninoandClaude Opus 5.5 bb1f3ff8e4 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>
2026-11-02 22:00:44 -06:00
ninoandClaude Opus 5.5 be19e35156 Libros de texto: fuente Project Gutenberg y lectura continua
Los EPUB se convierten en el servidor en una lista cerrada de bloques y el
navegador los maqueta en páginas según su pantalla. Cada página se dibuja en
un canvas y llega al lector como una imagen más, así que hoja, sombra,
hendidura y sonido no cambian.

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 21:02:29 -06:00
ninoandClaude Opus 5.5 5776389ebb La vista de una página funciona como un libro abierto
Entre las dos caras de una pareja la vista se recorre, con las páginas unidas
por el lomo. Entre parejas gira la hoja, con la página nueva en el reverso, la
siguiente debajo y la vista acompañando al giro. La hendidura alterna de lado
según la cara, y las páginas anchas ya no la llevan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 17:05:18 -06:00
ninoandClaude Opus 5.5 eabf82ad90 La hendidura del lomo también con el ajuste a lo ancho
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 16:44:40 -06:00
ninoandClaude Opus 5.5 a80391f382 Hendidura del lomo en las páginas y en la hoja que gira
En doble página va en el centro, en una página en el lado del lomo según el
sentido de lectura y en una página ancha en su centro.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 16:35:13 -06:00
ninoandClaude Opus 5.5 a6f01b0e54 La hoja proyecta su sombra sobre el papel, con luz de frente
La sombra sigue la forma de la hoja y su borde se ensancha con la altura. Sin
WebGL se mantiene el degradado desde el lomo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 16:20:11 -06:00
29 changed files with 5895 additions and 151 deletions
+3 -3
View File
@@ -5,14 +5,14 @@ PUID=1000
PGID=1000
# Dónde guardar los mangas descargados en el host (por defecto ./mangas dentro del proyecto).
MANGA_DIR_HOST=/zdata/media/mangas
MANGA_DIR_HOST=./mangas
# Interfaz y puerto publicados. 0.0.0.0 = todas.
BIND_IP=0.0.0.0
HOST_PORT=3352
# URL de la web que se usa en los enlaces de las notificaciones.
PUBLIC_URL=http://192.168.1.141:3352
PUBLIC_URL=http://localhost:3352
# Registro de cuentas nuevas (con la base vacía, el primer usuario siempre puede registrarse).
ALLOW_SIGNUP=false
@@ -20,7 +20,7 @@ ALLOW_SIGNUP=false
# Grupo "render" del host, para usar la GPU en el reescalado HD (getent group render | cut -d: -f3).
RENDER_GID=992
TZ=America/El_Salvador
TZ=UTC
CHECK_INTERVAL_HOURS=3
# --- Exponer a internet ---
+7
View File
@@ -4,7 +4,14 @@ data/*
# Descargas cuando se usan dentro del proyecto (MANGA_DIR_HOST por defecto)
mangas/*
!mangas/.keep
# Configuración real de cada servidor (la plantilla sí se versiona)
.env
.env.*
!.env.example
node_modules/
# Documentos internos de traspaso (se guardan solo en el servidor)
HANDOFF-LIBRO3D.md
# Basura del sistema y registros
*.log
.DS_Store
Thumbs.db
+16 -2
View File
@@ -17,7 +17,7 @@ Lector web de manga autoalojado, con las fuentes de
- Usuarios con contraseña; el **historial y la página exacta** se guardan en el servidor
(`data/manga.db`), así que puedes seguir desde cualquier dispositivo/sesión.
- **Descargas**: botón "⬇ Descargar" en la ficha. Baja el manga entero en segundo plano a
`/zdata/media/mangas/<Título>/Capitulo NNNN.cbz` (+ `cover.jpg`, `ComicInfo.xml` en cada CBZ),
`./mangas/<Título>/Capitulo NNNN.cbz` (+ `cover.jpg`, `ComicInfo.xml` en cada CBZ),
compatible con Kavita/Komga/Mihon. Se reanuda tras reinicios; repetirla baja solo los capítulos
nuevos. Los capítulos descargados se leen desde disco y, sin conexión, la ficha usa los datos guardados.
- **Novedades**: los mangas que empiezas a leer (o marcas con "Seguir") se revisan cada
@@ -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
@@ -144,7 +158,7 @@ responde (`app/connectivity.js`).
| Código | este repositorio | se recrea con `git clone` |
| `.env` | raíz del proyecto (no está en git) | rutas, puerto, PUID/PGID, URL pública |
| `data/` | raíz del proyecto (no está en git) | `manga.db` (+ `-wal`/`-shm`): usuarios, historial, estadísticas, seguimiento, ajustes; `secret`: clave del proxy de imágenes; `clips/`: recortes; `covers/`: portadas HD (caché) |
| Mangas descargados | `MANGA_DIR_HOST` (aquí `/zdata/media/mangas`) | CBZ + portadas |
| Mangas descargados | `MANGA_DIR_HOST` (por defecto `./mangas`) | CBZ + portadas |
Pasos en el sistema nuevo:
+603
View File
@@ -0,0 +1,603 @@
// 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)
// 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 = 3
const MAX_EPUB_BYTES = 100 * 1024 * 1024
const MAX_UNZIPPED_ENTRY = 64 * 1024 * 1024 // un XHTML o una imagen
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: '×' }
const entities = s => s.replace(/&(#x?[0-9a-f]+|[a-z0-9]+);/gi, (all, e) => {
if (e[0] !== '#') return ENTITIES[e] ?? all
const n = e[1].toLowerCase() === 'x' ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10)
return n > 0 && n <= 0x10ffff ? String.fromCodePoint(n) : ''
})
const attr = (attrs, name) => {
const m = new RegExp(`(?:^|\\s)${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, 'i').exec(attrs)
return m ? entities(m[1] ?? m[2]) : null
}
// Ruta dentro del ZIP de un enlace relativo a un archivo del libro.
function resolveHref(base, href) {
let rel = href.split('#')[0]
try { rel = decodeURIComponent(rel) } catch {}
return path.posix.normalize(path.posix.join(path.posix.dirname(base), rel)).replace(/^\.\//, '')
}
// ---------- XHTML a bloques ----------
const SKIP = new Set(['head', 'script', 'style', 'template', 'noscript', 'iframe', 'object', 'embed', 'audio', 'video', 'math', 'select', 'form', 'nav'])
const BLOCK = new Set(['p', 'div', 'section', 'article', 'aside', 'header', 'footer', 'figure', 'figcaption', 'main', 'body', 'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'table', 'thead', 'tbody', 'tfoot', 'tr', 'caption', 'pre', 'address', 'center', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote'])
const VOID = new Set(['br', 'hr', 'img', 'image', 'meta', 'link', 'input', 'col', 'area', 'base', 'wbr'])
const TOKEN = /<!--[\s\S]*?-->|<!\[CDATA\[[\s\S]*?\]\]>|<[?!][^>]*>|<(\/?)([a-zA-Z][a-zA-Z0-9:_-]*)((?:[^<>"']|"[^"]*"|'[^']*')*)>/g
// onAnchor(id) se llama con cada id del documento; devuelve true si ahí empieza un capítulo.
function xhtmlToBlocks(xml, file, imageOf, onBlock, onAnchor) {
let skip = 0, skipTag = null
let italic = 0, bold = 0, quote = 0, center = 0, verse = 0
const stack = [] // etiquetas de bloque con estilo, para deshacerlo al cerrarlas
let runs = [], heading = 0
const flush = () => {
// Espacios del código fuente fuera; los saltos de línea reales (<br>) son \n.
const out = []
let atStart = true
for (const [text, style] of runs) {
let t = text === '\n' ? text : text.replace(/[ \t\r\n\f]+/g, ' ') // el \n solo viene de un <br>
if (atStart) t = t.replace(/^ +/, '')
if (!t) continue
atStart = t.endsWith('\n')
const last = out.at(-1)
if (last && last[1] === style) last[0] += t
else out.push([t, style])
}
while (out.length) {
const last = out.at(-1)
last[0] = last[0].replace(/[ \n]+$/, '')
if (last[0]) break
out.pop()
}
runs = []
if (out.length && out.some(([t]) => t.trim())) {
const b = heading ? { k: 'h', l: heading, r: out } : { k: quote ? 'q' : 'p', r: out }
if (center || heading) b.c = 1
if (verse || out.some(([t]) => t.includes('\n'))) b.v = 1
onBlock(b)
}
}
const body = xml.search(/<body[\s>]/i)
let pos = body >= 0 ? body : 0
TOKEN.lastIndex = pos
for (let m; (m = TOKEN.exec(xml));) {
if (!skip && m.index > pos) {
const text = entities(xml.slice(pos, m.index))
if (text) runs.push([text, (italic ? 1 : 0) + (bold ? 2 : 0)])
}
pos = TOKEN.lastIndex
const tag = m[2]?.toLowerCase().replace(/^.*:/, '')
if (!tag) { // comentario, DOCTYPE; CDATA se pierde a propósito (no es texto de libro)
continue
}
const closing = m[1] === '/'
const rawAttrs = m[3] || ''
const selfClosed = /\/\s*$/.test(rawAttrs)
if (skip) {
if (tag === skipTag) {
if (closing) { if (--skip === 0) skipTag = null } else if (!selfClosed) skip++
}
continue
}
if (closing) {
if (BLOCK.has(tag)) {
flush()
const top = stack.findLastIndex(s => s.tag === tag)
if (top >= 0) {
for (const s of stack.splice(top)) {
quote -= s.quote; center -= s.center; verse -= s.verse; italic -= s.italic; bold -= s.bold
if (s.heading) heading = 0
}
}
} else if (tag === 'i' || tag === 'em' || tag === 'cite' || tag === 'dfn') italic = Math.max(0, italic - 1)
else if (tag === 'b' || tag === 'strong') bold = Math.max(0, bold - 1)
continue
}
const id = attr(rawAttrs, 'id') ?? (tag === 'a' ? attr(rawAttrs, 'name') : null)
if (id && onAnchor(id, flush)) { /* el capítulo nuevo ya vació lo pendiente */ }
const cls = (attr(rawAttrs, 'class') || '').toLowerCase()
const type = (attr(rawAttrs, 'epub:type') || '').toLowerCase()
// Números de página de la edición impresa: ruido entre el texto.
if (SKIP.has(tag) || /\bpage-?num(ber)?\b|\bpageno\b/.test(cls) || type.includes('pagebreak') || attr(rawAttrs, 'hidden') !== null) {
if (!selfClosed && !VOID.has(tag)) { skip = 1; skipTag = tag }
continue
}
if (tag === 'br') { runs.push(['\n', 0]); continue }
if (tag === 'hr') { flush(); onBlock({ k: 'hr' }); continue }
if (tag === 'img' || tag === 'image') {
const src = attr(rawAttrs, 'src') ?? attr(rawAttrs, 'xlink:href') ?? attr(rawAttrs, 'href')
const n = src != null && !/^[a-z]+:/i.test(src) ? imageOf(resolveHref(file, src)) : null
if (n != null) { flush(); onBlock({ k: 'img', n }) }
continue
}
if (BLOCK.has(tag)) {
flush()
const style = attr(rawAttrs, 'style') || ''
const s = {
tag,
quote: tag === 'blockquote' ? 1 : 0,
center: tag === 'center' || /\bcent(er|re)\b/.test(cls) || /text-align\s*:\s*center/i.test(style) ? 1 : 0,
verse: tag === 'pre' || /\b(verse|poem|poetry|stanza|line-?block)\b/.test(cls) ? 1 : 0,
italic: 0, bold: 0, heading: 0,
}
if (/^h[1-6]$/.test(tag)) { heading = Number(tag[1]); s.heading = 1 }
quote += s.quote; center += s.center; verse += s.verse
if (!selfClosed) stack.push(s)
else { quote -= s.quote; center -= s.center; verse -= s.verse; if (s.heading) heading = 0 }
continue
}
if (selfClosed) continue
if (tag === 'i' || tag === 'em' || tag === 'cite' || tag === 'dfn') italic++
else if (tag === 'b' || tag === 'strong') bold++
else if (tag === 'td' || tag === 'th') { if (runs.length) runs.push([' ', 0]) }
}
if (!skip && pos < xml.length) {
const text = entities(xml.slice(pos).replace(/<[^>]*$/, ''))
if (text.trim()) runs.push([text, 0])
}
flush()
}
// Ancho y alto de una imagen PNG, GIF, JPEG o WebP, por su cabecera.
function imageSize(b) {
if (b.length > 24 && b.readUInt32BE(0) === 0x89504e47) return { w: b.readUInt32BE(16), h: b.readUInt32BE(20) }
if (b.length > 10 && b.toString('latin1', 0, 3) === 'GIF') return { w: b.readUInt16LE(6), h: b.readUInt16LE(8) }
if (b.length > 4 && b[0] === 0xff && b[1] === 0xd8) {
for (let p = 2; p + 9 < b.length;) {
if (b[p] !== 0xff) { p++; continue }
const m = b[p + 1]
if (m >= 0xc0 && m <= 0xcf && m !== 0xc4 && m !== 0xc8 && m !== 0xcc) return { w: b.readUInt16BE(p + 7), h: b.readUInt16BE(p + 5) }
if (m === 0xff) { p++; continue }
p += 2 + b.readUInt16BE(p + 2)
}
}
if (b.length > 30 && b.toString('latin1', 0, 4) === 'RIFF' && b.toString('latin1', 8, 12) === 'WEBP') {
const kind = b.toString('latin1', 12, 16)
if (kind === 'VP8X') return { w: b.readUIntLE(24, 3) + 1, h: b.readUIntLE(27, 3) + 1 }
if (kind === 'VP8 ') return { w: b.readUInt16LE(26) & 0x3fff, h: b.readUInt16LE(28) & 0x3fff }
if (kind === 'VP8L') { const v = b.readUInt32LE(21); return { w: (v & 0x3fff) + 1, h: ((v >> 14) & 0x3fff) + 1 } }
}
return {}
}
// ---------- EPUB ----------
// Índice del libro en orden: [{ file, anchor, title }]. EPUB 3 (nav) o EPUB 2 (toc.ncx).
function readToc(read, opf, manifest) {
const navItem = [...manifest.values()].find(it => /\bnav\b/.test(it.props))
if (navItem) {
const xml = read(navItem.path)
const nav = /<nav\b[^>]*(?:epub:)?type\s*=\s*["']toc["'][^>]*>([\s\S]*?)<\/nav>/i.exec(xml)?.[1]
if (nav) {
const out = []
for (const m of nav.matchAll(/<a\b([^>]*)>([\s\S]*?)<\/a>/gi)) {
const href = attr(m[1], 'href')
if (!href) continue
out.push({ file: resolveHref(navItem.path, href), anchor: href.split('#')[1] || null, title: clean(m[2]) })
}
if (out.length) return out
}
}
const spineToc = /<spine\b[^>]*\btoc\s*=\s*["']([^"']+)["']/i.exec(opf)?.[1]
const ncx = manifest.get(spineToc) || [...manifest.values()].find(it => /ncx/.test(it.type))
if (!ncx) return []
const xml = read(ncx.path)
const out = []
// Solo la etiqueta y el contenido propios de cada entrada: los navPoint se anidan.
for (const m of xml.matchAll(/<navPoint\b[^>]*>\s*<navLabel\b[^>]*>\s*<text\b[^>]*>([\s\S]*?)<\/text>\s*<\/navLabel>\s*<content\b([^>]*)\/?>/gi)) {
const src = attr(m[2], 'src')
if (src) out.push({ file: resolveHref(ncx.path, src), anchor: src.split('#')[1] || null, title: clean(m[1]) })
}
return out
}
const clean = s => entities(String(s).replace(/<[^>]*>/g, ' ')).replace(/\s+/g, ' ').trim()
export function parseEpub(file) {
const index = readZipIndex(file)
const entries = new Map(index.map(e => [e.name, e]))
if (index.reduce((n, e) => n + e.unzipped, 0) > MAX_UNZIPPED_TOTAL) throw new Error('EPUB demasiado grande al descomprimir')
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')
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(/^/, '')
const container = read('META-INF/container.xml')
const opfPath = attr(/<rootfile\b([^>]*)>/i.exec(container)?.[1] || '', 'full-path')
if (!opfPath) throw new Error('EPUB sin paquete')
const opf = read(opfPath)
const dc = name => clean(new RegExp(`<dc:${name}\\b[^>]*>([\\s\\S]*?)</dc:${name}>`, 'i').exec(opf)?.[1] || '')
const manifest = new Map()
for (const m of opf.matchAll(/<item\b([^>]*)>/gi)) {
const id = attr(m[1], 'id'), href = attr(m[1], 'href')
if (id && href) manifest.set(id, { path: resolveHref(opfPath, href), type: attr(m[1], 'media-type') || '', props: attr(m[1], 'properties') || '' })
}
const spine = [...opf.matchAll(/<itemref\b([^>]*)>/gi)].map(m => manifest.get(attr(m[1], 'idref'))).filter(it => it && /x?html/i.test(it.type))
if (!spine.length) throw new Error('EPUB sin contenido')
let toc = []
try { toc = readToc(read, opf, manifest) } catch {}
const images = []
const imageIdx = new Map()
const imageOf = name => {
const e = entries.get(name)
if (!e || !BOOK_IMAGE.test(name) || e.unzipped > MAX_UNZIPPED_ENTRY) return null
if (!imageIdx.has(name)) { imageIdx.set(name, images.length); images.push(name) }
return imageIdx.get(name)
}
// Las entradas del índice que apuntan a cada archivo. Un capítulo empieza en su ancla, o en el archivo si no tiene.
const targets = new Map()
for (const t of toc) {
if (!targets.has(t.file)) targets.set(t.file, new Map())
targets.get(t.file).set(t.anchor, t.title)
}
const chapters = []
const start = title => chapters.push({ title, blocks: [] })
for (const item of spine) {
const here = targets.get(item.path)
if (here?.has(null) || (!toc.length)) start(here?.get(null) || '')
if (!chapters.length) start('')
xhtmlToBlocks(read(item.path), item.path, imageOf,
b => chapters.at(-1).blocks.push(b),
(id, flush) => {
if (!here?.has(id)) return false
flush()
start(here.get(id))
here.delete(id) // un id repetido no abre otro capítulo
return true
})
}
// Limpieza: fuera los vacíos; un capítulo que solo trae su título ("Parte primera") se une al siguiente.
const merged = []
let carry = null
for (const c of chapters) {
if (!c.blocks.length) continue
const blocks = carry ? [...carry.blocks, ...c.blocks] : c.blocks
const title = c.title || carry?.title || ''
carry = null
if (blocks.every(b => b.k === 'h' || b.k === 'hr') && c !== chapters.at(-1)) { carry = { title, blocks }; continue }
merged.push({ title, blocks })
}
if (carry) merged.push(carry)
const out = merged
if (!out.length) throw new Error('EPUB sin texto')
// 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) {
for (const b of c.blocks) {
if (b.k !== 'img') continue
try { Object.assign(b, imageSize(readBuf(images[b.n]))) } catch {}
}
}
const year = parseInt(dc('date'), 10)
return {
version: PARSER_VERSION,
title: dc('title'), authors: [...opf.matchAll(/<dc:creator\b[^>]*>([\s\S]*?)<\/dc:creator>/gi)].map(m => clean(m[1])).filter(Boolean),
language: dc('language').slice(0, 2).toLowerCase(), description: dc('description'), year: year > 0 && year < 2100 ? year : null,
images, cover, chapters: out,
}
}
// ---------- Fuente y caché ----------
// 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 => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&apos;' }[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 })
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 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 ${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(`${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))
}
}
const coverOf = (n, id) => coverUrl(`${GUTENBERG}/cache/epub/${n}/pg${n}.cover.medium.jpg`, id)
// "Cervantes Saavedra, Miguel de" -> "Miguel de Cervantes Saavedra"
const authorName = a => a.split(', ').reverse().join(' ')
const entryText = (xml, tag) => clean(new RegExp(`<${tag}\\b[^>]*>([\\s\\S]*?)</${tag}>`).exec(xml)?.[1] || '')
function parseFeed(xml) {
const out = []
for (const [entry] of xml.matchAll(/<entry>[\s\S]*?<\/entry>/g)) {
const n = /<id>[^<]*\/ebooks\/(\d+)\.opds<\/id>/.exec(entry)?.[1]
if (!n) continue // "Ordenar por…" y otras entradas de navegación
const id = `gb~${n}`
// El buscador añade el idioma al título: "Las Fábulas de Esopo (Spanish)".
const title = entryText(entry, 'title').replace(/\s*\([A-Z][a-z]+\)$/, '')
out.push({ id, source: 'gb', title, cover: coverOf(n, id), sub: entryText(entry, 'content') || undefined })
}
return out
}
// lang del usuario ("es-la") a idioma del catálogo ("spanish").
async function search(q, mode, page, lang) {
const name = LANG_NAMES[String(lang || 'es').slice(0, 2)]
const text = q.trim().replace(/\s+/g, ' ')
const qs = new URLSearchParams({ query: [text, name && `l.${name}`].filter(Boolean).join(' '), sort_order: mode === 'latest' && !text ? 'release_date' : 'downloads' })
const start = (Math.max(1, page | 0) - 1) * OPDS_PAGE + 1
if (start > 1) qs.set('start_index', String(start))
return cached(`gb:${qs}`, 30 * 60e3, async () => parseFeed(await (await 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.
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) {
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')
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)
const source = id.slice(0, 2)
return {
manga: {
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: 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 })),
}
}
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)
}
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 }
}
+16 -8
View File
@@ -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,10 +71,11 @@ 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)
if (cdSize > size) throw new Error('ZIP inválido')
const cd = Buffer.alloc(cdSize)
fs.readSync(fd, cd, 0, cdSize, cdOffset)
const entries = []
@@ -76,18 +83,19 @@ 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)
if (compSize > size) throw new Error('ZIP inválido')
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 +106,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.
+92
View File
@@ -0,0 +1,92 @@
// Créditos de los traductores: cada grupo repite casi la misma página en sus capítulos, y las de historia no se repiten.
// El navegador resume la página 0 y las 2 últimas de cada capítulo (el servidor no sabe decodificar imágenes) y aquí se
// comparan con las del resto de la obra. Solo se ocultan al leer por tomos, y nunca se borra nada.
const SIDE = 16
const SIG_BYTES = SIDE * SIDE
const SAME = 0.03 // diferencia media: los créditos quedan por debajo del 2% y la historia muy por encima
const SAME_SHAPE = 0.03
const MIN_PAGES = 5
const STRIP = 0.45 // tira larga: los créditos vienen pegados a la historia y no se puede quitar uno sin la otra
const PLAIN = 0.03 // página casi lisa (en blanco, negra): no son créditos, aunque se repitan
const decode = s => Buffer.from(String(s || ''), 'base64')
const spread = b => {
const mean = b.reduce((a, x) => a + x, 0) / b.length
return Math.sqrt(b.reduce((a, x) => a + (x - mean) ** 2, 0) / b.length) / 255
}
const difference = (a, b) => {
let sum = 0
for (let i = 0; i < SIG_BYTES; i++) sum += Math.abs(a[i] - b[i])
return sum / SIG_BYTES / 255
}
/** Qué páginas repetidas tiene cada capítulo pedido. rows: [{ id, n, pages: { índice: { s, ar } } }], de toda la obra. */
export function repeatedPages(rows, ids) {
const all = rows.flatMap(r => Object.entries(r.pages).map(([i, p]) => ({ id: r.id, i: Number(i), ar: p.ar, sig: decode(p.s) })))
.filter(p => p.sig.length === SIG_BYTES)
const out = {}
for (const r of rows) {
if (!ids.includes(r.id) || r.n < MIN_PAGES) continue
const hidden = []
for (const p of all) {
if (p.id !== r.id || p.ar < STRIP || spread(p.sig) < PLAIN) continue
if (![0, r.n - 2, r.n - 1].includes(p.i)) continue
if (all.some(o => o.id !== r.id && Math.abs(o.ar - p.ar) < SAME_SHAPE && difference(o.sig, p.sig) < SAME)) hidden.push(p.i)
}
if (hidden.length) out[r.id] = hidden.sort((a, b) => a - b)
}
return out
}
export function setupCredits({ db, HttpError, json, readBody, MANGA_ID, CHAPTER_ID }) {
db.exec(`
CREATE TABLE IF NOT EXISTS page_sigs (
manga_id TEXT NOT NULL,
chapter_id TEXT NOT NULL,
n INTEGER NOT NULL,
pages TEXT NOT NULL,
PRIMARY KEY (manga_id, chapter_id)
);
CREATE TABLE IF NOT EXISTS hide_credits (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
manga_id TEXT NOT NULL,
PRIMARY KEY (user_id, manga_id)
);`)
const enabled = (userId, mangaId) => !!db.prepare('SELECT 1 FROM hide_credits WHERE user_id = ? AND manga_id = ?').get(userId, mangaId)
function validPages(pages, n) {
const entries = Object.entries(pages || {})
if (!entries.length || entries.length > 3) return false
return entries.every(([i, p]) => [0, n - 2, n - 1].includes(Number(i)) && typeof p?.ar === 'number' && p.ar > 0 && decode(p.s).length === SIG_BYTES)
}
async function route(p, method, req, res, user) {
let m
if ((m = /^\/api\/credits\/([^/]+)\/option$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'POST') {
const { on } = await readBody(req)
if (on) db.prepare('INSERT OR IGNORE INTO hide_credits (user_id, manga_id) VALUES (?, ?)').run(user.id, m[1])
else db.prepare('DELETE FROM hide_credits WHERE user_id = ? AND manga_id = ?').run(user.id, m[1])
json(res, 200, { hideCredits: !!on })
return true
}
if ((m = /^\/api\/credits\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'POST') {
const body = await readBody(req)
const wanted = Array.isArray(body.chapters) ? body.chapters.slice(0, 60) : []
if (!wanted.every(c => CHAPTER_ID.test(c?.id || '') && Number.isInteger(c?.n) && c.n > 0 && c.n < 100000)) throw new HttpError(400, 'Capítulos inválidos')
const save = db.prepare('INSERT OR REPLACE INTO page_sigs (manga_id, chapter_id, n, pages) VALUES (?, ?, ?, ?)')
for (const c of wanted) {
const pages = body.sigs?.[c.id]
if (pages && validPages(pages, c.n)) save.run(m[1], c.id, c.n, JSON.stringify(pages))
}
const rows = db.prepare('SELECT chapter_id AS id, n, pages FROM page_sigs WHERE manga_id = ?').all(m[1]).map(r => ({ id: r.id, n: r.n, pages: JSON.parse(r.pages) }))
const known = new Map(rows.map(r => [r.id, r.n]))
// Un capítulo vuelto a subir puede tener otras páginas: su resumen ya no vale.
const missing = wanted.filter(c => known.get(c.id) !== c.n).map(c => c.id)
const current = rows.filter(r => wanted.every(c => c.id !== r.id || c.n === r.n))
json(res, 200, { missing, hidden: repeatedPages(current, wanted.map(c => c.id).filter(id => !missing.includes(id))) })
return true
}
return false
}
return { route, enabled }
}
+2 -1
View File
@@ -1,7 +1,7 @@
// Seguimiento de capítulos nuevos (+ notificaciones ntfy/Telegram y autodescarga),
// preferencias del lector por manga y estadísticas de lectura.
const CHECK_HOURS = Number(process.env.CHECK_INTERVAL_HOURS || 3)
const PUBLIC_URL = (process.env.PUBLIC_URL || 'http://192.168.1.141:3352').replace(/\/+$/, '')
const PUBLIC_URL = (process.env.PUBLIC_URL || 'http://localhost:3352').replace(/\/+$/, '')
// Día local (TZ del contenedor) en formato YYYY-MM-DD.
export const dayOf = ts => new Date(ts).toLocaleDateString('sv-SE')
@@ -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,
+27 -1
View File
@@ -12,6 +12,7 @@
// Todo se guarda en la base de datos, una vez por manga y para todos los usuarios: lo guardado se sirve sin
// consultar nada y, si es antiguo, se actualiza en segundo plano (si una fuente falla, se queda lo que había).
// Los mangas seguidos por alguien se mantienen siempre al día, aunque nadie abra su ficha.
import { parseMuVolumes, workTitle } from './volumes.js'
const DAY = 24 * 3600e3
const INFO_TTL = v => (v?.found ? 30 * DAY : 3 * DAY)
const SYNC_EVERY = 3600e3
@@ -119,6 +120,31 @@ export function setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, n
}, wait)
}
// Las entradas de MangaDex con el título de la obra, en el orden en que las devuelve (primero la más parecida).
function mdMatches(mangaId) {
return stored(`mdworks:${mangaId}`, v => (v.length ? 30 * DAY : 3 * DAY), async () => {
const { manga } = await fetchMangaFull(mangaId, 'es')
const title = workTitle(manga.title)
const r = await md('/manga', { title, limit: 10, 'contentRating[]': MD_RATINGS }, 6 * 3600e3)
return (r?.data || []).filter(m => mdTitles(m).some(t => similar(t, title))).map(m => m.id)
}, true)
}
// Capítulo -> tomo según las publicaciones de MangaUpdates. Sirve para las obras licenciadas, que MangaDex deja sin capítulos.
async function muVolumes(mangaId) {
const l = await links(mangaId, true)
if (!l.mu) return {}
return stored(`mumap:${mangaId}`, v => (Object.keys(v).length ? 7 * DAY : 3 * DAY), async () => {
const rows = []
for (let page = 1; page <= 20; page++) {
const r = await mu('/releases/search', { search: String(l.mu), search_type: 'series', perpage: 100, page })
rows.push(...(r?.results || []).map(x => x.record))
if (!r || page * 100 >= r.total_hits) break
}
return parseMuVolumes(rows)
}, true)
}
// ---------- Ficha ----------
const AL_FIELDS = `id idMal siteUrl title{romaji english native} synonyms description(asHtml:true) format status
startDate{year month day} endDate{year month day} chapters volumes countryOfOrigin isAdult
@@ -287,5 +313,5 @@ export function setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, n
return true
}
return { route, links, info, cachedInfo, dates }
return { route, links, mdMatches, muVolumes, similar, stored, info, cachedInfo, dates }
}
File diff suppressed because one or more lines are too long
+97
View File
@@ -0,0 +1,97 @@
// Detector de rostros de anime, en un Worker para no trabar el estante.
// Evalúa en JavaScript la cascada LBP «lbpcascade_animeface» de nagadomi: https://github.com/nagadomi/lbpcascade_animeface
// La cascada (animeface-cascade.json) es MIT, (C) 2011 nagadomi@nurs.or.jp; el aviso completo va en el propio archivo.
const integral = (gray, w, h) => {
const I = new Int32Array((w + 1) * (h + 1))
for (let y = 0; y < h; y++) {
let row = 0
for (let x = 0; x < w; x++) {
row += gray[y * w + x]
I[(y + 1) * (w + 1) + x + 1] = I[y * (w + 1) + x + 1] + row
}
}
return I
}
function equalize(gray) {
const hist = new Int32Array(256), n = gray.length
for (let i = 0; i < n; i++) hist[gray[i]]++
let i = 0
while (i < 255 && !hist[i]) i++
const out = new Uint8Array(n), first = hist[i]
if (first === n) return out.fill(i)
const scale = 255 / (n - first), lut = new Uint8Array(256)
let sum = 0
for (i++; i < 256; i++) { sum += hist[i]; lut[i] = Math.max(0, Math.min(255, Math.round(sum * scale))) }
for (let k = 0; k < n; k++) out[k] = lut[gray[k]]
return out
}
function shrink(gray, w, h, W, H) { // promedio por área, como INTER_LINEAR al reducir poco
const out = new Uint8Array(W * H), fx = w / W, fy = h / H
for (let y = 0; y < H; y++) {
const sy = (y + 0.5) * fy - 0.5, y0 = Math.max(0, Math.floor(sy)), y1 = Math.min(h - 1, y0 + 1), ty = sy - Math.floor(sy)
for (let x = 0; x < W; x++) {
const sx = (x + 0.5) * fx - 0.5, x0 = Math.max(0, Math.floor(sx)), x1 = Math.min(w - 1, x0 + 1), tx = sx - Math.floor(sx)
const a = gray[y0 * w + x0] * (1 - tx) + gray[y0 * w + x1] * tx, b = gray[y1 * w + x0] * (1 - tx) + gray[y1 * w + x1] * tx
out[y * W + x] = a * (1 - ty) + b * ty + 0.5
}
}
return out
}
function scan(cascade, gray, W, H, step, emit) {
const I = integral(gray, W, H), S = W + 1, { stages, features, w: cw, h: ch } = cascade
for (let wy = 0; wy + ch <= H; wy += step) {
for (let wx = 0; wx + cw <= W; wx += step) {
let pass = true
for (let s = 0; s < stages.length && pass; s++) {
const [limit, weak] = stages[s]
let sum = 0
for (let q = 0; q < weak.length; q++) {
const [f, subset, leaf] = weak[q], r = features[f], fx = wx + r[0], fy = wy + r[1], bw = r[2], bh = r[3]
const a = fy * S + fx, y1 = a + bh * S, y2 = a + 2 * bh * S, y3 = a + 3 * bh * S
const p0 = I[a], p1 = I[a + bw], p2 = I[a + 2 * bw], p3 = I[a + 3 * bw]
const p4 = I[y1], p5 = I[y1 + bw], p6 = I[y1 + 2 * bw], p7 = I[y1 + 3 * bw]
const p8 = I[y2], p9 = I[y2 + bw], p10 = I[y2 + 2 * bw], p11 = I[y2 + 3 * bw]
const p12 = I[y3], p13 = I[y3 + bw], p14 = I[y3 + 2 * bw], p15 = I[y3 + 3 * bw]
const c = p10 - p9 - p6 + p5
const code = ((p5 - p4 - p1 + p0 >= c) << 7) | ((p6 - p5 - p2 + p1 >= c) << 6) | ((p7 - p6 - p3 + p2 >= c) << 5) | ((p11 - p10 - p7 + p6 >= c) << 4) | ((p15 - p14 - p11 + p10 >= c) << 3) | ((p14 - p13 - p10 + p9 >= c) << 2) | ((p13 - p12 - p9 + p8 >= c) << 1) | (p9 - p8 - p5 + p4 >= c)
sum += leaf[subset[code >> 5] & (1 << (code & 31)) ? 0 : 1]
}
if (sum < limit) pass = false
}
if (pass) emit(wx, wy)
}
}
}
// Agrupa ventanas parecidas, como groupRectangles de OpenCV.
function group(rects, minNeighbors, eps = 0.2) {
const parent = rects.map((_, i) => i), find = i => (parent[i] === i ? i : (parent[i] = find(parent[i])))
const close = (a, b) => { const d = eps * 0.5 * (Math.min(a.w, b.w) + Math.min(a.h, b.h)); return Math.abs(a.x - b.x) <= d && Math.abs(a.y - b.y) <= d && Math.abs(a.x + a.w - b.x - b.w) <= d && Math.abs(a.y + a.h - b.y - b.h) <= d }
for (let i = 0; i < rects.length; i++) for (let j = 0; j < i; j++) if (close(rects[i], rects[j])) parent[find(i)] = find(j)
const by = new Map()
rects.forEach((r, i) => { const k = find(i); const g = by.get(k) || { x: 0, y: 0, w: 0, h: 0, n: 0 }; g.x += r.x; g.y += r.y; g.w += r.w; g.h += r.h; g.n++; by.set(k, g) })
const out = [...by.values()].filter(g => g.n > minNeighbors).map(g => ({ x: Math.round(g.x / g.n), y: Math.round(g.y / g.n), w: Math.round(g.w / g.n), h: Math.round(g.h / g.n), n: g.n }))
// Quita los que quedan dentro de otro mayor con más apoyos.
return out.filter(a => !out.some(b => b !== a && b.n >= a.n && a.x >= b.x - b.w * 0.2 && a.y >= b.y - b.h * 0.2 && a.x + a.w <= b.x + b.w * 1.2 && a.y + a.h <= b.y + b.h * 1.2 && b.w > a.w))
}
/** data: RGBA. Devuelve los rostros en píxeles de la imagen, con `n` = ventanas que los apoyan (más es más fiable). */
function detectFaces(cascade, data, w, h, { scale = 1.1, minNeighbors = 5, minSize = 24 } = {}) {
const raw = new Uint8Array(w * h)
for (let i = 0; i < raw.length; i++) raw[i] = data[i * 4] * 0.299 + data[i * 4 + 1] * 0.587 + data[i * 4 + 2] * 0.114 + 0.5
const gray = equalize(raw), rects = []
for (let f = 1; ; f *= scale) {
const size = Math.round(cascade.w * f)
const W = Math.round(w / f), H = Math.round(h / f)
if (W < cascade.w || H < cascade.h) break
if (size < minSize) continue
scan(cascade, f === 1 ? gray : shrink(gray, w, h, W, H), W, H, f > 2 ? 1 : 2, (x, y) => rects.push({ x: Math.round(x * f), y: Math.round(y * f), w: size, h: size }))
}
return group(rects, minNeighbors)
}
let cascade = null
self.onmessage = async ({ data: { id, pixels, w, h } }) => {
try {
cascade ||= await fetch(`/animeface-cascade.json${self.location.search}`).then(r => { if (!r.ok) throw new Error(r.status); return r.json() })
self.postMessage({ id, faces: detectFaces(cascade, new Uint8ClampedArray(pixels), w, h, { minNeighbors: 3 }) })
} catch (e) { self.postMessage({ id, faces: null }) }
}
+1564 -121
View File
File diff suppressed because it is too large Load Diff
+1091
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
Fuentes manuscritas de las notas del estante, de Google Fonts (solo el subconjunto latino):
Caveat, Patrick Hand, Kalam, Gochi Hand, Indie Flower y Shadows Into Light: SIL Open Font License 1.1 (https://openfontlicense.org).
Permanent Marker: Apache License 2.0 (https://www.apache.org/licenses/LICENSE-2.0).
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+3
View File
@@ -3,6 +3,8 @@
<head>
<meta charset="utf-8">
<meta name="sw-version" content="">
<meta name="b3d-version" content="">
<meta name="shelf-version" content="">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#14121a">
<title>Manga Reader</title>
@@ -21,6 +23,7 @@
<nav>
<a href="#/" data-icon="home">Inicio</a>
<a href="#/library" data-icon="library">Librería</a>
<a href="#/shelf" data-icon="shelf">Estante</a>
<a href="#/queues" data-icon="queue">Colas</a>
<a href="#/clips" data-icon="scissors">Recortes</a>
<a href="#/stats" data-icon="chart">Estadísticas</a>
File diff suppressed because it is too large Load Diff
+72
View File
@@ -79,6 +79,13 @@ input:focus, select:focus { border-color: var(--accent); }
.chapters a:hover { background: var(--surface-2); }
.chapters a.read { opacity: .5; }
.chapters a.current { outline: 1px solid var(--accent); opacity: 1; }
.chapters .volhead { display: flex; align-items: center; gap: 8px; margin-top: 6px; }
.chapters .voltoggle { flex: 1; min-width: 0; display: flex; align-items: center; gap: 12px; padding: 8px; border: 0; border-radius: 8px; background: var(--surface-2); color: inherit; font: inherit; text-align: left; cursor: pointer; }
.chapters .voltoggle.static { cursor: default; padding: 10px 12px; }
.chapters .voltoggle img { width: 44px; height: 63px; object-fit: cover; border-radius: 3px; flex: none; }
.chapters .voltoggle > span { display: grid; gap: 2px; }
.chapters .voltoggle .meta { text-align: left; }
.chapters .volhead > .btn { flex: none; }
.chapters .meta { font-size: .8rem; color: var(--muted); text-align: right; flex: none; }
.history { display: grid; gap: 10px; }
@@ -86,6 +93,8 @@ input:focus, select:focus { border-color: var(--accent); }
.settings { max-width: 480px; display: grid; gap: 16px; }
.settings section { background: var(--surface); padding: 16px; border-radius: var(--radius); display: grid; gap: 10px; }
.tableprev { width: 100%; max-height: 180px; object-fit: cover; border-radius: var(--radius); }
.tableprev[hidden] { display: none; }
.loading { padding: 40px; text-align: center; color: var(--muted); }
@@ -397,6 +406,17 @@ body.reading #netbanner { display: none; }
.paged .curldrop { position: absolute; z-index: 2; pointer-events: none; opacity: 0; }
.paged .curldrop.l { background: linear-gradient(90deg, rgba(0,0,0,.55), rgba(0,0,0,.2) 35%, rgba(0,0,0,0) 75%); }
.paged .curldrop.r { background: linear-gradient(270deg, rgba(0,0,0,.55), rgba(0,0,0,.2) 35%, rgba(0,0,0,0) 75%); }
/* Hendidura del lomo: la página se funde con el negro de detrás. Los puntos coinciden con gutter() en app.js. */
.paged img[data-gutter="l"], .paged img[data-gutter="r"] {
--gutter-stops: rgba(0,0,0,.4) 0, rgba(0,0,0,.6) 1.2%, rgba(0,0,0,.8) 3%, rgba(0,0,0,.93) 6%, #000 10%;
--gutter-mask: linear-gradient(var(--gutter-side), var(--gutter-stops));
-webkit-mask-image: var(--gutter-mask);
mask-image: var(--gutter-mask);
}
.paged img[data-gutter="l"] { --gutter-side: to right; }
.paged img[data-gutter="r"] { --gutter-side: to left; }
.paged .panmask { position: absolute; top: 0; bottom: 0; z-index: 4; pointer-events: none; background: #000; }
.paged .leafkeepback { position: absolute; z-index: 2; pointer-events: none; background: #000; }
.paged .leafkeep { position: absolute; z-index: 2; pointer-events: none; max-width: none; max-height: none; object-fit: fill; }
/* Info de la obra */
@@ -439,3 +459,55 @@ body.reading #netbanner { display: none; }
.tag.hot { color: var(--accent); }
@media (max-width: 640px) { .kv { grid-template-columns: 1fr; gap: 0; } .kv dd { margin-bottom: 8px; } .banner { height: 120px; } }
.chapters .meta .cdate.approx { opacity: .7; }
/* Libro 3D: el canvas dibuja el libro y #pages sigue debajo de los dedos, invisible, para los gestos. */
#shelf3d { position: fixed; inset: 0; z-index: 0; touch-action: none; background: #17100b; }
#shelf3d canvas { display: block; width: 100%; height: 100%; }
.shelftools { position: fixed; z-index: 2; top: calc(10px + env(safe-area-inset-top)); left: calc(10px + env(safe-area-inset-left)); display: flex; gap: 8px; }
.shelfnote:empty { display: none; } /* vacía no se ve; [hidden] dice que ya llegaron los tomos */
.shelfnote { position: fixed; z-index: 2; left: 50%; bottom: calc(14px + env(safe-area-inset-bottom)); transform: translateX(-50%); margin: 0; padding: 6px 12px; max-width: calc(100vw - 32px); border-radius: 8px; background: rgb(0 0 0 / .62); color: #fff; font-size: .85rem; text-align: center; }
.libhead { justify-content: space-between; align-items: center; }
#b3d { position: fixed; inset: 0; z-index: 0; }
#b3d[hidden] { display: none; }
#b3d canvas { display: block; width: 100%; height: 100%; }
.reader.b3d { background: #4a2e19; }
.reader.b3d > #pages { position: relative; z-index: 1; background: transparent; }
.reader.b3d > #pages img { visibility: hidden; }
/* Los dedos caen siempre en #pages, que no se quita. Si cayeran en la etapa, al pasar de página el «levantar» no llegaría al lector. */
.reader.b3d > #pages > * { pointer-events: none; }
/* El final del capítulo, sobre la contraportada y con sus colores. */
.reader.b3d .chapter-end { position: absolute; left: 0; top: 0; padding: 10%; align-content: center; gap: 12px; color: var(--end-ink, #fff); filter: var(--page-filter, none);
opacity: 0; visibility: hidden; transition: opacity .3s, visibility .3s; will-change: transform; }
.reader.b3d .chapter-end.on { opacity: 1; visibility: visible; }
.reader.b3d .chapter-end p { margin: 0; padding: 3px 12px; border-radius: 6px; background: var(--end-surface, transparent); color: var(--end-ink, #fff); font-weight: 600; }
.reader.b3d .chapter-end .btn { pointer-events: auto; width: 100%; max-width: 320px; min-height: 44px; white-space: normal; text-align: center;
background: var(--end-surface, #222); color: var(--end-ink, #fff); border-color: var(--end-line, #888); box-shadow: 0 2px 6px rgba(0, 0, 0, .35); }
.reader.b3d .chapter-end .btn.primary { background: var(--end-accent, #e50); color: var(--end-accentInk, #fff); border-color: var(--end-accent, #e50); }
.reader.b3d .chapter-end .btn:focus-visible { outline: 2px solid var(--end-ink, #fff); outline-offset: 2px; }
/* Para recortar se ve la página plana. */
.reader.b3d.b3dcrop { background: #000; }
.reader.b3d.b3dcrop > #b3d { display: none; }
.reader.b3d.b3dcrop > #pages img { visibility: visible; }
/* Vistazo del estante: la nota del tomo sacado, un pósit cuadrado pegado junto a él (a la derecha en apaisado, abajo en vertical).
Papel, tinta, letra e inclinación son los de la nota de su colección en la balda (--np, --ni, --nf, --nw, --ns, --nt). */
.shelfpeek { --np: #fff27a; --ni: #24242e; --nf: cursive; --nw: 400; --ns: 1; --nt: 0deg; --side: min(34vw, 330px);
position: fixed; z-index: 3; right: calc(5vw + env(safe-area-inset-right)); top: 50%; width: var(--side); height: var(--side); box-sizing: border-box;
display: flex; flex-direction: column; overflow: hidden; transform: translate(14px, -50%) rotate(var(--nt)); opacity: 0; transition: opacity .22s, transform .26s;
padding: 34px 22px 18px; border-radius: 1px 1px 2px 14px / 1px 1px 2px 8px; color: var(--ni); font-family: var(--nf), cursive; font-weight: var(--nw);
background: linear-gradient(180deg, rgb(0 0 0 / .07) 0 24px, transparent 24px), linear-gradient(160deg, rgb(255 255 255 / .18), transparent 40%), linear-gradient(0deg, rgb(0 0 0 / .1), transparent 30%), var(--np);
box-shadow: 0 1px 1px rgb(0 0 0 / .18), 4px 14px 22px -8px rgb(0 0 0 / .55); }
.shelfpeek.on { opacity: 1; transform: translate(0, -50%) rotate(var(--nt)); }
.shelfpeek .ptitle { margin: 0 26px 8px 0; font-family: inherit; font-weight: inherit; font-size: calc(clamp(1.3rem, 2.6vw, 1.8rem) * var(--ns)); line-height: 1.05; overflow-wrap: anywhere; }
.shelfpeek .psub, .shelfpeek .pwhere, .shelfpeek .phint { margin: 4px 0; font-size: calc(clamp(1rem, 1.7vw, 1.2rem) * var(--ns)); line-height: 1.2; }
.shelfpeek .psub { opacity: .8; }
.shelfpeek .phint { margin-top: auto; color: inherit; text-decoration: none; opacity: .75; }
.shelfpeek .peekx { position: absolute; top: 2px; right: 4px; width: 30px; height: 30px; display: grid; place-items: center; border: 0; border-radius: 50%; background: transparent; color: inherit; cursor: pointer; opacity: .6; }
@media (orientation: portrait) {
.shelfpeek { --side: min(64vw, 300px); right: auto; left: 50%; top: auto; bottom: calc(18px + env(safe-area-inset-bottom)); transform: translate(-50%, 14px) rotate(var(--nt)); }
.shelfpeek.on { transform: translate(-50%, 0) rotate(var(--nt)); }
.shelfpeek .ptitle { font-size: calc(1.45rem * var(--ns)); }
.shelfpeek .psub, .shelfpeek .pwhere, .shelfpeek .phint { font-size: calc(1.08rem * var(--ns)); }
}
body.handing .reader .loading { visibility: hidden; }
.handoff { position: fixed; inset: 0; z-index: 60; width: 100vw; height: 100vh; object-fit: cover; pointer-events: none; transition: opacity .35s ease; }
.handoff.out { opacity: 0; }
@media (max-width: 420px) { #topbar nav a { padding: 8px 7px; } }
+3 -3
View File
@@ -2,7 +2,7 @@
// dispositivo" (caché OFFLINE, la llena app.js) se leen sin red.
const SHELL = 'shell-v1'
const OFFLINE = 'offline-v1'
const SHELL_FILES = ['/', '/app.js', '/style.css', '/icon.svg', '/manifest.webmanifest', '/icon-192.png', '/icon-512.png']
const SHELL_FILES = ['/', '/app.js', '/style.css', '/book3d.js', '/shelf3d.js', '/animeface.js', '/animeface-cascade.json', '/fonts/caveat.woff2', '/fonts/patrickhand.woff2', '/fonts/kalam.woff2', '/fonts/gochihand.woff2', '/fonts/indieflower.woff2', '/fonts/permanentmarker.woff2', '/fonts/shadows.woff2', '/icon.svg', '/manifest.webmanifest', '/icon-192.png', '/icon-512.png']
// Se guarda la portada tal como la sirve el servidor y justo las versiones de app.js y style.css que
// enlaza (?v=…); cache: 'reload' para no copiar lo que tenga la caché HTTP (un proxy puede alargarla horas).
@@ -21,7 +21,7 @@ self.addEventListener('install', e => {
})
self.addEventListener('activate', e => {
e.waitUntil(caches.keys()
.then(keys => Promise.all(keys.filter(k => k !== SHELL && k !== OFFLINE).map(k => caches.delete(k))))
.then(keys => Promise.all(keys.filter(k => k !== SHELL && k !== OFFLINE && !k.startsWith('shelf-')) // shelf-*: lo que guarda el estante (shelf3d.js).map(k => caches.delete(k))))
.then(() => self.clients.claim()))
})
@@ -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)))
}
})
+2 -1
View File
@@ -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
+101 -10
View File
@@ -15,6 +15,10 @@ import { setupSecurity, safeFetch } from './security.js'
import { setupM440, MI_BASE } from './m440.js'
import { setupConnectivity } from './connectivity.js'
import { setupMeta } from './meta.js'
import { setupVolumes } from './volumes.js'
import { setupCredits } from './credits.js'
import { setupShelf } from './shelf.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 +166,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 +462,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' : /^(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.
@@ -539,6 +545,7 @@ async function fetchMangaFull(mangaId, lang) {
const src = sourceOf(mangaId)
return src === 'lc' ? lcManga(mangaId)
: src === 'mi' ? mi.manga(mangaId)
: isBookId(mangaId) ? books.manga(mangaId)
: { manga: await getManga(mangaId, lang), chapters: await getChapters(mangaId, lang) }
}
@@ -767,6 +774,45 @@ function json(res, status, body, headers = {}) {
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', ...headers })
res.end(JSON.stringify(body))
}
// Fondo de la mesa del libro 3D, por usuario. El navegador lo reduce antes de subirlo; aquí solo se aceptan estos formatos, mirando el contenido.
const TABLE_DIR = path.join(DATA_DIR, 'tables'), TABLE_MAX = 24 * 1024 * 1024
const tableType = b => {
if (b.length < 16) return null
if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'image/jpeg'
if (b.readUInt32BE(0) === 0x89504e47) return 'image/png'
if (b.toString('latin1', 0, 4) === 'RIFF' && b.toString('latin1', 8, 12) === 'WEBP') return 'image/webp'
return null
}
fs.mkdirSync(TABLE_DIR, { recursive: true })
const tableFile = id => path.join(TABLE_DIR, `${Number(id)}.img`)
const tableVersion = id => { try { return Math.round(fs.statSync(tableFile(id)).mtimeMs) } catch { return 0 } }
async function tableRoute(method, req, res, user, q) {
const file = tableFile(user.id)
if (method === 'GET') {
let buf
try { buf = fs.readFileSync(file) } catch { throw new HttpError(404, 'Sin fondo') }
res.writeHead(200, { 'Content-Type': tableType(buf) || 'application/octet-stream', 'Content-Length': buf.length, 'Cache-Control': q.get('v') ? 'private, max-age=31536000, immutable' : 'private, no-cache' })
return res.end(buf)
}
if (method === 'DELETE') {
fs.rmSync(file, { force: true })
return json(res, 200, { tableV: 0 })
}
if (method !== 'POST') throw new HttpError(405, 'Método no permitido')
let size = 0
const chunks = []
for await (const c of req) {
size += c.length
if (size > TABLE_MAX) throw new HttpError(413, 'La imagen es demasiado grande')
chunks.push(c)
}
const buf = Buffer.concat(chunks)
if (!tableType(buf)) throw new HttpError(400, 'La imagen debe ser JPEG, PNG o WebP')
const tmp = `${file}.tmp`
fs.writeFileSync(tmp, buf)
fs.renameSync(tmp, file)
return json(res, 200, { tableV: tableVersion(user.id) })
}
async function readBody(req) {
let size = 0
const chunks = []
@@ -777,8 +823,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}|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 = ?
@@ -789,7 +835,7 @@ const requireUser = req => {
return u
}
const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', '.webmanifest': 'application/manifest+json', '.png': 'image/png' }
const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', '.webmanifest': 'application/manifest+json', '.png': 'image/png', '.json': 'application/json', '.woff2': 'font/woff2', '.txt': 'text/plain; charset=utf-8' }
// index.html enlaza app.js y style.css con ?v=<hash del contenido>: si un proxy delante cambia el
// no-cache por una caché larga (Nginx Proxy Manager con «Cache Assets»), cada versión nueva tiene otra
// URL y el navegador no se queda con la vieja. Los archivos no cambian mientras el contenedor vive.
@@ -797,6 +843,8 @@ const assetVersion = name => crypto.createHash('sha256').update(fs.readFileSync(
const INDEX_HTML = fs.readFileSync(path.join(PUBLIC_DIR, 'index.html'), 'utf8')
.replace('src="/app.js"', `src="/app.js?v=${assetVersion('app.js')}"`)
.replace('href="/style.css"', `href="/style.css?v=${assetVersion('style.css')}"`)
.replace('<meta name="b3d-version" content="">', `<meta name="b3d-version" content="${assetVersion('book3d.js')}">`)
.replace('<meta name="shelf-version" content="">', `<meta name="shelf-version" content="${assetVersion('shelf3d.js')}${assetVersion('animeface.js')}">`)
.replace('<meta name="sw-version" content="">', `<meta name="sw-version" content="${assetVersion('sw.js')}">`)
function serveStatic(res, pathname) {
let file = path.join(PUBLIC_DIR, path.normalize(pathname).replace(/^(\.\.[/\\])+/, ''))
@@ -832,7 +880,8 @@ async function route(req, res) {
// --- Auth ---
if (p === '/api/me' && method === 'GET') {
const userCount = db.prepare('SELECT COUNT(*) n FROM users').get().n
return json(res, 200, { user: getUser(req), signup: ALLOW_SIGNUP || userCount === 0 })
const u = getUser(req)
return json(res, 200, { user: u && { ...u, tableV: tableVersion(u.id) }, signup: ALLOW_SIGNUP || userCount === 0 })
}
if ((p === '/api/login' || p === '/api/register') && method === 'POST') {
rateLimit(ip)
@@ -869,15 +918,22 @@ async function route(req, res) {
// Todo lo demás requiere sesión.
const user = requireUser(req)
if (p === '/api/table') return tableRoute(method, req, res, user, q)
// Un libro subido solo existe para quien lo subió, en cualquier ruta y parámetro que lo nombre.
let rawUrl = req.url
try { rawUrl = decodeURIComponent(req.url) } catch {}
for (const [ub] of rawUrl.matchAll(/ub~[0-9a-f]{32}/g)) if (!books.owns(user.id, ub)) throw new HttpError(404, 'No encontrado')
if (p.startsWith('/api/bans') && security.route(p, method, req, res, json) !== false) return
if ((await features.route(p, method, q, req, res, user)) !== false) return
if ((await credits.route(p, method, req, res, user)) !== false) return
if (shelf.route(p, method, res, user) !== false) return
if ((await upscale.route(p, method, req, res)) !== false) {
// Pedir el reescalado (o el automático) lo pone en la biblioteca de quien lo pide.
if (method === 'POST' && (m = /^\/api\/upscale\/([^/]+)(\/auto)?$/.exec(p)) && db.prepare('SELECT 1 FROM downloads WHERE manga_id = ?').get(m[1])) linkDownload(user.id, m[1])
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 +942,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 +979,15 @@ 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() : ['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))
const anyLang = q.get('anyLang') === '1'
@@ -933,6 +996,17 @@ async function route(req, res) {
}
let m
if ((m = /^\/api\/volumes\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'GET') {
return json(res, 200, { volumes: isBookId(m[1]) ? [] : await volumes.list(m[1], lang) })
}
if ((m = /^\/api\/logo\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'GET') {
return json(res, 200, { logo: isBookId(m[1]) ? null : await volumes.logo(m[1], lang).catch(() => null) })
}
if ((m = /^\/api\/tome\/([^/]+)\/([^/]+)\/(front|back)$/.exec(p)) && method === 'GET') {
let vol
try { vol = decodeURIComponent(m[2]) } catch { throw new HttpError(404, 'Cubierta no encontrada') }
return volumes.sendFace(res, m[1], vol, m[3])
}
if ((m = /^\/api\/manga\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'GET') {
let manga, chapters, offline = false
try {
@@ -951,9 +1025,21 @@ 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, hideCredits: credits.enabled(user.id, m[1]), download: downloadInfo(m[1], true), upscale: upscale.info(m[1]), clips: clips.count(user.id, m[1]), lists: library.listsWith(user.id, m[1]), info: isBookId(m[1]) ? null : meta.cachedInfo(m[1]), clipBanner: clips.bannerFor(user.id, m[1]), ...features.mangaExtras(user.id, manga) })
}
if (p === '/api/books' && method === 'POST') return json(res, 200, await books.upload(req, user))
if ((m = /^\/api\/books\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && m[1].startsWith('ub~') && method === 'DELETE') {
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') {
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 +1099,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 +1153,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 +1167,7 @@ async function route(req, res) {
}
const security = setupSecurity({ db })
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 })
@@ -1087,6 +1175,9 @@ const library = setupLibrary({ db, HttpError, json, readBody, MANGA_ID, CHAPTER_
// Al volver internet: reanudar las descargas en cola.
net.whenOnline(() => pumpDownloads())
const meta = setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, net, decode, imgUrl, UA, prepareBanner })
const credits = setupCredits({ db, HttpError, json, readBody, MANGA_ID, CHAPTER_ID })
const shelf = setupShelf({ db, json, isBookId, downloadCover, MY_DOWNLOADS })
const volumes = setupVolumes({ md, sourceOf, fetchMangaFull, stored: meta.stored, mdMatches: meta.mdMatches, muVolumes: meta.muVolumes, similar: meta.similar, miSearch: q => mi.search(q), imgUrl, DATA_DIR, UA, HttpError, net })
const clips = setupClips({ db, DATA_DIR, HttpError, json, readBody, MANGA_ID, CHAPTER_ID, upscale })
// Migración: lo descargado antes de que hubiera dueños pasa a quien lo lee, lo sigue o lo tiene en una
+36
View File
@@ -0,0 +1,36 @@
// Estante 3D: las obras de un usuario que pueden ir en el mueble. Los tomos de cada una se piden aparte (/api/volumes), porque pueden tardar.
import { hasNoVolumes } from './volumes.js'
export function setupShelf({ db, json, isBookId, downloadCover, MY_DOWNLOADS }) {
function works(userId) {
const out = new Map()
const add = (id, fields) => {
const w = out.get(id) || { id, title: '', cover: null, last: 0, chapterId: null, chapterNum: null, page: 0, lang: null }
w.title ||= fields.title || ''
w.cover ||= fields.cover || null
w.last = Math.max(w.last, fields.at || 0)
if (fields.chapterId) Object.assign(w, { chapterId: fields.chapterId, chapterNum: fields.chapterNum ?? null, page: fields.page || 0, lang: fields.lang || null })
out.set(id, w)
}
for (const r of db.prepare('SELECT manga_id, manga_title, cover, chapter_id, chapter_num, page, lang, updated_at FROM progress WHERE user_id = ?').all(userId)) {
add(r.manga_id, { title: r.manga_title, cover: r.cover, chapterId: r.chapter_id, chapterNum: r.chapter_num, page: r.page, lang: r.lang, at: r.updated_at })
}
for (const r of db.prepare('SELECT manga_id, title, cover, last_new_at, created_at FROM follows WHERE user_id = ? AND active = 1').all(userId)) {
add(r.manga_id, { title: r.title, cover: r.cover, at: r.last_new_at || r.created_at })
}
for (const r of db.prepare(`SELECT manga_id, title, folder, updated_at, json_extract(meta, '$.manga.cover') meta_cover FROM downloads WHERE ${MY_DOWNLOADS}`).all(userId)) {
add(r.manga_id, { title: r.title, cover: downloadCover(r.manga_id, r.folder, r.meta_cover), at: r.updated_at })
}
for (const r of db.prepare('SELECT i.manga_id, i.title, i.cover, i.added_at FROM list_items i JOIN lists l ON l.id = i.list_id WHERE l.user_id = ?').all(userId)) {
add(r.manga_id, { title: r.title, cover: r.cover, at: r.added_at })
}
const typeOf = db.prepare('SELECT type FROM manga_meta WHERE manga_id = ?')
return [...out.values()].filter(w => !isBookId(w.id) && !hasNoVolumes(typeOf.get(w.id)?.type)).sort((a, b) => b.last - a.last)
}
function route(p, method, res, user) {
if (p !== '/api/shelf' || method !== 'GET') return false
json(res, 200, { works: works(user.id) })
return true
}
return { route, works }
}
+770
View File
@@ -0,0 +1,770 @@
// Tomos: qué capítulos forman cada tomo y sus cubiertas originales.
// - Reparto, capítulo a capítulo: lista de Wikipedia, MangaDex (la edición con más datos), MangaUpdates y M440. Ninguna basta: MangaDex
// pierde los capítulos licenciados y M440 mete lo reciente en un tomo cajón. Cada una se limpia y se contrasta con las demás.
// - Portada: /cover de MangaDex, a tamaño original (prefiere la edición japonesa).
// - Contraportada: la Biblioteca Nacional de Japón (NDL) da el ISBN de cada tomo y Amazon sirve la imagen por ISBN.
// Si la edición japonesa no la tiene, se prueba la inglesa (ISBN por Open Library).
// Lo descargado queda en <DATA_DIR>/tomes/<id de MangaDex>/<tomo>-<cara>.img.
import fs from 'node:fs'
import path from 'node:path'
import crypto from 'node:crypto'
const DAY = 24 * 3600e3
const NDL = 'https://ndlsearch.ndl.go.jp/api/opensearch'
const OPEN_LIBRARY = 'https://openlibrary.org/search.json'
const AMAZON = 'https://m.media-amazon.com/images/P'
const WIKIPEDIA = 'https://en.wikipedia.org/w/api.php'
const MAX_IMAGE = 30 * 1024 * 1024
const MIN_IMAGE = 8000 // por debajo son el píxel transparente o una miniatura de Amazon
const VOL = /^[\p{L}\p{N}._-]{1,16}$/u
const MD_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
// "01" -> "1", "1.50" -> "1.5", "Vol. 3" no es un número y se queda igual.
export const volKey = v => {
const s = String(v ?? '').trim()
if (!s || s.toLowerCase() === 'none') return null
return /^\d+(\.\d+)?$/.test(s) ? String(parseFloat(s)) : s
}
const numKey = n => { const f = parseFloat(n); return Number.isFinite(f) ? String(f) : null }
const volOrder = (a, b) => (parseFloat(a) || Infinity) - (parseFloat(b) || Infinity) || String(a).localeCompare(String(b))
// Título comparable: sin signos, espacios ni diferencias de anchura.
export const titleKey = s => String(s || '').normalize('NFKC').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '')
export function ndlVolume(s) {
const m = /^(?:vol\.?|第)?\s*(\d{1,4})\s*巻?$/i.exec(String(s || '').normalize('NFKC').trim())
return m ? String(parseInt(m[1], 10)) : null
}
export function isbn10(raw) {
const s = String(raw || '').replace(/[^0-9Xx]/g, '').toUpperCase()
if (/^\d{9}[\dX]$/.test(s)) return s
if (!/^978\d{10}$/.test(s)) return null
const body = s.slice(3, 12)
let sum = 0
for (let i = 0; i < 9; i++) sum += (10 - i) * Number(body[i])
const c = (11 - (sum % 11)) % 11
return body + (c === 10 ? 'X' : String(c))
}
const xmlText = s => String(s || '').replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/g, "'").trim()
// De los registros de la NDL, los tomos de esta obra: mismo título exacto, tomo numérico, clasificado como manga y del
// autor con más tomos. Si el primer tomo sale muy lejos del año de la obra, es otra obra con el mismo título.
export function pickIsbns(xmlPages, titles, year) {
const keys = new Set(titles.map(titleKey).filter(Boolean))
const groups = new Map()
for (const xml of xmlPages) {
for (const [, it] of xml.matchAll(/<item>([\s\S]*?)<\/item>/g)) {
if (!keys.has(titleKey(xmlText(/<dc:title>([\s\S]*?)<\/dc:title>/.exec(it)?.[1])))) continue
const vol = ndlVolume(xmlText(/<dcndl:volume>([\s\S]*?)<\/dcndl:volume>/.exec(it)?.[1]))
if (!vol) continue
const isbns = [...it.matchAll(/xsi:type="dcndl:ISBN">([^<]+)</g)].map(m => isbn10(m[1])).filter(Boolean)
if (!isbns.length) continue
const author = titleKey(xmlText(/<author>([\s\S]*?)<\/author>/.exec(it)?.[1]).split(/[, ]/)[0]) || '?'
const when = parseInt(/<dc:date[^>]*>(\d{4})/.exec(it)?.[1], 10) || null
if (!groups.has(author)) groups.set(author, new Map())
const g = groups.get(author)
if (!g.has(vol)) g.set(vol, { isbns: [], when })
const e = g.get(vol)
for (const i of isbns) if (!e.isbns.includes(i)) e.isbns.push(i)
if (when && (!e.when || when < e.when)) e.when = when
}
}
const firstYear = g => Math.min(...[...g.values()].map(e => e.when || Infinity))
const best = [...groups.values()]
.filter(g => !year || !Number.isFinite(firstYear(g)) || Math.abs(firstYear(g) - year) <= 3)
.sort((a, b) => b.size - a.size)[0]
if (!best) return {}
return Object.fromEntries([...best].map(([v, e]) => [v, e.isbns]))
}
// Open Library: solo el título exacto seguido del tomo («Berserk of Gluttony, Vol. 1» no es Berserk) y solo ISBN en inglés.
// Ediciones de varios tomos y otros libros: su contraportada no es la de este tomo. One Piece "Vol. 1 (Romance Dawn /
// Buggy the Clown / Don't Get Fooled Again)" es en realidad el ómnibus East Blue 1-2-3.
const SEVERAL_VOLUMES = /omnibus|ómnibus|\d\s*-?\s*in\s*-?\s*\d|\d\s*[-–&]\s*\d|\s\/\s|box(ed)? set|collection|coloring|how to draw/
export function pickEnglishIsbns(docs, titles, vol) {
const out = []
for (const title of titles) {
const esc = titleLower(title).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const re = new RegExp(`^${esc}[,:]?\\s*(?:vol(?:ume)?\\.?\\s*)?0*${vol}(?:\\D|$)`)
for (const d of docs) {
if (!re.test(titleLower(d.title)) || SEVERAL_VOLUMES.test(titleLower(d.title))) continue
for (const x of d.isbn || []) {
const i = isbn10(x)
if (i && /^[01]/.test(i) && !out.includes(i)) out.push(i)
}
}
}
return out
}
const titleLower = s => String(s || '').normalize('NFKC').toLowerCase().replace(/\s+/g, ' ').trim()
// Una por tomo: la japonesa, si no la del idioma de lectura, luego la inglesa y luego cualquiera.
export function pickCovers(records, lang) {
const rank = l => { const i = ['ja', lang, 'en'].indexOf(l); return i < 0 ? 9 : i }
const out = {}
for (const r of records) {
const vol = volKey(r.attributes?.volume), file = r.attributes?.fileName, l = r.attributes?.locale
if (!vol || !file) continue
if (!out[vol] || rank(l) < rank(out[vol].locale)) out[vol] = { file, locale: l }
}
return out
}
function imageKind(b) {
if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'image/jpeg'
if (b.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return 'image/png'
if (b.subarray(0, 4).toString('latin1') === 'RIFF' && b.subarray(8, 12).toString('latin1') === 'WEBP') return 'image/webp'
return null
}
/** MangaDex trae errores de quien subió cada capítulo: el 232 en el tomo 1 de Chainsaw Man, por ejemplo.
* Devuelve los números que quedan lejos del resto de su tomo. Con menos de 3 no hay con qué comparar. */
export function strayNums(nums) {
const known = nums.map(parseFloat).filter(Number.isFinite).sort((a, b) => a - b)
if (known.length < 3) return new Set()
const middle = a => (a[(a.length - 1) >> 1] + a[a.length >> 1]) / 2
const center = middle(known)
const spread = middle(known.map(n => Math.abs(n - center)).sort((a, b) => a - b))
const limit = Math.max(4 * spread, 5)
const far = known.filter(n => Math.abs(n - center) > limit)
return new Set(far.length > Math.max(1, known.length * 0.15) ? [] : far) // varios lejos: es la numeración de la obra (Berserk)
}
/** Un mapa capítulo -> tomo sin lo que no es fiable: capítulos fuera de sitio y tomos cajón, mucho mayores que los demás. */
export function cleanVolMap(map) {
const byVol = new Map()
for (const [n, v] of Object.entries(map)) byVol.set(v, [...(byVol.get(v) || []), n])
const whole = nums => new Set(nums.map(n => Math.floor(parseFloat(n)))).size // el 2 y el 2.2 son un capítulo partido
const sizes = [...byVol.values()].map(whole).sort((a, b) => a - b)
const usual = sizes[(sizes.length - 1) >> 1]
const out = {}
for (const [v, nums] of byVol) {
const size = whole(nums)
if (size >= 40 || (size >= 20 && (sizes.length === 1 || size > 3 * usual))) continue
const stray = /^\d/.test(v) ? strayNums(nums) : new Set()
for (const n of nums) if (!stray.has(parseFloat(n))) out[n] = v
}
return out
}
/** ¿Dos fuentes dan el mismo reparto donde coinciden? Si numeran distinto (Berserk en Wikipedia), no. */
export function volMapsAgree(a, b) {
const shared = Object.keys(a).filter(n => n in b)
if (shared.length < 5) return true
return shared.filter(n => a[n] === b[n]).length / shared.length >= 0.6
}
/** Junta las fuentes por orden de preferencia; cada una solo rellena lo que falta. */
export function mergeVolMaps({ wiki = {}, md = {}, mu = {}, mi = {} }) {
const merged = {}
// M440 acierta más que las anotaciones de MangaUpdates, salvo si le falta un tomo en medio: suele haberlo unido al anterior.
const shown = new Set(Object.values(mi).map(Number))
const inMu = new Set(Object.values(mu).map(Number))
const miHasHole = [...inMu].some(v => !shown.has(v) && v < Math.max(...shown))
for (const src of [volMapsAgree(wiki, md) ? wiki : {}, md, ...(miHasHole ? [mu, mi] : [mi, mu])]) {
if (!volMapsAgree(src, merged)) continue
for (const [n, v] of Object.entries(src)) if (!(n in merged)) merged[n] = v
}
return merged
}
/** Publicaciones de MangaUpdates: cada grupo anota, a veces, el tomo de lo que publica ("35a", "12-14"). Si discrepan, gana la mayoría. */
export function parseMuVolumes(rows) {
const votes = {}
for (const { volume, chapter } of rows) {
const vol = /^\s*0*([1-9]\d{0,3})\s*$/.exec(String(volume ?? ''))?.[1]
if (!vol) continue
const ch = String(chapter ?? '').trim()
const one = /^(\d+(?:\.\d+)?)[a-z]?$/i.exec(ch)
const range = /^(\d+(?:\.\d+)?)[a-z]?\s*-\s*(\d+(?:\.\d+)?)[a-z]?$/i.exec(ch)
const nums = one ? [parseFloat(one[1])] : []
if (range) {
const [lo, hi] = [parseFloat(range[1]), parseFloat(range[2])]
if (hi >= lo && hi - lo < 50) {
nums.push(lo, hi)
for (let k = Math.ceil(lo); k <= hi; k++) nums.push(k)
}
}
for (const n of new Set(nums)) {
const count = (votes[n] ||= {})
count[vol] = (count[vol] || 0) + 1
}
}
const map = {}
for (const [n, count] of Object.entries(votes)) map[n] = Object.keys(count).reduce((a, b) => (count[b] > count[a] ? b : a))
return map
}
/** Lo aprovechable de las anotaciones de MangaUpdates: sin lo que rompe el orden de los tomos. Si no incluyen el tomo 1, solo valen
* si cubren al menos una cuarta parte de los capítulos (si no, son anotaciones sueltas). */
export function orderedMuMap(map, total = 0) {
const nums = Object.keys(map).sort((a, b) => parseFloat(a) - parseFloat(b))
const vol = nums.map(n => parseInt(map[n], 10))
// La subsecuencia no decreciente más larga.
const len = vol.map(() => 1), from = vol.map(() => -1)
let end = 0
vol.forEach((v, i) => {
for (let k = 0; k < i; k++) if (vol[k] <= v && len[k] + 1 > len[i]) { len[i] = len[k] + 1; from[i] = k }
if (len[i] > len[end]) end = i
})
const out = {}
for (let i = nums.length ? end : -1; i >= 0; i = from[i]) out[nums[i]] = map[nums[i]]
const size = Object.keys(out).length
return Object.values(out).includes('1') || (total && size >= total / 4) ? out : {}
}
/** MangaUpdates apunta el tomo de unos pocos capítulos. Se completa lo que queda claro entre dos de ellos:
* antes del primero, si es el tomo 1; entre dos del mismo tomo; y tras un extra (5.5, el cierre de su tomo) si después viene el tomo siguiente. */
export function fillVolGaps(map, nums) {
const anchors = Object.keys(map).map(parseFloat).sort((a, b) => a - b)
const out = { ...map }
for (const key of nums) {
if (key == null || key in map) continue
const n = parseFloat(key)
const prev = anchors.filter(a => a < n).at(-1)
const next = anchors.find(a => a > n)
if (next === undefined) continue
const after = map[next]
if (prev === undefined) {
if (after === '1' && n >= 1) out[key] = after // el capítulo 0 no suele ser parte del tomo
continue
}
const before = map[prev]
if (before === after ? next - prev <= 5 : !Number.isInteger(prev) && parseInt(after, 10) === parseInt(before, 10) + 1 && next - prev <= 15) out[key] = after
}
return out
}
export const ownVolMap = chapters => {
const map = {}
for (const c of chapters) {
const n = numKey(c.num), v = volKey(c.volume)
if (n && v && !(n in map)) map[n] = v
}
return map
}
/** Numeración que vuelve a empezar en cada tomo: el número no identifica el capítulo y solo vale su propio tomo. */
export function numbersRestart(chapters) {
const seen = new Map()
let repeated = 0
for (const c of chapters) {
const n = numKey(c.num), v = volKey(c.volume)
if (!n || !v) continue
if (seen.has(n) && seen.get(n) !== v) repeated++
else seen.set(n, v)
}
return repeated >= 3
}
/** El tomo de cada capítulo, en el orden de la lista (de menor a mayor). Un extra (7.5) va con el capítulo al que sigue,
* y lo que no tiene tomo entre dos del mismo se queda con ellos, para que el libro no se salte nada. */
export function assignVolumes(chapters, map = {}) {
const vols = chapters.map(c => map[numKey(c.num)] ?? null)
vols.forEach((v, i) => {
const n = parseFloat(chapters[i].num)
if (!v && i && vols[i - 1] && !Number.isInteger(n) && Math.floor(n) === Math.floor(parseFloat(chapters[i - 1].num))) vols[i] = vols[i - 1]
})
let last = -1
vols.forEach((v, i) => {
if (!v) return
if (last >= 0 && vols[last] === v && i - last <= 4) for (let k = last + 1; k < i; k++) vols[k] = v // un hueco grande no se sabe qué tiene
last = i
})
return vols
}
/** Quita el tomo a los capítulos mal etiquetados: un tramo corto cuyo tomo rompe el orden, y sin él los de alrededor quedan en orden
* (T14, T13, T15). Si no se arregla al quitarlo, es que la obra numera así: Berserk recorre los tomos 1-5 con el prólogo (0.01-0.2) y vuelve al T1 con el capítulo 1. */
export function dropMisplaced(vols, maxRun = 3) {
const out = [...vols]
if (!vols.every(v => !v || /^\d+$/.test(v))) return out
const runsOf = () => {
const runs = []
out.forEach((v, i) => {
if (!v) return
if (runs.at(-1)?.v === parseInt(v, 10)) runs.at(-1).at.push(i)
else runs.push({ v: parseInt(v, 10), at: [i] })
})
return runs
}
// De uno en uno y primero el más corto: al quitar un tramo, sus vecinos dejan de parecer mal puestos.
for (;;) {
const runs = runsOf()
const wrong = runs.filter((r, j) => {
if (r.at.length > maxRun) return false
const [a, b] = [runs[j - 1], runs[j + 1]]
if (a && b) return a.v <= b.v && (r.v < a.v || r.v > b.v)
const near = a || b
return near && r.at.length < near.at.length && (a ? r.v < a.v : r.v > b.v)
})
if (!wrong.length) return out
const hit = wrong.reduce((x, y) => (y.at.length < x.at.length ? y : x))
for (const i of hit.at) out[i] = null
}
}
/** Estima el tomo de lo que ninguna fuente sitúa: solo pueden quedar sin tomo los capítulos del final que no quepan en un tomo conocido.
* known: los tomos que se sabe que existen (tienen portada), aunque no se sepa qué capítulos llevan. */
export function placeLoose(chapters, vols, known = []) {
const out = dropMisplaced(vols)
const isNum = v => /^\d+$/.test(v ?? '')
const floorOf = i => Math.floor(parseFloat(chapters[i].num))
// Un corte no separa un capítulo de sus partes o su extra (5 y 5.5).
const cut = i => { while (i > 0 && i < chapters.length && floorOf(i) === floorOf(i - 1)) i++; return i }
const spread = (from, to, names) => {
let start = from
names.forEach((name, j) => {
const end = j === names.length - 1 ? to : Math.min(to, cut(from + Math.round((to - from) * (j + 1) / names.length)))
for (let i = start; i < end; i++) out[i] = name
start = Math.max(start, end)
})
}
const last = known.filter(isNum).reduce((a, v) => Math.max(a, parseInt(v, 10)), 0)
const assigned = out.flatMap((v, i) => (v ? [i] : []))
if (!assigned.length) {
if (last && chapters.length >= last * 2) spread(0, chapters.length, Array.from({ length: last }, (_, i) => String(i + 1)))
return out
}
const count = {}
for (const i of assigned) count[out[i]] = (count[out[i]] || 0) + 1
const sizes = Object.values(count).sort((a, b) => a - b)
const usual = Math.max(1, sizes[(sizes.length - 1) >> 1])
const between = (a, b) => { // los tomos que aún no tienen capítulos en ese tramo de números
const free = []
for (let k = a + 1; k < b; k++) if (!count[k]) free.push(String(k))
return free
}
// Antes del primer tomo.
const first = assigned[0]
if (first) {
const head = out[first]
const free = isNum(head) ? between(0, parseInt(head, 10)) : []
const n = Math.min(free.length, Math.round(first / usual))
if (n) spread(0, first, free.slice(-n))
else for (let i = 0; i < first; i++) out[i] = head
}
// Entre dos tomos.
for (let a = 0; a < assigned.length - 1; a++) {
const [l, r] = [assigned[a], assigned[a + 1]]
if (r - l < 2) continue
const [left, right] = [out[l], out[r]]
const free = isNum(left) && isNum(right) ? between(parseInt(left, 10), parseInt(right, 10)) : []
if (free.length) {
// Se igualan también los dos tomos de los lados: suelen venir de datos parciales (T1 con 4 capítulos, T3 con 1).
const per = (count[left] + count[right] + r - l - 1) / (free.length + 2)
const from = cut(l + 1 + Math.max(0, Math.round(per - count[left])))
const to = Math.max(from, r - Math.max(0, Math.round(per - count[right])))
for (let i = l + 1; i < from; i++) out[i] = left
for (let i = to; i < r; i++) out[i] = right
spread(from, to, free)
count[left] += from - l - 1
count[right] += r - to
continue
}
if (left === right || !isNum(left) || !isNum(right) || parseInt(right, 10) < parseInt(left, 10)) {
for (let i = l + 1; i < r; i++) out[i] = left
continue
}
// Dos tomos seguidos: el corte que más los iguala. Si hay un extra, cierra tomo: se corta justo después.
const whole = []
for (let i = l + 1; i <= r; i++) if (floorOf(i) !== floorOf(i - 1)) whole.push(i)
if (!whole.length) whole.push(r) // todo es del capítulo del tomo de la izquierda
const closes = whole.filter(i => i > l + 1 && !Number.isInteger(parseFloat(chapters[i - 1].num)))
const score = i => Math.abs((count[left] + i - l - 1) - (count[right] + r - i))
const at = (closes.length ? closes : whole).reduce((x, y) => (score(y) < score(x) ? y : x))
for (let i = l + 1; i < r; i++) out[i] = i < at ? left : right
count[left] += at - l - 1
count[right] += r - at
}
// Tras el último: solo en tomos que existen. Lo que no quepa se queda sin tomo.
const end = assigned.at(-1)
const free = isNum(out[end]) ? between(parseInt(out[end], 10), last + 1) : []
const rest = chapters.length - end - 1
if (free.length && rest) {
const fits = rest <= free.length * usual * 1.3
spread(end + 1, fits ? chapters.length : cut(end + 1 + free.length * usual), free)
}
return out
}
/** Cuántos elementos tiene {{Numbered list|start=N|a|b|c}}: las barras de primer nivel, sin contar las de sus plantillas. */
function numberedListCount(text, from) {
let depth = 0, bars = 0
for (let i = from; i < text.length - 1; i++) {
const two = text.slice(i, i + 2)
if (two === '{{' || two === '[[') { depth++; i++ } else if (two === '}}' || two === ']]') {
if (--depth < 0) break
i++
} else if (text[i] === '|' && depth === 0) bars++
}
return bars
}
/** Listas de capítulos de Wikipedia ({{Graphic novel list}}). Cada página numera a su manera: {{Numbered list|start=N}},
* "* 001. Título", "*Mission: 1–5" o una lista sin números, que sigue la cuenta. Las obras derivadas, que vuelven a
* empezar por el tomo 1, se ignoran. */
export function parseWikiVolumes(text) {
const map = {}, seen = new Set()
let next = 1
for (const block of text.split(/\{\{\s*Graphic novel list(?![\w/ ]*(?:header|\/))/i).slice(1)) {
const vol = volKey(/\|\s*VolumeNumber\s*=\s*([^\n|<]*)/i.exec(block)?.[1])
if (!vol) continue
if (seen.has(vol)) break
seen.add(vol)
const fields = [...block.matchAll(/\|\s*ChapterList(?:Col\d)?\s*=/gi)]
if (!fields.length) continue
const add = n => { map[String(n)] = vol; next = Math.max(next, n + 1) }
for (const f of fields) {
const start = f.index + f[0].length
const end = block.slice(start).search(/\n\s*\|\s*(?:ChapterList|LicensedTitle|Summary|LineColor|OriginalRelDate|OriginalISBN|LicensedRelDate|LicensedISBN|Volume\w*|Title)\w*\s*=/i)
const body = block.slice(start, end < 0 ? undefined : start + end)
const numbered = [...body.matchAll(/\{\{\s*(?:Numbered|Ordered) list\s*\|\s*start\s*=\s*(\d+)/gi)]
if (numbered.length) {
for (const m of numbered) {
const first = parseInt(m[1], 10)
for (let k = 0, c = numberedListCount(body, m.index + m[0].length); k < c; k++) add(first + k)
}
continue
}
const range = /^\s*\*?\s*(?:[\p{L} ]+:)?\s*(\d+)\s*[–-]\s*(\d+)\s*$/mu.exec(body)
if (range) {
for (let n = parseInt(range[1], 10), e = parseInt(range[2], 10); n <= e && e - n < 500; n++) add(n)
continue
}
for (const line of body.split('\n')) {
if (!/^\s*[*#]/.test(line)) continue
const own = /^\s*[*#]+\s*(?:Chapter\s*)?(\d+)\s*[.:]/i.exec(line)
add(own ? parseInt(own[1], 10) : next)
}
}
}
return map
}
/** El título de la obra sin la edición: "One Piece Full Color" es One Piece, con los mismos tomos. */
export function workTitle(title) {
const t = String(title || '').trim()
const base = t
.replace(/\s*[([{]\s*(?:(?:full|fan|official|digital(?:ly)?|a todo|a|todo)\s+)?colou?r(?:ed|ized|eado)?(?:\s+edition)?\s*[)\]}]\s*$/i, '')
.replace(/\s*[-–:]?\s*(?:full|fan|official|digital(?:ly)?)\s+colou?r(?:ed|ized)?(?:\s+edition)?\s*$/i, '')
.replace(/\s*[-–:]?\s*(?:a todo color|a color)\s*$/i, '')
return base.length >= 3 ? base : t
}
/** Manhwa y manhua no se publican por tomos: se leen por capítulos. */
export const hasNoVolumes = (type, original) => ['manhwa', 'manhua', 'webtoon'].includes(String(type || '').toLowerCase()) || ['ko', 'zh', 'zh-hk'].includes(original)
// Palabras de un título para comparar con nombres de archivo: sin tildes ni vocales largas («Sōsō» y «Sousou» son «soso»).
const LOGO_STOP = new Set(['the', 'and', 'wa', 'no', 'ga', 'wo', 'ni', 'de', 'to'])
const LOGO_EXTRA = new Set(['logo', 'logotype', 'wordmark', 'manga', 'anime', 'series', 'title', 'english', 'japanese', 'png', 'svg', 'webp', 'file', 'the', 'and', 'tv', 'version', 'new', 'old', 'int', 'international', 'with', 'colour', 'color', 'gradient', 'variant', 'alt', 'alternative', 'text', 'black', 'white', 'transparent', 'vector', 'official'])
export const logoWords = s => String(s).normalize('NFD').replace(/\p{M}/gu, '').toLowerCase().replace(/ou/g, 'o').replace(/uu/g, 'u').replace(/oo/g, 'o').split(/[^\p{L}\p{N}]+/u).filter(w => w.length >= 2 && !LOGO_STOP.has(w) && !/^\d+$/.test(w))
/** El archivo que mejor encaja con algún nombre de la obra: tiene «logo», casi todas las palabras del nombre y nada más que relleno. */
export function pickLogo(files, names) {
let best = null, bestScore = 0
files.forEach((file, i) => {
if (!/\.(png|svg|webp)$/i.test(file)) return
const fw = logoWords(file.replace(/^File:/, ''))
if (!fw.includes('logo')) return
names.forEach((name, k) => {
const nw = logoWords(name)
if (!nw.length || nw.join('').length < 4) return
const hit = nw.filter(w => fw.includes(w)).length / nw.length
const stray = fw.filter(w => !nw.includes(w) && !LOGO_EXTRA.has(w))
const score = hit + (k ? 0 : 0.05) - i * 0.001 // el nombre principal manda
if (hit >= 0.66 && !stray.length && score > bestScore) { best = file; bestScore = score }
})
})
return best
}
export const wikiKey = t => String(t).toLowerCase().replace(/×/g, 'x').replace(/[^\p{L}\p{N}]/gu, '')
export function setupVolumes({ md, sourceOf, fetchMangaFull, stored, mdMatches, muVolumes, similar, miSearch, imgUrl, DATA_DIR, UA, HttpError, net }) {
const DIR = path.join(DATA_DIR, 'tomes')
const fetchImage = async url => {
net.guard()
const r = await fetch(url, { headers: { 'User-Agent': UA }, redirect: 'follow', signal: AbortSignal.timeout(30000) }).catch(() => { net.failed(); return null })
if (!r) throw new HttpError(502, 'No se pudo descargar la cubierta')
if (r.status >= 500 || r.status === 429) throw new HttpError(502, 'La fuente de la cubierta no responde')
if (!r.ok) return null
const b = Buffer.from(await r.arrayBuffer())
return b.length >= MIN_IMAGE && b.length <= MAX_IMAGE && imageKind(b) ? b : null
}
// ---------- Lo que dice MangaDex de la obra ----------
async function mdVolumes(mdId, lang) {
return stored(`volumes:${mdId}:${lang || ''}`, DAY, async () => {
const [agg, mangaData] = await Promise.all([
md(`/manga/${mdId}/aggregate`, {}, 30 * 60e3),
md(`/manga/${mdId}`, {}, 30 * 60e3),
])
const byNum = {}
const vols = Object.values(agg.volumes || {}).sort((a, b) => volOrder(a.volume, b.volume))
for (const v of vols) {
const vol = volKey(v.volume)
if (!vol) continue
const inVol = Object.values(v.chapters || {})
const stray = /^\d/.test(vol) ? strayNums(inVol.map(c => c.chapter)) : new Set()
for (const c of inVol) {
const n = numKey(c.chapter)
if (n && !byNum[n] && !stray.has(parseFloat(n))) byNum[n] = vol
}
}
const records = []
for (let offset = 0; offset < 1000; offset += 100) {
const r = await md('/cover', { 'manga[]': [mdId], limit: 100, offset, 'order[volume]': 'asc' }, 30 * 60e3)
records.push(...r.data)
if (offset + 100 >= r.total || !r.data.length) break
}
const a = mangaData.data.attributes
const jaTitles = [a.title?.ja, ...(a.altTitles || []).map(t => t.ja)].filter(Boolean)
const enTitles = [a.title?.en, ...(a.altTitles || []).map(t => t.en)].filter(Boolean)
const roTitles = [a.title?.['ja-ro'], ...(a.altTitles || []).map(t => t['ja-ro'])].filter(Boolean)
return { byNum, covers: pickCovers(records, lang), jaTitles, enTitles, roTitles, year: a.year || null, original: a.originalLanguage || null }
}, true)
}
let gate = Promise.resolve()
const turn = () => { const p = gate.then(() => new Promise(r => setTimeout(r, 1100))); gate = p.catch(() => {}); return p } // la NDL y Wikipedia piden no insistir
// ---------- Otras fuentes del reparto ----------
async function wikiPages(titles) {
if (!titles.length) return []
await turn() // Wikipedia corta a quien va demasiado rápido
net.guard()
const url = `${WIKIPEDIA}?${new URLSearchParams({ action: 'query', prop: 'revisions', rvprop: 'content', rvslots: 'main', redirects: '1', format: 'json', formatversion: '2', titles: titles.join('|') })}`
const get = () => fetch(url, { headers: { 'User-Agent': UA }, signal: AbortSignal.timeout(30000) }).catch(() => { net.failed(); return null })
let r = await get()
if (r?.status === 429) { // límite pasajero: se espera una vez; el fallo no se guarda y se reintenta en la siguiente visita
await new Promise(res => setTimeout(res, 3000))
r = await get()
}
if (!r?.ok) throw new HttpError(502, 'No se pudo consultar Wikipedia')
return ((await r.json()).query?.pages || []).map(p => p.revisions?.[0]?.slots?.main?.content).filter(Boolean)
}
async function wikiSearch(names) {
await turn()
net.guard()
const url = `${WIKIPEDIA}?${new URLSearchParams({ action: 'query', list: 'search', srsearch: `intitle:list intitle:chapters ${names[0]}`, srlimit: '10', format: 'json', formatversion: '2' })}`
const r = await fetch(url, { headers: { 'User-Agent': UA }, signal: AbortSignal.timeout(30000) }).catch(() => { net.failed(); return null })
if (!r?.ok) throw new HttpError(502, 'No se pudo consultar Wikipedia')
const wanted = new Set(names.map(wikiKey))
return ((await r.json()).query?.search || []).map(x => x.title).filter(t => wanted.has(wikiKey(t.replace(/^List of /i, '').replace(/ (?:manga )?(?:chapters|volumes)$/i, '')))).slice(0, 3)
}
function wikiMap(mangaId, names) {
return stored(`wikimap:${mangaId}`, v => (Object.keys(v).length ? 30 * DAY : 7 * DAY), async () => {
const titles = names.slice(0, 8).flatMap(n => [`List of ${n} chapters`, `List of ${n} manga volumes`, `List of ${n} manga chapters`]).filter(t => !/[|#<>[\]{}]/.test(t))
let best = {}
let pages = await wikiPages(titles)
if (!pages.length) pages = await wikiPages(await wikiSearch(names)) // "SPY×FAMILY" es "Spy × Family" en Wikipedia
for (const text of pages) {
// One Piece y otras largas reparten la lista en subpáginas.
const subs = [...text.matchAll(/\{\{:(List of [^}|]+)\}\}/g)].map(m => m[1]).slice(0, 40)
let found = parseWikiVolumes(text)
for (let i = 0; i < subs.length; i += 10) {
for (const sub of await wikiPages(subs.slice(i, i + 10))) {
const part = parseWikiVolumes(sub)
found = { ...part, ...found }
}
}
if (Object.keys(found).length > Object.keys(best).length) best = found
}
return best
}, true)
}
function m440Map(mangaId, names) {
return stored(`mimap:${mangaId}`, DAY, async () => {
for (const name of names.slice(0, 2)) {
const hit = (await miSearch(name)).find(m => names.some(n => similar(m.title, n)))
if (hit) return ownVolMap((await fetchMangaFull(hit.id, 'es')).chapters)
}
return {}
}, true)
}
// ---------- ISBN de cada tomo ----------
async function ndlSearch(title) {
const pages = []
for (let idx = 1; idx <= 501; idx += 500) { // ONE PIECE tiene más de 600 registros
await turn()
net.guard()
const url = `${NDL}?${new URLSearchParams({ title, mediatype: 'books', cnt: '500', idx: String(idx) })}`
const r = await fetch(url, { headers: { 'User-Agent': UA }, signal: AbortSignal.timeout(30000) }).catch(() => { net.failed(); return null })
if (!r?.ok) throw new HttpError(502, 'No se pudo consultar la NDL')
const xml = await r.text()
pages.push(xml)
if (parseInt(/totalResults>(\d+)/.exec(xml)?.[1], 10) <= idx + 499) break
}
return pages
}
async function isbnsOf(mdId) {
return stored(`isbns:${mdId}`, v => (Object.keys(v).length ? 30 * DAY : 7 * DAY), async () => {
const d = await mdVolumes(mdId)
if (d.original !== 'ja' || !d.jaTitles.length) return {}
for (const t of d.jaTitles.slice(0, 3)) {
const found = pickIsbns(await ndlSearch(t), d.jaTitles, d.year)
if (Object.keys(found).length) return found
}
return {}
}, true)
}
async function englishIsbns(mdId, vol) {
const titles = (await mdVolumes(mdId)).enTitles.slice(0, 3)
if (!titles.length) return []
await turn()
net.guard()
const url = `${OPEN_LIBRARY}?${new URLSearchParams({ title: `${titles[0]}, Vol. ${vol}`, limit: '10', fields: 'title,isbn' })}`
const r = await fetch(url, { headers: { 'User-Agent': UA }, signal: AbortSignal.timeout(30000) }).catch(() => { net.failed(); return null })
if (!r?.ok) throw new HttpError(502, 'No se pudo consultar Open Library')
return pickEnglishIsbns((await r.json()).docs || [], titles, vol).slice(0, 4)
}
// ---------- Imágenes de un tomo ----------
async function fetchFace(mdId, vol, side) {
const volNum = /^\d+$/.test(vol) ? vol : null
if (side === 'front') {
const c = (await mdVolumes(mdId)).covers[vol]
if (c) {
const b = await fetchImage(`https://uploads.mangadex.org/covers/${mdId}/${c.file}`)
if (b) return b
}
}
if (!volNum) return null
for (const isbn of (await isbnsOf(mdId))[volNum] || []) {
const b = await fetchImage(`${AMAZON}/${isbn}.01.${side === 'back' ? 'BACK.' : ''}_SCRM_.jpg`)
if (b) return b
}
if (side === 'back') {
for (const isbn of await englishIsbns(mdId, volNum)) {
const b = await fetchImage(`${AMAZON}/${isbn}.01.BACK._SCRM_.jpg`)
if (b) return b
}
}
return null
}
const inflight = new Map()
function face(mdId, vol, side) {
const label = side === 'back' ? 'back2' : side // back2: las anteriores podían ser de un ómnibus
const file = path.join(DIR, mdId, `${crypto.createHash('sha1').update(vol).digest('hex').slice(0, 16)}-${label}.img`)
if (fs.existsSync(file)) return Promise.resolve(file)
const key = `tome:${mdId}:${vol}:${label}`
if (!inflight.has(key)) {
// Lo que no existe se recuerda un tiempo; un fallo de la red no, para reintentar.
inflight.set(key, stored(key, 14 * DAY, async () => {
const b = await fetchFace(mdId, vol, side)
if (!b) return { found: false }
fs.mkdirSync(path.dirname(file), { recursive: true })
const tmp = `${file}.${process.pid}.tmp`
fs.writeFileSync(tmp, b)
fs.renameSync(tmp, file)
return { found: true }
}, true).then(() => (fs.existsSync(file) ? file : null)).finally(() => inflight.delete(key)))
}
return inflight.get(key)
}
// ---------- Logo de la obra (Wikimedia Commons), para el título del lomo ----------
const COMMONS = 'https://commons.wikimedia.org/w/api.php'
let commonsRest = 0 // si Commons pide calma, no se le pregunta nada en un rato (y no se guarda el fallo)
async function commonsGet(params) {
if (Date.now() < commonsRest) throw new HttpError(503, 'Wikimedia Commons pidió esperar')
await turn()
net.guard()
const r = await fetch(`${COMMONS}?${new URLSearchParams({ ...params, format: 'json', formatversion: '2', maxlag: '5' })}`, { headers: { 'User-Agent': UA }, signal: AbortSignal.timeout(30000) }).catch(() => { net.failed(); return null })
const text = r?.ok ? await r.text() : ''
if (r?.status === 429 || (r?.ok && !text.startsWith('{')) || /"code":\s*"maxlag"/.test(text)) commonsRest = Date.now() + 10 * 60e3
const data = text.startsWith('{') ? JSON.parse(text) : null
if (!data || data.error) throw new HttpError(502, 'No se pudo consultar Wikimedia Commons')
return data
}
async function commonsLogo(names) {
const queries = [...new Set([...names.slice(0, 3), ...names.slice(0, 1).flatMap(n => logoWords(n).sort((a, b) => b.length - a.length).slice(0, 1))])]
for (const q of queries) {
const res = await commonsGet({ action: 'query', list: 'search', srnamespace: '6', srsearch: `${q} logo`, srlimit: '15' })
const best = pickLogo((res.query?.search || []).map(h => h.title), names)
if (!best) continue
const info = await commonsGet({ action: 'query', titles: best, prop: 'imageinfo', iiprop: 'url|size|mime', iiurlwidth: '960' }) // Wikimedia solo sirve miniaturas de ciertos anchos (960 sí, 800 no)
const ii = info.query?.pages?.[0]?.imageinfo?.[0]
if (ii && ii.width >= ii.height * 1.2) return ii.thumburl || ii.url || null // un logotipo es apaisado
}
return null
}
// Una vez por obra; lo no encontrado se vuelve a buscar a la semana.
function logo(mangaId, lang) {
return stored(`logo:${mangaId}`, v => (v ? 60 : 7) * DAY, async () => {
const { manga } = await fetchMangaFull(mangaId, lang)
if (hasNoVolumes(manga.type)) return null
const ids = sourceOf(mangaId) === 'md' ? [mangaId] : (await mdMatches(mangaId).catch(() => [])).slice(0, 1)
const data = ids.length ? await mdVolumes(ids[0], lang).catch(() => null) : null
const names = [...new Set([workTitle(manga.title), manga.title, ...(data?.enTitles || []), ...(data?.roTitles || [])])].filter(Boolean)
return commonsLogo(names)
}, true)
}
// ---------- API ----------
async function list(mangaId, lang) {
const { manga, chapters } = await fetchMangaFull(mangaId, lang)
if (hasNoVolumes(manga.type)) return []
const source = sourceOf(mangaId)
// Una obra puede estar varias veces en MangaDex (coloreada, por ejemplo): vale la que más datos tiene de estos capítulos.
const found = await mdMatches(mangaId).catch(() => [])
const ids = [...new Set(source === 'md' ? [mangaId, ...found] : found)].slice(0, 4)
const editions = (await Promise.all(ids.map(async id => ({ id, data: await mdVolumes(id, lang).catch(() => null) })))).filter(e => e.data)
const score = e => chapters.filter(c => e.data.byNum[numKey(c.num)]).length + Object.keys(e.data.covers).length
const best = editions.reduce((a, e) => (!a || score(e) > score(a) ? e : a), null)
if (hasNoVolumes(null, best?.data.original)) return [] // la fuente no siempre dice el tipo
const mdId = source === 'md' ? mangaId : best?.id || null
const data = editions.find(e => e.id === mdId)?.data || null
const own = cleanVolMap(ownVolMap(chapters))
let vols
if (numbersRestart(chapters)) vols = chapters.map(c => volKey(c.volume))
else {
const names = [...new Set([workTitle(manga.title), manga.title, ...(best?.data.enTitles || []), ...(best?.data.roTitles || [])])]
const [wiki, mi] = await Promise.all([
wikiMap(mangaId, names).catch(() => ({})),
source === 'mi' ? own : m440Map(mangaId, names).catch(() => ({})),
])
const byNum = cleanVolMap(best?.data.byNum || {})
const main = { wiki: cleanVolMap(wiki), md: source === 'md' ? { ...byNum, ...own } : byNum }
// Con Wikipedia y MangaDex casi completos, lo que falta son los capítulos aún sin tomo: no hace falta preguntar más.
const known = { ...main.wiki, ...main.md }
const missing = chapters.filter(c => !(numKey(c.num) in known)).length
const mu = missing > chapters.length * 0.1 ? fillVolGaps(orderedMuMap(cleanVolMap(await muVolumes(mangaId).catch(() => ({}))), chapters.length), chapters.map(c => numKey(c.num))) : {}
vols = assignVolumes(chapters, mergeVolMaps({ ...main, mu, mi: cleanVolMap(mi) }))
}
vols = placeLoose(chapters, vols, Object.keys(data?.covers || {}))
const groups = new Map()
vols.forEach((vol, i) => {
if (!vol) return
if (!groups.has(vol)) groups.set(vol, [])
groups.get(vol).push(chapters[i].id)
})
// Páginas del tomo, para el grosor del lomo en el estante. Si falta algún capítulo por contar, no se da.
const pagesOf = ids => { let n = 0; for (const id of ids) { const p = chapters.find(c => c.id === id)?.pages; if (!(p > 0)) return null; n += p } return n }
return [...groups.keys()].sort(volOrder).map(vol => {
const cover = data?.covers[vol]
return {
vol,
chapters: groups.get(vol),
pages: pagesOf(groups.get(vol)),
thumb: cover ? imgUrl(`https://uploads.mangadex.org/covers/${mdId}/${cover.file}.256.jpg`) : null,
art: cover ? imgUrl(`https://uploads.mangadex.org/covers/${mdId}/${cover.file}.512.jpg`) : null, // para sacar el arte del lomo
front: mdId && VOL.test(vol) ? `/api/tome/${mdId}/${encodeURIComponent(vol)}/front` : null,
back: mdId && VOL.test(vol) ? `/api/tome/${mdId}/${encodeURIComponent(vol)}/back` : null,
}
})
}
async function sendFace(res, mdId, vol, side) {
if (!MD_ID.test(mdId) || !VOL.test(vol) || !['front', 'back'].includes(side)) throw new HttpError(404, 'Cubierta no encontrada')
const file = await face(mdId, vol, side)
if (!file) throw new HttpError(404, 'Este tomo no tiene esa cubierta')
const head = Buffer.alloc(16)
const fd = fs.openSync(file, 'r')
fs.readSync(fd, head, 0, 16, 0)
fs.closeSync(fd)
res.writeHead(200, { 'Content-Type': imageKind(head) || 'application/octet-stream', 'Cache-Control': 'private, max-age=2592000', 'X-Content-Type-Options': 'nosniff' })
fs.createReadStream(file).pipe(res)
}
return { list, sendFace, logo: async (mangaId, lang) => { const u = await logo(mangaId, lang); return u ? imgUrl(u) : null } }
}
+1 -1
View File
@@ -40,7 +40,7 @@ services:
- "${RENDER_GID:-render}"
ports:
# 0.0.0.0 no depende de que la IP de la LAN o de Tailscale existan al arrancar.
# Para limitarlo a una interfaz: BIND_IP=192.168.1.141 en .env.
# Para limitarlo a una interfaz: BIND_IP=192.0.2.10 en .env.
- "${BIND_IP:-0.0.0.0}:${HOST_PORT:-3352}:3352"
read_only: true
tmpfs: