From e40127265f00e37da6e9ac02b166ed2cc3c4b9df Mon Sep 17 00:00:00 2001 From: Nino Date: Fri, 6 Nov 2026 09:42:34 -0600 Subject: [PATCH] Manga Reader: lector web autoalojado con modo libro 3D y estante --- .dockerignore | 4 + .env.example | 35 + .gitignore | 17 + Dockerfile | 27 + README.md | 177 + app/books.js | 603 ++++ app/cbz.js | 129 + app/clips.js | 192 + app/connectivity.js | 53 + app/covers.js | 115 + app/credits.js | 92 + app/features.js | 383 ++ app/library.js | 168 + app/m440-key.mjs | 55 + app/m440.js | 190 + app/meta.js | 317 ++ app/public/animeface-cascade.json | 1 + app/public/animeface.js | 97 + app/public/app.js | 4585 ++++++++++++++++++++++++ app/public/apple-touch-icon.png | Bin 0 -> 1503 bytes app/public/book3d.js | 1066 ++++++ app/public/fonts/LICENSE.txt | 3 + app/public/fonts/caveat.woff2 | Bin 0 -> 51220 bytes app/public/fonts/gochihand.woff2 | Bin 0 -> 19908 bytes app/public/fonts/indieflower.woff2 | Bin 0 -> 19508 bytes app/public/fonts/kalam.woff2 | Bin 0 -> 22144 bytes app/public/fonts/patrickhand.woff2 | Bin 0 -> 23944 bytes app/public/fonts/permanentmarker.woff2 | Bin 0 -> 29564 bytes app/public/fonts/shadows.woff2 | Bin 0 -> 16296 bytes app/public/icon-192.png | Bin 0 -> 2072 bytes app/public/icon-512.png | Bin 0 -> 6438 bytes app/public/icon.svg | 1 + app/public/index.html | 37 + app/public/manifest.webmanifest | 17 + app/public/shelf3d.js | 1353 +++++++ app/public/style.css | 513 +++ app/public/sw.js | 80 + app/security.js | 148 + app/server.js | 1222 +++++++ app/shelf.js | 36 + app/upscale.js | 280 ++ app/volumes.js | 770 ++++ data/.keep | 0 docker-compose.yml | 56 + mangas/.keep | 0 45 files changed, 12822 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/books.js create mode 100644 app/cbz.js create mode 100644 app/clips.js create mode 100644 app/connectivity.js create mode 100644 app/covers.js create mode 100644 app/credits.js create mode 100644 app/features.js create mode 100644 app/library.js create mode 100644 app/m440-key.mjs create mode 100644 app/m440.js create mode 100644 app/meta.js create mode 100644 app/public/animeface-cascade.json create mode 100644 app/public/animeface.js create mode 100644 app/public/app.js create mode 100644 app/public/apple-touch-icon.png create mode 100644 app/public/book3d.js create mode 100644 app/public/fonts/LICENSE.txt create mode 100644 app/public/fonts/caveat.woff2 create mode 100644 app/public/fonts/gochihand.woff2 create mode 100644 app/public/fonts/indieflower.woff2 create mode 100644 app/public/fonts/kalam.woff2 create mode 100644 app/public/fonts/patrickhand.woff2 create mode 100644 app/public/fonts/permanentmarker.woff2 create mode 100644 app/public/fonts/shadows.woff2 create mode 100644 app/public/icon-192.png create mode 100644 app/public/icon-512.png create mode 100644 app/public/icon.svg create mode 100644 app/public/index.html create mode 100644 app/public/manifest.webmanifest create mode 100644 app/public/shelf3d.js create mode 100644 app/public/style.css create mode 100644 app/public/sw.js create mode 100644 app/security.js create mode 100644 app/server.js create mode 100644 app/shelf.js create mode 100644 app/upscale.js create mode 100644 app/volumes.js create mode 100644 data/.keep create mode 100644 docker-compose.yml create mode 100644 mangas/.keep diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..aab752e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +data +mangas +.git +.env diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9bcc680 --- /dev/null +++ b/.env.example @@ -0,0 +1,35 @@ +# Copia a .env y ajusta. Todas son opcionales. + +# Usuario/grupo dueño de ./data y de la carpeta de mangas en el host (salida de `id -u` / `id -g`). +PUID=1000 +PGID=1000 + +# Dónde guardar los mangas descargados en el host (por defecto ./mangas dentro del proyecto). +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://localhost:3352 + +# Registro de cuentas nuevas (con la base vacía, el primer usuario siempre puede registrarse). +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=UTC +CHECK_INTERVAL_HOURS=3 + +# --- Exponer a internet --- +# Baneo de IP: tras 6 intentos fallidos (contraseña o registro cerrado) en 60 min, 24 h bloqueada. +# La red local, loopback y Tailscale (100.64.0.0/10) nunca se bloquean. BAN_IGNORE: más IPs, separadas por comas. +BAN_MAX_FAILS=6 +BAN_WINDOW_MIN=60 +BAN_HOURS=24 +# BAN_IGNORE=203.0.113.7 +# Si hay un proxy inverso delante (recomendado, con HTTPS): cabecera con la IP real del cliente. +# Caddy/Nginx: x-forwarded-for · Cloudflare Tunnel: cf-connecting-ip. Sin proxy, déjalo vacío. +# REAL_IP_HEADER=x-forwarded-for diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6e81f30 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# Datos de ejecución: usuarios, sesiones, historial y clave HMAC del proxy de imágenes +data/* +!data/.keep +# 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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..865a419 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +# Real-ESRGAN (ncnn-vulkan) para el reescalado HD. Solo se guardan los modelos de anime ×2 (capítulos) y ×4 (recortes). +FROM debian:trixie-slim AS esrgan +ARG ESRGAN_URL=https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.5.0/realesrgan-ncnn-vulkan-20220424-ubuntu.zip +ARG ESRGAN_SHA256=e5aa6eb131234b87c0c51f82b89390f5e3e642b7b70f2b9bbe95b6a285a40c96 +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl unzip && rm -rf /var/lib/apt/lists/* +RUN curl -fsSL -o /tmp/esr.zip "$ESRGAN_URL" \ + && echo "$ESRGAN_SHA256 /tmp/esr.zip" | sha256sum -c - \ + && mkdir -p /opt/realesrgan/models && cd /tmp && unzip -q esr.zip -d esr \ + && install -m 755 esr/realesrgan-ncnn-vulkan /opt/realesrgan/ \ + && cp esr/models/realesr-animevideov3-x2.* esr/models/realesr-animevideov3-x4.* /opt/realesrgan/models/ + +# Versión fijada: node:sqlite aún es experimental y una actualización silenciosa podría romperlo. +# Debian (no Alpine): Real-ESRGAN necesita glibc y los controladores Vulkan de Mesa. +FROM node:24.22.2-trixie-slim +ENV NODE_ENV=production NODE_NO_WARNINGS=1 \ + UPSCALER_BIN=/opt/realesrgan/realesrgan-ncnn-vulkan \ + MESA_SHADER_CACHE_DIR=/tmp/mesa-cache +RUN apt-get update && apt-get install -y --no-install-recommends tzdata libvulkan1 mesa-vulkan-drivers libgomp1 webp \ + && rm -rf /var/lib/apt/lists/* +COPY --from=esrgan /opt/realesrgan /opt/realesrgan +WORKDIR /app +COPY app/ ./ +# Usuario por defecto; docker-compose.yml lo sustituye por PUID:PGID. +USER node +EXPOSE 3352 +HEALTHCHECK --interval=60s --timeout=5s --start-period=20s CMD node -e "fetch('http://127.0.0.1:3352/api/me').then(r=>process.exit(r.status<500?0:1),()=>process.exit(1))" +CMD ["node", "server.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..d7fc0c3 --- /dev/null +++ b/README.md @@ -0,0 +1,177 @@ +# Manga Reader + +Lector web de manga autoalojado, con las fuentes de +[Tanko](https://github.com/Alexandro521/Tanko) (`src/servers/`) más M440: + +- **LeerCapitulo** (español, por defecto): mucho más catálogo. El scraper está reescrito para el + sitio actual y ya no necesita navegador headless (las páginas vienen en el HTML). +- **MangaDex** (multi-idioma): español = es-la + es combinados. Muchos títulos populares no tienen + capítulos por licencias; la ficha ofrece buscarlos en LeerCapitulo o M440. +- **M440** (m440.in, español): búsqueda (mínimo 3 letras), populares de su portada y actualizados + (paginado); sin categorías, porque ese listado está detrás del desafío de Cloudflare. La lista de + capítulos viene cifrada: la clave se saca de un script ofuscado del sitio ejecutándolo en un + proceso aparte sin permisos de archivos ni procesos (`app/m440-key.mjs`) y se vuelve a obtener sola + si el sitio la cambia. +- Búsqueda, populares y actualizados recientemente. +- Lector por página (toques/flechas, izq→der o der→izq) o en vertical, modo ahorro de datos. +- 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 + `./mangas//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 + `CHECK_INTERVAL_HOURS` (3 h). El inicio muestra "N caps. nuevos", la ficha marca NUEVO, los + mangas con copia local descargan lo nuevo solos, y se puede avisar por ntfy o Telegram (Ajustes). +- **Lector**: una página, doble página (tablet horizontal/PC; portada y páginas apaisadas van solas) + o vertical. Modo, ajuste y dirección se recuerdan por manga (por defecto: manhwa/manhua en + vertical, manga por página de derecha a izquierda). Zoom con pellizco, doble toque en el centro, + Ctrl+rueda o + / − / 0. Al pasar página la hoja se dobla como papel y, al deslizar, sigue al dedo; + suena un roce de papel elegido al azar entre 6 variantes sintetizadas en el navegador (volumen o + sin sonido en las opciones del lector). +- **Reescalado HD**: en la ficha de un manga descargado, "Reescalar a HD" pasa sus capítulos por + Real-ESRGAN (`realesr-animevideov3` ×2, en la GPU por Vulkan) y guarda la versión HD en + `/.hd/` (carpeta oculta: el CBZ original no se toca y Kavita/Komga no ven duplicados). + Con el manga marcado, los capítulos que se descarguen después también se reescalan. La lista de + capítulos marca **HD**, el lector usa la versión HD (se puede desactivar en sus ajustes) y desde + allí también se reescala un capítulo suelto. En una Intel HD 630 tarda ~13 s por página y cada + página ocupa ~2,5× más (WebP calidad 85). +- **Portadas HD**: las portadas de los mangas que sigues o tienes en el historial se reescalan solas en + segundo plano (×4 las pequeñas, ×2 el resto; ~4 s cada una) y desde entonces se sirven en HD. Las + de búsquedas y populares se quedan como están. Caché en `data/covers/` (se puede borrar: se regeneran). +- **Recortes**: en el lector, ✂ y arrastrar sobre un panel lo guarda en "Recortes" con su manga, + capítulo y página ("Ir a la página" lleva directo allí). Se recorta del original y solo el recorte se + reescala con Real-ESRGAN (×4, o ×2 si ya mide más de 1200 px; unos segundos). Se guardan en + `data/clips/` (`.png` original y `-hd.webp`), con nota opcional y descarga. +- **Colas** (antes "Descargas"): descargas en curso y en espera, cola de reescalado HD por manga + (capítulo y página en curso, siguientes, tiempo restante, errores con "Reintentar", "Detener"), + recortes y portadas pendientes, y debajo la biblioteca descargada con su tamaño; todo en tarjetas + verticales con portada, como el historial. +- **Librería** (antes "Historial"): pestañas Leyendo, Siguiendo, Terminados, En el dispositivo y + listas propias (Favoritos, Pendientes, Abandonados y las que crees). Desde la ficha, el botón + "Listas" añade o quita el manga de cada lista. +- **Recomendado para ti** (inicio): populares de MangaDex con capítulos en tu idioma dentro de tus 3 + géneros más leídos, sin lo que ya tienes en la librería. +- **Descarga anticipada** (Ajustes): al pasar la mitad de un capítulo, el servidor baja los N + siguientes (por defecto 2) a la copia local y, si se marca, los reescala a HD. +- **App instalable y lectura sin conexión**: manifest + service worker. En la ficha, "En este + dispositivo" guarda capítulos (páginas incluidas) en el navegador para leerlos sin internet; el + progreso leído sin red se envía al volver. **Requiere HTTPS** (o `localhost`): por `http://IP:puerto` + el navegador no permite service workers. Por ejemplo `tailscale serve` o un proxy con certificado. +- **Estadísticas**: rachas, capítulos y páginas por semana, calendario de actividad, géneros + 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 +cp .env.example .env # opcional: ajusta rutas, puerto, usuario… +docker compose up -d --build +# http://:3352 +``` + +El contenedor no depende del sistema operativo: imagen Node fijada (`node:24.22.2-trixie-slim`, +con Mesa Vulkan y Real-ESRGAN verificado por SHA-256), sin dependencias npm, y todo lo propio de la máquina está en `.env` (ver `.env.example`). +Sin `.env` arranca igual: puerto 3352 en todas las interfaces y descargas en `./mangas`. + +El primer usuario puede registrarse siempre; después el registro queda cerrado salvo +`ALLOW_SIGNUP=true` en `.env`. + +## Info de la obra y fechas de los capítulos + +La ficha de cada manga incluye datos de [AniList](https://anilist.co) y +[MangaUpdates](https://www.mangaupdates.com): autores, revista y editorial, fechas de publicación, tomos, +puntuaciones, rankings, adaptaciones al anime, obras relacionadas, recomendaciones, enlaces oficiales y la +imagen de fondo de la obra. +La obra se identifica por los enlaces de MangaDex; si el manga viene de LeerCapitulo o M440, por su título +(solo si coincide, para no mostrar datos de otra obra). + +Las fechas de la lista de capítulos son las de la **primera traducción conocida** de cada capítulo +(MangaDex en cualquier idioma y MangaUpdates), que en series en curso es muy cercana a la de publicación; +los sitios en español suelen poner la fecha en que ellos lo subieron. Las marcadas con «≈» son estimadas +entre dos capítulos con fecha. + +Todo se guarda en la base de datos, una vez por manga y para todos los usuarios: lo guardado se muestra sin +consultar nada y, si es antiguo (info 30 días, fechas 1 día), se actualiza en segundo plano. Los mangas que +sigue algún usuario se completan y se mantienen al día solos, aunque nadie abra su ficha. + +La imagen de fondo de la ficha se reescala con Real-ESRGAN (×4 si mide menos de 1000 px de ancho, ×2 si no). Si la obra +no tiene, se usa uno de tus recortes de ese manga; sin recortes, la ficha va sin imagen de fondo. + +En la ficha solo quedan a la vista «Continuar/Empezar a leer» y «Capítulos». El botón **⋯** abre el menú lateral con +seguir, listas, recortes, buscar en otras fuentes, dejar de leer, la copia local, el reescalado HD, «En este dispositivo» +y los datos de la obra (autores, editorial, publicación…). + +## Sin internet en el servidor + +Si el servidor pierde la conexión a internet (pero tú sigues llegando a él por la red local o +Tailscale), la app pasa sola a **modo sin conexión**: + +- Arriba aparece el aviso «El servidor no tiene internet». La portada muestra lo **descargado en el + servidor** con un buscador, y «Continuar leyendo» solo enseña esos mangas. +- Fichas y capítulos descargados funcionan como siempre, sin esperas. Lo que no está descargado + avisa al momento en vez de quedarse cargando. +- Las portadas salen de la copia local o de la caché HD. +- Las descargas en cola se pausan (no cuentan como fallidas). La comprobación de capítulos nuevos y la + descarga anticipada esperan. +- El servidor vuelve a comprobar la conexión cada 20 s. Al volver internet, todo se reanuda solo y + la portada vuelve a la normalidad. + +Se detecta cuando una fuente falla por red y ninguno de MangaDex, LeerCapitulo, M440 ni 1.1.1.1 +responde (`app/connectivity.js`). + +## Exponerlo a internet + +- **Pon HTTPS delante** (Caddy, Nginx, Cloudflare Tunnel o `tailscale funnel`): sin HTTPS la contraseña + y la cookie viajan en claro. Con HTTPS la cookie lleva `Secure` y se envía HSTS. +- Con un proxy inverso, define `REAL_IP_HEADER` en `.env` (`x-forwarded-for` en Caddy/Nginx, + `cf-connecting-ip` en Cloudflare) para que el baneo vea la IP real. Solo se cree esa cabecera si la + conexión llega desde una IP privada (el proxy), así que desde internet no se puede falsificar. +- **Baneo de IP**: 6 intentos fallidos (contraseña incorrecta, registro con el registro cerrado, + contraseña actual incorrecta) en 60 min bloquean la IP 24 h para toda la web. Ajustable con + `BAN_MAX_FAILS`, `BAN_WINDOW_MIN`, `BAN_HOURS`; red local, loopback y Tailscale nunca se bloquean. + En Ajustes se ven las IPs bloqueadas y se pueden desbloquear. +- Registro cerrado por defecto (`ALLOW_SIGNUP=false`); contraseñas de 8+ caracteres; cambiar la + contraseña cierra las demás sesiones. +- Cabeceras: CSP estricta (sin scripts en línea), `X-Frame-Options: DENY`, `nosniff`, `Referrer-Policy`. +- El proxy de imágenes solo descarga URLs firmadas por el servidor y nunca hacia IPs privadas + (comprobado también en cada redirección). Los errores internos no se muestran al cliente. + +## Qué guardar / mover a otro servidor + +| Qué | Dónde | Contenido | +|---|---|---| +| 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` (por defecto `./mangas`) | CBZ + portadas | + +Pasos en el sistema nuevo: + +```bash +git clone manga-reader && cd manga-reader +# copiar .env y data/ desde el servidor anterior (con el contenedor parado), y la carpeta de mangas +id -u; id -g # si no son 1000, ponlos en PUID/PGID del .env +docker compose up -d --build +``` + +Para el reescalado HD el host necesita `/dev/dri` (GPU Intel/AMD con Mesa) y `RENDER_GID` en `.env` +con el grupo `render`. Sin GPU, quita `devices`/`group_add` del compose: todo lo demás funciona igual. + +Si el usuario del host no coincide con PUID/PGID, el contenedor no arranca y el log dice +exactamente qué `chown` hacer. Si la carpeta de mangas no existe, Docker la crea como root: +créala antes (`mkdir -p` con tu usuario) para que las descargas funcionen. diff --git a/app/books.js b/app/books.js new file mode 100644 index 0000000..b5176db --- /dev/null +++ b/app/books.js @@ -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~", capítulo "gb~~". +// - Wikisource en español, con el EPUB que genera ws-export. IDs: "ws~", "ws~~". +// - Los EPUB que sube cada usuario, solo visibles para él. IDs: "ub~<32 hex>", "ub~<32 hex>~". +// En /books/ se guardan el EPUB (de ahí salen las imágenes) y el libro ya convertido. +import fs from 'node:fs' +import zlib from 'node:zlib' +import 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 = /||<[?!][^>]*>|<(\/?)([a-zA-Z][a-zA-Z0-9:_-]*)((?:[^<>"']|"[^"]*"|'[^']*')*)>/g + +// onAnchor(id) se llama con cada id del documento; devuelve true si ahí empieza un capítulo. +function xhtmlToBlocks(xml, file, imageOf, onBlock, onAnchor) { + let skip = 0, skipTag = null + let italic = 0, bold = 0, quote = 0, center = 0, verse = 0 + const stack = [] // etiquetas de bloque con estilo, para deshacerlo al cerrarlas + let runs = [], heading = 0 + const flush = () => { + // Espacios del código fuente fuera; los saltos de línea reales (
) son \n. + const out = [] + let atStart = true + for (const [text, style] of runs) { + let t = text === '\n' ? text : text.replace(/[ \t\r\n\f]+/g, ' ') // el \n solo viene de un
+ if (atStart) t = t.replace(/^ +/, '') + if (!t) continue + atStart = t.endsWith('\n') + const last = out.at(-1) + if (last && last[1] === style) last[0] += t + else out.push([t, style]) + } + while (out.length) { + const last = out.at(-1) + last[0] = last[0].replace(/[ \n]+$/, '') + if (last[0]) break + out.pop() + } + runs = [] + if (out.length && out.some(([t]) => t.trim())) { + const b = heading ? { k: 'h', l: heading, r: out } : { k: quote ? 'q' : 'p', r: out } + if (center || heading) b.c = 1 + if (verse || out.some(([t]) => t.includes('\n'))) b.v = 1 + onBlock(b) + } + } + const body = xml.search(/]/i) + let pos = body >= 0 ? body : 0 + TOKEN.lastIndex = pos + for (let m; (m = TOKEN.exec(xml));) { + if (!skip && m.index > pos) { + const text = entities(xml.slice(pos, m.index)) + if (text) runs.push([text, (italic ? 1 : 0) + (bold ? 2 : 0)]) + } + pos = TOKEN.lastIndex + const tag = m[2]?.toLowerCase().replace(/^.*:/, '') + if (!tag) { // comentario, DOCTYPE; CDATA se pierde a propósito (no es texto de libro) + continue + } + const closing = m[1] === '/' + const rawAttrs = m[3] || '' + const selfClosed = /\/\s*$/.test(rawAttrs) + if (skip) { + if (tag === skipTag) { + if (closing) { if (--skip === 0) skipTag = null } else if (!selfClosed) skip++ + } + continue + } + if (closing) { + if (BLOCK.has(tag)) { + flush() + const top = stack.findLastIndex(s => s.tag === tag) + if (top >= 0) { + for (const s of stack.splice(top)) { + quote -= s.quote; center -= s.center; verse -= s.verse; italic -= s.italic; bold -= s.bold + if (s.heading) heading = 0 + } + } + } else if (tag === 'i' || tag === 'em' || tag === 'cite' || tag === 'dfn') italic = Math.max(0, italic - 1) + else if (tag === 'b' || tag === 'strong') bold = Math.max(0, bold - 1) + continue + } + const id = attr(rawAttrs, 'id') ?? (tag === 'a' ? attr(rawAttrs, 'name') : null) + if (id && onAnchor(id, flush)) { /* el capítulo nuevo ya vació lo pendiente */ } + const cls = (attr(rawAttrs, 'class') || '').toLowerCase() + const type = (attr(rawAttrs, 'epub:type') || '').toLowerCase() + // Números de página de la edición impresa: ruido entre el texto. + if (SKIP.has(tag) || /\bpage-?num(ber)?\b|\bpageno\b/.test(cls) || type.includes('pagebreak') || attr(rawAttrs, 'hidden') !== null) { + if (!selfClosed && !VOID.has(tag)) { skip = 1; skipTag = tag } + continue + } + if (tag === 'br') { runs.push(['\n', 0]); continue } + if (tag === 'hr') { flush(); onBlock({ k: 'hr' }); continue } + if (tag === 'img' || tag === 'image') { + const src = attr(rawAttrs, 'src') ?? attr(rawAttrs, 'xlink:href') ?? attr(rawAttrs, 'href') + const n = src != null && !/^[a-z]+:/i.test(src) ? imageOf(resolveHref(file, src)) : null + if (n != null) { flush(); onBlock({ k: 'img', n }) } + continue + } + if (BLOCK.has(tag)) { + flush() + const style = attr(rawAttrs, 'style') || '' + const s = { + tag, + quote: tag === 'blockquote' ? 1 : 0, + center: tag === 'center' || /\bcent(er|re)\b/.test(cls) || /text-align\s*:\s*center/i.test(style) ? 1 : 0, + verse: tag === 'pre' || /\b(verse|poem|poetry|stanza|line-?block)\b/.test(cls) ? 1 : 0, + italic: 0, bold: 0, heading: 0, + } + if (/^h[1-6]$/.test(tag)) { heading = Number(tag[1]); s.heading = 1 } + quote += s.quote; center += s.center; verse += s.verse + if (!selfClosed) stack.push(s) + else { quote -= s.quote; center -= s.center; verse -= s.verse; if (s.heading) heading = 0 } + continue + } + if (selfClosed) continue + if (tag === 'i' || tag === 'em' || tag === 'cite' || tag === 'dfn') italic++ + else if (tag === 'b' || tag === 'strong') bold++ + else if (tag === 'td' || tag === 'th') { if (runs.length) runs.push([' ', 0]) } + } + if (!skip && pos < xml.length) { + const text = entities(xml.slice(pos).replace(/<[^>]*$/, '')) + if (text.trim()) runs.push([text, 0]) + } + flush() +} + +// Ancho y alto de una imagen PNG, GIF, JPEG o WebP, por su cabecera. +function imageSize(b) { + if (b.length > 24 && b.readUInt32BE(0) === 0x89504e47) return { w: b.readUInt32BE(16), h: b.readUInt32BE(20) } + if (b.length > 10 && b.toString('latin1', 0, 3) === 'GIF') return { w: b.readUInt16LE(6), h: b.readUInt16LE(8) } + if (b.length > 4 && b[0] === 0xff && b[1] === 0xd8) { + for (let p = 2; p + 9 < b.length;) { + if (b[p] !== 0xff) { p++; continue } + const m = b[p + 1] + if (m >= 0xc0 && m <= 0xcf && m !== 0xc4 && m !== 0xc8 && m !== 0xcc) return { w: b.readUInt16BE(p + 7), h: b.readUInt16BE(p + 5) } + if (m === 0xff) { p++; continue } + p += 2 + b.readUInt16BE(p + 2) + } + } + if (b.length > 30 && b.toString('latin1', 0, 4) === 'RIFF' && b.toString('latin1', 8, 12) === 'WEBP') { + const kind = b.toString('latin1', 12, 16) + if (kind === 'VP8X') return { w: b.readUIntLE(24, 3) + 1, h: b.readUIntLE(27, 3) + 1 } + if (kind === 'VP8 ') return { w: b.readUInt16LE(26) & 0x3fff, h: b.readUInt16LE(28) & 0x3fff } + if (kind === 'VP8L') { const v = b.readUInt32LE(21); return { w: (v & 0x3fff) + 1, h: ((v >> 14) & 0x3fff) + 1 } } + } + return {} +} + +// ---------- EPUB ---------- + +// Índice del libro en orden: [{ file, anchor, title }]. EPUB 3 (nav) o EPUB 2 (toc.ncx). +function readToc(read, opf, manifest) { + const navItem = [...manifest.values()].find(it => /\bnav\b/.test(it.props)) + if (navItem) { + const xml = read(navItem.path) + const nav = /]*(?:epub:)?type\s*=\s*["']toc["'][^>]*>([\s\S]*?)<\/nav>/i.exec(xml)?.[1] + if (nav) { + const out = [] + for (const m of nav.matchAll(/]*)>([\s\S]*?)<\/a>/gi)) { + const href = attr(m[1], 'href') + if (!href) continue + out.push({ file: resolveHref(navItem.path, href), anchor: href.split('#')[1] || null, title: clean(m[2]) }) + } + if (out.length) return out + } + } + const spineToc = /]*\btoc\s*=\s*["']([^"']+)["']/i.exec(opf)?.[1] + const ncx = manifest.get(spineToc) || [...manifest.values()].find(it => /ncx/.test(it.type)) + if (!ncx) return [] + const xml = read(ncx.path) + const out = [] + // Solo la etiqueta y el contenido propios de cada entrada: los navPoint se anidan. + for (const m of xml.matchAll(/]*>\s*]*>\s*]*>([\s\S]*?)<\/text>\s*<\/navLabel>\s*]*)\/?>/gi)) { + const src = attr(m[2], 'src') + if (src) out.push({ file: resolveHref(ncx.path, src), anchor: src.split('#')[1] || null, title: clean(m[1]) }) + } + return out +} +const clean = s => entities(String(s).replace(/<[^>]*>/g, ' ')).replace(/\s+/g, ' ').trim() + +export function parseEpub(file) { + const index = readZipIndex(file) + const entries = new Map(index.map(e => [e.name, e])) + if (index.reduce((n, e) => n + e.unzipped, 0) > MAX_UNZIPPED_TOTAL) throw new Error('EPUB demasiado grande al descomprimir') + 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(/]*)>/i.exec(container)?.[1] || '', 'full-path') + if (!opfPath) throw new Error('EPUB sin paquete') + const opf = read(opfPath) + const dc = name => clean(new RegExp(`]*>([\\s\\S]*?)`, 'i').exec(opf)?.[1] || '') + const manifest = new Map() + for (const m of opf.matchAll(/]*)>/gi)) { + const id = attr(m[1], 'id'), href = attr(m[1], 'href') + if (id && href) manifest.set(id, { path: resolveHref(opfPath, href), type: attr(m[1], 'media-type') || '', props: attr(m[1], 'properties') || '' }) + } + const spine = [...opf.matchAll(/]*)>/gi)].map(m => manifest.get(attr(m[1], 'idref'))).filter(it => it && /x?html/i.test(it.type)) + if (!spine.length) throw new Error('EPUB sin contenido') + let toc = [] + try { toc = readToc(read, opf, manifest) } catch {} + + const images = [] + const imageIdx = new Map() + const imageOf = name => { + const e = entries.get(name) + if (!e || !BOOK_IMAGE.test(name) || e.unzipped > MAX_UNZIPPED_ENTRY) return null + if (!imageIdx.has(name)) { imageIdx.set(name, images.length); images.push(name) } + return imageIdx.get(name) + } + + // Las entradas del índice que apuntan a cada archivo. Un capítulo empieza en su ancla, o en el archivo si no tiene. + const targets = new Map() + for (const t of toc) { + if (!targets.has(t.file)) targets.set(t.file, new Map()) + targets.get(t.file).set(t.anchor, t.title) + } + const chapters = [] + const start = title => chapters.push({ title, blocks: [] }) + for (const item of spine) { + const here = targets.get(item.path) + if (here?.has(null) || (!toc.length)) start(here?.get(null) || '') + if (!chapters.length) start('') + xhtmlToBlocks(read(item.path), item.path, imageOf, + b => chapters.at(-1).blocks.push(b), + (id, flush) => { + if (!here?.has(id)) return false + flush() + start(here.get(id)) + here.delete(id) // un id repetido no abre otro capítulo + return true + }) + } + + // Limpieza: fuera los vacíos; un capítulo que solo trae su título ("Parte primera") se une al siguiente. + const merged = [] + let carry = null + for (const c of chapters) { + if (!c.blocks.length) continue + const blocks = carry ? [...carry.blocks, ...c.blocks] : c.blocks + const title = c.title || carry?.title || '' + carry = null + if (blocks.every(b => b.k === 'h' || b.k === 'hr') && c !== chapters.at(-1)) { carry = { title, blocks }; continue } + merged.push({ title, blocks }) + } + if (carry) merged.push(carry) + const out = merged + if (!out.length) throw new Error('EPUB sin texto') + // Portada: EPUB 3 la marca en el manifiesto y EPUB 2 con un , que a veces no apunta a una imagen. + const coverId = /]*\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(/]*>([\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 => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' }[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) => `${esc(t)}` + return `${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('')}` +} + +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]*?)`).exec(xml)?.[1] || '') + function parseFeed(xml) { + const out = [] + for (const [entry] of xml.matchAll(/[\s\S]*?<\/entry>/g)) { + const n = /[^<]*\/ebooks\/(\d+)\.opds<\/id>/.exec(entry)?.[1] + if (!n) continue // "Ordenar por…" y otras entradas de navegación + const id = `gb~${n}` + // El buscador añade el idioma al título: "Las Fábulas de Esopo (Spanish)". + const title = entryText(entry, 'title').replace(/\s*\([A-Z][a-z]+\)$/, '') + out.push({ id, source: 'gb', title, cover: coverOf(n, id), sub: entryText(entry, 'content') || undefined }) + } + return out + } + // lang del usuario ("es-la") a idioma del catálogo ("spanish"). + async function search(q, mode, page, lang) { + const name = LANG_NAMES[String(lang || 'es').slice(0, 2)] + const text = q.trim().replace(/\s+/g, ' ') + const qs = new URLSearchParams({ query: [text, name && `l.${name}`].filter(Boolean).join(' '), sort_order: mode === 'latest' && !text ? 'release_date' : 'downloads' }) + const start = (Math.max(1, page | 0) - 1) * OPDS_PAGE + 1 + if (start > 1) qs.set('start_index', String(start)) + return cached(`gb:${qs}`, 30 * 60e3, async () => parseFeed(await (await 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 } +} diff --git a/app/cbz.js b/app/cbz.js new file mode 100644 index 0000000..382ae5a --- /dev/null +++ b/app/cbz.js @@ -0,0 +1,129 @@ +// Lectura/escritura mínima de CBZ (ZIP) sin dependencias. +// Escribe sin compresión (las imágenes ya vienen comprimidas); lee entradas "stored" y "deflate". +import fs from 'node:fs' +import zlib from 'node:zlib' + +const IMAGE_EXT = /\.(jpe?g|png|webp|gif|avif)$/i + +// entries: [{ name, data: Buffer }]. Escribe a un temporal y renombra para no dejar CBZ a medias. +export function writeCbz(file, entries) { + const parts = [] + const central = [] + let offset = 0 + for (const { name, data } of entries) { + const nameBuf = Buffer.from(name, 'utf8') + const crc = zlib.crc32(data) + const local = Buffer.alloc(30) + local.writeUInt32LE(0x04034b50, 0) + local.writeUInt16LE(20, 4) // versión necesaria + local.writeUInt16LE(0x0800, 6) // nombres en UTF-8 + local.writeUInt16LE(0, 8) // sin compresión + local.writeUInt16LE(0, 10) // hora DOS + local.writeUInt16LE(0x21, 12) // fecha DOS (1980-01-01) + local.writeUInt32LE(crc, 14) + local.writeUInt32LE(data.length, 18) + local.writeUInt32LE(data.length, 22) + local.writeUInt16LE(nameBuf.length, 26) + local.writeUInt16LE(0, 28) + parts.push(local, nameBuf, data) + + const cd = Buffer.alloc(46) + cd.writeUInt32LE(0x02014b50, 0) + cd.writeUInt16LE(20, 4) + cd.writeUInt16LE(20, 6) + cd.writeUInt16LE(0x0800, 8) + cd.writeUInt16LE(0, 10) + cd.writeUInt16LE(0, 12) + cd.writeUInt16LE(0x21, 14) + cd.writeUInt32LE(crc, 16) + cd.writeUInt32LE(data.length, 20) + cd.writeUInt32LE(data.length, 24) + cd.writeUInt16LE(nameBuf.length, 28) + cd.writeUInt32LE(offset, 42) + central.push(cd, nameBuf) + offset += 30 + nameBuf.length + data.length + } + const cdSize = central.reduce((n, b) => n + b.length, 0) + const end = Buffer.alloc(22) + end.writeUInt32LE(0x06054b50, 0) + end.writeUInt16LE(entries.length, 8) + end.writeUInt16LE(entries.length, 10) + end.writeUInt32LE(cdSize, 12) + end.writeUInt32LE(offset, 16) + + const tmp = `${file}.part` + fs.writeFileSync(tmp, Buffer.concat([...parts, ...central, end])) + fs.renameSync(tmp, file) +} + +// 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) + const tailLen = Math.min(size, 65557) + 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('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 = [] + const lh = Buffer.alloc(30) + 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 + fs.readSync(fd, lh, 0, 30, localOffset) + const start = localOffset + 30 + lh.readUInt16LE(26) + lh.readUInt16LE(28) + entries.push({ name, method, start, size: compSize, unzipped }) + } + return entries + } finally { + fs.closeSync(fd) + } +} + +export function imageType(name) { + const ext = name.split('.').pop().toLowerCase() + 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. 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, maxBytes ? { maxOutputLength: maxBytes } : {}) : buf +} + +// Envía una entrada del CBZ por la respuesta HTTP. +export function sendCbzEntry(res, file, entry) { + const headers = { 'Content-Type': imageType(entry.name), 'Cache-Control': 'private, max-age=604800' } + if (entry.method === 0) { + res.writeHead(200, { ...headers, 'Content-Length': entry.size }) + fs.createReadStream(file, { start: entry.start, end: entry.start + entry.size - 1 }).pipe(res) + return + } + const data = readCbzEntry(file, entry) + res.writeHead(200, headers) + res.end(data) +} diff --git a/app/clips.js b/app/clips.js new file mode 100644 index 0000000..4666fb6 --- /dev/null +++ b/app/clips.js @@ -0,0 +1,192 @@ +// Recortes: trozos de página (un panel, una viñeta) que el usuario guarda desde el lector, con la +// referencia al manga/capítulo/página de donde salen. Cada recorte se reescala solo (Real-ESRGAN ×4, +// o ×2 si ya es grande) en segundo plano. Los archivos van a /clips/: .png (original +// tal como se recortó) y -hd.webp (reescalado). +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { BIN, MODELS, MODEL, run } from './upscale.js' + +const MAX_BYTES = 64 * 1024 * 1024 // un recorte largo de lectura vertical en PNG +const MAX_SIDE = 8000 // a ×2 cabe en el máximo de WebP (16383 px) +const X4_MAX_SIDE = 1200 // a partir de aquí ×2: ×4 daría imágenes enormes sin ganar detalle +const WEBP_QUALITY = 90 + +// Ancho y alto de un PNG (cabecera IHDR), o null si no es un PNG. +function pngSize(buf) { + if (buf.length < 24 || buf.readUInt32BE(0) !== 0x89504e47 || buf.toString('latin1', 12, 16) !== 'IHDR') return null + return { w: buf.readUInt32BE(16), h: buf.readUInt32BE(20) } +} + +export function setupClips({ db, DATA_DIR, HttpError, json, readBody, MANGA_ID, CHAPTER_ID, upscale }) { + const DIR = path.join(DATA_DIR, 'clips') + fs.mkdirSync(DIR, { recursive: true }) + db.exec(` + CREATE TABLE IF NOT EXISTS clips ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + manga_id TEXT NOT NULL, + manga_title TEXT NOT NULL, + cover TEXT, + chapter_id TEXT NOT NULL, + chapter_label TEXT, + page INTEGER NOT NULL, -- 0-based + width INTEGER NOT NULL, + height INTEGER NOT NULL, + scale INTEGER, -- 2 o 4 cuando ya está reescalado + note TEXT, + status TEXT NOT NULL, -- queued | running | done | error | off (sin GPU) + error TEXT, + created_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_clips_user ON clips(user_id, created_at); + `) + db.prepare("UPDATE clips SET status = 'queued' WHERE status = 'running'").run() // reinicio a mitad + + const file = (id, hd) => path.join(DIR, hd ? `${id}-hd.webp` : `${id}.png`) + const hasX4 = () => fs.existsSync(path.join(MODELS, `${MODEL}-x4.bin`)) + + async function upscaleClip(clip) { + const scale = Math.max(clip.width, clip.height) <= X4_MAX_SIDE && hasX4() ? 4 : 2 + const work = fs.mkdtempSync(path.join(os.tmpdir(), 'clip-')) + try { + const out = path.join(work, 'out.png') + await run(BIN, ['-i', file(clip.id), '-o', out, '-m', MODELS, '-n', MODEL, '-s', String(scale), '-f', 'png']) + const tmp = path.join(DIR, `${clip.id}-hd.tmp.webp`) + await run('cwebp', ['-quiet', '-q', String(WEBP_QUALITY), '-m', '4', '-mt', out, '-o', tmp]) + if (!db.prepare('SELECT 1 FROM clips WHERE id = ?').get(clip.id)) return fs.rmSync(tmp, { force: true }) // borrado mientras tanto + fs.renameSync(tmp, file(clip.id, true)) + db.prepare("UPDATE clips SET status = 'done', scale = ?, error = NULL WHERE id = ?").run(scale, clip.id) + } finally { + fs.rmSync(work, { recursive: true, force: true }) + } + } + + let pumping = false + async function pump() { + if (pumping || !upscale.available()) return + pumping = true + try { + for (;;) { + const clip = db.prepare("SELECT * FROM clips WHERE status = 'queued' ORDER BY id LIMIT 1").get() + if (!clip) break + db.prepare("UPDATE clips SET status = 'running' WHERE id = ?").run(clip.id) + const t = Date.now() + try { + await upscaleClip(clip) + console.log(`Recorte ${clip.id} (${clip.width}×${clip.height}) reescalado en ${((Date.now() - t) / 1000).toFixed(1)} s`) + } catch (e) { + console.warn('Recorte', clip.id, e.message) + db.prepare("UPDATE clips SET status = 'error', error = ? WHERE id = ?").run(e.message.slice(0, 500), clip.id) + } + } + } finally { + pumping = false + } + } + setTimeout(pump, 3000) // tras arrancar (cwebp se comprueba de forma asíncrona) + + const view = c => ({ + id: c.id, mangaId: c.manga_id, mangaTitle: c.manga_title, cover: c.cover, chapterId: c.chapter_id, chapterLabel: c.chapter_label, + page: c.page, width: c.width, height: c.height, scale: c.scale, note: c.note, status: c.status, error: c.error, createdAt: c.created_at, + img: `/api/clips/${c.id}/img`, hd: c.status === 'done' ? `/api/clips/${c.id}/img?hd=1` : null, + }) + const own = (userId, id) => { + const c = db.prepare('SELECT * FROM clips WHERE id = ? AND user_id = ?').get(id, userId) + if (!c) throw new HttpError(404, 'Recorte no encontrado') + return c + } + const count = (userId, mangaId) => db.prepare('SELECT COUNT(*) n FROM clips WHERE user_id = ? AND manga_id = ?').get(userId, mangaId).n + + // Banner de la ficha cuando la obra no tiene: un recorte del propio usuario, el más apaisado (es el que mejor + // llena la franja) y, si los hay, de entre los ya reescalados. + function bannerFor(userId, mangaId) { + const c = db.prepare(`SELECT * FROM clips WHERE user_id = ? AND manga_id = ? + ORDER BY status = 'done' DESC, CAST(width AS REAL) / height DESC, id DESC LIMIT 1`).get(userId, mangaId) + if (!c) return null + const v = view(c) + return v.hd || v.img + } + + async function readImage(req) { + let size = 0 + const chunks = [] + for await (const c of req) { + size += c.length + if (size > MAX_BYTES) throw new HttpError(413, 'El recorte es demasiado grande') + chunks.push(c) + } + return Buffer.concat(chunks) + } + + async function route(p, method, q, req, res, user) { + if (p === '/api/clips' && method === 'GET') { + const mangaId = q.get('manga') + const rows = mangaId + ? db.prepare('SELECT * FROM clips WHERE user_id = ? AND manga_id = ? ORDER BY id DESC').all(user.id, mangaId) + : db.prepare('SELECT * FROM clips WHERE user_id = ? ORDER BY id DESC').all(user.id) + const mangas = db.prepare(`SELECT manga_id id, manga_title title, COUNT(*) n, MAX(id) last FROM clips WHERE user_id = ? + GROUP BY manga_id ORDER BY last DESC`).all(user.id) + return json(res, 200, { clips: rows.map(view), mangas, available: upscale.available() }), true + } + if (p === '/api/clips' && method === 'POST') { + // Imagen PNG en el cuerpo; la referencia va en la query. + const mangaId = q.get('mangaId') || '', chapterId = q.get('chapterId') || '', page = Number(q.get('page')) + if (!MANGA_ID.test(mangaId) || !CHAPTER_ID.test(chapterId) || !Number.isInteger(page) || page < 0 || page > 5000) throw new HttpError(400, 'Referencia inválida') + const buf = await readImage(req) + const dim = pngSize(buf) + if (!dim) throw new HttpError(400, 'El recorte debe ser PNG') + if (dim.w < 16 || dim.h < 16 || dim.w > MAX_SIDE || dim.h > MAX_SIDE) throw new HttpError(400, `El recorte debe medir entre 16 y ${MAX_SIDE} px por lado`) + const cover = (q.get('cover') || '').slice(0, 2000) + const r = db.prepare(`INSERT INTO clips (user_id, manga_id, manga_title, cover, chapter_id, chapter_label, page, width, height, status, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run( + user.id, mangaId, (q.get('mangaTitle') || '').slice(0, 300) || 'Sin título', cover.startsWith('/api/') ? cover : null, chapterId, + (q.get('chapterLabel') || '').slice(0, 300), page, dim.w, dim.h, upscale.available() ? 'queued' : 'off', Date.now()) + const id = Number(r.lastInsertRowid) + fs.writeFileSync(file(id), buf) + pump() + return json(res, 200, view(own(user.id, id))), true + } + let m + if ((m = /^\/api\/clips\/(\d+)\/img$/.exec(p)) && method === 'GET') { + const c = own(user.id, Number(m[1])) + const hd = q.get('hd') === '1' && c.status === 'done' + const f = file(c.id, hd) + if (!fs.existsSync(f)) throw new HttpError(404, 'Imagen no encontrada') + const name = `${c.manga_title} - ${c.chapter_label || 'cap'} - p${c.page + 1}${hd ? ' HD' : ''}`.replace(/[\\/:*?"<>|]+/g, '_') + res.writeHead(200, { + 'Content-Type': hd ? 'image/webp' : 'image/png', 'Cache-Control': 'private, max-age=31536000, immutable', + ...(q.get('dl') === '1' ? { 'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(name)}.${hd ? 'webp' : 'png'}` } : {}), + }) + fs.createReadStream(f).pipe(res) + return true + } + if ((m = /^\/api\/clips\/(\d+)$/.exec(p))) { + const c = own(user.id, Number(m[1])) + if (method === 'GET') return json(res, 200, view(c)), true + if (method === 'PATCH') { + const b = await readBody(req) + if (b.note !== undefined) db.prepare('UPDATE clips SET note = ? WHERE id = ?').run(String(b.note).slice(0, 1000) || null, c.id) + return json(res, 200, view(own(user.id, c.id))), true + } + if (method === 'DELETE') { + db.prepare('DELETE FROM clips WHERE id = ?').run(c.id) + fs.rmSync(file(c.id), { force: true }) + fs.rmSync(file(c.id, true), { force: true }) + return json(res, 200, { ok: true }), true + } + } + if ((m = /^\/api\/clips\/(\d+)\/retry$/.exec(p)) && method === 'POST') { + const c = own(user.id, Number(m[1])) + if (!upscale.available()) throw new HttpError(503, 'El reescalado no está disponible (falta Real-ESRGAN o la GPU en el contenedor)') + if (c.status === 'error' || c.status === 'off') db.prepare("UPDATE clips SET status = 'queued', error = NULL WHERE id = ?").run(c.id) + pump() + return json(res, 200, view(own(user.id, c.id))), true + } + return false + } + + const queueInfo = userId => db.prepare(`SELECT SUM(status = 'queued') queued, SUM(status = 'running') running, SUM(status = 'error') errors + FROM clips WHERE user_id = ?`).get(userId) + return { route, count, bannerFor, pump, queueInfo } +} diff --git a/app/connectivity.js b/app/connectivity.js new file mode 100644 index 0000000..c82fa1f --- /dev/null +++ b/app/connectivity.js @@ -0,0 +1,53 @@ +// ¿Tiene el servidor salida a internet? Si las fuentes fallan por red, se comprueba contra varios sitios +// a la vez; si ninguno responde, el servidor pasa a "sin conexión": las peticiones a las fuentes fallan +// al instante (en vez de esperar timeouts y reintentos), las descargas y comprobaciones se pausan, y la +// app trabaja solo con lo descargado. Mientras dure, se vuelve a comprobar cada poco y, al volver la +// conexión, se reanuda todo solo. +const PROBES = ['https://api.mangadex.org/ping', 'https://www.leercapitulo.co/', 'https://m440.in/', 'https://1.1.1.1/'] +const EVERY_ONLINE = 3 * 60e3 +const EVERY_OFFLINE = 20e3 + +export function setupConnectivity({ httpError }) { + const state = { online: true, since: Date.now(), checkedAt: 0 } + const onOnline = [] + let probing = null + + // Cualquier respuesta HTTP (aunque sea un error) demuestra que hay internet. + const probeOne = u => fetch(u, { method: 'HEAD', redirect: 'manual', signal: AbortSignal.timeout(6e3) }).then(() => true, () => false) + function check() { + probing ??= (async () => { + const ok = (await Promise.all(PROBES.map(probeOne))).some(Boolean) + state.checkedAt = Date.now() + if (ok !== state.online) { + state.online = ok + state.since = Date.now() + console.log(ok ? 'Conexión a internet recuperada: se reanudan descargas y comprobaciones.' : 'Sin conexión a internet: modo sin conexión (solo lo descargado).') + if (ok) for (const f of onOnline) { try { f() } catch (e) { console.warn('Reconexión:', e.message) } } + } + return ok + })().finally(() => { probing = null }) + return probing + } + // Una fuente falló por red (no por un error HTTP): comprobar si es la fuente o es internet. + function failed() { + if (state.online && Date.now() - state.checkedAt > 10e3) check() + } + // Para cortar al instante las peticiones a las fuentes mientras no hay internet. + function guard() { + if (!state.online) throw httpError(503, 'El servidor no tiene conexión a internet: solo está disponible lo descargado.') + } + + // Sin conexión: cada 20 s. En línea solo hace falta confirmar de vez en cuando (los fallos de red ya + // disparan la comprobación). + setInterval(() => { + if (!state.online || Date.now() - state.checkedAt > EVERY_ONLINE) check().catch(() => {}) + }, EVERY_OFFLINE).unref() + check().catch(() => {}) + + return { + online: () => state.online, + status: () => ({ online: state.online, since: state.since, checkedAt: state.checkedAt }), + check, failed, guard, + whenOnline: f => onOnline.push(f), + } +} diff --git a/app/covers.js b/app/covers.js new file mode 100644 index 0000000..09b66b9 --- /dev/null +++ b/app/covers.js @@ -0,0 +1,115 @@ +// Portadas en HD: cada portada que se sirve (de la fuente o la cover.jpg de una descarga) se encola para +// reescalarla con Real-ESRGAN (×4 si es pequeña, ×2 si no). Mientras tanto se sirve la original con +// caché corta; en cuanto existe la versión HD se sirve esa. Caché en /covers/.webp. +// Los banners de la ficha pasan por lo mismo, pero se reescalan siempre: se ven a todo el ancho de la pantalla. +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import crypto from 'node:crypto' +import { BIN, MODELS, MODEL, run } from './upscale.js' + +const MAX_QUEUE = 300 +const X4_MAX_SIDE = 400 +const MAX_SIDE = 1600 // más grande ya no hace falta reescalarla +const WEBP_QUALITY = 88 +const BANNER_X4_MAX_WIDTH = 1000 + +// Ancho/alto de un JPEG, PNG o WebP sin decodificarlo (null si no se reconoce). +export function imageSize(b) { + if (b.length > 24 && b.readUInt32BE(0) === 0x89504e47) return { type: 'png', w: b.readUInt32BE(16), h: b.readUInt32BE(20) } + if (b.length > 30 && b.toString('latin1', 0, 4) === 'RIFF' && b.toString('latin1', 8, 12) === 'WEBP') { + const f = b.toString('latin1', 12, 16) + if (f === 'VP8 ') return { type: 'webp', w: b.readUInt16LE(26) & 0x3fff, h: b.readUInt16LE(28) & 0x3fff } + if (f === 'VP8L') { const n = b.readUInt32LE(21); return { type: 'webp', w: (n & 0x3fff) + 1, h: ((n >> 14) & 0x3fff) + 1 } } + if (f === 'VP8X') return { type: 'webp', w: b.readUIntLE(24, 3) + 1, h: b.readUIntLE(27, 3) + 1 } + return null + } + if (b[0] === 0xff && b[1] === 0xd8) { + for (let i = 2; i + 9 < b.length;) { + if (b[i] !== 0xff) { i++; continue } + const marker = b[i + 1] + if (marker === 0xd8 || (marker >= 0xd0 && marker <= 0xd7) || marker === 0x01 || marker === 0xff) { i += marker === 0xff ? 1 : 2; continue } + const len = b.readUInt16BE(i + 2) + if (marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker)) return { type: 'jpg', w: b.readUInt16BE(i + 7), h: b.readUInt16BE(i + 5) } + i += 2 + len + } + } + return null +} + +export function setupCovers({ DATA_DIR, upscale }) { + const DIR = path.join(DATA_DIR, 'covers') + fs.mkdirSync(DIR, { recursive: true }) + const fileOf = key => path.join(DIR, `${crypto.createHash('sha256').update(key).digest('hex').slice(0, 32)}.webp`) + const queue = [] // { key, buf } + const pending = new Set() + const failed = new Set() // no reintentar hasta reiniciar + + // Si ya hay versión HD, la envía y devuelve true. + function sendCached(res, key) { + if (!upscale.available()) return false + const f = fileOf(key) + if (!fs.existsSync(f)) return false + res.writeHead(200, { 'Content-Type': 'image/webp', 'Cache-Control': 'private, max-age=2592000, immutable' }) + fs.createReadStream(f).pipe(res) + return true + } + // Cabecera de caché para la original mientras no hay HD (corta, para que el navegador pida luego la HD). + // Un banner no se guarda: en la siguiente visita ya se ve el reescalado. + const pendingCache = (banner = false) => (!upscale.available() ? 'private, max-age=604800, immutable' : banner ? 'no-store' : 'private, max-age=300') + + // ¿Falta generar esta imagen? (para no descargarla si no hace falta) + const wants = key => upscale.available() && !pending.has(key) && !failed.has(key) && queue.length < MAX_QUEUE && !fs.existsSync(fileOf(key)) + function enqueue(key, buf, banner = false) { + if (!wants(key)) return + pending.add(key) + queue.push({ key, buf, banner }) + pump() + } + + async function processOne({ key, buf, banner }) { + const size = imageSize(buf) + if (!size) throw new Error('formato no reconocido') + const work = fs.mkdtempSync(path.join(os.tmpdir(), 'cover-')) + try { + const out = path.join(work, 'out.png') + const tmp = `${fileOf(key)}.tmp` + if (!banner && Math.max(size.w, size.h) > MAX_SIDE) { + // Ya es grande: solo se recomprime para no volver a intentarlo. + const inp = path.join(work, `in.${size.type}`) + fs.writeFileSync(inp, buf) + if (size.type === 'webp') fs.copyFileSync(inp, tmp) + else await run('cwebp', ['-quiet', '-q', String(WEBP_QUALITY), inp, '-o', tmp]) + } else { + const inp = path.join(work, `in.${size.type}`) + fs.writeFileSync(inp, buf) + const small = banner ? size.w < BANNER_X4_MAX_WIDTH : Math.max(size.w, size.h) <= X4_MAX_SIDE + const scale = small && fs.existsSync(path.join(MODELS, `${MODEL}-x4.bin`)) ? 4 : 2 + await run(BIN, ['-i', inp, '-o', out, '-m', MODELS, '-n', MODEL, '-s', String(scale), '-f', 'png']) + await run('cwebp', ['-quiet', '-q', String(WEBP_QUALITY), '-m', '4', '-mt', out, '-o', tmp]) + } + fs.renameSync(tmp, fileOf(key)) + } finally { + fs.rmSync(work, { recursive: true, force: true }) + } + } + + let pumping = false + async function pump() { + if (pumping) return + pumping = true + try { + let n = 0 + const t = Date.now() + while (queue.length) { + const job = queue.shift() + try { await processOne(job); n++ } catch (e) { failed.add(job.key); console.warn('Portada HD:', e.message) } finally { pending.delete(job.key) } + } + if (n) console.log(`Portadas HD: ${n} en ${Math.round((Date.now() - t) / 1000)} s`) + } finally { + pumping = false + } + } + + return { sendCached, wants, enqueue, pendingCache, pendingCount: () => pending.size } +} diff --git a/app/credits.js b/app/credits.js new file mode 100644 index 0000000..60fc640 --- /dev/null +++ b/app/credits.js @@ -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 } +} diff --git a/app/features.js b/app/features.js new file mode 100644 index 0000000..9f3dbb7 --- /dev/null +++ b/app/features.js @@ -0,0 +1,383 @@ +// 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://localhost:3352').replace(/\/+$/, '') + +// Día local (TZ del contenedor) en formato YYYY-MM-DD. +export const dayOf = ts => new Date(ts).toLocaleDateString('sv-SE') +const sleep = ms => new Promise(r => setTimeout(r, ms)) +const chapterKey = c => c.num ?? c.id + +// Nombres de género unificados para las estadísticas (LeerCapitulo mezcla inglés y español). +const GENRE_ES = { + action: 'Acción', accion: 'Acción', adventure: 'Aventura', comedy: 'Comedia', drama: 'Drama', fantasy: 'Fantasía', + fantasia: 'Fantasía', romance: 'Romance', horror: 'Terror', terror: 'Terror', mystery: 'Misterio', misterio: 'Misterio', + psychological: 'Psicológico', psicologico: 'Psicológico', 'sci-fi': 'Ciencia ficción', 'science fiction': 'Ciencia ficción', + 'ciencia ficcion': 'Ciencia ficción', 'slice of life': 'Recuentos de la vida', 'recuentos de la vida': 'Recuentos de la vida', + sports: 'Deportes', deportes: 'Deportes', supernatural: 'Sobrenatural', sobrenatural: 'Sobrenatural', thriller: 'Thriller', + tragedy: 'Tragedia', tragedia: 'Tragedia', historical: 'Histórico', historico: 'Histórico', mecha: 'Mecha', + 'martial arts': 'Artes marciales', 'artes marciales': 'Artes marciales', 'school life': 'Vida escolar', 'vida escolar': 'Vida escolar', + shounen: 'Shounen', shoujo: 'Shoujo', seinen: 'Seinen', josei: 'Josei', isekai: 'Isekai', magic: 'Magia', magia: 'Magia', + harem: 'Harem', ecchi: 'Ecchi', music: 'Música', musica: 'Música', military: 'Militar', militar: 'Militar', medical: 'Médico', + crime: 'Crimen', crimen: 'Crimen', philosophical: 'Filosófico', reincarnation: 'Reencarnación', reencarnacion: 'Reencarnación', + monsters: 'Monstruos', monstruos: 'Monstruos', vampires: 'Vampiros', vampiros: 'Vampiros', zombies: 'Zombis', zombis: 'Zombis', + 'video games': 'Videojuegos', videojuegos: 'Videojuegos', 'time travel': 'Viajes en el tiempo', survival: 'Supervivencia', + supervivencia: 'Supervivencia', cooking: 'Cocina', cocina: 'Cocina', 'office workers': 'Oficinistas', villainess: 'Villana', + demons: 'Demonios', demonios: 'Demonios', gore: 'Gore', superhero: 'Superhéroes', 'virtual reality': 'Realidad virtual', + 'post-apocalyptic': 'Postapocalíptico', apocaliptico: 'Postapocalíptico', animals: 'Animales', 'gender bender': 'Gender bender', + 'boys\' love': 'Boys\' love', yaoi: 'Boys\' love', "girls' love": "Girls' love", yuri: "Girls' love", wuxia: 'Wuxia', + aliens: 'Alienígenas', ninja: 'Ninjas', samurai: 'Samuráis', police: 'Policial', mafia: 'Mafia', delinquents: 'Delincuentes', + 'reverse harem': 'Harem inverso', 'traditional games': 'Juegos tradicionales', ghosts: 'Fantasmas', 'monster girls': 'Monster girls', +} +// Etiquetas de formato que no son géneros. +const NOT_GENRE = new Set(['long strip', 'full color', 'web comic', 'official colored', 'fan colored', 'adaptation', 'anthology', + 'award winning', 'doujinshi', 'oneshot', '4-koma', 'self-published', 'user created', 'manga', 'manhwa', 'manhua', 'webtoon']) +export const genreName = raw => { + const k = String(raw).trim().toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '') + if (NOT_GENRE.has(k)) return null + return GENRE_ES[k] || String(raw).trim() +} +const MODES = ['paged', 'double', 'vertical'] +const FITS = ['height', 'width'] +const DIRS = ['ltr', 'rtl'] + +export function setupFeatures({ db, fetchMangaFull, enqueueDownload, downloadInfo, HttpError, json, readBody, MANGA_ID, net }) { + db.exec(` + CREATE TABLE IF NOT EXISTS follows ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + manga_id TEXT NOT NULL, + title TEXT NOT NULL, + cover TEXT, + lang TEXT, + active INTEGER NOT NULL DEFAULT 1, + known TEXT, -- JSON: números de capítulo ya vistos (null = sin inicializar) + new_keys TEXT NOT NULL DEFAULT '[]', -- JSON: números de capítulo nuevos aún no vistos por el usuario + last_checked INTEGER, + last_new_at INTEGER, + created_at INTEGER NOT NULL, + PRIMARY KEY (user_id, manga_id) + ); + CREATE TABLE IF NOT EXISTS manga_prefs ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + manga_id TEXT NOT NULL, + prefs TEXT NOT NULL, + PRIMARY KEY (user_id, manga_id) + ); + CREATE TABLE IF NOT EXISTS manga_meta ( + manga_id TEXT PRIMARY KEY, + title TEXT NOT NULL, + source TEXT, + type TEXT, + tags TEXT, + updated_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS page_reads ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + chapter_id TEXT NOT NULL, + page INTEGER NOT NULL, + manga_id TEXT NOT NULL, + day TEXT NOT NULL, + PRIMARY KEY (user_id, chapter_id, page) + ); + CREATE INDEX IF NOT EXISTS idx_page_reads_day ON page_reads(user_id, day); + CREATE TABLE IF NOT EXISTS chapter_done ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + chapter_id TEXT NOT NULL, + manga_id TEXT NOT NULL, + day TEXT NOT NULL, + PRIMARY KEY (user_id, chapter_id) + ); + CREATE TABLE IF NOT EXISTS kv (k TEXT PRIMARY KEY, v TEXT); + `) + if (!db.prepare('PRAGMA table_info(users)').all().some(c => c.name === 'notify')) db.exec('ALTER TABLE users ADD COLUMN notify TEXT') + + // Migración única: estadísticas e historial previos a esta versión. + if (!db.prepare("SELECT 1 FROM kv WHERE k = 'migr_features_v1'").get()) { + const insPage = db.prepare('INSERT OR IGNORE INTO page_reads (user_id, chapter_id, page, manga_id, day) VALUES (?, ?, ?, ?, ?)') + const insDone = db.prepare('INSERT OR IGNORE INTO chapter_done (user_id, chapter_id, manga_id, day) VALUES (?, ?, ?, ?)') + for (const r of db.prepare('SELECT * FROM chapter_reads').all()) { + const day = dayOf(r.updated_at) + for (let p = 0; p <= Math.min(r.page, 500); p++) insPage.run(r.user_id, r.chapter_id, p, r.manga_id, day) + if (r.total_pages && r.page >= r.total_pages - 1) insDone.run(r.user_id, r.chapter_id, r.manga_id, day) + } + db.prepare(`INSERT OR IGNORE INTO follows (user_id, manga_id, title, cover, lang, created_at) + SELECT user_id, manga_id, manga_title, cover, lang, updated_at FROM progress`).run() + db.prepare("INSERT INTO kv (k, v) VALUES ('migr_features_v1', ?)").run(String(Date.now())) + } + + function saveMeta(manga) { + db.prepare(`INSERT INTO manga_meta (manga_id, title, source, type, tags, updated_at) VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT (manga_id) DO UPDATE SET title = excluded.title, source = excluded.source, type = excluded.type, + tags = excluded.tags, updated_at = excluded.updated_at`) + .run(manga.id, manga.title, manga.source, manga.type || null, JSON.stringify((manga.tags || []).map(t => t.name ?? t)), Date.now()) + } + + // ---------- Notificaciones ---------- + const getNotify = userId => { + try { return JSON.parse(db.prepare('SELECT notify FROM users WHERE id = ?').get(userId)?.notify || '{}') } catch { return {} } + } + async function sendNotification(cfg, { title, message, click }) { + const errors = [] + if (cfg.ntfyUrl && cfg.ntfyTopic) { + try { + const headers = { 'Content-Type': 'application/json' } + if (cfg.ntfyToken) headers.Authorization = cfg.ntfyToken.includes(':') ? `Basic ${Buffer.from(cfg.ntfyToken).toString('base64')}` : `Bearer ${cfg.ntfyToken}` + // Publicación JSON: admite UTF-8 en el título (en cabeceras no). + const r = await fetch(cfg.ntfyUrl, { + method: 'POST', headers, signal: AbortSignal.timeout(15e3), + body: JSON.stringify({ topic: cfg.ntfyTopic, title, message, click, tags: ['books'] }), + }) + if (!r.ok) errors.push(`ntfy: HTTP ${r.status} ${(await r.text()).slice(0, 120)}`) + } catch (e) { errors.push(`ntfy: ${e.message}`) } + } + if (cfg.tgToken && cfg.tgChat) { + try { + const r = await fetch(`https://api.telegram.org/bot${cfg.tgToken}/sendMessage`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: AbortSignal.timeout(15e3), + body: JSON.stringify({ chat_id: cfg.tgChat, text: `${title}\n${message}\n${click}`, disable_web_page_preview: true }), + }) + if (!r.ok) errors.push(`Telegram: HTTP ${r.status} ${(await r.text()).slice(0, 120)}`) + } catch (e) { errors.push(`Telegram: ${e.message}`) } + } + return errors + } + const hasNotify = cfg => !!((cfg.ntfyUrl && cfg.ntfyTopic) || (cfg.tgToken && cfg.tgChat)) + + // ---------- Comprobación de capítulos nuevos ---------- + const state = { checking: null, lastCheckAt: null, nextCheckAt: null } + + async function checkManga(mangaId, lang) { + const { manga, chapters } = await fetchMangaFull(mangaId, lang || 'es-la') + saveMeta(manga) + if (!chapters.length) return 0 // respuesta vacía: no tocar lo conocido + const keys = chapters.map(chapterKey) + const byKey = new Map(chapters.map(c => [chapterKey(c), c])) + let anyNew = 0 + const now = Date.now() + for (const f of db.prepare('SELECT * FROM follows WHERE active = 1 AND manga_id = ? AND lang IS ?').all(mangaId, lang ?? null)) { + if (f.known == null) { + db.prepare('UPDATE follows SET known = ?, last_checked = ? WHERE user_id = ? AND manga_id = ?').run(JSON.stringify(keys), now, f.user_id, mangaId) + continue + } + const known = new Set(JSON.parse(f.known)) + const fresh = keys.filter(k => !known.has(k)) + const pending = [...new Set([...JSON.parse(f.new_keys), ...fresh])] + db.prepare(`UPDATE follows SET known = ?, new_keys = ?, last_checked = ?, last_new_at = COALESCE(?, last_new_at), title = ? + WHERE user_id = ? AND manga_id = ?`) + .run(JSON.stringify([...known, ...fresh]), JSON.stringify(pending), now, fresh.length ? now : null, manga.title, f.user_id, mangaId) + if (!fresh.length) continue + anyNew += fresh.length + const cfg = getNotify(f.user_id) + if (hasNotify(cfg)) { + const nums = fresh.map(k => byKey.get(k)?.num).filter(Boolean) + const message = `${fresh.length === 1 ? '1 capítulo nuevo' : `${fresh.length} capítulos nuevos`}${nums.length ? `: ${nums.slice(0, 8).join(', ')}${nums.length > 8 ? '…' : ''}` : ''}` + const errs = await sendNotification(cfg, { title: manga.title, message, click: `${PUBLIC_URL}/#/manga/${mangaId}` }) + if (errs.length) console.warn('Notificación', f.user_id, errs.join('; ')) + } + } + // Autodescarga: si el manga ya tiene copia local, bajar también lo nuevo. + const dl = anyNew && downloadInfo(mangaId) + // Solo si se baja el manga entero; con capítulos sueltos, lo nuevo no se descarga solo. + if (dl && dl.scope !== 'some' && !['queued', 'downloading'].includes(dl.status)) enqueueDownload(mangaId, dl.title, dl.lang) + return anyNew + } + + let skippedOffline = false + function checkFollows() { + if (state.checking) return state.checking + // Sin internet no se puede comprobar nada: se hace en cuanto vuelva la conexión. + if (!net.online()) { skippedOffline = true; return Promise.resolve() } + state.checking = (async () => { + const groups = db.prepare('SELECT manga_id, lang FROM follows WHERE active = 1 GROUP BY manga_id, lang').all() + let found = 0 + for (const g of groups) { + try { found += await checkManga(g.manga_id, g.lang) } catch (e) { console.warn('Comprobación', g.manga_id, e.message) } + await sleep(/^(lc|mi)~/.test(g.manga_id) ? 1500 : 2500) + } + state.lastCheckAt = Date.now() + if (groups.length) console.log(`Comprobados ${groups.length} mangas seguidos: ${found} capítulo(s) nuevo(s)`) + })().finally(() => { + state.checking = null + state.nextCheckAt = Date.now() + CHECK_HOURS * 3600e3 + }) + return state.checking + } + net.whenOnline(() => { if (skippedOffline) { skippedOffline = false; checkFollows() } }) + state.nextCheckAt = Date.now() + 90e3 + setTimeout(() => { + checkFollows() + setInterval(checkFollows, CHECK_HOURS * 3600e3) + }, 90e3) + + // ---------- Hooks llamados desde server.js ---------- + function onProgress(userId, b, page, total, prevPage) { + const day = dayOf(Date.now()) + const from = prevPage != null && page > prevPage && page - prevPage <= 300 ? prevPage + 1 : page + const ins = db.prepare('INSERT OR IGNORE INTO page_reads (user_id, chapter_id, page, manga_id, day) VALUES (?, ?, ?, ?, ?)') + for (let p = from; p <= page; p++) ins.run(userId, b.chapterId, p, b.mangaId, day) + 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, + /^(lc|mi)~/.test(b.mangaId) ? 'es' : b.lang || null, Date.now()) + } + + // Datos extra de la ficha; marca como vistos los capítulos nuevos. + function mangaExtras(userId, manga) { + saveMeta(manga) + const f = db.prepare('SELECT active, new_keys FROM follows WHERE user_id = ? AND manga_id = ?').get(userId, manga.id) + const newKeys = f?.active ? JSON.parse(f.new_keys) : [] + if (newKeys.length) db.prepare("UPDATE follows SET new_keys = '[]' WHERE user_id = ? AND manga_id = ?").run(userId, manga.id) + const prefs = db.prepare('SELECT prefs FROM manga_prefs WHERE user_id = ? AND manga_id = ?').get(userId, manga.id) + return { following: !!f?.active, newKeys, prefs: prefs ? JSON.parse(prefs.prefs) : null } + } + + function unfollow(userId, mangaId) { + db.prepare('UPDATE follows SET active = 0 WHERE user_id = ? AND manga_id = ?').run(userId, mangaId) + } + + // ---------- Estadísticas ---------- + function stats(userId) { + const pagesByDay = new Map(db.prepare('SELECT day, COUNT(*) n FROM page_reads WHERE user_id = ? GROUP BY day').all(userId).map(r => [r.day, r.n])) + const chapsByDay = new Map(db.prepare('SELECT day, COUNT(*) n FROM chapter_done WHERE user_id = ? GROUP BY day').all(userId).map(r => [r.day, r.n])) + const today = dayOf(Date.now()) + const addDays = (d, n) => { const x = new Date(`${d}T12:00:00`); x.setDate(x.getDate() + n); return x.toLocaleDateString('sv-SE') } + + // Rachas: días consecutivos con alguna página leída. + const days = [...pagesByDay.keys()].sort() + let best = 0, run = 0, prev = null + for (const d of days) { + run = prev && addDays(prev, 1) === d ? run + 1 : 1 + best = Math.max(best, run) + prev = d + } + let current = 0 + for (let d = pagesByDay.has(today) ? today : addDays(today, -1); pagesByDay.has(d); d = addDays(d, -1)) current++ + + // Semanas (lunes a domingo), las últimas 12. + const monday = d => { const x = new Date(`${d}T12:00:00`); x.setDate(x.getDate() - ((x.getDay() + 6) % 7)); return x.toLocaleDateString('sv-SE') } + const weeks = [] + for (let i = 11, start = monday(today); i >= 0; i--) { + const ws = addDays(start, -7 * i) + let pages = 0, chapters = 0 + for (let k = 0; k < 7; k++) { const d = addDays(ws, k); pages += pagesByDay.get(d) || 0; chapters += chapsByDay.get(d) || 0 } + weeks.push({ start: ws, pages, chapters }) + } + + // Calendario: últimas 18 semanas día a día. + const calStart = addDays(monday(today), -7 * 17) + const calendar = [] + for (let d = calStart; d <= today; d = addDays(d, 1)) calendar.push({ day: d, pages: pagesByDay.get(d) || 0, chapters: chapsByDay.get(d) || 0 }) + + // Géneros ponderados por páginas leídas de cada manga. + const perManga = db.prepare(`SELECT r.manga_id, COUNT(*) pages, m.tags, m.title, m.type FROM page_reads r + LEFT JOIN manga_meta m ON m.manga_id = r.manga_id WHERE r.user_id = ? GROUP BY r.manga_id`).all(userId) + const genreMap = new Map() + let withMeta = 0 + for (const m of perManga) { + if (!m.tags) continue + withMeta += m.pages + const names = [...new Set(JSON.parse(m.tags).map(genreName).filter(Boolean))] + for (const g of names) genreMap.set(g, (genreMap.get(g) || 0) + m.pages) + } + const genres = [...genreMap].sort((a, b) => b[1] - a[1]).slice(0, 10) + .map(([name, pages]) => ({ name, pages, pct: withMeta ? Math.round((pages / withMeta) * 100) : 0 })) + + const chapByManga = new Map(db.prepare('SELECT manga_id, COUNT(*) n FROM chapter_done WHERE user_id = ? GROUP BY manga_id').all(userId).map(r => [r.manga_id, r.n])) + const progress = new Map(db.prepare('SELECT manga_id, manga_title, cover FROM progress WHERE user_id = ?').all(userId).map(r => [r.manga_id, r])) + const topMangas = perManga.sort((a, b) => b.pages - a.pages).slice(0, 8).map(m => ({ + id: m.manga_id, title: m.title || progress.get(m.manga_id)?.manga_title || m.manga_id, cover: progress.get(m.manga_id)?.cover || null, + pages: m.pages, chapters: chapByManga.get(m.manga_id) || 0, + })) + const types = new Map() + for (const m of perManga) if (m.type) types.set(m.type, (types.get(m.type) || 0) + m.pages) + + return { + totals: { + pages: [...pagesByDay.values()].reduce((a, b) => a + b, 0), + chapters: [...chapsByDay.values()].reduce((a, b) => a + b, 0), + mangas: perManga.length, + activeDays: days.length, + thisWeekPages: weeks.at(-1).pages, + thisWeekChapters: weeks.at(-1).chapters, + }, + streak: { current, best, readToday: pagesByDay.has(today) }, + weeks, calendar, genres, topMangas, + types: [...types].sort((a, b) => b[1] - a[1]).map(([name, pages]) => ({ name, pages })), + } + } + + // ---------- Rutas ---------- + async function route(p, method, q, req, res, user) { + let m + if (p === '/api/follows' && method === 'GET') { + const list = db.prepare('SELECT manga_id, title, cover, lang, new_keys, last_checked, last_new_at FROM follows WHERE user_id = ? AND active = 1 ORDER BY COALESCE(last_new_at, 0) DESC, created_at DESC').all(user.id) + return json(res, 200, { + follows: list.map(f => ({ ...f, newCount: JSON.parse(f.new_keys).length, new_keys: undefined })), + checking: !!state.checking, lastCheckAt: state.lastCheckAt, nextCheckAt: state.nextCheckAt, intervalHours: CHECK_HOURS, + }) + } + if (p === '/api/follows/check' && method === 'POST') { + checkFollows() + return json(res, 200, { checking: true }) + } + if ((m = /^\/api\/follow\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'POST') { + const b = await readBody(req) + const lang = /^(lc|mi)~/.test(m[1]) ? 'es' : /^[a-z]{2}(-[a-z]{2})?$/.test(b.lang || '') ? b.lang : user.lang + db.prepare(`INSERT INTO follows (user_id, manga_id, title, cover, lang, active, created_at) VALUES (?, ?, ?, ?, ?, 1, ?) + ON CONFLICT (user_id, manga_id) DO UPDATE SET active = 1, lang = excluded.lang, title = excluded.title, cover = excluded.cover, + known = CASE WHEN follows.lang IS excluded.lang THEN follows.known ELSE NULL END`) + .run(user.id, m[1], String(b.title || '').slice(0, 300), b.cover ? String(b.cover).slice(0, 1000) : null, lang, Date.now()) + checkManga(m[1], lang).catch(e => console.warn('Seguir', m[1], e.message)) // inicializa lo conocido + return json(res, 200, { following: true }) + } + if ((m = /^\/api\/follow\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'DELETE') { + unfollow(user.id, m[1]) + return json(res, 200, { following: false }) + } + if ((m = /^\/api\/prefs\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'POST') { + const b = await readBody(req) + if (!MODES.includes(b.mode) || !FITS.includes(b.fit) || !DIRS.includes(b.dir) || (b.gap != null && typeof b.gap !== 'boolean')) throw new HttpError(400, 'Preferencias inválidas') + db.prepare('INSERT OR REPLACE INTO manga_prefs (user_id, manga_id, prefs) VALUES (?, ?, ?)') + .run(user.id, m[1], JSON.stringify({ mode: b.mode, fit: b.fit, dir: b.dir, ...(b.gap != null ? { gap: b.gap } : {}) })) + return json(res, 200, { ok: true }) + } + if (p === '/api/stats' && method === 'GET') return json(res, 200, stats(user.id)) + if (p === '/api/notify' && method === 'GET') { + const cfg = getNotify(user.id) + return json(res, 200, { ntfyUrl: cfg.ntfyUrl || '', ntfyTopic: cfg.ntfyTopic || '', hasNtfyToken: !!cfg.ntfyToken, tgChat: cfg.tgChat || '', hasTgToken: !!cfg.tgToken }) + } + if (p === '/api/notify' && method === 'POST') { + const b = await readBody(req) + const old = getNotify(user.id) + const str = (v, max) => String(v ?? '').trim().slice(0, max) + const cfg = { + ntfyUrl: str(b.ntfyUrl, 300).replace(/\/+$/, ''), + ntfyTopic: str(b.ntfyTopic, 64), + // Campo de token vacío = conservar el guardado; "-" = borrarlo. + ntfyToken: b.ntfyToken === '-' ? '' : str(b.ntfyToken, 300) || old.ntfyToken || '', + tgToken: b.tgToken === '-' ? '' : str(b.tgToken, 100) || old.tgToken || '', + tgChat: str(b.tgChat, 40), + } + if (cfg.ntfyUrl && !/^https?:\/\/[^\s/]+/.test(cfg.ntfyUrl)) throw new HttpError(400, 'URL de ntfy inválida') + if (cfg.ntfyTopic && !/^[\w-]{1,64}$/.test(cfg.ntfyTopic)) throw new HttpError(400, 'Tema de ntfy inválido (letras, números, - y _)') + if (cfg.tgToken && !/^\d+:[\w-]{20,}$/.test(cfg.tgToken)) throw new HttpError(400, 'Token de Telegram inválido') + if (cfg.tgChat && !/^-?\d+$|^@\w+$/.test(cfg.tgChat)) throw new HttpError(400, 'Chat de Telegram inválido') + db.prepare('UPDATE users SET notify = ? WHERE id = ?').run(JSON.stringify(cfg), user.id) + return json(res, 200, { ok: true }) + } + if (p === '/api/notify/test' && method === 'POST') { + const cfg = getNotify(user.id) + if (!hasNotify(cfg)) throw new HttpError(400, 'No hay ningún canal de notificación configurado') + const errors = await sendNotification(cfg, { title: 'Manga Reader', message: 'Notificación de prueba: los avisos de capítulos nuevos llegarán aquí.', click: PUBLIC_URL }) + if (errors.length) throw new HttpError(502, errors.join(' · ')) + return json(res, 200, { ok: true }) + } + return false + } + + return { route, onProgress, mangaExtras, unfollow, checkFollows, stats } +} diff --git a/app/library.js b/app/library.js new file mode 100644 index 0000000..0e38e2e --- /dev/null +++ b/app/library.js @@ -0,0 +1,168 @@ +// Librería: listas propias del usuario, recomendaciones según sus géneros y descarga anticipada +// de los capítulos siguientes a lo que se está leyendo. +const PREFETCH_DEFAULT = { n: 2, hd: false } +const DEFAULT_LISTS = ['Favoritos', 'Pendientes', 'Abandonados'] +const REC_TTL = 6 * 3600e3 +import { genreName } from './features.js' +const norm = s => String(s).trim().toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '') + +export function setupLibrary({ db, HttpError, json, readBody, MANGA_ID, fetchMangaFull, enqueueDownload, linkDownload, upscale, stats, searchManga, mdCategories, net }) { + db.exec(` + CREATE TABLE IF NOT EXISTS lists ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + created_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS list_items ( + list_id INTEGER NOT NULL REFERENCES lists(id) ON DELETE CASCADE, + manga_id TEXT NOT NULL, + title TEXT NOT NULL, + cover TEXT, + added_at INTEGER NOT NULL, + PRIMARY KEY (list_id, manga_id) + ); + `) + if (!db.prepare('PRAGMA table_info(users)').all().some(c => c.name === 'prefetch')) db.exec('ALTER TABLE users ADD COLUMN prefetch TEXT') + + // ---------- Listas ---------- + function listsOf(userId) { + let rows = db.prepare('SELECT id, name FROM lists WHERE user_id = ? ORDER BY id').all(userId) + if (!rows.length && !db.prepare('SELECT 1 FROM kv WHERE k = ?').get(`lists_init_${userId}`)) { + const ins = db.prepare('INSERT INTO lists (user_id, name, created_at) VALUES (?, ?, ?)') + for (const n of DEFAULT_LISTS) ins.run(userId, n, Date.now()) + db.prepare('INSERT INTO kv (k, v) VALUES (?, ?)').run(`lists_init_${userId}`, '1') + rows = db.prepare('SELECT id, name FROM lists WHERE user_id = ? ORDER BY id').all(userId) + } + const count = db.prepare('SELECT COUNT(*) n FROM list_items WHERE list_id = ?') + return rows.map(l => ({ ...l, count: count.get(l.id).n })) + } + const ownList = (userId, id) => { + const l = db.prepare('SELECT * FROM lists WHERE id = ? AND user_id = ?').get(id, userId) + if (!l) throw new HttpError(404, 'Lista no encontrada') + return l + } + // Listas que contienen un manga (para la ficha). + const listsWith = (userId, mangaId) => db.prepare(`SELECT l.id FROM lists l JOIN list_items i ON i.list_id = l.id + WHERE l.user_id = ? AND i.manga_id = ?`).all(userId, mangaId).map(r => r.id) + + // ---------- Descarga anticipada ---------- + function prefetchCfg(userId) { + try { return { ...PREFETCH_DEFAULT, ...JSON.parse(db.prepare('SELECT prefetch FROM users WHERE id = ?').get(userId)?.prefetch || '{}') } } catch { return { ...PREFETCH_DEFAULT } } + } + const recent = new Map() // `${manga}|${chapter}` -> ts, para no repetir en cada guardado de progreso + // Al pasar la mitad de un capítulo, encola los N siguientes (y, si se pidió, su versión HD). + function onProgress(userId, b, page, total) { + const cfg = prefetchCfg(userId) + if (!cfg.n || !total || page < Math.floor(total / 2) || !net.online()) return + const key = `${b.mangaId}|${b.chapterId}|${cfg.n}|${cfg.hd}` + if (Date.now() - (recent.get(key) || 0) < 30 * 60e3) return + recent.set(key, Date.now()) + if (recent.size > 2000) recent.delete(recent.keys().next().value) + const lang = /^(lc|mi)~/.test(b.mangaId) ? 'es' : b.lang + fetchMangaFull(b.mangaId, lang).then(({ manga, chapters }) => { + const i = chapters.findIndex(c => c.id === b.chapterId) + if (i < 0) return + const next = chapters.slice(i + 1, i + 1 + cfg.n).map(c => c.id) + if (!next.length) return + const have = new Set(db.prepare('SELECT chapter_id FROM downloaded_chapters WHERE manga_id = ?').all(b.mangaId).map(r => r.chapter_id)) + const missing = next.filter(id => !have.has(id)) + if (missing.length) { + enqueueDownload(b.mangaId, manga.title, lang, missing) + linkDownload(userId, b.mangaId) + console.log(`Descarga anticipada: ${manga.title} → ${missing.length} cap(s).`) + } + if (cfg.hd && upscale.available()) upscale.request(next) + }).catch(e => console.warn('Descarga anticipada:', e.message)) + } + + // ---------- Recomendaciones ---------- + const recCache = new Map() // userId -> { at, data } + // Populares de MangaDex (por seguidores) en tus 3 géneros principales, con capítulos en tu idioma. + // LeerCapitulo no ordena sus géneros por popularidad, por eso la fuente es MangaDex. + async function recommendations(userId, refresh) { + const hit = recCache.get(userId) + if (hit && !refresh && Date.now() - hit.at < REC_TTL) return hit.data + if (!net.online()) return hit?.data ?? { genres: [], items: [] } + const lang = db.prepare('SELECT lang FROM users WHERE id = ?').get(userId)?.lang || 'es-la' + const genres = stats(userId).genres.slice(0, 5) + const cats = (await mdCategories().catch(() => [])).filter(c => c.group === 'Géneros' || c.group === 'Temáticas') + const byName = new Map(cats.map(c => [norm(genreName(c.name) || c.name), c])) + const picked = genres.map(g => ({ g, cat: byName.get(norm(g.name)) })).filter(x => x.cat).slice(0, 3) + const mine = db.prepare(`SELECT manga_id, manga_title title FROM progress WHERE user_id = ? UNION SELECT manga_id, title FROM follows WHERE user_id = ? + UNION SELECT i.manga_id, i.title FROM list_items i JOIN lists l ON l.id = i.list_id WHERE l.user_id = ?`).all(userId, userId, userId) + const knownIds = new Set(mine.map(r => r.manga_id)) + const knownTitles = new Set(mine.map(r => norm(r.title).replace(/[^a-z0-9]/g, ''))) + // Puntuación: aparecer en varios de tus géneros y salir arriba en cada lista. + const score = new Map() + const page = refresh ? 1 + Math.floor(Math.random() * 3) : 1 // "otras recomendaciones": otra página + const results = await Promise.all(picked.map(({ cat }) => searchManga('', lang, 'popular', cat.id, page).catch(() => []))) + results.forEach((list, gi) => list.forEach((m, rank) => { + if (!m.id || knownIds.has(m.id) || knownTitles.has(norm(m.title).replace(/[^a-z0-9]/g, ''))) return + const s = score.get(m.id) || { m, pts: 0, why: [] } + s.pts += (picked.length - gi) * 2 + Math.max(0, 30 - rank) / 10 + 3 + s.why.push(picked[gi].g.name) + score.set(m.id, s) + })) + const items = [...score.values()].sort((a, b) => b.pts - a.pts).slice(0, 18) + .map(({ m, why }) => ({ ...m, sub: `Por ${why.slice(0, 2).join(' y ')}` })) + const data = { genres: picked.map(x => ({ name: x.g.name, id: x.cat.id, source: 'md' })), items } + if (items.length) recCache.set(userId, { at: Date.now(), data }) // vacío (fuente caída): no guardarlo 6 h + return data + } + + // ---------- Rutas ---------- + async function route(p, method, q, req, res, user) { + let m + if (p === '/api/lists' && method === 'GET') return json(res, 200, listsOf(user.id)), true + if (p === '/api/lists' && method === 'POST') { + const b = await readBody(req) + const name = String(b.name || '').trim().slice(0, 60) + if (!name) throw new HttpError(400, 'Ponle un nombre a la lista') + if (db.prepare('SELECT COUNT(*) n FROM lists WHERE user_id = ?').get(user.id).n >= 50) throw new HttpError(400, 'Demasiadas listas') + const id = Number(db.prepare('INSERT INTO lists (user_id, name, created_at) VALUES (?, ?, ?)').run(user.id, name, Date.now()).lastInsertRowid) + return json(res, 200, { id, name, count: 0 }), true + } + if ((m = /^\/api\/lists\/(\d+)$/.exec(p))) { + const l = ownList(user.id, Number(m[1])) + if (method === 'GET') { + const items = db.prepare('SELECT manga_id, title, cover, added_at FROM list_items WHERE list_id = ? ORDER BY added_at DESC').all(l.id) + return json(res, 200, { id: l.id, name: l.name, items }), true + } + if (method === 'PATCH') { + const name = String((await readBody(req)).name || '').trim().slice(0, 60) + if (!name) throw new HttpError(400, 'Ponle un nombre a la lista') + db.prepare('UPDATE lists SET name = ? WHERE id = ?').run(name, l.id) + return json(res, 200, { ok: true }), true + } + if (method === 'DELETE') { db.prepare('DELETE FROM lists WHERE id = ?').run(l.id); return json(res, 200, { ok: true }), true } + } + if ((m = /^\/api\/lists\/(\d+)\/items$/.exec(p)) && method === 'POST') { + const l = ownList(user.id, Number(m[1])) + const b = await readBody(req) + if (!MANGA_ID.test(b.mangaId || '')) throw new HttpError(400, 'Manga inválido') + const cover = String(b.cover || '').slice(0, 1000) + db.prepare(`INSERT INTO list_items (list_id, manga_id, title, cover, added_at) VALUES (?, ?, ?, ?, ?) + ON CONFLICT (list_id, manga_id) DO UPDATE SET title = excluded.title, cover = excluded.cover`) + .run(l.id, b.mangaId, String(b.title || '').slice(0, 300) || b.mangaId, cover.startsWith('/api/') ? cover : null, Date.now()) + return json(res, 200, { lists: listsWith(user.id, b.mangaId) }), true + } + if ((m = /^\/api\/lists\/(\d+)\/items\/([^/]+)$/.exec(p)) && method === 'DELETE') { + const l = ownList(user.id, Number(m[1])) + const mangaId = decodeURIComponent(m[2]) + db.prepare('DELETE FROM list_items WHERE list_id = ? AND manga_id = ?').run(l.id, mangaId) + return json(res, 200, { lists: listsWith(user.id, mangaId) }), true + } + if (p === '/api/prefetch' && method === 'GET') return json(res, 200, prefetchCfg(user.id)), true + if (p === '/api/prefetch' && method === 'POST') { + const b = await readBody(req) + const n = Math.min(10, Math.max(0, Number(b.n) | 0)) + db.prepare('UPDATE users SET prefetch = ? WHERE id = ?').run(JSON.stringify({ n, hd: !!b.hd }), user.id) + return json(res, 200, prefetchCfg(user.id)), true + } + if (p === '/api/recommendations' && method === 'GET') return json(res, 200, await recommendations(user.id, q.get('refresh') === '1')), true + return false + } + + return { route, onProgress, listsOf, listsWith } +} diff --git a/app/m440-key.mjs b/app/m440-key.mjs new file mode 100644 index 0000000..75050d3 --- /dev/null +++ b/app/m440-key.mjs @@ -0,0 +1,55 @@ +// Extrae la clave con la que M440 cifra su lista de capítulos. La clave está repartida en trozos dentro de +// un script ofuscado (js/ads2.js) que llama a CryptoJS.AES.decrypt(datos, clave, …). En vez de +// desofuscarlo, se ejecuta en un contexto vm vacío con un CryptoJS falso que solo anota la clave. +// +// Se lanza como proceso aparte con el modelo de permisos de Node (sin acceso a archivos salvo este, sin +// procesos hijo ni workers) y con tiempo límite. Dentro del vm no se pasa ningún objeto del proceso +// (todo se define con código dentro del contexto) y la generación de código desde strings está +// desactivada, así que el script no puede alcanzar process ni require. +// Entrada: el script por stdin. Salida: {"key": "..."} por stdout. +import vm from 'node:vm' + +let src = '' +process.stdin.setEncoding('utf8') +for await (const chunk of process.stdin) { + src += chunk + if (src.length > 2e6) process.exit(2) +} + +const ctx = vm.createContext({}, { codeGeneration: { strings: false, wasm: false } }) +vm.runInContext(` + var __key = null; + var window = globalThis, self = globalThis; + var UsaPoncho = '{"ct":"AAAA","iv":"00","s":"00"}'; + function mk() { + return new Proxy(function () {}, { + get: function (t, k) { + if (k === Symbol.toPrimitive) return function () { return '' }; + if (k === 'toString') return function () { return '[]' }; + return mk(); + }, + apply: function () { return mk() }, + construct: function () { return mk() }, + }); + } + var document = mk(), $ = mk(), jQuery = $, localStorage = mk(), sessionStorage = mk(); + var navigator = { userAgent: 'Mozilla/5.0', language: 'es' }, location = { href: 'https://m440.in/', hostname: 'm440.in' }; + var setTimeout = function () {}, setInterval = function () {}, clearTimeout = function () {}, clearInterval = function () {}; + var console = { log: function () {}, warn: function () {}, error: function () {}, info: function () {}, debug: function () {}, trace: function () {}, table: function () {}, exception: function () {} }; + var id = function (s) { return s }; + var CryptoJS = { + AES: { + decrypt: function (c, k) { if (__key === null) __key = String(k); return { toString: function () { return '"[]"' } } }, + encrypt: mk(), + }, + enc: { Utf8: { parse: id, stringify: id }, Hex: { parse: id, stringify: id }, Base64: { parse: id, stringify: id } }, + lib: { CipherParams: { create: id }, WordArray: { create: id } }, + format: { OpenSSL: {} }, + }; +`, ctx) +try { + vm.runInContext(src, ctx, { timeout: 5000 }) +} catch {} +const key = vm.runInContext('typeof __key === "string" ? __key : ""', ctx) +process.stdout.write(JSON.stringify({ key: typeof key === 'string' ? key.slice(0, 200) : '' })) +process.exit(0) diff --git a/app/m440.js b/app/m440.js new file mode 100644 index 0000000..9674b48 --- /dev/null +++ b/app/m440.js @@ -0,0 +1,190 @@ +// Fuente M440.in (MangasIn, tema MMRCMS), en español. +// IDs: manga "mi~", capítulo "mi~~". +// - Búsqueda (/search?q=) y últimas actualizaciones (/lasted?p=) son JSON; los populares salen de la portada. +// - La lista de capítulos viene cifrada en la ficha (AES de CryptoJS con contraseña). La contraseña se +// saca de un script ofuscado con m440-key.mjs, en un proceso aislado, y se guarda hasta que deje de servir. +// - Las páginas del capítulo vienen en el HTML como (a veces en base64). +// - El listado por categorías está detrás del desafío de Cloudflare: esta fuente no tiene categorías. +import crypto from 'node:crypto' +import path from 'node:path' +import { spawn } from 'node:child_process' +import { fileURLToPath } from 'node:url' + +export const MI_BASE = 'https://m440.in' +const KEY_SCRIPT = path.join(path.dirname(fileURLToPath(import.meta.url)), 'm440-key.mjs') + +// httpError(status, message): crea el error HTTP del servidor (se pasa como función porque la clase se +// declara más abajo en server.js). +export function setupM440({ cached, httpError, coverUrl, decode, BROWSER_UA, net }) { + + // Como mucho una petición de HTML/JSON cada 700 ms (el sitio limita a ~1/s). + let gate = Promise.resolve() + const throttle = () => { + const p = gate.then(() => new Promise(r => setTimeout(r, 700))) + gate = p + return gate + } + async function raw(pathname, asJson) { + for (let i = 0; ; i++) { + net.guard() + await throttle() + const res = await fetch(MI_BASE + pathname, { + headers: { 'User-Agent': BROWSER_UA, 'Accept-Language': 'es', Referer: MI_BASE + '/', ...(asJson ? { 'X-Requested-With': 'XMLHttpRequest' } : {}) }, + signal: AbortSignal.timeout(30e3), + }).catch(() => { net.failed(); return null }) + if (res?.ok) return asJson ? res.json() : res.text() + if (res?.status === 404) throw httpError(404, 'M440 respondió 404') + const down = !res || res.status >= 500 + if (!down || i >= 2) throw httpError(502, down ? `M440 no responde ahora mismo${res ? ` (error ${res.status} de su servidor)` : ''}. Prueba en un rato.` : `M440 respondió ${res.status}`) + await new Promise(r => setTimeout(r, 1500 * (i + 1))) + } + } + const get = (pathname, ttl = 10 * 60e3, asJson = false) => cached(`mi:${pathname}`, ttl, () => raw(pathname, asJson)) + + const coverOf = slug => `${MI_BASE}/uploads/manga/${slug}/cover/cover_250x350.jpg` + const cover = (src, id) => coverUrl(new URL(src, MI_BASE).toString(), id) + const slugOf = href => /\/manga\/([a-z0-9._-]+)\/?$/i.exec(href || '')?.[1]?.toLowerCase() + + // ---------- Listados ---------- + async function search(q, mode, page = 1) { + if (q) { + if (Buffer.byteLength(q.trim()) < 3) throw httpError(400, 'En M440 la búsqueda necesita al menos 3 letras') + const list = await get(`/search?q=${encodeURIComponent(q.trim())}`, 10 * 60e3, true) + return (Array.isArray(list) ? list : []).filter(e => /^[a-z0-9._-]+$/i.test(e.data || '')).map(e => { + const id = `mi~${e.data.toLowerCase()}` + const img = /]+src="([^"]+)"/.exec(e.label || '')?.[1] + const status = / /^[a-z0-9._-]+$/i.test(e.manga_slug || '')).map(e => { + const id = `mi~${e.manga_slug.toLowerCase()}` + const ch = e.chapters?.[0]?.chapter_number + return { id, source: 'mi', title: decode(e.manga_name), cover: cover(coverOf(e.manga_slug), id), sub: ch ? `Cap. ${ch}` : undefined } + }) + } + // Populares: "Actualizaciones populares" de la portada (no pagina). + const html = await get('/') + const out = [] + const seen = new Set() + const re = /class="manga-name">\s*]*>([^<]+)<\/a>[\s\S]*?<\/i>\s*([^<]*)/g + for (const m of html.matchAll(re)) { + const slug = slugOf(m[1]) + if (!slug || seen.has(slug)) continue + seen.add(slug) + const id = `mi~${slug}` + out.push({ id, source: 'mi', title: decode(m[2]), cover: cover(m[3], id), sub: /^#\s*([0-9.]+)/.exec(decode(m[4]))?.[1] ? `Cap. ${/^#\s*([0-9.]+)/.exec(decode(m[4]))[1]}` : undefined }) + } + return out + } + + // ---------- Lista de capítulos cifrada ---------- + let key = null + let keyPromise = null + function fetchKey() { + keyPromise ??= (async () => { + const script = await raw('/js/ads2.js', false) + const out = await new Promise((resolve, reject) => { + const child = spawn(process.execPath, ['--permission', `--allow-fs-read=${KEY_SCRIPT}`, KEY_SCRIPT], { stdio: ['pipe', 'pipe', 'ignore'], timeout: 15e3 }) + let buf = '' + child.stdout.on('data', d => { if (buf.length < 10e3) buf += d }) + child.on('error', reject) + child.on('close', () => resolve(buf)) + child.stdin.end(script) + }) + const k = JSON.parse(out || '{}').key + if (!k) throw httpError(502, 'M440 cambió su forma de cifrar los capítulos (no se encontró la clave)') + console.log('M440: clave de capítulos actualizada') + return k + })().finally(() => { keyPromise = null }) + return keyPromise + } + // AES-256-CBC con clave/IV derivados como OpenSSL EVP_BytesToKey (MD5), igual que CryptoJS con contraseña. + function decrypt({ ct, s }, pass) { + const salt = Buffer.from(s, 'hex') + let d = Buffer.alloc(0) + let prev = Buffer.alloc(0) + while (d.length < 48) { + prev = crypto.createHash('md5').update(Buffer.concat([prev, Buffer.from(pass, 'utf8'), salt])).digest() + d = Buffer.concat([d, prev]) + } + const dc = crypto.createDecipheriv('aes-256-cbc', d.subarray(0, 32), d.subarray(32, 48)) + let v = JSON.parse(Buffer.concat([dc.update(Buffer.from(ct, 'base64')), dc.final()]).toString('utf8')) + if (typeof v === 'string') v = JSON.parse(v) + if (!Array.isArray(v)) throw new Error('formato inesperado') + return v + } + async function chapterList(html) { + const lit = /=\s*("\{\\"ct\\"(?:[^"\\]|\\.)*")\s*;/.exec(html)?.[1] + if (!lit) return [] + const data = JSON.parse(JSON.parse(lit)) + if (key) { + try { return decrypt(data, key) } catch {} + } + key = await fetchKey() + try { return decrypt(data, key) } catch { throw httpError(502, 'No se pudo descifrar la lista de capítulos de M440') } + } + + // ---------- Ficha ---------- + async function manga(id) { + const slug = id.slice(3) + const html = await get(`/manga/${slug}`) + const dd = label => { + const m = new RegExp(`
${label}
\\s*]*>([\\s\\S]*?)`).exec(html) + return m ? m[1] : '' + } + const links = s => [...s.matchAll(/
]*>([^<]+)<\/a>/g)].map(m => decode(m[1])).filter(Boolean) + const well = /
\s*
[\s\S]*?Sinopsis[\s\S]*?<\/h5>([\s\S]*?)<\/div>/.exec(html)?.[1] || '' + // Los saltos del código fuente no cuentan: solo
(línea) y

(párrafo). + const description = decode(well.replace(/\s+/g, ' ').replace(//gi, '\n').replace(/<\/p>/gi, '\n\n')) + .split('\n').map(l => l.trim()).join('\n').replace(/\n{3,}/g, '\n\n').trim() + const list = await chapterList(html) + const chapters = list.filter(c => /^[a-z0-9._-]+$/i.test(c.slug || '')).map(c => { + const num = String(c.number ?? '').trim() || null + const name = decode(c.name) + return { + id: `${id}~${c.slug.toLowerCase()}`, num, volume: c.volume && c.volume !== '0' ? String(c.volume) : null, + title: name.replace(/^Cap[ií]tulo\s*[0-9.]*\s*[:.-]?\s*/i, '').trim(), + pages: null, group: (c.created_at || '').slice(0, 10).split('-').reverse().join('/'), publishAt: c.created_at || null, + } + }) + chapters.sort((a, b) => (parseFloat(a.num) || 0) - (parseFloat(b.num) || 0)) + const status = decode(/manga-list\/tag\/[^"]*"[^>]*title="([^"]+)"/.exec(html)?.[1] || '') + const year = parseInt(decode(dd('Fecha de Publicaci[oó]n')), 10) + return { + manga: { + id, + source: 'mi', + title: decode(/

]*id="title"[^>]*>([^<]+)/.exec(html)?.[1]) || slug, + description, + status: status || null, + type: decode(dd('Tipo')) || null, + year: year > 1900 && year < 2100 ? year : null, + tags: [...links(dd('Demogr[aá]fico')), ...links(dd('G[eé]nero'))].slice(0, 12).map(name => ({ id: null, name })), + authors: [...new Set([...links(dd('Autor')), ...links(dd('Artista'))])], + languages: ['es'], + cover: cover(/class="boxed"[\s\S]*?]*class="img-responsive"[^>]*data-src=['"]\s*([^'"]+?)\s*['"]/g)].map(m => { + let u = decode(m[1]) + if (!/^https?:\/\//.test(u)) { + try { u = decodeURIComponent(Buffer.from(u.split('://').pop(), 'base64').toString('utf8')) } catch { return null } + } + return /^https?:\/\//.test(u) ? u : null + }).filter(Boolean) + if (!pages.length) throw httpError(502, 'No se encontraron páginas en M440') + return pages + } + + return { search, manga, pageUrls } +} diff --git a/app/meta.js b/app/meta.js new file mode 100644 index 0000000..c5e7234 --- /dev/null +++ b/app/meta.js @@ -0,0 +1,317 @@ +// Información extra de un manga (ficha "Info") y fechas de publicación de sus capítulos, de fuentes +// externas: MangaDex (enlaces entre sitios y fechas), AniList (sinopsis, autores, puntuación, adaptaciones, +// recomendaciones) y MangaUpdates (revista, editoriales, fechas de traducciones). +// +// 1. Identificar la obra: si viene de MangaDex, sus enlaces ya dicen cuál es en AniList/MangaUpdates; si +// viene de otra fuente se busca por título en MangaDex, luego en AniList y luego en MangaUpdates (solo +// se acepta un título que coincida, para no mostrar datos de otra obra). +// 2. Fechas: los sitios en español suelen poner la fecha en que ellos subieron el capítulo (a veces la +// misma para cientos). Se toma la primera fecha conocida de cada número de capítulo en cualquier +// idioma (MangaDex) o grupo (MangaUpdates): es la fecha de la primera traducción, muy cercana a la de +// publicación en series en curso. +// 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 +const ANILIST = 'https://graphql.anilist.co' +const MU_API = 'https://api.mangaupdates.com/v1' +const MD_RATINGS = ['safe', 'suggestive', 'erotica', 'pornographic'] + +// Títulos comparables: sin acentos, mayúsculas, signos ni espacios. +const norm = s => String(s || '').normalize('NFKD').replace(/[̀-ͯ]/g, '').toLowerCase().replace(/&/g, 'and').replace(/[^\p{L}\p{N}]+/gu, '') +const bigrams = s => { const b = new Map(); for (let i = 0; i < s.length - 1; i++) { const k = s.slice(i, i + 2); b.set(k, (b.get(k) || 0) + 1) } return b } +function similar(a, b) { + a = norm(a); b = norm(b) + if (!a || !b) return false + if (a === b) return true + if (Math.min(a.length, b.length) < 6) return false + const A = bigrams(a), B = bigrams(b) + let hit = 0 + for (const [k, n] of A) hit += Math.min(n, B.get(k) || 0) + return (2 * hit) / (a.length - 1 + b.length - 1) >= 0.9 +} +// Número de capítulo como clave ("01" -> "1", "10.50" -> "10.5"). +const numKey = n => { const f = parseFloat(n); return Number.isFinite(f) ? String(f) : null } +// HTML de AniList/MangaUpdates a texto (solo saltos de línea). +const toText = (html, decode) => decode(String(html || '').replace(/\r/g, '').replace(/\s*/gi, '\n').replace(/<\/p>/gi, '\n\n').replace(/<[^>]+>/g, '')) + .split('\n').map(l => l.trim()).join('\n').replace(/\n{3,}/g, '\n\n').trim() + +export function setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, net, decode, imgUrl, UA, prepareBanner }) { + db.exec(`CREATE TABLE IF NOT EXISTS meta_cache (key TEXT PRIMARY KEY, value TEXT NOT NULL, at INTEGER NOT NULL)`) + + // Guardado en la base de datos con caducidad; ttl puede depender del valor (p. ej. "no encontrado" dura menos). + // Si caducó se devuelve lo guardado y se actualiza en segundo plano; con wait se espera a la actualización. + const inflight = new Map() + function stored(key, ttl, fn, wait = false) { + const row = db.prepare('SELECT value, at FROM meta_cache WHERE key = ?').get(key) + const old = row ? JSON.parse(row.value) : undefined + const life = v => (typeof ttl === 'function' ? ttl(v) : ttl) + if (row && Date.now() - row.at < life(old)) return Promise.resolve(old) + if (!inflight.has(key)) { + inflight.set(key, (async () => { + try { + const v = await fn(old) + db.prepare('INSERT INTO meta_cache (key, value, at) VALUES (?, ?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value, at = excluded.at').run(key, JSON.stringify(v), Date.now()) + return v + } catch (e) { + if (old !== undefined) return old + throw e + } + })().finally(() => inflight.delete(key))) + } + return old !== undefined && !wait ? Promise.resolve(old) : inflight.get(key) + } + + // Peticiones a AniList/MangaUpdates, de una en una con una pausa (los dos limitan la frecuencia). + let gate = Promise.resolve() + const turn = ms => { const p = gate.then(() => new Promise(r => setTimeout(r, ms))); gate = p; return p } + async function getJson(url, opts = {}, name = 'la fuente') { + for (let i = 0; ; i++) { + net.guard() + await turn(400) + const res = await fetch(url, { ...opts, headers: { 'User-Agent': UA, Accept: 'application/json', ...(opts.body ? { 'Content-Type': 'application/json' } : {}), ...opts.headers }, signal: AbortSignal.timeout(20e3) }) + .catch(() => { net.failed(); return null }) + if (res?.ok) return res.json() + if (res?.status === 404) return null + const retry = !res || res.status === 429 || res.status >= 500 + if (!retry || i >= 2) throw new HttpError(502, `No se pudo consultar ${name}${res ? ` (${res.status})` : ''}`) + await new Promise(r => setTimeout(r, (Number(res?.headers.get('retry-after')) || 2 * (i + 1)) * 1000)) + } + } + const anilist = (query, variables) => getJson(ANILIST, { method: 'POST', body: JSON.stringify({ query, variables }) }, 'AniList').then(d => d?.data) + const mu = (path, body) => getJson(MU_API + path, body ? { method: 'POST', body: JSON.stringify(body) } : {}, 'MangaUpdates') + + // ---------- Identificar la obra ---------- + // MangaUpdates: los enlaces nuevos son el id en base 36; los antiguos (numéricos) no sirven para la API. + const muIdOf = s => (/^[0-9a-z]+$/.test(s || '') && /[a-z]/.test(s) ? parseInt(s, 36) : null) + const mdLinks = m => { + const l = m.attributes.links || {} + return { md: m.id, al: Number(l.al) || null, mu: muIdOf(l.mu), mal: Number(l.mal) || null } + } + const mdTitles = m => [...Object.values(m.attributes.title || {}), ...(m.attributes.altTitles || []).flatMap(t => Object.values(t))] + + async function links(mangaId, wait = false) { + return stored(`links:${mangaId}`, v => (v?.md || v?.al || v?.mu ? 30 * DAY : 3 * DAY), async () => { + if (sourceOf(mangaId) === 'md') { + const d = await md(`/manga/${mangaId}`, {}, 30 * 60e3) + return { ...mdLinks(d.data), via: 'MangaDex' } + } + const { manga } = await fetchMangaFull(mangaId, 'es') + const title = manga.title + // MangaDex: el que tenga ese título (o alternativo); entre varios, el que más enlaces tenga. + // Una fuente caída no significa "no encontrado": no se guarda y se reintenta. + let failed = false + const soft = p => p.catch(() => { failed = true; return null }) + const r = await soft(md('/manga', { title, limit: 10, 'contentRating[]': MD_RATINGS }, 6 * 3600e3)) + const hits = (r?.data || []).filter(m => mdTitles(m).some(t => similar(t, title))) + hits.sort((a, b) => Object.keys(b.attributes.links || {}).length - Object.keys(a.attributes.links || {}).length) + if (hits[0]) return { ...mdLinks(hits[0]), via: 'MangaDex', title: mdTitles(hits[0])[0] } + const a = await soft(anilist(`query($s:String){Page(perPage:6){media(search:$s,type:MANGA){id idMal title{romaji english native} synonyms}}}`, { s: title })) + const am = a?.Page?.media?.find(m => [m.title.romaji, m.title.english, m.title.native, ...(m.synonyms || [])].some(t => similar(t, title))) + if (am) return { md: null, al: am.id, mu: null, mal: am.idMal || null, via: 'AniList', title: am.title.romaji } + const s = await soft(mu('/series/search', { search: title, perpage: 8 })) + const sm = s?.results?.find(x => similar(decode(x.record.title), title) || similar(decode(x.hit_title), title)) + if (sm) return { md: null, al: null, mu: sm.record.series_id, mal: null, via: 'MangaUpdates', title: decode(sm.record.title) } + if (failed) throw new HttpError(502, 'No se pudo identificar la obra') + return {} + }, 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 + averageScore meanScore popularity favourites genres + tags{name rank isMediaSpoiler isGeneralSpoiler} + rankings{rank type allTime context year} + coverImage{extraLarge large} bannerImage + staff(perPage:12,sort:RELEVANCE){edges{role node{name{full native}}}} + externalLinks{site url type language} + relations{edges{relationType(version:2) node{id type format status title{romaji english} startDate{year} coverImage{large}}}} + recommendations(perPage:8,sort:RATING_DESC){nodes{rating mediaRecommendation{id type format title{romaji english} coverImage{large} averageScore}}}` + const date = d => (d?.year ? [d.year, d.month, d.day].filter(Boolean).map((x, i) => (i ? String(x).padStart(2, '0') : x)).join('-') : null) + const cover = u => (u ? imgUrl(u) : null) + + async function info(mangaId, wait = false) { + const l = await links(mangaId, wait) + return stored(`info:${mangaId}`, INFO_TTL, async old => { + let failed = false + const [al, ms] = await Promise.all([ + l.al ? anilist(`query($id:Int){Media(id:$id,type:MANGA){${AL_FIELDS}}}`, { id: l.al }) + : l.mal ? anilist(`query($id:Int){Media(idMal:$id,type:MANGA){${AL_FIELDS}}}`, { id: l.mal }) : null, + l.mu ? mu(`/series/${l.mu}`) : null, + ].map(p => Promise.resolve(p).catch(e => { console.warn('Info:', e.message); failed = true; return null }))) + const a = al?.Media + // Con una fuente caída no se pisa lo que había ni se da la obra por no encontrada. + if (failed && (old?.found || (!a && !ms))) throw new HttpError(502, 'No se pudo consultar la información de la obra') + if (!a && !ms) return { found: false } + const staff = (a?.staff?.edges || []).filter(e => /^(Story|Art|Original)/i.test(e.role)).map(e => ({ role: e.role, name: e.node.name.full, native: e.node.name.native })) + const muAuthors = (ms?.authors || []).map(x => ({ role: x.type === 'Artist' ? 'Art' : 'Story', name: decode(x.name) })) + return { + found: true, + titles: { + romaji: a?.title?.romaji || decode(ms?.title || '') || null, + english: a?.title?.english || null, + native: a?.title?.native || null, + other: [...new Set([...(a?.synonyms || []), ...(ms?.associated || []).map(x => decode(x.title))])].slice(0, 12), + }, + description: a?.description ? toText(a.description, decode) : ms?.description ? toText(ms.description, decode) : '', + format: a?.format || ms?.type || null, + country: a?.countryOfOrigin || null, + status: a?.status || null, + statusDetail: ms?.status ? decode(ms.status) : null, + start: date(a?.startDate) || (ms?.year ? String(ms.year) : null), + end: date(a?.endDate), + chapters: a?.chapters || null, + volumes: a?.volumes || null, + latestChapter: ms?.latest_chapter || null, + score: a?.averageScore ?? a?.meanScore ?? null, + muScore: ms?.bayesian_rating ?? null, + muVotes: ms?.rating_votes ?? null, + popularity: a?.popularity ?? null, + favourites: a?.favourites ?? null, + rankings: (a?.rankings || []).filter(r => r.allTime || r.year).slice(0, 4).map(r => ({ rank: r.rank, type: r.type, allTime: r.allTime, year: r.year })), + genres: a?.genres?.length ? a.genres : (ms?.genres || []).map(g => g.genre), + tags: (a?.tags || []).filter(t => !t.isMediaSpoiler && !t.isGeneralSpoiler && t.rank >= 60).slice(0, 14).map(t => t.name), + staff: staff.length ? staff : muAuthors, + magazines: (ms?.publications || []).map(p => ({ name: decode(p.publication_name), publisher: decode(p.publisher_name || '') })), + publishers: (ms?.publishers || []).map(p => ({ name: decode(p.publisher_name), type: p.type, notes: decode(p.notes || '') })), + licensed: ms?.licensed ?? null, + anime: ms?.anime?.start ? { start: decode(ms.anime.start), end: decode(ms.anime.end || '') } : null, + adaptations: (a?.relations?.edges || []).filter(e => e.node.type === 'ANIME').map(e => ({ title: e.node.title.romaji, format: e.node.format, year: e.node.startDate?.year || null, status: e.node.status })), + related: (a?.relations?.edges || []).filter(e => e.node.type === 'MANGA').slice(0, 8).map(e => ({ relation: e.relationType, title: e.node.title.english || e.node.title.romaji, format: e.node.format, year: e.node.startDate?.year || null, cover: cover(e.node.coverImage?.large) })), + recommendations: (a?.recommendations?.nodes || []).map(n => n.mediaRecommendation).filter(r => r && r.type === 'MANGA').slice(0, 8).map(r => ({ title: r.title.english || r.title.romaji, romaji: r.title.romaji, format: r.format, score: r.averageScore, cover: cover(r.coverImage?.large) })), + cover: cover(a?.coverImage?.extraLarge || a?.coverImage?.large), + banner: cover(a?.bannerImage), + links: [ + a?.siteUrl ? { site: 'AniList', url: a.siteUrl } : null, + ms?.url ? { site: 'MangaUpdates', url: ms.url } : null, + a?.idMal || l.mal ? { site: 'MyAnimeList', url: `https://myanimelist.net/manga/${a?.idMal || l.mal}` } : null, + l.md ? { site: 'MangaDex', url: `https://mangadex.org/title/${l.md}` } : null, + ...(a?.externalLinks || []).filter(x => x.type !== 'SOCIAL' || /official/i.test(x.site)).map(x => ({ site: x.site + (x.language ? ` (${x.language})` : ''), url: x.url })), + ].filter(x => x && /^https?:\/\//.test(x.url)).slice(0, 14), + matched: { via: l.via || null, title: l.title || null }, + } + }, wait) + } + + // La ficha guardada, sin esperar a ninguna fuente (null si aún no hay). Si caducó, se actualiza por detrás. + function cachedInfo(mangaId) { + const row = db.prepare('SELECT value FROM meta_cache WHERE key = ?').get(`info:${mangaId}`) + if (!row) return null + info(mangaId).catch(() => {}) + return JSON.parse(row.value) + } + + // ---------- Fechas de los capítulos ---------- + async function dates(mangaId, wait = false) { + const l = await links(mangaId, wait) + if (!l.md && !l.mu) return { dates: {}, via: [] } + return stored(`dates:${mangaId}`, DAY, async old => { + let failed = false + const soft = p => p.catch(() => { failed = true; return null }) + const out = {} + const via = [] + const add = (n, d) => { + const k = numKey(n) + if (!k || !d) return + d = d.slice(0, 10) + if (!out[k] || d < out[k]) out[k] = d + } + if (l.md) { + let n = 0 + for (let offset = 0; offset < 10000; offset += 500) { + const d = await soft(md(`/manga/${l.md}/feed`, { + limit: 500, offset, 'order[chapter]': 'asc', 'contentRating[]': MD_RATINGS, + includeExternalUrl: 1, includeEmptyPages: 1, includeFuturePublishAt: 0, includeUnavailable: 1, + }, 3600e3)) + for (const c of d?.data || []) { add(c.attributes.chapter, c.attributes.publishAt || c.attributes.createdAt); n++ } + if (!d || offset + 500 >= d.total) break + } + if (n) via.push('MangaDex') + } + if (l.mu) { + let n = 0 + for (let page = 1; page <= 40; page++) { + const r = await soft(mu('/releases/search', { search: String(l.mu), search_type: 'series', perpage: 100, page, orderby: 'date', asc: 'asc' })) + for (const x of r?.results || []) { + const ch = String(x.record.chapter || '') + const d = x.record.release_date + // "415-416" o "12-12.5": todos los enteros del tramo y los extremos. + const m = /^(\d+(?:\.\d+)?)\s*-\s*(\d+(?:\.\d+)?)$/.exec(ch) + if (m) { + const [lo, hi] = [parseFloat(m[1]), parseFloat(m[2])] + add(lo, d); add(hi, d) + if (hi - lo < 50) for (let k = Math.ceil(lo); k <= hi; k++) add(k, d) + } else if (/^\d+(\.\d+)?$/.test(ch)) add(ch, d) + n++ + } + if (!r || page * 100 >= r.total_hits) break + } + if (n) via.push('MangaUpdates') + } + if (failed && (old?.via?.length || !Object.keys(out).length)) throw new HttpError(502, 'No se pudieron consultar las fechas') + return { dates: out, via } + }, wait) + } + + // ---------- Mangas seguidos: siempre con su información ---------- + // Cada hora se completa lo que falte o haya caducado, de uno en uno (también lo seguido al empezar a leer), + // y se reescalan los banners guardados que aún no lo estén. + let syncing = false + async function syncFollowed() { + if (syncing || !net.online()) return + syncing = true + try { + for (const { manga_id: id } of db.prepare('SELECT DISTINCT manga_id FROM follows WHERE active = 1').all()) { + if (!net.online()) break + await info(id, true).catch(e => console.warn('Info de seguidos', id, e.message)) + await dates(id, true).catch(e => console.warn('Fechas de seguidos', id, e.message)) + } + for (const { value } of db.prepare("SELECT value FROM meta_cache WHERE key LIKE 'info:%'").all()) { + const { banner } = JSON.parse(value) + if (banner) await prepareBanner(banner).catch(e => console.warn('Banner', e.message)) + } + } finally { syncing = false } + } + setTimeout(syncFollowed, 60e3).unref() + setInterval(syncFollowed, SYNC_EVERY).unref() + net.whenOnline(syncFollowed) + + async function route(p, method, res) { + const m = /^\/api\/meta\/([^/]+)(\/dates)?$/.exec(p) + if (!m || method !== 'GET') return false + const id = decodeURIComponent(m[1]) + json(res, 200, await (m[2] ? dates(id) : info(id))) + return true + } + + return { route, links, mdMatches, muVolumes, similar, stored, info, cachedInfo, dates } +} diff --git a/app/public/animeface-cascade.json b/app/public/animeface-cascade.json new file mode 100644 index 0000000..a32167c --- /dev/null +++ b/app/public/animeface-cascade.json @@ -0,0 +1 @@ +{"license":"lbpcascade_animeface, https://github.com/nagadomi/lbpcascade_animeface\nThe MIT License (MIT)\n\nCopyright (C) 2011 by nagadomi@nurs.or.jp\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.","w":24,"h":24,"stages":[[-1.263674259185791,[[103,[-1302,-1,-2097218,-1,-1,-1,-1,-1],[-0.8628431558609009,0.8288403153419495]],[111,[-270553558,-16593,-1342197841,-24657,-1069073,-28753,-268456017,-28753],[-0.8301590085029602,0.5024479627609253]],[274,[-791883568,-570557057,-10521123,-2261129,-168835596,-14680065,-536870913,-577],[-0.6802006363868713,0.4293278455734253]]]],[-1.636199951171875,[[17,[-67110150,-1,-1,-1,-1,-1,-1,-1],[-0.7564964890480042,0.6566571593284607]],[226,[-21846,-18501,-546118,-17745,-67130689,-20481,-21589,-5185],[-0.6622880697250366,0.45048093795776367]],[167,[-17104902,269658770,-5046274,-14539742,-1,-67897614,-1,-11301],[-0.6063612699508667,0.41438210010528564]],[410,[-67109392,-5,-537133576,-1282,1154814128,-4638,-1448359800,-71304194],[-0.5924391746520996,0.35992857813835144]],[329,[-422583808,2030231298,-445326064,-35408702,1313755528,222323776,-589330296,-3146022],[-0.5127382278442383,0.39016643166542053]],[196,[-927858550,1525075679,149029012,-71367005,1365708031,-386200322,-419477761,-1110321],[-0.4261343479156494,0.3813542425632477]]]],[-1.6592464447021484,[[112,[-18,-1,-17,-1,-1,-1,-1,-1],[-0.7186874747276306,0.5039339661598206]],[76,[-98500654,-327681,-81592321,-1,-67641345,-1,-525313,-1],[-0.6528414487838745,0.40119534730911255]],[405,[-571474700,-4097,-1409548289,-1,-1939013504,-65537,-269484808,-1],[-0.5936161279678345,0.3178099989891052]],[121,[-486563073,-218104129,553639679,-88085761,1082066687,-151040033,-37889,-32769],[-0.38881537318229675,0.46969282627105713]],[91,[-28754,-4741217,-5787766,-4272213,-268464465,-71364690,-340815953,-1616049394],[-0.5380537509918213,0.3086091876029968]],[446,[419443088,285235408,254456305,-16204592,-1391444552,-1359433799,-1424163143,-1079260968],[-0.42636507749557495,0.3572568893432617]],[327,[-597814534,-555989829,-69009699,-13080945,1523056856,-23444034,-222757126,-72695094],[-0.3299240469932556,0.4131940007209778]],[564,[253993232,855774768,261231921,1605348720,1061134545,-1109396681,-8408880,-1073744225],[-0.43008872866630554,0.29400870203971863]]]],[-1.54403817653656,[[288,[-65537,-331809,-5586945,-5595969,-4524038,-5578822,-72683526,-1431651670],[-0.49188971519470215,0.4876288175582886]],[65,[-168317009,-167792753,-101341265,-677969,-201347153,-675921,-67260497,-1077966929],[-0.5765073299407959,0.3772933781147003]],[505,[-264195,-4677924,-67109379,-7370536,-16903,-16933387,-1614832472,-542162864],[-0.46903330087661743,0.4083128273487091]],[267,[-2295048,-3,-34537480,-131073,-1281,-5121,-1,-1],[-0.5294912457466125,0.295517235994339]],[359,[134218832,1346373084,485543039,-571861671,470810624,-1715453444,-671033126,-1146484562],[-0.43582525849342346,0.34078335762023926]],[531,[-205802701,855872035,-1275381997,-1751670853,-545348677,-1210587213,-71320902,-1161839685],[-0.3371037542819977,0.453489750623703]],[118,[-487093505,-218127617,-1560391937,-62721,1077346555,2063540991,-134275329,-47553],[-0.31872883439064026,0.44437479972839355]],[50,[-142610526,-253073502,-92102144,-741350074,-150497792,4218884,-785930752,-246946298],[-0.35306012630462646,0.33983978629112244]],[207,[-1024537970,-689028406,147245278,1885009118,-937884982,911296300,-280506658,-73676801],[-0.32728925347328186,0.3312322199344635]],[570,[-1086335181,-2026903933,402122549,-11553395,-2038958942,-2047885573,-747113584,-3150353],[-0.2583779990673065,0.41633638739585876]],[198,[-98401792,-257947744,744505346,-44030416,1449330176,-1224726752,-13357430,-215989504],[-0.33786505460739136,0.3310038447380066]]]],[-1.5968077182769775,[[161,[-327682,-234815982,-9217,-327702,-67108865,-19,-35169,-1025],[-0.6077409982681274,0.3348584771156311]],[42,[-234901585,-134893649,-101453937,-676433,-167792721,-840273,-67662929,-61685],[-0.5801490545272827,0.2930995523929596]],[462,[-8391180,-9439249,-262916,-265250,2137191648,-8976,-1048856,-71303446],[-0.5099717378616333,0.3178481161594391]],[295,[-607128576,-2136813818,-646465352,-33556977,-732956532,-871835636,-1461741368,-5243185],[-0.4626306891441345,0.2914928197860718]],[227,[-833836928,-547492971,-36372488,-6209,-69293062,-1788225,-67174657,-1],[-0.710062563419342,0.15655440092086792]],[120,[-1024463121,-202396945,-1428264195,-2102581,1473185007,-33559809,-83932193,-39353],[-0.2852258086204529,0.4243815243244171]],[327,[-665695145,-638053093,-634372129,2134510939,-62259176,-570765889,-66531622,-1154847045],[-0.2963193356990814,0.3593541085720062]],[548,[-1357384943,-343742928,-1612720912,-538063840,-1956010591,-2003587624,131028417,-538978871],[-0.3405310809612274,0.3084990084171295]],[535,[-138422528,588489479,1067695873,-1074794561,-27345985,-1342177351,-1360024918,-1429214225],[-0.2772350311279297,0.3785536289215088]],[32,[-69217521,318980663,-546308677,553910191,-1119418848,1023541829,-1078203530,1426587151],[-0.34162473678588867,0.2840517461299896]],[68,[-272896094,-1060178292,-563023360,-740103008,-421596795,4254308,-151788864,-804784960],[-0.3335507810115814,0.25905099511146545]],[218,[2080341787,1060846641,-1107591393,-608438981,-537157638,-1879067494,1226316347,272137786],[-0.40187957882881165,0.21486374735832214]],[171,[-1057233345,-629705185,-500534530,807650042,68650975,-92965029,2013228271,-135284769],[-0.3021503984928131,0.2727862298488617]],[221,[1509885074,2004291294,555376605,1073426430,2135919867,267332795,-620820929,1006580307],[-0.33670711517333984,0.25498929619789124]]]],[-1.5330305099487305,[[242,[-168299308,-103680513,-17039875,-16513,-104858115,-1093,-68157441,-1074807809],[-0.5250251889228821,0.3111686110496521]],[146,[-1052690,-134217745,-4114,-134744587,-67108865,-101187585,-7185,-17],[-0.5587370991706848,0.2360144555568695]],[472,[-1086455823,520682672,2147352049,-5382736,-1433403459,-21364743,-1883244099,-541076003],[-0.31670504808425903,0.4062115252017975]],[202,[1224607962,1392201471,-2097544450,-88674562,136185054,-251707461,-319042850,-67147009],[-0.435680627822876,0.2630350887775421]],[246,[-327758,-1682395368,-101499042,-4484198,-264197,-4276368,-67372516,-64294],[-0.3509634733200073,0.2855299413204193]],[568,[-1221597405,926924550,-1216481488,-7340245,-6297677,-571475207,-142608393,-81],[-0.25762778520584106,0.3931208550930023]],[145,[-550531409,-4223169,-1799410513,-40915442,-100806657,-71439443,-290865,-5271921],[-0.36129701137542725,0.25089186429977417]],[518,[973252242,1059044139,691879211,1033633599,1061157819,1052455857,1001855675,994000443],[-0.40735718607902527,0.21259017288684845]],[292,[-155561249,-121033,-227353697,822221407,-732409764,-134860169,-5579014,-81596545],[-0.2780284583568573,0.3157797157764435]],[117,[-492377361,-67966273,-487796737,-537989558,1157580795,-623117,-33726753,-571901],[-0.23659884929656982,0.3690859377384186]],[49,[-3146078,-87102466,1929375776,-110170912,-209718716,1073987584,-172501248,-263724778],[-0.3126767873764038,0.2740819752216339]],[608,[-17830961,-52594705,1894769378,-759235926,-746588864,1145539840,-476648760,-255332678],[-0.23596332967281342,0.33085909485816956]],[294,[1787490066,-264197568,2011318014,-10345952,1816350540,1309473484,-1075852357,-347121653],[-0.3239920735359192,0.23825044929981232]],[58,[538050560,10548908,633938201,761344263,1178943808,1145357893,1473577739,17132883],[-0.43849360942840576,0.17257992923259735]],[62,[-1343377669,-67129357,-1478513667,536586967,-1342462217,-1141683202,-805589267,268372159],[-0.3904886543750763,0.19998973608016968]],[469,[-1141917951,2055732023,-67115040,-5259282,891279264,-1073742033,-252723072,-70254961],[-0.32046061754226685,0.2269742637872696]],[148,[-123294037,-629126677,-129217217,-106165406,-58765569,-53989993,-92475474,-260742313],[-0.22333471477031708,0.33217769861221313]],[270,[11436672,899321384,-2035277788,-134750226,494898348,805293739,-134222129,-1330],[-0.5181822776794434,0.14062055945396423]]]],[-1.617965817451477,[[251,[-16842753,-65537,-1078263809,992149503,-21315590,-65537,-4530178,-1155334401],[-0.40925416350364685,0.37826526165008545]],[506,[-13,-37748737,-67108878,-1,-2061,-16385,-134744078,-134217729],[-0.33893144130706787,0.38312068581581116]],[123,[-33558546,-34087433,-34078866,-33554433,-68423681,-17175821,-100925441,-1025],[-0.6069239974021912,0.1872701644897461]],[495,[1061158675,993509311,-1073758401,-1077936129,2139094975,2147450879,-68175185,721352703],[-0.34320926666259766,0.2941015064716339]],[188,[-889254689,-606139649,-822276097,-536932833,-894497025,-1694560673,-302051361,1543438867],[-0.3382209241390228,0.2658557891845703]],[387,[2110840317,-170373132,-67108867,-492679,-1898390020,-458756,-5701956,-1414920152],[-0.30869945883750916,0.2828564941883087]],[40,[-390074625,-96482225,-35656193,-571112243,216501503,-1393570577,-1933586817,1087717587],[-0.3291912078857422,0.22378942370414734]],[438,[-8388612,-71569970,-12587010,-5313858,-885750552,-17113860,-303256326,-1398104918],[-0.3155522048473358,0.2283312976360321]],[300,[-44092621,1534539583,2113879867,-72385733,2147434111,-4440513,2034203967,1937456447],[-0.5197641849517822,0.14146961271762848]],[168,[-352334129,-767580353,552889982,1920724734,-713601266,1983999843,-3411973,-201588757],[-0.2473347932100296,0.2858215272426605]],[609,[-6225,-295247985,-759442752,1084948202,-68348,1141308928,-86191156,-253235222],[-0.24679842591285706,0.2772952914237976]],[419,[151016821,184578384,-550121483,524098780,139726044,-1076822018,-1879556100,-1078263671],[-0.256909042596817,0.266051709651947]],[302,[-539102470,-2497,-1107343618,-33566754,-268571713,-16450,-1093681969,-67633153],[-0.2787766456604004,0.2446342259645462]],[485,[-4743040,-1107956700,-1376293472,-812648524,-846885756,-1113473919,-2031764308,76926853],[-0.2878116965293884,0.2346356213092804]],[86,[-209870857,2010119803,83886091,1057456929,2046623741,-174587905,565776015,2012079087],[-0.21731185913085938,0.297241747379303]],[173,[2130440170,-1494225046,-1116212241,1877473231,58685261,1179325709,-612376625,-5169],[-0.24395407736301422,0.2575765550136566]],[618,[-92280577,-106446978,-151355790,1624177388,-402930100,-145101820,-1224807732,-798818356],[-0.22018355131149292,0.28403928875923157]],[199,[1711793897,430272415,788433915,1073703931,2113740797,2113925119,-33605377,553123759],[-0.30896610021591187,0.20028412342071533]],[588,[-278925405,-1884375241,-17322192,-541066313,-1755323001,126740978,-671090992,-135268353],[-0.21448580920696259,0.29228198528289795]],[15,[-134217809,-605290706,-344462688,-236258702,-12599545,1128721759,-134241472,-134220842],[-0.20150026679039001,0.3063429594039917]],[308,[-62849320,2136267568,1429265656,-39780400,2105335016,-4222980,-222437720,-131148],[-0.5517482161521912,0.11506053060293198]],[385,[269753717,520624093,-1124313601,2109618489,894436372,-1619636227,1608012104,-1414922213],[-0.2652745842933655,0.22207336127758026]]]],[-1.5240784883499146,[[209,[-16777473,-83890433,-764480770,1914896094,-8241,-4194369,-17,-8388609],[-0.37706291675567627,0.34414002299308777]],[471,[-638582861,-214171669,-503319568,-33554449,-2144,-340000769,1375195600,-671088657],[-0.401825875043869,0.28752782940864563]],[4,[-20561,-235683841,-937985,-33574963,-201774161,-67426137,-67260433,-8977137],[-0.3826923668384552,0.23733018338680267]],[538,[-142608637,858242867,-2285,-1077936385,-2121,-1073750089,-269484113,-1358958865],[-0.25483861565589905,0.3326546549797058]],[204,[1484409054,-81102033,36761342,1375797182,1248987294,-11018250,-30220550,-15466497],[-0.3411288559436798,0.24120210111141205]],[371,[-539165220,-131082,-281215236,-1130504,-54722596,-272891953,-54724376,-5138],[-0.4527590572834015,0.17377805709838867]],[25,[-1493,-202376410,-1023938528,-251920682,-140545280,1682265604,-143133120,-211288085],[-0.27601632475852966,0.2942805588245392]],[210,[2130668066,-1372889750,-1073898641,-1058833,1147095110,1192205127,-872415281,-11534385],[-0.27625253796577454,0.26642629504203796]],[99,[-4200469,-135335198,-203431774,-70261556,-67111946,-2147048126,-145230366,-203950650],[-0.19389793276786804,0.3761134743690491]],[115,[-353457425,-74805585,-353472977,-1176914,1693926379,2146838449,-134410392,2135245639],[-0.2886756360530853,0.24483714997768402]],[508,[-1148733519,780856288,-1276684815,-15737456,-1346395480,-1880315220,97883549,-570434343],[-0.24260485172271729,0.28319093585014343]],[612,[-335550942,1122675878,39428642,44487402,-202381696,147984,-530587456,-1060116485],[-0.3207935690879822,0.2175094038248062]],[487,[-2118783,-1086344025,-1094734171,-1375868937,-5919064,-1073826123,-1598643539,16056055],[-0.23602557182312012,0.2590687870979309]],[637,[-134222161,-892340722,-85984544,-521142342,-202377530,2323523,-135792960,-255066161],[-0.21411967277526855,0.2883610129356384]],[122,[-621281281,305332202,-262286,-4728238,-36700161,2138570743,-8388641,-201],[-0.21160617470741272,0.2865854501724243]],[66,[-68164637,-909443356,-415296912,-679676768,-1153701470,-2033649432,1979708660,-716703808],[-0.21729548275470734,0.2750277519226074]],[166,[-488641938,1886375671,-894547222,1886317154,1869480350,571498307,-212336949,-227541041],[-0.24970392882823944,0.22789211571216583]],[474,[-1961000015,-1450948560,-688194123,-33689132,-2113163334,-1933923148,537001,-1981819431],[-0.22741013765335083,0.2472570389509201]],[234,[16385024,813192181,1071056213,1557346127,1002160826,-1178714129,-544243969,-1420854370],[-0.5305399894714355,0.10699179023504257]],[594,[-100673809,-201327669,-117970254,-756099110,-136314996,1917240912,-77683000,-229638210],[-0.17796075344085693,0.3293799161911011]],[545,[285687569,991770411,-268453965,-586154031,248379123,-1477454505,1467478960,-556794161],[-0.22852730751037598,0.24927006661891937]],[233,[1350551227,280743415,-2000770498,855637755,1912588527,955645951,-150999109,551549951],[-0.28712141513824463,0.21096032857894897]]]],[-1.6037335395812988,[[203,[-84213766,1490747034,-136087790,-1334,-610271233,-16781314,-287582050,-9042],[-0.4084002375602722,0.24690119922161102]],[536,[-146803789,322156531,-536873673,-1111623753,-167791621,-285185,-1073742917,-1077952581],[-0.22172482311725616,0.40040645003318787]],[255,[-171060792,-620071486,-1626088625,-1078985329,-2142658748,1330043724,-78652593,1263533903],[-0.32599684596061707,0.23890890181064606]],[95,[-34493009,-235280985,-240005745,-222980691,-168720385,-240078929,-487551569,-574910835],[-0.42127540707588196,0.18673938512802124]],[325,[-539101185,2138416567,760823775,-154689,-5141009,-1229192670,771426991,15139330],[-0.3363325297832489,0.23910291492938995]],[101,[-630216070,-757354758,-1830057793,-100695298,-788821861,-1160568079,-2101537,1509884027],[-0.2783498764038086,0.23219773173332214]],[350,[-133121,-396388,-575086596,-2880236,-605245958,-2565644,-7287816,201345105],[-0.341799795627594,0.19213064014911652]],[290,[-933606177,-657453485,-298165537,302651455,-858897400,-83995559,-588587302,-13967649],[-0.2805669605731964,0.2174130082130432]],[525,[437239691,1000060863,221724059,-1073743937,2095005119,-1094770761,1043807935,1054674879],[-0.3048814535140991,0.20650653541088104]],[184,[-18895094,-131137,-176166466,-203950081,-679546994,-458838209,-68609,-83903493],[-0.47668248414993286,0.1267579048871994]],[12,[2070372663,1998954923,1360207630,1065855538,1377767440,1048259495,1532963338,1968650755],[-0.2667206823825836,0.2210763692855835]],[507,[-922484943,-2027893262,-12585487,-8266672,-1488986463,-1674711105,1393415553,-542141047],[-0.2740706503391266,0.22819964587688446]],[194,[1354368042,814917051,-1126355395,880586619,1559710910,2029785056,-89175330,553090803],[-0.3573450744152069,0.15573251247406006]],[589,[-282073181,261095098,1871689490,-541068327,55427495,8912388,612324563,-705694721],[-0.2390359789133072,0.23758962750434875]],[104,[-206573910,-422925430,-386145678,-136123704,-286278705,1141151376,-479206272,-708320058],[-0.23658229410648346,0.2405620813369751]],[206,[-654652226,-2476513,-1573208611,855821951,-660959876,-9016767,-1131742502,-14483617],[-0.26215147972106934,0.21193760633468628]],[390,[-9010944,-680453375,-1887353857,-47901136,-608526176,-574418016,-1046431574,536871426],[-0.3288300037384033,0.16450397670269012]],[448,[39859029,1953028272,222289789,-538380840,665888169,-71795760,248524477,-1950416872],[-0.2608546018600464,0.2046564668416977]],[24,[686465762,-1275658177,692424703,-1350105627,-1308678529,-1141437607,88731642,802765499],[-0.4210531413555145,0.12438227236270905]],[617,[-16784885,-92289121,-1600719916,-798303556,-159384564,805854224,-128647992,-536622122],[-0.21299290657043457,0.24448233842849731]],[163,[-521214738,-220246129,-1561452498,-219421974,1080886061,308284728,1942440958,-139730089],[-0.23567050695419312,0.20919010043144226]],[559,[-218116608,2854795,586149408,231713907,-136605256,888171669,-152650100,-1393765652],[-0.2231275737285614,0.22375470399856567]],[581,[-180380895,1437970737,1001598225,-147463246,375874851,623058979,589757370,50832259],[-0.26574811339378357,0.18162758648395538]],[176,[-349183357,-205064856,1591152880,-177359388,-1414794578,-846925632,100053228,-985415232],[-0.18771494925022125,0.2632349133491516]],[360,[1329651669,1544846740,-1624838371,-81289607,1174445848,-636449096,-811966501,-609253157],[-0.14781230688095093,0.32182273268699646]],[426,[320255795,-1122912577,1562588985,-3678414,81212071,-1345083980,1775598843,828858931],[-0.3038376569747925,0.15808410942554474]],[611,[-14681,-1041249298,551283406,1089139306,-721422588,14971399,-404950336,-1057492274],[-0.2350015789270401,0.19808870553970337]],[327,[-656473793,-1621491117,-252526727,360189542,-967046976,-54702987,2092583960,-81592757],[-0.22867658734321594,0.21082723140716553]],[195,[-213859158,-17798588,-2144935180,-674987740,-681448807,-826009773,-993984833,-74230701],[-0.17290616035461426,0.2766934335231781]]]],[-1.5635454654693604,[[29,[-218103809,276561887,-138412065,-1,822083583,1895301085,-131073,-8193],[-0.2779134511947632,0.2914063334465027]],[410,[-134217732,-2124833,-1141117716,-545051746,-327187232,-16385,-923826040,-290455878],[-0.47651201486587524,0.16560232639312744]],[291,[-488453418,-36125012,1580386906,-6863783,1078215240,1288454875,-834384306,-536917493],[-0.2843717634677887,0.23990501463413239]],[196,[-631371010,-571503013,-394375427,-53617,-699199745,-100726021,-168882689,2079264479],[-0.2852992117404938,0.22660185396671295]],[217,[-548034902,-67108929,-678035457,-33557569,-203424081,-3147385,-6913,-2097425],[-0.38240256905555725,0.16137607395648956]],[566,[-1155272272,-550848592,-304122592,-1055752,-807405859,-842933767,1224691796,-572523826],[-0.2944502532482147,0.21465636789798737]],[70,[-202375193,-152109334,-939854174,-1049502,-146817149,1111796162,-201330492,-237504574],[-0.20517322421073914,0.29565006494522095]],[362,[-562278827,-589558940,-709079073,1597507911,-242185992,-37871622,-7815682,-1423310294],[-0.2691945731639862,0.2276601344347]],[456,[938981281,1039662303,1071988533,-1077430345,2138399927,2128867248,1844920255,553084791],[-0.3116950988769531,0.1914835125207901]],[595,[-6001,-738460161,-491325280,-253577536,-101257344,-268405248,-218703928,-262931494],[-0.19845137000083923,0.28662288188934326]],[219,[-572575425,-134758593,-17432673,-33572033,-41975809,-606209,1819508539,991508795],[-0.2848263084888458,0.19544802606105804]],[309,[-923858941,16533375,687803098,16641781,1425998848,7298130,-117618036,-1459880273],[-0.3118402063846588,0.17289452254772186]],[38,[43975170,570610210,1259547439,91211681,1344147713,71570948,157771867,1194450935],[-0.40333592891693115,0.13441282510757446]],[547,[-620234959,-144179343,-146804237,-275775558,-1956118605,-1364333069,1464849904,-273678849],[-0.22020047903060913,0.2527577579021454]],[207,[-889729313,-553657669,-1560807681,1383525103,-889272690,-278931701,-3289105,-8913157],[-0.20864352583885193,0.2517823278903961]],[386,[1527208720,453049088,1849883952,-577776648,119313820,-635902472,235845803,-1081104213],[-0.2004029005765915,0.24476240575313568]],[105,[-1792337,-135877649,-877376633,-6542642,-170938369,-1158881281,-335549505,-45848810],[-0.1526852548122406,0.32784590125083923]],[45,[-207358038,-420480598,-201337216,-706480664,-2247288,38040834,-201852992,-707267118],[-0.2064659297466278,0.24018539488315582]],[489,[2125956864,876459318,801054519,-1343357377,2029731224,1039974069,517353000,549746646],[-0.34877002239227295,0.1354045271873474]],[305,[1573977072,-545230924,-503523394,-50543968,-10687586,-806753917,-132104,-131700],[-0.39878302812576294,0.11964873224496841]],[119,[-152047962,-219222301,-386358750,-134552838,1919010662,3367751,-234912529,-203950258],[-0.18678680062294006,0.2509252727031708]],[57,[-1548225838,-544303113,169017858,-1079528078,-65317898,1003848540,366421887,-8390913],[-0.3702678382396698,0.12458038330078125]],[236,[1558449156,1469328420,1056312229,972818209,2062028732,867725236,-299020370,944057152],[-0.32043197751045227,0.1403111219406128]],[372,[-935886289,247647375,584647234,1086240770,1243551262,140912131,1181705168,-433064409],[-0.25840097665786743,0.17777059972286224]],[89,[553422030,-646738485,-1378666531,-1326610755,-1193300225,-51663377,-1544714034,13992968],[-0.32469943165779114,0.13890093564987183]],[586,[-104865536,823385006,-1803553008,-137497690,73373105,557062035,-134236512,-493357073],[-0.21948955953121185,0.2058616727590561]],[575,[-621064755,-225691,-187123238,-211615832,-163588416,-834187516,-222115108,-532480182],[-0.16864611208438873,0.2542310059070587]],[359,[1080299799,403982237,-1396998339,1063594813,-1828978672,-1140929027,-64447240,-1414919521],[-0.2084922343492508,0.20172889530658722]],[429,[-278726750,1605286067,-1984582,-1183826,2008218293,-303018266,-454955821,1710712691],[-0.2077249437570572,0.20896007120609283]],[275,[1480501015,415012755,-1133019206,-1620136218,1060451253,988740863,-1174410540,-1157825603],[-0.18233922123908997,0.23548512160778046]],[5,[-14680093,-404291672,-76582128,-748686168,-138428475,-925915262,-143379488,-775432703],[-0.1731029748916626,0.2502557933330536]],[212,[-1537244639,1407124485,955327325,2007572271,1904150032,925836884,-671762604,-80740449],[-0.17120473086833954,0.24483337998390198]],[90,[242724607,-1477218697,133671801,267644576,-1896108289,-1476658180,-352868111,267886847],[-0.43268245458602905,0.09933996200561523]]]],[-1.4806630611419678,[[171,[-352321537,-16783377,-85984514,-218107170,-35652609,-5769225,-262165,-142606337],[-0.3028380870819092,0.2633202373981476]],[546,[-524299,-67633156,-537657347,-8228,-1073762819,-4219400,1300190552,-571517612],[-0.3547776937484741,0.2022661715745926]],[121,[-486544641,-136840193,-486552833,-4358,-134252545,-40001,-134258721,-41105],[-0.18016183376312256,0.3356707692146301]],[108,[-35928657,-47065673,-168170073,-172632151,-254750721,-638271497,-103690801,-610826356],[-0.34803321957588196,0.1765604317188263]],[446,[152059857,453032944,268172721,-1081999912,1058848761,-1074468867,-1155547143,-1077967479],[-0.24453158676624298,0.22779227793216705]],[512,[1060093883,-2118241,521748443,-543178753,1034941423,1054804398,1039581151,1068449727],[-0.2960316240787506,0.1986057609319687]],[74,[-1055825,-154210581,-489790880,-68688222,-143657140,1883399558,1909716800,-143132733],[-0.20965711772441864,0.2508717477321625]],[570,[2000680707,-1895041657,2143282995,-5243477,-1086853213,-1349915235,1373599043,-540016705],[-0.22155438363552094,0.22709301114082336]],[167,[-67108993,-147652614,-802882,-80484704,-8650787,-788545,-2832,-2685102],[-0.1801174432039261,0.27400344610214233]],[633,[-5393,-153096193,-85197202,-521211906,-136321466,1145489220,-67651890,-523766818],[-0.18812599778175354,0.2648163139820099]],[13,[-1105,-202377882,-537677054,-67700788,-73927229,1146024982,-147589632,-706219049],[-0.17558620870113373,0.2530442774295807]],[324,[1309147731,1342867306,1855638557,1060310665,1279543388,1581780126,2128137819,463161099],[-0.215001180768013,0.20435485243797302]],[136,[470028175,-134246505,-609905701,-40167997,-236079105,-50477073,-1544564785,-1350038900],[-0.2391396313905716,0.1823912262916565]],[627,[-335548501,49278626,-968163296,1621881580,-469768313,71821925,-135800892,-709367826],[-0.21279625594615936,0.20251362025737762]],[85,[2071061109,1597442983,2142580527,60883457,601335733,2012720631,-268461122,1151825767],[-0.24757970869541168,0.1766466647386551]],[526,[-35661939,-1077936193,-301995075,-1183745,-154163571,-122046731,-1449525588,-1933180945],[-0.12376438826322556,0.34610992670059204]],[413,[-68157449,2012427621,-135270402,-318116709,93786352,878040771,1928528416,-252444969],[-0.3528245687484741,0.1253521740436554]],[97,[-299178321,-253579410,-505086341,-106781557,-528036177,-12284558,-269619473,-918469118],[-0.18893779814243317,0.2243262231349945]],[156,[1324150526,1610280955,-1358958593,-438721,1861166843,2063056823,-33851009,1374120788],[-0.23356471955776215,0.17476162314414978]],[254,[-855766433,1584267795,1915910911,156285,-153399800,-211608747,-293675318,-553456689],[-0.2666938602924347,0.15304766595363617]],[578,[-738212306,-188874866,-1569000750,-789874034,-142702264,2031301632,-774720374,-800870518],[-0.19974224269390106,0.1993950754404068]],[549,[-466664669,648734646,1438586999,-35130723,-1414815573,717124154,94090911,-806368801],[-0.13644744455814362,0.2769096791744232]],[342,[1249838607,693062607,184519193,144110238,2055029833,204701535,2054005330,-288359529],[-0.24984607100486755,0.15084804594516754]],[72,[-9443413,-23204177,-507513166,-137462546,-159909905,-1006442233,-192945170,-235407873],[-0.11998380720615387,0.3182705342769623]],[421,[1494269173,-720349708,1063892989,-545465860,-122642948,-1074219268,-1347932744,-1417019368],[-0.24514228105545044,0.15939368307590485]],[336,[1602171163,-69345349,2092678079,-1342178561,847146299,-352846093,1809574457,2071953275],[-0.25552767515182495,0.14843150973320007]],[348,[-151650305,-572701128,-843776304,-712703488,-275972610,-611461836,-23282034,67390465],[-0.19797073304653168,0.19756489992141724]],[80,[-6353,1920937967,-1066998110,-209788174,1460628998,1095128909,1900515906,-178784682],[-0.20146924257278442,0.19506125152111053]],[535,[671069953,868219827,-1355307213,-1246761157,1008137220,1700181427,1596434984,143191592],[-0.29868990182876587,0.12109743058681488]],[102,[247622355,165839509,803910905,1273476677,134210527,-541883513,-1375763256,265808373],[-0.38485702872276306,0.0911681279540062]],[96,[-37750801,-812843030,-220433808,-144715680,-121636937,-309815552,1626855360,-716966446],[-0.16374103724956512,0.22773180902004242]],[543,[153361681,286744433,-1351190668,-1368652198,389244373,1069414655,41379716,-322010229],[-0.2370050847530365,0.15526917576789856]],[328,[-866462505,-765927816,1978289788,1058089531,1599921360,-539435782,-291741862,-1626666481],[-0.15901291370391846,0.23870913684368134]],[283,[-656868899,-1688724035,-1510113953,863235675,-1872148424,-5628044,-5072136,-1079878326],[-0.20384007692337036,0.1838795691728592]],[134,[-1367079007,-1114562671,1840407673,-80561904,-1480914455,-809727614,-374463023,67108864],[-0.23268628120422363,0.15638644993305206]],[481,[-101203328,-579340569,-676354283,-839058037,-572153460,-388903440,-859517276,214036430],[-0.19198764860630035,0.19319075345993042]],[605,[-209752881,-137424530,-487666946,1884224456,-10503608,-228324026,-84216120,-228393014],[-0.16494055092334747,0.21874834597110748]],[567,[-742399053,1128773464,2012640561,-684720133,96431507,1146418932,903034192,-2143824025],[-0.24089863896369934,0.14685575664043427]]]],[-1.531322717666626,[[118,[-755055873,-603985153,-83902465,-21765,-176712705,-21761,-173313,-45313],[-0.1970607340335846,0.3158511221408844]],[379,[-1,-5,-453312513,-67436549,-520375056,-1048577,-1409303830,-67371013],[-0.20109902322292328,0.2925731837749481]],[472,[-1157431311,721466416,-5538351,-551232336,-1442447427,-6095109,267895293,-810822183],[-0.246646910905838,0.21074186265468597]],[208,[-754987265,-635491769,-422379777,268627711,-293623114,2135249669,-12546,-11011073],[-0.22645103931427002,0.22744908928871155]],[498,[1602221841,941538719,-1107427819,-1614811905,2038431645,1073474047,-34735681,1023278847],[-0.2475070357322693,0.21533721685409546]],[596,[-4213,-655362609,-925375808,-253107478,-2099320,1145502980,-118489462,-255332370],[-0.1756095141172409,0.27202552556991577]],[246,[-262146,-575078470,-1242334370,-302312,1004126207,-16418,-449086918,-536885544],[-0.22480174899101257,0.19904428720474243]],[98,[1404276623,-239733835,2100921775,-705445473,-251883793,-84835913,-118452273,-609710193],[-0.3100532591342926,0.14657047390937805]],[21,[-1050709,-219156762,-488639934,-253308274,-9444029,1078966087,-167774398,-235408433],[-0.19676518440246582,0.224064901471138]],[210,[-17597718,390550447,1543499727,-1051889,938702543,-955581534,800062155,-4194737],[-0.2056204229593277,0.21108107268810272]],[148,[-231825238,-118708248,-126915649,-35385563,-51306769,-122622556,-64495777,1883338499],[-0.23083308339118958,0.19100864231586456]],[544,[1461976433,-687707020,-671102467,-69411352,-283119619,-34615555,-545307240,-825239080],[-0.2323579341173172,0.1847982257604599]],[214,[1076339200,27197442,-1430500781,-15102177,345466176,1407143952,1909970806,-8423553],[-0.2082488238811493,0.19525180757045746]],[562,[-1910004736,227976629,-1397791456,-1647335945,-1090738556,1023222964,-1359086680,-1431505921],[-0.2151651829481125,0.18513250350952148]],[107,[-72356977,-674304370,-88611088,-68163352,-210769969,1615099074,-186211200,-170421565],[-0.15987128019332886,0.25031208992004395]],[19,[35788594,-570473098,173184975,-63105,686291999,-5259537,205462959,-811630449],[-0.3976033329963684,0.10220080614089966]],[482,[-335567743,-134878025,-1109925897,-273158221,-68311108,-67174475,-1088045138,549775094],[-0.18809646368026733,0.20953576266765594]],[571,[-1372850253,-13974142,-1678790192,-76566280,-1973441117,247966637,1465861521,-570433813],[-0.19961197674274445,0.19941739737987518]],[635,[-687870257,-253248569,-150996374,-1058605588,-42473594,1111936836,-562907444,-730337333],[-0.17394885420799255,0.2222379446029663]],[205,[1117962991,1383945687,-500503332,809498271,243157775,2115858421,-75727153,-2363489],[-0.21008644998073578,0.18192526698112488]],[397,[1546701304,1502674361,1862028696,-68619312,1224269564,-8404279,737211100,-20651148],[-0.45097893476486206,0.08725540339946747]],[60,[-393501017,-151003489,-8925315,-1073880539,-2068927745,-1157701426,-858402901,67453124],[-0.22688327729701996,0.16925102472305298]],[232,[1510739604,863309605,-1360064513,-1713172665,1993154559,953815035,-79736866,725943043],[-0.30244749784469604,0.12280921638011932]],[293,[-431620010,1347961407,1645639766,673317522,1083067468,1591659708,-467746,-14463397],[-0.2931867241859436,0.13229398429393768]],[507,[-1912234189,788717234,2000052609,-544997440,-1960506219,-1405096517,1362056513,-913318435],[-0.2361961156129837,0.16040034592151642]],[400,[-995127773,732950504,712712466,586101759,2120161651,242180931,2013206538,-3257],[-0.21951305866241455,0.17167043685913086]],[0,[-13634581,-55838738,-1242056846,-540019520,-112200001,-1060453490,-202902559,-705171148],[-0.1406068205833435,0.2656737267971039]],[276,[1598289887,520138729,-4239399,-1224492136,796904957,-1703941,-103457531,-1141394078],[-0.13021205365657806,0.27274754643440247]],[151,[-271652182,-24632894,-899102988,-10017558,-1209538625,-201046843,-136907778,-137935742],[-0.14946521818637848,0.23473292589187622]],[116,[-756028693,-218968594,-2101451038,-7430,1120845454,2014372962,1666642894,-168057645],[-0.13821019232273102,0.2557409107685089]],[33,[1534696261,1062842133,-1342824577,16852899,2098415232,2098566439,-403313733,1346862895],[-0.22740162909030914,0.15381287038326263]],[238,[295636992,821772143,1801930031,995190037,2063248814,-1080066052,-1165455861,779029435],[-0.4081084132194519,0.087570421397686]],[299,[-10350288,1953972019,2142658045,-168792567,-849660939,-20977786,1374479412,1879061282],[-0.3079519271850586,0.1165873184800148]],[565,[674213888,234891194,1069719170,1066331550,322168757,297054135,100431778,-940649218],[-0.25961834192276,0.1387970894575119]],[461,[-857747457,-542310940,-336830604,1856806596,-35369559,-33792609,-2064905188,-858837124],[-0.14236339926719666,0.2515232264995575]],[137,[-1208749149,-808992822,1941781200,-248516152,-1493452850,-932784438,1406792916,-171445034],[-0.15282876789569855,0.22942787408828735]],[169,[-358692337,1913603755,1780904038,847543030,2130366282,1465284100,-8519941,-146296866],[-0.20485755801200867,0.16542507708072662]],[598,[-624964407,1929293739,1660384790,-235366402,-781729664,1950379016,-86250104,-251396098],[-0.14611224830150604,0.23367854952812195]],[417,[470098293,68724625,2137775989,-1892868772,2124421621,-19018243,977084317,700385347],[-0.20630234479904175,0.1670524924993515]],[326,[-366232099,-190253736,-1380867905,-69371941,540412351,2038193550,178462734,1624598175],[-0.22028318047523499,0.15421783924102783]],[615,[-341839709,-1026897234,-2084621072,-2054694368,-1493174289,191097334,-53018662,-2117081441],[-0.15134109556674957,0.2281082272529602]],[306,[-553664381,1415565023,-2072524587,1367308795,-252592630,1556899377,-22070406,-525337637],[-0.13117921352386475,0.2531299889087677]],[377,[108201239,9837407,592193301,136324358,1550649125,247541396,1311001108,1141928512],[-0.3607528507709503,0.09472320973873138]],[261,[-933699454,1776876279,-820938308,-539560229,-42108166,-554199719,-544738,-84412445],[-0.37590476870536804,0.08685655891895294]]]],[-1.496147632598877,[[327,[-620831233,-537069577,-101417505,-13168873,-556086056,-536873473,-543629570,-81080321],[-0.20265258848667145,0.2719714343547821]],[458,[2147483575,2147483647,-1073873409,-2228257,2146795519,2130572534,2130018047,2012705527],[-0.2265007197856903,0.23255248367786407]],[447,[-274730064,-1828490718,369062224,-542114548,-1364483424,-1347425350,1423397120,-978321525],[-0.36266660690307617,0.13946540653705597]],[185,[-572799778,-658813697,1438600447,-537458434,-805311745,-923335015,-800005,1610025215],[-0.20851968228816986,0.22624310851097107]],[249,[-856105761,-33882273,-1171097633,-1224545442,-861365000,-2458133,-71565606,-72679542],[-0.23597721755504608,0.18162688612937927]],[48,[-21,-3741714,-34078816,-527638,-113,13434820,-135536062,-168298546],[-0.15795913338661194,0.27534615993499756]],[410,[-202379265,-33554465,-269488146,-17891337,-1023410948,-66561,-1091883318,-1052929],[-0.15668000280857086,0.26539573073387146]],[264,[2038380347,-67289281,1824356671,-101238593,-631259777,-67492802,1886915711,528432447],[-0.35712456703186035,0.1191648617386818]],[593,[-100673841,-69224881,-118170894,-260898840,-1050808,-163419068,-195891512,-261881906],[-0.17496080696582794,0.24545976519584656]],[191,[-789133138,1357808511,344927999,276753983,-117987153,-119992597,-369100129,552039275],[-0.2423415631055832,0.15460087358951569]],[568,[993523459,1068448807,-134417102,-40896850,121108099,-1929384980,2004276738,-1207959621],[-0.23254016041755676,0.1672218292951584]],[445,[1043149619,134807472,-147062799,-1389785608,778508721,-1078985799,1052163003,-1347437061],[-0.15260687470436096,0.2397785186767578]],[224,[420417423,363269511,-1181413745,-41029713,487264223,-67125253,-1141534769,-7385137],[-0.2702747583389282,0.1346423476934433]],[510,[137232896,33988648,1929270085,1977700989,548021027,539665932,585611707,-1880109889],[-0.20664082467556,0.18262037634849548]],[83,[-537395337,1915903126,-1083662512,536906274,-1726232561,1041620991,-271596849,-780664993],[-0.3592633008956909,0.10130912810564041]],[610,[-16781429,-152051773,-469767584,1626332074,-136839290,423686,-253248888,-255332390],[-0.17431952059268951,0.2102852165699005]],[133,[-336599041,-8133922,-1141113930,-37248794,-912916546,-308113878,-1007163154,-993664890],[-0.12619158625602722,0.26890262961387634]],[157,[-491853138,1357672160,573189794,-69711698,1661179311,541861739,2002744015,-170436793],[-0.18496830761432648,0.1792939454317093]],[625,[-5457,-479461718,-343330784,-236206134,-734006329,1113998678,-486541632,-707789857],[-0.14289534091949463,0.23088638484477997]],[244,[34604197,314455839,709823416,1055849114,242550243,1006448564,777592829,-1075859328],[-0.23958338797092438,0.13976256549358368]],[331,[-1672813806,2117239444,1981954909,790760502,-1761080108,-2823052,-7282818,-352180693],[-0.204872727394104,0.1605996936559677]],[584,[-14682192,1410054962,930545776,-704669782,1462208945,485762336,2094510506,756531114],[-0.19781313836574554,0.16727229952812195]],[313,[-8917097,-1090285819,-1361920071,-1365204576,-587337803,-21561419,-1628979300,-1092178412],[-0.13299308717250824,0.24190054833889008]],[553,[-67124722,-244319281,-159678928,-235658710,-235168120,556126992,-640763208,-247201910],[-0.1459912806749344,0.21836180984973907]],[432,[-1574459869,1801694139,688355093,1137244896,1597321139,667307894,2103932223,-875206861],[-0.1999962031841278,0.1616845428943634]],[467,[1415519575,2086366073,-277518529,223099000,2085048285,-1107460609,2103254796,-2004256296],[-0.173945352435112,0.17971649765968323]],[631,[-136321265,1345830367,-352396334,1615494266,-807156,-1773853180,-156565858,-797708465],[-0.15880204737186432,0.19731280207633972]],[153,[1244179144,1261546146,-363663553,-268711247,1351877499,-280641,-84327201,184487424],[-0.294883668422699,0.10669518262147903]],[287,[139709719,43029023,716864123,145948174,72842328,-542019334,520215374,-68543909],[-0.20783309638500214,0.15214750170707703]],[388,[-12132331,-1257178152,536162265,-1073758883,1531509500,-1143015176,677185805,-1916803061],[-0.13157057762145996,0.2420303374528885]],[508,[-1297104925,908165800,-1346954911,-1658201648,-1566723143,699314924,459246985,-1918904868],[-0.18554656207561493,0.171234130859375]],[178,[-2030042720,-638576553,-856623777,-1155485679,-272560641,-73725530,-1699976999,-2013232128],[-0.19297820329666138,0.16354376077651978]],[523,[-802903936,219991281,-1380735344,-1677912129,-1196039740,-1924874396,-285426550,-1981250341],[-0.16431039571762085,0.19124364852905273]],[187,[786271998,353427327,769929200,901316269,-2013419265,1070845648,-1929524065,745335551],[-0.34417709708213806,0.09130412340164185]],[258,[678666755,1795098123,715934564,-11026437,2029921792,58675732,-672615084,-138415361],[-0.16051757335662842,0.19698475301265717]],[537,[757053313,1066712229,-1220563659,-1077439073,884821983,-1073791051,-137412097,-1343329298],[-0.1701008826494217,0.17927823960781097]],[82,[1389352639,1912983387,552783085,-1334070951,1883213615,875581417,1061134046,1628826575],[-0.2355739176273346,0.135020449757576]],[43,[-148378625,-454104340,-444894224,-3679494,-170132001,-2004452736,1979433184,-705169834],[-0.13890986144542694,0.22841182351112366]],[292,[-562163691,-612498656,-398797281,453517379,1576666200,-69248251,-836204322,-14537969],[-0.19448943436145782,0.15733850002288818]],[394,[-802954462,-214240474,-171104766,-526590,167222773,669761943,-138184978,1684498211],[-0.2066839337348938,0.15022484958171844]],[444,[-323100186,-21839924,-1663054876,-1785792953,279978382,685830218,1700463695,1075309899],[-0.23692423105239868,0.12721052765846252]],[183,[-470300502,-779440474,317666416,-237112100,-1623348022,-837984724,1892930262,-720943418],[-0.15833552181720734,0.19168458878993988]],[419,[286629325,419495792,-281560203,2100829820,-1760265995,-1107509763,-1347405073,-1416952693],[-0.14504113793373108,0.21510179340839386]],[231,[-127662976,-1087598676,773459488,-35385847,1652563440,-170590944,-386639138,675557121],[-0.2552856206893921,0.12368638068437576]],[431,[-571158532,-1123001732,-541836356,1911335552,-730029140,-520339162,-1192711304,-1090725180],[-0.416248083114624,0.07490038871765137]],[84,[-232924694,974885303,-1350574289,1336799215,291214187,-2140233508,1643271951,1607457567],[-0.20407570898532867,0.15173958241939545]],[307,[-554874737,413568674,-1442634037,281801574,2020022853,671503039,-193074614,-390597677],[-0.18695932626724243,0.1645810306072235]],[75,[-135269693,-742200265,-1410699614,-13836096,1237270351,1879118941,-203115702,-243795973],[-0.1366724967956543,0.2194761484861374]],[41,[149662391,267466357,1751289335,248190673,662646779,1052753397,-1476979457,233637266],[-0.4083424508571625,0.07441364228725433]]]],[-1.3947604894638062,[[201,[1258228431,1541393407,-362082691,-2102673,1138427903,-33607787,-36885,-8451185],[-0.22683575749397278,0.21698297560214996]],[542,[-142609485,-147341345,2147430871,-1244137569,-671091209,-536948741,-175245326,-167774241],[-0.21389076113700867,0.22244058549404144]],[311,[-67108865,-1683297558,-1522913379,-1365204446,-537268289,-340083462,-277894725,-286274934],[-0.1599258929491043,0.2807232439517975]],[385,[1493442005,487622616,796877695,-541253667,1548222680,-537371139,-4661570,-1141957985],[-0.16421283781528473,0.25366565585136414]],[453,[2012324787,-786541,2127984599,-1712157,1467463095,-21102638,635748263,552953719],[-0.23738333582878113,0.17089855670928955]],[251,[-655697441,-109412993,1067113279,251802999,-1364535078,-14766082,-1077216354,-366813250],[-0.2866738736629486,0.1445491462945938]],[44,[-67108865,-741279774,-404489384,-586351998,-68816978,-222027262,-143300086,-783949310],[-0.13955391943454742,0.2744167447090149]],[470,[335904627,688454032,-576326285,-821588848,-2057208907,-1716607047,-1955598855,-1884303895],[-0.18432578444480896,0.20841632783412933]],[577,[-603989137,-18,-1291846942,-253431302,-8388664,-1132204282,-1226317176,-800850994],[-0.15180069208145142,0.2387949675321579]],[92,[-1107974265,-1107488321,-438980181,-34271041,-705322561,-1779060809,-1852239449,1035052479],[-0.26681289076805115,0.13597114384174347]],[130,[547815938,539725376,590284047,254798327,111100181,1881297024,1050504923,1086317355],[-0.29581764340400696,0.11302649974822998]],[402,[-202970846,1997573378,-807003368,-33822848,1383556917,2146907953,-8949889,-8951981],[-0.29145026206970215,0.11627478897571564]],[548,[-1351616079,-275847760,-807451663,-7342864,-1959811935,-2102481202,91706817,-577790499],[-0.22961539030075073,0.14421683549880981]],[315,[-173279301,-570831744,-591597857,-1920606976,-875315793,-743121507,-84161761,80942663],[-0.16261959075927734,0.20734244585037231]],[154,[-295507282,-222108994,-337368354,-9054488,1695393195,1276183616,1465712383,-203995565],[-0.1629696786403656,0.20082713663578033]],[529,[743681152,1060998324,-1108396745,-1109544971,2146248364,-1131831369,-1109106804,751052416],[-0.2580071985721588,0.12539248168468475]],[624,[-134222193,-823203865,-355090400,-219170820,-9699354,1120917334,-236982078,-168297777],[-0.15296129882335663,0.21096526086330414]],[8,[-142608477,-292753778,-105415054,-168900640,924034995,1086518660,2012737924,-705178122],[-0.16148518025875092,0.19514200091362]],[164,[-635449601,1383502423,-491069862,818069179,-763408706,-132708062,-550005,-143263077],[-0.159551739692688,0.19565145671367645]],[304,[-91324273,-638948913,1459102352,-234883138,-411134456,608256066,-201791862,-213123857],[-0.124847911298275,0.24199718236923218]],[141,[1924049967,-68243729,-1443053961,-102600915,-17306377,-56061955,-17170689,-247107769],[-0.13098762929439545,0.22698824107646942]],[273,[-34538584,-36058140,-1051656288,-35789003,-268782921,-131587919,-10961985,-938149707],[-0.3826730251312256,0.08256648480892181]],[128,[-676125470,-422419785,733606671,1331165455,-1581583803,1441710245,-1152161,-1048625],[-0.20781999826431274,0.1617911159992218]],[541,[335879441,857358129,-1080172780,1036762602,812061588,-1074364481,1313293872,-1344439906],[-0.23761694133281708,0.13307097554206848]],[330,[-155579051,1977548902,-109936641,527894909,-754445200,-138473890,-72820226,-83217585],[-0.1539456993341446,0.1961182951927185]],[109,[-105978129,-89202036,1206379146,-137441666,1935124399,1946251504,-1326188912,-146279937],[-0.13842856884002686,0.21920017898082733]],[490,[1833936128,997703423,-1255263405,-1113732166,1549563407,-100835876,-1365059701,960064042],[-0.28704747557640076,0.10284743458032608]],[298,[1085179907,1558038199,-647534401,-780680693,1246731967,-495023986,-2065708378,458754],[-0.2138298749923706,0.138645738363266]],[630,[-235929793,-35017425,-974852131,-1473993990,-154806396,-39664848,-218780980,-1069420542],[-0.13126356899738312,0.2278546243906021]],[380,[1164428247,330592127,221719867,220515469,1525638961,771553661,6317897,71567872],[-0.22609888017177582,0.1348307579755783]],[450,[-168189951,-537955555,-261358634,-237300738,-220334180,-301885632,-674048822,-784091958],[-0.12464315444231033,0.23845015466213226]],[248,[202362591,529960950,687636059,715847819,49893116,-1075370050,-1090575784,-275320512],[-0.18483379483222961,0.1581660807132721]],[369,[-59776341,-546708861,-1996605390,-751324454,-5509237,1212568876,1987049206,-84414181],[-0.10926777869462967,0.2634029686450958]],[322,[-859874849,-589690915,-1078272001,-269396281,1444829252,-361363,-1088889270,-14524897],[-0.1407398134469986,0.2058841586112976]],[558,[-656422264,1404815771,215051032,1573253083,-1258510968,1056763359,-1342722118,-1431572741],[-0.1293744295835495,0.22350135445594788]],[580,[-538976242,-706933761,-1157899644,362671324,-616768120,76414080,-1145002358,-257163377],[-0.13464896380901337,0.21594136953353882]],[500,[125169463,419200829,-1376628745,952940586,936641716,-1309459009,1895069444,-389533710],[-0.19427908957004547,0.151099294424057]],[197,[-936448836,296527797,1725790972,1040187375,2094788862,989460023,-119468066,821391343],[-0.22964347898960114,0.12504659593105316]],[27,[-8452601,1934077794,1941583474,-1298074901,2048863812,2084627268,1498884488,-481959089],[-0.16685572266578674,0.1735781729221344]],[363,[-854920272,1466228596,1841174397,-1350754044,-1912043332,-544412176,-1074884950,-1351636949],[-0.1349325031042099,0.2078791856765747]],[399,[-759714941,1319853762,465518886,25093107,1311451838,262313924,1264269234,-67639305],[-0.1470017284154892,0.19267882406711578]],[285,[-62210118,-95633686,-543498916,-5647854,-582576008,-858743621,-1076589240,533272664],[-0.2338734269142151,0.122316375374794]],[63,[663490442,868591391,944180691,-1725045803,-1515212829,-35061268,-960230409,-1358689591],[-0.2431904673576355,0.11639515310525894]],[476,[-488118528,-1001853142,-1284508299,-24149798,1848082858,706898854,-822111589,-1027604833],[-0.15382418036460876,0.18594354391098022]],[68,[-344199257,-355533682,-1103629790,-672858904,1070593471,-997397002,-152595754,-246940218],[-0.1509171724319458,0.18637944757938385]],[31,[1793322823,6471586,2136661902,544275114,813647735,1076229465,388745606,1349749552],[-0.5150347352027893,0.05549565702676773]],[479,[-1051226111,-906782909,-1752851951,-573214941,-514355772,-34304515,-1917518166,-2080365046],[-0.18393677473068237,0.148982435464859]],[561,[1139012403,1401008551,133777171,366319523,381097650,1549129111,1842035418,547765943],[-0.21134988963603973,0.12946300208568573]],[53,[-525423697,-1284755713,-208931,-1357271134,1492289279,-108851201,-327389860,-694524],[-0.12473458796739578,0.2229464203119278]],[408,[-1053781711,1488768055,-1883353875,1772158314,-328087099,-1117247528,-937669396,144711680],[-0.24978365004062653,0.11137084662914276]],[158,[1652543131,308775679,-956675393,-1083456334,1883606772,377030805,980155213,1904691731],[-0.17729288339614868,0.15420784056186676]],[211,[-228447566,1986675278,-1710034373,539124387,1681978890,-2146306,2146938783,-277360677],[-0.24232980608940125,0.11379676312208176]],[344,[1316210703,384430053,-1605375990,-1886392130,1727284535,788489447,-84560439,-34605257],[-0.19972075521945953,0.137681245803833]]]],[-1.4742519855499268,[[121,[-1023416577,-4353,-218103809,-4199429,-469764097,-38913,-218146817,-33041],[-0.15614232420921326,0.2845953106880188]],[569,[-14419983,-40645392,2105497077,-17200,-1074790407,-1963226659,1509905749,-573581860],[-0.26622509956359863,0.17384453117847443]],[277,[-2228225,-537002337,-993984612,-570614067,-2360321,-539165186,-1086001221,-582353009],[-0.15457338094711304,0.2492058277130127]],[501,[1497849681,-46703179,-541254857,-152143362,1245972736,-19211521,-355578294,-930420486],[-0.22253337502479553,0.16836225986480713]],[359,[1348230493,-572761801,901578239,-575135907,-665562984,-587575815,-577217316,-1079359265],[-0.2195247858762741,0.16715624928474426]],[530,[-22751,924006327,-1242563275,-1343226995,1740615048,-286293035,-1375884146,143518351],[-0.2085951864719391,0.16993917524814606]],[18,[-4177,-252706834,-89435614,-152109174,2012210959,1212315399,-137891006,-196608261],[-0.1806883066892624,0.1935441941022873]],[619,[-16782645,-67108979,-220049174,-1292960022,-671090880,2085024332,-261162808,-261619974],[-0.1650635004043579,0.2022944837808609]],[420,[-382737744,50788352,-1994165808,-15731432,-1707289424,-1424360140,1166807040,-1995450709],[-0.2954464256763458,0.11053616553544998]],[395,[-237505662,-7875693,-44228609,-541082249,-140249609,-2689391,-168296713,1441130294],[-0.18134017288684845,0.181584432721138]],[252,[1223614047,-566850177,452714110,1377860351,1314115214,-25223481,-887124790,-14950405],[-0.1985388845205307,0.16829168796539307]],[104,[-139465237,-221860946,-386469728,-5064,-67109970,-1027308284,-252448634,-170396417],[-0.1359160840511322,0.24295561015605927]],[181,[1547110426,-594080544,1242779512,2136932364,-31486309,-633908553,-93345025,1407648427],[-0.21696040034294128,0.1479005366563797]],[370,[1499790452,2109508592,-575091844,-302188396,-591074644,-68222826,-88022980,-109264732],[-0.4229183793067932,0.07374207675457001]],[383,[-606273731,-196612,-37945347,-1078413452,-68622672,-1073833673,-886375426,-357847909],[-0.13053973019123077,0.23168803751468658]],[42,[-504256601,-103693889,-1552631861,1907462573,-248469585,-243578961,-1001289745,-181465207],[-0.2983328104019165,0.10050108283758163]],[54,[-134222325,-59852914,2059134464,-236458270,-11550966,273579030,2011488840,-179834277],[-0.16351912915706635,0.17984919250011444]],[443,[1599067921,487038815,-780142176,-36833527,-228755067,-1147797541,-67116406,-1936719953],[-0.14494937658309937,0.2044524997472763]],[423,[-1060117723,105172292,-1816406059,-25779,724509227,585891728,1289685275,-1951405137],[-0.13382528722286224,0.21853315830230713]],[478,[-86114193,-313171,1111290354,-203817238,-2438456,-162604796,-233662248,-268173862],[-0.1297757625579834,0.22842766344547272]],[34,[2046686481,456335616,2042029853,864486144,-933104412,1873167254,-196099146,1728444263],[-0.21469497680664062,0.13323721289634705]],[465,[343343103,-570466369,1857224703,672685290,1434275581,-50389665,1962159104,-1933164800],[-0.17130576074123383,0.16903875768184662]],[589,[-818941053,87013538,-111184397,-37754531,-1898061693,176551654,1383588499,-943720513],[-0.16567197442054749,0.17757371068000793]],[519,[788713130,-1156586598,521182527,-1916946,2134622139,-541888753,-1893755925,1066076831],[-0.17230549454689026,0.16435682773590088]],[143,[-922759554,318630654,616432687,-302831042,-727324161,41682483,1920329832,1348428442],[-0.2049153596162796,0.13880960643291473]],[2,[723823356,-1076777961,-548951307,84377713,737552635,-1210946315,-1625454337,51577009],[-0.3284936249256134,0.08823275566101074]],[552,[-393329,-201327633,-220237262,1908912120,-536940600,-1069443776,-222112070,-245114118],[-0.11370333284139633,0.24661171436309814]],[280,[137866647,330497943,-1369628875,-1075382238,1313256957,1000285681,770980944,-279533994],[-0.15602348744869232,0.18064066767692566]],[39,[-876627029,-341120470,1475736498,-712395712,-1079659634,-2134787454,324531680,-985140140],[-0.16076508164405823,0.16893979907035828]],[230,[-790584800,-798953804,-1461039873,-33961394,-146490212,1421872403,-21414210,-1710152177],[-0.1537749469280243,0.17769819498062134]],[170,[2122294795,1377502722,1392496187,1342876671,-21078444,1174557952,-18705082,-213683390],[-0.19270925223827362,0.13925707340240479]],[636,[-286265721,1087368110,577492704,1623915622,-135286528,1145196902,1993571328,1347679734],[-0.2657798230648041,0.09998544305562973]],[398,[1982039846,397254535,791353898,-1627349,1729455863,1974525379,1867999102,1742958439],[-0.23255635797977448,0.11416502296924591]],[155,[-930355482,1391502078,-1394668033,-36122826,385550518,889011848,1061650251,812083063],[-0.21627771854400635,0.12228459119796753]],[474,[-1575620175,1360049280,-2023751683,-2633232,-2102872143,-1474564867,724025243,-879765027],[-0.13511040806770325,0.19969221949577332]],[576,[-19674853,-209862853,-1174651909,-247081470,-947934244,-67273344,-361432166,-268305918],[-0.12421544641256332,0.21285703778266907]],[56,[1606418223,1888960082,-549204542,9024010,-1749831699,-176720023,-617002195,-117478001],[-0.2160504013299942,0.12219076603651047]],[533,[532659985,481076660,1069384497,-1109532747,897535455,-1146290249,2093248670,986457767],[-0.2275753915309906,0.11520259827375412]],[407,[4710295,70527783,636325663,772164110,73221521,255335261,1734675540,14912],[-0.2595272362232208,0.10210981220006943]],[129,[1981030261,-784558837,26018085,1070988785,1565896703,528994556,791806603,1902506983],[-0.15912999212741852,0.1656181961297989]],[260,[67381250,-1627145297,-322568424,1301083899,-547766386,-1073754371,-270867206,-1351352674],[-0.4017013907432556,0.06479436159133911]],[225,[1425967788,2082375658,-1345048904,502528730,-168390835,-32914981,-35926390,818686652],[-0.19302360713481903,0.1390720158815384]],[547,[1868527361,-1221977296,-1891735758,2074998596,-821039197,-308147286,1432343616,-812652087],[-0.22188051044940948,0.12489976733922958]],[131,[-1557400589,-1248274,-613056656,1439425388,-1213219890,-2112947072,-1027083856,-706494382],[-0.13170881569385529,0.2017805129289627]],[614,[-1006640382,1080797230,-1475932128,1123009640,865575328,113265744,-1334843708,-462948914],[-0.20952266454696655,0.12552599608898163]],[360,[-1013973025,486547392,1073128087,-1626784775,105420508,-1152767500,-874785925,-611646437],[-0.11399252712726593,0.23128414154052734]],[338,[-830633942,2127447645,-1191940048,1916927034,-251667124,1112837698,-176687438,-250616629],[-0.14943134784698486,0.17533594369888306]],[346,[1417087799,280764279,113190420,231275440,1397886477,1081212924,1978420548,-792412416],[-0.17520387470722198,0.1438317745923996]],[316,[67284919,168863207,-550613281,765994050,-1262428729,-1076672520,-649814438,-1369156349],[-0.18277360498905182,0.13956578075885773]],[320,[-1004017133,1535149056,653028909,1572339459,408031108,2023804820,147856938,1606763075],[-0.16832774877548218,0.14991748332977295]],[403,[1304393556,1433759573,77663858,-1499455289,1970754580,888309687,1747679550,1786390635],[-0.38091838359832764,0.06628952920436859]],[6,[-856294403,-2317726,-1552485576,-369560984,-469158006,-62120224,-6132168,-783949310],[-0.13586831092834473,0.18866735696792603]],[585,[-570439936,818277201,-1159260624,367517684,2128999812,1485249311,-1397821528,-1576556801],[-0.15883666276931763,0.16120925545692444]],[292,[1555765623,-1621160633,-95796705,1999587663,1161353270,-8647867,-546809186,-16056501],[-0.12610651552677155,0.20256702601909637]],[509,[-165168237,-1036863384,-962127881,2140655952,-2085503053,-1361645123,721875631,-907031362],[-0.10026291012763977,0.2529263198375702]],[138,[-1041372593,-34650142,526856810,-142651678,-134299697,-472860218,-545794344,-706219574],[-0.11102330684661865,0.2323770970106125]],[430,[-895385053,-74011487,-2139413994,-269627264,907480823,9225072,325710813,-807964109],[-0.1419346034526825,0.1777896285057068]],[532,[-33182560,431646387,-1444991724,-1883374155,873740209,-1109836522,-84107345,-1345837813],[-0.13789501786231995,0.18308758735656738]],[591,[1512284963,1141564138,1212531296,1113560758,-1557154909,177122818,100626839,-1018171923],[-0.16264808177947998,0.1549759954214096]],[621,[-465586289,-1650720961,1677193050,-1245645074,1249883404,-1321181114,-924458918,1096148570],[-0.1387788951396942,0.1835004836320877]]]],[-1.4156285524368286,[[534,[-638102187,-679525099,-1028701999,-39250355,-50358343,-16824947,-70270465,-264417],[-0.2394787222146988,0.17213819921016693]],[246,[-102,-327766,-1317552232,-2088,-5261442,-67190785,-11965510,-539143080],[-0.23085838556289673,0.16734325885772705]],[117,[-286283025,1945448179,-287698945,-1201662,1962887167,-2019343,-36869,2138326851],[-0.2055557668209076,0.16909728944301605]],[446,[202504181,487127216,2143026545,-13386576,-1088807427,-1153572867,1002289913,-1145108339],[-0.1936747282743454,0.17333285510540009]],[607,[-81,-203438129,-119610708,-788532244,-3145908,1564880128,-69535000,-247203890],[-0.14611057937145233,0.22614458203315735]],[150,[83988110,-16658643,-5992498,2139989165,-1895069745,-1184840063,-303839025,-15741813],[-0.42162150144577026,0.0763353630900383]],[290,[1281287903,-992067593,-1707189025,304231711,-1931918256,-822131563,-664085938,-553461153],[-0.24101121723651886,0.1289917677640915]],[522,[-99,-67111489,-3670529,-2097155,-16465,-4334435,-151633,-1930571891],[-0.08985897898674011,0.3546532094478607]],[67,[-138414161,-204477202,-562727296,-1706382,-7871509,-2066808860,-170413340,-706218018],[-0.12456064671278,0.23621052503585815]],[440,[-22020619,-1688271921,-1268777234,-1126483556,-731392784,-5256193,-508600094,-1343229953],[-0.21830691397190094,0.13843542337417603]],[204,[-595670817,2137779887,842165854,696450687,-898069314,-13148161,-293893442,-11035046],[-0.16304337978363037,0.1813068836927414]],[177,[654378409,-215812202,-1617431212,-104401568,214730925,-624893564,-520853376,-1001098172],[-0.1843288540840149,0.15385165810585022]],[268,[-923466703,882443319,684861821,214183562,1216310488,821823419,-385921285,552943443],[-0.26095736026763916,0.10501748323440552]],[418,[-498279535,268466432,-135469008,2098156943,-733180535,958528317,296415627,-1894785397],[-0.149624302983284,0.1872091442346573]],[587,[-80747741,-440533585,917467936,-1117913366,1400489367,108504455,-470302329,-154675522],[-0.1663636416196823,0.16690850257873535]],[55,[-18880725,-202391769,-759287262,-119277466,1457470730,1950626060,1998312834,-263979350],[-0.16729773581027985,0.1605130285024643]],[599,[-603994229,1911548847,-194527230,-236195126,-134744696,12806020,-51512662,-247205941],[-0.11797425895929337,0.22724191844463348]],[297,[-489829469,1226741,800489375,-1350581579,1377190149,1085406199,-604275713,-145],[-0.12585067749023438,0.20891451835632324]],[3,[-1971608833,-1288591395,1475176695,1563627259,-1375785217,-17649729,-537679105,1337656827],[-0.23411718010902405,0.11347461491823196]],[437,[-591344417,2063130525,-1896214768,-307205284,-571913191,-59966273,-393475068,-2001993712],[-0.20314617455005646,0.1376352310180664]],[282,[101187391,364219279,-1176650755,1000767114,912373727,-1937409,2136472389,-12101881],[-0.1254676729440689,0.21802827715873718]],[99,[-207622237,1976559278,-421595998,-36702260,-4523009,-395844156,-151653426,-168299300],[-0.106666199862957,0.24177177250385284]],[247,[-176423049,42000772,-1076962432,153287170,2069362679,-1434126487,1743140804,-1608450558],[-0.3063652515411377,0.08508893102407455]],[215,[1572799552,1555302911,502016374,-783578511,-639160325,-1910286183,-272236801,1497303731],[-0.16857950389385223,0.15063311159610748]],[618,[-536892241,-34722976,-754993444,1925642998,-440,-565086396,1902047384,-800915489],[-0.14988699555397034,0.16982515156269073]],[10,[-76547105,1459287522,-83019108,-110054718,1164994324,-91993341,-851351024,1900143122],[-0.14859989285469055,0.16797077655792236]],[493,[210804112,1014671005,-135055168,-1078395007,2113214933,-1074900241,-5022132,1021654866],[-0.2165178656578064,0.11568113416433334]],[473,[1864359859,1195796354,-1679085072,1603270029,25372035,13287051,1163609361,1166013371],[-0.2746153175830841,0.09260080009698868]],[23,[-914427905,-1288795169,-303404033,-1078546750,1419528191,-31168917,-35070209,-61577692],[-0.10754923522472382,0.24138067662715912]],[243,[-864105242,445290293,-1393758754,951036643,752302524,-1078070320,-542736642,-1126565110],[-0.1480167955160141,0.16932740807533264]],[142,[-273756954,1942415086,575448290,-237796308,-148918550,-1005259754,1940834010,-238567265],[-0.13087278604507446,0.19493532180786133]],[322,[9446003,202403103,1824152607,147849757,1288968284,1576228077,1479597122,522870367],[-0.23417076468467712,0.10817093402147293]],[139,[-487937866,-262094681,-1100039561,-6056955,-877674561,-5305710,-322568449,-1002108093],[-0.15504436194896698,0.1600046455860138]],[81,[-145030135,1397697579,-368311472,2104467426,1948713096,1040733696,1984303748,-213412093],[-0.15200254321098328,0.1641494780778885]],[582,[-553663488,457408791,1690141440,1367473802,1628234624,8189057,-168234104,-2104755233],[-0.16342587769031525,0.15110038220882416]],[559,[-536951808,429892094,-1375777504,501218759,284931200,-1195353611,-1376873302,-1410664983],[-0.15035997331142426,0.16500341892242432]],[528,[-44894976,-1624795335,-1917477097,-1074532871,-1260347476,-53365552,-94048341,-1349298635],[-0.12065671384334564,0.20961812138557434]],[393,[-391658845,-878467154,948331218,2028076114,-617926264,1225967168,810803670,-403971773],[-0.13285785913467407,0.1815747171640396]],[382,[235150099,2111413,-288488515,797719756,273701972,-3580555,235419741,775624979],[-0.22059056162834167,0.11039188504219055]],[375,[-109781508,1400132593,-553322700,-1067589039,-277020752,-893591409,-400248840,-1575945558],[-0.35007745027542114,0.07238174974918365]],[263,[1442780976,2147431737,-1666169666,-537068613,-50644545,-101694274,1208049627,1394086042],[-0.24647489190101624,0.09931936115026474]],[73,[-202903617,2062270127,905170938,-577838430,-177209377,-97988910,-7930226,-195566882],[-0.09162109345197678,0.2710406482219696]],[545,[168785687,1235052478,786401219,781123582,-1706219793,-1632109057,-1127231054,-907025697],[-0.1308988332748413,0.19008301198482513]],[406,[120052671,78952369,1815027037,40380180,1968535543,500178705,1182027596,1141154900],[-0.1992865800857544,0.12260554730892181]],[59,[-2123369037,-1007755834,-75536528,-539651674,-1347699781,-863068216,1903277778,-716704080],[-0.13662229478359222,0.18261213600635529]],[419,[18421695,-1616330919,1605960693,-1079042055,-1083695656,-583092739,-1389350577,-1414856417],[-0.10727475583553314,0.21985870599746704]],[468,[1078396753,354631550,-1967409351,428813808,2020671776,822063083,1609375984,201788848],[-0.2235974669456482,0.11054740101099014]],[425,[-136461589,-219700788,-1199836930,-205259036,-682692658,-35350016,-144121122,-246950930],[-0.09437340497970581,0.2560350298881531]],[79,[-159406545,1899276489,-465811838,1922100042,2029683712,406996630,1410543184,-800720294],[-0.16321568191051483,0.14754760265350342]],[401,[707767079,489540902,946742334,468429541,1549207925,513686359,2137543788,-839975353],[-0.20473939180374146,0.11587535589933395]],[566,[-1895302736,-1962819824,1692382641,-1351708978,1991000568,-1779630215,1152997709,-845161478],[-0.17858609557151794,0.1356053501367569]],[323,[-134476363,-2967727,-100720641,1308314038,-475336,-1368359364,-1558008326,706880550],[-0.27970603108406067,0.08576671779155731]],[110,[1109568162,965390091,-68932241,-316003794,1404437503,-152829605,-363761031,1476677383],[-0.21853850781917572,0.10969985276460648]],[555,[-134485751,-204476433,2021424858,1893059240,-59263232,-329974006,-1360480358,-1320953925],[-0.13401556015014648,0.18581999838352203]],[579,[-11036405,-777934827,2013002256,1897900916,-33842751,-1532740203,-1140932694,-1056772933],[-0.11947764456272125,0.2034149467945099]],[269,[-120159104,-590068356,1249209660,838451038,2026278092,-1460363686,-54526018,-1202980021],[-0.1263890117406845,0.1901579201221466]],[626,[-9458765,864484918,-1511008592,-1243354942,824178116,-2115851904,2108896696,-974655785],[-0.11565050482749939,0.20734450221061707]],[354,[841991296,285521189,-1619726583,-1383762267,269749288,1909506021,1324502735,136255191],[-0.18641577661037445,0.12567663192749023]],[64,[-1689540600,-171047537,367437942,-545536546,-27554929,-201455017,132968039,-56919],[-0.35486286878585815,0.06668654829263687]],[427,[-605489791,-1143709809,-33693697,-537069149,-711441259,-1200820238,-2033713755,71617805],[-0.16660074889659882,0.14271223545074463]],[477,[1308689921,584290,1663521749,-146281108,-1077992529,1108859814,-1259897617,-2080382578],[-0.13657788932323456,0.16795969009399414]],[71,[1120340511,285084254,-117988732,-1107318042,1085786093,-252053507,-1002113816,1348812912],[-0.22501066327095032,0.10219082236289978]],[352,[-1422610505,85245907,-1904285905,-1920684632,659881463,-1259208707,-11206692,-1963834010],[-0.14944109320640564,0.15598995983600616]],[52,[-10490065,1498147823,-553944480,-134217734,2145379919,1434648139,-118032742,-178274362],[-0.11018689721822739,0.20814745128154755]],[30,[-2134754710,37889724,-1915431920,794654769,608172791,1106720444,1637266185,262899994],[-0.42608824372291565,0.05488509684801102]],[557,[-601044984,2043973039,674530844,-201458955,1488507776,-1334981997,-395001942,-1827697397],[-0.13864666223526,0.167188823223114]]]],[-1.3544789552688599,[[540,[-144706765,-1289764937,-1210088649,-1073758505,-939528193,-1091062785,-1025,-1073745921],[-0.14142465591430664,0.26060691475868225]],[207,[-1025521953,-562077953,-489238786,1111423727,-896495890,-11540707,-270799138,-144703521],[-0.2282639443874359,0.16443152725696564]],[310,[-922776433,209603519,788277263,149814190,2129497804,246132079,-120197154,-1461715061],[-0.24959196150302887,0.13804991543293]],[507,[1997012981,-1089741086,2101339632,-852493088,-1154547807,-1416646978,1565513153,-1894785592],[-0.2350001186132431,0.14007705450057983]],[318,[-2621697,-572527203,-7537699,-540158759,-1285,-1084358700,-87233877,1527643417],[-0.13948769867420197,0.2373763918876648]],[516,[-12497,-67110961,1895293114,-202902104,-4473080,-582642432,-138761558,-247202870],[-0.12023023515939713,0.25221487879753113]],[489,[2138824465,1006370495,-1628438593,-1343230209,2147476917,1023206896,-323568595,619075335],[-0.22485122084617615,0.1354176253080368]],[387,[-581657131,-715649732,1609916351,-1086636163,-36644456,-201803780,-38076486,-1414920182],[-0.1813817024230957,0.16125626862049103]],[193,[1983843871,1509371575,1804010935,-76854621,1420240815,-139122955,-144183573,2068252417],[-0.19591303169727325,0.1467915028333664]],[622,[-2097425,-134218881,-20059542,-252920114,-1048690,1433398614,-134679350,-168300550],[-0.1234404668211937,0.2348768562078476]],[69,[-135790657,-101712898,1790961276,-1117268,-167790693,-1092435916,-134748966,-171444278],[-0.0936608761548996,0.2928491234779358]],[334,[1426527010,-134922701,-283812105,-2623147,-1643650329,-789597,-285199645,-2130690814],[-0.19195295870304108,0.1388207972049713]],[128,[-188748058,-604825097,228284687,-1059465,-744525073,-134502345,-74199121,-2098289],[-0.1702539324760437,0.1586102694272995]],[504,[1444369751,1571127165,1068923765,-418381862,1832345573,-1136660769,1164394880,-1928344087],[-0.17193807661533356,0.16115176677703857]],[212,[577742851,-69269885,687362130,939513855,1131934720,1456416085,930627893,-2100289],[-0.13450787961483002,0.1927766501903534]],[371,[-799212524,-209768513,-76223492,-117260294,-830701092,-357900157,-1262178576,-68422742],[-0.38245826959609985,0.06848365813493729]],[278,[207857047,271759280,-286880937,783085486,242614261,-1090523169,-287801388,-1158721947],[-0.19277557730674744,0.13489215075969696]],[220,[-509434961,-78710066,-1275726622,-753404420,-203423798,-1895375284,-673909042,-716971826],[-0.11536061018705368,0.22317345440387726]],[433,[-966602846,464451514,1072605484,-1080755764,2001878519,2011854582,-759073,2145875767],[-0.17061035335063934,0.14551682770252228]],[570,[-813715661,2085594419,2080225111,-1085276791,-2024283721,456261102,1944045462,-537989201],[-0.15524835884571075,0.16316364705562592]],[0,[-4194317,-437584658,-34446,-8391184,-1048677,-892667004,2009592769,-145255149],[-0.11445097625255585,0.21161417663097382]],[160,[-354096414,1341058784,-21551621,805293574,1727467967,2145870579,-954962,1608997489],[-0.24929216504096985,0.09536799788475037]],[175,[1133488646,19079215,1923684923,531607301,1643598741,16213639,1617959234,1911899943],[-0.2060607522726059,0.11344346404075623]],[638,[-201331985,785378799,-89140512,-506473810,-117440562,4699124,-137366572,-237519977],[-0.1317863017320633,0.17929145693778992]],[333,[1500975106,-591757505,-758294281,-159531458,1694185913,-1025496001,1422394875,2130083615],[-0.31970807909965515,0.07318614423274994]],[186,[-252197833,-222679057,-691545539,2146925322,-6971745,-20010264,-23089430,809597526],[-0.15578804910182953,0.154715895652771]],[472,[528507857,220264592,61472209,-1121194504,-1442796359,-325000515,254280095,-1913940771],[-0.16838937997817993,0.14214324951171875]],[113,[-475271441,-85279086,-969873294,-134745356,-251673889,1917934182,2004345816,-238553377],[-0.11203543841838837,0.20810666680335999]],[222,[191466152,330415091,1572941818,995482546,-438844995,1062600569,-1777930,803654826],[-0.29350510239601135,0.07969966530799866]],[149,[-357896534,-759148596,-2131974922,-70545720,-2069117009,1145131456,1431040222,-250146674],[-0.14853553473949432,0.1520741581916809]],[100,[-1045909650,-485611602,-1652363393,-544464370,-210833459,-205732025,-412155925,1161839428],[-0.18374817073345184,0.12407581508159637]],[484,[-2095020544,657773984,-1117496013,-272004556,-2094691888,-1119555728,770155170,618987575],[-0.22434021532535553,0.10049981623888016]],[551,[-604093873,-680840318,-1293353250,1356493274,-100805812,-1358042356,-255656308,-800923830],[-0.15413197875022888,0.1519971489906311]],[422,[-434654828,-1852916328,1623711061,-39002980,-1050953440,-356992779,-853603320,-909122417],[-0.15186285972595215,0.15066848695278168]],[362,[-547512961,-614724543,-5289121,1609498725,-593979176,-30505107,-4653062,-5570290],[-0.09397383779287338,0.2500700056552887]],[502,[421738261,512647543,-369466298,644660618,2014315985,-122826753,-1422579952,144522760],[-0.22758260369300842,0.1047687903046608]],[414,[-1743450761,-1667229731,-1111617731,-1918337240,-925364899,-1401618467,-396751348,149751812],[-0.20826348662376404,0.11135675758123398]],[327,[-589833067,1358228811,-1174324513,1295585037,1415054460,-677683843,-333918502,-81110961],[-0.15275685489177704,0.14786334335803986]],[513,[268447386,-768359989,971741463,-746591329,527673597,-1371129041,267492031,976958107],[-0.21398673951625824,0.1099848747253418]],[287,[458687,74623383,-2110418338,547384028,1146347479,-300476649,1564515406,-549791922],[-0.1906532496213913,0.12086330354213715]],[321,[-1292446785,-1832472107,-874707144,-1316029507,221482943,-54635093,545718286,1952442355],[-0.1822858303785324,0.12629486620426178]],[613,[-270555509,-2024964282,-340115456,-225707460,-214435894,140869335,-755499270,-514854962],[-0.12966547906398773,0.17814037203788757]],[237,[1082294452,279297776,739665110,-1112548388,1558433004,2084562652,-285242720,822082519],[-0.21644125878810883,0.10754965990781784]],[436,[-15204353,1555857143,-370262113,250109974,-813863457,-118130955,1282485260,-1069443322],[-0.11899302154779434,0.19272921979427338]],[195,[-458694430,-18865688,-1560262314,-174816220,1940735675,2090354613,1843399327,-619769841],[-0.14177682995796204,0.1578184813261032]],[396,[-281556317,1261252513,-1844107376,-1199966654,1342875535,19047986,2048844537,-968363117],[-0.12672142684459686,0.1768610030412674]],[349,[1148552471,346904839,-953859756,-2069582840,808277389,-1544759304,278153766,4483088],[-0.21089854836463928,0.10583920031785965]],[26,[-7511510,-490213457,-1597284352,-261998934,1392468480,403119942,1463169024,1346631127],[-0.28440985083580017,0.07838387042284012]],[235,[-190183264,1490329067,-1474001346,-1131666523,1952776616,-1196417886,-93570934,-1442369763],[-0.15732380747795105,0.14436712861061096]],[77,[-234098945,1368758007,-117547043,-4245393,1380304763,-1137653377,-214434069,-302694186],[-0.10450263321399689,0.21435043215751648]],[617,[-71305489,-102079985,-2135231236,-1835277350,-268448192,1963334918,-1485652036,1347612190],[-0.16540440917015076,0.1348690539598465]],[359,[-643869705,-1701290179,-88374529,-4249741,-615216080,-17244690,-248849941,-1146418449],[-0.08967950940132141,0.25216883420944214]],[259,[-2018016477,-135365668,-1220542478,-176295708,-1145529441,-809304950,-718277394,-716994988],[-0.11881193518638611,0.18229863047599792]],[521,[-659116544,-39007946,-1632034575,-1109530378,-507923208,215233620,-38434133,-1994050161],[-0.132778137922287,0.16686922311782837]],[250,[139447519,-1707244049,543030878,-1876755446,418206416,-1344311399,1070800532,-549216572],[-0.1713460087776184,0.12920229136943817]],[563,[1065351043,-1747387425,1440546065,-1130366210,1655662591,-1481415173,-1230520517,-1409895749],[-0.12196241319179535,0.18406112492084503]],[590,[-1489788317,-215482932,1167516208,-1243059476,924747395,731626188,494917253,-1008733729],[-0.14610977470874786,0.15349069237709045]],[132,[-404231235,-154083334,-386960532,-102894914,-1494941761,-523327360,-341382230,-993681342],[-0.11227646470069885,0.19230186939239502]],[189,[461571246,898745267,1045718764,1067757070,838700687,523352002,752994271,746684614],[-0.31202149391174316,0.06988177448511124]],[319,[34848759,77858295,1755294463,-1612362699,279183197,-1091412483,74513294,-583830953],[-0.11771897226572037,0.1843392699956894]],[412,[-251804257,-1722448993,2138034394,1294782986,-571552448,-178921642,-290600310,604546090],[-0.1507510393857956,0.14529505372047424]],[286,[1276237310,1447458454,-934498184,1623452708,-1280217356,1357512592,552402944,-528812416],[-0.3494941294193268,0.06317922472953796]],[449,[1663467552,416069478,-1587070595,-1216236355,394667813,694428982,203361851,-1578764654],[-0.18098776042461395,0.12132294476032257]],[554,[-54546545,-142747138,-1662192658,-117332434,-558788200,1595934520,-1372737862,-1603959254],[-0.11871227622032166,0.18538504838943481]],[580,[-136315383,1373438347,2030042960,1935331222,-1732324440,-1866576357,-104547704,-1603561958],[-0.12392864376306534,0.17591291666030884]],[37,[1179711253,932391543,-159717504,1907212206,1953330597,1588556119,1323654868,-59937033],[-0.12764546275138855,0.17045098543167114]]]],[-1.3531562089920044,[[539,[503783185,488612115,-1644183017,-6291490,1583288237,-8273,-67147553,-50467906],[-0.23604758083820343,0.14624665677547455]],[125,[-754980097,-92799137,586973151,-1206650282,-1015162113,-524401,1993957087,-172492917],[-0.20661883056163788,0.16887924075126648]],[384,[-669517935,16839696,-584633040,2098167213,17062277,404341136,716110059,789549483],[-0.19015172123908997,0.16193783283233643]],[94,[-232805713,-20973057,-34603081,-42607105,-356519937,-118109025,-387977505,-2134520828],[-0.1541261523962021,0.1933469921350479]],[466,[528479223,1904195575,-55119905,-10575890,-947918857,-288358401,1944539762,-135803137],[-0.20460499823093414,0.14255046844482422]],[47,[-201327709,-19927066,1384109026,-739317020,-12587089,1179121028,-241174704,-168296506],[-0.1505453735589981,0.185949444770813]],[339,[-581632048,-606277136,-368021128,-176383780,-956433298,-268467029,1551686188,-36312104],[-0.30771997570991516,0.09145938605070114]],[229,[1595362495,532541183,-1879371521,1474776767,2138187743,1066097155,-537382949,1073678919],[-0.21706582605838776,0.1526443213224411]],[254,[-864133153,-692189161,-1099006309,304102399,1492012696,-93634736,1580914378,1929543391],[-0.2538846433162689,0.10271452367305756]],[457,[-64904797,-305660509,1468164915,-565,1059143671,2107965041,2079816191,-18385129],[-0.1301027238368988,0.1987140029668808]],[511,[-220210673,-536887410,-757597954,-235406646,-67108980,-867369216,-31934210,-251404342],[-0.10590624809265137,0.23959128558635712]],[497,[-740299264,146795517,-50352379,-1912628641,1053777812,901722868,771235244,546303999],[-0.22008468210697174,0.11258465051651001]],[355,[343358231,213430181,-1364197793,1073696095,1515411668,-1059425,-13050290,-1078259937],[-0.11953093856573105,0.20282387733459473]],[373,[1245651467,183707959,582887578,31777007,1691296546,237724247,1348920347,-203426945],[-0.2097310721874237,0.11584123969078064]],[35,[2043933695,-144771167,19989255,1072001427,-506069031,-1929904385,592718063,-169511337],[-0.12918440997600555,0.18772880733013153]],[606,[-8388705,-6177,1207429886,-170921106,-34868280,-53228224,-120789286,-263979270],[-0.0942123755812645,0.25828978419303894]],[179,[1023876484,-256822903,-1178663971,-236886881,-236217863,-52522546,833036237,-8385148],[-0.19691897928714752,0.11818353086709976]],[124,[-85998033,1383065263,1880502784,1516235723,2004838208,1346401792,1406554770,-214434290],[-0.1725260466337204,0.1334143728017807]],[508,[-215485453,-1146159840,-1684308559,-551551504,-1448107335,-1364266068,197057439,-536879651],[-0.11676036566495895,0.1934867650270462]],[388,[1867466672,-654132904,-1678940751,-1073748483,-1814283784,982153656,163645464,-1409292229],[-0.13684672117233276,0.17416830360889435]],[241,[809177088,255278841,2052280087,2064460633,-357326549,-1147540547,1220038219,2137729130],[-0.3888067305088043,0.05829756334424019]],[463,[2113404919,2119573369,-1216467140,686062828,1599431925,-35250219,-379253932,-588874972],[-0.15329168736934662,0.15723362565040588]],[74,[-142872849,-1123585,-1226054942,-168821790,-16843777,-682478139,-148908590,-170396193],[-0.1018904522061348,0.22314058244228363]],[491,[666353457,301069589,-134324227,-1850148995,-163618376,-1430878019,-272720724,747437600],[-0.15450522303581238,0.1451505720615387]],[265,[-1520456817,-672619806,-1650134352,-739249448,-410644,1850609408,-671883070,-716704562],[-0.1173662468791008,0.1908893585205078]],[640,[-268439637,1743224491,-1062209824,2075129070,-100680209,17194724,-503319132,-705694017],[-0.1188521459698677,0.1887996941804886]],[351,[-235865865,-778321560,-773813480,-977205055,-671809621,-2037042305,-1373573570,33902919],[-0.16477152705192566,0.13532666862010956]],[281,[1288503263,496561871,-41996993,-44831381,513794520,-6296133,-11544504,-545596144],[-0.137915700674057,0.1612575650215149]],[592,[-536990993,-545437826,-526919430,1927770592,-1149453240,-12157692,-795681042,-263995441],[-0.12841881811618805,0.18196146190166473]],[200,[-998586714,1923629704,-1567936970,-106682636,1246039486,1391923172,2128445439,-1161220571],[-0.15602493286132812,0.1402648389339447]],[574,[-902847741,1122672495,1740485216,-252907012,716157611,40020694,-1354763301,-705694561],[-0.12058999389410019,0.18453432619571686]],[411,[43190589,237000119,-1479864545,218107700,1727349849,-84927193,168837124,201328132],[-0.2745037078857422,0.080660879611969]],[503,[1145396501,-596306061,-1883250830,604912112,1244529921,1003257717,2066631424,-2012515040],[-0.22889545559883118,0.0964030921459198]],[337,[-704658769,-746617960,1373614706,-235613442,-75520946,1213059150,-705694758,-783549270],[-0.10090714693069458,0.21733656525611877]],[239,[-555432832,-1192997968,-286225699,-35864850,798508188,-1108076048,-365142037,-1475542263],[-0.12968091666698456,0.16475941240787506]],[288,[-573549447,-676853411,-1052201605,118622855,2113410296,-336957660,-618632262,-1442645118],[-0.2590109705924988,0.0811891183257103]],[368,[59193218,2011775753,1576662974,-4195534,1435621351,-1702058057,-2017202266,1977972599],[-0.181268572807312,0.1182248443365097]],[499,[-988850988,1016641345,160914500,-474084890,154236052,-1078989845,1441076224,1040163634],[-0.31549975275993347,0.06667444109916687]],[364,[4349984,639681328,129645151,668510722,1148195120,2062963956,153885736,1647779843],[-0.22751913964748383,0.09298747777938843]],[602,[-276828158,1130870371,-1531186142,1894639586,1969214592,21205461,-621813270,-792462373],[-0.14819277822971344,0.1404644250869751]],[353,[118533695,270567044,1288523533,-1113072698,827708669,1605729269,-1091248161,-1141436093],[-0.11226476728916168,0.1889517605304718]],[340,[-85094773,1487850663,721041258,1374657596,1581250843,1112227395,-132434392,-362545185],[-0.11850622296333313,0.17904981970787048]],[424,[-1044577783,-3170331,-210830594,-539528456,-1410339954,-1907435776,-201789206,-213651366],[-0.10257221013307571,0.2029486894607544]],[632,[-176173273,-63971382,-92540894,549647982,-1338560,1476888389,-78600722,1081266138],[-0.18841557204723358,0.11295656859874725]],[520,[-671890176,-181237993,-1548798927,2005171473,319759748,-51300395,-1366522344,71618053],[-0.20921583473682404,0.10211694240570068]],[88,[-1602114652,-1100433156,-2115529,-1184520048,-1984574213,-297169991,366750175,88360004],[-0.1925288438796997,0.10750143229961395]],[216,[39586,2101407957,-35376942,-644184357,-1586894610,-971554785,-5808574,-784335945],[-0.38588806986808777,0.054346900433301926]],[496,[-1706958944,451947443,-277301988,-1936926482,1031093973,-1141080111,-100907,-5619417],[-0.1415555328130722,0.15182578563690186]],[385,[135519103,-1701046404,-476620169,532383797,-110098979,-17150466,789376991,-1153760421],[-0.09721077978610992,0.22095255553722382]],[119,[-68163926,-758185242,-457882974,1943200494,1903550542,1144214864,892323778,-178276493],[-0.14763863384723663,0.14450113475322723]],[163,[-894505938,1726040062,-1434959774,1382606572,877626085,912263760,854318029,-235154495],[-0.19948244094848633,0.11108753830194473]],[114,[-391748914,-87495070,1790763645,-34746582,-1894316363,-151775424,1299316735,-380410251],[-0.13808603584766388,0.15448948740959167]],[514,[-222361681,-142684161,-1011944610,-254805175,-178667764,-968192512,-267197752,-251479346],[-0.12034154683351517,0.17198434472084045]],[377,[202861343,507076415,721758013,687907104,1979993013,-907174864,1142178076,1145104400],[-0.21315360069274902,0.09752862900495529]],[510,[-928024797,453054,-218994892,-185220464,-2126798169,577432266,713554911,-1090527317],[-0.11146946996450424,0.18797753751277924]],[611,[-4213,-1009925217,-1562674624,-1151402642,-234885490,-590488821,-269837110,-774636550],[-0.10050378739833832,0.2044314742088318]],[442,[-797631013,362314737,-1347420291,-1079419975,1280903925,-1262749233,-1377484823,-2001796897],[-0.10836073011159897,0.19475993514060974]],[432,[-316169693,-1344106541,942691362,-202380558,930163489,240608166,1442141879,-470334095],[-0.17380046844482422,0.11888035386800766]],[252,[148589439,-598544709,1720098523,302055418,1690358359,-537721785,1465335308,-581808818],[-0.1894366443157196,0.10867591947317123]],[272,[-227276285,417733055,141878230,-1930563657,443500552,-1197990268,-1091601874,-1428702701],[-0.15042302012443542,0.13850454986095428]],[365,[-295443825,1881192242,602866173,-1583502408,217335229,1880585519,771686574,74185330],[-0.15697461366653442,0.13160589337348938]],[240,[-266098716,966942003,-1407291626,-1109396138,1947725557,517720022,-2137792561,822083471],[-0.1492486596107483,0.14093182981014252]],[467,[1147473171,1279326997,-821293199,17582980,1561349141,1035756917,1693723716,134813760],[-0.26860836148262024,0.0791471004486084]],[480,[-745413331,-1188194,1267976940,-244057892,-117834344,-1182421888,-1244286022,-675048758],[-0.09677238017320633,0.22355352342128754]],[378,[-602815049,2117625195,-1582168171,-443273818,-810235495,-1412593179,-906457953,-301186044],[-0.12496618181467056,0.16549517214298248]],[223,[784214531,-1481197765,-1175381145,-142769749,-1364414680,-1560957006,1999583123,705541055],[-0.21282529830932617,0.10288743674755096]],[528,[-182344572,446549361,-1443603113,-1212176388,-554397288,-1963900929,-84743208,-1364216305],[-0.1101992055773735,0.18950794637203217]],[616,[-1303385209,-896561650,-523800238,-151652106,-548431989,1168430244,-503320351,-2064649010],[-0.1337122768163681,0.15766866505146027]],[22,[-218105953,-679114691,-1267388358,-73099594,1743867279,2020921186,-6432370,-179836046],[-0.09166912734508514,0.2281249612569809]],[332,[1790410753,1194205785,712913755,1475242910,1659961600,360013679,1966797212,-10529238],[-0.139383465051651,0.14807218313217163]],[488,[-56065920,-160826232,1866034740,-2282635,-1612249423,-2037227788,-1350786837,-1091042443],[-0.12180531024932861,0.16849491000175476]]]],[-1.3695704936981201,[[120,[-893410561,-536892673,-353374337,-2103706,-203480129,-21509,-167809029,-1881225],[-0.16063986718654633,0.20273709297180176]],[505,[-33816579,2073344700,-1558201345,-4208492,-1141113363,-1076259588,1055902713,-575711916],[-0.26204735040664673,0.12330465018749237]],[335,[-293634177,1400863359,-389223329,-201985,-834109825,-285447041,1851805055,1870364287],[-0.2892760634422302,0.11206240952014923]],[357,[1515124725,369927936,-1610666831,925855537,272631836,1531994580,528108223,455021023],[-0.1916453242301941,0.1483595371246338]],[560,[1060087328,398437823,79655216,-1914176017,1558052245,520000392,-1268336765,212632751],[-0.26242268085479736,0.10239273309707642]],[623,[-2629905,-78655505,-891554078,-1058015250,-264250,13096772,-168561212,-235405345],[-0.13851559162139893,0.19113600254058838]],[162,[-202375721,-2111636702,-721301001,-1574913280,56560078,-18677777,1087758426,-353913415],[-0.24820300936698914,0.10685873031616211]],[50,[-8917009,-103854097,-225450462,-1575174,-8377,608239190,-209718336,-171445561],[-0.13929376006126404,0.17934627830982208]],[588,[-543165517,533196199,-1426074862,-544347146,1130218371,8697232,2010559954,-741343554],[-0.15528613328933716,0.153925821185112]],[527,[-411054193,-1281884233,-1115833348,-1079673394,-1581284984,-1148589108,-289498744,-2000102976],[-0.10468549281358719,0.2299884855747223]],[172,[-25174417,2117286413,-804523330,539753215,-1568838361,2136045431,-87049377,2002249599],[-0.24891264736652374,0.09397143125534058]],[366,[1210754048,537666304,161348605,627257024,1158955796,1668769269,2008953695,1744796787],[-0.22588098049163818,0.10365047305822372]],[180,[-281288273,-70327314,-796921740,-738730264,-76562513,-808792948,-1745881398,-136841842],[-0.09550877660512924,0.24650630354881287]],[341,[-886059513,168764323,-267976118,-973090298,2097352643,176495367,1802990159,-385882317],[-0.1835000216960907,0.1264064460992813]],[595,[-1,-134221825,-218431816,-235405578,-573046902,-35956864,-177472566,-170394389],[-0.08009938150644302,0.28082871437072754]],[314,[-14155881,-1111314545,-1353609437,-1358649856,-20250627,-75628649,2146789220,-69994733],[-0.10310708731412888,0.214595764875412]],[404,[85077823,206351119,984419861,20769365,1278506495,509705715,1276407516,-599458444],[-0.18984027206897736,0.11972358822822571]],[206,[-665271074,1464887091,-2145223045,83944575,-664089410,1012828430,-1092945194,-12125474],[-0.20856955647468567,0.10800682753324509]],[49,[-138414161,-152571922,-234883034,-218173190,-69748337,-796834004,-51382306,-235410218],[-0.1027757078409195,0.20767506957054138]],[420,[-75500559,-1089602040,-645940751,-3162657,-1738551119,-1692862817,1308311003,-1993363541],[-0.09340593218803406,0.2308514267206192]],[455,[-853368159,-1054814,389048702,-1710123,-1154186313,249649875,-10095637,-606120681],[-0.1059320941567421,0.20079787075519562]],[11,[-2094007297,-442069004,-605586698,-34146080,-538208337,-1964457677,1912461024,-168302604],[-0.1310754418373108,0.16108058393001556]],[198,[-158677344,-181113360,-1528008969,-571176396,1048269475,1988456372,-27492353,-11978479],[-0.128440260887146,0.16143999993801117]],[544,[1596188085,-987304491,452284863,-5645158,-497029639,-48137,1575704025,-806361704],[-0.15479210019111633,0.13679373264312744]],[416,[-803355391,269612059,-103964896,-584190505,-1253042176,-1287846874,-1364704630,70252047],[-0.1722220629453659,0.1234646886587143]],[486,[-474377216,339541264,1577057677,-1916842185,-281745276,-1666877764,1774055176,135393823],[-0.17964713275432587,0.11774364113807678]],[381,[-169103377,-138944407,-4966664,-1117990427,-67251458,-75752779,-34803588,-1920930339],[-0.1593102514743805,0.13285309076309204]],[1,[575365347,953914139,-1078241825,530438005,8352735,-1140861665,-2360355,100616191],[-0.20287837088108063,0.1128615140914917]],[301,[-2087738451,-236978972,1945051832,-204474216,-4213090,1812100654,-163122454,-721178166],[-0.10795794427394867,0.1885324865579605]],[406,[-763363841,1161592123,1218843669,589341989,1147453433,878107283,1673862468,71320641],[-0.1465872824192047,0.14068178832530975]],[448,[338702321,-955723536,223548671,-1618687592,640728233,-1892106840,253306637,160038936],[-0.2504936456680298,0.0826164186000824]],[309,[-1069741553,285037405,549372488,157368060,1549206792,476787267,-223284936,-1460015186],[-0.17551939189434052,0.11551206558942795]],[16,[-559645958,-82033479,-315888674,990559098,-363475207,-1143281825,-279609123,2137035924],[-0.11419827491044998,0.18112944066524506]],[641,[-1880100957,172207746,-2042666496,-1609982232,-877665398,84329482,1961357506,1153890247],[-0.2089102417230606,0.09833736717700958]],[19,[139296,2079280460,-1035450949,-69253746,155985087,2113766731,135764411,1608486923],[-0.4137556254863739,0.04951225593686104]],[312,[-732977643,277903282,-1368217772,-459228376,813142006,837885431,267818252,-1528500380],[-0.15581859648227692,0.13339102268218994]],[517,[-83917399,-173673311,1626321594,-237716638,-42225124,1728340229,-1232225590,-217329398],[-0.11577171832323074,0.1834014356136322]],[316,[771960639,134409859,258363165,-1394364928,1598414807,2012884973,1584664128,708838210],[-0.216262087225914,0.0954771488904953]],[572,[666873121,-1893565126,-805613104,2079585760,774873761,571125668,387929937,-1046488867],[-0.19903813302516937,0.10453511774539948]],[28,[1635451153,957686021,-451272973,1032273426,1958556740,1036989745,177547808,720439818],[-0.23054862022399902,0.08963410556316376]],[70,[-12625,-404502550,-612371614,-540029722,-8650866,-1469579796,-67108900,-171445794],[-0.09864431619644165,0.21435028314590454]],[451,[1576224017,1500983061,-1166946313,1564704015,-289709104,-1073863490,-828084534,469780763],[-0.16094450652599335,0.12683431804180145]],[245,[1216020163,491947673,-499451301,-71647474,170564572,-37852258,-346584676,-75029248],[-0.15302763879299164,0.13355283439159393]],[524,[77630976,505053924,-1913377232,-1381980602,569648564,1022552432,-1514830304,12870692],[-0.2971450686454773,0.06969578564167023]],[550,[-496000253,-894677310,586380658,-1071684144,-1104764373,640414632,1062711983,-537927685],[-0.11207269877195358,0.1844300478696823]],[317,[-805261899,832624929,-898801809,-1107465875,-413863428,-1242559753,-17457656,783226135],[-0.11341021209955215,0.1786959171295166]],[578,[-672676857,-671259169,-1426431184,-255886694,2067195272,1640486343,-139008630,-247225717],[-0.11504149436950684,0.17776061594486237]],[144,[-257186129,-55922845,-1077084195,-144891130,-20726341,-119362033,-410783813,-850035177],[-0.10251285135746002,0.19268713891506195]],[600,[-143655125,1979709255,1894602946,-754483258,-161491838,1154539319,-192615542,-237504533],[-0.10122653841972351,0.19483508169651031]],[356,[2135702864,-1079215788,-36702019,800889112,-1154973384,-1120359512,-340087880,-1574789070],[-0.2610571086406708,0.07492011785507202]],[367,[-1064320849,-888408663,-1962966286,1885579722,-23076338,773293122,-85339410,-481827590],[-0.11406119912862778,0.1742027997970581]],[374,[-213751904,1605143820,-154679544,-16761,1735620549,1320605166,-504922689,2011657827],[-0.1686951071023941,0.11749855428934097]],[503,[341128023,1349080691,-1529909789,1250525156,1550284273,-72450689,1588033856,-2012225976],[-0.1634955108165741,0.11994868516921997]],[256,[-32515877,-176855289,-261031297,1057333783,-1032058104,-9935818,-5099570,-13137826],[-0.07970619946718216,0.24850650131702423]],[61,[-2068804128,-614601995,1600116177,-71247792,2029357231,-1082179864,-2010903395,205586689],[-0.1640268862247467,0.11898551881313324]],[213,[-1435261405,1677689511,645850208,-236093449,657455952,121542592,2002383380,-210522417],[-0.12863418459892273,0.15204958617687225]],[439,[157636991,-102735875,1049370419,1031056562,208487861,712996735,-44814592,-1994037727],[-0.15739968419075012,0.1273098737001419]],[435,[-1060257076,2111306357,-1702557168,-590175618,2013791500,2051870375,-52680182,-1454021666],[-0.19914233684539795,0.10423366725444794]],[358,[-625293571,-19622942,-8397475,-314591297,27992299,2121821739,136847918,1146475566],[-0.19054318964481354,0.10671291500329971]],[492,[1789983778,1018022897,1861236018,-1611852202,876642213,975183222,1055947774,-1343409645],[-0.15487708151340485,0.12867331504821777]],[279,[327569,218144993,-1778603565,-1353503484,1175427833,-1882729345,-1546330360,-1154386112],[-0.17322303354740143,0.11569347232580185]],[601,[-349703245,1433019314,-818911184,-1010310506,1433778070,920808880,1642167992,-1328024681],[-0.12355220317840576,0.1641998440027237]],[93,[-14441821,-609294870,-757607712,2138420300,60657067,-893238130,-182328108,-706218300],[-0.12934589385986328,0.15674130618572235]],[474,[-1928604261,-1929346844,-581011079,-541065844,-1962793025,-1959006544,563584939,-1880104977],[-0.08118641376495361,0.24935099482536316]],[597,[-420879597,-589318153,-893880704,-1313297248,2046665348,-1124139690,-1345913272,-264117294],[-0.11719886213541031,0.16577914357185364]],[382,[405429943,35157617,-142668179,-1390625436,1507609804,-621125793,-1973510450,-1165812617],[-0.1302436739206314,0.1481214165687561]],[147,[-3217685,-30740518,1938426620,-538214752,-18219057,-1255846052,-201922374,-237023073],[-0.08887697756290436,0.21971827745437622]],[639,[-738726997,187692013,2011134000,-1955661828,-210119423,38790407,-755108400,-987308314],[-0.144447460770607,0.13702638447284698]],[452,[-206995293,-545767542,1907093886,1935741694,-335563268,-280903864,-675039554,-211558710],[-0.09513164311647415,0.210707426071167]],[271,[-1002116576,1325996544,-665522936,-597824890,1711699965,-73301301,-20448286,-110104046],[-0.41298919916152954,0.04899816960096359]],[407,[207949127,547888959,694611503,671116356,81544535,-709767,4261201,4220420],[-0.286625474691391,0.0699523314833641]],[127,[-1060119505,1644687048,-1964847366,582987502,-784636060,827683077,1339008607,1415048063],[-0.34356600046157837,0.056598760187625885]],[266,[-803222897,-61161545,1635244831,-1845510438,2098171271,-116434698,-21512229,-513563541],[-0.09976167976856232,0.2059866040945053]],[51,[-347603025,-1870744068,679009562,-113708376,1464161920,-312465308,-41992630,-246943142],[-0.11211391538381577,0.17646948993206024]],[190,[-2013321968,-742767341,1337165114,-72328646,-1787329601,-841026207,512443531,314072475],[-0.18131621181964874,0.1113327294588089]],[319,[236183479,76657666,176155709,908181400,409361495,-1141645905,-36647350,-1153869993],[-0.1289842575788498,0.15491998195648193]],[586,[1266667810,1511123775,2094253840,-44707245,333808663,628498310,-402685040,-962095185],[-0.17281804978847504,0.11423593014478683]],[192,[-236396348,-727146278,1815085813,-38007520,-171378486,-37738304,-206180609,-107261665],[-0.10720640420913696,0.18341147899627686]]]],[-1.3276469707489014,[[434,[1206384503,2004349367,-587202625,-570458153,1962898935,-135266313,-1091309570,-20973573],[-0.17421774566173553,0.17884284257888794]],[165,[-134742022,-627902534,1968427302,-560480,806424439,-67650565,51447330,-2133022],[-0.2147657871246338,0.133590966463089]],[515,[1057161146,-6307941,496502567,-1617216785,391903915,959252225,772505566,974076347],[-0.2319275587797165,0.12110722810029984]],[257,[-1700555934,-500504126,370126658,-1079154897,-284626360,-1924137013,387672847,-3156277],[-0.252623975276947,0.10572800785303116]],[159,[-1000152458,1895493330,-281178273,2146355560,2093311095,855405813,-543404209,821946229],[-0.206059068441391,0.12582625448703766]],[87,[-34608193,-201856262,-352407873,-1461520,-1057490945,-307438354,-52433718,-1060773758],[-0.11796126514673233,0.224318727850914]],[106,[1362202543,-776621899,-193898077,-652093965,-805254933,-1070494539,-755921969,-553071219],[-0.2505123019218445,0.1018962562084198]],[168,[-897588481,1378039343,586731134,849323774,-615806066,1984956738,-10032198,-212598805],[-0.1737886518239975,0.13972662389278412]],[620,[-1329,-67371793,-1023412510,-219219134,-146872510,-950677692,-170852800,-263718034],[-0.14755988121032715,0.1593601256608963]],[409,[-807993858,-444659489,-222269284,-806825748,-620958468,-268492551,-286463746,203485430],[-0.2265692502260208,0.10586404800415039]],[14,[-33558609,-65083682,-811175422,-123558262,2138007431,436495750,2139054674,-263724334],[-0.13516700267791748,0.16823019087314606]],[327,[-749235619,-740780991,1904177247,1594623829,1529354448,-172286859,-15541176,-1358771366],[-0.18130488693714142,0.12141185253858566]],[182,[1584990352,-473857834,1605700853,-100717318,-212604370,2125138082,-883446021,1534527706],[-0.15860173106193542,0.14470864832401276]],[573,[-1507874397,113426414,310100625,-621282947,573547171,583934606,1300686811,-908069413],[-0.11836400628089905,0.18674106895923615]],[429,[1929570099,1923122619,-1622715780,-1577842,-213749257,-303632667,-168461162,1626695507],[-0.17876005172729492,0.12363989651203156]],[391,[-73430485,-9438562,2060940976,-201347366,-1144276274,-2029070526,-134228252,-146546470],[-0.1017240658402443,0.21338729560375214]],[376,[240453683,156000191,695730801,151579604,1499214964,243072615,1282672708,1080359008],[-0.28052809834480286,0.0774340033531189]],[470,[962023287,34183056,-852552331,-1347425040,1042303461,-1413613637,-2073063769,-1884301079],[-0.13582970201969147,0.15512573719024658]],[140,[-816847897,-235995450,1886384818,-707134232,-335615089,1147431968,-136072004,-171444858],[-0.11215072125196457,0.1839037537574768]],[564,[1059584305,-1155622093,766342448,-1098140737,337323539,-289461769,222106113,-1917846854],[-0.20957885682582855,0.09911903738975525]],[78,[-2102645,-249598262,318267466,-345408,-622854336,1577202951,-246163506,-247012394],[-0.1202320083975792,0.16939368844032288]],[343,[1291652149,416690045,162229011,199289850,16481324,2128737110,2062154440,-287838248],[-0.16363579034805298,0.12322180718183517]],[135,[167774464,-153547823,2081759095,-34798827,1856352957,-1309945394,-343857497,-1476130752],[-0.1910814493894577,0.10656961053609848]],[5,[-207883269,-337118226,-220200976,-672139828,-205541893,1284500906,1402970448,-984099979],[-0.12751424312591553,0.159833624958992]],[361,[-46205443,-77802884,1811618781,-675364873,-61313858,-1149589828,-824262757,-487644637],[-0.10761560499668121,0.1867380440235138]],[289,[-1308578941,545317504,-1228750258,-546977248,1313629625,1084548279,-448115765,1312899649],[-0.1779690682888031,0.11297041922807693]],[236,[2124418214,497585588,-273412583,-36860403,410545912,-21749827,-57253938,977282818],[-0.20535370707511902,0.0986572876572609]],[634,[-2628625,1397615486,-222948678,1760951400,-707857434,-108282272,-255668840,-246153218],[-0.09891577810049057,0.20372001826763153]],[441,[1349520221,349187989,1306762509,220739316,1551189437,-1786970795,-860861440,137176337],[-0.21401090919971466,0.09538363665342331]],[483,[-127866240,-1243498252,2075148792,-656172,-1077434979,1089506804,-1075988545,-1614085205],[-0.1046956405043602,0.19067412614822388]],[253,[-573002017,446301319,-1152229985,51053115,-585457444,-897361,-2109064294,-1442650241],[-0.18012882769107819,0.11004448682069778]],[494,[-252189692,66845267,-974148203,-1923221545,1325215904,951641541,2060832424,677760795],[-0.18193097412586212,0.1096099242568016]],[583,[-738863711,1434287531,1197897784,1438902146,1641133962,-66261760,-37880920,-2004551028],[-0.10881505161523819,0.18144680559635162]],[174,[573541027,1676608015,2242082,-206569477,997981440,1449923588,911537988,-212864065],[-0.1332176774740219,0.14928372204303741]],[421,[1746154929,-570884612,1069104059,1601896941,-1187998728,-1080239892,196885913,-1414887399],[-0.15190990269184113,0.13106991350650787]],[428,[-821319005,-814945359,1779284706,-1712448,-210261089,1179740053,-1214778225,-136349857],[-0.09939505159854889,0.20520499348640442]],[380,[-145235969,-541098113,-1368522945,-1478098961,-177733697,-13142085,-1287296305,-1073370811],[-0.08019614964723587,0.24394263327121735]],[7,[-1846704337,-178936003,-1015991060,-772726649,-1444167761,-1145590493,-772690101,1503989547],[-0.20097000896930695,0.10192957520484924]],[202,[1316145887,271851495,572705407,273870363,2131758335,-246658261,-14692406,-143193986],[-0.1798076182603836,0.10900650173425674]],[347,[-103681070,2069215860,1035229628,-688071392,596173394,-72609495,-80709704,-669718415],[-0.21817618608474731,0.09075445681810379]],[417,[495269373,487127029,1072919743,2108013936,2055212316,-1615987457,175799945,-1359017709],[-0.12845875322818756,0.1588725596666336]],[296,[585867779,-2097176059,-2098537921,-539000365,1583727364,1442265348,-1814891014,-204483334],[-0.10467831045389175,0.18451270461082458]],[303,[-607653333,-9692435,-247750602,-747325454,-13637809,38165774,-204802086,-775686706],[-0.10023513436317444,0.19359007477760315]],[629,[-545306625,2064440055,-352337922,318168918,-74269108,-143238655,-828194850,-1069094165],[-0.1195247545838356,0.16566169261932373]],[97,[-1610421825,2086727596,2091054521,-43774102,1956117671,-1079985741,529529502,-855371243],[-0.1406981199979782,0.1364859640598297]],[604,[581936675,583427210,717332896,1614603900,1779402659,71308546,930838418,1172302558],[-0.2065264880657196,0.09351257979869843]],[541,[404419349,932903717,1877421366,755818324,743838869,-1145519202,2007639040,-1363402412],[-0.17568394541740417,0.10853296518325806]],[603,[-67115261,1783467842,62649872,-177358022,1674508167,1627845012,2071982800,-1061706326],[-0.14163950085639954,0.1350509077310562]],[464,[-725234227,-65396777,-1603801204,-2002121734,-574092835,-559480843,-664465910,-928072645],[-0.11832091212272644,0.16329441964626312]],[9,[1740090031,-119056410,-48862274,-36986092,1905130415,-458821676,-1686142136,-789056533],[-0.09134785085916519,0.20984113216400146]],[389,[1163198464,-642500693,2032893884,-68817014,486915972,-364436958,2111264989,1551847259],[-0.32341209053993225,0.05920873209834099]],[454,[-89996128,2143001535,-319868938,-303249964,-941319771,-17356811,-1376506227,12609509],[-0.16184104979038239,0.12333205342292786]],[407,[224468919,20399931,275107615,218193753,359924797,357845277,1715740495,67410948],[-0.20427259802818298,0.09396296739578247]],[116,[2093999747,2076877547,-1536818480,-68260112,342087583,977675846,-2054892578,-143178538],[-0.11508999764919281,0.16942168772220612]],[262,[61902593,-1083933765,-53051661,-19728780,-758080769,-274773161,-2065401704,-2140078764],[-0.13256002962589264,0.14487990736961365]],[532,[-357248640,-1176763950,-1380155585,-537027259,844380056,805091765,787808170,-287169169],[-0.1147993728518486,0.1661256104707718]],[34,[1897919481,934478619,1607954095,1062273795,-568662347,2092388823,-471626574,1140325367],[-0.13866116106510162,0.13873730599880219]],[372,[-1037057913,-1657025808,46547560,273413291,1868532619,1378320161,-743476278,-347081181],[-0.11563215404748917,0.16552960872650146]],[546,[-1199314443,-408265787,-913588740,-1655009092,-1372935763,1873526476,-914524407,-979905444],[-0.1930251270532608,0.10073655843734741]],[392,[1034106418,-711186286,-1664267794,-171720173,606214007,-1128035247,564213287,1934636578],[-0.329284131526947,0.05941547453403473]],[126,[-687891445,-614004341,-433115782,2129635058,385995416,1494300484,1184757738,-237518882],[-0.11321404576301575,0.17364130914211273]],[558,[-50542464,-203846926,-1517043500,2113515223,-1091188084,-1864583803,-387006582,-880564821],[-0.10755964368581772,0.17839278280735016]],[475,[-357780685,-2045314380,429770709,-139071500,-1492218965,61726170,728218027,-339771255],[-0.10421722382307053,0.18218158185482025]],[556,[-302139391,1593041108,735992730,1353726386,-1293624936,-1196135168,-340731990,-1591537478],[-0.12337599694728851,0.15013375878334045]],[275,[1547535029,278984577,-1224785328,-1934989504,788137925,955118580,-1444508712,-1463819671],[-0.1586635708808899,0.11958929151296616]],[176,[-1439519837,-336661820,-1812236650,-712007984,-1960207458,-868173878,857715686,-973873982],[-0.12497423589229584,0.15143482387065887]],[359,[-761946313,1449128951,2079574803,1577003953,1515347100,-537167905,-67450178,-4500465],[-0.0817737728357315,0.2283615618944168]],[152,[-496441618,-750877463,-389118725,-37689332,1436594609,-578584890,-706221607,1893151375],[-0.13894660770893097,0.13676303625106812]],[504,[68506993,604858146,237580290,-2121958112,1638929,-1097225097,340092224,34511200],[-0.27272871136665344,0.0688985139131546]],[415,[-2147155497,1560733239,-303387241,-42356291,-598665995,-1162842499,-295060500,-1410399929],[-0.09935402125120163,0.19108591973781586]],[228,[-202202453,-590590115,1756643743,2109944543,871348042,-786157488,1542764527,-1693726949],[-0.09322153031826019,0.2062782347202301]],[460,[-1500254282,1435658607,582487508,484240674,2099271125,-46970395,1709452864,-467317454],[-0.15570642054080963,0.11965727806091309]],[46,[-919902696,-123093042,398152976,-135141176,1743172232,-1396378320,2083515430,1413872256],[-0.18301638960838318,0.10124789923429489]],[36,[-751855101,-145897929,2019538276,817985198,803892992,71726960,2134193492,1932970373],[-0.18066957592964172,0.099701888859272]],[20,[268485266,-899463709,586345979,2106381807,541077489,-84983361,1246258371,-1474337],[-0.30872756242752075,0.05945972725749016]],[322,[1213715019,-2076172757,615109711,232265406,1473517724,-1362446289,-1316954598,-1153808357],[-0.15449893474578857,0.12041906267404556]],[628,[-357568517,323871454,788404112,300650740,1538176507,-1983070516,-707831379,-1002326334],[-0.10190636664628983,0.18244536221027374]],[459,[1757579808,985458211,-1912341890,329521286,922752757,2094473459,132997598,-336317625],[-0.16428998112678528,0.1118251159787178]],[345,[-771571504,-9274104,969443530,77684746,-1178752308,1794067453,-1562083078,-604642545],[-0.1964097023010254,0.09406990557909012]],[284,[583589,-1811895121,-1462990311,-1594353,1953890287,-420466765,-104632354,-1678170365],[-0.10462117195129395,0.18044032156467438]]]]],"features":[[0,0,1,1],[0,0,5,7],[0,0,6,5],[0,0,6,6],[0,0,7,6],[0,1,1,1],[0,1,1,3],[0,1,8,6],[0,2,1,1],[0,2,1,2],[0,2,1,4],[0,2,2,1],[0,3,2,7],[0,4,1,1],[0,4,1,2],[0,5,1,1],[0,5,6,2],[0,5,8,6],[0,6,1,1],[0,6,6,5],[0,6,6,6],[0,7,1,1],[0,7,1,2],[0,7,3,2],[0,7,6,3],[0,8,1,1],[0,9,1,1],[0,9,1,2],[0,9,2,5],[0,9,4,3],[0,9,5,2],[0,11,4,2],[0,13,4,3],[0,14,5,3],[0,15,5,3],[0,15,8,3],[0,16,1,1],[0,16,3,2],[0,19,8,1],[1,0,1,1],[1,0,4,4],[1,0,5,6],[1,0,7,6],[1,1,1,1],[1,1,1,4],[1,2,1,1],[1,2,1,2],[1,3,1,1],[1,4,1,1],[1,5,1,1],[1,6,1,1],[1,6,1,2],[1,7,1,1],[1,7,3,2],[1,8,1,1],[1,9,1,1],[1,12,4,2],[1,14,7,2],[1,18,7,2],[2,0,1,1],[2,0,4,3],[2,0,5,2],[2,0,5,6],[2,0,6,4],[2,0,6,7],[2,0,7,6],[2,1,1,1],[2,2,1,1],[2,3,1,1],[2,3,1,2],[2,4,1,1],[2,4,2,4],[2,5,1,1],[2,5,1,2],[2,6,1,1],[2,7,1,1],[2,7,5,5],[2,8,2,3],[2,9,1,1],[2,9,1,2],[2,10,1,1],[2,11,1,2],[2,11,2,2],[2,12,4,3],[2,14,7,1],[2,15,5,3],[2,15,7,3],[3,0,1,4],[3,0,3,2],[3,0,4,4],[3,0,4,6],[3,0,6,5],[3,0,7,6],[3,1,1,1],[3,1,3,3],[3,1,6,5],[3,2,1,1],[3,2,2,3],[3,2,6,4],[3,3,1,1],[3,3,2,4],[3,3,2,5],[3,3,4,5],[3,3,6,7],[3,4,1,1],[3,4,2,3],[3,4,6,3],[3,5,1,1],[3,5,6,3],[3,6,1,1],[3,6,2,2],[3,6,6,3],[3,6,6,4],[3,7,1,1],[3,7,2,1],[3,7,2,2],[3,8,1,1],[3,8,2,2],[3,8,2,3],[3,9,1,1],[3,9,2,2],[3,10,2,2],[3,10,3,3],[3,10,6,1],[3,11,1,1],[3,11,2,2],[3,14,1,1],[3,14,2,1],[3,15,6,1],[3,15,7,3],[3,19,7,1],[4,0,1,1],[4,0,1,2],[4,0,1,3],[4,0,5,2],[4,0,5,3],[4,0,5,4],[4,1,1,1],[4,2,1,1],[4,2,2,3],[4,3,1,1],[4,3,2,3],[4,4,1,1],[4,4,1,4],[4,4,2,2],[4,4,5,3],[4,4,5,5],[4,5,1,1],[4,5,2,2],[4,6,1,1],[4,6,5,2],[4,7,1,1],[4,7,1,2],[4,7,2,3],[4,8,1,1],[4,8,2,1],[4,8,2,2],[4,9,1,1],[4,9,1,2],[4,9,2,1],[4,9,2,2],[4,9,3,4],[4,9,3,5],[4,10,1,1],[4,10,1,2],[4,10,3,3],[4,11,1,1],[4,11,3,3],[4,12,1,1],[4,13,1,1],[4,14,1,1],[4,14,2,1],[4,15,2,1],[4,15,5,1],[4,16,1,1],[4,18,3,2],[5,0,1,1],[5,0,3,1],[5,0,5,2],[5,0,5,4],[5,1,1,1],[5,1,1,6],[5,1,1,7],[5,2,1,1],[5,2,5,6],[5,3,1,5],[5,3,2,3],[5,3,3,4],[5,4,1,4],[5,4,4,3],[5,4,6,3],[5,5,2,2],[5,6,1,1],[5,6,1,3],[5,6,2,2],[5,7,1,1],[5,7,1,3],[5,7,2,2],[5,8,1,1],[5,8,2,2],[5,9,1,1],[5,9,1,2],[5,10,1,2],[5,10,2,3],[5,11,1,2],[5,12,1,1],[5,12,1,2],[5,13,1,1],[5,14,1,1],[5,15,2,1],[5,15,5,1],[5,16,3,1],[5,17,1,1],[5,18,1,1],[5,18,2,1],[6,0,1,7],[6,0,3,8],[6,0,4,7],[6,0,6,5],[6,0,6,6],[6,1,1,1],[6,1,1,6],[6,1,3,5],[6,1,6,6],[6,3,4,3],[6,4,2,3],[6,4,4,4],[6,4,4,5],[6,5,1,1],[6,5,1,4],[6,6,1,1],[6,6,1,2],[6,6,1,3],[6,6,2,2],[6,6,6,5],[6,7,1,1],[6,7,1,2],[6,7,2,2],[6,7,6,4],[6,8,1,1],[6,8,2,2],[6,8,6,3],[6,8,6,4],[6,10,1,1],[6,11,1,1],[6,11,1,2],[6,11,2,3],[6,11,2,4],[6,12,1,1],[6,12,1,2],[6,13,1,1],[6,13,1,2],[6,14,1,1],[6,14,1,2],[6,15,1,1],[6,15,4,1],[6,16,2,1],[6,16,4,1],[6,18,1,1],[7,0,1,1],[7,0,3,7],[7,0,3,8],[7,0,4,1],[7,0,5,5],[7,0,5,6],[7,1,1,1],[7,3,2,2],[7,5,3,5],[7,6,1,2],[7,7,1,1],[7,7,3,2],[7,7,3,4],[7,8,1,1],[7,8,3,4],[7,8,5,4],[7,10,1,1],[7,10,1,2],[7,10,3,3],[7,11,1,1],[7,11,1,2],[7,12,1,1],[7,12,1,2],[7,13,1,1],[7,13,1,2],[7,13,2,1],[7,13,2,2],[7,13,4,3],[7,14,1,1],[7,14,1,2],[7,14,2,1],[7,15,1,1],[7,15,2,1],[7,16,1,1],[7,16,2,1],[7,16,3,1],[7,16,4,1],[7,18,1,1],[7,19,3,1],[8,0,2,3],[8,0,5,4],[8,0,5,6],[8,1,1,1],[8,1,3,7],[8,2,1,1],[8,4,1,1],[8,5,2,5],[8,6,1,1],[8,6,2,1],[8,6,2,5],[8,7,1,1],[8,8,1,1],[8,9,1,5],[8,10,1,1],[8,10,1,2],[8,11,1,2],[8,12,2,2],[8,13,1,1],[8,13,2,1],[8,13,3,2],[8,14,1,1],[8,14,2,1],[8,14,5,3],[8,15,1,1],[8,15,1,2],[8,15,2,1],[8,15,3,3],[8,15,5,3],[8,16,1,1],[8,16,2,1],[8,16,3,1],[8,17,1,1],[8,17,2,1],[8,19,1,1],[9,0,3,6],[9,0,4,3],[9,0,5,6],[9,1,4,6],[9,2,1,1],[9,3,1,1],[9,5,1,5],[9,6,1,1],[9,6,2,1],[9,7,1,1],[9,8,1,1],[9,8,2,1],[9,8,2,3],[9,9,1,1],[9,9,2,4],[9,10,2,3],[9,11,1,1],[9,11,2,3],[9,12,2,2],[9,13,1,1],[9,14,1,1],[9,14,2,1],[9,15,1,1],[9,15,1,2],[9,15,2,1],[9,15,5,3],[9,16,1,1],[9,16,2,1],[9,16,2,2],[9,17,1,1],[9,17,2,1],[9,18,2,1],[9,18,5,2],[9,19,2,1],[10,4,1,1],[10,4,3,2],[10,5,1,1],[10,5,1,5],[10,5,2,5],[10,6,1,1],[10,7,1,1],[10,7,3,1],[10,8,2,4],[10,9,1,1],[10,10,1,1],[10,10,1,2],[10,10,4,3],[10,11,1,1],[10,11,2,3],[10,15,1,1],[10,15,1,2],[10,15,2,1],[10,16,1,1],[10,16,2,1],[10,17,1,1],[10,17,2,1],[11,0,2,7],[11,0,3,3],[11,1,1,1],[11,1,3,4],[11,4,1,1],[11,4,2,2],[11,4,3,2],[11,5,1,1],[11,5,1,5],[11,5,2,2],[11,6,1,1],[11,7,1,1],[11,8,1,1],[11,8,2,1],[11,8,4,3],[11,9,1,1],[11,9,3,4],[11,10,1,1],[11,11,1,1],[11,11,1,2],[11,11,2,3],[11,11,3,3],[11,12,1,1],[11,12,2,2],[11,12,4,2],[11,13,1,1],[11,14,1,1],[11,14,2,1],[11,15,1,1],[11,15,2,1],[11,16,1,1],[11,16,2,1],[11,17,1,1],[11,17,2,1],[11,18,2,1],[12,0,1,1],[12,1,1,1],[12,3,2,3],[12,4,3,2],[12,5,1,1],[12,5,2,2],[12,6,1,1],[12,6,1,5],[12,7,1,1],[12,8,2,1],[12,9,4,3],[12,10,2,2],[12,11,1,1],[12,11,1,2],[12,11,2,3],[12,12,1,1],[12,12,3,2],[12,13,1,1],[12,14,1,1],[12,14,2,1],[12,14,4,3],[12,15,1,1],[12,16,1,1],[12,16,2,1],[12,17,1,1],[12,18,2,2],[13,0,1,1],[13,0,3,3],[13,2,1,1],[13,4,2,3],[13,5,2,2],[13,6,1,1],[13,6,2,2],[13,7,1,1],[13,7,2,2],[13,8,1,1],[13,10,1,1],[13,10,1,2],[13,10,2,3],[13,11,1,1],[13,11,1,2],[13,12,1,1],[13,12,3,2],[13,13,1,1],[13,14,1,1],[13,14,2,1],[13,15,1,1],[13,15,2,1],[13,16,1,1],[13,16,2,1],[13,17,1,1],[13,18,1,1],[13,18,2,1],[13,19,1,1],[14,0,1,1],[14,1,2,3],[14,2,1,1],[14,4,2,2],[14,4,2,3],[14,5,1,1],[14,5,1,2],[14,5,2,2],[14,6,2,1],[14,6,2,2],[14,7,1,1],[14,7,2,2],[14,7,2,3],[14,7,3,2],[14,8,1,1],[14,8,1,2],[14,8,2,1],[14,8,2,2],[14,9,1,1],[14,9,2,1],[14,9,2,2],[14,9,2,4],[14,11,1,1],[14,11,1,2],[14,12,1,1],[14,13,1,1],[14,14,1,1],[14,14,1,2],[14,14,2,1],[14,15,1,1],[14,16,1,1],[14,17,1,1],[14,18,1,1],[15,0,1,1],[15,0,1,6],[15,0,1,7],[15,1,1,1],[15,1,1,6],[15,2,1,1],[15,3,1,1],[15,3,1,5],[15,4,1,5],[15,4,2,3],[15,5,1,1],[15,5,2,3],[15,6,1,1],[15,6,1,2],[15,6,1,3],[15,6,2,2],[15,6,3,2],[15,7,1,1],[15,7,1,2],[15,7,2,2],[15,7,2,3],[15,8,1,1],[15,8,1,2],[15,8,1,5],[15,8,2,2],[15,8,2,3],[15,9,1,2],[15,9,2,2],[15,10,1,2],[15,10,2,2],[15,11,1,1],[15,11,2,2],[15,12,1,1],[15,12,1,2],[15,13,1,1],[15,13,1,2],[15,14,1,1],[15,15,1,1],[15,17,1,1],[15,18,1,1],[16,0,1,1],[16,2,1,1],[16,3,1,1],[16,3,1,2],[16,4,1,1],[16,5,1,1],[16,6,1,1],[16,7,1,1],[16,8,1,1],[16,8,1,2],[16,8,2,3],[16,9,1,1],[16,9,1,2],[16,10,1,2],[16,11,1,1],[16,11,1,2],[16,11,2,2],[16,12,1,1],[16,12,1,2],[16,13,1,1],[16,14,1,1],[16,15,1,1],[16,16,1,1],[16,17,1,1],[17,0,1,1],[17,0,1,3],[17,2,1,1],[17,3,1,1],[17,5,1,1],[17,6,1,1],[17,6,1,3],[17,7,1,1],[17,7,1,2],[17,7,1,3],[17,8,1,1],[17,10,1,1],[17,11,1,1],[17,12,1,1],[17,13,1,1],[17,14,1,1],[17,16,1,1],[18,0,1,1],[18,1,1,1],[18,2,1,1],[18,3,1,1],[18,4,1,1],[18,4,1,2],[18,5,1,1],[18,6,1,1],[18,7,1,1],[18,8,1,2],[18,9,1,1],[18,10,1,1],[18,16,1,1],[19,0,1,1],[19,1,1,2],[19,2,1,1],[19,3,1,1],[19,4,1,1],[19,5,1,1],[19,6,1,1],[19,8,1,1],[19,9,1,1],[19,10,1,1],[19,11,1,1],[19,12,1,1],[20,0,1,1],[20,1,1,1],[20,2,1,1],[20,3,1,1],[20,3,1,3],[20,4,1,1],[20,5,1,1],[20,7,1,1],[20,8,1,1],[20,8,1,2],[20,9,1,1],[20,11,1,2],[21,0,1,1],[21,0,1,4],[21,1,1,1],[21,2,1,1],[21,3,1,1],[21,3,1,2],[21,4,1,1],[21,5,1,1],[21,6,1,1],[21,7,1,1],[21,8,1,1],[21,9,1,1],[21,10,1,1]]} \ No newline at end of file diff --git a/app/public/animeface.js b/app/public/animeface.js new file mode 100644 index 0000000..d2fff29 --- /dev/null +++ b/app/public/animeface.js @@ -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 }) } +} diff --git a/app/public/app.js b/app/public/app.js new file mode 100644 index 0000000..7b1ec7e --- /dev/null +++ b/app/public/app.js @@ -0,0 +1,4585 @@ +const app = document.getElementById('app') +// Iconos SVG en línea (trazo estilo Lucide), heredan el color del texto. +const ICONS = { + home: '', + history: '', + download: '', + settings: '', + play: '', + book: '', + stopRead: '', + stop: '', + trash: '', + refresh: '', + search: '', + sort: '', + back: '', + prev: '', + next: '', + more: '', + check: '', + login: '', + logout: '', + userPlus: '', + flame: '', + clock: '', + grid: '', + wifiOff: '', + globe: '', + key: '', + bell: '', + bellOff: '', + chart: '', + zoomOut: '', + send: '', + dots: '', + x: '', + scroll: '', + pages: '', + scissors: '', + image: '', + queue: '', + library: '', + bookmark: '', + shelf: '', + phone: '', + plus: '', + pencil: '', + sparkles: '', +} +const icon = name => `` +// Iconos del menú superior (index.html). +document.querySelectorAll('#topbar nav a[data-icon]').forEach(a => { a.innerHTML = `${icon(a.dataset.icon)}${a.textContent}` }) + +const SOURCES = { lc: 'LeerCapitulo (español)', mi: 'M440 (español)', md: 'MangaDex (multi-idioma)', gb: 'Project Gutenberg (libros)', ws: 'Wikisource (libros en español)', ub: 'Mis libros (EPUB subidos)' } +const NO_CATS = new Set(['mi', 'gb', 'ws', 'ub']) // fuentes sin listado por categorías +const BOOK_SOURCES = new Set(['gb', 'ws', 'ub']) +const isBookId = id => /^(gb|ws|ub)~/.test(id || '') +// Ajuste del usuario: ver solo manga, solo libros o ambos. Solo filtra lo que se ofrece; un enlace directo sigue abriendo. +const contentMode = () => me?.content_mode || 'both' +const wantsId = id => contentMode() === 'both' || (contentMode() === 'books') === isBookId(id) +const visibleSources = () => Object.entries(SOURCES).filter(([k]) => contentMode() === 'both' || (contentMode() === 'books') === BOOK_SOURCES.has(k)) +// Filtros de imagen del lector (CSS filter); el brillo se suma aparte. +const FILTERS = { + none: ['Sin filtro', ''], + sepia: ['Sepia', 'sepia(.65) contrast(.95)'], + paper: ['Papel cálido', 'sepia(.35) saturate(1.15) hue-rotate(-8deg) brightness(.95)'], + vintage: ['Vintage', 'sepia(.45) contrast(1.1) saturate(.75)'], + night: ['Noche (invertido)', 'invert(.88) hue-rotate(180deg) contrast(.95)'], + gray: ['Blanco y negro', 'grayscale(1)'], + contrast: ['Alto contraste', 'contrast(1.35) brightness(1.03)'], + vivid: ['Colores vivos', 'saturate(1.45) contrast(1.05)'], + // Sin filtro CSS: una capa ámbar en modo multiplicar sobre todo el lector (.reader.bluelight), que + // quita azul a todo lo que hay en pantalla, también durante las animaciones. + blue: ['Filtro de luz azul', ''], +} +const DL_ACTIVE = ['queued', 'downloading'] +const plural = (n, w) => `${n} ${w}${n === 1 ? '' : 's'}` +const fmtDur = sec => sec < 90 ? '≈ 1 min' : sec < 3600 ? `≈ ${Math.round(sec / 60)} min` : `≈ ${Math.floor(sec / 3600)} h ${Math.round((sec % 3600) / 60)} min` +const numKey = n => { const f = parseFloat(n); return Number.isFinite(f) ? String(f) : null } +const fmtDate = d => { + const t = new Date(`${String(d).slice(0, 10)}T12:00:00`) + return Number.isNaN(t.getTime()) ? '' : t.toLocaleDateString('es', { day: 'numeric', month: 'short', year: 'numeric' }) +} +const fmtSize = b => (b >= 1e9 ? `${(b / 1e9).toFixed(1)} GB` : b >= 1e6 ? `${Math.round(b / 1e6)} MB` : `${Math.round((b || 0) / 1e3)} KB`) +// Progreso global contando la fracción del capítulo en curso. +function downloadPct(d) { + if (!d?.total) return 0 + const frac = d.current?.pagesTotal ? d.current.pages / d.current.pagesTotal : 0 + return Math.min(100, ((d.done + frac) / d.total) * 100) +} +function downloadDetail(d) { + const c = d?.current + if (!c) return d?.status === 'queued' ? 'Esperando turno…' : '' + return c.pagesTotal ? `Cap. ${esc(c.chapter ?? '?')} · pág. ${c.pages}/${c.pagesTotal}` : `Cap. ${esc(c.chapter ?? '?')} · preparando…` +} +function downloadLabel(d) { + if (!d) return '' + const n = `${d.done}/${d.total || '?'}` + return { + queued: 'En cola…', + downloading: `Descargando ${n}`, + done: `Descargado (${d.done} caps.)`, + error: `Descarga incompleta ${n}`, + cancelled: `Descarga cancelada ${n}`, + }[d.status] || d.status +} +const LANGS = { 'es-la': 'Español (Latam)', es: 'Español (España)', en: 'English', 'pt-br': 'Português (BR)', fr: 'Français', it: 'Italiano', ja: '日本語' } +let me = null +let cleanup = null + +// ---------- Utilidades ---------- +const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])) +const $ = (sel, root = document) => root.querySelector(sel) +// Portadas que no cargan: icono de la app (la CSP no permite onerror="…" en el HTML). +document.addEventListener('error', e => { + const img = e.target + if (img.tagName !== 'IMG' || !img.hasAttribute('data-fallback') || img.dataset.failed) return + img.dataset.failed = '1' + img.src = '/icon.svg' +}, true) +const store = { + get(k, d) { try { return JSON.parse(localStorage.getItem(k)) ?? d } catch { return d } }, + set(k, v) { try { localStorage.setItem(k, JSON.stringify(v)) } catch {} }, +} +function toast(msg) { + const t = document.getElementById('toast') + t.textContent = msg + t.hidden = false + clearTimeout(toast.t) + toast.t = setTimeout(() => (t.hidden = true), 2500) +} +const NO_SERVER = 'Sin conexión con el servidor' +async function api(path, opts = {}) { + // Si tarda, se comprueba si el servidor sigue ahí (una fuente lenta no es motivo para cortar, un + // servidor que no contesta sí): así nada se queda en «Cargando…» para siempre sin red. + const ctrl = new AbortController() + const watchdog = setTimeout(async () => { if (!(await pingServer())) ctrl.abort() }, 2500) + let res + try { + res = await fetch(path, { + ...opts, + signal: ctrl.signal, + headers: opts.body ? { 'Content-Type': 'application/json' } : undefined, + body: opts.body ? JSON.stringify(opts.body) : undefined, + }) + } catch { + setServerReachable(false) + throw new Error(NO_SERVER) + } finally { + clearTimeout(watchdog) + } + // Respuesta de la copia del service worker: el servidor no contestó a tiempo; comprobar el estado. + if (res.headers.get('x-from-cache')) refreshNet() + else if (!serverReachable) setServerReachable(true) + const data = await res.json().catch(() => ({})) + if (res.status === 401 && !path.startsWith('/api/login')) { me = null; location.hash = '#/login'; throw new Error('No autenticado') } + // 503: el servidor ya sabe que no hay internet. 502: una fuente falló; el servidor tarda unos segundos en confirmarlo. + if (res.status === 503) refreshNet() + else if (res.status === 502) setTimeout(refreshNet, 7000) + if (!res.ok) throw new Error(data.error || `Error ${res.status}`) + return data +} + +// ---------- Estado de la conexión ---------- +// Dos casos distintos, con su aviso arriba: +// - serverReachable = false: no se llega al servidor (sin red, servidor caído). Se lee lo guardado en +// este dispositivo; la portada lo muestra. Al volver, se envía el progreso pendiente. +// - serverOnline = false: el servidor contesta pero él no tiene internet. Se lee lo descargado en el +// servidor; la portada lo muestra. +// Se comprueba cada 20 s (cada 10 s sin servidor) y al volver a la pestaña. +let serverOnline = true +let serverReachable = true +let netChecked = false +const isHome = () => !(location.hash.slice(1).split('?')[0] || '/').split('/').filter(Boolean).length +async function pingServer() { + const r = await fetch('/api/status', { cache: 'no-store', signal: AbortSignal.timeout(3000) }).catch(() => null) + return r ? (r.ok ? await r.json().catch(() => ({ online: true })) : { online: true }) : null +} +function netBanner() { + let b = $('#netbanner') + const msg = !serverReachable + ? `${icon('wifiOff')}${NO_SERVER}. ${OFFLINE_OK ? 'Puedes leer lo guardado en este dispositivo; ' : ''}se reconecta solo.` + : !serverOnline ? `${icon('wifiOff')}El servidor no tiene internet. Puedes leer lo descargado en el servidor; se reconecta solo.` : '' + if (!msg) return b?.remove() + if (!b) { b = document.createElement('div'); b.id = 'netbanner'; $('#topbar').after(b) } + if (b.innerHTML !== msg) b.innerHTML = msg +} +function setServerReachable(ok) { + if (ok === serverReachable) return + serverReachable = ok + netBanner() + if (ok) { flushProgress(); refreshNet() } + if (isHome()) router() +} +async function refreshNet() { + const st = await pingServer() + netChecked = true + if (!st) return setServerReachable(false) + const changed = !serverReachable || st.online !== serverOnline + serverOnline = st.online !== false + if (!serverReachable) return setServerReachable(true) // ya redibuja y vuelve a llamar + netBanner() + if (changed && isHome()) router() +} +setInterval(() => { if (serverReachable) refreshNet() }, 20e3) +setInterval(() => { if (!serverReachable) refreshNet() }, 10e3) +document.addEventListener('visibilitychange', () => { if (!document.hidden) refreshNet() }) +function timeAgo(ts) { + const s = (Date.now() - ts) / 1000 + if (s < 60) return 'hace un momento' + if (s < 3600) return `hace ${Math.floor(s / 60)} min` + if (s < 86400) return `hace ${Math.floor(s / 3600)} h` + return `hace ${Math.floor(s / 86400)} d` +} +const chapLabel = c => (c.num ? `Cap. ${c.num}` : 'Oneshot') + (c.title ? ` — ${c.title}` : '') +const readUrl = (mangaId, chapterId, page) => `#/read/${mangaId}/${chapterId}${page != null ? `?page=${page + 1}` : ''}` + +// ---------- Sin conexión (capítulos guardados en este dispositivo) ---------- +// Requiere service worker, que el navegador solo permite con HTTPS (o en localhost). +const OFFLINE_OK = 'serviceWorker' in navigator && window.isSecureContext && 'caches' in window +const OFF_CACHE = 'offline-v1' +// Con versión en la URL: si un proxy guarda sw.js en caché, cada versión nueva tiene otra URL. +if (OFFLINE_OK) navigator.serviceWorker.register(`/sw.js?v=${document.querySelector('meta[name="sw-version"]')?.content || ''}`).catch(() => {}) +const offIdx = () => store.get('offlineChapters', {}) // chapterId -> { mangaId, mangaTitle, cover, lang, label, num, pages, bytes, at } +const offHas = id => !!offIdx()[id] +const offState = { running: null, queue: [], listeners: new Set(), cancelled: new Set(), abort: null } +const offEmit = () => offState.listeners.forEach(f => { try { f() } catch {} }) +// Capítulos guardados agrupados por manga. +function offByManga() { + const g = new Map() + for (const [chapterId, c] of Object.entries(offIdx())) { + const m = g.get(c.mangaId) || { mangaId: c.mangaId, title: c.mangaTitle, cover: c.cover, chapters: [], bytes: 0, at: 0 } + m.chapters.push({ chapterId, ...c }); m.bytes += c.bytes || 0; m.at = Math.max(m.at, c.at) + g.set(c.mangaId, m) + } + return [...g.values()].sort((a, b) => b.at - a.at) +} +// items: [{ mangaId, mangaTitle, cover, lang, chapterId, label, num }] +function offEnqueue(items) { + for (const it of items) if (!offHas(it.chapterId) && !offState.queue.some(q => q.chapterId === it.chapterId) && offState.running?.chapterId !== it.chapterId) offState.queue.push(it) + navigator.storage?.persist?.().catch(() => {}) + offEmit() + offRun() +} +async function offRun() { + if (offState.running) return + while (offState.queue.length) { + const it = offState.queue.shift() + offState.running = { ...it, done: 0, total: 0 } + offEmit() + try { await offSaveChapter(it) } catch (err) { if (!offState.cancelled.has(it.chapterId)) toast(`No se pudo guardar ${it.label}: ${err.message}`) } + offState.cancelled.delete(it.chapterId) + } + offState.running = null + offEmit() +} +// Cancela lo que quede por guardar de un manga (o todo): lo pendiente sale de la cola y el capítulo en +// curso se interrumpe y se borra lo que ya se había guardado de él. +function offCancel(mangaId = null) { + offState.queue = offState.queue.filter(q => mangaId && q.mangaId !== mangaId) + if (offState.running && (!mangaId || offState.running.mangaId === mangaId)) { + offState.cancelled.add(offState.running.chapterId) + offState.abort?.abort() // corta las páginas que se estén bajando + } + offEmit() +} +async function offSaveChapter(it) { + const cache = await caches.open(OFF_CACHE) + const saved = [] + offState.abort = new AbortController() + try { await offSaveInto(cache, it, saved, offState.abort.signal) } catch (err) { + await Promise.all(saved.map(u => cache.delete(u, { ignoreSearch: false }))) // nada de capítulos a medias + throw err + } +} +async function offSaveInto(cache, it, saved, signal) { + const stop = () => { if (offState.cancelled.has(it.chapterId)) throw new Error('cancelado') } + const hd = store.get('readerSettings', {}).hd !== false + const chapUrl = `/api/chapter/${it.chapterId}?saver=0&hd=${hd ? 1 : 0}` + const res = await fetch(chapUrl, { signal }) + if (!res.ok) throw new Error(`Error ${res.status}`) + const data = await res.clone().json() + if (data.text) { + // Libro: lo que se guarda es su texto y sus imágenes; la lista queda en la copia, para poder borrarlos después. + const textUrl = `/api/book/${it.mangaId}/text` + const tres = await fetch(textUrl, { signal }) + if (!tres.ok) throw new Error(`Error ${tres.status}`) + const text = await tres.json() + const images = new Set(text.chapters.flatMap(c => c.blocks).filter(b => b.k === 'img').map(b => `/api/book/${it.mangaId}/img/${b.n}`)) + data.pages = [textUrl, ...images] + await cache.put(chapUrl, new Response(JSON.stringify(data), { headers: { 'Content-Type': 'application/json' } })) + } else await cache.put(chapUrl, res) + saved.push(chapUrl) + const mUrl = `/api/manga/${it.mangaId}?lang=${encodeURIComponent(it.lang || '')}` + const mres = await fetch(mUrl).catch(() => null) + if (mres?.ok) await cache.put(mUrl, mres) + if (it.cover) { const c = await fetch(it.cover).catch(() => null); if (c?.ok) await cache.put(it.cover, c) } + offState.running.total = data.pages.length + let bytes = 0, i = 0 + const worker = async () => { + while (i < data.pages.length) { + stop() + const u = data.pages[i++] + // Hasta 3 intentos por página (las fuentes fallan a ratos), cada uno con tiempo límite. + let blob, type + for (let t = 1; ; t++) { + try { + const r = await fetch(u, { signal: AbortSignal.any([signal, AbortSignal.timeout(90e3)]) }) + if (!r.ok) throw new Error(`página ${r.status}`) + type = r.headers.get('Content-Type') + blob = await r.blob() + break + } catch (err) { + stop() + if (t >= 3) throw err + await new Promise(res => setTimeout(res, 2000 * t)) + } + } + bytes += blob.size + stop() + await cache.put(u, new Response(blob, { headers: { 'Content-Type': type || 'image/jpeg' } })) + saved.push(u) + offState.running.done++ + offEmit() + } + } + await Promise.all([worker(), worker(), worker()]) + stop() + const idx = offIdx() + idx[it.chapterId] = { mangaId: it.mangaId, mangaTitle: it.mangaTitle, cover: it.cover, lang: it.lang, label: it.label, num: it.num ?? null, pages: data.pages.length, bytes, at: Date.now() } + store.set('offlineChapters', idx) +} +async function offRemove(chapterIds) { + const cache = await caches.open(OFF_CACHE) + const idx = offIdx() + for (const id of chapterIds) { + const hit = await cache.match(`/api/chapter/${id}`, { ignoreSearch: true }) + if (hit) { + const data = await hit.json().catch(() => ({ pages: [] })) + await Promise.all((data.pages || []).map(u => cache.delete(u))) + await cache.delete(`/api/chapter/${id}`, { ignoreSearch: true }) + } + const mangaId = idx[id]?.mangaId + delete idx[id] + if (mangaId && !Object.values(idx).some(c => c.mangaId === mangaId)) await cache.delete(`/api/manga/${mangaId}`, { ignoreSearch: true }) + } + store.set('offlineChapters', idx) + offEmit() +} + +// Progreso leído sin conexión: se guarda aquí y se envía al volver la red. +function queueProgress(p) { const q = store.get('pendingProgress', {}); q[p.mangaId] = p; store.set('pendingProgress', q) } +async function flushProgress() { + const q = store.get('pendingProgress', {}) + for (const [k, p] of Object.entries(q)) { + try { await api('/api/progress', { method: 'POST', body: p }); delete q[k] } catch { if (!navigator.onLine) break; delete q[k] } // con red y error: descartarlo + } + store.set('pendingProgress', q) +} +window.addEventListener('online', flushProgress) +setTimeout(flushProgress, 3000) +// Instalar como app (Chrome/Edge/Android ofrecen el aviso; se guarda para el botón de Ajustes). +let installPrompt = null +window.addEventListener('beforeinstallprompt', e => { e.preventDefault(); installPrompt = e }) + +// ---------- Router ---------- +let prevHash = '', curHash = '' // para volver atrás sin pasar por el lector +let shelfTrip = null // { mangaId } mientras se lee algo abierto desde el estante: libro 3D y vuelta al estante +async function router() { + if (location.hash !== curHash) { prevHash = curHash; curHash = location.hash } + if (cleanup) { cleanup(); cleanup = null } + document.body.classList.remove('reading') + const [pathPart, query] = location.hash.slice(1).split('?') + const parts = (pathPart || '/').split('/').filter(Boolean) + const params = new URLSearchParams(query || '') + if (parts[0] !== 'read' && parts[0] !== 'shelf') shelfTrip = null + document.querySelectorAll('#topbar nav a').forEach(a => a.classList.toggle('active', a.getAttribute('href') === `#/${parts[0] ?? ''}`)) + + if (!me) { + const [r] = await Promise.all([api('/api/me').catch(() => ({})), netChecked ? null : refreshNet()]) + me = r.user + me && (me.signup = r.signup) + if (!me) return viewAuth(r.signup) + } + try { + if (parts[0] === 'login') return (location.hash = '#/') + if (parts[0] === 'manga') return await viewManga(parts[1]) + if (parts[0] === 'info') return (location.hash = `#/manga/${parts[1]}`) // la ficha ya incluye la info + if (parts[0] === 'read') return await viewReader(parts[1], parts[2], Number(params.get('page') || 0) - 1) + if (parts[0] === 'history') return (location.hash = '#/library') // antiguo nombre + if (parts[0] === 'library') return await viewLibrary(params) + if (parts[0] === 'shelf') return await viewShelf(params) + if (parts[0] === 'downloads') return (location.hash = '#/queues') // antiguo nombre + if (parts[0] === 'queues') return await viewQueues() + if (parts[0] === 'stats') return await viewStats() + if (parts[0] === 'clips') return await viewClips(params) + if (parts[0] === 'settings') return viewSettings() + return await viewHome(params) + } catch (e) { + if (e.message !== 'No autenticado') app.innerHTML = `

${esc(e.message)}

` + } +} +window.addEventListener('hashchange', router) +router() + +// ---------- Login / registro ---------- +function viewAuth(signup) { + app.innerHTML = ` +
+

Manga Reader

+

Inicia sesión para leer y guardar tu progreso.

+
+ + +

+ + ${signup ? `` : ''} +
+
` + const form = $('form', app) + form.addEventListener('submit', async ev => { + ev.preventDefault() + const mode = ev.submitter?.value === 'register' ? 'register' : 'login' + const fd = new FormData(form) + try { + await api(`/api/${mode}`, { method: 'POST', body: { username: fd.get('username'), password: fd.get('password') } }) + me = null + location.hash = '#/' + router() + } catch (e) { $('.error', form).textContent = e.message } + }) +} + +// ---------- Inicio ---------- +function mangaCard(m) { + return `
+ +
${esc(m.title)}
${m.sub ? `
${esc(m.sub)}
` : ''}
` +} +function continueCard(h) { + const pct = h.total_pages ? Math.round(((h.page + 1) / h.total_pages) * 100) : 0 + // Terminado con capítulos nuevos: a la ficha, para elegir el siguiente. + return ` + +
${esc(h.manga_title)} +
${h.chapter_num ? `Cap. ${esc(h.chapter_num)}` : 'Oneshot'} · pág. ${h.page + 1}/${h.total_pages || '?'}
+ ${h.finished ? '
Terminado
' : h.hasNew ? '
Caps. nuevos
' : ''} +
${timeAgo(h.updated_at)}
+
` +} +// Sección "Novedades": mangas seguidos con capítulos nuevos. +function newsSection(fol) { + fol = { ...fol, follows: fol.follows.filter(f => wantsId(f.manga_id)) } + const fresh = fol.follows.filter(f => f.newCount > 0) + const status = fol.checking ? 'Comprobando…' : fol.lastCheckAt ? `Última comprobación ${timeAgo(fol.lastCheckAt)}` : `Se comprueba cada ${fol.intervalHours} h` + if (!fol.follows.length) return '' + return `
+

${icon('sparkles')} Novedades ${fresh.length ? `${fresh.reduce((n, f) => n + f.newCount, 0)}` : ''}

+ Sigues ${fol.follows.length} manga${fol.follows.length === 1 ? '' : 's'} · ${status} +
+ ${fresh.length ? `` + : '

No hay capítulos nuevos en los mangas que sigues.

'}` +} +function bindNews() { + $('#checknow')?.addEventListener('click', async e => { + e.target.closest('button').disabled = true + await api('/api/follows/check', { method: 'POST' }) + toast('Comprobando capítulos nuevos…') + // Refrescar la sección mientras dura la comprobación. + const poll = async () => { + const fol = await api('/api/follows').catch(() => null) + if (!fol || !$('#news')) return + $('#news').innerHTML = newsSection(fol) + bindNews() + if (fol.checking) setTimeout(poll, 3000) + else toast('Comprobación terminada') + } + setTimeout(poll, 2000) + }) +} +const tagHref = (source, t) => `#/?source=${source}&tag=${encodeURIComponent(t.id)}&tagName=${encodeURIComponent(t.name)}` +// Recomendaciones según los géneros que más lees (las calcula el servidor, caché de 6 h). +async function loadRecs(refresh = false) { + if (!$('#recs')) return + const r = await api(`/api/recommendations${refresh ? '?refresh=1' : ''}`).catch(() => null) + // La portada pudo redibujarse mientras tanto: usar la sección actual. + const box = $('#recs') + if (!box) return + if (!r?.items?.length) { box.innerHTML = ''; return } + box.innerHTML = `

${icon('sparkles')} Recomendado para ti

+ Según lo que más lees: ${r.genres.map(g => `${esc(g.name)}`).join(', ')} +
+
${r.items.map(mangaCard).join('')}
` + $('#recref', box).addEventListener('click', e => { e.currentTarget.disabled = true; loadRecs(true) }) +} +// Portada sin internet en el servidor: lo descargado en el servidor, con buscador local. +async function viewHomeOffline(params) { + app.innerHTML = ` +
+

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

+ +
Cargando…
` + const [all, hist] = await Promise.all([api('/api/search?q='), api('/api/history').catch(() => [])]) + const list = all.filter(m => wantsId(m.id)) + const have = new Set(list.map(m => m.id)) + const cont = hist.filter(h => !h.finished && have.has(h.manga_id)) + if (cont.length) $('#cont').innerHTML = `

Continuar leyendo

${cont.slice(0, 12).map(continueCard).join('')}
` + const res = $('#results') + const fold = t => String(t).toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '') + const render = () => { + const words = fold($('[name=q]').value).split(/\s+/).filter(Boolean) + const shown = list.filter(m => words.every(w => fold(m.title).includes(w))) + res.className = shown.length ? 'grid' : 'loading' + res.innerHTML = shown.length ? shown.map(mangaCard).join('') : list.length ? 'Nada descargado con ese nombre.' : 'No hay nada descargado en el servidor todavía.' + } + $('form.search').addEventListener('submit', e => e.preventDefault()) + $('[name=q]').addEventListener('input', render) + render() +} + +// Portada sin conexión con el servidor: lo guardado en este dispositivo. +function viewHomeDevice() { + const dev = OFFLINE_OK ? offByManga() : [] + app.innerHTML = `

${icon('phone')} En este dispositivo

` + (dev.length + ? `
${dev.map(m => mangaCard({ id: m.mangaId, title: m.title, cover: m.cover, sub: `${plural(m.chapters.length, 'capítulo')} guardado${m.chapters.length === 1 ? '' : 's'}` })).join('')}
` + : `

${OFFLINE_OK ? 'No hay capítulos guardados en este dispositivo. Con conexión, usa «Guardar en el dispositivo» en la ficha o en el lector para leer sin internet.' + : 'Leer sin conexión necesita abrir la app por HTTPS.'}

`) +} + +async function viewHome(params) { + if (!serverReachable) return viewHomeDevice() + if (!serverOnline) return viewHomeOffline(params) + const q = params.get('q') || '' + const tag = params.get('tag') || '' + const tagName = params.get('tagName') || tag + const anyLang = params.get('any') === '1' + const shownSources = visibleSources() + const isShown = k => shownSources.some(([s]) => s === k) + const source = isShown(params.get('source')) ? params.get('source') : isShown(store.get('source', 'lc')) ? store.get('source', 'lc') : shownSources[0][0] + let mode = tag ? 'cats' : params.get('mode') || 'popular' + if (mode === 'cats' && NO_CATS.has(source)) mode = 'popular' + store.set('source', source) + const showCats = mode === 'cats' && !tag + app.innerHTML = ` +
+
+
${shownSources.map(([k, v]) => ``).join('')}
+ + + ${source === 'ub' ? `
Solo tú puedes verlos. Máximo 100 MB por libro.
` : ''} + ${source === 'ub' ? (q ? `

Resultados para “${esc(q)}”

` : '') : q && !tag ? `

Resultados para “${esc(q)}”

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

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

` : ''} +
Cargando…
+
` + const form = $('form', app) + const go = (extra = {}) => { + const p = new URLSearchParams({ source, ...(form.q.value ? { q: form.q.value } : { mode }), ...(tag ? { tag, tagName } : {}), ...($('[name=any]').checked ? { any: '1' } : {}), ...extra }) + location.hash = `#/?${p}` + } + form.addEventListener('submit', e => { e.preventDefault(); go() }) + $('[name=any]').addEventListener('change', () => go()) + app.querySelectorAll('#modes button').forEach(b => b.addEventListener('click', () => { location.hash = `#/?source=${source}&mode=${b.dataset.m}${anyLang ? '&any=1' : ''}` })) + app.querySelectorAll('#sources button').forEach(b => b.addEventListener('click', () => { location.hash = `#/?source=${b.dataset.s}&mode=${mode}${anyLang ? '&any=1' : ''}` })) + + if (!q && !tag && !showCats) { + Promise.all([api('/api/history'), api('/api/follows')]).then(([hist, fol]) => { + if (!$('#cont')) return + $('#cont').innerHTML = `
${newsSection(fol)}
` + (hist = hist.filter(h => !h.finished && wantsId(h.manga_id)), hist.length ? `

Continuar leyendo

${hist.slice(0, 12).map(continueCard).join('')}
` : '') + bindNews(fol) + }).catch(() => {}) + if (contentMode() !== 'books') loadRecs() // las recomendaciones son de manga + } + + $('#ubfile')?.addEventListener('change', async e => { + const files = [...e.target.files] + const label = $('#ubpick') + label.classList.add('disabled') + let added = 0 + for (const [i, f] of files.entries()) { + toast(`Subiendo ${files.length > 1 ? `${i + 1} de ${files.length}: ` : ''}${f.name}…`) + try { + const r = await fetch('/api/books', { method: 'POST', headers: { 'Content-Type': 'application/epub+zip' }, body: f }) + const d = await r.json().catch(() => ({})) + if (!r.ok) throw new Error(d.error || `Error ${r.status}`) + added++ + } catch (err) { toast(`${f.name}: ${err.message}`) } + } + label.classList.remove('disabled') + if (added) { toast(added === 1 ? 'Libro añadido' : `${added} libros añadidos`); router() } + }) + const res = $('#results') + if (showCats) { + const cats = await api(`/api/categories?source=${source}`) + const groups = cats.reduce((m, c) => m.set(c.group, [...(m.get(c.group) || []), c]), new Map()) + res.className = 'cats' + res.innerHTML = [...groups].map(([g, list]) => `

${esc(g)}

${list.map(t => `${esc(t.name)}`).join('')}
`).join('') + return + } + + // Paginación: MangaDex siempre; LeerCapitulo solo en categorías; M440 en "Actualizados" (sus otros listados no paginan). + const pageable = source === 'md' || source === 'gb' || source === 'ws' || !!tag || (source === 'mi' && mode === 'latest' && !q) + const seen = new Set() + let page = 1 + const load = async () => { + const qs = new URLSearchParams({ q, mode, source, page, ...(tag ? { tag } : {}), ...(anyLang ? { anyLang: '1' } : {}) }) + const list = (await api(`/api/search?${qs}`)).filter(m => !seen.has(m.id) && seen.add(m.id)) + if (page === 1) { + res.className = list.length ? 'grid' : 'loading' + res.innerHTML = list.length ? '' : source === 'ub' && !q ? 'Aún no has subido ningún libro. Pulsa «Subir EPUB» para añadir el primero.' : 'Sin resultados.' + } + res.insertAdjacentHTML('beforeend', list.map(mangaCard).join('')) + $('#more').hidden = !pageable || list.length < 20 + } + $('#more').addEventListener('click', async e => { + e.target.disabled = true + page++ + try { await load() } catch (err) { toast(err.message) } + e.target.disabled = false + }) + await load() +} + +// ---------- Ficha del manga ---------- +// ---------- Datos de la obra de AniList y MangaUpdates (parte de la ficha) ---------- +const AL_FORMAT = { MANGA: 'Manga', NOVEL: 'Novela ligera', ONE_SHOT: 'Oneshot', Manga: 'Manga', Manhwa: 'Manhwa', Manhua: 'Manhua' } +const AL_STATUS = { FINISHED: 'Terminado', RELEASING: 'En publicación', NOT_YET_RELEASED: 'Sin estrenar', CANCELLED: 'Cancelado', HIATUS: 'En pausa' } +const AL_COUNTRY = { JP: 'Japón', KR: 'Corea del Sur', CN: 'China', TW: 'Taiwán' } +const AL_ROLE = r => r.replace(/\bchs?\b/g, 'caps.').replace(/^Story & Art/i, 'Historia y dibujo').replace(/^Original Creator/i, 'Creador original').replace(/^Original Story/i, 'Historia original').replace(/^Story/i, 'Historia').replace(/^Art/i, 'Dibujo') +const AL_REL = { SEQUEL: 'Secuela', PREQUEL: 'Precuela', SIDE_STORY: 'Historia paralela', SPIN_OFF: 'Spin-off', ALTERNATIVE: 'Versión alternativa', PARENT: 'Obra principal', ADAPTATION: 'Adaptación', SOURCE: 'Original', CHARACTER: 'Personajes', SUMMARY: 'Resumen', COMPILATION: 'Recopilación', CONTAINS: 'Contiene', OTHER: 'Relacionado' } +const AL_ANIME = { TV: 'Serie TV', TV_SHORT: 'Serie corta', MOVIE: 'Película', OVA: 'OVA', ONA: 'ONA', SPECIAL: 'Especial', MUSIC: 'Vídeo musical' } +const fmtFuzzy = d => (!d ? '' : d.length === 4 ? d : d.length === 7 ? new Date(`${d}-15T12:00:00`).toLocaleDateString('es', { month: 'long', year: 'numeric' }) : fmtDate(d)) + +// Lo que la ficha pinta a partir de la información guardada de la obra (d.found). +function infoParts(d, manga) { + const row = (k, v) => (v ? `
${k}
${v}
` : '') + const t = d.titles + const people = d.staff.reduce((m, p) => m.set(p.name, [...(m.get(p.name) || []), AL_ROLE(p.role)]), new Map()) + const dates = d.start ? `${fmtFuzzy(d.start)}${d.end ? ` – ${fmtFuzzy(d.end)}` : d.status === 'RELEASING' ? ' – actualidad' : ''}` : '' + const size = [d.volumes ? `${d.volumes} tomos` : '', d.chapters ? `${d.chapters} capítulos` : d.latestChapter ? `${d.latestChapter} capítulos (hasta ahora)` : ''].filter(Boolean).join(' · ') + const ranks = d.rankings.map(r => `#${r.rank} ${r.type === 'RATED' ? 'mejor valorado' : 'más popular'}${r.allTime ? ' de siempre' : ` de ${r.year}`}`) + const cardOf = r => ` + +
${esc(r.title)}
${esc([r.relation ? AL_REL[r.relation] || r.relation : '', r.year || '', r.score ? `${r.score}%` : ''].filter(Boolean).join(' · '))}
` + const seen = new Set([manga.title.toLowerCase()]) + const altTitles = [t.english, t.romaji, t.native].filter(x => x && !seen.has(x.toLowerCase()) && seen.add(x.toLowerCase())) + const srcDesc = (manga.description || '').trim() + return { + banner: d.banner, + sub: altTitles.join(' · '), + scores: `
+ ${d.score != null ? `
${d.score}%AniList
` : ''} + ${d.muScore != null ? `
${Number(d.muScore).toFixed(1)}MangaUpdates${d.muVotes ? ` · ${fmtNum(d.muVotes)} votos` : ''}
` : ''} + ${d.popularity ? `
${fmtNum(d.popularity)}lectores en AniList
` : ''} +
+ ${ranks.length ? `
${ranks.map(r => `${esc(r)}`).join('')}
` : ''}`, + kv: `${people.size ? [...people].map(([name, roles]) => row(esc(roles.join(' y ')), esc(name))).join('') : row('Autor', esc(manga.authors.join(', ')))} + ${row('Revista', d.magazines.map(m => esc(m.name) + (m.publisher ? ` (${esc(m.publisher)})` : '')).join(', '))} + ${row('Editorial', d.publishers.filter(p => p.type === 'Original').map(p => esc(p.name)).join(', '))} + ${row('En inglés', d.publishers.filter(p => p.type === 'English').map(p => esc(p.name)).join(', '))} + ${row('Tipo', esc([AL_FORMAT[d.format] || d.format, AL_COUNTRY[d.country]].filter(Boolean).join(' · ')))} + ${row('Publicación', esc(dates || manga.year || ''))} + ${row('Estado', esc([AL_STATUS[d.status], d.statusDetail].filter(Boolean).join(' · ') || manga.status || ''))} + ${row('Extensión', esc(size))} + ${row('Anime', [...d.adaptations.map(a => esc(`${AL_ANIME[a.format] || a.format || 'Anime'}${a.year ? ` (${a.year})` : ''}`)), d.anime ? `adapta: ${esc(d.anime.start)}${d.anime.end ? ` → ${esc(d.anime.end)}` : ''}` : ''].filter(Boolean).join('
'))}`, + genres: d.genres.map(g => `${esc(g)}`).join(''), + softTags: d.tags.map(g => `${esc(g)}`).join(''), + synopsis: `${srcDesc ? `

Sinopsis

${esc(srcDesc)}

` : ''} + ${d.description ? (srcDesc ? `
Sinopsis de AniList (en inglés)

${esc(d.description)}

` : `

Sinopsis (en inglés)

${esc(d.description)}

`) : ''}`, + extra: `${t.other.length ? `

Otros títulos

${t.other.map(esc).join(', ')}

` : ''} + ${d.related.length ? `

Obras relacionadas

${d.related.map(cardOf).join('')}
` : ''} + ${d.recommendations.length ? `

Si te gusta, prueba

${d.recommendations.map(cardOf).join('')}
` : ''} + ${d.links.length ? `

Enlaces

${d.links.map(l => `${esc(l.site)}`).join('')}
` : ''} +

Datos de AniList y MangaUpdates${d.matched?.title ? ` (identificado por título como «${esc(d.matched.title)}»)` : ''}. Las fechas de los capítulos son las de la primera traducción conocida.

`, + } +} + +async function viewManga(id, langOverride) { + if (cleanup) { cleanup(); cleanup = null } + app.innerHTML = '
Cargando…
' + const lang = langOverride || store.get(`lang:${id}`, me.lang) + const { manga, chapters, reads, progress, chapterLangs, offline, downloaded, hideCredits: creditsOn, download, upscale, following, clips: clipCount = 0, lists: inLists = [], newKeys = [], info: savedInfo = null, clipBanner = null } = await api(`/api/manga/${id}?lang=${encodeURIComponent(lang)}`) + const dlSet = new Set(downloaded) + const newSet = new Set(newKeys.map(String)) + let isFollowing = following + const readMap = new Map(reads.map(r => [r.chapter_id, r])) + const isLc = manga.source !== 'md' // sitios en español sin selector de idioma + const isBook = isBookId(id) + const langOpts = [...new Set([lang, me.lang, ...chapterLangs, ...manga.languages])].filter(Boolean) + const altSearch = `#/?source=lc&q=${encodeURIComponent(manga.title)}` + const altSearchMi = `#/?source=mi&q=${encodeURIComponent(manga.title)}` + const first = chapters[0] + let mainBtn = '' + if (progress && chapters.some(c => c.id === progress.chapter_id)) { + mainBtn = `${icon('play')} Continuar · ${progress.chapter_num ? `Cap. ${esc(progress.chapter_num)}` : 'Oneshot'}, pág. ${progress.page + 1}` + } else if (progress) { + mainBtn = `${icon('play')} Continuar · Cap. ${esc(progress.chapter_num ?? '?')} (${esc(LANGS[progress.lang] || progress.lang || '')}), pág. ${progress.page + 1}` + } + if (first && !mainBtn) mainBtn = `${icon('book')} Empezar a leer` + + const menuItems = [ + manga.source === 'lc' || isBook ? '' : `${icon('search')} Buscar en LeerCapitulo`, + manga.source === 'mi' || isBook ? '' : `${icon('search')} Buscar en M440`, + manga.source === 'ub' ? `` : '', + progress || reads.length ? `` : '', + ].join('') + + const srcTags = manga.tags.map(t => (t.id ? `${esc(t.name)}` : `${esc(t.name ?? t)}`)).join('') + const srcSub = `${esc(manga.authors.join(', '))}${manga.year ? ` · ${manga.year}` : ''}${manga.status ? ` · ${esc(manga.status)}` : ''}` + app.innerHTML = ` + +
+ +
+

${esc(manga.title)}

+
${srcSub}
+
+
${mainBtn} + ${chapters.length ? `` : ''} + ${SHELF_OK && !isBook && !/^(manhwa|manhua|webtoon)$/i.test(manga.type || '') ? `${icon('shelf')} Estante` : ''} + +
+ ${offline ? '

Sin conexión con la fuente: mostrando solo los capítulos descargados.

' : ''} +
+
+
+
+
+ + +
+

Capítulos (${chapters.length})${newSet.size ? ` ${newSet.size} ${newSet.size === 1 ? 'nuevo' : 'nuevos'}` : ''}

+
+ ${isLc ? '' : ``} + ${chapters.length > 1 ? `` : ''} +
+
+ ${chapters.length ? '
    ' : isLc ? '

    Este manga no tiene capítulos.

    ' : `

    No hay capítulos legibles en ${esc(LANGS[lang] || lang)} (MangaDex retira algunos por licencia). + Prueba a buscarlo en LeerCapitulo o M440. + ${chapterLangs.length ? `Disponibles en: ${chapterLangs.map(l => `${esc(LANGS[l] || l)}`).join(' ')}` : 'No hay capítulos en ningún idioma.'}

    `} + ` + + // --- Información de la obra: la guardada llega con la ficha; si aún no hay, se pide y se pinta al llegar. + const synBox = $('#msyn') + const paintInfo = d => { + if (!synBox.isConnected) return // ya se cambió de página + const parts = d?.found ? infoParts(d, manga) : null + const banner = $('#mbanner') + // El de AniList (&b=1: reescalado) o, si no tiene, uno de tus recortes; sin ninguno, nada. + const bannerUrl = parts?.banner ? `${parts.banner}&b=1` : clipBanner + banner.hidden = !bannerUrl + banner.style.backgroundImage = bannerUrl ? `url("${bannerUrl}")` : '' + if (!manga.cover && d?.cover) $('#mcover').src = d.cover + $('#msub').innerHTML = parts ? esc(parts.sub) : srcSub + $('#mscores').innerHTML = parts?.scores || '' + const folder = download ? `
    Carpeta
    mangas/${esc(download.folder)}
    ` : '' + $('#mkv').innerHTML = (parts?.kv || '') + folder + $('#mdata').hidden = !parts && !folder + $('#mtags').innerHTML = (srcTags || parts?.genres || '') + (parts?.softTags || '') + const srcDesc = (manga.description || '').trim() + synBox.innerHTML = parts?.synopsis || (srcDesc ? `

    Sinopsis

    ${esc(srcDesc)}

    ` : '') + $('#mextra').innerHTML = parts?.extra || (d ? `

    No se encontró esta obra en AniList ni en MangaUpdates. Puede tener otro título allí: prueba a buscarla en + AniList.

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

    Buscando información de la obra…

    ' + api(`/api/meta/${id}`).then(paintInfo, () => { if (synBox.isConnected) $('#mextra').innerHTML = '' }) + } + + // --- Menú lateral (botón ⋯): seguir, listas, recortes, otras fuentes, copia local, reescalado HD, este dispositivo y datos de la obra. + const openLib = open => { + $('#libdrawer').classList.toggle('open', open) + $('#libdrawer').inert = !open + $('#libback').hidden = !open + } + // El ⋯ avisa con un punto si hay algo en curso (descarga, reescalado o guardado en el dispositivo). + const libBusy = {} + const markLib = (kind, busy) => { + libBusy[kind] = busy + $('#libbtn').innerHTML = `${icon('dots')}${Object.values(libBusy).some(Boolean) ? ' ' : ''}` + } + const toChapters = () => $('.list-head')?.scrollIntoView({ behavior: 'smooth' }) + $('#libbtn').addEventListener('click', () => openLib(true)) + $('#libclose').addEventListener('click', () => openLib(false)) + $('#libback').addEventListener('click', () => openLib(false)) + $('#tochap')?.addEventListener('click', toChapters) + const onLibKey = e => { if (e.key === 'Escape') openLib(false) } + document.addEventListener('keydown', onLibKey) + + const secHead = (ic, title, status) => `
    ${icon(ic)} ${title}
    ${status ? `
    ${status}
    ` : ''}
    ` + + // --- Selector de capítulos compartido por "Copia local" (kind 'dl') y "Reescalado HD" (kind 'hd'): + // todo lo pendiente (solo HD), un rango o una selección hecha en la lista de abajo. + let wantedSet = new Set(download?.wanted || []) + let hdSet = new Set(upscale?.chapters || []), hdJobs = upscale?.jobs || {} + let hdInfo = upscale + let sel = null // null | { kind: 'dl' | 'hd', mode: 'pending' | 'range' | 'pick' } + const picked = new Set() + let lastPick = null + const KINDS = { + dl: { + verb: 'Descargar', ic: 'download', modes: { range: 'Rango', pick: 'Elegir' }, + // Sin descargar y sin estar ya en cola. + candidates: () => chapters.filter(c => !dlSet.has(c.id) && !wantedSet.has(c.id)), + rangeBase: () => chapters, + mark: c => (dlSet.has(c.id) ? ' (descargado)' : ''), + summary: ids => { + const pages = ids.reduce((n, cid) => n + (chapters.find(c => c.id === cid)?.pages || 0), 0) + return plural(ids.length, 'capítulo') + (pages ? ` · ${pages} págs.` : '') + }, + offTitle: c => (dlSet.has(c.id) ? 'Ya descargado' : 'Ya está en cola'), + run: async ids => { + renderDl(await api(`/api/download/${id}`, { method: 'POST', body: { lang, title: manga.title, chapterIds: ids } })) + toast(`${plural(ids.length, 'capítulo')} en cola de descarga`) + }, + }, + hd: { + verb: 'Reescalar', ic: 'sparkles', modes: { pending: 'Pendientes', range: 'Rango', pick: 'Elegir' }, + // Descargados, sin HD y sin estar ya en cola (los fallidos sí, para reintentar). + candidates: () => chapters.filter(c => dlSet.has(c.id) && !hdSet.has(c.id) && (!hdJobs[c.id] || hdJobs[c.id] === 'error')), + rangeBase: () => chapters.filter(c => dlSet.has(c.id)), + mark: c => (hdSet.has(c.id) ? ' (HD)' : ''), + summary: ids => { + const pages = ids.reduce((n, cid) => n + (hdInfo?.pages?.[cid] || 0), 0) + return `${plural(ids.length, 'capítulo')} · ${pages} págs. · ${fmtDur(pages * (hdInfo?.secPerPage || 13))}` + }, + offTitle: c => (!dlSet.has(c.id) ? 'No está descargado' : 'Ya está en HD o en cola'), + run: async ids => { + renderHd(await api(`/api/upscale/${id}`, { method: 'POST', body: { chapterIds: ids } })) + toast(`${plural(ids.length, 'capítulo')} en cola para HD`) + }, + }, + } + if (OFFLINE_OK) KINDS.off = { + verb: 'Guardar', ic: 'phone', modes: { range: 'Rango', pick: 'Elegir' }, + candidates: () => chapters.filter(c => !offHas(c.id) && !offState.queue.some(q => q.chapterId === c.id) && offState.running?.chapterId !== c.id), + rangeBase: () => chapters, + mark: c => (offHas(c.id) ? ' (en el dispositivo)' : ''), + summary: ids => plural(ids.length, 'capítulo'), + offTitle: () => 'Ya está en el dispositivo', + run: async ids => { + offEnqueue(ids.map(cid => { const c = chapters.find(x => x.id === cid); return { mangaId: id, mangaTitle: manga.title, cover: manga.cover, lang, chapterId: cid, label: chapLabel(c), num: c.num } })) + toast(`Guardando ${plural(ids.length, 'capítulo')} en este dispositivo`) + }, + } + const selIds = () => { + if (!sel) return [] + const K = KINDS[sel.kind] + const cand = K.candidates() + if (sel.mode === 'pending') return cand.map(c => c.id) + if (sel.mode === 'pick') return cand.filter(c => picked.has(c.id)).map(c => c.id) + const base = K.rangeBase() + let a = base.findIndex(c => c.id === $('#selfrom')?.value), b = base.findIndex(c => c.id === $('#selto')?.value) + if (a < 0 || b < 0) return [] + if (a > b) [a, b] = [b, a] + const inRange = new Set(base.slice(a, b + 1).map(c => c.id)) + return cand.filter(c => inRange.has(c.id)).map(c => c.id) + } + const updateSummary = () => { + const ids = selIds() + const K = sel && KINDS[sel.kind] + if ($('#selsum')) $('#selsum').textContent = ids.length ? K.summary(ids) : sel?.mode === 'pick' ? 'Marca capítulos en la lista de abajo.' : 'No hay capítulos pendientes en esa selección.' + if ($('#selgo')) $('#selgo').disabled = !ids.length + const bar = $('#selbar') + if (!bar) return + bar.hidden = sel?.mode !== 'pick' + if (sel?.mode !== 'pick') return + bar.innerHTML = `${ids.length ? K.summary(ids) : 'Ningún capítulo marcado'} + + ` + $('#selrun').addEventListener('click', runSel) + $('#selexit').addEventListener('click', () => setSel(null)) + } + async function runSel() { + const ids = selIds() + if (!ids.length) return + const K = KINDS[sel.kind] + try { + if (sel.kind === 'hd' && sel.mode === 'pending' && $('#hdautoform') && $('#hdautoform').checked !== !!hdInfo?.auto) { + await api(`/api/upscale/${id}/auto`, { method: 'POST', body: { auto: $('#hdautoform').checked } }).catch(() => {}) + } + picked.clear() + setSel(null) + await K.run(ids) + } catch (err) { toast(err.message) } + } + function setSel(next) { + const wasPick = sel?.mode === 'pick', prevKind = sel?.kind + if (next?.kind !== prevKind) picked.clear() + sel = next + if (prevKind && prevKind !== next?.kind) renderSelForm(prevKind) // cerrar el otro formulario + if (next) renderSelForm(next.kind) + if (wasPick !== (next?.mode === 'pick') || (wasPick && prevKind !== next?.kind)) renderList() + if (next?.mode === 'pick' && !wasPick) { openLib(false); toChapters() } // se marca en la lista: hay que verla + $('#dlopen')?.classList.toggle('on', sel?.kind === 'dl') + $('#hdopen')?.classList.toggle('on', sel?.kind === 'hd') + $('#offopen')?.classList.toggle('on', sel?.kind === 'off') + updateSummary() + } + function renderSelForm(kind) { + const box = $(`#${kind}form`) + if (!box) return + const open = sel?.kind === kind + box.hidden = !open + if (!open) { box.innerHTML = ''; return } + const K = KINDS[kind] + const base = K.rangeBase(), cand = K.candidates() + const opt = (c, on) => `` + const from = cand[0] || base[0], to = cand[Math.min(cand.length, 10) - 1] || base.at(-1) + box.innerHTML = ` +
    ${Object.entries(K.modes).map(([m, label]) => ``).join('')}
    + ${sel.mode === 'range' ? `
    +
    ` : ''} + ${sel.mode === 'pick' ? '
    Marca los capítulos en la lista de abajo. Mayús + clic marca un tramo entero.
    ' : ''} + ${sel.mode === 'pending' ? `
    Todos los capítulos descargados que aún no están en HD.
    + ` : ''} +
    +
    ` + box.querySelectorAll('[data-sm]').forEach(b => b.addEventListener('click', () => setSel({ kind, mode: b.dataset.sm }))) + $('#selfrom')?.addEventListener('change', updateSummary) + $('#selto')?.addEventListener('change', updateSummary) + $('#selclose').addEventListener('click', () => setSel(null)) + $('#selgo').addEventListener('click', runSel) + } + // Tras un sondeo: repintar lo que dependa de los conjuntos y cerrar el selector si ya no queda nada. + const refreshSel = () => { + renderList() + if (sel && sel.mode !== 'pick') renderSelForm(sel.kind) + if (sel && !KINDS[sel.kind].candidates().length) setSel(null) + else updateSummary() + } + + // --- Copia local (descarga) con sondeo del progreso mientras está activa. + let dlTimer = null + const renderDl = d => { + const box = $('#dl') + if (!box) return + const active = d && DL_ACTIVE.includes(d.status) + markLib('dl', !!active) + const some = d?.scope === 'some' + const partial = d && d.done < d.total + const bar = d && (active || partial) ? `
    ` : '' + const canPick = !offline && chapters.some(c => !dlSet.has(c.id) && !wantedSet.has(c.id) && !(d?.wanted || []).includes(c.id)) + const pickBtn = label => (canPick ? `` : '') + const delBtn = d ? `` : '' + let status = '', body = '', actions = '' + if (!d) { + body = '
    Guarda capítulos en CBZ en el servidor para leerlos sin conexión (y poder reescalarlos a HD). Puedes bajar el manga entero o solo algunos capítulos.
    ' + actions = `${pickBtn('Elegir capítulos…')}` + } else if (active) { + status = `${downloadLabel(d)} · ${Math.floor(downloadPct(d))}%` + body = `${bar}
    ${downloadDetail(d)}
    ` + actions = `${pickBtn('Añadir capítulos…')}${delBtn}` + } else { + const have = dlSet.size + status = d.status === 'done' + ? `${icon('check')} ${some ? `${have} de ${chapters.length}` : have} capítulos${d.size ? ` · ${fmtSize(d.size)}` : ''}` + : downloadLabel(d) + body = bar + (d.status === 'error' && d.error ? `
    ${esc(d.error)}
    ` : '') + + (some && d.status === 'done' ? '
    Solo capítulos elegidos: los nuevos no se descargan solos.
    ' : '') + const resume = d.status === 'error' ? 'Reintentar' : d.status === 'cancelled' ? 'Continuar descarga' : null + actions = some + ? `${resume ? `` : ''}${pickBtn('Descargar más…')}${delBtn}` + : `${delBtn}` + } + if (!$('#dltop')) box.innerHTML = '
    ' + $('#dltop').innerHTML = secHead('download', 'Copia local', status) + body + (offline ? '' : `
    ${actions}
    `) + $('#dlgo')?.addEventListener('click', async () => { + // Reanudar respeta el modo (todo o capítulos elegidos). + renderDl(await api(`/api/download/${id}`, { method: 'POST', body: { lang, title: manga.title, ...(some ? { chapterIds: d.wanted?.length ? d.wanted : [] } : {}) } }).catch(err => (toast(err.message), d))) + }) + $('#dlall')?.addEventListener('click', async () => { + const missing = chapters.filter(c => !dlSet.has(c.id)).length + if (d && !confirm(`¿Descargar todo «${manga.title}»?\nSe bajarán ${plural(missing, 'capítulo')} que faltan y, en adelante, los nuevos.`)) return + renderDl(await api(`/api/download/${id}`, { method: 'POST', body: { lang, title: manga.title } })) + toast(`Descargando en mangas/${manga.title}`) + }) + $('#dlopen')?.addEventListener('click', () => setSel(sel?.kind === 'dl' ? null : { kind: 'dl', mode: 'range' })) + $('#dlcancel')?.addEventListener('click', async () => renderDl(await api(`/api/download/${id}/cancel`, { method: 'POST' }))) + $('#dldel')?.addEventListener('click', async () => { + if (!confirm(d.shared ? `¿Quitar «${manga.title}» de tu biblioteca descargada?\nOtro usuario también lo tiene: los archivos se quedan para él y lo puedes seguir leyendo.` + : `¿Eliminar la copia local de «${manga.title}»?\nSe borrará la carpeta mangas/${d.folder}${d.size ? ` (${fmtSize(d.size)})` : ''}, incluida la versión HD.\nTu historial de lectura no se toca.`)) return + const r = await api(`/api/download/${id}`, { method: 'DELETE' }) + toast(r.kept ? 'Quitado de tu biblioteca' : 'Copia local eliminada') + viewManga(id, langOverride) + }) + const wantedBefore = wantedSet.size + wantedSet = new Set(d?.wanted || []) + if (wantedSet.size !== wantedBefore) refreshSel() + clearTimeout(dlTimer) + if (active) dlTimer = setTimeout(async () => { + const next = await api(`/api/download/${id}`).catch(() => d) + if (!$('#dl')) return + if (next && next.done !== d.done) { + const fresh = await api(`/api/manga/${id}?lang=${encodeURIComponent(lang)}`).catch(() => null) + fresh?.downloaded.forEach(x => dlSet.add(x)) + if (fresh?.download) next.size = fresh.download.size + if (fresh?.upscale) renderHd(fresh.upscale) + refreshSel() + } + renderDl(next) + }, 2000) + } + + // --- Reescalado HD de lo descargado. + let hdTimer = null + const renderHd = u => { + const box = $('#hd') + if (!box) return + clearTimeout(hdTimer) + hdInfo = u + const show = !!(u?.available && u.downloaded && !offline) + box.hidden = !show + markLib('hd', show && (!!u.current || Object.values(u.jobs).some(s => s !== 'error'))) + if (show) { + const queued = Object.values(u.jobs).filter(s => s !== 'error').length + const active = !!u.current || queued > 0 + const c = u.current + const status = `${u.hd} de ${u.downloaded} capítulos${u.size ? ` · ${fmtSize(u.size)}` : ''}` + const remaining = Object.entries(u.jobs).filter(([, st]) => st !== 'error').map(([cid]) => cid) + let body = '' + if (active) { + const pct = c?.total ? (c.pages / c.total) * 100 : 0 + body = `
    +
    ${c ? `Cap. ${esc(c.num ?? '?')} · pág. ${c.pages}/${c.total}` : 'Esperando turno…'}${queued > 1 ? ` · ${queued - 1} más en cola` : ''} · faltan ${fmtDur(remaining.reduce((n, cid) => n + (u.pages?.[cid] || 0), 0) * u.secPerPage - (c?.pages || 0) * u.secPerPage)}
    ` + } else if (!u.hd) { + body = `
    Mejora la nitidez con IA (Real-ESRGAN ×2). Unos ${u.secPerPage} s por página; el original no se toca.
    ` + } + if (u.error) body += `
    Falló algún capítulo: ${esc(u.error)}
    ` + const canAdd = KINDS.hd.candidates().length > 0 + const actions = [ + canAdd ? `` : '', + active ? `` : '', + u.hd ? `` : '', + ].join('') + // Los formularios se conservan entre sondeos: solo se repinta la parte de arriba. + if (!$('#hdtop')) box.innerHTML = '
    ' + $('#hdtop').innerHTML = secHead('sparkles', 'Reescalado HD', status) + body + (actions ? `
    ${actions}
    ` : '') + + (u.auto ? '
    Los capítulos que se descarguen también se reescalan.
    ' : '') + $('#hdopen')?.addEventListener('click', () => setSel(sel?.kind === 'hd' ? null : { kind: 'hd', mode: 'pending' })) + $('#hdcancel')?.addEventListener('click', async () => { + if (!confirm('¿Detener el reescalado?\nSe vacía la cola de este manga; lo ya reescalado se queda.')) return + renderHd(await api(`/api/upscale/${id}/cancel`, { method: 'POST' })) + }) + $('#hddel')?.addEventListener('click', async () => { + if (!confirm(`¿Quitar la versión HD de «${manga.title}»?\nSe borra mangas/${download?.folder ?? ''}/.hd${u.size ? ` (${fmtSize(u.size)})` : ''}; los capítulos originales se quedan.`)) return + renderHd(await api(`/api/upscale/${id}`, { method: 'DELETE' })) + toast('Versión HD eliminada') + }) + if (active) hdTimer = setTimeout(async () => { const next = await api(`/api/upscale/${id}`).catch(() => null); if (next && $('#hd')) renderHd(next) }, 3000) + } + const changed = (u?.chapters?.length ?? 0) !== hdSet.size || JSON.stringify(u?.jobs || {}) !== JSON.stringify(hdJobs) + hdSet = new Set(u?.chapters || []); hdJobs = u?.jobs || {} + if (changed) refreshSel() + } + + // --- En este dispositivo (sin conexión). + const renderOff = () => { + const box = $('#off') + if (!box || !OFFLINE_OK) return + const mine = Object.entries(offIdx()).filter(([, c]) => c.mangaId === id) + const bytes = mine.reduce((n, [, c]) => n + (c.bytes || 0), 0) + const run = offState.running?.mangaId === id ? offState.running : null + const queued = offState.queue.filter(q => q.mangaId === id).length + markLib('off', !!(run || queued)) + box.hidden = false + if (isBook) { + // Un libro se guarda entero: el texto de todos sus capítulos y sus imágenes. + const status = mine.length ? `${icon('check')} Guardado · ${fmtSize(bytes)}` : '' + const note = run ? `Guardando… ${run.done}/${run.total || '?'}` : queued ? 'En cola…' : mine.length ? 'Se puede leer sin internet.' : 'Guarda el libro en este móvil o PC para leerlo sin internet.' + box.innerHTML = `${secHead('phone', 'En este dispositivo', status)}
    ${note}
    + ${mine.length ? `` : run || queued ? '' : ``}
    ` + $('#offbook')?.addEventListener('click', () => offEnqueue([{ mangaId: id, mangaTitle: manga.title, cover: manga.cover, lang: manga.languages?.[0] || 'es', chapterId: chapters[0].id, label: 'Libro completo', num: null }])) + $('#offdel')?.addEventListener('click', async () => { + if (!confirm(`¿Borrar «${manga.title}» de este dispositivo (${fmtSize(bytes)})?`)) return + await offRemove(mine.map(([cid]) => cid)) + toast('Borrado del dispositivo') + }) + return + } + // Siguientes 3 sin guardar a partir de donde vas (o desde el principio). + const from = Math.max(0, chapters.findIndex(c => c.id === progress?.chapter_id)) + const nextIds = chapters.slice(from).filter(c => !offHas(c.id)).slice(0, 3) + let body = '' + if (run || queued) { + const pct = run?.total ? (run.done / run.total) * 100 : 0 + body = `
    +
    ${run ? `${esc(run.label)} · pág. ${run.done}/${run.total || '?'}` : 'Esperando…'}${queued ? ` · ${queued} más` : ''}
    ` + } else if (!mine.length) body = '
    Guarda capítulos en este móvil o PC para leerlos sin internet (también los no descargados en el servidor).
    ' + const actions = [ + nextIds.length && !run ? `` : '', + KINDS.off.candidates().length ? `` : '', + mine.length ? `` : '', + ].join('') + if (!$('#offtop')) box.innerHTML = '
    ' + $('#offtop').innerHTML = secHead('phone', 'En este dispositivo', mine.length ? `${plural(mine.length, 'capítulo')} · ${fmtSize(bytes)}` : '') + body + `
    ${actions}
    ` + $('#offnext')?.addEventListener('click', () => KINDS.off.run(nextIds.map(c => c.id))) + $('#offopen')?.addEventListener('click', () => setSel(sel?.kind === 'off' ? null : { kind: 'off', mode: 'range' })) + $('#offdel')?.addEventListener('click', async () => { + if (!confirm(`¿Borrar de este dispositivo los ${plural(mine.length, 'capítulo')} guardados de «${manga.title}» (${fmtSize(bytes)})?`)) return + await offRemove(mine.map(([cid]) => cid)) + toast('Borrado del dispositivo') + }) + } + let offRefreshAt = 0 + const onOff = () => { + if (!$('#off')) return + renderOff() + // La lista y el selector solo cuando cambia qué está guardado (no en cada página descargada). + const n = Object.keys(offIdx()).length + offState.queue.length + (offState.running ? 1 : 0) + if (n !== offRefreshAt) { offRefreshAt = n; refreshSel() } + } + if (OFFLINE_OK) offState.listeners.add(onOff) + + // --- Listas propias (menú "Listas"). + let listSet = new Set(inLists) + const renderListsBtn = () => { + const b = $('#lbtn') + if (!b) return + b.classList.toggle('on', listSet.size > 0) + b.innerHTML = `${icon('bookmark')} ${listSet.size ? `En ${plural(listSet.size, 'lista')}` : 'Listas'}` + } + renderListsBtn() + async function renderListsMenu() { + const box = $('#lmlist') + if (!box) return + const lists = await api('/api/lists').catch(() => []) + box.innerHTML = lists.map(l => ``).join('') + + `${icon('library')} Ver mi librería` + box.querySelectorAll('[data-list]').forEach(x => x.addEventListener('change', async () => { + const lid = Number(x.dataset.list) + try { + const r = x.checked + ? await api(`/api/lists/${lid}/items`, { method: 'POST', body: { mangaId: id, title: manga.title, cover: manga.cover } }) + : await api(`/api/lists/${lid}/items/${encodeURIComponent(id)}`, { method: 'DELETE' }) + listSet = new Set(r.lists) + renderListsBtn() + } catch (err) { toast(err.message); x.checked = !x.checked } + })) + $('#lnew').addEventListener('click', async () => { + const name = prompt('Nombre de la nueva lista:') + if (!name?.trim()) return + try { + const l = await api('/api/lists', { method: 'POST', body: { name } }) + const r = await api(`/api/lists/${l.id}/items`, { method: 'POST', body: { mangaId: id, title: manga.title, cover: manga.cover } }) + listSet = new Set(r.lists) + renderListsBtn() + renderListsMenu() + toast(`Añadido a «${l.name}»`) + } catch (err) { toast(err.message) } + }) + } + $('#lmenu')?.addEventListener('toggle', e => { if (e.target.open) renderListsMenu() }) + + const renderFollow = () => { + const b = $('#follow') + b.innerHTML = isFollowing ? `${icon('bell')} Siguiendo` : `${icon('bellOff')} Seguir` + b.classList.toggle('on', isFollowing) + b.title = isFollowing ? 'Recibes avisos de capítulos nuevos. Pulsa para dejar de seguir.' : 'Avisarme cuando salgan capítulos nuevos' + b.hidden = offline || isBook + } + renderFollow() + // Solo con tomos: es donde se ocultan. Manhwa y manhua no tienen, y se leen como siempre. + let hideCredits = !!creditsOn + function renderCredits() { + const b = $('#hidecred') + if (!b) return + b.innerHTML = `${icon(hideCredits ? 'check' : 'scissors')} Ocultar créditos` + b.classList.toggle('on', hideCredits) + b.setAttribute('aria-pressed', hideCredits) + b.title = 'Al leer por tomos, salta las páginas de créditos repetidas (la primera y las 2 últimas de cada capítulo). No se borra nada.' + b.hidden = offline || !vols.length + } + $('#hidecred').addEventListener('click', async () => { + const r = await api(`/api/credits/${id}/option`, { method: 'POST', body: { on: !hideCredits } }) + hideCredits = r.hideCredits + renderCredits() + toast(hideCredits ? 'Se ocultarán los créditos al leer por tomos' : 'Se mostrarán todas las páginas') + }) + $('#follow').addEventListener('click', async () => { + const r = await api(`/api/follow/${id}`, isFollowing ? { method: 'DELETE' } : { method: 'POST', body: { title: manga.title, cover: manga.cover, lang } }) + isFollowing = r.following + renderFollow() + toast(isFollowing ? 'Te avisaremos de los capítulos nuevos' : 'Ya no sigues este manga') + }) + $('#ubdel')?.addEventListener('click', async () => { + if (!confirm(`¿Eliminar «${manga.title}»?\nSe borrará el archivo del servidor y tu progreso de lectura.`)) return + try { + await api(`/api/books/${id}`, { method: 'DELETE' }) + if (OFFLINE_OK) await offRemove(Object.entries(offIdx()).filter(([, c]) => c.mangaId === id).map(([cid]) => cid)) + toast('Libro eliminado') + location.hash = '#/?source=ub' + } catch (err) { toast(err.message) } + }) + $('#drop')?.addEventListener('click', async () => { + if (!confirm(`¿Dejar de leer «${manga.title}»?\nSe quitará del historial, se borrará el progreso de sus capítulos y dejarás de seguirlo.`)) return + await api(`/api/history/${id}`, { method: 'DELETE' }) + toast('Quitado del historial') + // Volver a donde se estaba (salvo el lector o la propia ficha, que lo volverían a marcar) o al inicio. + const back = prevHash && !prevHash.startsWith('#/read/') && !prevHash.startsWith(`#/manga/${id}`) && prevHash !== '#/login' + location.hash = back ? prevHash : '#/' + }) + // Cerrar el desplegable de listas al pulsar fuera. + const closeMenu = e => app.querySelectorAll('.menu[open]').forEach(menu => { if (!menu.contains(e.target)) menu.open = false }) + document.addEventListener('click', closeMenu) + cleanup = () => { clearTimeout(dlTimer); clearTimeout(hdTimer); document.removeEventListener('click', closeMenu); document.removeEventListener('keydown', onLibKey); offState.listeners.delete(onOff) } + app.querySelectorAll('[data-lang]').forEach(a => a.addEventListener('click', e => { e.preventDefault(); store.set(`lang:${id}`, a.dataset.lang); viewManga(id, a.dataset.lang) })) + + const ul = $('.chapters', app) + let desc = store.get('chapterOrderDesc', true) + const chapMeta = (c, r, done) => (newSet.has(String(c.num ?? c.id)) ? 'NUEVO ' : '') + + (dlSet.has(c.id) ? `${icon('download')} ` : wantedSet.has(c.id) ? `${icon('download')}… ` : '') + + (hdSet.has(c.id) ? 'HD ' + : hdJobs[c.id] ? `HD${hdJobs[c.id] === 'error' ? '!' : '…'} ` : '') + + (OFFLINE_OK && offHas(c.id) ? `${icon('phone')} ` : '') + + [r ? (done ? 'Leído' : `pág. ${r.page + 1}/${r.total_pages}`) : manga.source === 'md' ? esc(c.group) : '', chapDate(c)].filter(Boolean).join(' · ') + // Fecha de cada capítulo: la primera publicación conocida (MangaDex/MangaUpdates, se pide aparte) o, si + // no, la de la fuente, salvo que sea la de una subida masiva (la misma fecha en muchos capítulos). + let extDates = {} + const srcDatesOk = (() => { + const ds = chapters.map(c => c.publishAt?.slice(0, 10)).filter(Boolean) + const n = {} + for (const d of ds) n[d] = (n[d] || 0) + 1 + return ds.length < 8 || Math.max(...Object.values(n)) / ds.length <= 0.25 + })() + // Fechas por capítulo: { d, approx } por id. Un capítulo no puede ser posterior al siguiente (las + // traducciones a veces llegan desordenadas) y los huecos entre dos fechas conocidas se estiman. + let dateMap = new Map() + function buildDates() { + const DAY = 864e5 + const known = chapters.map(c => { + const d = extDates[numKey(c.num)] || (srcDatesOk ? c.publishAt?.slice(0, 10) : null) + const t = d ? Date.parse(`${d}T12:00:00Z`) : NaN + return { c, n: parseFloat(c.num), t: Number.isNaN(t) ? null : t } + }).filter(x => Number.isFinite(x.n)).sort((a, b) => a.n - b.n) + let min = Infinity + for (let i = known.length - 1; i >= 0; i--) if (known[i].t != null) { min = Math.min(min, known[i].t); known[i].t = min } + dateMap = new Map() + let prev = null + known.forEach((x, i) => { + if (x.t == null) return + dateMap.set(x.c.id, { t: x.t, approx: false }) + if (prev && i - prev.i > 1 && i - prev.i <= 150) { + for (let k = prev.i + 1; k < i; k++) { + const f = (known[k].n - prev.n) / (x.n - prev.n) + dateMap.set(known[k].c.id, { t: Math.round((prev.t + f * (x.t - prev.t)) / DAY) * DAY, approx: true }) + } + } + prev = { i, n: x.n, t: x.t } + }) + } + buildDates() + const chapDate = c => { + const e = dateMap.get(c.id) + if (!e) return '' + const d = new Date(e.t).toISOString().slice(0, 10) + return `${e.approx ? '≈ ' : ''}${fmtDate(d)}` + } + // Tomos: llegan aparte, para no hacer esperar a la ficha. Los plegados se recuerdan mientras se está en ella. + let vols = [], volOf = new Map(), folded = new Set() + if (!isBook) api(`/api/volumes/${id}?lang=${encodeURIComponent(lang)}`).then(r => { + if (!r.volumes?.length || !ul?.isConnected) return + vols = r.volumes + volOf = new Map(vols.flatMap(v => v.chapters.map(c => [c, v]))) + const open = volOf.get(progress?.chapter_id) || vols[0] + folded = new Set(vols.filter(v => v !== open).map(v => v.vol)) + renderList() + renderCredits() + }, () => {}) + const volHead = v => { + if (!v) return '
  • Sin tomo
  • ' + const inVol = chapters.filter(c => volOf.get(c.id) === v) + const isDone = c => { const r = readMap.get(c.id); return r && r.total_pages && r.page >= r.total_pages - 1 } + const read = inVol.filter(isDone).length, from = inVol.find(c => !isDone(c)) || inVol[0], r = readMap.get(from.id) + return `
  • ${icon('book')} Leer tomo
  • ` + } + ul?.addEventListener('click', e => { + const b = e.target.closest('button.voltoggle') + if (!b) return + folded.has(b.dataset.vol) ? folded.delete(b.dataset.vol) : folded.add(b.dataset.vol) + renderList() + }) + function renderList() { + if (!ul) return + const list = desc ? [...chapters].reverse() : chapters + const picking = sel?.mode === 'pick' + pickable = new Set(picking ? KINDS[sel.kind].candidates().map(c => c.id) : []) + ul.classList.toggle('picking', picking) + // Cada tomo, junto: si un capítulo quedó en medio de otros, no se repite la cabecera. + const seen = new Set() + const grouped = !vols.length ? list : list.flatMap(c => { + const v = volOf.get(c.id) + if (!v) return [c] + if (seen.has(v)) return [] + seen.add(v) + return list.filter(x => volOf.get(x.id) === v) + }) + let curVol + ul.innerHTML = grouped.map((c, i) => { + const v = volOf.get(c.id) || null + const head = vols.length && v !== curVol ? volHead(v) : '' + curVol = v + if (v && !picking && folded.has(v.vol)) return head + return head + chapterItem(c, i) + }).join('') + } + let pickable = new Set() + function chapterItem(c, i) { + const picking = sel?.mode === 'pick' + const r = readMap.get(c.id) + const done = r && r.total_pages && r.page >= r.total_pages - 1 + if (picking) { + const ok = pickable.has(c.id) + return `
  • ` + } + const cls = progress?.chapter_id === c.id ? 'current' : done ? 'read' : '' + return `
  • ${esc(chapLabel(c))}${chapMeta(c, r, done)}
  • ` + } + // Selección en la lista (Mayús + clic marca el tramo desde el último marcado). + ul?.addEventListener('click', e => { + const box = e.target.closest('input[data-cid]') + if (!box) return + const i = Number(box.dataset.i) + if (e.shiftKey && lastPick != null) { + const [a, b] = [Math.min(lastPick, i), Math.max(lastPick, i)] + ul.querySelectorAll('input[data-cid]:not(:disabled)').forEach(x => { + const k = Number(x.dataset.i) + if (k >= a && k <= b) { x.checked = box.checked; box.checked ? picked.add(x.dataset.cid) : picked.delete(x.dataset.cid) } + }) + } else box.checked ? picked.add(box.dataset.cid) : picked.delete(box.dataset.cid) + lastPick = i + updateSummary() + }) + renderList() + if (!isBook) api(`/api/meta/${id}/dates`).then(r => { extDates = r.dates || {}; if (Object.keys(extDates).length) { buildDates(); renderList() } }, () => {}) + renderDl(download) + renderHd(upscale) + renderOff() + $('#order')?.addEventListener('click', () => { desc = !desc; lastPick = null; store.set('chapterOrderDesc', desc); renderList() }) + $('#lang')?.addEventListener('change', e => { store.set(`lang:${id}`, e.target.value); viewManga(id, e.target.value) }) +} + +// ---------- Lector ---------- +const B3D_OK = matchMedia('(pointer: coarse)').matches && 'WebGL2RenderingContext' in window +const READ_MODES = { paged: 'Una página', double: 'Doble página', vertical: 'Vertical' } +const PAGE_ANIMS = { flip: 'Pasar hoja', slide: 'Deslizar', fade: 'Fundido', none: 'Sin animación' } +// ---------- Sonido al pasar página ---------- +// Seis variantes de roce de papel sintetizadas al momento (sin archivos de audio): ruido filtrado cuyo tono +// sube con la velocidad de la hoja (el aire que mueve), chasquidos sueltos (las fibras del papel) y un golpe +// sordo al caer. Cada vez sale una variante distinta de la anterior, con pequeñas variaciones de duración y +// tono para que no suene nunca igual. +const TURN_SOUNDS = [ + { dur: 0.30, f: [1700, 3600], q: 0.9, peak: 0.55, crackle: 70, thump: 0.25 }, // hoja fina y rápida + { dur: 0.50, f: [800, 2100], q: 0.7, peak: 0.6, crackle: 45, thump: 0.6 }, // hoja gruesa, lenta + { dur: 0.36, f: [2300, 5000], q: 1.1, peak: 0.5, crackle: 240, thump: 0.15 }, // papel crujiente + { dur: 0.42, f: [1200, 2500], q: 0.5, peak: 0.45, crackle: 18, thump: 0 }, // roce suave + { dur: 0.50, f: [1400, 3100], q: 0.8, peak: 0.6, crackle: 110, thump: 0.4, twoStep: true }, // se despega y cae + { dur: 0.26, f: [2800, 5800], q: 1.4, peak: 0.4, crackle: 160, thump: 0.12, snap: true }, // satinado, con chasquido +] +function synthTurn(p, sr, rnd = Math.random) { + const n = Math.round(sr * (p.dur + 0.1)) + const out = new Float32Array(n) + // Filtro paso banda (biquad RBJ, ganancia 1 en el centro). + const band = () => { + let x1 = 0, x2 = 0, y1 = 0, y2 = 0, b0 = 0, a1 = 0, a2 = 0 + return { + set(f, q) { const w = 2 * Math.PI * Math.min(f, sr * 0.45) / sr, al = Math.sin(w) / (2 * q), a0 = 1 + al; b0 = al / a0; a1 = -2 * Math.cos(w) / a0; a2 = (1 - al) / a0 }, + run(x) { const y = b0 * (x - x2) - a1 * y1 - a2 * y2; x2 = x1; x1 = x; y2 = y1; y1 = y; return y }, + } + } + const air = band(), fiber = band(), body = band() + fiber.set(4200, 0.8) + body.set(170, 0.9) + const a = Math.log(0.5) / Math.log(p.peak) // sin(π·x^a) tiene su máximo en x = peak + const swell = x => x <= 0 || x >= 1 ? 0 : Math.sin(Math.PI * x ** a) ** 2 + const env = x => p.twoStep ? Math.max(0.75 * swell(Math.min(1, x / 0.38)), swell((x - 0.25) / 0.75)) : swell(x) + let flutter = 0, grain = 0, grainK = 1, snapAt = p.snap ? Math.round(sr * p.dur * 0.12) : -1 + const thumpAt = Math.round(sr * p.dur * 0.86), thumpK = Math.exp(-1 / (0.035 * sr)) + let thump = 0 + for (let i = 0; i < n; i++) { + const x = i / (sr * p.dur), e = env(x), w = rnd() * 2 - 1 + if (i % 32 === 0) air.set(p.f[0] + (p.f[1] - p.f[0]) * e, p.q) + flutter += (w - flutter) * 0.004 // temblor lento del volumen + let s = air.run(w) * e * (1 + 1.8 * flutter) + // Chasquidos: más frecuentes cuanto más rápido se mueve la hoja. + if (rnd() < (p.crackle * (0.15 + e)) / sr) { grain = Math.max(grain, (0.3 + 0.7 * rnd()) * (0.3 + e)); grainK = Math.exp(-1 / ((0.0006 + 0.0025 * rnd()) * sr)) } + if (i === snapAt) { grain = 1.6; grainK = Math.exp(-1 / (0.004 * sr)) } + grain *= grainK + s += fiber.run(w) * grain * 1.4 + if (i === thumpAt) thump = p.thump + thump *= thumpK + s += body.run(w) * thump * 3 + out[i] = s + } + let peak = 0 + for (const v of out) peak = Math.max(peak, Math.abs(v)) + const fade = Math.round(sr * 0.01) + for (let i = 0; i < n; i++) out[i] = (out[i] / (peak || 1)) * Math.min(1, (n - i) / fade) + return out +} +const SOUND_LEVELS = { off: ['Sin sonido', 0], low: ['Sonido bajo', 0.15], mid: ['Sonido medio', 0.3], high: ['Sonido alto', 0.55] } +const sfx = { ctx: null, last: -1 } +// El contexto de audio solo arranca dentro de un gesto del usuario (iOS): se prepara al tocar el lector. +function sfxUnlock() { + try { + const AC = window.AudioContext || window.webkitAudioContext + if (!AC) return null + sfx.ctx ??= new AC() + if (sfx.ctx.state !== 'running') sfx.ctx.resume().catch(() => {}) + return sfx.ctx + } catch { return null } +} +// pan: de -1 (izquierda) a 1 (derecha), el lado por el que se mueve la hoja. +function turnSound(level, pan = 0) { + const vol = SOUND_LEVELS[level]?.[1] + if (!vol || document.hidden) return + const ctx = sfxUnlock() + if (!ctx) return + try { + let i + do i = Math.floor(Math.random() * TURN_SOUNDS.length); while (i === sfx.last) + sfx.last = i + const p = TURN_SOUNDS[i], k = 0.88 + Math.random() * 0.24 + const data = synthTurn({ ...p, dur: p.dur * (0.88 + Math.random() * 0.24), f: p.f.map(f => f * k) }, ctx.sampleRate) + const buf = ctx.createBuffer(1, data.length, ctx.sampleRate) + buf.getChannelData(0).set(data) + const src = ctx.createBufferSource() + src.buffer = buf + const gain = ctx.createGain() + gain.gain.value = vol * (0.85 + Math.random() * 0.3) + src.connect(gain) + if (pan && ctx.createStereoPanner) { + const sp = ctx.createStereoPanner() + sp.pan.value = pan + gain.connect(sp).connect(ctx.destination) + } else gain.connect(ctx.destination) + src.start() + } catch {} +} +// Ajustes por defecto según el tipo de obra (se usan hasta que el usuario cambie algo en ese manga). +function defaultPrefs(manga) { + if (manga.type === 'Libro') return { mode: 'paged', fit: 'height', dir: 'ltr' } + if (['Manhwa', 'Manhua', 'Webtoon'].includes(manga.type)) return { mode: 'vertical', fit: 'width', dir: 'ltr' } + if (manga.type === 'Manga') return { mode: 'paged', fit: 'height', dir: 'rtl' } + const legacy = store.get('readerSettings', {}) + return { mode: READ_MODES[legacy.mode] ? legacy.mode : 'paged', fit: legacy.fit === 'width' ? 'width' : 'height', dir: legacy.dir === 'rtl' ? 'rtl' : 'ltr' } +} + +// ---------- Libros: el libro entero se maqueta como un texto continuo (cada capítulo empieza justo debajo del +// anterior) en páginas del tamaño de la pantalla, y cada página se dibuja en un canvas. Al lector le llega como +// una imagen más, así que hoja, sombra, hendidura y sonido no cambian. +const BOOK_DEFAULTS = { size: 100, font: 'serif', margin: 'mid', paper: 'light' } +const BOOK_FONTS = { serif: "Georgia, 'Iowan Old Style', 'Noto Serif', 'Times New Roman', serif", sans: "system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif" } +const BOOK_PAPERS = { light: { bg: '#fbf9f3', fg: '#22201c', dim: '#8b867b' }, sepia: { bg: '#f3e9d3', fg: '#3a2f21', dim: '#8f7f66' }, dark: { bg: '#1b1a18', fg: '#d8d4ca', dim: '#7c786f' } } +const BOOK_MARGINS = { small: 0.055, mid: 0.085, large: 0.12 } +const BOOK_KEEP_PAGES = 16 // páginas dibujadas que se conservan a cada lado de la actual +const bookSettings = () => ({ ...BOOK_DEFAULTS, ...store.get('bookSettings', {}) }) +let bookResume = null // al volver a maquetar (rotar, cambiar la letra): seguir en la misma palabra +let bookCache = null // { id, text } el último libro, para no pedirlo otra vez al reabrir +let bookLayoutCache = null // { key, layout } y su maquetado, para esta pantalla y esta letra + +// El libro como una sola lista de bloques. Cada capítulo lleva su división y su título, aunque el texto no lo traiga. +function bookFlatten(chapters) { + const flat = [], chapBlock = [] + chapters.forEach((c, i) => { + chapBlock.push(flat.length) + if (i > 0) flat.push({ k: 'div' }) + if (c.blocks[0]?.k !== 'h' && !/^Sección \d+$/.test(c.title)) flat.push({ k: 'h', l: 2, r: [[c.title, 0]] }) + for (const b of c.blocks) flat.push(b) + }) + return { flat, chapBlock } +} + +// Una primera pasada solo busca dónde empieza cada página; lo que se dibuja se calcula al pedir esa página. Así un +// libro de miles de páginas no ocupa memoria con la posición de cada palabra. +function makeBookLayout(text, W, H, opts) { + const { flat, chapBlock } = bookFlatten(text.chapters) + const measure = document.createElement('canvas').getContext('2d') + const family = BOOK_FONTS[opts.font] || BOOK_FONTS.serif + const px = Math.min(22, Math.max(15, W / 24)) * (opts.size / 100) + const lineH = Math.round(px * 1.45) + const marginX = Math.round(W * (BOOK_MARGINS[opts.margin] || BOOK_MARGINS.mid)) + const top = Math.round(Math.max(px * 2.6, 30)), bottom = Math.round(H - Math.max(px * 2.6, 30)) + const cw = W - marginX * 2 + const widths = new Map() + const fontOf = (size, style, forceBold) => `${style & 1 ? 'italic ' : ''}${style & 2 || forceBold ? 'bold ' : ''}${size}px ${family}` + const widthOf = (t, font) => { + const key = `${font}|${t}` + let w = widths.get(key) + if (w === undefined) { + if (measure.font !== font) measure.font = font + w = measure.measureText(t).width + widths.set(key, w) + } + return w + } + // Un bloque en líneas: cada línea son cajas (palabras, que pueden cambiar de estilo por dentro). + function breakLines(runs, width, indent, size, forceBold) { + const spaceW = widthOf(' ', fontOf(size, 0, forceBold)) + const lines = [] + let line = { boxes: [], w: 0, hard: false } + let box = null, space = false + const room = () => width - (lines.length ? 0 : indent) + const endLine = hard => { line.hard = hard; lines.push(line); line = { boxes: [], w: 0, hard: false } } + const place = b => { + if (line.boxes.length && line.w + spaceW + b.w > room()) endLine(false) + line.w += (line.boxes.length ? spaceW : 0) + b.w + line.boxes.push(b) + } + const flushBox = () => { + if (!box) return + // Una palabra más ancha que la línea se corta por letras. + if (box.w > room() && box.frags.length === 1) { + const { font } = box.frags[0] + let cur = '' + for (const ch of Array.from(box.frags[0].t)) { + if (cur && widthOf(cur + ch, font) > width) { + place({ frags: [{ t: cur, font, w: widthOf(cur, font) }], w: widthOf(cur, font) }) + endLine(false) + cur = '' + } + cur += ch + } + if (cur) place({ frags: [{ t: cur, font, w: widthOf(cur, font) }], w: widthOf(cur, font) }) + } else place(box) + box = null + } + for (const [t0, style] of runs) { + const font = fontOf(size, style, forceBold) + for (const m of t0.matchAll(/\n| +|[^ \n]+/g)) { + const t = m[0] + if (t === '\n') { flushBox(); space = false; endLine(true) } + else if (t[0] === ' ') { flushBox(); space = true } + else { + if (space) { flushBox(); space = false } + const w = widthOf(t, font) + box ??= { frags: [], w: 0 } + box.frags.push({ t, font, w }) + box.w += w + } + } + } + flushBox() + if (line.boxes.length) endLine(true) + return { lines, spaceW } + } + const headSize = b => Math.round(px * ({ 1: 1.5, 2: 1.35, 3: 1.2 }[b.l] || 1.08)) + const headLines = b => { const size = headSize(b), height = Math.round(size * 1.3); return { ...breakLines(b.r, cw, 0, size, true), height } } + + const pages = [{ block: 0, line: 0, word: 0, after: true }] + const chapStarts = [0] + // Recorre el libro desde el inicio de una página. Sin items, anota dónde empieza cada página; con items, recoge lo + // que hay que dibujar en esa página y termina. + function* walk(cp, items) { + let y = top, used = false, words = cp.word, after = cp.after + const newPage = (block, line) => { + if (items) return true + pages.push({ block, line, word: words, after }) + y = top + used = false + return false + } + const remaining = () => bottom - y + const placeLine = (l, { x, width, indent, center, justify, spaceW, height }) => { + if (items) { + const avail = width - (center ? 0 : indent) + let gap = spaceW + if (justify && !l.hard && l.boxes.length > 1) { + const stretched = spaceW + (avail - l.w) / (l.boxes.length - 1) + if (stretched < spaceW + px * 1.2) gap = stretched // una línea muy suelta se queda sin justificar + } + let cx = center ? x + (width - l.w) / 2 : x + indent + const segs = [] + for (const b of l.boxes) { + for (const f of b.frags) { segs.push({ t: f.t, font: f.font, x: cx }); cx += f.w } + cx += gap + } + items.push({ line: segs, y: y + height * 0.78 }) + } + y += height + words += l.boxes.length + used = true + } + for (let b = cp.block; b < flat.length; b++) { + if (!items && !(b & 127)) yield + const block = flat[b] + const from = b === cp.block ? cp.line : 0 + if (block.k === 'div') { + // División de capítulo: el capítulo sigue en la misma página si caben la línea, su título y algo de texto. + const nx = flat[b + 1] + let need = lineH * 5 + if (nx?.k === 'h') { const h = headLines(nx); need += h.lines.length * h.height } + if (used && remaining() < need && newPage(b, 0)) return + if (!items) chapStarts.push(pages.length - 1) + if (used) { + y += lineH * 1.2 + items?.push({ rule: true, y }) + y += lineH * 0.6 + } + after = true + continue + } + if (block.k === 'hr') { + if (remaining() < lineH * 3 && used && newPage(b, 0)) return + y += lineH * 0.7 + items?.push({ rule: true, y }) + y += lineH * 0.8 + used = true + after = true + continue + } + if (block.k === 'img') { + if (!block.w || !block.h) continue + const fit = Math.min(cw / block.w, (bottom - top) / block.h, 2) + const w = block.w * fit, h = block.h * fit + if (h > remaining() && used && newPage(b, 0)) return + items?.push({ n: block.n, x: (W - w) / 2, y, w, h }) + y += h + lineH * 0.5 + used = true + after = true + continue + } + if (block.k === 'h') { + const { lines, spaceW, height } = headLines(block) + if (!from) { + const need = lines.length * height + lineH * 3 + (used ? lineH * 1.2 : 0) + if (used && remaining() < need && newPage(b, 0)) return // el título no se queda solo al pie + if (used) y += lineH * 1.2 + } + for (let i = from; i < lines.length; i++) { + if (remaining() < height && used && newPage(b, i)) return + placeLine(lines[i], { x: marginX, width: cw, indent: 0, center: true, justify: false, spaceW, height }) + } + y += lineH * 0.8 + after = true + continue + } + const quote = block.k === 'q' + const verse = !!block.v + const center = !!block.c + const inset = quote ? Math.round(px * 1.4) : 0 + const width = cw - inset * 2 + const indent = !after && !quote && !verse && !center ? Math.round(px * 1.3) : 0 + const { lines, spaceW } = breakLines(block.r, width, indent, px, false) + if (quote && used && !from) y += lineH * 0.3 + for (let i = from; i < lines.length;) { + const fits = Math.floor(remaining() / lineH) + let k = Math.min(fits, lines.length - i) + if (!verse) { + // Ni una primera línea sola al pie ni una última línea sola arriba de la página siguiente. + if (i === 0 && k === 1 && lines.length > 1) k = 0 + else if (i + k === lines.length - 1) k = k >= 2 ? k - 1 : 0 + } + if (k <= 0) { + if (!used) k = Math.max(1, Math.min(fits, lines.length - i)) // no cabe ni en una página vacía: no dar vueltas + else { if (newPage(b, i)) return; continue } + } + for (let n = 0; n < k; n++) placeLine(lines[i + n], { x: marginX + inset, width, indent: i + n === 0 ? indent : 0, center, justify: !verse && !center, spaceW, height: lineH }) + i += k + if (i < lines.length && newPage(b, i)) return + } + if (quote) y += lineH * 0.3 + after = quote + } + } + return { + pages, chapStarts, chapBlock, flat, top, cw, family, footPx: Math.max(11, Math.round(px * 0.68)), + // Reparte el libro cediendo el paso de vez en cuando, para que la pantalla no se quede congelada. + async build() { + let t = performance.now() + for (const _ of walk(pages[0], null)) { + if (performance.now() - t > 40) { await new Promise(r => setTimeout(r)); t = performance.now() } + } + }, + capture(i) { + const items = [] + const run = walk(pages[i], items) + while (!run.next().done); + return items + }, + } +} + +async function loadBookText(mangaId) { + if (bookCache?.id === mangaId) return bookCache.text + const text = await api(`/api/book/${mangaId}/text`) + bookCache = { id: mangaId, text } + return text +} + +async function createBook(text, mangaId, size) { + const opts = bookSettings() + const colors = BOOK_PAPERS[opts.paper] || BOOK_PAPERS.light + const { w: W, h: H } = size + const key = JSON.stringify([mangaId, W, H, opts]) + let layout = bookLayoutCache?.key === key ? bookLayoutCache.layout : null + if (!layout) { + layout = makeBookLayout(text, W, H, opts) + await layout.build() + bookLayoutCache = { key, layout } + } + const { pages, chapStarts, chapBlock, flat } = layout + const dpr = Math.min(window.devicePixelRatio || 1, 3, 4096 / Math.max(W, H)) + const canvas = document.createElement('canvas') + canvas.width = Math.round(W * dpr) + canvas.height = Math.round(H * dpr) + const ctx = canvas.getContext('2d', { alpha: false }) + const urls = new Map() + const waiting = new Map() + const queue = [] + const images = new Map() // n -> { im, ready } + let released = false, timer = 0 + const imageOf = n => { + let e = images.get(n) + if (!e) { + const im = new Image() + e = { im, ok: false, ready: new Promise(r => { im.onload = () => { e.ok = im.naturalWidth > 0; e.failed = !e.ok; r() }; im.onerror = () => { e.failed = true; r() } }) } + images.set(n, e) + im.src = `/api/book/${mangaId}/img/${n}` + } + return e + } + const chapterOfBlock = block => { + let lo = 0, hi = chapBlock.length - 1 + while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (chapBlock[mid] <= block) lo = mid; else hi = mid - 1 } + return lo + } + function draw(i, items) { + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + ctx.fillStyle = colors.bg + ctx.fillRect(0, 0, W, H) + ctx.textBaseline = 'alphabetic' + ctx.textAlign = 'left' + ctx.fillStyle = colors.fg + let font = '' + for (const it of items) { + if (it.line) { + for (const s of it.line) { + if (s.font !== font) { ctx.font = font = s.font } + ctx.fillText(s.t, s.x, it.y) + } + } else if (it.rule) { + ctx.fillStyle = colors.dim + ctx.fillRect(W / 2 - W * 0.08, it.y, W * 0.16, 1) + ctx.fillStyle = colors.fg + } else { + const e = imageOf(it.n) + if (e.ok) ctx.drawImage(e.im, it.x, it.y, it.w, it.h) + } + } + // Título del capítulo arriba (salvo donde empieza) y número abajo: guías de lectura, no parte del texto. + ctx.fillStyle = colors.dim + ctx.textAlign = 'center' + ctx.font = `${layout.footPx}px ${layout.family}` + const p = pages[i] + const c = chapterOfBlock(p.block) + const title = text.chapters[c].title + const atStart = p.line === 0 && (p.block === chapBlock[c] || (p.block === chapBlock[c] + 1 && flat[p.block].k === 'h')) + if (!atStart && title && !/^Sección \d+$/.test(title)) { + let t = title + if (ctx.measureText(t).width > layout.cw) { + while (t && ctx.measureText(`${t}…`).width > layout.cw) t = t.slice(0, -1) + t += '…' + } + ctx.fillText(t, W / 2, layout.top - layout.footPx * 1.2) + } + ctx.fillText(`${i + 1} / ${pages.length}`, W / 2, H - layout.footPx * 1.4) + } + const pendingImages = items => items.filter(it => it.n != null && !images.get(it.n)?.ok && !images.get(it.n)?.failed).map(it => imageOf(it.n).ready) + const settle = (i, url) => { + if (!urls.has(i)) urls.set(i, url) + for (const cb of waiting.get(i) || []) cb(urls.get(i)) + waiting.delete(i) + } + const forget = i => { + const u = urls.get(i) + if (u?.startsWith('blob:')) URL.revokeObjectURL(u) + urls.delete(i) + } + async function tick() { + timer = 0 + if (released || !queue.length) return + if (book.busy?.()) { timer = setTimeout(tick, 150); return } // no dibujar mientras gira la hoja + const i = queue.shift() + if (urls.has(i)) { settle(i, urls.get(i)); timer = setTimeout(tick); return } + const items = layout.capture(i) + timer = -1 // ocupado: que nadie arranque otra vuelta mientras llegan las imágenes + await Promise.all(pendingImages(items)) + if (released) return + draw(i, items) + canvas.toBlob(blob => { + if (released) return + if (blob && !urls.has(i)) settle(i, URL.createObjectURL(blob)) + else settle(i, urls.get(i) ?? book.src(i)) + timer = setTimeout(tick, 16) + }, 'image/png') + } + const book = { + count: pages.length, W, H, busy: null, onChange: null, + // Síncrono: la página hace falta ya (la URL no cambia una vez entregada; identifica su textura). Si aún no han + // llegado sus imágenes, se dibuja sin ellas y se avisa para cambiarla al llegar. + src(i) { + if (urls.has(i)) return urls.get(i) + const items = layout.capture(i) + const pending = pendingImages(items) + draw(i, items) + urls.set(i, canvas.toDataURL('image/png')) + if (waiting.has(i)) settle(i, urls.get(i)) + if (pending.length) { + Promise.all(pending).then(() => { + if (released || !urls.has(i)) return + urls.delete(i) + book.onChange?.(i) + }) + } + return urls.get(i) + }, + // Asíncrono: se dibuja cuando haya un momento libre, en el orden en que se pidió. + whenReady(i, cb) { + if (urls.has(i)) return cb(urls.get(i)) + if (!waiting.has(i)) { waiting.set(i, []); queue.push(i) } + waiting.get(i).push(cb) + if (!timer) timer = setTimeout(tick) + }, + // Lo dibujado lejos de la página actual se suelta; si se vuelve, se dibuja otra vez. + trim(center) { + for (const i of [...urls.keys()]) if (Math.abs(i - center) > BOOK_KEEP_PAGES) forget(i) + }, + near: (i, center) => Math.abs(i - center) <= BOOK_KEEP_PAGES, + wordAt: i => pages[Math.min(i, pages.length - 1)]?.word ?? 0, + pageAtWord(word) { + let lo = 0, hi = pages.length - 1 + while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (pages[mid].word <= word) lo = mid; else hi = mid - 1 } + return lo + }, + chapterStart: c => chapStarts[c] ?? 0, + // Si varios capítulos empiezan en una página, cuenta el último: ahí es donde sigue la lectura. + chapterAt(p) { + let lo = 0, hi = chapStarts.length - 1 + while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (chapStarts[mid] <= p) lo = mid; else hi = mid - 1 } + return lo + }, + chapterPages: c => Math.max(1, (chapStarts[c + 1] ?? pages.length) - chapStarts[c]), + release() { + released = true + clearTimeout(timer) + for (const i of [...urls.keys()]) forget(i) + canvas.width = canvas.height = 0 + }, + } + return book +} + +// Resumen de una página para compararla con las de otros capítulos: 16×16 en grises y su proporción. +async function pageSig(url) { + const res = await fetch(url) + if (!res.ok) throw new Error('Sin imagen') + const bmp = await createImageBitmap(await res.blob()) + const ar = bmp.width / bmp.height + // A la mitad cada vez, hasta 64×64: una reducción grande de golpe da resultados distintos en Safari y en Chrome. + let from = bmp, w = bmp.width, h = bmp.height, cv, cx + while (w > 64 || h > 64 || w !== h) { + w = Math.max(64, w >> 1) + h = Math.max(64, h >> 1) + cv = document.createElement('canvas') + cv.width = w + cv.height = h + cx = cv.getContext('2d', { willReadFrequently: w === 64 && h === 64 }) + cx.imageSmoothingQuality = 'high' + cx.drawImage(from, 0, 0, w, h) + from = cv + } + bmp.close() + const d = cx.getImageData(0, 0, 64, 64).data + const out = new Uint8Array(256) + for (let y = 0; y < 16; y++) { + for (let x = 0; x < 16; x++) { + let sum = 0 + for (let j = 0; j < 4; j++) { + for (let i = 0; i < 4; i++) { + const k = ((y * 4 + j) * 64 + x * 4 + i) * 4 + sum += 0.299 * d[k] + 0.587 * d[k + 1] + 0.114 * d[k + 2] + } + } + out[y * 16 + x] = Math.round(sum / 16) + } + } + return { s: btoa(String.fromCharCode(...out)), ar: Math.round(ar * 1000) / 1000 } +} + +// Qué páginas de cada capítulo del tomo son créditos: la 0 y las 2 últimas, si se repiten en otros capítulos de la obra. +async function creditPages(mangaId, parts) { + const chapters = parts.filter(x => x.urls.length >= 5).map(x => ({ id: x.id, n: x.urls.length })) + if (!chapters.length) return {} + const ask = sigs => api(`/api/credits/${mangaId}`, { method: 'POST', body: { chapters, sigs } }) + const first = await ask() + if (!first.missing.length) return first.hidden + const jobs = parts.filter(x => first.missing.includes(x.id)) + const sigs = {} + let next = 0 + await Promise.all(Array.from({ length: 3 }, async () => { + while (next < jobs.length) { + const x = jobs[next++], n = x.urls.length + try { + const pages = {} + for (const i of [0, n - 2, n - 1]) pages[i] = await pageSig(x.urls[i]) + sigs[x.id] = pages + } catch {} // se volverá a intentar la próxima vez + } + })) + return (await ask(sigs)).hidden +} + +// Las páginas de todos los capítulos del tomo de chapterId, o null si no tiene tomo o no se puede saber a tiempo. +async function loadTome(mangaId, lang, chapters, chapterId, chap, settings, hideCredits) { + const within = (p, ms) => Promise.race([p, new Promise(r => setTimeout(r, ms, null))]) + const r = await within(api(`/api/volumes/${mangaId}?lang=${encodeURIComponent(lang)}`).catch(() => null), 20000) // la primera vez consulta varias fuentes + const vols = r?.volumes || [] + const at = vols.findIndex(v => v.chapters.includes(chapterId)) + if (at < 0) return null + const v = vols[at] + $('.reader .loading', app)?.replaceChildren(`Preparando el tomo ${v.vol}…`) + const query = `?saver=${settings.saver ? 1 : 0}&hd=${settings.hd === false ? 0 : 1}` + const want = chapters.map((c, i) => ({ c, i })).filter(x => v.chapters.includes(x.c.id)) + const parts = new Array(want.length) + let nextJob = 0 + await Promise.all(Array.from({ length: 4 }, async () => { + while (nextJob < want.length) { + const k = nextJob++, { c, i } = want[k] + const data = c.id === chapterId ? chap : await api(`/api/chapter/${c.id}${query}`).catch(() => null) + if (data?.pages?.length) parts[k] = { i, id: c.id, local: !!data.local, urls: data.pages } + } + })) + const ok = parts.filter(Boolean) + if (!ok.some(x => x.id === chapterId)) return null + const cover = v.front ? await within(new Promise(res => { + const im = new Image() + im.onload = () => res(im.naturalWidth > 0) + im.onerror = () => res(false) + im.src = v.front + }), 15000) : false + // Si tarda, esta vez se leen enteros; los resúmenes quedan guardados para la próxima. + const hidden = hideCredits ? (await within(creditPages(mangaId, ok).catch(() => null), 15000)) || {} : {} + const pages = cover ? [v.front] : [] + const starts = new Set() + for (const x of ok) { + const out = new Set(hidden[x.id] || []) + x.keep = x.urls.map((_, j) => j).filter(j => !out.has(j)) + x.start = pages.length + x.count = x.keep.length + starts.add(x.start) + pages.push(...x.keep.map(j => x.urls[j])) + } + const partOf = c => ok.find(x => x.i === c) + const tome = { + vol: v.vol, back: v.back, cover: !!cover, parts: ok, pages, starts, + has: c => !!partOf(c), + chapterAt: p => { let k = ok[0]; for (const x of ok) if (p >= x.start) k = x; return k.i }, + chapterStart: c => partOf(c).start, + chapterPages: c => partOf(c).count, + // El progreso, la ruta y los recortes usan el número real de la página, con los créditos contados. Acabar la última + // visible es acabar el capítulo. + rawCount: c => partOf(c).urls.length, + rawPage: (c, local) => { const x = partOf(c); return local >= x.count - 1 ? x.urls.length - 1 : x.keep[local] }, + localPage: (c, raw) => { const at = partOf(c).keep.findIndex(j => j >= raw); return at < 0 ? partOf(c).count - 1 : at }, + // Las URL de MangaDex caducan a los 15 minutos, y un tomo se lee durante más tiempo. + async refresh(c) { + for (const x of ok) { + if (x.local || x.i < c || x.i > c + 1) continue + const data = await api(`/api/chapter/${x.id}${query}`).catch(() => null) + if (data?.pages?.length === x.urls.length) for (let j = 0; j < x.count; j++) pages[x.start + j] = data.pages[x.keep[j]] + } + }, + } + return { tome, volOf: id => vols.find(x => x.chapters.includes(id)) || null } +} + +async function viewReader(mangaId, chapterId, startPage) { + document.body.classList.add('reading') + app.innerHTML = '
    Cargando capítulo…
    ' + const lang = store.get(`lang:${mangaId}`, me.lang) + // Filtro, brillo y ahorro de datos son globales (navegador); modo, ajuste y dirección van por manga (servidor). + const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)').matches + const globalSettings = { saver: false, filter: 'none', brightness: 100, anim: reduceMotion ? 'fade' : 'flip', sfx: 'mid', ...store.get('readerSettings', {}) } + const [info, chap] = await Promise.all([ + api(`/api/manga/${mangaId}?lang=${encodeURIComponent(lang)}`), + api(`/api/chapter/${chapterId}?saver=${globalSettings.saver ? 1 : 0}&hd=${globalSettings.hd === false ? 0 : 1}`).catch(err => { + // Un libro guardado se guarda entero: sin conexión, cualquier capítulo abre con el texto del dispositivo. + if (!isBookId(mangaId) || !OFFLINE_OK || !Object.values(offIdx()).some(c => c.mangaId === mangaId)) throw err + const pos = store.get(`bookPos:${mangaId}`) + return { text: true, pages: [], savedPage: pos?.chapterId === chapterId ? pos.page : 0, savedTotal: pos?.chapterId === chapterId ? pos.totalPages : 0 } + }), + ]) + const { manga, chapters } = info + const settings = { ...globalSettings, ...defaultPrefs(manga), ...(info.prefs || {}) } + const idx = chapters.findIndex(c => c.id === chapterId) + let chapter = chapters[idx] || { id: chapterId, num: info.progress?.chapter_num, title: '' } + let prev = idx > 0 ? chapters[idx - 1] : null + let next = idx >= 0 && idx < chapters.length - 1 ? chapters[idx + 1] : null + const isPaged = () => settings.mode !== 'vertical' + const isDouble = () => settings.mode === 'double' && window.innerWidth >= 700 && window.innerWidth > window.innerHeight + // Libro: las páginas salen de maquetar el texto para esta pantalla (con forma de página, para que nunca cuenten como anchas). + const isBook = !!chap.text + const noun = isBook ? 'libro' : 'manga' + const bookSize = () => { + const vw = window.innerWidth, vh = window.innerHeight + return { w: Math.floor(Math.min(isDouble() ? vw / 2 : vw, vh * (vh < 600 ? 1.05 : 0.8))), h: vh } + } + let book = null + if (isBook) { + settings.fit = 'height' + const opening = location.hash + app.innerHTML = '
    Preparando el libro…
    ' + book = await createBook(await loadBookText(mangaId), mangaId, bookSize()) + if (location.hash !== opening) { book.release(); return } + book.busy = () => turning + } + // Tomo: sus capítulos, uno detrás de otro, forman un solo libro con su portada delante. La ruta y el progreso siguen + // siendo por capítulo. Sin datos del tomo, se lee el capítulo suelto. + let tome = null, nextVol = null + if (!isBook && settings.tomes !== false && idx >= 0) { + const opening = location.hash + const loaded = await loadTome(mangaId, lang, chapters, chapterId, chap, globalSettings, info.hideCredits) + if (location.hash !== opening) return + if (loaded) { + tome = loaded.tome + const first = tome.parts[0].i, last = tome.parts.at(-1).i + prev = first > 0 ? chapters[first - 1] : null + next = last < chapters.length - 1 ? chapters[last + 1] : null + nextVol = next && loaded.volOf(next.id) // lo que sigue puede ser un capítulo sin tomo + } + } + const seq = book || tome // las páginas cruzan varios capítulos + const pages = tome ? tome.pages : chap.pages + const pageSrc = book ? i => book.src(i) : i => pages[i] + const total = book ? book.count : pages.length + let wanted = startPage >= 0 ? startPage : chap.savedPage || 0 + if (book) { + // El progreso de un libro se guarda por capítulo. Con otra pantalla o tamaño de letra un capítulo tiene otro + // número de páginas: ir a la equivalente. + const c = Math.max(idx, 0) + const pagesHere = book.chapterPages(c) + if (chap.savedTotal > 1 && chap.savedTotal !== pagesHere) wanted = Math.round((wanted / (chap.savedTotal - 1)) * (pagesHere - 1)) + wanted = book.chapterStart(c) + Math.min(wanted, pagesHere - 1) + if (bookResume?.mangaId === mangaId) wanted = book.pageAtWord(bookResume.word) + bookResume = null + } + if (tome) { + const local = tome.localPage(idx, Math.max(wanted, 0)) + wanted = tome.cover && idx === tome.parts[0].i && local === 0 ? 0 : tome.chapterStart(idx) + local + } + let page = Math.min(Math.max(wanted, 0), total - 1) + let shown = [page] // páginas visibles (2 en doble página) + const lastShown = () => (isPaged() ? shown.at(-1) : page) + + // Guardado del progreso (con debounce y al salir de la página). + let saveTimer = null + const bookPayload = (c, local, count) => ({ + mangaId, chapterId: chapters[c].id, mangaTitle: manga.title, cover: manga.cover, chapterNum: chapters[c].num ?? null, + chapterTitle: chapters[c].title, lang: tome ? (manga.source !== 'md' ? 'es' : chapters[c].lang || lang) : manga.languages?.[0] || 'es', page: local, totalPages: count, isLast: c === chapters.length - 1, + }) + const payload = () => { + if (!seq) return mangaPayload() + const p = lastShown(), c = seq.chapterAt(p) + const local = Math.max(0, p - seq.chapterStart(c)) + return tome ? bookPayload(c, tome.rawPage(c, local), tome.rawCount(c)) : bookPayload(c, local, seq.chapterPages(c)) + } + const mangaPayload = () => ({ + mangaId, chapterId, mangaTitle: manga.title, cover: manga.cover, chapterNum: chapter.num ?? null, + chapterTitle: chapter.title, lang: manga.source !== 'md' ? 'es' : chapter.lang || lang, page: lastShown(), totalPages: total, + isLast: idx >= 0 && !next, // al terminarlo, el manga sale de "Continuar leyendo" + }) + const saveNow = () => { clearTimeout(saveTimer); saveTimer = null; const p = payload(); if (isBook) store.set(`bookPos:${mangaId}`, { chapterId: p.chapterId, page: p.page, totalPages: p.totalPages }); api('/api/progress', { method: 'POST', body: p }).catch(() => queueProgress(p)) } + const scheduleSave = () => { clearTimeout(saveTimer); saveTimer = setTimeout(saveNow, 700) } + const beacon = () => { if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; navigator.onLine ? navigator.sendBeacon('/api/progress', JSON.stringify(payload())) : queueProgress(payload()) } } + const onVis = () => document.visibilityState === 'hidden' && beacon() + document.addEventListener('visibilitychange', onVis) + window.addEventListener('pagehide', beacon) + saveNow() + + let curChap = seq ? seq.chapterAt(page) : idx + const tomeTimer = tome ? setInterval(() => tome.refresh(curChap), 10 * 60e3) : 0 + const routePage = () => (tome ? tome.rawPage(curChap, Math.max(0, lastShown() - tome.chapterStart(curChap))) : lastShown()) + const chapOpts = chapters.map((c, i) => ``).join('') + app.innerHTML = ` +
    +
    + ${icon('back')} +
    ${esc(manga.title)}
    + + + + +
    + + +
    +
    + ${seq ? `${icon('prev')} Cap. ant.` : prev ? `${icon('prev')} Cap. ant.` : ''} + + + + ${seq ? `Cap. sig. ${icon('next')}` : next ? `Cap. sig. ${icon('next')}` : ''} +
    +
    ` + const reader = $('.reader', app) + let b3d = null, b3dLoading = false // el libro 3D, si está activo + const box = $('#pages', app) + const slider = $('#slider', app) + $('#mode').value = settings.mode + $('#fit').value = settings.fit + $('#dir').value = settings.dir + // Botón rápido páginas ⇄ vertical (vuelve al último modo por páginas usado). + let lastPaged = settings.mode === 'double' ? 'double' : 'paged' + function renderModeQuick() { + const b = $('#modequick') + b.innerHTML = icon(isPaged() ? 'scroll' : 'pages') + b.title = isPaged() ? 'Leer en vertical (desplazamiento continuo)' : 'Leer por páginas' + $('#mode').value = settings.mode + } + $('#filter').value = FILTERS[settings.filter] ? settings.filter : 'none' + $('#bright').value = settings.brightness + const applyFilter = () => { + const f = [FILTERS[settings.filter]?.[1] || '', settings.brightness != 100 ? `brightness(${settings.brightness / 100})` : ''].join(' ').trim() + box.style.setProperty('--page-filter', f || 'none') + b3d?.setFilter(f) + reader.classList.toggle('bluelight', settings.filter === 'blue') + } + applyFilter() + if ($('#saver')) $('#saver').checked = settings.saver + if ($('#hd')) $('#hd').checked = settings.hd !== false + const endBlock = book ? `` : `

    ${tome ? `Fin del tomo ${esc(tome.vol)}` : `Fin del capítulo ${esc(chapter.num ?? '')}`}

    + ${next ? `${icon('next')} Siguiente: ${nextVol ? `Tomo ${esc(nextVol.vol)}` : esc(chapLabel(next))}` : '

    No hay más capítulos disponibles.

    '} + ${icon('back')} Volver a la ficha
    ` + + if (!info.prefs && !store.get(`autoPrefs:${mangaId}`)) { + store.set(`autoPrefs:${mangaId}`, 1) + if (manga.type) toast(`${manga.type}: ${READ_MODES[settings.mode].toLowerCase()}${settings.mode === 'vertical' ? '' : `, ${settings.dir === 'rtl' ? 'der → izq' : 'izq → der'}`}. Lo que cambies se recordará.`) + } + + // Libro: al cruzar a otro capítulo leyendo, los que quedan atrás cuentan como leídos; a uno lejano (la barra, el + // selector) se salta sin marcar nada. + let lastBookPage = page + const syncBookNav = () => { + $('#bprev').style.display = curChap > 0 || (shown[0] ?? page) > 0 ? '' : 'none' + $('#bnext').style.display = curChap < chapters.length - 1 ? '' : 'none' + $('#chap').value = chapters[curChap].id + } + const syncBookChapter = () => { + const p = lastShown() + if (book) { + book.trim(p) + if (!isPaged()) { + box.querySelectorAll('img[src]').forEach(im => { + if (!book.near(Number(im.dataset.i), p)) { im.removeAttribute('src'); im.dataset.lazy = '1' } + }) + } + } + const c = seq.chapterAt(p) + if (c !== curChap) { + if (c > curChap && Math.abs(p - lastBookPage) <= 2) { + for (let j = curChap; j < c; j++) { + const count = tome ? tome.rawCount(j) : seq.chapterPages(j) + api('/api/progress', { method: 'POST', body: bookPayload(j, count - 1, count) }).catch(() => {}) + } + } + curChap = c + if (tome) { chapterId = chapters[c].id; chapter = chapters[c] } + history.replaceState(null, '', readUrl(mangaId, chapters[c].id)) + } + lastBookPage = p + syncBookNav() + } + const setPageLabel = () => { + if (seq) syncBookChapter() + $('#pnum').textContent = `${shown.length > 1 ? `${shown[0] + 1}-${shown[1] + 1}` : lastShown() + 1} / ${total}` + slider.value = lastShown() + 1 + } + let observer = null + let atEnd = false + // Libro: ¿la pantalla ya pide otro tamaño de página? La barra del navegador móvil al aparecer no cuenta. + const sizeChanged = () => { + const s = bookSize() + return s.w !== book.W || Math.abs(s.h - book.H) > 120 + } + function reopenBook() { + bookResume = { mangaId, word: book.wordAt(shown[0] ?? page) } + history.replaceState(null, '', readUrl(mangaId, chapters[curChap].id)) + router() + } + + // --- Doble página: la 1.ª va sola (portada) y las apaisadas (ya dobles) también. + const wide = new Set() + const isWide = img => img.naturalWidth > img.naturalHeight * 1.1 + const groupOf = i => (isDouble() ? spreadOf(i) : [i]) + // Las páginas que quedan frente a frente en el libro abierto: la portada sola, luego de dos en dos; una página + // ancha ocupa las dos caras. + function spreadOf(i) { + for (let s = 0; s < total;) { + // En un tomo, la primera página de cada capítulo no empieza pareja: así las parejas de dentro son las de siempre. + const g = s === 0 || tome?.starts.has(s) || wide.has(s) || wide.has(s + 1) || s + 1 >= total ? [s] : [s, s + 1] + if (g.includes(i)) return g + s += g.length + } + return [i] + } + // Las 6 siguientes y las 2 anteriores (para que al retroceder la hoja tenga ya su reverso), ya + // decodificadas. Se guardan las referencias para que la descarga no se pierda si se recoge la basura. + const preloaded = new Map() + function preload() { + const want = [] + for (let k = 1; k <= 6 && shown.at(-1) + k < total; k++) want.push(shown.at(-1) + k) + for (let k = 1; k <= 2 && shown[0] - k >= 0; k++) want.push(shown[0] - k) + for (const i of want) { + if (preloaded.has(i)) continue + const im = new Image() + im.onload = () => { + if (!isWide(im) || wide.has(i)) return + wide.add(i) + refreshGutters() // una página ancha cambia las parejas de las páginas de delante + } + preloaded.set(i, im) + const load = src => { im.src = src; im.decode?.().catch(() => {}) } + if (book) book.whenReady(i, load) // un libro dibuja sus páginas poco a poco, sin frenar la lectura + else load(pages[i]) + } + for (const i of preloaded.keys()) if (i < shown[0] - 4 || i > shown.at(-1) + 10) preloaded.delete(i) + scheduleLeafTextures() + } + const imgReady = im => im.complete && im.naturalWidth > 0 + // Lado de la hendidura del lomo: l o r (borde de la página); una página ancha no lleva (none). + // En la vista de una página, la cara de delante de cada pareja lo tiene al lado contrario que la hoja que gira. + const gutterOf = (i, n) => { + if (wide.has(i)) return 'none' + const rtl = settings.dir === 'rtl' + if (shown.length === 2) return rtl === (n === 0) ? 'l' : 'r' + const spread = spreadOf(i) + return !isDouble() && spread.length === 2 && spread[0] === i ? (rtl ? 'l' : 'r') : rtl ? 'r' : 'l' + } + const refreshGutters = () => { box.querySelectorAll('.stage:not(.leaving) img').forEach((im, n) => { im.dataset.gutter = gutterOf(Number(im.dataset.i), n) }); if (b3dOn()) b3d?.show(shown) } + // Vista de una página con «pasar hoja»: entre las dos caras de una pareja no se gira la hoja, se recorre con la vista. + function isPan(old) { + const imgs = old.querySelectorAll('img') + if (settings.anim !== 'flip' || isDouble() || box.classList.contains('fit-width') || imgs.length !== 1 || shown.length !== 1) return false + const from = Number(imgs[0].dataset.i) + return from !== shown[0] && spreadOf(from).includes(shown[0]) + } + // Hacia una página que aún no ha llegado, la actual se queda (con un indicador tras un momento) hasta que + // llegue o pasen 4 s, en vez de pasar la hoja y dejar negro debajo. Si se pide otra página mientras, + // manda la última. + let showSeq = 0, waitTimer = 0 + // dir: 1 hacia delante, -1 hacia atrás, 0 sin animación (salto con la barra, cambio de modo…). + function showPaged(dir = 0) { + const seq = ++showSeq + clearTimeout(waitTimer) + box.classList.remove('waiting') + finishTurn() + const stage = document.createElement('div') + stage.className = 'stage' + stage.classList.toggle('two', shown.length === 2) + stage.innerHTML = shown.map((i, n) => ``).join('') + const imgs = [...stage.querySelectorAll('img')] + if ($('.stage', box) && dir && !wantDrag && !imgs.every(imgReady) && !b3dOn()) { // el libro 3D no espera: la página llega a su malla cuando esté + waitTimer = setTimeout(() => box.classList.add('waiting'), 200) + const touchedAt = grabY // el toque ya habrá acabado cuando llegue la página + const arrived = Promise.all(imgs.map(im => (im.decode ? im.decode() : new Promise(r => { im.onload = im.onerror = r })).catch(() => {}))) + Promise.race([arrived, new Promise(r => setTimeout(r, 4000))]).then(() => { + if (seq !== showSeq) return + clearTimeout(waitTimer) + box.classList.remove('waiting') + if (!atEnd && isPaged() && box.isConnected) { + grabY = touchedAt + try { presentPaged(stage, dir) } finally { grabY = null } + } + }) + return + } + presentPaged(stage, dir) + } + function presentPaged(stage, dir) { + const old = $('.stage', box) + // Lo que aún no ha llegado se ve con un indicador de carga y aparece con un fundido, no de golpe. + const imgs = [...stage.querySelectorAll('img')] + const pending = imgs.filter(im => !imgReady(im)) + pending.forEach(im => { + im.classList.add('pending') + const done = () => { + im.classList.remove('pending') + if (!stage.querySelector('img.pending')) stage.classList.remove('loading') + } + im.addEventListener('load', done, { once: true }) + im.addEventListener('error', done, { once: true }) + }) + stage.classList.toggle('loading', pending.length > 0) + // Al arrastrar, el sonido sale al soltar (si la página llega a pasar). + if (dir && !wantDrag && (old ? !isPan(old) : b3dOn())) turnSound(settings.sfx, turnPan(dir)) + if (b3dOn()) { + box.replaceChildren(stage) + syncB3d(settings.anim === 'flip' && zoom.s === 1 ? dir : 0, wantDrag) + } else if (old && dir && settings.anim !== 'none' && zoom.s === 1) turnPage(old, stage, dir, wantDrag) + else { box.replaceChildren(stage) } + stage.querySelectorAll('img').forEach(img => img.addEventListener('load', () => { + const i = Number(img.dataset.i) + if (isWide(img)) img.dataset.gutter = 'none' + if (isWide(img) && !wide.has(i)) { + wide.add(i) + if (shown.length === 2 && shown.includes(i)) goTo(shown[0], false, true) // reagrupar + } + }, { once: true })) + resetZoom() + window.scrollTo(0, 0) + preload() + } + // Giro entre dos parejas, en la vista de una página: la hoja lleva la página vieja por una cara y la nueva por la otra. + function isBookTurn(old, dir) { + const imgs = old.querySelectorAll('img') + if (settings.anim !== 'flip' || isDouble() || box.classList.contains('fit-width') || imgs.length !== 1 || shown.length !== 1) return false + const from = Number(imgs[0].dataset.i), to = shown[0] + if (Math.abs(from - to) !== 1) return false + const [opening, turned] = dir > 0 ? [to, from] : [from, to] + return spreadOf(opening)[0] === opening && spreadOf(opening).length === 2 && !wide.has(turned) + } + // --- Animación al pasar página. La página nueva se pone debajo y la vieja (o una hoja) se anima encima; + // al terminar solo queda la nueva. Si se pasa otra página antes de acabar, la animación se completa ya. + let turning = null // { anims, cleanup } (y move/release si sigue al dedo) + let wantDrag = false + let grabY = null // altura de la pantalla por la que el dedo tocó o agarró la página + // Lado por el que se mueve la hoja: el de la página que pasa (más marcado en doble página). + const turnPan = dir => (settings.dir === 'rtl' ? -1 : 1) * dir * (shown.length === 2 ? 0.3 : 0.12) + function finishTurn() { + if (!turning) return + const t = turning + turning = null + t.anims.forEach(a => { try { a.cancel() } catch {} }) + t.cleanup() + } + // Animación hecha a mano (requestAnimationFrame) con la misma forma que las de la Web Animations API + // que usa finishTurn: cancel() y la promesa finished. + function frameAnim(duration, step) { + let raf = 0, settle + const finished = new Promise((res, rej) => { settle = { res, rej } }) + const t0 = performance.now() + const tick = now => { + const g = Math.min(1, (now - t0) / duration) + step(g) + if (g < 1) raf = requestAnimationFrame(tick) + else settle.res() + } + step(0) + raf = requestAnimationFrame(tick) + return { finished, cancel() { cancelAnimationFrame(raf); settle.rej(new Error('cancelada')) } } + } + // Hoja de papel que se dobla: tiras verticales anidadas, cada una girada un poco respecto a la anterior, + // así que la hoja forma una curva. Cada tira enseña su trozo de la página por delante y del reverso + // (la página siguiente en doble página, o papel en blanco) por detrás. + // Ángulos de la hoja dividida en N tiras, común a la versión WebGL y a la de capas. + function curlCurve(N) { + // Ángulo (0 = en su sitio, 180 = pasada) de cada tira en el avance g (0 a 1). El borde libre va por + // delante del lomo y la curva es máxima a mitad de camino. reverse: la hoja vuelve a su sitio (el + // mismo gesto al revés, con el borde libre llegando primero). + function angles(g, reverse) { + const e = g < 0.5 ? 4 * g ** 3 : 1 - (-2 * g + 2) ** 3 / 2 + const bend = Math.sin(Math.PI * g) * 125 + return Array.from({ length: N }, (_, i) => { + const a = Math.min(180, Math.max(0, 180 * e + bend * (((i + 0.5) / N) ** 1.4 - 0.45))) + return reverse ? 180 - a : a + }) + } + // Tramo de g en el que se ve algo de la hoja sobre la página (alguna tira por debajo de 90°): con una + // sola página, lo que pasa del lomo queda fuera de la pantalla y no vale la pena esperarlo. + function span(reverse) { + const seen = g => angles(g, reverse).some(a => a < 88) + let g0 = 0, g1 = 1 + while (g0 < 1 && !seen(g0)) g0 += 0.01 + while (g1 > g0 && !seen(g1)) g1 -= 0.01 + return [Math.max(0, g0 - 0.02), Math.min(1, g1 + 0.04)] + } + // Posición del borde libre (fracción del ancho desde el lomo: 1 = en su sitio, -1 = pasada del todo) + // y el avance g que la pone donde está el dedo. + const edge = (g, reverse) => angles(g, reverse).reduce((sum, a) => sum + Math.cos((a * Math.PI) / 180), 0) / N + function solve(target, reverse) { + let lo = 0, hi = 1 + for (let k = 0; k < 18; k++) { + const m = (lo + hi) / 2 + if ((edge(m, reverse) > target) !== reverse) lo = m + else hi = m + } + return (lo + hi) / 2 + } + return { angles, span, solve } + } + // --- Hoja con WebGL: una malla con la página como textura, en un solo canvas. Hace lo mismo que paperCurl + // (que queda para cuando no hay WebGL2) sin una capa por cara, que es lo que le pesa a la GPU. + const LEAF_COLS = 32, LEAF_ROWS = 8 + const BOOK_FOLLOW_FROM = 0.25 // parte del giro antes de que la vista empiece a acompañar a la hoja + const LEAF_SHADOW_STEP = 2 // columnas de la hoja por cada tramo de sombra + const SHADOW_DARK = 0.95, SHADOW_SOFT = 0.4, SHADOW_FALL = 3 // oscuridad junto al papel, ancho del borde y caída con la altura + let leafGL = null, leafGLOff = false, leafTimer = 0 + const leafTextures = new Map() // URL de la página → textura + const leafNoTexture = new Set() // las que no se pudieron subir (demasiado grandes, sin permiso…) + function initLeafGL() { + if (leafGL || leafGLOff) return leafGL + const canvas = document.createElement('canvas') + canvas.className = 'curlgl' + const gl = canvas.getContext('webgl2', { alpha: true, premultipliedAlpha: true, antialias: true, depth: true, powerPreference: 'high-performance' }) + const shader = (type, src) => { const sh = gl.createShader(type); gl.shaderSource(sh, src); gl.compileShader(sh); return sh } + const prog = gl?.createProgram() + if (gl) { + // Misma proyección que perspective en CSS: el origen en el centro y la escala 1 / (1 - z / perspectiva). + gl.attachShader(prog, shader(gl.VERTEX_SHADER, `#version 300 es + uniform vec3 uView; + layout(location = 0) in vec4 aPos; + layout(location = 1) in vec2 aUV; + out vec2 vUV; + out float vC; + void main() { + float w = 1.0 - aPos.z / uView.z; + gl_Position = vec4(aPos.x / uView.x * 2.0 - 1.0, 1.0 - aPos.y / uView.y * 2.0, 1.05128 * w - 0.10256, w); + vUV = aUV; + vC = aPos.w; + }`)) + // La cara se oscurece al inclinarse. El sesgo negativo evita que la página se vea blanda con los mipmaps. + gl.attachShader(prog, shader(gl.FRAGMENT_SHADER, `#version 300 es + precision highp float; + uniform sampler2D uFront, uBack; + uniform bool uPaper, uSpineLeft, uWide; + in vec2 vUV; + in float vC; + out vec4 color; + // La hendidura del lomo: los mismos puntos que data-gutter en style.css, para que no salte al empezar el giro. + float gutter(float d) { + if (d < 0.012) return mix(0.4, 0.6, d / 0.012); + if (d < 0.03) return mix(0.6, 0.8, (d - 0.012) / 0.018); + if (d < 0.06) return mix(0.8, 0.93, (d - 0.03) / 0.03); + if (d < 0.1) return mix(0.93, 1.0, (d - 0.06) / 0.04); + return 1.0; + } + void main() { + vec4 t; + float b; + float fromSpine = uSpineLeft ? vUV.x : 1.0 - vUV.x; + if (gl_FrontFacing) { + t = texture(uFront, vUV, -0.5); + b = (1.0 - (1.0 - vC) * 0.3) * (uWide ? 1.0 : gutter(fromSpine)); + } else { + t = uPaper ? vec4(0.914, 0.898, 0.863, 1.0) : texture(uBack, vec2(1.0 - vUV.x, vUV.y), -0.5); + b = (1.0 - (1.0 + vC) * 0.3) * gutter(fromSpine); + } + color = vec4(t.rgb * b, t.a); + }`)) + gl.linkProgram(prog) + } + if (!gl || !gl.getProgramParameter(prog, gl.LINK_STATUS)) { leafGLOff = true; return null } + gl.useProgram(prog) + gl.uniform1i(gl.getUniformLocation(prog, 'uFront'), 0) + gl.uniform1i(gl.getUniformLocation(prog, 'uBack'), 1) + gl.enable(gl.DEPTH_TEST) + gl.depthFunc(gl.LEQUAL) + gl.clearColor(0, 0, 0, 0) + const count = (LEAF_COLS + 1) * (LEAF_ROWS + 1) + const posBuf = gl.createBuffer(), uvBuf = gl.createBuffer(), indexBuf = gl.createBuffer() + gl.bindBuffer(gl.ARRAY_BUFFER, posBuf) + gl.bufferData(gl.ARRAY_BUFFER, count * 16, gl.DYNAMIC_DRAW) + gl.enableVertexAttribArray(0) + gl.vertexAttribPointer(0, 4, gl.FLOAT, false, 0, 0) + gl.bindBuffer(gl.ARRAY_BUFFER, uvBuf) + gl.bufferData(gl.ARRAY_BUFFER, count * 8, gl.DYNAMIC_DRAW) + gl.enableVertexAttribArray(1) + gl.vertexAttribPointer(1, 2, gl.FLOAT, false, 0, 0) + const at = (c, f) => c * (LEAF_ROWS + 1) + f + const index = new Uint16Array(LEAF_COLS * LEAF_ROWS * 6) + let n = 0 + for (let c = 0; c < LEAF_COLS; c++) for (let f = 0; f < LEAF_ROWS; f++) { + index.set([at(c, f), at(c, f + 1), at(c + 1, f), at(c + 1, f), at(c, f + 1), at(c + 1, f + 1)], n) + n += 6 + } + gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuf) + gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, index, gl.STATIC_DRAW) + // Sombra sobre el papel: la proyección de la hoja con la luz de frente. Se pinta detrás de la hoja, sin imagen. + const shadowProg = gl.createProgram() + gl.attachShader(shadowProg, shader(gl.VERTEX_SHADER, `#version 300 es + uniform vec2 uSize; + layout(location = 0) in vec2 aPos; + layout(location = 1) in vec2 aRamp; + layout(location = 2) in float aAlpha; + out vec2 vRamp; + out float vAlpha; + void main() { + gl_Position = vec4(aPos.x / uSize.x * 2.0 - 1.0, 1.0 - aPos.y / uSize.y * 2.0, 0.95, 1.0); + vRamp = aRamp; + vAlpha = aAlpha; + }`)) + gl.attachShader(shadowProg, shader(gl.FRAGMENT_SHADER, `#version 300 es + precision mediump float; + in vec2 vRamp; + in float vAlpha; + out vec4 color; + void main() { + vec2 r = vRamp * vRamp * (3.0 - 2.0 * vRamp); + color = vec4(0.0, 0.0, 0.0, vAlpha * r.x * r.y); + }`)) + gl.linkProgram(shadowProg) + if (!gl.getProgramParameter(shadowProg, gl.LINK_STATUS)) { leafGLOff = true; return null } + // Cada tramo son 3×3 cuadros: el centro es la tira proyectada y el resto, su borde difuso. + const shadowSegs = LEAF_COLS / LEAF_SHADOW_STEP + const shadowVao = gl.createVertexArray(), shadowBuf = gl.createBuffer(), shadowIndex = gl.createBuffer() + gl.bindVertexArray(shadowVao) + gl.bindBuffer(gl.ARRAY_BUFFER, shadowBuf) + gl.bufferData(gl.ARRAY_BUFFER, shadowSegs * 16 * 20, gl.DYNAMIC_DRAW) + gl.enableVertexAttribArray(0) + gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 20, 0) + gl.enableVertexAttribArray(1) + gl.vertexAttribPointer(1, 2, gl.FLOAT, false, 20, 8) + gl.enableVertexAttribArray(2) + gl.vertexAttribPointer(2, 1, gl.FLOAT, false, 20, 16) + const shadowIdx = new Uint16Array(shadowSegs * 54) + let m = 0 + for (let q = 0; q < shadowSegs; q++) for (let j = 0; j < 3; j++) for (let k = 0; k < 3; k++) { + const v = (a, b) => q * 16 + a * 4 + b + shadowIdx.set([v(j, k), v(j, k + 1), v(j + 1, k), v(j + 1, k), v(j, k + 1), v(j + 1, k + 1)], m) + m += 6 + } + gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, shadowIndex) + gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, shadowIdx, gl.STATIC_DRAW) + gl.bindVertexArray(null) + gl.bindBuffer(gl.ARRAY_BUFFER, posBuf) + gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true) + const aniso = gl.getExtension('EXT_texture_filter_anisotropic') + // Sin el contexto (iOS puede quitarlo con la app en segundo plano), el siguiente giro crea otro. + canvas.addEventListener('webglcontextlost', () => { if (leafGL?.gl === gl) { leafGL = null; leafTextures.clear() } }) + leafGL = { canvas, gl, prog, posBuf, uvBuf, aniso, shadowProg, shadowVao, shadowBuf, shadowCount: m, shadowData: new Float32Array(shadowSegs * 16 * 5), + uSize: gl.getUniformLocation(shadowProg, 'uSize'), positions: new Float32Array(count * 4), uvs: new Float32Array(count * 2), indexCount: n, + uView: gl.getUniformLocation(prog, 'uView'), uPaper: gl.getUniformLocation(prog, 'uPaper'), uSpineLeft: gl.getUniformLocation(prog, 'uSpineLeft'), uWide: gl.getUniformLocation(prog, 'uWide'), maxSize: gl.getParameter(gl.MAX_TEXTURE_SIZE) } + return leafGL + } + function leafTexture(im) { + const known = leafTextures.get(im.src) + if (known) return known + const { gl, aniso, maxSize } = leafGL + if (Math.max(im.naturalWidth, im.naturalHeight) > maxSize) throw new Error('página demasiado grande') + const tex = gl.createTexture() + gl.activeTexture(gl.TEXTURE0) + gl.bindTexture(gl.TEXTURE_2D, tex) + try { + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, im) + gl.generateMipmap(gl.TEXTURE_2D) + if (gl.getError() !== gl.NO_ERROR) throw new Error('sin memoria para la textura') + } catch (e) { gl.deleteTexture(tex); throw e } + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE) + if (aniso) gl.texParameterf(gl.TEXTURE_2D, aniso.TEXTURE_MAX_ANISOTROPY_EXT, Math.min(4, gl.getParameter(aniso.MAX_TEXTURE_MAX_ANISOTROPY_EXT))) + leafTextures.set(im.src, tex) + return tex + } + // Sube con antelación, en reposo y de una en una, las páginas de los giros posibles: subirla al empezar el + // giro daría un tirón. Una página que no llegó a subirse se sube al girar. + function scheduleLeafTextures(delay = 300) { + clearTimeout(leafTimer) + if (!leafGLOff && settings.anim === 'flip' && isPaged() && !box.classList.contains('fit-width') && !b3dWanted()) leafTimer = setTimeout(nextLeafTexture, delay) + } + function nextLeafTexture() { + if (turning) return scheduleLeafTextures(400) + const n = shown.length + const near = [] + for (let k = 1; k <= n; k++) near.push(shown.at(-1) + k, shown[0] - k) + const wanted = [...box.querySelectorAll('.stage img'), ...near.map(i => preloaded.get(i)).filter(Boolean)] + const keep = new Set(wanted.map(im => im.src)) + for (const [src, tex] of leafTextures) if (!keep.has(src)) { leafGL?.gl.deleteTexture(tex); leafTextures.delete(src) } + const missing = wanted.filter(im => !leafTextures.has(im.src) && !leafNoTexture.has(im.src)) + const im = missing.find(imgReady) + if (!im) { if (missing.length) scheduleLeafTextures(800); return } + if (!initLeafGL()) return + try { leafTexture(im) } catch { leafNoTexture.add(im.src) } + scheduleLeafTextures(120) + } + function freeLeafGL() { + leafGLOff = true + clearTimeout(leafTimer) + leafGL?.gl.getExtension('WEBGL_lose_context')?.loseContext() + leafGL = null + leafTextures.clear() + } + // Devuelve null si no se puede (sin WebGL2 o sin textura): entonces se usa paperCurl. + function glCurl(r, frontIm, backIm, spineLeft, paper = r) { + if (!initLeafGL() || leafNoTexture.has(frontIm.src) || (backIm && leafNoTexture.has(backIm.src))) return null + const L = leafGL + const { gl, canvas, positions, uvs } = L + let front, back + try { + front = leafTexture(frontIm) + back = backIm ? leafTexture(backIm) : null + } catch { leafNoTexture.add(frontIm.src); if (backIm) leafNoTexture.add(backIm.src); return null } + const W = box.clientWidth, H = box.clientHeight, N = LEAF_COLS, R = LEAF_ROWS + const pw = Math.round(W * devicePixelRatio), ph = Math.round(H * devicePixelRatio) + if (canvas.width !== pw || canvas.height !== ph) { canvas.width = pw; canvas.height = ph } + Object.assign(canvas.style, { width: `${W}px`, height: `${H}px` }) + gl.viewport(0, 0, pw, ph) + gl.uniform3f(L.uView, W, H, parseFloat(getComputedStyle(box).perspective) || 2200) + gl.useProgram(L.shadowProg) + gl.uniform2f(L.uSize, W, H) + gl.useProgram(L.prog) + const scale = pw / W + gl.scissor(Math.round(paper.l * scale), Math.round((H - paper.t - paper.h) * scale), Math.round(paper.w * scale), Math.round(paper.h * scale)) + gl.uniform1i(L.uPaper, back ? 0 : 1) + gl.uniform1i(L.uSpineLeft, spineLeft ? 1 : 0) + gl.uniform1i(L.uWide, frontIm.dataset.gutter === 'none' ? 1 : 0) + gl.activeTexture(gl.TEXTURE0) + gl.bindTexture(gl.TEXTURE_2D, front) + gl.activeTexture(gl.TEXTURE1) + gl.bindTexture(gl.TEXTURE_2D, back) + // Las columnas van del lomo al borde libre: con el lomo a la derecha, la malla queda al revés. + gl.frontFace(spineLeft ? gl.CCW : gl.CW) + for (let c = 0, n = 0; c <= N; c++) for (let f = 0; f <= R; f++) { + uvs[n++] = spineLeft ? c / N : 1 - c / N + uvs[n++] = f / R + } + gl.bindBuffer(gl.ARRAY_BUFFER, L.uvBuf) + gl.bufferSubData(gl.ARRAY_BUFFER, 0, uvs) + gl.bindBuffer(gl.ARRAY_BUFFER, L.posBuf) + const curve = curlCurve(N) + const step = (spineLeft ? r.w : -r.w) / N + const sgn = spineLeft ? -1 : 1 + const spineX = r.l + (spineLeft ? 0 : r.w) + let pivotY = r.h / 2 + const rad = Math.PI / 180 + const mul = (a, b) => [ + a[0] * b[0] + a[1] * b[3] + a[2] * b[6], a[0] * b[1] + a[1] * b[4] + a[2] * b[7], a[0] * b[2] + a[1] * b[5] + a[2] * b[8], + a[3] * b[0] + a[4] * b[3] + a[5] * b[6], a[3] * b[1] + a[4] * b[4] + a[5] * b[7], a[3] * b[2] + a[4] * b[5] + a[5] * b[8], + a[6] * b[0] + a[7] * b[3] + a[8] * b[6], a[6] * b[1] + a[7] * b[4] + a[8] * b[7], a[6] * b[2] + a[7] * b[5] + a[8] * b[8], + ] + // Con la luz de frente, cada tira cae en vertical sobre el papel (x e y sin cambiar). La altura decide el borde: + // cerca del papel, nítido y oscuro; lejos, ancho y claro. Con MAX, los tramos que se solapan no oscurecen de más, + // y con la prueba de profundidad no se pinta lo que tapa la hoja. + function drawShadow(lift) { + const data = L.shadowData, col = (R + 1) * 4, strength = SHADOW_DARK * Math.min(1, lift * 4), fall = r.w * SHADOW_FALL + let n = 0 + for (let c = 0; c < N; c += LEAF_SHADOW_STEP) { + const i = c * col, e = (c + LEAF_SHADOW_STEP) * col, down = R * 4 + const ax = positions[i], ay = positions[i + 1], bx = positions[i + down], by = positions[i + down + 1], cx = positions[e], cy = positions[e + 1] + const len = Math.hypot(bx - ax, by - ay) || 1, vx = (bx - ax) / len, vy = (by - ay) / len + let ux = vy, uy = -vx + if ((cx - ax) * ux + (cy - ay) * uy < 0) { ux = -ux; uy = -uy } + for (let j = 0; j < 4; j++) for (let k = 0; k < 4; k++) { + const at = (j < 2 ? i : e) + (k < 2 ? 0 : down) + const height = Math.abs(positions[at + 2]), soft = 2 + height * SHADOW_SOFT + const oj = j === 0 ? -1 : j === 3 ? 1 : 0, ok = k === 0 ? -1 : k === 3 ? 1 : 0 + data[n++] = positions[at] + (ux * oj + vx * ok) * soft + data[n++] = positions[at + 1] + (uy * oj + vy * ok) * soft + data[n++] = oj ? 0 : 1 + data[n++] = ok ? 0 : 1 + data[n++] = strength / (1 + height / fall) + } + } + gl.useProgram(L.shadowProg) + gl.bindVertexArray(L.shadowVao) + gl.bindBuffer(gl.ARRAY_BUFFER, L.shadowBuf) + gl.bufferSubData(gl.ARRAY_BUFFER, 0, data) + gl.enable(gl.SCISSOR_TEST) + gl.enable(gl.BLEND) + gl.blendEquation(gl.MAX) + gl.blendFunc(gl.ONE, gl.ONE) + gl.depthMask(false) + gl.drawElements(gl.TRIANGLES, L.shadowCount, gl.UNSIGNED_SHORT, 0) + gl.depthMask(true) + gl.disable(gl.BLEND) + gl.disable(gl.SCISSOR_TEST) + gl.bindVertexArray(null) + gl.useProgram(L.prog) + gl.bindBuffer(gl.ARRAY_BUFFER, L.posBuf) + } + // Devuelve cuánto está levantada la hoja (0 a 1). Encadena los giros de tira en + // tira como lo hacen las capas anidadas de paperCurl. + function set(g, reverse, tiltZ = 0, tiltX = 0, cam = 0) { + const a = curve.angles(g, reverse) + let m = [1, 0, 0, 0, 1, 0, 0, 0, 1], x = 0, y = 0, z = 0, prev = 0, prevC = 1, lift = 0, n = 0 + const column = c => { + for (let f = 0; f <= R; f++) { + const v = (r.h * f) / R - pivotY + positions[n++] = spineX + cam + x + m[1] * v + positions[n++] = r.t + pivotY + y + m[4] * v + positions[n++] = z + m[7] * v + positions[n++] = c + } + } + for (let i = 0; i < N; i++) { + const c = Math.cos(a[i] * rad) + if (i) { x += m[0] * step; y += m[3] * step; z += m[6] * step } + const t = sgn * (a[i] - prev) * rad + m = mul(m, [Math.cos(t), 0, Math.sin(t), 0, 1, 0, -Math.sin(t), 0, Math.cos(t)]) + if (i && (tiltZ || tiltX)) { + const tz = ((tiltZ * c) / (N - 1)) * rad, tx = ((tiltX * c) / (N - 1)) * rad + m = mul(mul(m, [Math.cos(tz), -Math.sin(tz), 0, Math.sin(tz), Math.cos(tz), 0, 0, 0, 1]), [1, 0, 0, 0, Math.cos(tx), -Math.sin(tx), 0, Math.sin(tx), Math.cos(tx)]) + } + column(i ? (c + prevC) / 2 : c) + prev = a[i] + prevC = c + lift = Math.max(lift, Math.sin(a[i] * rad)) + } + x += m[0] * step; y += m[3] * step; z += m[6] * step + column(prevC) + gl.bufferSubData(gl.ARRAY_BUFFER, 0, positions) + gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT) + gl.drawElements(gl.TRIANGLES, L.indexCount, gl.UNSIGNED_SHORT, 0) + if (lift > 0) drawShadow(lift) + return lift + } + const pivot = y => { pivotY = y === 'bottom' ? r.h : y === 'top' ? 0 : r.h / 2 } + return { el: canvas, gl: true, set, span: curve.span, solve: curve.solve, pivot } + } + function paperCurl(r, frontSrc, backSrc, spineLeft, bent = false) { + const N = Math.max(10, Math.min(22, Math.round(r.w / 24))) + const w = r.w / N + // Cada cara se sale OV px por los lados de su tira para tapar la unión con la vecina: sin solape, + // Safari deja una rendija oscura entre tiras. + // Curvada, las tiras dejan de encajar justas lejos del punto fijo: hace falta más solape. + const OV = bent ? 4 : 1.5 + const sgn = spineLeft ? -1 : 1 // hacia dónde gira: el borde libre se levanta hacia quien mira + const el = document.createElement('div') + el.className = 'curl' + const strips = [] + let parent = el + for (let i = 0; i < N; i++) { + const fc = spineLeft ? i : N - 1 - i // columna de la página (desde la izquierda) de esta tira + const bc = N - 1 - fc // en el reverso, la hoja está dada la vuelta + const s = document.createElement('div') + s.className = 'strip' + Object.assign(s.style, { width: `${w}px`, height: `${r.h}px`, [spineLeft ? 'left' : 'right']: '0', transformOrigin: spineLeft ? 'left center' : 'right center' }) + // Solape hacia la izquierda y la derecha de la tira (no por fuera de la página, en la primera y la última + // columna). El reverso está girado sobre su centro: allí su izquierda es la derecha de la tira. + const l = fc > 0 ? OV : 0, rr = fc < N - 1 ? OV : 0 + const face = (cls, src, col, off) => `
    ${src ? `` : ''}
    ` + s.innerHTML = face('front', frontSrc, fc, l) + face(backSrc ? 'back' : 'back paper', backSrc, bc, rr) + parent.append(s) + strips.push({ s, f: s.children[0], b: s.children[1] }) + parent = s + } + const step = spineLeft ? w : -w + const { angles, span, solve } = curlCurve(N) + // Devuelve cuánto está levantada la hoja (0 a 1), para la sombra de debajo. + // tiltZ/tiltX: curvatura total de la hoja agarrada por arriba o por abajo. Se reparte entre las tiras salvo la + // del lomo, que no se curva, y el borde libre se lleva todo: la hoja sigue pegada al libro. Cambia de + // signo con la cara visible para que la parte agarrada vaya siempre por delante. + function set(g, reverse, tiltZ = 0, tiltX = 0) { + let prev = 0, lift = 0 + angles(g, reverse).forEach((a, i) => { + const t = strips[i] + const c = Math.cos((a * Math.PI) / 180) + const bend = i && (tiltZ || tiltX) ? ` rotateZ(${((tiltZ * c) / (N - 1)).toFixed(3)}deg) rotateX(${((tiltX * c) / (N - 1)).toFixed(3)}deg)` : '' + t.s.style.transform = `${i ? `translateX(${step}px) ` : ''}rotateY(${(sgn * (a - prev)).toFixed(2)}deg)${bend}` + prev = a + // Se oscurece al inclinarse. Con un filtro en la propia cara y no con una capa negra encima: en Safari + // cada capa se compone aparte y en las uniones entre tiras asomaban líneas. + t.f.style.filter = `brightness(${(1 - (1 - c) * 0.3).toFixed(3)})` + t.b.style.filter = `brightness(${(1 - (1 + c) * 0.3).toFixed(3)})` + lift = Math.max(lift, Math.sin((a * Math.PI) / 180)) + }) + return lift + } + // Punto fijo de la curvatura: el borde opuesto al agarrado (no afecta al giro sobre el eje vertical). + const pivot = y => strips.forEach(t => { t.s.style.transformOrigin = `${spineLeft ? 'left' : 'right'} ${y}` }) + return { el, set, span, solve, pivot } + } + const GRAB_TILT_Z = 14, GRAB_TILT_X = 10 // curvatura total, en grados, de la hoja agarrada por arriba o por abajo + // drag: la página sigue al dedo; devuelve el control (move/release) en vez de animarse sola. + function turnPage(old, stage, dir, drag = false) { + // "Delante" es hacia la izquierda en izq→der (la hoja derecha pasa a la izquierda) y al revés en der→izq. + const sign = (settings.dir === 'rtl' ? -1 : 1) * dir + const fitWidth = box.classList.contains('fit-width') + const pan = isPan(old) + let kind = pan ? 'pan' : settings.anim + const ready = im => im.complete && im.naturalWidth > 0 + const oldImgs = [...old.querySelectorAll('img')] + // La hoja necesita las páginas ya cargadas y la misma disposición (1↔1 o 2↔2); si no, deslizar. + if (kind === 'flip' && (fitWidth || !oldImgs.every(ready))) kind = 'slide' + if (fitWidth && kind === 'slide') kind = 'fade' // páginas largas con scroll: un fundido queda mejor + old.classList.add('leaving') + box.prepend(stage) // la nueva debajo (antes en el DOM), la vieja encima + const double = oldImgs.length === 2 && shown.length === 2 + // Una página hacia atrás: la hoja que vuelve es la nueva, que tiene que estar ya cargada. + if (kind === 'flip' && !double && dir < 0 && !ready(stage.querySelector('img'))) kind = 'slide' + // En doble página, el reverso de la hoja es una página nueva: sin cargar, se verían solo las sombras + // de las tiras (líneas oscuras). + if (kind === 'flip' && double && ![...stage.querySelectorAll('img')].every(ready)) kind = 'slide' + const extra = [] + const cleanup = () => { old.remove(); extra.forEach(e => e.remove()); stage.style.transform = '' } + // Al soltar el dedo sin pasar la página: queda la vieja como estaba. + const revert = () => { stage.remove(); extra.forEach(e => e.remove()); old.classList.remove('leaving'); Object.assign(old.style, { visibility: '', opacity: '', transform: '', background: '' }) } + const easeOut = t => 1 - (1 - t) ** 3 + // Tapa lo que hay a los lados de la página, para que las vecinas no aparezcan de golpe en una pantalla más ancha. + const addSideMasks = width => { + const side = (box.clientWidth - width) / 2 + if (side <= 0.5) return + for (const edge of ['left', 'right']) { + const mask = document.createElement('i') + mask.className = 'panmask' + Object.assign(mask.style, { [edge]: '0', width: `${side}px` }) + box.append(mask) + extra.push(mask) + } + } + // step(g) dibuja el avance g; toG(q) convierte lo recorrido por el dedo (0-1 de range px) en avance. + const B = box.getBoundingClientRect() + const rel = im => { const r = im.getBoundingClientRect(); return { l: r.left - B.left, t: r.top - B.top, w: r.width, h: r.height } } + const place = (el, r) => Object.assign(el.style, { left: `${r.l}px`, top: `${r.t}px`, width: `${r.w}px`, height: `${r.h}px` }) + const grabOf = r => { + const at = grabY != null ? (grabY - B.top - r.t) / r.h : 0.5 + return at < 1 / 3 ? -1 : at > 2 / 3 ? 1 : 0 + } + const newIm = stage.querySelector('img') + let bookCurl = null + if (kind === 'flip' && ready(newIm) && isBookTurn(old, dir)) { + bookCurl = glCurl(rel(oldImgs[0]), oldImgs[0], newIm, sign > 0, { l: 0, t: 0, w: box.clientWidth, h: box.clientHeight }) + } + let step, dur, range = box.clientWidth, toG = q => q, auto = easeOut, g0 = 0, g1 = 1 + if (kind === 'fade') { + step = g => { old.style.opacity = String(1 - g) } + dur = 180 + } else if (kind === 'slide') { + step = g => { old.style.transform = `translateX(${-sign * 100 * g}%)`; stage.style.transform = `translateX(${sign * 100 * (1 - g)}%)` } + dur = 260 + } else if (kind === 'pan') { + // Las dos páginas van juntas, lomo con lomo: la nueva empuja a la vieja. Lo que se vea a los lados de la página + // queda tapado, para que la nueva no aparezca de golpe si la pantalla es más ancha que ella. + const oldW = oldImgs[0].getBoundingClientRect().width + const newW = ready(newIm) ? newIm.getBoundingClientRect().width : oldW + const shift = (oldW + newW) / 2 + addSideMasks(Math.max(oldW, newW)) + old.style.background = 'transparent' + step = g => { old.style.transform = `translateX(${-sign * shift * g}px)`; stage.style.transform = `translateX(${sign * shift * (1 - g)}px)` } + step(0) + dur = 460 + range = shift + auto = t => (t < 0.5 ? 2 * t * t : 1 - 2 * (1 - t) ** 2) + } else if (bookCurl) { + // Como un libro: la hoja gira sobre el lomo y descubre la página que tiene debajo, la vista la acompaña y la + // nueva, en el reverso de la hoja, cae en su sitio. Alrededor están las páginas vecinas, lomo con lomo. + const rect = rel(oldImgs[0]), spineLeft = sign > 0 + const hinge = spineLeft ? rect.l : rect.l + rect.w + const newRect = rel(newIm) + const travel = (spineLeft ? newRect.l + newRect.w : newRect.l) - hinge + const from = Number(oldImgs[0].dataset.i), to = shown[0] + const under = [] + const neighbour = (i, onHingeSide) => { + if (i < 0 || i >= total || wide.has(i)) return + const im = preloaded.get(i) || Object.assign(new Image(), { src: pageSrc(i) }) + const fit = imgReady(im) ? Math.min(box.clientWidth / im.naturalWidth, box.clientHeight / im.naturalHeight) : 0 + const w = fit ? im.naturalWidth * fit : rect.w, h = fit ? im.naturalHeight * fit : rect.h + im.className = 'leafkeep' + im.alt = '' + im.dataset.gutter = spineLeft === onHingeSide ? 'l' : 'r' + place(im, { l: spineLeft === onHingeSide ? hinge : hinge - w, t: (box.clientHeight - h) / 2, w, h }) + im.style.willChange = 'transform' + under.push(im) + } + const back = document.createElement('i') + back.className = 'leafkeepback' + place(back, { l: 0, t: 0, w: box.clientWidth, h: box.clientHeight }) + neighbour(to + dir, true) + neighbour(from - dir, false) + box.append(back, ...under, bookCurl.el) + extra.push(back, ...under, bookCurl.el) + addSideMasks(Math.max(rect.w, newRect.w)) + old.style.visibility = 'hidden' + const grab = grabOf(rect) + if (grab) bookCurl.pivot(grab < 0 ? 'bottom' : 'top') + step = g => { + const follow = Math.min(1, Math.max(0, (g - BOOK_FOLLOW_FROM) / (1 - BOOK_FOLLOW_FROM))) + const cam = travel * follow * follow * (3 - 2 * follow) + for (const im of under) im.style.transform = `translateX(${cam}px)` + const k = grab ? grab * Math.sin(Math.PI * g) : 0 + bookCurl.set(g, false, (spineLeft ? 1 : -1) * GRAB_TILT_Z * k, GRAB_TILT_X * k, cam) + } + step(0) + dur = 850 + auto = t => t + range = rect.w + toG = q => bookCurl.solve(1 - q * 2, false) + } else { + // Pasar hoja: la hoja se dobla como papel (se levanta primero el borde libre y el lomo la sigue). + // Medidas relativas al contenedor. + const byX = imgs => [...imgs].sort((a, b) => a.getBoundingClientRect().left - b.getBoundingClientRect().left) + let rect, paper, frontIm, backIm = null, spineLeft, reverse = false + if (double) { + // Doble página: gira la hoja del lado hacia el que se avanza (derecha si sign > 0); su reverso es + // la página nueva del otro lado del lomo, y la página vieja de ese lado sigue a la vista (copia + // encima de la nueva) hasta que la hoja cae. + const oldX = byX(oldImgs) + // Las nuevas puede que aún no estén cargadas (sin tamaño ni posición): su lado sale del orden de lectura. + const newX = [...stage.querySelectorAll('img')] + if (settings.dir === 'rtl') newX.reverse() + const front = sign > 0 ? oldX.at(-1) : oldX[0] + const stay = sign > 0 ? oldX[0] : oldX.at(-1) + rect = rel(front) + const other = rel(stay) + const left = Math.min(rect.l, other.l), top = Math.min(rect.t, other.t) + paper = { l: left, t: top, w: Math.max(rect.l + rect.w, other.l + other.w) - left, h: Math.max(rect.t + rect.h, other.t + other.h) - top } + frontIm = front + backIm = sign > 0 ? newX[0] : newX.at(-1) + spineLeft = sign > 0 + const keep = document.createElement('img') + keep.className = 'leafkeep' + keep.dataset.gutter = stay.dataset.gutter + keep.src = stay.src + place(keep, rel(stay)) + // Su hendidura es transparente: sin negro detrás, se vería la página nueva y la hendidura se aclararía. + const keepBack = document.createElement('i') + keepBack.className = 'leafkeepback' + place(keepBack, rel(stay)) + box.append(keepBack, keep) + extra.push(keepBack, keep) + old.style.visibility = 'hidden' // la hoja y la copia ocupan su lugar + } else { + // Una página: el lomo está a la izquierda en izq→der y a la derecha en der→izq. Hacia delante, la + // página actual se levanta y se va; hacia atrás, la anterior vuelve y se coloca encima (el mismo + // gesto al revés, con el borde libre por delante), con la actual debajo hasta que la tapa. + const img = dir > 0 ? oldImgs[0] : stage.querySelector('img') + rect = rel(img) + frontIm = img + spineLeft = settings.dir !== 'rtl' + reverse = dir < 0 + if (dir > 0) old.style.visibility = 'hidden' + } + const grab = grabOf(rect) + const curl = glCurl(rect, frontIm, backIm, spineLeft, paper) || paperCurl(rect, frontIm.src, backIm?.src || null, spineLeft, !!grab) + // Sin WebGL, la sombra de debajo es un degradado desde el lomo. + let drop = null + if (!curl.gl) { + place(curl.el, rect) + drop = document.createElement('i') + drop.className = `curldrop ${spineLeft ? 'l' : 'r'}` + place(drop, rect) + box.append(drop) + extra.push(drop) + } + box.append(curl.el) + extra.push(curl.el) + ;[g0, g1] = double ? [0, 1] : curl.span(reverse) + // Tocada o agarrada por arriba o por abajo (tercio superior o inferior), esa parte va por delante: la hoja se curva + // sin separarse del lomo. Sin capas nuevas; es 0 al principio y al final del giro, así que cae exacta. + // Por el centro, como siempre. + if (grab) curl.pivot(grab < 0 ? 'bottom' : 'top') + step = g => { + const k = grab ? grab * Math.sin(Math.PI * Math.min(1, Math.max(0, (g - g0) / (g1 - g0 || 1)))) : 0 + const lift = curl.set(g, reverse, (spineLeft ? 1 : -1) * (reverse ? -1 : 1) * GRAB_TILT_Z * k, GRAB_TILT_X * k) + if (drop) drop.style.opacity = (lift * 0.8).toFixed(3) + } + dur = double ? 750 : 610 + auto = t => g0 + (g1 - g0) * t + // El borde libre va donde lo lleva el dedo: de su sitio al lomo (una página) o al otro lado (doble); + // hacia atrás en una página, del lomo a su sitio. + range = double ? 2 * rect.w : rect.w + toG = q => curl.solve(reverse ? q : 1 - q * (double ? 2 : 1), reverse) + } + if (drag) { + let g = toG(0) + step(g) + const ctl = { + anims: [], cleanup, range, pan, + move(q) { g = toG(Math.min(1, Math.max(0, q))); step(g) }, + // Termina de pasar la página (commit) o la devuelve a su sitio; done(commit) al acabar. + release(commit, done) { + const from = g, to = commit ? g1 : g0 + const a = frameAnim(Math.max(120, (dur * Math.abs(to - from)) / (g1 - g0 || 1)), t => { g = from + (to - from) * easeOut(t); step(g) }) + ctl.anims = [a] + a.finished.then(() => { + if (turning !== ctl) return + turning = null + if (commit) cleanup() + else revert() + done(commit) + }, () => {}) + }, + } + turning = ctl + return + } + const anims = [frameAnim(dur, t => step(auto(t)))] + turning = { anims, cleanup } + anims[0].finished.then(() => { if (turning?.anims === anims) { turning = null; cleanup() } }, () => {}) + } + function showEnd(dir = 0) { + finishTurn() + showSeq++ // descarta una página que estuviera esperando a cargar + clearTimeout(waitTimer) + box.classList.remove('waiting') + atEnd = true + resetZoom() + box.innerHTML = endBlock + if (!b3dWanted()) reader.classList.remove('hide-ui') // en el libro 3D el final se ve en la contraportada, sin barras encima + else if (dir && !wantDrag && settings.anim === 'flip') turnSound(settings.sfx, turnPan(dir)) + syncB3d(settings.anim === 'flip' ? dir : 0, wantDrag) + } + + function goTo(n, fromScroll = false, force = false, dir = 0) { + if (isPaged()) { + if (n >= total) return showEnd(dir) + n = Math.min(Math.max(n, 0), total - 1) + const g = groupOf(n) + const changed = atEnd || g[0] !== shown[0] || g.length !== shown.length + const wasEnd = atEnd + atEnd = false + shown = g + page = g[0] + setPageLabel() + if (changed || force || !$('.stage img', box)) showPaged(changed && (!wasEnd || b3dWanted()) ? dir : 0) + if (changed) scheduleSave() + return + } + n = Math.min(Math.max(n, 0), total - 1) + const changed = n !== page + page = n + shown = [n] + setPageLabel() + if (!fromScroll) box.querySelectorAll('img')[page]?.scrollIntoView() + if (changed) scheduleSave() + } + const forward = () => { if (!atEnd) goTo(shown.at(-1) + 1, false, false, 1) } + const backward = () => { + if (atEnd) { box.innerHTML = ''; goTo(total - 1, false, true, -1); return } + if (page > 0) goTo(groupOf(page - 1)[0], false, false, -1) + } + + // En der → izq, la barra de abajo va como la lectura: el capítulo siguiente a la izquierda. + function syncDirUi() { + const rtl = isPaged() && settings.dir === 'rtl' + slider.dir = rtl ? 'rtl' : 'ltr' + $('.rbar.bottom').classList.toggle('rtl', rtl) + } + + // --- Libro 3D: otro módulo dibuja el libro; el lector sigue llevando la página, el progreso y los gestos. + const fromShelf = shelfTrip?.mangaId === mangaId // desde el estante se lee en 3D aunque el ajuste diga otra cosa (sin guardarlo) + const b3dWanted = () => B3D_OK && (!!settings.book3d || fromShelf) && isPaged() && settings.fit !== 'width' + const b3dOn = () => b3dWanted() && !atEnd + const b3dPaper = () => { + const hex = book ? (BOOK_PAPERS[bookSettings().paper] || BOOK_PAPERS.light).bg : '#efeadf' + return [1, 3, 5].map(n => parseInt(hex.slice(n, n + 2), 16) / 255) + } + // El final del capítulo va sobre la contraportada, con sus colores, y la sigue cuando el libro se mueve. + function placeEnd(at, colors) { + const el = $('.chapter-end', box) + if (!el) return + el.classList.toggle('on', !!at) + if (!at) return + const o = box.getBoundingClientRect() + el.style.width = `${at.width}px` + el.style.height = `${at.height}px` + el.style.transform = `translate(${at.x - o.left}px, ${at.y - o.top}px)` + if (colors) for (const [k, v] of Object.entries(colors)) el.style.setProperty(`--end-${k}`, v) + } + function syncB3d(dir = 0, drag = false) { + const on = b3dWanted(), host = $('#b3d', reader) + if (!host) return + reader.classList.toggle('b3d', on) + host.hidden = !on + if (!b3dWanted()) { + b3d?.destroy() + b3d = null + return + } + // Las hojas del lector normal no se usan aquí: que no ocupen memoria dos veces. + clearTimeout(leafTimer) + for (const tex of leafTextures.values()) leafGL?.gl.deleteTexture(tex) + leafTextures.clear() + if (b3d) { + if (!on) return + const ctl = atEnd ? b3d.showBack(dir, drag) : b3d.show(shown, dir, drag) + if (ctl) turning = ctl + return + } + if (b3dLoading) return + b3dLoading = true + import(`/book3d.js?v=${document.querySelector('meta[name=b3d-version]')?.content || ''}`).then(m => { + b3dLoading = false + if (!b3dWanted() || !box.isConnected) return + b3d = m.createBook3D(host, { + total, src: pageSrc, whenReady: book ? (i, cb) => book.whenReady(i, cb) : null, spreadOf, isWide: i => wide.has(i), + dir: () => settings.dir, paper: b3dPaper, still: reduceMotion, table: me?.tableV ? `/api/table?v=${me.tableV}` : null, back: tome?.back || null, double: isDouble, onTurnEnd: ctl => { if (turning === ctl) turning = null }, onBack: placeEnd, + }) + if (!b3d) throw new Error('sin WebGL2') + applyFilter() + syncB3d() + }).catch(err => { + console.warn('Libro 3D:', err) + b3dLoading = false + settings.book3d = false + saveGlobal() + if ($('#b3dfx', reader)) $('#b3dfx', reader).checked = false + syncB3d() + toast('El libro 3D no está disponible en este dispositivo') + }) + } + function render() { + observer?.disconnect() + const keep = lastShown() + if (book && sizeChanged()) return reopenBook() // cambiar a doble página cambia el tamaño de página + resetZoom() + box.className = isPaged() ? `paged ${settings.fit === 'width' ? 'fit-width' : ''} ${isDouble() ? 'double' : ''}` : `vertical fit-${settings.fit}` + renderModeQuick() + box.dataset.dir = settings.dir + syncB3d() + // En der → izq la barra de progreso también avanza de derecha a izquierda (en vertical no aplica). + syncDirUi() + $('#dir').hidden = !isPaged() + if (isPaged()) { + box.innerHTML = '' + atEnd = false + shown = [] + goTo(keep) + return + } + page = keep + shown = [keep] + // Las páginas de un libro tienen tamaño conocido: no hace falta cargar las anteriores para saltar a la guardada. + const near = i => (book ? Math.abs(i - page) <= 2 : i < page + 3) + box.innerHTML = Array.from({ length: total }, (_, i) => ``).join('') + endBlock + box.querySelectorAll('img').forEach(img => img.addEventListener('load', () => img.setAttribute('data-loaded', ''), { once: true })) + const lazy = new IntersectionObserver(entries => entries.forEach(e => { + if (!e.isIntersecting || !e.target.dataset.lazy) return + delete e.target.dataset.lazy + const i = Number(e.target.dataset.i) + if (book) book.whenReady(i, url => { if (!e.target.dataset.lazy) e.target.src = url }) + else e.target.src = pageSrc(i) + }), { rootMargin: '2000px 0px' }) + // No seguir el scroll hasta haber saltado a la página guardada, para no pisarla con la 0. + let ready = false + observer = new IntersectionObserver(entries => { + if (!ready) return + for (const e of entries) if (e.isIntersecting) goTo(Number(e.target.dataset.i), true) + }, { rootMargin: '-45% 0px -45% 0px' }) + const imgs = [...box.querySelectorAll('img')] + imgs.forEach(img => { lazy.observe(img); observer.observe(img) }) + const lazyDisconnect = observer.disconnect.bind(observer) + observer.disconnect = () => { lazyDisconnect(); lazy.disconnect() } + const before = (book ? [] : imgs.slice(0, page + 1)).map(img => img.complete ? null : new Promise(r => { img.addEventListener('load', r, { once: true }); img.addEventListener('error', r, { once: true }) })) + Promise.race([Promise.all(before), new Promise(r => setTimeout(r, 8000))]).then(() => { + imgs[page]?.scrollIntoView() + lastY = window.scrollY + setTimeout(() => (ready = true), 300) + }) + setPageLabel() + } + + // --- Zoom (modos por página): pellizco, doble toque/clic en el centro, Ctrl+rueda y teclas + - 0. + const zoom = { s: 1, x: 0, y: 0 } + const stageEl = () => $('.stage:not(.leaving)', box) + function applyZoom() { + const st = stageEl() + if (st) st.style.transform = zoom.s === 1 ? '' : `translate(${zoom.x}px, ${zoom.y}px) scale(${zoom.s})` + box.classList.toggle('zoomed', zoom.s > 1) + if (b3dWanted()) b3d?.setZoom(zoom.s, zoom.x, zoom.y) + $('#zoomreset').hidden = zoom.s === 1 + } + function clampPan() { + const st = stageEl() + if (!st) return + const W = st.offsetWidth, H = st.offsetHeight + zoom.x = Math.min(0, Math.max(W - W * zoom.s, zoom.x)) + zoom.y = Math.min(0, Math.max(H - H * zoom.s, zoom.y)) + } + // (px, py): punto en coordenadas del contenedor que queda fijo al cambiar la escala. + function zoomAt(s, px, py) { + s = Math.min(5, Math.max(1, s)) + const cx = (px - zoom.x) / zoom.s, cy = (py - zoom.y) / zoom.s + zoom.s = s + zoom.x = px - cx * s + zoom.y = py - cy * s + if (s === 1) { zoom.x = 0; zoom.y = 0 } + clampPan() + applyZoom() + } + function resetZoom() { zoom.s = 1; zoom.x = 0; zoom.y = 0; applyZoom() } + const local = (cx, cy) => { const r = box.getBoundingClientRect(); return [cx - r.left, cy - r.top] } + + const toggleUi = () => { reader.classList.toggle('hide-ui'); if (reader.classList.contains('hide-ui')) $('.rpanel', reader).hidden = true } + const pts = new Map() + let gesture = null, tap = null, lastTap = { t: 0, x: 0, y: 0 }, uiTimer = null + function onTap(cx, cy) { + const now = Date.now() + const dbl = now - lastTap.t < 300 && Math.hypot(cx - lastTap.x, cy - lastTap.y) < 40 + lastTap = dbl ? { t: 0, x: 0, y: 0 } : { t: now, x: cx, y: cy } + const xr = cx / window.innerWidth + const center = xr > 0.3 && xr < 0.7 + if (dbl && !atEnd && (center || zoom.s > 1)) { + clearTimeout(uiTimer) + if (zoom.s > 1) resetZoom() + else zoomAt(2.5, ...local(cx, cy)) + return + } + if (zoom.s > 1) return // con zoom, un toque no pasa página + if (center) { clearTimeout(uiTimer); uiTimer = setTimeout(toggleUi, 280); return } + // La hoja se levanta por la altura del toque, igual que al arrastrarla. + grabY = cy + try { (xr >= 0.7) === (settings.dir === 'ltr') ? forward() : backward() } finally { grabY = null } + } + box.addEventListener('pointerdown', e => { + if (!isPaged() || (atEnd && !b3dWanted()) || e.target.closest('a,button,select,input')) return + // Un primer dedo con otros aún apuntados: sus «levantar» nunca llegaron. Sin esto, cada toque contaría como pellizco para siempre. + if (e.isPrimary && pts.size) { + pts.clear() + tap = null + if (gesture?.type === 'turn') finishTurn() + gesture = null + } + if (gesture?.type === 'turn') return // otro dedo mientras se pasa la página + if (e.pointerType === 'mouse' && e.button !== 0) return + if (settings.sfx !== 'off') sfxUnlock() + pts.set(e.pointerId, { x: e.clientX, y: e.clientY }) + if (pts.size === 1) { + tap = { x: e.clientX, y: e.clientY, t: e.timeStamp, moved: false } // la hora del evento: un tirón no debe alargar el toque + gesture = zoom.s > 1 ? { type: 'pan', x: e.clientX, y: e.clientY } : null + if (gesture) box.setPointerCapture?.(e.pointerId) + } else if (pts.size === 2 && atEnd) { + tap = null // la contraportada no se amplía + } else if (pts.size === 2) { + const [a, b] = [...pts.values()] + gesture = { type: 'pinch', d: Math.hypot(a.x - b.x, a.y - b.y), mx: (a.x + b.x) / 2, my: (a.y + b.y) / 2 } + tap = null + } + }) + box.addEventListener('pointermove', e => { + if (!pts.has(e.pointerId)) return + pts.set(e.pointerId, { x: e.clientX, y: e.clientY }) + if (gesture?.type === 'turn') { + if (turning !== gesture.ctl) return + const d = (e.clientX - gesture.x0) * gesture.k + gesture.v = (d - gesture.d) / Math.max(1, e.timeStamp - gesture.t) + gesture.d = d + gesture.t = e.timeStamp + gesture.ctl.move(d / gesture.ctl.range) + return + } + if (tap && Math.hypot(e.clientX - tap.x, e.clientY - tap.y) > 10) tap.moved = true + // Arrastre horizontal con un dedo: la página empieza a pasar y sigue al dedo. + if (tap && !gesture && pts.size === 1 && zoom.s === 1 && e.pointerType !== 'mouse' && settings.anim !== 'none') { + const dx = e.clientX - tap.x, dy = e.clientY - tap.y + if (Math.abs(dx) > 14 && Math.abs(dx) > Math.abs(dy) * 1.3) { + const fwd = (dx < 0) === (settings.dir === 'ltr') + if (fwd ? !atEnd && (shown.at(-1) + 1 < total || b3dWanted()) : page > 0 || atEnd) { + const prev = [...shown], wasEnd = atEnd + finishTurn() + wantDrag = true + grabY = tap.y + try { fwd ? forward() : backward() } finally { wantDrag = false; grabY = null } + if (turning?.move) { + gesture = { type: 'turn', ctl: turning, prev, wasEnd, dir: fwd ? 1 : -1, x0: tap.x, k: dx < 0 ? -1 : 1, d: Math.abs(dx), v: 0, t: e.timeStamp } + turning.move(gesture.d / turning.range) + box.setPointerCapture?.(e.pointerId) + } + tap = null + } + } + } + if (gesture?.type === 'pinch' && pts.size >= 2) { + const [a, b] = [...pts.values()] + const d = Math.hypot(a.x - b.x, a.y - b.y), mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2 + zoom.x += mx - gesture.mx + zoom.y += my - gesture.my + zoomAt(zoom.s * (d / gesture.d), ...local(mx, my)) + Object.assign(gesture, { d, mx, my }) + } else if (gesture?.type === 'pan') { + zoom.x += e.clientX - gesture.x + zoom.y += e.clientY - gesture.y + gesture.x = e.clientX + gesture.y = e.clientY + clampPan() + applyZoom() + } + }) + const endPointer = e => { + if (!pts.delete(e.pointerId)) return + if (gesture?.type === 'turn') { + // Se pasa si se ha llevado más de un 30 % o se ha soltado con impulso hacia delante. + const g = gesture + gesture = null + tap = null + if (turning !== g.ctl) return + const q = g.d / g.ctl.range + const commit = e.type === 'pointerup' && (g.v > 0.35 || (q > 0.3 && g.v > -0.15)) + if (commit && !g.ctl.pan) turnSound(settings.sfx, turnPan(g.dir)) + g.ctl.release(commit, ok => { + if (ok) return + shown = g.prev + page = g.prev[0] + setPageLabel() + scheduleSave() + if (g.wasEnd) return showEnd() // se quedó en la contraportada + atEnd = false + if (b3dOn()) showPaged() // el lector vuelve a la página de antes, sin animación + }) + return + } + if (pts.size === 1 && zoom.s > 1) { + const [p] = [...pts.values()] + gesture = { type: 'pan', x: p.x, y: p.y } + } else if (pts.size === 0) { + if (e.type === 'pointerup' && tap && !tap.moved && e.timeStamp - tap.t < 350) onTap(e.clientX, e.clientY) + else if (e.type === 'pointerup' && tap?.moved && zoom.s === 1 && e.pointerType !== 'mouse') { + // Deslizar el dedo en horizontal pasa página (hacia la izquierda = adelante en izq→der). + const dx = e.clientX - tap.x, dy = e.clientY - tap.y + if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy) * 1.5 && e.timeStamp - tap.t < 800) ((dx < 0) === (settings.dir === 'ltr') ? forward : backward)() + } + gesture = null + tap = null + } + } + box.addEventListener('pointerup', endPointer) + box.addEventListener('pointercancel', endPointer) + box.addEventListener('wheel', e => { + if (!isPaged() || atEnd || !e.ctrlKey) return // Ctrl+rueda o pellizco del trackpad + e.preventDefault() + zoomAt(zoom.s * Math.exp(-e.deltaY / 200), ...local(e.clientX, e.clientY)) + }, { passive: false }) + // En modo vertical el zoom es el nativo del navegador; un toque muestra/oculta las barras. + box.addEventListener('click', e => { + if (isPaged() || e.target.closest('a,button')) return + reader.classList.toggle('hide-ui') + }) + $('#zoomreset').addEventListener('click', resetZoom) + $('#ropts').addEventListener('click', () => { const p = $('.rpanel', reader); p.hidden = !p.hidden; $('#ropts').classList.toggle('on', !p.hidden) }) + + const saveGlobal = () => store.set('readerSettings', { tomes: settings.tomes !== false, saver: settings.saver, filter: settings.filter, brightness: settings.brightness, hd: settings.hd !== false, anim: settings.anim, sfx: settings.sfx, book3d: !!settings.book3d }) + const savePrefs = () => { + return api(`/api/prefs/${mangaId}`, { method: 'POST', body: { mode: settings.mode, fit: settings.fit, dir: settings.dir } }) + .then(() => toast(`Guardado para este ${noun}`)).catch(err => toast(err.message)) + } + // En un libro, volver a maquetar reabre el lector: antes hay que esperar a que el modo nuevo esté guardado. + const savePrefsThenRender = () => { const saved = savePrefs(); if (book) saved.finally(render); else render() } + $('#mode').addEventListener('change', e => { settings.mode = e.target.value; if (isPaged()) lastPaged = settings.mode; savePrefsThenRender() }) + $('#modequick').addEventListener('click', () => { + settings.mode = isPaged() ? 'vertical' : lastPaged + savePrefsThenRender() + }) + $('#fit').addEventListener('change', e => { settings.fit = e.target.value; savePrefs(); render() }) + $('#dir').addEventListener('change', e => { + settings.dir = e.target.value + box.dataset.dir = settings.dir + refreshGutters() + syncDirUi() + savePrefs() + }) + $('#filter').addEventListener('change', e => { settings.filter = e.target.value; saveGlobal(); applyFilter() }) + $('#anim').value = settings.anim + $('#anim').addEventListener('change', e => { settings.anim = e.target.value; saveGlobal() }) + $('#sfx').value = SOUND_LEVELS[settings.sfx] ? settings.sfx : 'mid' + $('#sfx').addEventListener('change', e => { settings.sfx = e.target.value; saveGlobal(); if (settings.sfx !== 'off') turnSound(settings.sfx) }) + $('#bright').addEventListener('input', e => { settings.brightness = Number(e.target.value); saveGlobal(); applyFilter() }) + $('#bright').addEventListener('dblclick', e => { e.target.value = settings.brightness = 100; saveGlobal(); applyFilter() }) + $('#saver')?.addEventListener('change', e => { settings.saver = e.target.checked; saveGlobal(); history.replaceState(null, '', readUrl(mangaId, chapterId, routePage())); router() }) + $('#hd')?.addEventListener('change', e => { settings.hd = e.target.checked; saveGlobal(); history.replaceState(null, '', readUrl(mangaId, chapterId, routePage())); router() }) + $('#hdone')?.addEventListener('click', async e => { + e.target.closest('button').disabled = true + try { await api(`/api/upscale/${mangaId}`, { method: 'POST', body: { chapterId } }); toast('En cola: estará en HD en unos minutos') } catch (err) { toast(err.message); e.target.closest('button').disabled = false } + }) + if ($('#tomefx')) { + $('#tomefx').checked = settings.tomes !== false + $('#tomefx').addEventListener('change', e => { settings.tomes = e.target.checked; saveGlobal(); history.replaceState(null, '', readUrl(mangaId, chapterId, routePage())); router() }) + } + // Un capítulo de este libro se alcanza pasando páginas; uno de otro tomo se abre aparte. + const inSeq = c => !!book || !!tome?.has(c) + $('#chap').addEventListener('change', e => { + const c = chapters.findIndex(x => x.id === e.target.value) + if (seq && inSeq(c)) goTo(seq.chapterStart(c)) + else location.hash = readUrl(mangaId, e.target.value) + }) + if (seq) { + $('#bnext').addEventListener('click', e => { + e.preventDefault() + if (inSeq(curChap + 1)) goTo(seq.chapterStart(curChap + 1)) + else location.hash = readUrl(mangaId, chapters[curChap + 1].id) + }) + $('#bprev').addEventListener('click', e => { + e.preventDefault() + // Al inicio del capítulo actual o, si ya se está ahí, al anterior que empiece en otra página. + let c = curChap + while (c > 0 && inSeq(c - 1) && seq.chapterStart(c) >= (shown[0] ?? page)) c-- + if (seq.chapterStart(c) < (shown[0] ?? page)) goTo(seq.chapterStart(c)) + else if (!book && tome.cover && c === tome.parts[0].i && (shown[0] ?? page) > 0) goTo(0) + else if (c > 0 && !inSeq(c - 1)) location.hash = readUrl(mangaId, chapters[c - 1].id) + else goTo(seq.chapterStart(c)) + }) + } + if (book) { + book.onChange = i => { box.querySelectorAll(`img[data-i="${i}"]`).forEach(im => { im.src = book.src(i) }); if (b3dOn()) b3d?.invalidate(i) } + } + $('#offone')?.addEventListener('click', e => { + const b = e.target.closest('button') + b.disabled = true + b.innerHTML = `${icon('phone')} Guardando…` + const done = () => { if (offHas(chapterId) && b.isConnected) { b.innerHTML = `${icon('phone')} En el dispositivo`; offState.listeners.delete(done) } } + offState.listeners.add(done) + offEnqueue([{ mangaId, mangaTitle: manga.title, cover: manga.cover, lang, chapterId, label: chapLabel(chapter), num: chapter.num }]) + }) + slider.addEventListener('input', () => goTo(Number(slider.value) - 1)) + + // --- Recortes: se arrastra un rectángulo sobre la página y se ajusta con los puntos de sus esquinas. Se recorta + // del original (no de la versión HD) y el servidor lo reescala aparte. El rectángulo se guarda en coordenadas + // del documento: al llevar un punto al borde de la pantalla la lectura se desplaza y la zona sigue creciendo, y + // en lectura vertical puede abarcar varias páginas, que se unen en una sola imagen. + const CROP_MAX_SIDE = 8000 // el mismo límite que en el servidor (clips.js) + const CROP_MAX_AREA = 16e6 // por encima, Safari en iOS puede no generar el canvas + let crop = null + function endCrop() { + if (!crop) return + cancelAnimationFrame(crop.raf) + crop.ov.remove() + window.removeEventListener('scroll', crop.draw) + crop = null + reader.classList.remove('b3dcrop') + $('#clipbtn')?.classList.remove('on') + } + function startCrop() { + if (crop) return endCrop() + if (atEnd || !box.querySelector('img')) return toast('Vuelve a una página para recortar') + reader.classList.add('b3dcrop') // para recortar se ve la página plana, como en el lector normal + reader.classList.remove('hide-ui') + $('.rpanel', reader).hidden = true + const ov = document.createElement('div') + ov.className = 'cropov' + ov.innerHTML = `
    ${icon('scissors')} Arrastra sobre el panel que quieres guardar + +
    + ` + reader.append(ov) + $('#clipbtn').classList.add('on') + const rectEl = $('.crop-rect', ov), saveBtn = $('[data-a=save]', ov), hint = $('.crop-bar em', ov) + crop = { ov, r: null, start: null, prev: null, pan: null, pid: null, off: { x: 0, y: 0 }, last: null, raf: 0 } + crop.draw = () => { + const r = crop?.r + if (!r) return + Object.assign(rectEl.style, { left: `${r.l - scrollX}px`, top: `${r.t - scrollY}px`, width: `${r.r - r.l}px`, height: `${r.b - r.t}px` }) + rectEl.hidden = false + } + const canScroll = () => document.documentElement.scrollHeight > innerHeight + 1 + // Lleva la esquina que se arrastra hasta el dedo; la opuesta (crop.start) se queda fija. + const stretch = () => { + const x = crop.last.x + scrollX + crop.off.x, y = crop.last.y + scrollY + crop.off.y + crop.r = { l: Math.min(x, crop.start.x), t: Math.min(y, crop.start.y), r: Math.max(x, crop.start.x), b: Math.max(y, crop.start.y) } + crop.draw() + } + // Con el dedo cerca del borde de arriba o de abajo, la lectura se desplaza y la zona sigue creciendo. + const EDGE = 80, EDGE_SPEED = 22 + const autoScroll = () => { + if (!crop?.start) return + const y = crop.last.y + const v = y < EDGE ? -(EDGE - y) / EDGE : y > innerHeight - EDGE ? (y - innerHeight + EDGE) / EDGE : 0 + if (v && crop.r) { + scrollBy(0, v * EDGE_SPEED) + stretch() + } + crop.raf = requestAnimationFrame(autoScroll) + } + window.addEventListener('scroll', crop.draw, { passive: true }) + ov.addEventListener('wheel', e => { e.preventDefault(); scrollBy(0, e.deltaY) }, { passive: false }) + ov.addEventListener('pointerdown', e => { + if (e.target.closest('.crop-bar') || (e.pointerType === 'mouse' && e.button !== 0) || crop.pid != null) return + const x = e.clientX + scrollX, y = e.clientY + scrollY, r = crop.r + const handle = e.target.closest('.crop-h')?.dataset.h + crop.pid = e.pointerId + ov.setPointerCapture(e.pointerId) + // Con el dedo la lectura no se puede desplazar de otro modo: deslizar dentro de la zona la mueve, para llegar + // a las esquinas que quedan fuera de la pantalla. + if (!handle && e.pointerType !== 'mouse' && r && canScroll() && x >= r.l && x <= r.r && y >= r.t && y <= r.b) { + crop.pan = e.clientY + return + } + if (handle) { + const cx = handle[1] === 'w' ? r.l : r.r, cy = handle[0] === 'n' ? r.t : r.b + crop.start = { x: handle[1] === 'w' ? r.r : r.l, y: handle[0] === 'n' ? r.b : r.t } + crop.off = { x: cx - x, y: cy - y } + crop.prev = r + } else { + crop.start = { x, y } + crop.off = { x: 0, y: 0 } + crop.prev = null + crop.r = null + rectEl.hidden = true + saveBtn.disabled = true + } + crop.last = { x: e.clientX, y: e.clientY } + crop.raf = requestAnimationFrame(autoScroll) + }) + ov.addEventListener('pointermove', e => { + if (e.pointerId !== crop.pid) return + if (crop.pan != null) { + scrollBy(0, crop.pan - e.clientY) + crop.pan = e.clientY + return + } + if (!crop.start) return + crop.last = { x: e.clientX, y: e.clientY } + stretch() + }) + const up = e => { + if (!crop || e.pointerId !== crop.pid) return + crop.pid = null + crop.pan = null + if (!crop.start) return + cancelAnimationFrame(crop.raf) + crop.start = null + // Demasiado pequeña: al ajustar vuelve a como estaba; al marcarla de cero, no queda nada. + if (!crop.r || crop.r.r - crop.r.l < 12 || crop.r.b - crop.r.t < 12) crop.r = crop.prev + if (crop.r) crop.draw() + else rectEl.hidden = true + saveBtn.disabled = !crop.r + if (crop.r) hint.textContent = canScroll() ? 'Mueve los puntos; llévalos al borde para seguir' : 'Ajusta la zona con los puntos' + } + ov.addEventListener('pointerup', up) + ov.addEventListener('pointercancel', up) + $('[data-a=cancel]', ov).addEventListener('click', endCrop) + saveBtn.addEventListener('click', e => saveCrop(e.target.closest('button'))) + } + // Las imágenes bajo el rectángulo y la zona de cada una, en píxeles de pantalla desde su esquina. Por páginas, solo + // la de mayor intersección; en vertical, todas las que toca, de arriba abajo. + function cropParts(r) { + const c = { l: r.l - scrollX, t: r.t - scrollY, r: r.r - scrollX, b: r.b - scrollY } + const hits = [] + for (const img of box.querySelectorAll('img')) { + const b = img.getBoundingClientRect() + const w = Math.min(c.r, b.right) - Math.max(c.l, b.left), h = Math.min(c.b, b.bottom) - Math.max(c.t, b.top) + if (w <= 0 || h <= 0) continue + hits.push({ img, b, area: w * h, x0: Math.max(c.l, b.left) - b.left, y0: Math.max(c.t, b.top) - b.top, x1: Math.min(c.r, b.right) - b.left, y1: Math.min(c.b, b.bottom) - b.top }) + } + const ready = hit => hit.img.complete && hit.img.naturalWidth > 0 + if (isPaged()) { + const loaded = hits.filter(ready) + return loaded.length ? [loaded.reduce((a, h) => (h.area > a.area ? h : a))] : [] + } + if (!hits.every(ready)) throw new Error('Espera a que carguen las páginas de la zona') + return hits + } + async function saveCrop(btn) { + btn.disabled = true + try { + const parts = crop?.r ? cropParts(crop.r) : [] + if (!parts.length) throw new Error('Marca una zona sobre la página') + // Del original: el servidor lo reescala él mismo. + const sources = await Promise.all(parts.map(({ img }) => new Promise((resolve, reject) => { + const u = new URL(img.currentSrc || img.src, location.href) + u.searchParams.delete('hd') + const im = new Image() + im.onload = () => resolve(im) + im.onerror = () => reject(new Error('No se pudo cargar la página')) + im.src = u.href + }))) + // Misma escala para todas (la de la página con más detalle), una debajo de otra y sin los huecos de la lectura. + const scale = Math.max(...parts.map((h, i) => sources[i].naturalWidth / h.b.width)) + const left = Math.min(...parts.map(h => h.b.left + h.x0)), right = Math.max(...parts.map(h => h.b.left + h.x1)) + const fullW = (right - left) * scale, fullH = parts.reduce((sum, h) => sum + h.y1 - h.y0, 0) * scale + if (fullW < 16 || fullH < 16) throw new Error('La zona es demasiado pequeña') + const k = scale * Math.min(1, CROP_MAX_SIDE / Math.max(fullW, fullH), Math.sqrt(CROP_MAX_AREA / (fullW * fullH))) + const cv = document.createElement('canvas') + cv.width = Math.round((right - left) * k) + cv.height = Math.round(parts.reduce((sum, h) => sum + h.y1 - h.y0, 0) * k) + const ctx = cv.getContext('2d') + ctx.imageSmoothingQuality = 'high' + if (parts.length > 1) { ctx.fillStyle = '#000'; ctx.fillRect(0, 0, cv.width, cv.height) } // páginas de distinto ancho + let y = 0 + parts.forEach((h, i) => { + const src = sources[i], fx = src.naturalWidth / h.b.width, fy = src.naturalHeight / h.b.height + const top = Math.round(y * k) + y += h.y1 - h.y0 + ctx.drawImage(src, Math.round(h.x0 * fx), Math.round(h.y0 * fy), Math.max(1, Math.round((h.x1 - h.x0) * fx)), Math.max(1, Math.round((h.y1 - h.y0) * fy)), + Math.round((h.b.left + h.x0 - left) * k), top, Math.round((h.x1 - h.x0) * k), Math.round(y * k) - top) + }) + const blob = await new Promise(r => cv.toBlob(r, 'image/png')) + if (!blob) throw new Error('La zona es demasiado grande para este dispositivo') + const at = Number(parts[0].img.dataset.i), from = tome ? chapters[tome.chapterAt(at)] : chapter + const qs = new URLSearchParams({ mangaId, chapterId: from.id, page: tome ? tome.rawPage(tome.chapterAt(at), Math.max(0, at - tome.chapterStart(tome.chapterAt(at)))) : at, mangaTitle: manga.title, chapterLabel: chapLabel(from), cover: manga.cover || '' }) + const res = await fetch(`/api/clips?${qs}`, { method: 'POST', headers: { 'Content-Type': 'image/png' }, body: blob }) + const data = await res.json().catch(() => ({})) + if (!res.ok) throw new Error(data.error || `Error ${res.status}`) + endCrop() + toast(data.status === 'off' ? 'Recorte guardado' : 'Recorte guardado: se está mejorando a HD') + } catch (err) { + toast(err.message) + btn.disabled = false + } + } + $('#clipbtn').addEventListener('click', startCrop) + + const onKey = e => { + if (crop) { if (e.key === 'Escape') endCrop(); return } + if (!isPaged() || e.target.matches('input,select')) return + const fwdKey = settings.dir === 'ltr' ? 'ArrowRight' : 'ArrowLeft' + const backKey = settings.dir === 'ltr' ? 'ArrowLeft' : 'ArrowRight' + const [cx, cy] = [box.clientWidth / 2, Math.min(box.clientHeight, window.innerHeight) / 2] + if (e.key === fwdKey || e.key === ' ') { e.preventDefault(); forward() } + else if (e.key === backKey) backward() + else if (atEnd) return + else if (e.key === '+' || e.key === '=') zoomAt(zoom.s * 1.25, cx, cy) + else if (e.key === '-') zoomAt(zoom.s / 1.25, cx, cy) + else if (e.key === '0') resetZoom() + } + document.addEventListener('keydown', onKey) + let lastY = 0 + const onScroll = () => { + if (settings.mode !== 'vertical') return + const y = window.scrollY + if (Math.abs(y - lastY) > 30) { reader.classList.toggle('hide-ui', y > lastY); lastY = y } + } + window.addEventListener('scroll', onScroll, { passive: true }) + // Al girar la tablet o redimensionar, rehacer la agrupación de doble página. + let resizeTimer = null + const onResize = () => { + clearTimeout(resizeTimer) + resizeTimer = setTimeout(() => { + if (book && sizeChanged()) return reopenBook() + if (!isPaged() || atEnd) return + box.classList.toggle('double', isDouble()) + goTo(lastShown(), false, true) + }, 200) + } + window.addEventListener('resize', onResize) + + if (book) { + const opts = bookSettings() + for (const [id, key] of [['bsize', 'size'], ['bfont', 'font'], ['bmargin', 'margin'], ['bpaper', 'paper']]) { + $(`#${id}`).value = String(opts[key]) + $(`#${id}`).addEventListener('change', e => { + store.set('bookSettings', { ...bookSettings(), [key]: key === 'size' ? Number(e.target.value) : e.target.value }) + reopenBook() + }) + } + } + if ($('#b3dfx')) { + $('#b3dfx').checked = !!settings.book3d + $('#b3dfx').addEventListener('change', e => { + settings.book3d = e.target.checked + saveGlobal() + // iOS solo pide el permiso del sensor desde un toque, y este lo es. El resultado lo recoge el módulo al arrancar. + if (e.target.checked) (window.DeviceMotionEvent?.requestPermission || window.DeviceOrientationEvent?.requestPermission)?.call(window.DeviceMotionEvent?.requestPermission ? DeviceMotionEvent : DeviceOrientationEvent)?.catch?.(() => {}) + render() + }) + } + render() + cleanup = () => { + b3d?.destroy() + b3d = null + book?.release() + clearInterval(tomeTimer) + freeLeafGL() + endCrop() + if (saveTimer) saveNow() + observer?.disconnect() + clearTimeout(uiTimer) + document.removeEventListener('visibilitychange', onVis) + window.removeEventListener('pagehide', beacon) + document.removeEventListener('keydown', onKey) + window.removeEventListener('scroll', onScroll) + window.removeEventListener('resize', onResize) + } +} + +// ---------- Recortes ---------- +const clipStatus = c => c.status === 'done' ? `HD` + : c.status === 'error' ? 'HD!' + : c.status === 'off' ? '' : 'HD…' +async function viewClips(params) { + const mangaId = params.get('manga') + const here = () => location.hash.split('?')[0] === '#/clips' + app.innerHTML = '
    Cargando…
    ' + let data = await api(`/api/clips${mangaId ? `?manga=${encodeURIComponent(mangaId)}` : ''}`) + if (!here()) return + const total = data.mangas.reduce((a, m) => a + m.n, 0) + app.innerHTML = `

    ${icon('scissors')} Recortes

    + ${data.mangas.length > 1 || mangaId ? `
    + + ${data.mangas.map(m => ``).join('')}
    ` : ''} +
    + ` + app.querySelectorAll('.tabs [data-m]').forEach(b => b.addEventListener('click', () => { location.hash = b.dataset.m ? `#/clips?manga=${encodeURIComponent(b.dataset.m)}` : '#/clips' })) + const grid = $('#cgrid'), lb = $('#lb') + let open = -1, showHd = true + + function renderGrid() { + grid.innerHTML = data.clips.length ? data.clips.map((c, i) => ` +
    + +
    ${esc(c.mangaTitle)}${esc((c.chapterLabel || '').split(' — ')[0])} · p. ${c.page + 1} ${clipStatus(c)} + ${c.note ? `${esc(c.note)}` : ''}
    +
    `).join('') + : `

    Aún no tienes recortes. En el lector, toca ${icon('scissors')} y arrastra sobre un panel para guardarlo aquí; se mejora a HD automáticamente.

    ` + grid.querySelectorAll('.clip').forEach(f => f.addEventListener('click', () => openClip(Number(f.dataset.i)))) + } + function openClip(i) { + open = i + const c = data.clips[i] + if (!c) return closeClip() + const src = c.hd && showHd ? c.hd : c.img + const size = c.scale ? `${c.width}×${c.height} → ${c.width * c.scale}×${c.height * c.scale} (×${c.scale})` : `${c.width}×${c.height}` + lb.innerHTML = ` +
    +
    +
    + ${i + 1} / ${data.clips.length} + + + + + +
    +

    ${esc(c.mangaTitle)}

    +
    ${esc(c.chapterLabel || '')} · página ${c.page + 1}
    +
    ${size} · ${timeAgo(c.createdAt)} ${clipStatus(c)}
    + ${c.status === 'error' ? `

    ${esc(c.error || 'Error al reescalar')}

    ` : ''} + ${c.status === 'off' ? '

    Reescalado no disponible en este servidor.

    ' : ''} + + ${icon('book')} Ir a la página + ${c.hd ? `` : ''} +
    + ${icon('download')} Descargar + ${c.status === 'error' || (c.status === 'off' && data.available) ? `` : ''} + +
    +
    ` + lb.hidden = false + document.body.classList.add('noscroll') + lb.querySelector('[data-a=prev]').addEventListener('click', () => openClip(open - 1)) + lb.querySelector('[data-a=next]').addEventListener('click', () => openClip(open + 1)) + lb.querySelector('[data-a=close]').addEventListener('click', closeClip) + $('#showhd', lb)?.addEventListener('change', e => { showHd = e.target.checked; openClip(open) }) + $('#note', lb).addEventListener('change', async e => { + try { Object.assign(c, await api(`/api/clips/${c.id}`, { method: 'PATCH', body: { note: e.target.value } })); renderGrid(); toast('Nota guardada') } catch (err) { toast(err.message) } + }) + lb.querySelector('[data-a=retry]')?.addEventListener('click', async () => { + try { Object.assign(c, await api(`/api/clips/${c.id}/retry`, { method: 'POST' })); renderGrid(); openClip(open); poll() } catch (err) { toast(err.message) } + }) + lb.querySelector('[data-a=del]').addEventListener('click', async () => { + if (!confirm('¿Eliminar este recorte?')) return + try { + await api(`/api/clips/${c.id}`, { method: 'DELETE' }) + data.clips.splice(open, 1) + renderGrid() + data.clips.length ? openClip(Math.min(open, data.clips.length - 1)) : closeClip() + } catch (err) { toast(err.message) } + }) + } + function closeClip() { open = -1; lb.hidden = true; lb.innerHTML = ''; document.body.classList.remove('noscroll') } + lb.addEventListener('click', e => { if (e.target === lb || e.target.classList.contains('lb-img')) closeClip() }) + const onKey = e => { + if (open < 0 || e.target.matches('input')) return + if (e.key === 'Escape') closeClip() + else if (e.key === 'ArrowLeft' && open > 0) openClip(open - 1) + else if (e.key === 'ArrowRight' && open < data.clips.length - 1) openClip(open + 1) + } + document.addEventListener('keydown', onKey) + + // Mientras haya recortes en cola, refrescar su estado. + let timer = null + function poll() { + clearTimeout(timer) + if (!data.clips.some(c => c.status === 'queued' || c.status === 'running')) return + timer = setTimeout(async () => { + if (!here()) return + const fresh = await api(`/api/clips${mangaId ? `?manga=${encodeURIComponent(mangaId)}` : ''}`).catch(() => null) + if (!fresh || !here()) return + const key = d => d.clips.map(c => `${c.id}:${c.status}`).join() + if (key(fresh) !== key(data)) { + const openId = data.clips[open]?.id + data = fresh + renderGrid() + if (openId != null) { const j = data.clips.findIndex(c => c.id === openId); j >= 0 ? openClip(j) : closeClip() } + } + poll() + }, 3000) + } + renderGrid() + poll() + cleanup = () => { clearTimeout(timer); document.removeEventListener('keydown', onKey); document.body.classList.remove('noscroll') } +} + +// ---------- Librería ---------- +// Pestañas: Leyendo, Siguiendo, Terminados, En el dispositivo y las listas propias. +const libCard = ({ href, cover, title, sub, pct, badge, del }) => ` + + ${badge ? `${badge}` : ''} + ${del ? `` : ''} +
    +
    ${esc(title)}
    + ${sub ? `
    ${sub}
    ` : ''} +
    ` +// ---------- Estante 3D ---------- +const SHELF_OK = 'WebGL2RenderingContext' in window // también con ratón o teclado: a diferencia del lector, no depende de los gestos táctiles +if (!SHELF_OK) document.querySelector('#topbar nav a[href="#/shelf"]')?.remove() +// Foto del último fotograma del estante, encima de todo mientras el lector se monta; se desvanece cuando el libro 3D ya está dibujado. +function handoffCover(src) { + document.querySelector('.handoff')?.remove() + const img = Object.assign(document.createElement('img'), { className: 'handoff', src, alt: '' }) + document.body.append(img) + document.body.classList.add('handing') + const t0 = performance.now() + let mounted = 0 // cuándo apareció el lector: si no va a haber libro 3D (modo vertical, error), no se espera más + const fade = () => { document.body.classList.remove('handing'); img.classList.add('out'); setTimeout(() => img.remove(), 450) } + const wait = () => { + if (!img.isConnected) return document.body.classList.remove('handing') + const ready = document.querySelector('#b3d:not([hidden]) canvas') + if (ready) return setTimeout(fade, 350) // las texturas de la tapa tardan un instante + if (!mounted && document.querySelector('.reader .rbar')) mounted = performance.now() + const failed = !document.querySelector('.reader') && performance.now() - t0 > 400 // el lector no llegó a montarse (error) + if (failed || (mounted && performance.now() - mounted > 1500) || performance.now() - t0 > 30000 || !location.hash.startsWith('#/read/')) return fade() + setTimeout(wait, 60) + } + // Se navega cuando la foto ya está pintada: así no asoma la pantalla de carga entre el estante y el lector. + return (img.decode ? img.decode().catch(() => {}) : Promise.resolve()).then(() => new Promise(r => requestAnimationFrame(() => { setTimeout(wait, 120); r() }))) // para entonces ya se está en el lector +} +// Lo último que se supo del estante, en este dispositivo: con ello se monta al momento (sin esperar a cada obra) +// y sin conexión. Tomos y logos se refrescan luego, y solo se rehace lo que cambie. +const shelfSnapKey = () => `shelfSnap:${me?.username || ''}` +const LOGO_DAYS = { found: 30, none: 7 } +const shelfSort = (works, order) => order === 'az' + ? [...works].sort((a, b) => String(a.title).replace(/^[^\p{L}\p{N}]+/u, '').localeCompare(String(b.title).replace(/^[^\p{L}\p{N}]+/u, ''), 'es', { sensitivity: 'base', numeric: true })) + : [...works].sort((a, b) => (b.last || 0) - (a.last || 0)) +async function viewShelf(params) { + if (!SHELF_OK) { + app.innerHTML = `

    Estante 3D

    Este navegador no tiene WebGL2.

    ${icon('library')} Volver a la librería

    ` + return + } + document.body.classList.add('reading') // sin la barra de arriba + let order = store.get('shelfOrder', 'recent') + const orderLabel = () => (order === 'az' ? `${icon('sort')} A–Z` : `${icon('sort')} Recientes`) + app.innerHTML = `
    +

    + ` + const note = $('#shelfnote'), card = $('#shelfpeek'), orderBtn = $('#shelforder') + orderBtn.innerHTML = orderLabel() + const say = (text, ms) => { clearTimeout(note.t); note.textContent = text; note.hidden = !text; if (ms) note.t = setTimeout(() => { note.hidden = true }, ms) } + const focusOn = shelfTrip?.mangaId || params?.get('focus') || null // al volver del lector, o desde la ficha + shelfTrip = null + let shelf = null, gone = false, offline = !serverReachable + const onKey = e => { if (e.key === 'Escape') shelf?.unpeek() } + document.addEventListener('keydown', onKey) + cleanup = () => { gone = true; document.removeEventListener('keydown', onKey); clearTimeout(snapTimer); saveSnap(); shelf?.destroy(); shelf = null } + // Foto del estante en el dispositivo. + const snap = store.get(shelfSnapKey(), null) + const logos = snap?.logos || {} // id -> { url, at } + let works = [], snapTimer = 0 + const saveSnap = () => { if (works.length && !offline) store.set(shelfSnapKey(), { at: Date.now(), works, logos }) } + const saveSoon = () => { clearTimeout(snapTimer); snapTimer = setTimeout(saveSnap, 1500) } + // Sin conexión, cada tomo se lee por los capítulos guardados en el dispositivo. + const onDevice = id => !offline || offHas(id) + // Vistazo: lo que se sabe del tomo sacado, junto a él. + const readOf = info => { + const { work, vol, state } = info + if (offline) { + if (state === 'reading' && work.chapterId && offHas(work.chapterId) && (!vol.chapters?.length || vol.chapters.includes(work.chapterId))) return readUrl(work.id, work.chapterId, work.page || 0) + const here = (vol.chapters || []).find(offHas) + return here ? readUrl(work.id, here) : null + } + if (state === 'reading' && work.chapterId) return readUrl(work.id, work.chapterId, work.page || 0) + if (vol.chapters?.length) return readUrl(work.id, vol.chapters[0]) + return work.chapterId ? readUrl(work.id, work.chapterId, work.page || 0) : `#/manga/${work.id}` + } + // A la mesa: el estante posa el tomo como el lector lo mostrará y una foto del último fotograma tapa el cambio. + let leaving = false + const toTable = info => { + if (leaving) return + const href = readOf(info) + if (!href) return say('Este tomo no está guardado en el dispositivo.', 2500) + const go = async snap => { + if (gone) return + if (href.startsWith('#/read/')) shelfTrip = { mangaId: info.work.id } + if (snap) await handoffCover(snap) // la foto ya está pintada antes de quitar el estante: no se ve nada entre medias + location.hash = href + } + leaving = true + if (!href.startsWith('#/read/') || !shelf?.fly(go)) go(null) + } + // El tomo sacado lleva su nota, como la de la balda, con lo que se sabe de él. + const showPeek = info => { + if (!info) { card.classList.remove('on'); clearTimeout(card.hideTimer); card.hideTimer = setTimeout(() => { if (!card.classList.contains('on')) card.hidden = true }, 250); return } + const { work, vol, state } = info, n = vol.chapters?.length || 0, at = n && work.chapterId ? vol.chapters.indexOf(work.chapterId) : -1 + const facts = [vol.vol ? `Tomo ${esc(vol.vol)}` : '', n ? `${n} ${n === 1 ? 'capítulo' : 'capítulos'}` : '', vol.pages ? `${vol.pages} páginas` : ''].filter(Boolean).join(' · ') + const pct = state === 'read' ? 100 : at >= 0 ? Math.round((at / n) * 100) : 0 + const where = state === 'read' ? 'Leído' : state === 'reading' ? (at >= 0 ? `Vas por el capítulo ${at + 1} de ${n} de este tomo` : 'Lo estás leyendo') : 'Sin empezar' + const href = readOf(info), st = shelfMod.noteStyle(work.id) + card.style.cssText = `--np:${st.paper};--ni:${st.ink};--nf:"${st.family}";--nw:${st.weight};--ns:${st.scale};--nt:${(st.tilt * -18).toFixed(2)}deg` + card.innerHTML = ` +

    ${esc(shelfMod.shortTitle(work.title, 60))}

    ${facts ? `

    ${facts}

    ` : ''} +

    ${where}

    +
    ${href ? `${icon('book')} ${state === 'reading' ? 'Seguir leyendo' : 'Leer'}` : 'No está guardado en el dispositivo'}${offline ? '' : `Ver ficha`}
    ` + card.querySelector('.peekx').addEventListener('click', () => shelf?.unpeek()) + card.querySelector('#peekread')?.addEventListener('click', e => { e.preventDefault(); toTable(info) }) + clearTimeout(card.hideTimer) + card.hidden = false + requestAnimationFrame(() => card.classList.add('on')) + } + const ver = name => document.querySelector(`meta[name=${name}-version]`)?.content || '' + let shelfMod = null + // Lo guardado en el dispositivo, para cuando no hay servidor. + const offlineWorks = () => { + const known = new Map((snap?.works || []).map(w => [w.id, w])) + return offByManga().filter(m => !isBookId(m.mangaId)).map(m => known.get(m.mangaId) || { id: m.mangaId, title: m.title, cover: m.cover, last: m.at, chapterId: null, page: 0, lang: m.chapters[0]?.lang || null }) + } + const start = (list, lib) => { + works = list + shelf = shelfMod.createShelf3D($('#shelf3d'), lib, { + works: shelfSort(works, order), + still: matchMedia('(prefers-reduced-motion: reduce)').matches, + onPeek: showPeek, + onOpen: info => toTable(info), // tocar el tomo sacado lo lleva a la mesa + table: me?.tableV ? `/api/table?v=${me.tableV}` : null, + // Un tomo sin portada saca el arte del lomo de sus primeras páginas: la 0 suele ser de créditos. + pagesOf: offline ? null : chapterId => api(`/api/chapter/${chapterId}?saver=0&hd=0`).then(d => (d?.pages || []).slice(1, 6), () => []), + }) + if (!shelf) throw new Error('sin WebGL2') + if (focusOn) shelf.focus(focusOn) + for (const w of works) if (logos[w.id]?.url) shelf.setLogo(w.id, logos[w.id].url) + } + orderBtn.addEventListener('click', () => { + order = order === 'az' ? 'recent' : 'az' + store.set('shelfOrder', order) + orderBtn.innerHTML = orderLabel() + shelf?.unpeek() + shelf?.setWorks(shelfSort(works, order)) + shelf?.toTop() + }) + try { + const [lib, mod] = await Promise.all([import(`/book3d.js?v=${ver('b3d')}`), import(`/shelf3d.js?v=${ver('shelf')}`)]) + shelfMod = mod + if (gone) return + mod.loadNoteFonts() + // Con foto guardada, el mueble sale ya; la lista del servidor llega después. + if (snap?.works?.length && !offline) start(snap.works.filter(w => wantsId(w.id)), lib) + else if (!snap?.works?.length && !offline) say('Colocando los tomos…') + let fresh = null + if (!offline) fresh = await api('/api/shelf').catch(e => { if (e.message === NO_SERVER || !serverReachable) offline = true; else throw e; return null }) + if (gone) return + if (offline) { + const list = offlineWorks() + if (shelf) { works = list; shelf.setWorks(shelfSort(works, order)) } else if (list.length) start(list, lib) + return say(list.length ? 'Sin conexión: solo lo guardado en este dispositivo.' : 'Sin conexión, y no hay tomos guardados en este dispositivo.', list.length ? 4000 : 0) + } + // La lista del servidor manda; de la foto se conservan los tomos ya conocidos. + const had = new Map(works.map(w => [w.id, w])) + const next = fresh.works.map(w => (had.get(w.id)?.vols ? { ...w, vols: had.get(w.id).vols } : w)) + if (!next.length) { shelf?.destroy(); shelf = null; works = []; store.set(shelfSnapKey(), null); return say('Aún no hay nada en tu biblioteca: los tomos de lo que leas, sigas o descargues aparecerán aquí.') } + const sameList = shelf && next.length === works.length && next.every((w, i) => { const o = had.get(w.id); return o && o.title === w.title && o.cover === w.cover && o.chapterId === w.chapterId && o.page === w.page && o.last === w.last }) + if (!shelf) start(next, lib) + else if (!sameList) { works = next; shelf.setWorks(shelfSort(works, order)) } + else works = next.map(w => had.get(w.id)) + saveSoon() + // Los tomos llegan aparte, de pocos en pocos: primero las obras que aún no tienen, luego se repasan las demás. + const queue = [...works.filter(w => !w.vols), ...works.filter(w => w.vols)] + let pending = queue.filter(w => !w.vols).length + if (!pending) say('') + const worker = async () => { + while (queue.length && !gone) { + const w = queue.shift(), first = !w.vols + const r = await api(`/api/volumes/${w.id}?lang=${encodeURIComponent(w.lang || 'es')}`).catch(() => null) + if (gone) return + const vols = r?.volumes?.length ? r.volumes : null + if (vols && JSON.stringify(vols) !== JSON.stringify(w.vols || null)) { w.vols = vols; shelf.setVolumes(w.id, vols); saveSoon() } + if (first && --pending <= 0) say('') + } + } + for (let k = 0; k < 3; k++) worker() + // Logos para el título de los lomos: la primera vez el servidor los busca en Wikimedia Commons, sin prisa. + const day = 864e5, now = Date.now() + const logoQueue = works.filter(w => { const l = logos[w.id]; return !l || now - (l.at || 0) > (l.url ? LOGO_DAYS.found : LOGO_DAYS.none) * day }) + const logoWorker = async () => { + while (logoQueue.length && !gone) { + const w = logoQueue.shift() + const r = await api(`/api/logo/${w.id}?lang=${encodeURIComponent(w.lang || 'es')}`).catch(() => undefined) + if (gone) return + if (r === undefined) continue // error: se vuelve a preguntar en la próxima visita + logos[w.id] = { url: r.logo || null, at: Date.now() } + if (r.logo) shelf.setLogo(w.id, r.logo) + saveSoon() + } + } + for (let k = 0; k < 2; k++) logoWorker() + } catch (e) { + if (!gone) say(`No se pudo abrir el estante: ${e.message}`) + } +} + +async function viewLibrary(params) { + const tab = params.get('tab') || 'reading' + app.innerHTML = '
    Cargando…
    ' + const [hist, fol, lists] = serverReachable + ? await Promise.all([api('/api/history').catch(() => null), api('/api/follows').catch(() => null), api('/api/lists').catch(() => [])]) + : [null, null, []] + if (!location.hash.startsWith('#/library')) return + const online = !!hist + const dev = OFFLINE_OK ? offByManga().filter(m => wantsId(m.mangaId)) : [] + const reading = (hist || []).filter(h => !h.finished && wantsId(h.manga_id)) + const finished = (hist || []).filter(h => h.finished && wantsId(h.manga_id)) + const follows = (fol?.follows || []).filter(f => wantsId(f.manga_id)) + const tabs = [ + ['reading', 'Leyendo', 'book', reading.length], + ['following', 'Siguiendo', 'bell', follows.length], + ['finished', 'Terminados', 'check', finished.length], + ...(OFFLINE_OK && (dev.length || tab === 'device') ? [['device', 'En el dispositivo', 'phone', dev.length]] : []), + ...lists.map(l => [`list-${l.id}`, l.name, 'bookmark', contentMode() === 'both' ? l.count : 0]), // el total de la lista incluye lo filtrado + ] + const curList = tab.startsWith('list-') ? lists.find(l => `list-${l.id}` === tab) : null + app.innerHTML = `

    Librería

    ${SHELF_OK ? `${icon('shelf')} Estante 3D` : ''}
    +
    ${tabs.map(([k, label, ic, n]) => ``).join('')} + ${online ? `` : ''}
    + ${curList ? `
    Lista propia · añade mangas desde su ficha con ${icon('bookmark')} Listas +
    ` : ''} +
    Cargando…
    ` + app.querySelectorAll('[data-tab]').forEach(b => b.addEventListener('click', () => { location.hash = `#/library?tab=${b.dataset.tab}` })) + const body = $('#libbody') + const grid = (cards, empty) => (cards.length ? `
    ${cards.join('')}
    ` : `

    ${empty}

    `) + const histCard = h => libCard({ + href: `#/manga/${h.manga_id}`, cover: h.cover, title: h.manga_title, + pct: h.total_pages ? Math.round(((h.page + 1) / h.total_pages) * 100) : 0, + badge: h.finished ? (h.hasNew ? 'Caps. nuevos' : '') : h.hasNew ? 'Caps. nuevos' : '', + sub: `${h.chapter_num ? `Cap. ${esc(h.chapter_num)}` : 'Oneshot'} · ${timeAgo(h.updated_at)}`, + del: { attrs: `data-delhist="${h.manga_id}"`, title: 'Borrar del historial' }, + }) + if (!online && tab !== 'device') { + body.innerHTML = `

    Sin conexión con el servidor.${dev.length ? ' Puedes leer lo guardado en este dispositivo:' : ''}

    ` + + (dev.length ? `

    ${icon('phone')} En el dispositivo

    ` : '') + } else if (tab === 'reading') body.innerHTML = grid(reading.map(histCard), 'No estás leyendo nada ahora mismo.') + else if (tab === 'finished') body.innerHTML = grid(finished.map(histCard), 'Aún no has terminado ningún manga.') + else if (tab === 'following') { + body.innerHTML = grid(follows.map(f => libCard({ + href: `#/manga/${f.manga_id}`, cover: f.cover, title: f.title, badge: f.newCount ? `${f.newCount} nuevo${f.newCount > 1 ? 's' : ''}` : '', + sub: f.last_new_at ? `Último capítulo nuevo ${timeAgo(f.last_new_at)}` : 'Sin novedades aún', + del: { attrs: `data-unfollow="${f.manga_id}"`, title: 'Dejar de seguir', icon: 'bellOff' }, + })), 'No sigues ningún manga. Pulsa «Seguir» en su ficha para recibir avisos de capítulos nuevos.') + } else if (tab === 'device') { + const total = dev.reduce((n, m) => n + m.bytes, 0) + body.innerHTML = (dev.length ? `

    ${plural(dev.reduce((n, m) => n + m.chapters.length, 0), 'capítulo')} · ${fmtSize(total)} en este dispositivo. Se leen sin internet.

    ` : '') + + grid(dev.map(m => { + const last = m.chapters.sort((a, b) => (Number(a.num) || 0) - (Number(b.num) || 0)) + return libCard({ + href: `#/manga/${m.mangaId}`, cover: m.cover, title: m.title, + sub: `${plural(m.chapters.length, 'capítulo')} · ${fmtSize(m.bytes)}
    ${esc(last.map(c => c.num ?? '·').slice(0, 8).join(', '))}${last.length > 8 ? '…' : ''}`, + del: { attrs: `data-deldev="${m.mangaId}"`, title: 'Borrar del dispositivo' }, + }) + }), 'No hay nada guardado en este dispositivo. En la ficha de un manga, «⋯ → En este dispositivo» guarda capítulos para leerlos sin internet.') + } else if (curList) { + const l = await api(`/api/lists/${curList.id}`).catch(err => (toast(err.message), { items: [] })) + body.innerHTML = grid(l.items.filter(it => wantsId(it.manga_id)).map(it => libCard({ + href: `#/manga/${it.manga_id}`, cover: it.cover, title: it.title, sub: `Añadido ${timeAgo(it.added_at)}`, + del: { attrs: `data-unlist="${it.manga_id}"`, title: 'Quitar de la lista', icon: 'x' }, + })), 'Esta lista está vacía. Añade mangas desde su ficha con el botón «Listas».') + } else body.innerHTML = '

    Esa lista ya no existe.

    ' + + const reload = () => viewLibrary(params) + body.addEventListener('click', async e => { + const b = e.target.closest('button.hdel') + if (!b) return + e.preventDefault() + e.stopPropagation() + try { + if (b.dataset.delhist) { + if (!confirm('¿Borrar el historial de este manga?')) return + await api(`/api/history/${b.dataset.delhist}`, { method: 'DELETE' }) + } else if (b.dataset.unfollow) { + await api(`/api/follow/${b.dataset.unfollow}`, { method: 'DELETE' }) + toast('Ya no sigues este manga') + } else if (b.dataset.deldev) { + const m = dev.find(x => x.mangaId === b.dataset.deldev) + if (!confirm(`¿Borrar de este dispositivo los ${plural(m.chapters.length, 'capítulo')} de «${m.title}» (${fmtSize(m.bytes)})?`)) return + await offRemove(m.chapters.map(c => c.chapterId)) + } else if (b.dataset.unlist) { + await api(`/api/lists/${curList.id}/items/${encodeURIComponent(b.dataset.unlist)}`, { method: 'DELETE' }) + } + reload() + } catch (err) { toast(err.message) } + }) + $('#newlist')?.addEventListener('click', async () => { + const name = prompt('Nombre de la nueva lista:') + if (!name?.trim()) return + try { const l = await api('/api/lists', { method: 'POST', body: { name } }); location.hash = `#/library?tab=list-${l.id}` } catch (err) { toast(err.message) } + }) + $('#lren')?.addEventListener('click', async () => { + const name = prompt('Nuevo nombre de la lista:', curList.name) + if (!name?.trim()) return + try { await api(`/api/lists/${curList.id}`, { method: 'PATCH', body: { name } }); reload() } catch (err) { toast(err.message) } + }) + $('#ldel')?.addEventListener('click', async () => { + if (!confirm(`¿Eliminar la lista «${curList.name}»? Los mangas no se tocan.`)) return + try { await api(`/api/lists/${curList.id}`, { method: 'DELETE' }); location.hash = '#/library' } catch (err) { toast(err.message) } + }) +} + +// ---------- Descargas ---------- +// Actualiza `from` para que quede igual que `to` tocando solo lo que difiere. Los hijos con data-key se +// emparejan por clave (una tarjeta que cambia de posición conserva su y no recarga la portada). +function morph(from, to, childrenOnly = false) { + if (!childrenOnly) { + if (from.nodeType !== to.nodeType || from.nodeName !== to.nodeName) { from.replaceWith(to); return to } + if (from.nodeType !== 1) { if (from.nodeValue !== to.nodeValue) from.nodeValue = to.nodeValue; return from } + for (const { name } of [...from.attributes]) if (!to.hasAttribute(name) && name !== 'data-failed') from.removeAttribute(name) + for (const { name, value } of [...to.attributes]) { + if (name === 'src' && from.dataset.failed) continue // portada que no cargó: se queda el icono + if (from.getAttribute(name) !== value) from.setAttribute(name, value) + } + } + const byKey = new Map([...from.children].filter(n => n.dataset.key).map(n => [n.dataset.key, n])) + const kids = [...to.childNodes] + kids.forEach((nk, i) => { + const key = nk.nodeType === 1 ? nk.dataset.key : null + const at = from.childNodes[i] + let old = key ? byKey.get(key) : at && !(at.nodeType === 1 && at.dataset.key) ? at : null + if (key) byKey.delete(key) + const node = old ? morph(old, nk) : nk + if (from.childNodes[i] !== node) from.insertBefore(node, from.childNodes[i] || null) + }) + while (from.childNodes.length > kids.length) from.lastChild.remove() + return from +} + +async function viewQueues() { + if (cleanup) { cleanup(); cleanup = null } + const here = () => location.hash === '#/queues' + if (!$('#qwrap')) app.innerHTML = '
    Cargando…
    ' + let q, list + try { + [q, list] = await Promise.all([api('/api/queues'), api('/api/downloads')]) + } catch (err) { + // Sin servidor: al menos la cola de este dispositivo (que sigue por su cuenta si hay red a medias). + if (!OFFLINE_OK || !here()) throw err + q = { downloads: [], upscale: { available: true, groups: [], current: null }, clips: { queued: 0, running: 0, errors: 0 }, covers: 0, offline: err.message } + list = [] + } + if (!here()) return + const up = q.upscale + const library = list.filter(d => !DL_ACTIVE.includes(d.status)) + const hdQueued = up.groups.reduce((n, g) => n + g.queued, 0) + const total = list.reduce((n, d) => n + (d.size || 0), 0) + const extra = [ + q.clips.queued + q.clips.running ? `${icon('scissors')} ${plural(q.clips.queued + q.clips.running, 'recorte')} reescalándose` : '', + q.clips.errors ? `${plural(q.clips.errors, 'recorte')} con error` : '', + q.covers ? `${icon('image')} ${plural(q.covers, 'portada')} en cola` : '', + ].filter(Boolean) + // Tarjeta vertical (como en Historial): portada, barra de progreso, título, detalles y acciones. + const qcard = ({ key, id, cover, badge, pct, title, lines, actions = '', del = '' }) => ` +
    + + ${badge ? `${badge}` : ''}${del} +
    + ${esc(title)} +
    ${lines}
    + ${actions ? `
    ${actions}
    ` : ''} +
    ` + // Guardado en este dispositivo: lo hace el navegador, no el servidor (offState). + const devHtml = () => { + if (!OFFLINE_OK) return '' + const items = [...(offState.running ? [offState.running] : []), ...offState.queue] + const groups = new Map() + for (const it of items) { + const g = groups.get(it.mangaId) || { ...it, chapters: [] } + g.chapters.push(it) + groups.set(it.mangaId, g) + } + const saved = Object.values(offIdx()) + const bytes = saved.reduce((n, c) => n + (c.bytes || 0), 0) + return `

    ${icon('phone')} En este dispositivo ${items.length ? `${items.length}` : ''}

    +

    Capítulos que se guardan en este ${/Mobi|Android|iPhone/i.test(navigator.userAgent) ? 'móvil' : 'navegador'} para leer sin conexión; la cola sigue mientras la app esté abierta. + ${saved.length ? `Guardados: ${plural(saved.length, 'capítulo')} · ${fmtSize(bytes)}.` : ''}

    + ${groups.size ? `
    ${[...groups.values()].map((g, i) => { + const cur = offState.running?.mangaId === g.mangaId ? offState.running : null + const rest = g.chapters.length - (cur ? 1 : 0) + return qcard({ + key: `dev:${g.mangaId}`, id: g.mangaId, cover: g.cover, title: g.mangaTitle, + pct: cur?.total ? (cur.done / cur.total) * 100 : null, + badge: cur ? icon('phone') : i + 1, + lines: `${cur ? `
    ${esc(cur.label)} · pág. ${cur.done}/${cur.total || '?'}
    ` : ''} + ${rest ? `
    ${cur ? `${rest} más` : plural(rest, 'capítulo')} en cola
    ` : ''}`, + actions: ``, + }) + }).join('')}
    ` : '

    No se está guardando nada. Se añade desde la ficha («⋯ → En este dispositivo») o desde el lector.

    '}` + } + const html = q.offline ? `

    Colas

    ${esc(q.offline)}: no se pueden ver las colas del servidor.

    +
    ${devHtml()}
    ` : `

    Colas

    +
    +

    ${icon('download')} Descargas ${q.downloads.length ? `${q.downloads.length}` : ''}

    + ${q.downloads.length ? `
    ${q.downloads.map((d, i) => qcard({ + key: `dl:${d.manga_id}`, id: d.manga_id, cover: d.cover, title: d.title, pct: downloadPct(d), + badge: d.status === 'downloading' ? icon('download') : i + 1, + lines: `
    ${esc(downloadLabel(d))} · ${Math.floor(downloadPct(d))}%
    + ${d.status === 'downloading' ? `
    ${downloadDetail(d)}
    ` : ''}${d.scope === 'some' ? '
    Capítulos elegidos
    ' : ''}`, + actions: ``, + })).join('')}
    ` : '

    No hay descargas en curso. Se añaden desde la ficha de un manga.

    '} +
    + ${OFFLINE_OK ? `
    ${devHtml()}
    ` : ''} +
    +

    ${icon('sparkles')} Reescalado HD ${hdQueued ? `${hdQueued}` : ''}

    + ${up.available ? '' : '

    El reescalado no está disponible en este servidor (falta Real-ESRGAN o la GPU).

    '} + ${up.groups.length ? `${hdQueued ? `

    Un capítulo cada vez, en orden de llegada · ${up.secPerPage} s por página · quedan ${fmtDur(up.eta)}

    ` : ''} +
    ${up.groups.map((g, i) => { + const cur = up.current?.mangaId === g.mangaId ? up.current : null + const rest = g.queued - (cur ? 1 : 0) + const next = g.next.length ? ` (sig.: ${g.next.map(n => esc(n ?? '?')).join(', ')}${rest > g.next.length ? '…' : ''})` : '' + return qcard({ + key: `hd:${g.mangaId}`, id: g.mangaId, cover: g.cover, title: g.title, + pct: cur?.total ? (cur.pages / cur.total) * 100 : null, + badge: cur ? icon('sparkles') : g.queued ? i + 1 : '!', + lines: `${cur ? `
    Cap. ${esc(cur.num ?? '?')} · pág. ${cur.pages}/${cur.total}
    ` : ''} + ${g.queued ? `
    ${rest ? `${cur ? `${rest} más` : plural(rest, 'capítulo')} en cola${next}` : 'Último de la cola'}
    ${fmtDur(g.eta)}
    ` : ''} + ${g.errors.length ? `
    ${g.errors.length === 1 ? `Cap. ${esc(g.errors[0].num ?? '?')}: ${esc(g.errors[0].error || 'error')}` : `${g.errors.length} con error (caps. ${g.errors.slice(0, 6).map(e => esc(e.num ?? '?')).join(', ')}${g.errors.length > 6 ? '…' : ''})`}
    ` : ''}`, + actions: `${g.errors.length ? `` : ''} + ${g.queued ? `` : ''}`, + }) + }).join('')}
    ` : up.available ? '

    No hay capítulos esperando. Se reescalan desde la ficha de un manga descargado o desde el lector.

    ' : ''} + ${extra.length ? `
    ${extra.join('')}
    ` : ''} +
    +
    +

    ${icon('book')} Biblioteca descargada

    +

    CBZ en ./mangas (compatibles con Kavita, Komga, Mihon…); se leen desde el disco.${list.length ? ` Total: ${fmtSize(total)}.` : ''}

    + ${library.length ? `
    ${library.map(d => qcard({ + key: `lib:${d.manga_id}`, id: d.manga_id, cover: d.cover, title: d.title, + pct: d.total ? Math.min(100, (d.done / d.total) * 100) : null, + lines: `
    ${esc(downloadLabel(d))}
    ${fmtSize(d.size)}
    + ${d.status === 'error' ? `
    Con errores
    ` : ''}`, + del: ``, + })).join('')}
    ` : `

    ${list.length ? 'Todo lo descargado está aún en la cola.' : 'Aún no tienes nada descargado. Usa “Descargar” en la ficha de un manga; lo que ya descargó otro usuario aparece aquí en cuanto lo empiezas a leer.'}

    `} +
    ` + // Refresco sin parpadeo: se actualiza solo lo que cambia (las portadas no se vuelven a cargar). + let wrap = $('#qwrap') + if (!wrap) { + app.innerHTML = `
    ${html}
    ` + wrap = $('#qwrap') + // Delegación: los botones se crean y actualizan en cada refresco. + const act = async (fn, msg) => { try { await fn(); if (msg) toast(msg) } catch (err) { toast(err.message) } viewQueues() } + wrap.addEventListener('click', e => { + const b = e.target.closest('button') + if (!b) return + if (b.dataset.cancel) act(() => api(`/api/download/${b.dataset.cancel}/cancel`, { method: 'POST' }), 'Descarga cancelada') + else if (b.dataset.hdstop) act(() => api(`/api/upscale/${b.dataset.hdstop}/cancel`, { method: 'POST' }), 'Reescalado detenido') + else if (b.dataset.devcancel) { offCancel(b.dataset.devcancel); toast('Guardado cancelado') } + else if (b.dataset.retry) act(() => api(`/api/upscale/${b.dataset.retry}`, { method: 'POST', body: { chapterIds: b.dataset.ids.split(' ') } }), 'Vuelto a la cola') + else if (b.dataset.del) { + const shared = !!b.dataset.shared + if (!confirm(shared ? `¿Quitar «${b.dataset.title}» de tu biblioteca descargada?\nOtro usuario también lo tiene: los archivos se quedan.` + : `¿Eliminar la copia local de «${b.dataset.title}» (${fmtSize(Number(b.dataset.size))})?\nTu historial de lectura no se toca.`)) return + act(() => api(`/api/download/${b.dataset.del}`, { method: 'DELETE' }), shared ? 'Quitado de tu biblioteca' : 'Copia local eliminada') + } + }) + } else { + const tpl = document.createElement('div') + tpl.innerHTML = html + morph(wrap, tpl, true) + } + // Refresco: rápido mientras algo avanza, lento si no (por si llega algo nuevo). + const busy = q.downloads.length || up.current || up.groups.some(g => g.queued) || q.clips.queued + q.clips.running || q.covers + const t = setTimeout(() => here() && viewQueues(), q.offline ? 10000 : busy ? 2000 : 15000) + // El guardado en el dispositivo avanza en el navegador: su sección se redibuja sola en cada página. + let raf = 0 + const onDev = () => { + if (raf) return + raf = requestAnimationFrame(() => { + raf = 0 + const box = $('#devq') + if (!box || !here()) return + const tpl = document.createElement('section') + tpl.innerHTML = devHtml() + morph(box, tpl, true) + }) + } + offState.listeners.add(onDev) + cleanup = () => { clearTimeout(t); offState.listeners.delete(onDev); cancelAnimationFrame(raf) } +} + +// ---------- Estadísticas ---------- +const fmtNum = n => n.toLocaleString('es') +const fmtDay = d => new Date(`${d}T12:00:00`).toLocaleDateString('es', { day: 'numeric', month: 'short' }) +const niceMax = v => { const p = 10 ** Math.floor(Math.log10(v)); return [1, 2, 2.5, 5, 10].map(m => m * p).find(m => m >= v) } + +// Barras verticales de una sola serie; cada columna tiene una zona de hover más grande que la barra. +function barChart(data, { value, tip, label }, W = 560) { + const H = 190, L = 34, B = 24, T = 8 + const max = niceMax(Math.max(1, ...data.map(value))) + const cw = (W - L) / data.length + const bw = Math.max(4, Math.min(28, cw - 6)) + const y = v => T + (H - T - B) * (1 - v / max) + const ticks = Number.isInteger(max / 2) ? [0, max / 2, max] : [0, max] + const grid = ticks.map(v => `${fmtNum(Math.round(v))}`).join('') + const bars = data.map((d, i) => { + const v = value(d) + const x = L + i * cw + (cw - bw) / 2 + const top = y(v), base = y(0), r = Math.min(4, bw / 2, base - top) + const bar = v > 0 ? `` : '' + const every = W < 420 ? 3 : 2 + const lab = (data.length - 1 - i) % every ? '' : `${label(d)}` + return `${bar}${lab}` + }).join('') + return `${grid}${bars}` +} + +async function viewStats() { + app.innerHTML = '
    Cargando…
    ' + const st = await api('/api/stats') + const t = st.totals + if (!t.pages) { + app.innerHTML = '

    Estadísticas

    Aún no hay lecturas registradas. Lee algún capítulo y vuelve por aquí.

    ' + return + } + const maxDay = Math.max(1, ...st.calendar.map(d => d.pages)) + const level = p => (p === 0 ? 0 : Math.min(5, Math.ceil((p / maxDay) * 5))) + const weekLabel = w => fmtDay(w.start) + const tile = (value, label, sub = '') => `
    ${value}
    ${label}
    ${sub ? `
    ${sub}
    ` : ''}
    ` + // Calendario: columnas = semanas (lunes arriba). + const firstDow = (new Date(`${st.calendar[0].day}T12:00:00`).getDay() + 6) % 7 + const cells = [...Array(firstDow).fill(''), ...st.calendar.map(d => + ``)].join('') + const gmax = Math.max(1, ...st.genres.map(g => g.pages)) + + app.innerHTML = `

    ${icon('chart')} Estadísticas

    +
    + ${tile(`${st.streak.current} ${st.streak.current === 1 ? 'día' : 'días'}`, 'Racha actual', st.streak.current && !st.streak.readToday ? 'Lee hoy para mantenerla' : st.streak.readToday ? 'Hoy ya leíste ✓' : '')} + ${tile(`${st.streak.best} ${st.streak.best === 1 ? 'día' : 'días'}`, 'Mejor racha')} + ${tile(fmtNum(t.thisWeekChapters), 'Capítulos esta semana', `${fmtNum(t.thisWeekPages)} páginas`)} + ${tile(fmtNum(t.chapters), 'Capítulos terminados')} + ${tile(fmtNum(t.pages), 'Páginas leídas')} + ${tile(fmtNum(t.mangas), 'Mangas', `${t.activeDays} ${t.activeDays === 1 ? 'día' : 'días'} con lectura`)} +
    +
    +

    Capítulos terminados por semana

    +

    Páginas leídas por semana

    +
    +

    Actividad (últimas 18 semanas)

    +
    ${cells}
    +
    Menos Más páginas
    +
    +
    +

    Géneros favoritos

    + ${st.genres.length ? `
    ${st.genres.map(g => `
    + ${esc(g.name)}${g.pct}%
    `).join('')}
    +

    Porcentaje de páginas leídas de mangas con ese género (un manga cuenta en todos sus géneros).

    ` + : '

    Abre la ficha de los mangas que lees para registrar sus géneros.

    '} +
    +

    Lo más leído

    + +
    +
    +
    Ver datos por semana + + ${[...st.weeks].reverse().map(w => ``).join('')}
    Semana delCapítulosPáginas
    ${fmtDay(w.start)}${w.chapters}${fmtNum(w.pages)}
    +
    + ` + + // Las gráficas se dibujan al ancho real del panel para que el texto no encoja en el móvil. + const charts = { + chapters: { value: w => w.chapters, label: weekLabel, tip: w => `Semana del ${fmtDay(w.start)}: ${w.chapters} caps.` }, + pages: { value: w => w.pages, label: weekLabel, tip: w => `Semana del ${fmtDay(w.start)}: ${fmtNum(w.pages)} págs.` }, + } + const drawCharts = () => app.querySelectorAll('.chart-slot').forEach(el => { el.innerHTML = barChart(st.weeks, charts[el.dataset.chart], Math.max(260, el.clientWidth)) }) + drawCharts() + let rt = null + const onResize = () => { clearTimeout(rt); rt = setTimeout(drawCharts, 150) } + window.addEventListener('resize', onResize) + + // Tooltip compartido para barras, celdas y géneros. + const tipEl = $('#tip') + const move = e => { + const target = e.target.closest('[data-tip]') + if (!target) { tipEl.hidden = true; return } + tipEl.textContent = target.dataset.tip + tipEl.hidden = false + const r = tipEl.getBoundingClientRect() + tipEl.style.left = `${Math.min(window.innerWidth - r.width - 8, Math.max(8, e.clientX - r.width / 2))}px` + tipEl.style.top = `${Math.max(8, e.clientY - r.height - 12)}px` + } + app.addEventListener('pointermove', move) + app.addEventListener('pointerdown', move) + app.addEventListener('pointerleave', () => (tipEl.hidden = true)) + cleanup = () => { app.removeEventListener('pointermove', move); app.removeEventListener('pointerdown', move); window.removeEventListener('resize', onResize) } +} + +// ---------- Ajustes ---------- +// Cualquier imagen que el navegador sepa abrir, reducida a un tamaño que la tableta pueda usar como textura. +async function tableJpeg(file) { + const url = URL.createObjectURL(file) + try { + const im = new Image() + await new Promise((ok, fail) => { im.onload = ok; im.onerror = () => fail(new Error('Este dispositivo no puede abrir esa imagen')); im.src = url }) + const k = Math.min(1, 2048 / Math.max(im.naturalWidth, im.naturalHeight)) + const c = Object.assign(document.createElement('canvas'), { width: Math.max(1, Math.round(im.naturalWidth * k)), height: Math.max(1, Math.round(im.naturalHeight * k)) }) + const g = c.getContext('2d') + g.fillStyle = '#4a2e19' // lo transparente queda del color de la mesa + g.fillRect(0, 0, c.width, c.height) + g.imageSmoothingQuality = 'high' + g.drawImage(im, 0, 0, c.width, c.height) + const blob = await new Promise(r => c.toBlob(r, 'image/jpeg', 0.88)) + if (!blob) throw new Error('No se pudo preparar la imagen') + return blob + } finally { URL.revokeObjectURL(url) } +} +function viewSettings() { + app.innerHTML = `

    Ajustes

    +
    +
    Cuenta
    Sesión iniciada como ${esc(me.username)}
    +
    +
    Idioma preferido de los capítulos +
    +
    ${icon('book')} Qué mostrar +

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

    +
    +
    ${icon('book')} Libro 3D: fondo de la mesa +

    Pon cualquier imagen en lugar de la madera. Se guarda en tu cuenta y se usa en todos tus dispositivos.

    + +
    + + +
    +
    Cambiar contraseña +
    + + +
    +
    ${icon('download')} Descarga anticipada +

    Al pasar la mitad de un capítulo, el servidor descarga los siguientes en segundo plano para que abran al instante (y estén en la copia local).

    +
    + + +
    +
    ${icon('phone')} App y lectura sin conexión + ${OFFLINE_OK ? `

    Capítulos guardados en este dispositivo: …. Se guardan desde la ficha de cada manga («⋯ → En este dispositivo») o desde el lector.

    +
    ${icon('library')} Ver lo guardado + +
    ` + : `

    Instalar la app y leer sin conexión necesita que la página se abra por HTTPS (el navegador no lo permite en http://${esc(location.host)}). Por ejemplo con tailscale serve o un proxy inverso con certificado.

    `} +
    +
    ${icon('key')} Seguridad: IPs bloqueadas
    Cargando…
    +
    ${icon('bell')} Avisos de capítulos nuevos +

    El servidor revisa cada pocas horas los mangas que sigues (los que empiezas a leer se siguen solos). + Si un manga tiene copia local, los capítulos nuevos se descargan automáticamente. Además puedes recibir una notificación:

    +
    + ntfy + + + + Telegram (opcional) + + +

    Los tokens guardados no se muestran: deja el campo vacío para conservarlo o escribe «-» para borrarlo.

    +
    +
    +
    ` + const showTable = () => { + const prev = $('#tableprev') + if (!prev) return + prev.hidden = !me.tableV + if (me.tableV) prev.src = `/api/table?v=${me.tableV}` + else prev.removeAttribute('src') + $('#tabledel').hidden = !me.tableV + } + showTable() + $('#tablefile').addEventListener('change', async e => { + const f = e.target.files[0] + e.target.value = '' + if (!f) return + try { + const blob = await tableJpeg(f) + const res = await fetch('/api/table', { method: 'POST', headers: { 'Content-Type': blob.type }, body: blob }) + const data = await res.json().catch(() => ({})) + if (!res.ok) throw new Error(data.error || `Error ${res.status}`) + me.tableV = data.tableV + showTable() + toast('Fondo guardado') + } catch (err) { toast(err.message) } + }) + $('#tabledel').addEventListener('click', async () => { + try { + await api('/api/table', { method: 'DELETE' }) + me.tableV = 0 + showTable() + toast('La mesa vuelve a ser de madera') + } catch (err) { toast(err.message) } + }) + api('/api/notify').then(n => { + const f = $('#notify') + if (!f) return + f.ntfyUrl.value = n.ntfyUrl + f.ntfyTopic.value = n.ntfyTopic + f.tgChat.value = n.tgChat + if (n.hasNtfyToken) f.ntfyToken.placeholder = 'Token guardado ✓ (vacío = conservar, «-» = borrar)' + if (n.hasTgToken) f.tgToken.placeholder = 'Token guardado ✓ (vacío = conservar, «-» = borrar)' + }).catch(() => {}) + $('#notify').addEventListener('submit', async e => { + e.preventDefault() + const f = e.target + try { + await api('/api/notify', { method: 'POST', body: Object.fromEntries(['ntfyUrl', 'ntfyTopic', 'ntfyToken', 'tgToken', 'tgChat'].map(k => [k, f[k].value])) }) + toast('Avisos guardados') + viewSettings() + } catch (err) { toast(err.message) } + }) + const renderBans = async () => { + const b = await api('/api/bans').catch(() => null) + const box = $('#bans') + if (!box || !b) return + box.innerHTML = `

    Tras ${b.maxFails} intentos fallidos en ${b.windowMin} min (contraseña incorrecta o registro cerrado), la IP queda bloqueada ${b.banHours} h. La red local y Tailscale no se bloquean nunca.

    + ${b.bans.length ? `
    ${b.bans.map(x => `
    ${esc(x.ip)}hasta ${new Date(x.until).toLocaleString('es')} · ${esc(x.reason || '')} +
    `).join('')}
    ` : '

    Ninguna IP bloqueada.

    '} +

    Tu IP vista por el servidor: ${esc(b.you.ip)}${b.you.protected ? ' (red local, nunca se bloquea)' : ''}${b.trustedHeader ? ` · leída de ${esc(b.trustedHeader)}` : ''}

    + ${b.fails.length ? `

    Intentos fallidos recientes: ${b.fails.map(f => `${esc(f.ip)} ×${f.n}`).join(', ')}

    ` : ''}` + box.querySelectorAll('[data-unban]').forEach(x => x.addEventListener('click', async () => { + try { await api(`/api/bans/${encodeURIComponent(x.dataset.unban)}`, { method: 'DELETE' }); toast('IP desbloqueada'); renderBans() } catch (err) { toast(err.message) } + })) + } + renderBans() + api('/api/prefetch').then(c => { if (!$('#pfn')) return; $('#pfn').value = String(c.n); $('#pfhd').checked = c.hd }).catch(() => {}) + const savePf = () => api('/api/prefetch', { method: 'POST', body: { n: Number($('#pfn').value), hd: $('#pfhd').checked } }).then(() => toast('Descarga anticipada guardada')).catch(err => toast(err.message)) + $('#pfn').addEventListener('change', savePf) + $('#pfhd').addEventListener('change', savePf) + if (OFFLINE_OK) { + const dev = offByManga() + $('#offsum').textContent = dev.length ? `${plural(dev.reduce((n, m) => n + m.chapters.length, 0), 'capítulo')} de ${plural(dev.length, 'manga')} · ${fmtSize(dev.reduce((n, m) => n + m.bytes, 0))}` : 'ninguno' + $('#offclear').hidden = !dev.length + $('#offclear').addEventListener('click', async () => { + if (!confirm('¿Borrar todos los capítulos guardados en este dispositivo?')) return + await caches.delete(OFF_CACHE) + store.set('offlineChapters', {}) + toast('Borrado') + viewSettings() + }) + const inst = $('#install') + inst.hidden = !installPrompt + inst.addEventListener('click', async () => { installPrompt?.prompt(); installPrompt = null; inst.hidden = true }) + } + $('#ntest').addEventListener('click', async () => { + try { await api('/api/notify/test', { method: 'POST' }); toast('Notificación de prueba enviada') } catch (err) { toast(err.message) } + }) + $('#logout').addEventListener('click', async () => { await api('/api/logout', { method: 'POST' }); me = null; location.hash = '#/login'; router() }) + $('#lang').addEventListener('change', async e => { await api('/api/settings', { method: 'POST', body: { lang: e.target.value } }); me.lang = e.target.value; toast('Idioma guardado') }) + $('#contentmode').addEventListener('change', async e => { + const prev = contentMode() + try { + await api('/api/settings', { method: 'POST', body: { contentMode: e.target.value } }) + me.content_mode = e.target.value + toast('Guardado') + } catch (err) { e.target.value = prev; toast(err.message) } + }) + $('#pw').addEventListener('submit', async e => { + e.preventDefault() + try { + await api('/api/settings', { method: 'POST', body: { oldPassword: e.target.old.value, newPassword: e.target.new.value } }) + e.target.reset(); toast('Contraseña cambiada') + } catch (err) { toast(err.message) } + }) +} diff --git a/app/public/apple-touch-icon.png b/app/public/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..988fb75a46b803b13652dd5cd5cf8c6b2d2e3320 GIT binary patch literal 1503 zcmeAS@N?(olHy`uVBq!ia0vp^TR@nD2}o{QKQR?ZF%}28J29*~C-ahlfpw;*i(^Q| zoVRy;Gh$t3+8_SE;^NhORjSvud#S5ShNHnFH<3HLbg~=^7rxut)ved9p|s=0q23M- zJ(kx_N3`yis;&^&&#h&+^uo1c0=}yZGbdT;y*ul=!QRlg`1zSRpY6IuxOUE*Q~CbP z9Fyhmo;(a?UYFv1nb~1y2eVM2gA$*|5ynmn1wnR|M-47A6R--->OVa5bK1RXW8>HV zFFMveZvK96k7coM|B8hcb7szbKlQ;`Yvbot*AJQ2drCbrv$J@9X6F0rkAB}YQLLBQ zC*Qi0Z~BM*me;k9&EEbd+w+_STQ;+A&Mf2Y8(W_2uA2GiL$6-{+edr9S2CRxX-{VN zJZ?GtdF{17ce%Pu*I4{HRw#Y=X>P^uuNxi}xZYV&-XQ%*aB40q#OW^|J@^sXQ6fB1 zkFRfjtB1K@j`oD0BL~}p7Kluqq{L@7g=y)GDK9J{%x-C0@lEUf_Q>H@R^e-_!YxsE zcF75{FYncI@d$ z7aNrg`h5JaDzrZMtf}+0R?$ z=GlBX@nqqo_KON(I6_cZU%y6oYo>%`i12ae za@*Pu@17^cJ)C>$ePhY+l_6%iC9$|76OHn~T5O)L;H~S69N#tn^6XW7FM} z_MAJnBT_R(;^q~><9FVtPu_E`wL3k^peN^5$KyYb&a~g(BluHEzUJ?*oI7hojCyib zSx(sZ_oeszxpsmD{TDWG+xO|Qe*CsHiOLY+_L+YTtgn6%STiRvCE)nBCN}}()UxVX zSlRJyo$TN3H$HFss~qiS{(I-^`n6}z)c(F$p0BHServKsCD0MqE^Z9xlexb!SLui7 zyE~=#|8AGBcy^avZ>|1yJD{s>gnjFK-#v5EypJ2_Rv-JmH38&ucBw#j8RO5(pH0_u z*>~cPy#4%n_E(z{Q-Y7rOis;x+4{KOw(|YNhUA3vzn*&M*A=N0E!PKoxqar`_&+a? z_RHs2i7e$&?zh?|YSM(9%!5p~Z<<}pSNQP!&SLJ%x^ix8SC0sOka@JT_2qPbo2lO{ z8=TrEy35}7x>4M*J*NEj!ISB?%|U^u{KN7{=iBYu@G3|G6K5giP5lH9QwM|IcjhAMsCIl5TQWj|848?r7yvZpLsCvAGdFiUI}ZTBfPQC)U61vBRx0!S@e$Slo!@5;u1&EZneJL@%%D zMi|hNB_f3dpELrxJUs>3)%$^YOk|s=$IC|v#hNa=Iz7bMXM1zjoa#)7&YZp^KM-21 zToQO(@XThrfvI$o%)LF2#g?T>ZWBFz-eJ;byO*}BCn)meUyFA1P+@$(*SJ{kc(mT- z#w)XVg%(a^xOZmFeAjK Math.exp(-t / BOOK.DIP) +// Lo que se levanta la hoja sobre un bloque plano. t: 0 en la costura, 1 en el borde exterior. open: 0 cerrado, 1 abierto. +export const arch = (t, open = 1) => open * (BOOK.CROWN * (1 - dipAt(t)) - BOOK.FALL * t * t) +// Altura de la hoja a profundidad v (0: la de arriba; 1: la de abajo). La curva se atenúa hasta la mitad en la última. +// k: 1 con el bloque entero; 0 sin hojas (la mitad sobre la que aún no ha aterrizado la cubierta). +export function sheetZ(t, v = 0, open = 1, k = 1) { + return BOOK.BOARD + k * (BOOK.THICK * (1 - v) * (1 - 0.45 * dipAt(t) * open) + arch(t, open) * (1 - v / 2)) +} +export const surface = (t, open = 1, k = 1) => sheetZ(t, 0, open, k) +const LID_Z = BOOK.BOARD * 2 + BOOK.THICK // la cara de arriba de la cubierta con el libro cerrado +const smooth = (a, b, x) => { const t = Math.max(0, Math.min(1, (x - a) / (b - a))); return t * t * (3 - 2 * t) } +const easeIO = t => (t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2) +// La mitad sobre la que aterriza la cubierta: hasta que está casi abajo no hay nada, solo la mesa. Después la cubierta ya la tapa. +export const landWidth = a => (a < 0.97 ? 0 : Math.max(0, -Math.cos(a * Math.PI))) +// Una hoja a media vuelta sobre la costura. a: 0 tumbada en su lado, 1 tumbada en el otro. rigid: la cubierta, que no se dobla. +// Parte de la hoja en reposo y vuelve a ella: al empezar y al acabar coincide con la superficie del libro, sin saltos. +export function sheetCurve(a, side, W, rigid = false, cols = 30, ext = 0) { + const L = rigid ? 0 : 0.38, pts = [] + const hinge = rigid ? LID_Z + (surface(0) - LID_Z) * a : surface(0) + 0.0015 + const rest = t => (rigid ? LID_Z + (surface(t) - LID_Z) * a : surface(t) + 0.0015) + let x = 0, z = 0, prev = 0 + for (let c = 0; c <= cols; c++) { + const t = (c / cols) ** 2, phi = Math.PI * (rigid ? a : smooth(0, 1, a * (1 + L) - L * t)) + x += Math.cos(phi) * (t - prev) * (W + ext) + z += Math.sin(phi) * (t - prev) * (W + ext) + prev = t + pts.push({ t, phi, x: side * x, z: hinge + z + (rest(t) - hinge) * Math.cos(phi) ** 2 }) + } + pts.forEach((p, c) => { + const q = pts[Math.min(cols, c + 1)], o = pts[Math.max(0, c - 1)], tx = q.x - o.x, tz = q.z - o.z, l = Math.hypot(tx, tz) || 1 + p.nx = (side * -tz) / l + p.nz = (side * tx) / l + }) + return pts +} +// Matriz de color de un filtro CSS (filas: r, g, b, desplazamiento), para aplicarlo a las páginas y no a la mesa. +export function filterMatrix(css) { + let M = [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0]] + const then = N => { M = N.map(r => [0, 1, 2, 3].map(c => r[0] * M[0][c] + r[1] * M[1][c] + r[2] * M[2][c] + (c === 3 ? r[3] : 0))) } + for (const [, name, val] of String(css || '').matchAll(/([a-z-]+)\(([-\d.]+)(?:deg|%)?\)/g)) { + const v = Number(val), i = 1 - v + if (name === 'brightness') then([[v, 0, 0, 0], [0, v, 0, 0], [0, 0, v, 0]]) + else if (name === 'contrast') { const o = (1 - v) / 2; then([[v, 0, 0, o], [0, v, 0, o], [0, 0, v, o]]) } + else if (name === 'invert') { const k = 1 - 2 * v; then([[k, 0, 0, v], [0, k, 0, v], [0, 0, k, v]]) } + else if (name === 'grayscale') then([[0.2126 + 0.7874 * i, 0.7152 - 0.7152 * i, 0.0722 - 0.0722 * i, 0], [0.2126 - 0.2126 * i, 0.7152 + 0.2848 * i, 0.0722 - 0.0722 * i, 0], [0.2126 - 0.2126 * i, 0.7152 - 0.7152 * i, 0.0722 + 0.9278 * i, 0]]) + else if (name === 'sepia') then([[0.393 + 0.607 * i, 0.769 - 0.769 * i, 0.189 - 0.189 * i, 0], [0.349 - 0.349 * i, 0.686 + 0.314 * i, 0.168 - 0.168 * i, 0], [0.272 - 0.272 * i, 0.534 - 0.534 * i, 0.131 + 0.869 * i, 0]]) + else if (name === 'saturate') then([[0.213 + 0.787 * v, 0.715 - 0.715 * v, 0.072 - 0.072 * v, 0], [0.213 - 0.213 * v, 0.715 + 0.285 * v, 0.072 - 0.072 * v, 0], [0.213 - 0.213 * v, 0.715 - 0.715 * v, 0.072 + 0.928 * v, 0]]) + else if (name === 'hue-rotate') { + const c = Math.cos((v * Math.PI) / 180), n = Math.sin((v * Math.PI) / 180) + then([[0.213 + c * 0.787 - n * 0.213, 0.715 - c * 0.715 - n * 0.715, 0.072 - c * 0.072 + n * 0.928, 0], [0.213 - c * 0.213 + n * 0.143, 0.715 + c * 0.285 + n * 0.14, 0.072 - c * 0.072 - n * 0.283, 0], [0.213 - c * 0.213 - n * 0.787, 0.715 - c * 0.715 + n * 0.715, 0.072 + c * 0.928 + n * 0.072, 0]]) + } + } + return M +} + +const norm = v => { const l = Math.hypot(...v) || 1; return v.map(n => n / l) } +const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]] +const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2] +// Perspectiva cerrada e inclinación mayor que medio campo de visión: así la cámara queda al sur del libro y se ve el canto de abajo. +export const FOV = (14 * Math.PI) / 180, TILT = (14 * Math.PI) / 180, NEAR = 0.5, FAR = 40 + +// Matriz de la cámara, por columnas. tilt: inclinación hacia el sur, para ver el libro con algo de ángulo. +export function cameraMatrix(target, dist, aspect, tilt = TILT, yaw = 0) { + const eye = [target[0] + dist * Math.sin(yaw) * Math.cos(tilt), target[1] - dist * Math.sin(tilt), target[2] + dist * Math.cos(yaw) * Math.cos(tilt)] + const f = norm([target[0] - eye[0], target[1] - eye[1], target[2] - eye[2]]), s = norm(cross(f, [0, 1, 0])), u = cross(s, f) + const k = 1 / Math.tan(FOV / 2), A = (FAR + NEAR) / (NEAR - FAR), B = (2 * FAR * NEAR) / (NEAR - FAR) + const row = (v, o) => [v[0], v[1], v[2], o] + const V0 = row(s, -dot(s, eye)), V1 = row(u, -dot(u, eye)), V2 = row(f.map(n => -n), dot(f, eye)) + const m = new Float32Array(16) + for (let c = 0; c < 4; c++) { + m[c * 4] = (k / aspect) * V0[c] + m[c * 4 + 1] = k * V1[c] + m[c * 4 + 2] = A * V2[c] + (c === 3 ? B : 0) + m[c * 4 + 3] = -V2[c] + } + return m +} +export function project(m, p) { + const w = m[3] * p[0] + m[7] * p[1] + m[11] * p[2] + m[15] + return [(m[0] * p[0] + m[4] * p[1] + m[8] * p[2] + m[12]) / w, (m[1] * p[0] + m[5] * p[1] + m[9] * p[2] + m[13]) / w] +} +// Distancia y objetivo para que los puntos llenen la pantalla, centrados y dejando margen (en fracción de la pantalla). +export function fitCamera(points, aspect, marginX, marginY) { + const target = [0, 0, 0] + const n = points.length + for (let k = 0; k < 2; k++) target[k] = points.reduce((a, p) => a + p[k], 0) / n + const limX = 1 - 2 * marginX, limY = 1 - 2 * marginY + let lo = 0.5, hi = 30 + const extent = d => { + for (let pass = 0; pass < 5; pass++) { // recentrar: con perspectiva e inclinación el centro se desplaza + const m = cameraMatrix(target, d, aspect), q = points.map(p => project(m, p)) + const xs = q.map(a => a[0]), ys = q.map(a => a[1]) + const midX = (Math.max(...xs) + Math.min(...xs)) / 2, midY = (Math.max(...ys) + Math.min(...ys)) / 2 + const half = d * Math.tan(FOV / 2) + target[0] += midX * half * aspect + target[1] += midY * half + } + const m = cameraMatrix(target, d, aspect), q = points.map(p => project(m, p)) + const xs = q.map(a => a[0]), ys = q.map(a => a[1]) + return Math.max((Math.max(...xs) - Math.min(...xs)) / 2 / limX, (Math.max(...ys) - Math.min(...ys)) / 2 / limY) + } + for (let i = 0; i < 26; i++) { + const d = (lo + hi) / 2 + if (extent(d) > 1) lo = d; else hi = d + } + extent(hi) + return { target, dist: hi } +} + +// Colores de la portada para la contraportada y sus botones. +const luma = c => { const f = v => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4); return 0.2126 * f(c[0]) + 0.7152 * f(c[1]) + 0.0722 * f(c[2]) } +const contrast = (a, b) => { const x = luma(a), y = luma(b); return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05) } +export const inkOn = c => (contrast(c, [1, 1, 1]) >= contrast(c, [0.07, 0.06, 0.05]) ? [1, 1, 1] : [0.07, 0.06, 0.05]) +const mixColor = (a, b, t) => a.map((n, k) => n + (b[k] - n) * t) +export const css = c => `rgb(${c.map(n => Math.round(n * 255)).join(' ')})` +// Color dominante y color de acento: el acento es el que más destaca sobre el dominante, sin ser una mota de la imagen. +export function coverPalette(data) { + const boxes = new Map() + for (let p = 0; p < data.length; p += 4) { + const key = ((data[p] >> 4) << 8) | ((data[p + 1] >> 4) << 4) | (data[p + 2] >> 4) + const b = boxes.get(key) || { n: 0, c: [0, 0, 0] } + b.n++ + for (let k = 0; k < 3; k++) b.c[k] += data[p + k] / 255 + boxes.set(key, b) + } + const list = [...boxes.values()].map(b => ({ n: b.n, c: b.c.map(v => v / b.n) })).sort((a, b) => b.n - a.n) + const base = list[0]?.c || [0.2, 0.14, 0.1] + let accent = null, best = 0 + for (const b of list) { + const sat = Math.max(...b.c) - Math.min(...b.c), score = Math.sqrt(b.n) * Math.hypot(b.c[0] - base[0], b.c[1] - base[1], b.c[2] - base[2]) * (0.35 + sat) + if (score > best) { best = score; accent = b.c } + } + accent ||= base + // Si apenas se distingue del dominante, se aclara o se oscurece hasta que se vea. + const away = luma(base) > 0.3 ? [0, 0, 0] : [1, 1, 1] + for (let i = 0; i < 10 && contrast(accent, base) < 2.4; i++) accent = mixColor(accent, away, 0.18) + return { base, accent } +} + +// Madera generada: se repite sin que se note el corte. +export function woodCanvas() { + const S = 1024, PLANKS = 5, canvas = Object.assign(document.createElement('canvas'), { width: S, height: S }), g = canvas.getContext('2d') + let seed = 7 + const rnd = () => (seed = (seed * 16807) % 2147483647) / 2147483647 + for (let p = 0; p < PLANKS; p++) { + const x0 = (p * S) / PLANKS, w = S / PLANKS, light = 34 + rnd() * 7 + g.save() + g.beginPath(); g.rect(x0, 0, w, S); g.clip() + g.fillStyle = `hsl(${24 + rnd() * 5}, 46%, ${light}%)` + g.fillRect(x0, 0, w, S) + const phase = rnd() * 6, swing = 4 + rnd() * 10 + for (let i = 0; i < 170; i++) { + const x = x0 + rnd() * w, dark = rnd() < 0.72 + g.strokeStyle = dark ? `rgba(40,20,8,${0.05 + rnd() * 0.16})` : `rgba(255,214,160,${0.02 + rnd() * 0.07})` + g.lineWidth = 0.5 + rnd() * 2.4 + g.beginPath() + for (let y = 0; y <= S; y += 32) { + const turn = (y / S) * Math.PI * 2 + const dx = Math.sin(turn + phase) * swing + Math.sin(turn * 3 + x * 0.05) * 1.6 + if (y) g.lineTo(x + dx, y); else g.moveTo(x + dx, y) + } + g.stroke() + } + g.restore() + g.fillStyle = 'rgba(20,9,3,.55)' + g.fillRect(x0 - 1, 0, 2, S) + } + return canvas +} + +const VS = `#version 300 es +uniform mat4 uM; +layout(location = 0) in vec3 aPos; +layout(location = 1) in vec3 aNor; +layout(location = 2) in vec2 aUV; +out vec3 vPos; out vec3 vNor; out vec2 vUV; +void main() { vPos = aPos; vNor = aNor; vUV = aUV; gl_Position = uM * vec4(aPos, 1.0); }` +const FS = `#version 300 es +precision highp float; +uniform int uKind; +uniform sampler2D uTex; +uniform vec3 uColor; +uniform vec4 uMap, uRect; +uniform float uSoft, uAlpha, uSeamAt, uHas, uFlip, uMirror; +uniform vec2 uCast; +uniform vec3 uTab; +uniform mat3 uFx; +uniform vec3 uFxO; +in vec3 vPos; in vec3 vNor; in vec2 vUV; +out vec4 color; +const vec3 L = normalize(vec3(-0.16, -0.26, 0.95)); +// Luz: 1 con la hoja plana; se oscurece al inclinarse hacia la costura o en las caras laterales. +float light() { return clamp(mix(0.55, 1.0, max(dot(normalize(vNor) * uFlip, L), 0.0) / L.z), 0.0, 1.06); } +// La sombra de la hoja que gira: desde la costura hasta donde cae la proyección de su borde. +float castShade() { + if (uCast.y <= 0.0 || abs(uCast.x) < 0.001) return 1.0; + float r = vPos.x / uCast.x; + if (r <= 0.0) return 1.0; + // La hoja solo mide lo que mide el libro: su sombra no llega a la mesa de más arriba ni de más abajo. + float inY = 1.0 - smoothstep(0.5, 0.56, abs(vPos.y)); + return 1.0 - uCast.y * inY * (1.0 - smoothstep(0.7, 1.15, r)); +} +vec3 fx(vec3 c) { return clamp(uFx * c + uFxO, 0.0, 1.0); } +// Misma hendidura que el lector normal, para que la página se vea igual. +float gutter(float d) { + if (d < 0.012) return mix(0.45, 0.62, d / 0.012); + if (d < 0.03) return mix(0.62, 0.82, (d - 0.012) / 0.018); + if (d < 0.06) return mix(0.82, 0.94, (d - 0.03) / 0.03); + if (d < 0.1) return mix(0.94, 1.0, (d - 0.06) / 0.04); + return 1.0; +} +void main() { + if (uKind == 0) { // mesa: la luz cae sobre el libro y se apaga hacia fuera + vec3 wood = uTab.z > 0.0 ? texture(uTex, vec2(0.5 + vPos.x * uTab.x, 0.5 - vPos.y * uTab.y), -0.4).rgb : texture(uTex, vUV, -0.4).rgb; + float r = length((vPos.xy - uRect.xy) / uRect.zw); + color = vec4(wood * mix(1.08, 0.62, smoothstep(0.5, 2.1, r)) * castShade(), 1.0); + } else if (uKind == 1) { // sombra en la mesa: un rectángulo difuso + vec2 a = smoothstep(uRect.xy - uSoft, uRect.xy + uSoft, vPos.xy) * (1.0 - smoothstep(uRect.zw - uSoft, uRect.zw + uSoft, vPos.xy)); + color = vec4(0.05, 0.02, 0.0, 1.0) * uAlpha * a.x * a.y; + } else if (uKind == 2) { // tapa + color = vec4(uColor * light() * castShade(), 1.0); + } else if (uKind == 3) { // canto: cada línea es una hoja, y sigue la curva porque la malla la sigue + float v = vUV.y * 15.0, w = fwidth(v); + float line = 1.0 - smoothstep(0.0, max(w, 0.35), abs(fract(v) - 0.5) - 0.12); + vec3 c = uColor * (1.0 - 0.17 * line) * mix(0.82, 1.0, smoothstep(0.0, 0.45, vUV.y)); + color = vec4(fx(c) * min(1.0, light() * 1.3) * castShade(), 1.0); // el papel de los cantos recibe luz rebotada de la mesa + } else if (uKind == 4) { // página + float ux = uMirror > 0.5 ? 1.0 - vUV.x : vUV.x; + vec2 q = vec2(ux * uMap.x + uMap.y, vUV.y * uMap.z + uMap.w); + vec3 c = uColor; + if (uHas > 0.5 && q.x >= 0.0 && q.x <= 1.0 && q.y >= 0.0 && q.y <= 1.0) c = texture(uTex, q, -0.5).rgb; + color = vec4(fx(c) * light() * gutter(uSeamAt < 0.5 ? ux : 1.0 - ux) * castShade(), 1.0); + } else if (uKind == 5) { // portada, plana y sin hendidura + color = vec4(fx(uHas > 0.5 ? texture(uTex, vUV, -0.5).rgb : uColor) * light() * castShade(), 1.0); + } else { // lomo: el color del borde de la portada, de arriba abajo + color = vec4(fx(uHas > 0.5 ? texture(uTex, vec2(uSeamAt, vUV.y)).rgb : uColor) * light() * castShade(), 1.0); + } +}` + +// Junta rejillas en una malla: pos(3) normal(3) uv(2). +export class Shape { + constructor() { this.v = []; this.i = [] } + grid(cols, rows, at) { + const base = this.v.length / 8 + for (let c = 0; c <= cols; c++) for (let r = 0; r <= rows; r++) this.v.push(...at(c, r)) + for (let c = 0; c < cols; c++) for (let r = 0; r < rows; r++) { + const a = base + c * (rows + 1) + r, b = a + rows + 1 + this.i.push(a, a + 1, b, b, a + 1, b + 1) + } + return this + } + box(x0, x1, y0, y1, z0, z1) { + const quad = (o, a, b, n) => this.grid(1, 1, (c, r) => [o[0] + a[0] * c + b[0] * r, o[1] + a[1] * c + b[1] * r, o[2] + a[2] * c + b[2] * r, ...n, c, r]) + const dx = x1 - x0, dy = y1 - y0, dz = z1 - z0 + quad([x0, y0, z1], [dx, 0, 0], [0, dy, 0], [0, 0, 1]) + quad([x0, y0, z0], [dx, 0, 0], [0, 0, dz], [0, -1, 0]) + quad([x0, y1, z0], [dx, 0, 0], [0, 0, dz], [0, 1, 0]) + quad([x0, y0, z0], [0, dy, 0], [0, 0, dz], [-1, 0, 0]) + quad([x1, y0, z0], [0, dy, 0], [0, 0, dz], [1, 0, 0]) + return this + } +} +const COLS = 30 +const tAt = c => (c / COLS) ** 2 // más columnas junto a la costura, donde la curva es más cerrada +function slope(t, open, k) { const e = 0.002, a = Math.min(1, t + e), b = Math.max(0, t - e); return (surface(a, open, k) - surface(b, open, k)) / (a - b) } +// Una mitad del libro. side: -1 izquierda, 1 derecha. width: lo que ya se ha extendido, de 0 a 1. +function pageShape(side, W, open, k = 1, width = 1) { + return new Shape().grid(COLS, 1, (c, r) => { + const t = tAt(c), n = norm([(-side * slope(t, open, k)) / W, 0, 1]) + return [side * W * width * t, 0.5 - r, surface(t, open, k), ...n, side > 0 ? t : 1 - t, r] + }) +} +// Los cantos de esa mitad, extruidos desde la misma curva: abajo, arriba y por fuera. +function edgeShape(sh, side, W, open, k = 1, width = 1) { + const RINGS = 5, v = r => (r >= RINGS ? 1 : r / RINGS) + const z = (t, r) => (r > RINGS ? BOOK.BOARD : sheetZ(t, v(r), open, k)) + for (const [y, ny] of [[-0.5, -1], [0.5, 1]]) sh.grid(COLS, RINGS + 1, (c, r) => { const t = tAt(c); return [side * W * width * t, y, z(t, r), 0, ny, 0, t, v(r)] }) + sh.grid(1, RINGS + 1, (c, r) => [side * W * width, c - 0.5, z(1, r), side, 0, 0, c, v(r)]) + return sh +} +function spineShape(outward, top, size) { + const STEPS = 10, mid = top / 2 + return new Shape().grid(STEPS, 1, (c, r) => { + const a = (c / STEPS) * Math.PI, sx = Math.sin(a), cz = Math.cos(a) + return [outward * size * sx, 0.5 + BOOK.OVER - r * (1 + 2 * BOOK.OVER), mid + mid * cz, outward * sx, 0, cz, c / STEPS, r] + }) +} +// La hoja que gira y, en la cubierta, su grosor por el borde de abajo y por fuera. +function sheetShape(pts, y0 = -0.5, y1 = 0.5) { + const s = new Shape(), n = pts.length - 1 + s.grid(n, 1, (c, r) => { const p = pts[c]; return [p.x, y1 + (y0 - y1) * r, p.z, p.nx, 0, p.nz, p.u, r] }) + return s +} +function rimShape(pts, y0, y1, thick) { + const s = new Shape(), n = pts.length - 1, e = pts[n] + s.grid(n, 1, (c, r) => { const p = pts[c]; return [p.x - p.nx * thick * r, y0, p.z - p.nz * thick * r, 0, -1, 0, 0, 0] }) + s.grid(1, 1, (c, r) => [e.x - e.nx * thick * r, y0 + (y1 - y0) * c, e.z - e.nz * thick * r, e.nz * Math.sign(e.x || 1), 0, -e.nx * Math.sign(e.x || 1), 0, 0]) + return s +} + +// El sensor: los ejes de la pantalla a partir de la orientación del dispositivo. La inclinación se desvanece con el tiempo. +// MAX_DEG: lo que hay que girar el dispositivo para llegar al tope. ORBIT_*: cuánto rodea entonces la cámara al libro, en grados. +export const MAX_DEG = 15 +const FADE_S = 2.5, QUIET = 0.6, ORBIT_TILT = 7, ORBIT_YAW = 10, ORBIT_BACK = 0.06 +export function createTilt(onChange) { + const target = { x: 0, y: 0 } + let listening = false, gotOrientation = false, lastT = 0, lastOri = null, lastFade = 0, askOnTouch = null + const clamp = v => Math.max(-MAX_DEG, Math.min(MAX_DEG, v)) + function screenAngle() { + let a = (((window.orientation ?? screen.orientation?.angle ?? 0) % 360) + 360) % 360 + const landscape = innerWidth > innerHeight + if (landscape && a % 180 === 0) a = 90 + if (!landscape && a % 180) a = 0 + return (a * Math.PI) / 180 + } + function add(dx, dy) { + const a = screenAngle(), c = Math.cos(a), s = Math.sin(a) + target.x = clamp(target.x + dx * c - dy * s) + target.y = clamp(target.y + dx * s + dy * c) + lastFade = lastFade || performance.now() + onChange() + } + const stepOf = t => { const dt = Math.min(0.1, Math.max(0, (t - lastT) / 1000)); lastT = t; return dt } + const quiet = v => (Math.abs(v) < QUIET ? 0 : v) + function onOrientation(e) { + if (e.beta == null || e.gamma == null) return + gotOrientation = true + const dt = stepOf(e.timeStamp) + if (lastOri) { + const dx = e.beta - lastOri.b, dy = e.gamma - lastOri.g + const still = dt > 0 && Math.hypot(dx, dy) / dt < QUIET + if (!still && Math.abs(dx) < 25 && Math.abs(dy) < 25) add(dx, dy) + } + lastOri = { b: e.beta, g: e.gamma } + } + function onMotion(e) { + const r = e.rotationRate + if (gotOrientation || r?.alpha == null || r?.beta == null) return + const dt = stepOf(e.timeStamp) + add(quiet(r.alpha) * dt, quiet(r.beta) * dt) // especificación: alpha es el eje x y beta el eje y + } + function listen(on) { + if (on === listening) return + listening = on + const method = on ? 'addEventListener' : 'removeEventListener' + window[method]('devicemotion', onMotion) + window[method]('deviceorientation', onOrientation) + target.x = target.y = 0 + lastT = performance.now() + lastOri = null + onChange() + } + const forget = () => { if (askOnTouch) document.removeEventListener('click', askOnTouch); askOnTouch = null } + // iOS pide permiso desde un toque. Con el permiso ya dado se repite sin toque; si no, espera al siguiente. Sin permiso, el libro queda quieto. + function permit() { + if (!isSecureContext) return + const M = window.DeviceMotionEvent, O = window.DeviceOrientationEvent + const asked = M?.requestPermission ? M.requestPermission() : O?.requestPermission ? O.requestPermission() : null + if (!asked) return listen(true) + asked.then(answer => { forget(); if (answer === 'granted' && document.visibilityState === 'visible') listen(true) }, () => { + if (askOnTouch) return + askOnTouch = () => { forget(); permit() } + document.addEventListener('click', askOnTouch) + }) + } + return { + target, + permit, + listen, + get listening() { return listening }, + // Con el dispositivo quieto pueden dejar de llegar lecturas: el regreso al centro va por tiempo. + fade(now) { + const dt = Math.min(0.1, Math.max(0, (now - lastFade) / 1000)), keep = Math.exp(-dt / FADE_S) + lastFade = now + for (const k of ['x', 'y']) target[k] = Math.abs(target[k] * keep) < 0.3 ? 0 : target[k] * keep + }, + stop() { forget(); listen(false) }, + } +} + +export function createBook3D(container, opts) { + const { total, src, whenReady, spreadOf, isWide, still = false } = opts + const canvas = document.createElement('canvas') + canvas.className = 'b3dcanvas' + const gl = canvas.getContext('webgl2', { alpha: false, antialias: true, depth: true, powerPreference: 'high-performance' }) + if (!gl) return null + container.replaceChildren(canvas) + let dead = false, raf = 0, u = {}, prog = null, wood = null, tableMesh = null, aniso = null, maxSize = 2400 + let tableTex = null, tableAspect = 1 + let backTex = null, backColors = null, backSent = '', backFrom = null, backNear = false, backTimer = 0 + let W = 0, coverColor = [0.2, 0.14, 0.1], view = null, statics = null, staticKey = '', dyn = null + let anim = null, camNow = null, camMove = null, zoom = { s: 1, x: 0, y: 0 }, fxM = filterMatrix('') + const textures = new Map(), loading = new Map(), queue = [] + const fitCache = new Map() + let uploadTimer = 0, rect = { width: 1, height: 1 } + const tiltNow = { x: 0, y: 0 } + const tilt = createTilt(() => draw()) + + function makeMesh(sh, usage = gl.STATIC_DRAW) { + const vao = gl.createVertexArray(), vb = gl.createBuffer(), ib = gl.createBuffer() + gl.bindVertexArray(vao) + gl.bindBuffer(gl.ARRAY_BUFFER, vb) + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(sh.v), usage) + gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ib) + gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(sh.i), usage) + gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 32, 0) + gl.enableVertexAttribArray(1); gl.vertexAttribPointer(1, 3, gl.FLOAT, false, 32, 12) + gl.enableVertexAttribArray(2); gl.vertexAttribPointer(2, 2, gl.FLOAT, false, 32, 24) + gl.bindVertexArray(null) + return { vao, vb, ib, n: sh.i.length } + } + const freeMesh = m => { if (m) { gl.deleteVertexArray(m.vao); gl.deleteBuffer(m.vb); gl.deleteBuffer(m.ib) } } + const drawMesh = m => { gl.bindVertexArray(m.vao); gl.drawElements(gl.TRIANGLES, m.n, gl.UNSIGNED_SHORT, 0) } + // Mallas que cambian en cada fotograma de una animación: se reutilizan los mismos búferes. + function dynMesh(name, sh) { + const m = (dyn[name] ||= makeMesh(new Shape(), gl.DYNAMIC_DRAW)) + gl.bindVertexArray(m.vao) + gl.bindBuffer(gl.ARRAY_BUFFER, m.vb) + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(sh.v), gl.DYNAMIC_DRAW) + gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, m.ib) + gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(sh.i), gl.DYNAMIC_DRAW) + gl.bindVertexArray(null) + m.n = sh.i.length + return m + } + + function initGL() { + const shader = (type, text) => { const s = gl.createShader(type); gl.shaderSource(s, text); gl.compileShader(s); return s } + prog = gl.createProgram() + gl.attachShader(prog, shader(gl.VERTEX_SHADER, VS)) + gl.attachShader(prog, shader(gl.FRAGMENT_SHADER, FS)) + gl.linkProgram(prog) + if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(prog) || 'shader') + gl.useProgram(prog) + u = {} + for (const n of ['uM', 'uKind', 'uTex', 'uColor', 'uMap', 'uRect', 'uSoft', 'uAlpha', 'uSeamAt', 'uHas', 'uFlip', 'uMirror', 'uCast', 'uTab', 'uFx', 'uFxO']) u[n] = gl.getUniformLocation(prog, n) + gl.uniform1i(u.uTex, 0) + gl.uniform1f(u.uFlip, 1) + gl.enable(gl.DEPTH_TEST) + gl.depthFunc(gl.LEQUAL) + gl.clearColor(0.29, 0.18, 0.1, 1) + gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA) + aniso = gl.getExtension('EXT_texture_filter_anisotropic') + maxSize = Math.min(gl.getParameter(gl.MAX_TEXTURE_SIZE), 2400) // más grande no se aprecia y agota la memoria en la tableta + wood = gl.createTexture() + gl.bindTexture(gl.TEXTURE_2D, wood) + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, woodCanvas()) + gl.generateMipmap(gl.TEXTURE_2D) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT) + const TABLE = 14, TILE = 1.7 + tableMesh = makeMesh(new Shape().grid(1, 1, (c, r) => { const x = (c * 2 - 1) * TABLE, y = (r * 2 - 1) * TABLE; return [x, y, 0, 0, 0, 1, x / TILE, y / TILE] })) + dyn = {} + statics = null + staticKey = '' + } + + // ----- Texturas ----- + function makeTexture(source) { + const tex = gl.createTexture() + gl.bindTexture(gl.TEXTURE_2D, tex) + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source) + gl.generateMipmap(gl.TEXTURE_2D) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE) + if (aniso) gl.texParameterf(gl.TEXTURE_2D, aniso.TEXTURE_MAX_ANISOTROPY_EXT, Math.min(8, gl.getParameter(aniso.MAX_TEXTURE_MAX_ANISOTROPY_EXT))) + return tex + } + // Fondo elegido por el usuario: una sola imagen fija sobre la mesa, que llena lo que se ve. Si asoma el borde, sigue en espejo, sin corte. + function loadTable() { + if (!opts.table) return + const im = new Image() + im.onload = () => { + if (dead || gl.isContextLost() || !im.naturalWidth) return + tableTex = makeTexture(im) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.MIRRORED_REPEAT) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.MIRRORED_REPEAT) + tableAspect = im.naturalWidth / im.naturalHeight + draw() + } + im.src = opts.table + } + function tableScale() { + let half = 1.5 + if (W > 0) { + const ref = fitOf({ kind: 'open', focus: rect.height > rect.width ? 1 : 0 }) // la vista más abierta de esta pantalla + const hy = ref.dist * 1.3 * Math.tan(FOV / 2) * 1.1 + Math.abs(ref.target[1]), hx = hy * (rect.width / rect.height) + Math.abs(ref.target[0]) + half = Math.max(hy, hx / tableAspect) + } + return 1 / (2 * half) + } + // La contraportada: los colores de la portada, en espejo y muy difuminados para que continúe por el lomo, con un marco de su color de acento. + function buildBack(im) { + try { + const small = Object.assign(document.createElement('canvas'), { width: 48, height: 72 }), sg = small.getContext('2d', { willReadFrequently: true }) + sg.drawImage(im, 0, 0, 48, 72) + const { base, accent } = coverPalette(sg.getImageData(0, 0, 48, 72).data) + const H = 1152, w = Math.round(H * Math.min(0.85, Math.max(0.45, im.naturalWidth / im.naturalHeight))) + const c = Object.assign(document.createElement('canvas'), { width: w, height: H }), g = c.getContext('2d', { willReadFrequently: true }) + g.fillStyle = css(base) + g.fillRect(0, 0, w, H) + const blur = Object.assign(document.createElement('canvas'), { width: 3, height: 4 }), bg = blur.getContext('2d') + bg.translate(3, 0) + bg.scale(-1, 1) + bg.drawImage(small, 0, 0, 3, 4) + g.imageSmoothingEnabled = true + g.imageSmoothingQuality = 'high' + g.globalAlpha = 0.4 + g.drawImage(blur, 0, 0, w, H) + g.globalAlpha = 1 + const shade = g.createRadialGradient(w / 2, H / 2, H * 0.2, w / 2, H / 2, H * 0.72) + shade.addColorStop(0, 'rgba(0,0,0,0)') + shade.addColorStop(1, 'rgba(0,0,0,.28)') + g.fillStyle = shade + g.fillRect(0, 0, w, H) + const inset = w * 0.055 + g.strokeStyle = css(accent) + g.lineWidth = w * 0.009 + g.strokeRect(inset, inset, w - 2 * inset, H - 2 * inset) + g.lineWidth = w * 0.003 + g.strokeRect(inset * 1.35, inset * 1.35, w - 2.7 * inset, H - 2.7 * inset) + const mid = g.getImageData(Math.round(w * 0.2), Math.round(H * 0.3), Math.round(w * 0.6), Math.round(H * 0.4)).data, surface = [0, 0, 0] + for (let p = 0; p < mid.length; p += 4) for (let k = 0; k < 3; k++) surface[k] += mid[p + k] + const area = mid.length / 4 + for (let k = 0; k < 3; k++) surface[k] /= area * 255 + if (backTex) gl.deleteTexture(backTex) + backTex = makeTexture(c) + backColors = { surface: css(surface), ink: css(inkOn(surface)), accent: css(accent), accentInk: css(inkOn(accent)), line: css(mixColor(surface, inkOn(surface), 0.45)) } + backSent = '' + } catch (err) { console.warn('Contraportada:', err) } // sin ella, la tapa queda del color del borde de la portada + } + // Se fabrica una vez y solo cerca del final: al abrir el capítulo no debe retrasar las primeras páginas. + function ensureBack() { + if (!backFrom || !backNear || dead || gl.isContextLost()) return + const im = backFrom + backFrom = null + buildBack(im) + realBack() + draw() + } + // La contraportada original del tomo, si existe, sustituye a la fabricada; los colores de los botones no cambian. + let backImage = null, backAsked = false + function realBack() { + if (backImage) return placeRealBack() + if (!opts.back || backAsked) return + backAsked = true + const im = new Image() + im.onload = () => { + if (dead || !im.naturalWidth) return + backImage = im + placeRealBack() + draw() + } + im.src = opts.back + } + function placeRealBack() { + if (dead || gl.isContextLost() || !backColors) return + let source = backImage + const big = Math.max(source.naturalWidth, source.naturalHeight) + if (big > maxSize) { + const k = maxSize / big, c = Object.assign(document.createElement('canvas'), { width: Math.round(source.naturalWidth * k), height: Math.round(source.naturalHeight * k) }) + c.getContext('2d').drawImage(source, 0, 0, c.width, c.height) + source = c + } + if (backTex) gl.deleteTexture(backTex) + backTex = makeTexture(source) + } + function upload(i, im) { + let source = im, w = im.naturalWidth, h = im.naturalHeight + if (!w || !h) return + if (Math.max(w, h) > maxSize) { // reducir aquí: pasarla entera podría fallar + const k = maxSize / Math.max(w, h), c = Object.assign(document.createElement('canvas'), { width: Math.round(w * k), height: Math.round(h * k) }), g = c.getContext('2d') + g.imageSmoothingQuality = 'high' + g.drawImage(im, 0, 0, c.width, c.height) + source = c + } + if (i === 0) { coverColor = sampleCover(im); backFrom = im; ensureBack() } + const tex = makeTexture(source) + const had = textures.get(i) + if (had) gl.deleteTexture(had.tex) + textures.set(i, { tex, w, h }) + // La forma del libro la da la primera página que no sea ancha. + if (!W && w < h) { W = Math.min(0.85, Math.max(0.45, w / h)); staticKey = ''; fitCache.clear() } + draw() + } + function sampleCover(im) { + try { + const c = Object.assign(document.createElement('canvas'), { width: 8, height: 8 }), g = c.getContext('2d', { willReadFrequently: true }) + g.drawImage(im, 0, im.naturalHeight * 0.92, im.naturalWidth, im.naturalHeight * 0.08, 0, 0, 8, 8) // el canto de abajo, que es lo que se ve + const d = g.getImageData(0, 0, 8, 8).data, sum = [0, 0, 0] + for (let p = 0; p < d.length; p += 4) for (let k = 0; k < 3; k++) sum[k] += d[p + k] + return sum.map(v => (v / 64 / 255) * 0.62) + } catch { return coverColor } + } + // Se sube de una en una y lo que se ve ahora primero: subir una textura grande para un momento el hilo principal. + function pump() { + uploadTimer = 0 + if (dead || !queue.length || gl.isContextLost()) return + if (anim && !anim.dragging && anim.p < 1) { uploadTimer = setTimeout(pump, 120); return } // no durante un giro + const visible = view ? pagesOf(view) : [] + const at = queue.findIndex(q => visible.includes(q.i)) + const [next] = queue.splice(Math.max(0, at), 1) + if (!textures.has(next.i) && wanted(next.i)) upload(next.i, next.im) + if (queue.length) uploadTimer = setTimeout(pump, visible.includes(queue[0]?.i) ? 0 : 90) + } + let keepFrom = 0, keepTo = 0 + let glEpoch = 0 // lo que se pidió antes de perder el contexto ya no vale: su textura nacería muerta y la página quedaría en blanco + const wanted = i => i === 0 || (i >= keepFrom && i <= keepTo) + function want(i) { + if (i == null || i < 0 || i >= total || textures.has(i) || loading.has(i) || gl.isContextLost()) return + if (queue.some(q => q.i === i)) return + loading.set(i, true) + const epoch = glEpoch + const go = url => { + const im = new Image() + im.onload = async () => { + await im.decode?.().catch(() => {}) + if (dead || epoch !== glEpoch) return + loading.delete(i) + queue.push({ i, im }) + if (!uploadTimer) uploadTimer = setTimeout(pump, 0) + } + im.onerror = () => { if (epoch === glEpoch) loading.delete(i) } + im.src = url + } + if (whenReady) whenReady(i, go); else go(src(i)) + } + function trim() { + for (const [i, t] of textures) if (!wanted(i)) { gl.deleteTexture(t.tex); textures.delete(i) } + for (let n = queue.length - 1; n >= 0; n--) if (!wanted(queue[n].i)) queue.splice(n, 1) + } + + // ----- Lo que se ve ----- + const rtl = () => opts.dir() === 'rtl' + function viewOf(group) { + const first = group[0], spread = spreadOf(first) + if (spread.length === 1 && isWide(first)) return { kind: 'wide', page: first, focus: 0, key: `w${first}` } + if (spread.length === 1 && first === 0) return { kind: 'closed', under: rtl() ? -1 : 1, cover: 0, focus: 0, key: 'c' } + const [a, b = null] = spread, right = rtl() ? a : b, left = rtl() ? b : a + return { kind: 'open', right, left, focus: group.length === 1 && !(spread.length === 1 && opts.double?.()) ? (group[0] === right ? 1 : -1) : 0, key: `o${a}` } // a doble página, una suelta se ve con el libro entero + } + const pagesOf = v => (v.kind === 'closed' ? [0] : v.kind === 'wide' ? [v.page] : [v.left, v.right]).filter(i => i != null) + const pageOn = (v, side) => (v.kind === 'wide' ? { i: v.page, wide: true } : v.kind === 'closed' ? { i: null } : { i: side > 0 ? v.right : v.left }) + const hasAll = v => pagesOf(v).every(i => textures.has(i)) + + // Lo que debe llenar la pantalla. A una página, solo esa mitad; la otra sigue ahí, fuera de cuadro. + function frameOf(v) { + const O = BOOK.OVER, y0 = -0.5 - O, y1 = 0.5 + O, h = BOOK.BOARD + BOOK.THICK + BOOK.CROWN + let x0 = -W - O, x1 = W + O + if (v.kind === 'closed') { + x0 = v.under > 0 ? -BOOK.SPINE : -W - O + x1 = v.under > 0 ? W + O : BOOK.SPINE + } else if (v.focus) { + x0 = v.focus > 0 ? 0 : -W - O + x1 = v.focus > 0 ? W + O : 0 + } + return [[x0, y0, 0], [x1, y0, 0], [x0, y1, 0], [x1, y1, 0], [x0, y0, h], [x1, y0, h], [x0, y1, h], [x1, y1, h]] + } + // El encuadre se calcula una vez por vista y tamaño de pantalla: no hace falta repetirlo en cada fotograma. + function fitOf(v) { + const margin = Math.max(14, Math.min(40, Math.min(rect.width, rect.height) * 0.04)) + const key = `${v.kind}:${v.under || 0}:${v.focus}:${W}:${rect.width}x${rect.height}` + let cam = fitCache.get(key) + if (!cam) { + cam = fitCamera(frameOf(v), rect.width / rect.height, margin / rect.width, margin / rect.height) + if (fitCache.size > 12) fitCache.clear() + fitCache.set(key, cam) + } + return cam + } + const mixCam = (a, b, t) => ({ target: a.target.map((n, k) => n + (b.target[k] - n) * t), dist: a.dist + (b.dist - a.dist) * t }) + + // Libro en reposo: la malla se reutiliza. Durante la cubierta se rehace en cada fotograma. + function bookShapes(o, under, land) { + const O = BOOK.OVER, ed = new Shape() + const out = { left: null, right: null } + for (const side of [-1, 1]) { + const isLand = under && side === -under, k = isLand ? land.k : 1, width = isLand ? land.width : 1 + if (!width) continue + out[side > 0 ? 'right' : 'left'] = pageShape(side, W, o, k, width) + edgeShape(ed, side, W, o, k, width) + } + const landOf = side => (under && side === -under ? land.width : 1) + const xL = -W * landOf(-1) - (landOf(-1) ? O : 0), xR = W * landOf(1) + (landOf(1) ? O : 0) + out.edges = ed + out.board = new Shape().box(xL, xR, -0.5 - O, 0.5 + O, 0, BOOK.BOARD) + return out + } + function staticMeshes(v) { + const key = `${v.kind === 'closed' ? `c${v.under}` : 'o'}:${W}` + if (key === staticKey && statics) return statics + if (statics) for (const m of Object.values(statics)) freeMesh(m) + staticKey = key + const sh = v.kind === 'closed' ? bookShapes(0, v.under, { k: 0, width: 0 }) : bookShapes(1, 0, null) + statics = {} + for (const [n, s] of Object.entries(sh)) if (s) statics[n] = makeMesh(s) + return statics + } + + function pageMap(i, side, wide) { + const t = textures.get(i) + if (!t) return null + const region = wide ? 2 * W : W, a = t.w / t.h + let sx = 1, sy = 1 // Fracción del hueco que llena la imagen, sin deformarla + if (a < region) sx = a / region; else sy = region / a + const ox = (1 - sx) / 2, oy = (1 - sy) / 2 + const scale = wide ? 0.5 : 1, start = wide && side > 0 ? 0.5 : 0 + return { tex: t.tex, map: [scale / sx, (start - ox) / sx, 1 / sy, -oy / sy] } + } + function drawPage(mesh, page, side, mirror = false) { + const p = page?.i == null ? null : pageMap(page.i, side, page.wide) + gl.uniform1i(u.uKind, 4) + gl.uniform1f(u.uSeamAt, side > 0 ? 0 : 1) + gl.uniform1f(u.uHas, p ? 1 : 0) + gl.uniform1f(u.uMirror, mirror ? 1 : 0) + if (p) { gl.bindTexture(gl.TEXTURE_2D, p.tex); gl.uniform4fv(u.uMap, p.map) } + gl.uniform3fv(u.uColor, opts.paper()) + drawMesh(mesh) + gl.uniform1f(u.uMirror, 0) + } + // Dibuja las dos caras de una hoja: la de delante en su lado y la de detrás, ya volteada, en el otro. + function drawSheet(mesh, side, front, back, lid = null) { + gl.enable(gl.CULL_FACE) + gl.frontFace(side > 0 ? gl.CCW : gl.CW) + gl.cullFace(gl.BACK) + if (lid) { + gl.uniform1i(u.uKind, 5) + gl.uniform1f(u.uHas, lid.tex ? 1 : 0) + if (lid.tex) gl.bindTexture(gl.TEXTURE_2D, lid.tex) + gl.uniform3fv(u.uColor, coverColor) + drawMesh(mesh) + } else drawPage(mesh, front, side) + gl.cullFace(gl.FRONT) + gl.uniform1f(u.uFlip, -1) + drawPage(mesh, back, -side, true) + gl.uniform1f(u.uFlip, 1) + gl.disable(gl.CULL_FACE) + } + + function drawBook(m, left, right) { + gl.uniform1i(u.uKind, 2) + gl.uniform3fv(u.uColor, coverColor) + drawMesh(m.board) + gl.uniform1i(u.uKind, 3) + gl.uniform3fv(u.uColor, opts.paper()) + drawMesh(m.edges) + if (m.left) drawPage(m.left, left, -1) + if (m.right) drawPage(m.right, right, 1) + } + // Lo que va en la tapa: la portada delante, o la contraportada por detrás del libro. El lomo toma el color de su borde. + const lidOf = isBack => ({ tex: isBack ? backTex : textures.get(0)?.tex }) + function drawSpine(under, top, size, lid) { + gl.uniform1i(u.uKind, 6) + gl.uniform1f(u.uHas, lid.tex ? 1 : 0) + if (lid.tex) gl.bindTexture(gl.TEXTURE_2D, lid.tex) + gl.uniform3fv(u.uColor, coverColor) + gl.uniform1f(u.uSeamAt, under > 0 ? 0.01 : 0.99) + drawMesh(dynMesh('spine', spineShape(-under, top, size))) + } + // La cubierta: una pieza rígida con grosor. a: 0 cerrada, 1 ya abierta. + function drawCover(under, a, backPage, lid) { + const O = BOOK.OVER + const pts = sheetCurve(a, under, W, true, 12, O) + pts.forEach(p => { p.u = under > 0 ? p.t : 1 - p.t }) + const mesh = dynMesh('cover', sheetShape(pts, -0.5 - O, 0.5 + O)) + gl.uniform1i(u.uKind, 2) + gl.uniform3fv(u.uColor, coverColor) + drawMesh(dynMesh('rim', rimShape(pts, -0.5 - O, 0.5 + O, BOOK.BOARD))) + drawSheet(mesh, under, null, backPage, lid) + return pts.at(-1) + } + + function stepTilt(now) { + tilt.fade(now) + const on = !still && zoom.s === 1 + const want = on ? { x: tilt.target.x / MAX_DEG, y: tilt.target.y / MAX_DEG } : { x: 0, y: 0 } + let moving = !!(tilt.target.x || tilt.target.y) && on + for (const k of ['x', 'y']) { + const d = want[k] - tiltNow[k] + if (Math.abs(d) < 0.002) tiltNow[k] = want[k] + else { tiltNow[k] += d * 0.25; moving = true } + } + return moving + } + function stepAnim(now) { + if (!anim) return false + const dt = Math.min(0.12, (now - anim.last) / 1000) // tras un tirón, recuperar sin alargar la animación + anim.last = now + if (anim.dragging) { + const d = anim.goal - anim.p + anim.p += d * 0.35 + return true + } + const before = anim.p + anim.p = anim.back ? Math.max(0, before - dt / anim.dur) : Math.min(1, before + dt / anim.dur) + if ((anim.back && anim.p <= 0) || (!anim.back && anim.p >= 1)) { finishAnim(!anim.back); return !!anim } + return true + } + // Acaba la animación en uno de los dos extremos y deja el libro en reposo. + function finishAnim(commit = true) { + const a = anim + if (!a) return + anim = null + view = commit ? a.to : a.from + camNow = null + camMove = null + opts.onTurnEnd?.(a.ctl) + a.done?.(commit) + draw() + } + + // Dónde queda la contraportada en la pantalla: el lector pone ahí los botones, y siguen al libro cuando se mueve. + function reportBack(M) { + if (!opts.onBack) return + let at = null + if (!anim && view?.back && W > 0) { + const O = BOOK.OVER, far = view.under * (W + O) + const q = [[0, -0.5 - O], [far, -0.5 - O], [0, 0.5 + O], [far, 0.5 + O]].map(([x, y]) => project(M, [x, y, LID_Z])) + const xs = q.map(p => ((p[0] + 1) / 2) * rect.width), ys = q.map(p => ((1 - p[1]) / 2) * rect.height) + at = { x: Math.min(...xs), y: Math.min(...ys), width: Math.max(...xs) - Math.min(...xs), height: Math.max(...ys) - Math.min(...ys) } + } + const key = at ? `${Math.round(at.x)},${Math.round(at.y)},${Math.round(at.width)},${Math.round(at.height)}` : 'off' + if (key === (backSent || 'off')) return + backSent = key + opts.onBack(at, backColors) + } + + function paint(now) { + raf = 0 + if (dead || gl.isContextLost()) return + rect = container.getBoundingClientRect() + if (!rect.width || !rect.height) return + const dpr = Math.min(window.devicePixelRatio || 1, 3) + const cw = Math.round(rect.width * dpr), ch = Math.round(rect.height * dpr) + if (canvas.width !== cw || canvas.height !== ch) { canvas.width = cw; canvas.height = ch } + gl.viewport(0, 0, cw, ch) + gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT) + let again = stepTilt(now) + if (stepAnim(now)) again = true + const v = anim ? anim.to : view + const ready = v && W > 0 + const aspect = cw / ch + let cam = { target: [0, 0, 0], dist: 4 } + if (ready) { + cam = fitOf(v) + if (anim) cam = mixCam(anim.camFrom, cam, easeIO(anim.p)) + else if (camMove) { + const t = Math.min(1, (now - camMove.t0) / camMove.dur) + cam = mixCam(camMove.from, cam, easeIO(t)) + if (t >= 1) camMove = null; else again = true + } + camNow = cam + } + // La sombra de la hoja se calcula antes, porque cae también en la mesa. + let castX = 0, castA = 0, cover = null, sheet = null + if (ready && anim) { + const e = anim.dragging ? anim.p : easeIO(anim.p) + if (anim.kind === 'cover') { + cover = { a: anim.opening ? e : 1 - e, under: anim.under } + castX = Math.cos(cover.a * Math.PI) * cover.under * (W + BOOK.OVER) + castA = Math.sin(cover.a * Math.PI) * 0.5 + } else { + sheet = { a: e, side: anim.side } + sheet.pts = sheetCurve(e, anim.side, W) + castX = sheet.pts.at(-1).x + castA = Math.sin(e * Math.PI) * 0.45 + } + } + // Una hoja en alto queda cerca de la cámara: durante el giro, esta se aleja y se pone más vertical para que quepa y no se vea torcida. + const lift = cover ? Math.sin(cover.a * Math.PI) : sheet ? 0.35 * Math.sin(sheet.a * Math.PI) : 0 + if (lift) cam = { target: cam.target, dist: cam.dist * (1 + 0.3 * lift) } + // Al rodear el libro, la cámara se aleja un poco para que el lado que se acerca no se salga de la pantalla. + const round = Math.max(Math.abs(tiltNow.x), Math.abs(tiltNow.y)) + if (round) cam = { target: cam.target, dist: cam.dist * (1 + ORBIT_BACK * round) } + const dTilt = ((-tiltNow.x * ORBIT_TILT - 9 * lift) * Math.PI) / 180, yaw = (-tiltNow.y * ORBIT_YAW * Math.PI) / 180 + const M = cameraMatrix(cam.target, cam.dist, aspect, TILT + dTilt, yaw) + // Zoom del lector y el pequeño desplazamiento del movimiento: se aplican a la imagen final, que se vuelve a dibujar nítida. + const s = zoom.s, ox = s - 1 + (2 * (zoom.x + tiltNow.y * 3)) / rect.width, oy = -(s - 1) - (2 * (zoom.y + tiltNow.x * 3)) / rect.height + for (let c = 0; c < 4; c++) { + M[c * 4] = s * M[c * 4] + ox * M[c * 4 + 3] + M[c * 4 + 1] = s * M[c * 4 + 1] + oy * M[c * 4 + 3] + } + gl.uniformMatrix4fv(u.uM, false, M) + reportBack(M) + gl.uniformMatrix3fv(u.uFx, false, [fxM[0][0], fxM[1][0], fxM[2][0], fxM[0][1], fxM[1][1], fxM[2][1], fxM[0][2], fxM[1][2], fxM[2][2]]) + gl.uniform3f(u.uFxO, fxM[0][3], fxM[1][3], fxM[2][3]) + gl.uniform2f(u.uCast, castX, castA) + // Mesa + gl.depthMask(true) + gl.uniform1i(u.uKind, 0) + gl.bindTexture(gl.TEXTURE_2D, tableTex || wood) + if (tableTex) { const ts = tableScale(); gl.uniform3f(u.uTab, ts / tableAspect, ts, 1) } else gl.uniform3f(u.uTab, 0, 0, 0) + gl.uniform4f(u.uRect, cam.target[0], cam.target[1], Math.max(W, 0.7) * 1.3, 1) + drawMesh(tableMesh) + if (!ready) { if (again) draw(); return } + // Sombras en la mesa: una muy pegada une el libro con la madera, la otra cae a un lado y abajo. + const O = BOOK.OVER + let bx0 = -W - O, bx1 = W + O + if (cover || v.kind === 'closed') { + const under = cover ? cover.under : v.under, reach = cover ? landWidth(cover.a) : 0 + if (under > 0) { bx0 = -(W + O) * reach - BOOK.SPINE * (1 - reach); bx1 = W + O } else { bx0 = -W - O; bx1 = (W + O) * reach + BOOK.SPINE * (1 - reach) } + } + gl.enable(gl.BLEND) + gl.depthMask(false) + gl.disable(gl.DEPTH_TEST) + gl.uniform1i(u.uKind, 1) + for (const [dx, dy, soft, alpha] of [[-0.05, -0.062, 0.1, 0.5], [-0.02, -0.026, 0.04, 0.58], [-0.003, -0.004, 0.012, 0.75]]) { + gl.uniform4f(u.uRect, bx0 + dx, -0.5 - O + dy, bx1 + dx, 0.5 + O + dy) + gl.uniform1f(u.uSoft, soft) + gl.uniform1f(u.uAlpha, alpha) + drawMesh(tableMesh) + } + gl.disable(gl.BLEND) + gl.enable(gl.DEPTH_TEST) + gl.depthMask(true) + // Libro + if (cover) { + // Durante la apertura, la mitad sobre la que aterriza se extiende justo debajo de la cubierta. + const openV = anim.opening ? anim.to : anim.from, a = cover.a, o = smooth(0, 1, a) + const width = landWidth(a), land = { k: smooth(0.97, 1, a), width } + const sh = bookShapes(o, cover.under, land), m = {} + for (const [n, q] of Object.entries(sh)) if (q) m[n] = dynMesh(`b_${n}`, q) + const under = pageOn(openV, cover.under) + const pages = { [cover.under]: under, [-cover.under]: { i: null } } + drawBook(m, pages[-1], pages[1]) + const size = BOOK.SPINE * Math.max(0, 1 - a * 2.5) + const lid = lidOf(anim.from.back || anim.to.back) + if (size > 0) drawSpine(cover.under, LID_Z, size, lid) + drawCover(cover.under, a, pageOn(openV, -cover.under), lid) + } else if (sheet) { + // La hoja lleva por delante la página vieja de su lado y por detrás la nueva del otro. Debajo ya se ve la nueva de su lado. + const m = staticMeshes(v), s0 = sheet.side + const pages = { [s0]: pageOn(anim.to, s0), [-s0]: pageOn(anim.from, -s0) } + drawBook(m, pages[-1], pages[1]) + sheet.pts.forEach(p => { p.u = s0 > 0 ? p.t : 1 - p.t }) + gl.uniform2f(u.uCast, 0, 0) + drawSheet(dynMesh('sheet', sheetShape(sheet.pts)), s0, pageOn(anim.from, s0), pageOn(anim.to, -s0)) + } else if (v.kind === 'closed') { + drawBook(staticMeshes(v), null, null) + const lid = lidOf(v.back) + drawSpine(v.under, LID_Z, BOOK.SPINE, lid) + drawCover(v.under, 0, { i: null }, lid) + } else { + drawBook(staticMeshes(v), pageOn(v, -1), pageOn(v, 1)) + } + if (again) draw() + } + function draw() { if (!raf && !dead) raf = requestAnimationFrame(paint) } + + function startTurn(from, to, dir, drag) { + const fwd = dir > 0, r = rtl() + const ctl = { anims: [{ cancel() {} }], range: Math.max(200, rect.width * (from.focus || to.focus ? 0.9 : 0.5)) } + const shut = from.kind === 'closed' ? from : to.kind === 'closed' ? to : null, cover = !!shut + anim = { + ctl, from, to, p: 0, goal: 0, back: false, dragging: !!drag, last: performance.now(), camFrom: camNow || fitOf(from), + kind: cover ? 'cover' : 'sheet', dur: cover ? 1.3 : 0.75, + opening: to.kind !== 'closed', under: shut?.under ?? (r ? -1 : 1), + side: fwd === r ? -1 : 1, // lado del que sale la hoja: en der → izq, avanzar la levanta de la izquierda + } + ctl.cleanup = () => { if (anim?.ctl === ctl) finishAnim(!anim.back) } + if (drag) { + ctl.move = q => { if (anim?.ctl === ctl) { anim.goal = Math.max(0, Math.min(1, q)); draw() } } + ctl.release = (commit, done) => { + if (anim?.ctl !== ctl) return done?.(commit) + anim.dragging = false + anim.back = !commit + anim.done = done + anim.dur *= 0.7 + // Lo arrastrado ya es lineal: se pasa a la curva de la animación sin saltos. + anim.p = inverseEase(anim.p) + anim.last = performance.now() + draw() + } + } + return ctl + } + const inverseEase = y => (y < 0.5 ? Math.sqrt(y / 2) : 1 - Math.sqrt((1 - y) / 2)) + + const ro = new ResizeObserver(draw) + ro.observe(container) + const onVis = () => { if (document.visibilityState === 'visible') { if (!still) tilt.permit() } else tilt.listen(false) } + document.addEventListener('visibilitychange', onVis) + canvas.addEventListener('webglcontextlost', e => { + e.preventDefault() + glEpoch++ + if (anim) finishAnim(true) + textures.clear(); loading.clear(); queue.length = 0; statics = null; staticKey = ''; backTex = null; tableTex = null + }) + canvas.addEventListener('webglcontextrestored', () => { + initGL() + loadTable() + want(0) + if (view) for (const i of pagesOf(view)) want(i) + draw() + }) + initGL() + loadTable() + if (!still) tilt.permit() + + const api = { + // Devuelve el control del giro si lo hay: el lector lo usa para el arrastre y para acabarlo antes de tiempo. + show(group, dir = 0, drag = false) { + if (!group.length) return null + if (anim) finishAnim(!anim.back) + const next = viewOf(group), first = group[0], last = group.at(-1) + keepFrom = first - 3 + keepTo = last + 4 + pagesOf(next).forEach(want) + want(0) // la portada da el color de la tapa y de la contraportada, aunque se empiece a mitad de capítulo + if (!backNear && last + 4 >= total - 1) { backNear = true; clearTimeout(backTimer); backTimer = setTimeout(ensureBack, 1200) } // cuando acabe el giro + for (let k = 1; k <= 3; k++) { want(last + k); want(first - k) } + trim() + const prev = view + let ctl = null + if (prev && dir && prev.key !== next.key && !(prev.kind === 'closed' && next.kind === 'closed') && W > 0 && hasAll(next)) ctl = startTurn(prev, next, dir, drag) + else if (prev && dir && prev.key === next.key && prev.focus !== next.focus && W > 0 && camNow) camMove = { from: camNow, t0: performance.now(), dur: 450 } // la otra cara de la misma pareja + if (ctl) anim.to = next + else view = next + draw() + return ctl + }, + // Fin del capítulo: la tapa de atrás se cierra sobre las hojas. El libro queda boca abajo y con el lomo al otro lado. + showBack(dir = 0, drag = false) { + if (anim) finishAnim(!anim.back) + want(0) + backNear = true + ensureBack() + const next = { kind: 'closed', back: true, under: rtl() ? 1 : -1, focus: 0, key: 'e' }, prev = view + const ctl = prev && dir && prev.kind !== 'closed' && W > 0 && textures.has(0) ? startTurn(prev, next, dir, drag) : null + if (!ctl) view = next + draw() + return ctl + }, + setZoom(s, x, y) { zoom = { s, x, y }; draw() }, + setFilter(css) { fxM = filterMatrix(css); draw() }, + invalidate(i) { + const t = textures.get(i) + if (t) { gl.deleteTexture(t.tex); textures.delete(i) } + want(i) + }, + draw, + // Para las pruebas. + debug: () => ({ view: view?.key, focus: view?.focus, anim: anim && { kind: anim.kind, p: anim.p, side: anim.side, dragging: anim.dragging }, tilt: { ...tiltNow }, target: { ...tilt.target }, listening: tilt.listening, textures: [...textures.keys()], W, zoom: zoom.s, cam: camNow && { x: camNow.target[0], dist: camNow.dist } }), + destroy() { + dead = true + cancelAnimationFrame(raf) + clearTimeout(uploadTimer) + clearTimeout(backTimer) + ro.disconnect() + tilt.stop() + document.removeEventListener('visibilitychange', onVis) + gl.getExtension('WEBGL_lose_context')?.loseContext() + canvas.remove() + }, + } + canvas.b3d = api // para las pruebas + return api +} diff --git a/app/public/fonts/LICENSE.txt b/app/public/fonts/LICENSE.txt new file mode 100644 index 0000000..ced165d --- /dev/null +++ b/app/public/fonts/LICENSE.txt @@ -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). diff --git a/app/public/fonts/caveat.woff2 b/app/public/fonts/caveat.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..36e42dcc27ef3652aa8f210827f57154511766a9 GIT binary patch literal 51220 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 Z|BDc|l&n&Dt6G!e_j~`Z9WMX?004Ad42A#z literal 0 HcmV?d00001 diff --git a/app/public/fonts/gochihand.woff2 b/app/public/fonts/gochihand.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..b929dadca9009b8d0607563e386de19687a59301 GIT binary patch literal 19908 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 Hx&{CMPCS)9 literal 0 HcmV?d00001 diff --git a/app/public/fonts/indieflower.woff2 b/app/public/fonts/indieflower.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..f28fef8a35ebac78ca6f0c0b1514d1f7fbf7a003 GIT binary patch literal 19508 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 XB8~@}r;Gr+90Een5BetHiQxbMRXqy9 literal 0 HcmV?d00001 diff --git a/app/public/fonts/kalam.woff2 b/app/public/fonts/kalam.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..0a6bd556a497783e70b3a0dc941bc81133c63842 GIT binary patch literal 22144 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 HNd*7^%DAr& literal 0 HcmV?d00001 diff --git a/app/public/fonts/patrickhand.woff2 b/app/public/fonts/patrickhand.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..e80b54b43638b7eca06658defe672ac887030713 GIT binary patch literal 23944 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 literal 0 HcmV?d00001 diff --git a/app/public/fonts/permanentmarker.woff2 b/app/public/fonts/permanentmarker.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..203ccaeb9c2bab914dbb05e4501915a16315200b GIT binary patch literal 29564 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
      S}D~Xz1ggD{L7{ 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 Jo`3ha{{z9RE`|UA literal 0 HcmV?d00001 diff --git a/app/public/fonts/shadows.woff2 b/app/public/fonts/shadows.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..f15b2036a950b82c2235c151eb558ff40e7d49ad GIT binary patch literal 16296 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
    1. -(=ACq(s(flW!S+D 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 E58znT3IG5A literal 0 HcmV?d00001 diff --git a/app/public/icon-512.png b/app/public/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..4212255f7723ddaad662451c1359a06caf4aa0a9 GIT binary patch literal 6438 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 diff --git a/app/public/index.html b/app/public/index.html new file mode 100644 index 0000000..b29080c --- /dev/null +++ b/app/public/index.html @@ -0,0 +1,37 @@ + + + + + + + + + +Manga Reader + + + + + + + + + + +
      + Manga Reader + +
      +
      + + + + diff --git a/app/public/manifest.webmanifest b/app/public/manifest.webmanifest new file mode 100644 index 0000000..2ab4abd --- /dev/null +++ b/app/public/manifest.webmanifest @@ -0,0 +1,17 @@ +{ + "name": "Manga Reader", + "short_name": "Mangas", + "description": "Lector de manga autoalojado", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#14121a", + "theme_color": "#14121a", + "lang": "es", + "icons": [ + { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }, + { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }, + { "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml" } + ] +} diff --git a/app/public/shelf3d.js b/app/public/shelf3d.js new file mode 100644 index 0000000..358c6b7 --- /dev/null +++ b/app/public/shelf3d.js @@ -0,0 +1,1353 @@ +// Estante 3D: un mueble de madera con los tomos de la biblioteca. Mira hacia +z, con y hacia arriba; se reciben las piezas comunes de book3d.js. +export const SHELF = { PITCH: 1.2, BOOK_H: 1, DEPTH: 0.82, FRONT: 0.74, FACE_W: 0.7, FACE_Z: 0.58, FACE_T: 0.09, GAP: 0.07, BOARD: 0.055, SIDE: 0.075 } + +// ----- Arte del lomo ----- +const hslOf = (r, g, b) => { + const max = Math.max(r, g, b), min = Math.min(r, g, b) + return { sat: max ? (max - min) / max : 0, val: max / 255, chroma: (max - min) / 255 } +} +/** Lo a color que es una imagen: la parte de sus píxeles con color de verdad. El blanco, el negro y los grises no cuentan. */ +export function colorfulness(data) { + let colored = 0, n = 0 + for (let p = 0; p < data.length; p += 4) { + n++ + if (hslOf(data[p], data[p + 1], data[p + 2]).chroma > 0.16) colored++ + } + return n ? colored / n : 0 +} +export const COLOR_PAGE = 0.25 +/** La columna más vistosa de una imagen, para el lomo: mucho color y detalle, sin los bordes, donde suele haber marcos. Devuelve su x izquierda. */ +export function bestColumn(data, w, h, colW, from = 0) { + const cols = new Float64Array(w), detail = new Float64Array(w) + for (let x = 0; x < w; x++) { + let last = 0 + for (let y = from; y < h; y++) { + const p = (y * w + x) * 4, { chroma } = hslOf(data[p], data[p + 1], data[p + 2]), lum = (data[p] * 0.3 + data[p + 1] * 0.59 + data[p + 2] * 0.11) / 255 + cols[x] += chroma + if (y > from) detail[x] += Math.min(0.25, Math.abs(lum - last)) + last = lum + } + } + const lo = Math.round(w * 0.06), hi = Math.max(lo, Math.round(w * 0.94) - colW) + let best = lo, bestScore = -1 + for (let x = lo; x <= hi; x++) { + let score = 0 + for (let k = x; k < x + colW && k < w; k++) score += cols[k] + detail[k] * 0.8 + if (score > bestScore) { bestScore = score; best = x } + } + return best +} + +const FACE_LOGO_Y = 0.24, FACE_LOGO_W = 0.26 +const FACE_MIN = 0.14 // Los rostros más pequeños, respecto al ancho de la portada, casi siempre son errores o figuras lejanas. + +/** El rostro para el lomo: el mayor entre los de tamaño suficiente. Devuelve [x, y, ancho, alto] en fracciones de la imagen, o 0. */ +export function chooseFace(faces, w, h) { + // Pequeño y en lo alto de la portada suele ser el logo: la calavera de One Piece tiene cara de rostro. + const big = (faces || []).filter(f => f.w >= w * FACE_MIN && !(f.y + f.h / 2 < h * FACE_LOGO_Y && f.w < w * FACE_LOGO_W)) + if (!big.length) return 0 + const f = big.reduce((a, b) => (b.w > a.w ? b : a)) + return [f.x / w, f.y / h, f.w / w, f.h / h].map(v => Math.round(v * 1000) / 1000) +} +/** La franja alrededor de un rostro, como en los lomos de verdad: algo de pelo arriba y la cara debajo. Todo en píxeles de la imagen. */ +export function faceStrip(face, w, h) { + const fx = face[0] * w, fy = face[1] * h, fw = face[2] * w, fh = face[3] * h + const visible = 1 / ART_RATIO, sw = Math.min(fw * 1.3, h / visible, w) // visible: alto que se ve, en anchos de franja + return { + sx: Math.max(0, Math.min(w - sw, fx + fw / 2 - sw / 2)), + sy: Math.max(0, Math.min(h - sw * visible, fy - fh * 0.4)), + sw, + } +} + +// Medidas de un tomo, en altos de un B6 (18,2 cm). El grosor sale de las páginas, como en papel: unos 0,07 mm por página más las tapas. +// Sin páginas contadas se estiman por capítulos. Con tope, para que un tomo enorme no parezca un ladrillo. +export const SPINE_MIN = 0.06, SPINE_MAX = 0.1 +export const spineWidth = (pages, chapters) => { + const p = pages > 0 ? pages : chapters ? chapters * 19 : 180 + return Math.max(SPINE_MIN, Math.min(SPINE_MAX, (2 + p * 0.07) / 182)) +} +// Alto y fondo de una colección, según la proporción de sus portadas: la de bolsillo de las revistas shōnen (11,2 × 17,6 cm) +// es más estrecha que la B6 de las seinen (12,8 × 18,2 cm). Todos los tomos de una obra miden lo mismo; solo cambia el grosor. +export function bookSize(aspect) { + const a = Math.max(0.6, Math.min(0.76, aspect || 0.703)), mm = a < 0.655 ? 176 : 182 + return { h: mm / 182, d: (SHELF.FACE_W * mm * a) / 128 } +} + +/** El tomo que se muestra de frente: por el que se va o, si no se ha empezado, el primero. */ +export function faceIndex(vols, chapterId) { + const i = vols.findIndex(v => (v.chapters || []).includes(chapterId)) + return i < 0 ? 0 : i +} + +/** Reparte las obras en filas de un ancho dado. Una obra sin tomos conocidos es un solo libro, de frente. */ +export function layoutShelf(works, rowWidth, sizeOf = () => bookSize()) { + const rows = [[]] + let x = 0 + const place = (item, w) => { + if (x + w > rowWidth && rows.at(-1).length) { rows.push([]); x = 0 } + rows.at(-1).push({ ...item, x, w }) + x += w + } + for (const work of works) { + if (rows.at(-1).length) x += SHELF.GAP + const vols = work.vols?.length ? work.vols : [{ vol: null, chapters: [], thumb: work.cover }] + const face = faceIndex(vols, work.chapterId), { h, d } = sizeOf(work) + vols.forEach((vol, i) => { + const isFace = i === face, t = spineWidth(vol.pages, vol.chapters?.length) + place({ work, vol, face: isFace, h, d, t }, isFace ? d : t) + }) + } + return rows.filter(r => r.length) +} + +const VS = `#version 300 es +uniform mat4 uM, uModel; // uModel: solo el tomo que se saca; lo demás ya está en el mueble +layout(location = 0) in vec3 aPos; +layout(location = 1) in vec3 aNor; +layout(location = 2) in vec2 aUV; +out vec3 vPos; out vec3 vNor; out vec2 vUV; +void main() { vec4 p = uModel * vec4(aPos, 1.0); vPos = p.xyz; vNor = mat3(uModel) * aNor; vUV = aUV; gl_Position = uM * p; }` +const FS = `#version 300 es +precision highp float; +uniform int uKind; +uniform sampler2D uTex; +uniform vec3 uColor; +uniform float uHas, uPitch, uDepth, uDim, uAlpha; +uniform vec4 uSpot; // mesa: centro del tomo y radio de la luz +uniform vec2 uTab; // mesa propia: escala de la foto; sin ella, madera repetida +uniform vec2 uSize; +uniform vec4 uHide; // x0, x1, y0, y1 del hueco del tomo sacado: ni él ni sus costados se pintan en la fila +in vec3 vPos; in vec3 vNor; in vec2 vUV; +out vec4 color; +const vec3 L = normalize(vec3(-0.22, 0.42, 0.88)); +void main() { + if (uKind != 0 && uHide.y > uHide.x && vPos.y > uHide.z && vPos.y < uHide.w) { + const float e = 0.0002; + bool inside = vPos.x > uHide.x + e && vPos.x < uHide.y - e; + bool own = (abs(vPos.x - uHide.x) <= e && vNor.x < -0.5) || (abs(vPos.x - uHide.y) <= e && vNor.x > 0.5); + if (inside || own) discard; // los costados de los vecinos sí quedan a la vista + } + vec3 c; + if (uKind == 0) c = texture(uTex, vUV, -0.4).rgb * vec3(0.92, 0.84, 0.76); // madera del mueble, algo más oscura que la mesa + else if (uKind == 1) c = texture(uTex, vUV).rgb; // lomos + else if (uKind == 2) c = uHas > 0.5 ? texture(uTex, vUV, -0.5).rgb : uColor; // portada + else if (uKind == 3 || uKind == 5) { // las hojas, vistas por arriba: rayas irregulares a lo ancho del tomo, medidas en el mueble + float a = uKind == 3 ? vUV.x : vUV.y, v = a / 0.0042, w = fwidth(v), k = floor(v); + float n = fract(sin(k * 12.9898) * 43758.5453); + float line = 1.0 - smoothstep(0.08, 0.08 + max(w, 0.25), abs(fract(v) - 0.5)); + float dense = 1.0 - smoothstep(0.45, 0.9, w); // muy lejos, las rayas se funden en un tono + c = vec3(0.91, 0.88, 0.81) * (1.0 - (0.1 + 0.05 * n) * dense) * (1.0 - line * (0.16 + 0.22 * n) * dense); + // Entre un tomo y otro queda una rendija oscura. + if (uKind == 3) c *= mix(0.45, 1.0, smoothstep(0.0, 0.0035, min(vUV.x, vUV.y - vUV.x))); + } else if (uKind == 6) { // la mesa del lector, que aparece detrás del tomo + vec3 t = uHas > 0.5 ? texture(uTex, clamp(vUV * uTab + 0.5, 0.0, 1.0)).rgb : texture(uTex, vUV / 1.7).rgb; + float d = length(vPos - uSpot.xyz) / uSpot.w; + color = vec4(t * mix(1.04, 0.42, smoothstep(0.35, 1.6, d)), uAlpha); + return; + } else c = uColor; // tapas, de canto + float lit = mix(0.62, 1.05, clamp(dot(normalize(vNor), L), 0.0, 1.0)); + // Lo del fondo y lo que queda bajo la balda de arriba recibe menos luz. + float z = clamp(vPos.z / uDepth, 0.0, 1.0); + float under = fract(vPos.y / uPitch); + float shade = mix(0.5, 1.0, smoothstep(0.0, 0.95, z)) * mix(mix(0.5, 1.0, z), 1.0, smoothstep(0.62, 0.98, 1.0 - under)); + vec2 q = gl_FragCoord.xy / uSize - 0.5; + float room = 1.0 - 0.34 * smoothstep(0.25, 0.8, length(q * vec2(1.0, 0.8))); + color = vec4(c * lit * shade * room * uDim * vec3(1.03, 1.0, 0.95), uAlpha); +}` + +// Un cuadrilátero: origen, dos lados, normal y coordenadas de textura en sus esquinas. +function quad(sh, o, a, b, n, uv = [0, 0, 1, 1]) { + sh.grid(1, 1, (c, r) => [o[0] + a[0] * c + b[0] * r, o[1] + a[1] * c + b[1] * r, o[2] + a[2] * c + b[2] * r, ...n, uv[0] + (uv[2] - uv[0]) * c, uv[1] + (uv[3] - uv[1]) * r]) +} +const hash = s => { let h = 7; for (const ch of String(s)) h = (h * 31 + ch.charCodeAt(0)) | 0; return Math.abs(h) } +const hsl = (h, s, l) => { const a = s * Math.min(l, 1 - l), f = n => { const k = (n + h / 30) % 12; return l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1)) }; return [f(0), f(8), f(4)] } + +const STORE = 'shelfPalette' +// Lo que cuesta sacar se guarda en el dispositivo (Cache Storage, se conserva al actualizar la app): el arte recortado +// de cada lomo, y las portadas de frente y los logos tal cual. La siguiente visita no espera a la red, y sin +// conexión el mueble se ve igual. +const KEEP = 'shelf-v1' +let keepBox = null +const keeper = () => (keepBox ||= typeof caches !== 'undefined' && globalThis.isSecureContext !== false ? caches.open(KEEP).catch(() => null) : Promise.resolve(null)) +const keptKey = (kind, url) => `/__shelf/${kind}?u=${encodeURIComponent(url)}` +export const keptBlob = (kind, url) => keeper().then(c => c?.match(keptKey(kind, url))).then(r => (r ? r.blob() : null)).catch(() => null) +export const keepBlob = (kind, url, blob) => keeper().then(c => c?.put(keptKey(kind, url), new Response(blob, { headers: { 'content-type': blob.type || 'image/jpeg' } }))).catch(() => {}) +const STRIP_PER_UNIT = 440, STRIP_MAX = 2048, STRIP_H = 512, IMG_AT_ONCE = 4 // ancho de la tira según el de la fila; más de 2048 no se nota y gasta memoria +const blurBox = typeof document === 'undefined' ? null : document.createElement('canvas') // de paso, para desenfocar bajo las letras +const ART_SKIP = 0.26, FACE_INPUT = 360 // sin rostro se salta lo alto de la portada; ancho con que se buscan los rostros +const ART_RATIO = 0.14, ART_H = 512, ART_KEEP = 240 // franja guardada: algo más ancha que el lomo más grueso +// Notas adhesivas con el nombre de cada colección: fuentes manuscritas propias (fonts/LICENSE.txt) y colores de pósit. +const NOTE_FONTS = [['Caveat', 'caveat', 600, 1.15], ['Patrick Hand', 'patrickhand', 400, 1], ['Kalam', 'kalam', 700, 0.95], ['Gochi Hand', 'gochihand', 400, 1], ['Indie Flower', 'indieflower', 400, 0.95], ['Permanent Marker', 'permanentmarker', 400, 0.85], ['Shadows Into Light', 'shadows', 400, 1.05]] +const NOTE_COLORS = ['#fff27a', '#ffb8cf', '#a9dcff', '#c4f2a8', '#ffd38f', '#e3c8ff'] +const NOTE_INKS = ['#24242e', '#1b2b6e', '#3a1f5c', '#123c2c'] +const NOTE_H = 0.13, NOTE_PX = 128 +// El nombre de la nota: como mucho 40 letras, cortando entre palabras. +export function shortTitle(title, max = 40) { + const t = String(title || '').replace(/\s+/g, ' ').trim() + if (t.length <= max) return t + const cut = t.slice(0, max), sp = cut.lastIndexOf(' ') + return `${(sp > 0 ? cut.slice(0, sp) : cut).replace(/[\s,.;:·–—-]+$/, '')}…` +} +// Aspecto de la nota de una obra: el mismo en el mueble y en la ficha del tomo sacado. +export function noteStyle(id) { + const seed = hash(id), [family, , weight, scale] = NOTE_FONTS[seed % NOTE_FONTS.length] + return { seed, family: `shelf ${family}`, weight, scale, paper: NOTE_COLORS[(seed >> 3) % NOTE_COLORS.length], ink: NOTE_INKS[(seed >> 6) % NOTE_INKS.length], tilt: ((seed % 97) / 96 - 0.5) * 0.1 } +} +let notesFonts = null +export function loadNoteFonts() { + if (notesFonts || typeof FontFace === 'undefined') return notesFonts || Promise.resolve() + notesFonts = Promise.all(NOTE_FONTS.map(([name, file, weight]) => new FontFace(`shelf ${name}`, `url(/fonts/${file}.woff2)`, { weight: String(weight) }).load().then(f => { document.fonts.add(f) }, () => {}))) + return notesFonts +} +const IDENTITY = new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]) +const FLY_MS = 900 +const PEEK_OPEN_MS = 720, PEEK_CLOSE_MS = 520, PEEK_SLIDE = 0.34 // el tomo sale recto de la fila y luego gira hacia la cámara +const ZOOM_MAX = 3, ORBIT_TILT = 10, ORBIT_YAW = 18, ORBIT_BACK = 0.08, REST_TILT = -3, FRICTION_MS = 320 + +export function createShelf3D(container, lib, { works: firstWorks = [], rowsLandscape = 2.3, rowsPortrait = 3, still = false, pagesOf = null, onPeek = null, onOpen = null, table = null } = {}) { + const { Shape, woodCanvas, cameraMatrix, project, FOV, coverPalette, inkOn, css, createTilt, MAX_DEG } = lib + const canvas = document.createElement('canvas') + canvas.className = 'shelfcanvas' + const gl = canvas.getContext('webgl2', { alpha: false, antialias: true, depth: true, powerPreference: 'high-performance' }) + if (!gl) return null + container.replaceChildren(canvas) + const maxTex = gl.getParameter(gl.MAX_TEXTURE_SIZE) || 2048 + + let dead = false, raf = 0, u = {}, prog = null, wood = null, furniture = null, works = firstWorks, aniso = null + let rows = [], rowWidth = 3, size = { w: 1, h: 1 }, visibleH = 2.8, top = 0, bottom = 0, y = 0, layoutTimer = 0, rowsKey = '' + const built = new Map(), covers = new Map(), loadingImg = new Map(), waiting = [] + const arts = new Map(), panels = new Map(), askedPanel = new Set() // arte de los lomos, y página a color de los tomos sin portada + let toTopNext = false + let x = 0, zoom = 1, velocity = 0, lastMove = 0, focusId = null // focusId: obra que se mantiene a la vista hasta que se toque el mueble + const tiltNow = { x: 0, y: 0 } + const tilt = createTilt(() => draw()) + let imgBusy = 0 + let palettes = {} + try { palettes = JSON.parse(localStorage.getItem(STORE) || '{}') } catch {} + let saveTimer = 0 + const savePalettes = () => { + clearTimeout(saveTimer) + saveTimer = setTimeout(() => { try { localStorage.setItem(STORE, JSON.stringify(palettes)) } catch {} }, 800) + } + + function makeMesh(sh) { + const vao = gl.createVertexArray(), vb = gl.createBuffer(), ib = gl.createBuffer() + gl.bindVertexArray(vao) + gl.bindBuffer(gl.ARRAY_BUFFER, vb) + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(sh.v), gl.STATIC_DRAW) + gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ib) + gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(sh.i), gl.STATIC_DRAW) + gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 32, 0) + gl.enableVertexAttribArray(1); gl.vertexAttribPointer(1, 3, gl.FLOAT, false, 32, 12) + gl.enableVertexAttribArray(2); gl.vertexAttribPointer(2, 2, gl.FLOAT, false, 32, 24) + gl.bindVertexArray(null) + return { vao, vb, ib, n: sh.i.length } + } + const freeMesh = m => { if (m) { gl.deleteVertexArray(m.vao); gl.deleteBuffer(m.vb); gl.deleteBuffer(m.ib) } } + const drawMesh = m => { gl.bindVertexArray(m.vao); gl.drawElements(gl.TRIANGLES, m.n, gl.UNSIGNED_SHORT, 0) } + function texFrom(source, tex = gl.createTexture()) { + gl.bindTexture(gl.TEXTURE_2D, tex) + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source) + gl.generateMipmap(gl.TEXTURE_2D) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE) + if (aniso) gl.texParameterf(gl.TEXTURE_2D, aniso.TEXTURE_MAX_ANISOTROPY_EXT, Math.min(8, gl.getParameter(aniso.MAX_TEXTURE_MAX_ANISOTROPY_EXT))) + return tex + } + + function initGL() { + const shader = (type, text) => { const s = gl.createShader(type); gl.shaderSource(s, text); gl.compileShader(s); return s } + prog = gl.createProgram() + gl.attachShader(prog, shader(gl.VERTEX_SHADER, VS)) + gl.attachShader(prog, shader(gl.FRAGMENT_SHADER, FS)) + gl.linkProgram(prog) + if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(prog) || 'shader') + gl.useProgram(prog) + u = {} + for (const n of ['uM', 'uModel', 'uKind', 'uTex', 'uColor', 'uHas', 'uPitch', 'uDepth', 'uSize', 'uDim', 'uHide', 'uAlpha', 'uSpot', 'uTab']) u[n] = gl.getUniformLocation(prog, n) + gl.uniform1i(u.uTex, 0) + gl.uniformMatrix4fv(u.uModel, false, IDENTITY) + gl.uniform1f(u.uDim, 1) + gl.uniform1f(u.uAlpha, 1) + gl.uniform4f(u.uHide, 0, 0, 0, 0) + gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA) + gl.uniform1f(u.uPitch, SHELF.PITCH) + gl.uniform1f(u.uDepth, SHELF.DEPTH) + gl.enable(gl.DEPTH_TEST) + gl.depthFunc(gl.LEQUAL) + gl.enable(gl.CULL_FACE) + gl.disable(gl.CULL_FACE) // las mallas no siguen un solo sentido de giro + gl.clearColor(0.09, 0.055, 0.035, 1) + aniso = gl.getExtension('EXT_texture_filter_anisotropic') + wood = texFrom(woodCanvas()) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT) + } + + // ----- Colocación ----- + const rowBase = k => -(k + 1) * SHELF.PITCH // la cara de arriba de la balda de esa fila + function buildFurniture() { + freeMesh(furniture) + const sh = new Shape(), { BOARD, SIDE, DEPTH } = SHELF, W = rowWidth, low = rowBase(Math.max(rows.length, 1) - 1) - BOARD, TILE = 1.7 + // Fondo. + quad(sh, [-SIDE, low, 0], [W + 2 * SIDE, 0, 0], [0, -low + BOARD, 0], [0, 0, 1], [0, low / TILE, (W + 2 * SIDE) / TILE, BOARD / TILE]) + // Laterales, por dentro y de canto. + for (const [x, n] of [[0, 1], [W, -1]]) quad(sh, [x, low, 0], [0, 0, DEPTH], [0, -low + BOARD, 0], [n, 0, 0], [0.2, low / TILE, 0.2 + DEPTH / TILE, BOARD / TILE]) + for (const x of [-SIDE, W]) quad(sh, [x, low, DEPTH], [SIDE, 0, 0], [0, -low + BOARD, 0], [0, 0, 1], [x, low / TILE, x + SIDE / TILE, BOARD / TILE]) + // Baldas, con la veta a lo largo. + const board = yTop => { + const uv = (a, b) => [yTop * 0.37, a / TILE, yTop * 0.37 + b / TILE, 0] + quad(sh, [W, yTop, 0], [-W, 0, 0], [0, 0, DEPTH], [0, 1, 0], [yTop * 0.37, 0, yTop * 0.37 + DEPTH / TILE, W / TILE]) + quad(sh, [0, yTop - BOARD, DEPTH], [W, 0, 0], [0, BOARD, 0], [0, 0, 1], uv(W, 0)) + quad(sh, [0, yTop - BOARD, 0], [W, 0, 0], [0, 0, DEPTH], [0, -1, 0], [yTop * 0.37, 0, yTop * 0.37 + DEPTH / TILE, W / TILE]) + } + for (let k = 0; k < Math.max(rows.length, 1); k++) board(rowBase(k)) + // Tapa de arriba. + quad(sh, [-SIDE, 0, DEPTH], [W + 2 * SIDE, 0, 0], [0, BOARD, 0], [0, 0, 1], [0, 0, W / TILE, 0.04]) + quad(sh, [-SIDE, BOARD, 0], [W + 2 * SIDE, 0, 0], [0, 0, DEPTH], [0, 1, 0], [0, 0, W / TILE, DEPTH / TILE]) + quad(sh, [0, 0, 0], [W, 0, 0], [0, 0, DEPTH], [0, -1, 0], [0, 0, W / TILE, DEPTH / TILE]) + furniture = makeMesh(sh) + } + const urlOf = item => item.vol.art || item.vol.thumb || item.work.cover || null + const panelKey = item => item.vol.chapters?.[0] || null + // De dónde sale el arte de este lomo: su portada, una página a color o, en último caso, la portada de la obra. + const artOf = item => item.vol.art || item.vol.thumb || panels.get(panelKey(item)) || item.work.cover || null + const fallbackPalette = item => { + const h = hash(item.work.id) % 360, base = hsl(h, 0.42, 0.34) + return { base, accent: hsl((h + 40) % 360, 0.55, 0.72) } + } + const paletteOf = item => palettes[artOf(item)] || fallbackPalette(item) + // Una obra sin portada: su color y su título. + function plainCover(item) { + const c = Object.assign(document.createElement('canvas'), { width: 358, height: 512 }), g = c.getContext('2d'), { base, accent } = paletteOf(item) + g.fillStyle = css(base) + g.fillRect(0, 0, c.width, c.height) + g.strokeStyle = css(accent) + g.lineWidth = 5 + g.strokeRect(22, 22, c.width - 44, c.height - 44) + g.fillStyle = css(inkOn(base)) + g.textAlign = 'center' + g.font = '700 34px system-ui, sans-serif' + const lines = [] + for (const word of String(item.work.title).split(/\s+/)) { + const last = lines.at(-1) + if (last != null && g.measureText(`${last} ${word}`).width <= c.width - 80) lines[lines.length - 1] = `${last} ${word}` + else lines.push(word) + } + const shown = lines.slice(0, 8) + shown.forEach((line, i) => g.fillText(line, c.width / 2, c.height / 2 + (i - (shown.length - 1) / 2) * 42 + 12, c.width - 70)) + return c + } + const coverKey = item => urlOf(item) || `plain:${item.work.id}` + const plainFor = key => item => { if (!covers.has(key)) { covers.set(key, texFrom(plainCover(item))); draw() } } + // keep: la imagen se guarda en el dispositivo (portadas de frente y logos) y de ahí sale la próxima vez. + const keepUrls = new Set() + function loadImage(url, done, keep = false) { + if (keep && url.startsWith('/')) keepUrls.add(url) + if (loadingImg.has(url)) { loadingImg.get(url).push(done); return } + loadingImg.set(url, [done]) + waiting.push(url) + pumpImages() + } + async function keptSrc(url) { + const hit = await keptBlob('img', url) + if (hit) return URL.createObjectURL(hit) + const r = await fetch(url, { credentials: 'same-origin' }).catch(() => null) + if (!r?.ok || !/^image\//.test(r.headers.get('content-type') || '')) return null + const blob = await r.blob() + keepBlob('img', url, blob) + return URL.createObjectURL(blob) + } + function pumpImages() { + while (!dead && imgBusy < IMG_AT_ONCE && waiting.length) { + const url = waiting.pop() // lo último pedido es lo que está a la vista + imgBusy++ + const im = new Image() + im.decoding = 'async' + // Una imagen que no acaba (una fuente colgada) no puede tapar la cola: a los 15 s deja su hueco, y si llega luego se usa igual. + let freed = false + const free = () => { if (!freed) { freed = true; imgBusy--; clearTimeout(slow); pumpImages() } } + const slow = setTimeout(free, 15000) + const finish = ok => { + const list = loadingImg.get(url) || [] + loadingImg.delete(url) + if (!dead) for (const f of list) f(ok ? im : null) + free() + } + if (!keepUrls.has(url)) { + im.onload = () => finish(true) + im.onerror = () => finish(false) + im.src = url + continue + } + keptSrc(url).catch(() => null).then(src => { + im.onload = () => { if (src) URL.revokeObjectURL(src); finish(true) } + im.onerror = () => { + if (src) { URL.revokeObjectURL(src); src = null; im.src = url; return } // lo guardado no sirve: de la red + finish(false) + } + im.src = src || url + }) + } + } + // Alto y fondo de cada obra, por la proporción de su portada. Lo aprendido rehace el mueble una vez; luego se recuerda. + const sizeOf = work => bookSize(palettes[`size:${work.id}`]) + function learnSize(item, url, im) { + const work = item.work + const key = `size:${work.id}` + if (palettes[key] || !im?.naturalHeight || !url || url !== (item.vol.art || item.vol.thumb || work.cover)) return // solo portadas, no páginas + const before = sizeOf(work) + palettes[key] = Math.round((im.naturalWidth / im.naturalHeight) * 1000) / 1000 + savePalettes() + const now = sizeOf(work) + if (now.h !== before.h || Math.abs(now.d - before.d) > 0.004) api.relayout() + } + function learnPalette(url, im) { + if (palettes[url]) return + try { + const small = Object.assign(document.createElement('canvas'), { width: 48, height: 72 }), g = small.getContext('2d', { willReadFrequently: true }) + g.drawImage(im, 0, 0, 48, 72) + const { base, accent } = coverPalette(g.getImageData(0, 0, 48, 72).data) + palettes[url] = { base, accent } + savePalettes() + } catch {} + } + + // Rostros: de uno en uno y lo último pedido primero. Lo encontrado se recuerda, y también lo no encontrado. + const faceWaiting = [] + let faceWorker = null, faceBusy = false, faceSeq = 0 + function askFace(url) { + if (faceWaiting.some(q => q === url)) return + faceWaiting.push(url) + pumpFaces() + } + function startFaceWorker() { + try { + faceWorker = new Worker(`/animeface.js${new URL(import.meta.url).search}`) + faceWorker.onerror = () => { faceWorker?.terminate(); faceWorker = false; faceBusy = false; faceWaiting.length = 0 } + } catch { faceWorker = false } + return faceWorker + } + function pumpFaces() { + if (dead || faceBusy || !faceWaiting.length || faceWorker === false || (!faceWorker && !startFaceWorker())) return + const url = faceWaiting.pop() + if (palettes[`face:${url}`] !== undefined) return pumpFaces() + faceBusy = true + let given = false + const giveUp = setTimeout(() => { given = true; faceBusy = false; pumpFaces() }, 30000) // la imagen no llega: se sigue con las demás + loadImage(url, im => { + clearTimeout(giveUp) + if (given) return + let pixels = null, w = FACE_INPUT, h = 0 + if (im) try { + h = Math.max(1, Math.round((w * im.naturalHeight) / im.naturalWidth)) + const c = Object.assign(document.createElement('canvas'), { width: w, height: h }), g = c.getContext('2d', { willReadFrequently: true }) + g.drawImage(im, 0, 0, w, h) + pixels = g.getImageData(0, 0, w, h).data.buffer + } catch { pixels = null } + if (!pixels || !faceWorker) { faceBusy = false; return pumpFaces() } + const id = ++faceSeq + faceWorker.onmessage = ({ data }) => { + if (data.id !== id) return + faceBusy = false + if (dead) return + if (data.faces) { + const face = chooseFace(data.faces, w, h) + palettes[`face:${url}`] = face + savePalettes() + if (face) { + cropArt(url, im, face) + // La fila pudo rehacerse mientras tanto: se avisa a las que hay ahora. + for (const b of built.values()) if (rows[b.k]?.some(it => !it.face && artOf(it) === url)) b.repaint?.() + } + } + pumpFaces() + } + faceWorker.postMessage({ id, pixels, w, h }, [pixels]) + }) + } + function learnArt(url, im, wantFace) { + if (!arts.has(url)) cropArt(url, im, palettes[`face:${url}`] || null) + if (wantFace && palettes[`face:${url}`] === undefined) askFace(url) + } + function cropArt(url, im, face) { + try { + const w = im.naturalWidth, h = im.naturalHeight + const small = Object.assign(document.createElement('canvas'), { width: 64, height: Math.max(1, Math.round((64 * h) / w)) }), g = small.getContext('2d', { willReadFrequently: true }) + g.drawImage(im, 0, 0, small.width, small.height) + const colW = Math.max(2, Math.round(small.height * ART_RATIO)) + const out = Object.assign(document.createElement('canvas'), { width: Math.round(ART_H * ART_RATIO), height: ART_H }) + if (face) { + const { sx, sy, sw } = faceStrip(face, w, h) + out.getContext('2d').drawImage(im, sx, sy, sw, sw / ART_RATIO, 0, 0, out.width, out.height) + } else { + // Sin rostro: de la banda de en medio hacia abajo, porque arriba suelen ir el logo y la editorial. + const keep = 1 - ART_SKIP, from = Math.round(small.height * ART_SKIP), cw = Math.max(2, Math.round(colW * keep)) + const at = bestColumn(g.getImageData(0, 0, small.width, small.height).data, small.width, small.height, Math.min(cw, small.width), from) + out.getContext('2d').drawImage(im, (at / small.width) * w, h * ART_SKIP, h * ART_RATIO * keep, h * keep, 0, 0, out.width, out.height) + } + putArt(url, out) + out.toBlob?.(b => { if (b) keepBlob('art', url, b) }, 'image/jpeg', 0.88) + } catch {} + } + function putArt(url, art) { + arts.delete(url) + arts.set(url, art) + if (arts.size > ART_KEEP) { const old = arts.keys().next().value; arts.get(old)?.close?.(); arts.delete(old) } // se vuelve a sacar del dispositivo si hace falta + } + // El arte ya recortado en otra visita. Solo vale si también se recuerda su color (localStorage); si no, se rehace. + const artAsked = new Set() + function keptArt(url, done) { + if (artAsked.has(url)) return + artAsked.add(url) + const miss = () => { artAsked.delete(url); done(false) } + if (!palettes[url]) return miss() + keptBlob('art', url).then(async b => { + if (dead) return + if (!b) return miss() + let art = null + try { art = typeof createImageBitmap === 'function' ? await createImageBitmap(b) : null } catch {} + if (!art) { + art = await new Promise(res => { const im = new Image(), src = URL.createObjectURL(b); im.onload = () => { URL.revokeObjectURL(src); res(im) }; im.onerror = () => { URL.revokeObjectURL(src); res(null) }; im.src = src }) + } + artAsked.delete(url) + if (dead) return + if (!art) return done(false) + putArt(url, art) + done(true) + }) + } + // Tomo sin portada: la primera de sus páginas que sea a color. Lo encontrado se recuerda; lo no encontrado, también, para no volver a descargar páginas. + function findPanel(item, done) { + const key = panelKey(item) + if (!key || !pagesOf || askedPanel.has(key)) return + askedPanel.add(key) + const saved = palettes[`panel:${key}`] + if (saved !== undefined) { if (saved) { panels.set(key, saved); done() } return } + pagesOf(key).then(async urls => { + let found = null + for (const url of urls) { + const im = await new Promise(res => loadImage(url, res)) + if (dead) return + if (!im) continue + try { + const c = Object.assign(document.createElement('canvas'), { width: 40, height: 60 }), g = c.getContext('2d', { willReadFrequently: true }) + g.drawImage(im, 0, 0, 40, 60) + if (colorfulness(g.getImageData(0, 0, 40, 60).data) >= COLOR_PAGE) { found = url; break } + } catch {} + } + palettes[`panel:${key}`] = found || false + savePalettes() + if (found) { panels.set(key, found); done() } + }, () => {}) + } + // Los lomos de una fila se dibujan juntos en una tira: una textura por fila, por muchos tomos que tenga. + const stripWidth = () => Math.max(512, Math.min(STRIP_MAX, maxTex, Math.round(rowWidth * STRIP_PER_UNIT))) + // ----- Logos de las obras (Wikimedia Commons, por el proxy propio para poder pintarlos en la tira) ----- + const logos = new Map() + function logoLight(im) { + try { + const c = Object.assign(document.createElement('canvas'), { width: 96, height: Math.max(1, Math.round((96 * im.naturalHeight) / im.naturalWidth)) }), g = c.getContext('2d', { willReadFrequently: true }) + g.drawImage(im, 0, 0, c.width, c.height) + const px = g.getImageData(0, 0, c.width, c.height).data + let lum = 0, a = 0 + for (let p = 0; p < px.length; p += 4) { const al = px[p + 3] / 255; lum += al * (px[p] * 0.299 + px[p + 1] * 0.587 + px[p + 2] * 0.114); a += al } + return a ? lum / a / 255 : 0.5 + } catch { return 0.5 } + } + function setLogo(id, url) { + if (!url || logos.has(id)) return + loadImage(url, im => { + if (!im || dead || im.naturalWidth < im.naturalHeight * 1.2) return + logos.set(id, { im, light: logoLight(im) }) + for (const b of built.values()) if ((rows[b.k] || []).some(i => i.work.id === id)) b.repaint?.() + }, true) + } + + // ----- Notas: un pósit por colección, pegado al canto de la balda de arriba ----- + function rowNotes(row) { + const groups = [] + for (let i = 0; i < row.length;) { + let j = i + while (j + 1 < row.length && row[j + 1].work === row[i].work) j++ + groups.push({ work: row[i].work, x0: row[i].x, x1: row[j].x + row[j].w }) + i = j + 1 + } + const out = [] + let free = 0.02 + for (const n of groups) { + const span = n.x1 - n.x0, w = Math.max(0.26, Math.min(0.42, span * 0.85)) + let x = Math.max(free, n.x0 + Math.min(0.03, span * 0.1)) + if (x + w > rowWidth - 0.02) x = rowWidth - 0.02 - w + if (x < free - 1e-6 || x > n.x1 + 0.12) continue // no cabe junto a los suyos + out.push({ ...n, x, w, seed: hash(n.work.id) }) + free = x + w + 0.03 + } + return out + } + function paintNotes(notes, canvas) { + const g = canvas.getContext('2d') + g.setTransform(1, 0, 0, 1, 0, 0) + g.clearRect(0, 0, canvas.width, canvas.height) + let x = 0 + for (const n of notes) { + const W = n.px, H = NOTE_PX, { family, weight, scale, paper, ink } = noteStyle(n.work.id) + g.save() + g.translate(x, 0) + g.fillStyle = paper + g.fillRect(0, 0, W, H) + g.fillStyle = 'rgba(0,0,0,.07)' // la franja con pegamento + g.fillRect(0, 0, W, H * 0.16) + const curl = g.createLinearGradient(0, H * 0.7, 0, H) + curl.addColorStop(0, 'rgba(0,0,0,0)') + curl.addColorStop(1, 'rgba(0,0,0,.14)') + g.fillStyle = curl + g.fillRect(0, 0, W, H) + g.fillStyle = ink + g.textAlign = 'center' + g.textBaseline = 'middle' + const title = shortTitle(n.work.title), font = px => `${weight} ${Math.round(px)}px "${family}", cursive` + let size = H * 0.5 * scale + g.font = font(size) + const room = W * 0.9 + if (g.measureText(title).width > room) size = Math.max(H * 0.3 * scale, (size * room) / g.measureText(title).width) + g.font = font(size) + if (g.measureText(title).width > room) { + // Dos renglones, partiendo por el espacio más cercano al centro. + const words = title.split(/\s+/) + let best = 1, bestD = Infinity + for (let i = 1; i < words.length; i++) { const d = Math.abs(words.slice(0, i).join(' ').length - title.length / 2); if (d < bestD) { bestD = d; best = i } } + const a = words.slice(0, best).join(' '), b = words.slice(best).join(' ') + size = Math.min(H * 0.36 * scale, (size * room) / Math.max(g.measureText(a).width, g.measureText(b).width, 1) * 1) + g.font = font(size) + g.fillText(a, W / 2, H * 0.4, room) + g.fillText(b, W / 2, H * 0.4 + size * 0.95, room) + } else g.fillText(title, W / 2, H * 0.56, room) + g.restore() + x += W + } + } + function buildNotes(b, k) { + const notes = rowNotes(rows[k]) + if (!notes.length) return + const base = rowBase(k), edge = k === 0 ? base + SHELF.PITCH : base + SHELF.PITCH - SHELF.BOARD, top = edge + SHELF.BOARD * 0.75, z = SHELF.DEPTH + 0.004 + for (const n of notes) n.px = Math.round((NOTE_PX * n.w) / NOTE_H) + const canvas = Object.assign(document.createElement('canvas'), { width: Math.min(maxTex, notes.reduce((a, n) => a + n.px, 0)), height: NOTE_PX }) + const sh = new Shape() + let u = 0 + for (const n of notes) { + const a = noteStyle(n.work.id).tilt, c = Math.cos(a), sn = Math.sin(a), U0 = u / canvas.width, U1 = (u + n.px) / canvas.width + quad(sh, [n.x, top, z], [n.w * c, n.w * sn, 0], [NOTE_H * sn, -NOTE_H * c, 0], [0, 0, 1], [U0, 0, U1, 1]) + u += n.px + } + paintNotes(notes, canvas) + b.notes = { mesh: makeMesh(sh), canvas, tex: texFrom(canvas), list: notes } + loadNoteFonts().then(() => { if (!dead && built.get(k) === b && b.notes) { paintNotes(notes, canvas); texFrom(canvas, b.notes.tex); draw() } }) + } + + function paintStrip(row, strip) { + const g = strip.getContext('2d'), toPx = strip.width / rowWidth + g.setTransform(1, 0, 0, 1, 0, 0) + g.fillStyle = '#222' + g.fillRect(0, 0, strip.width, STRIP_H) + for (const it of row) { + if (it.face) continue + const { base, accent } = paletteOf(it) + const x = it.x * toPx, w = it.w * toPx, PX = STRIP_H / it.h + // Se escribe en unidades de la pantalla: en una fila estrecha la tira se estira a lo ancho y el texto se deformaría. + const sx = toPx / PX, wText = it.w * PX + g.setTransform(1, 0, 0, 1, x, 0) + const art = arts.get(artOf(it)) + let ink = inkOn(base), backing = null + if (art) { + // El arte ocupa todo el lomo; de la franja guardada se usa el centro, lo que corresponde a su grosor. + const part = Math.min(art.width, (art.height * it.w) / it.h) + g.imageSmoothingEnabled = true + g.imageSmoothingQuality = 'high' + g.drawImage(art, (art.width - part) / 2, 0, part, art.height, 0, 0, w, STRIP_H) + backing = (x0, y0, bw, bh, force) => { + // Desenfoque suave bajo las letras: se reduce lo pintado y se vuelve a estirar. + const X = x + x0, sw = Math.max(2, Math.round(bw / 4)), sh = Math.max(2, Math.round(bh / 4)) + blurBox.width = sw; blurBox.height = sh + const bg = blurBox.getContext('2d', { willReadFrequently: true }) + bg.imageSmoothingEnabled = true + bg.drawImage(strip, X, y0, bw, bh, 0, 0, sw, sh) + const px = bg.getImageData(0, 0, sw, sh).data + let lum = 0 + for (let p = 0; p < px.length; p += 4) lum += px[p] * 0.299 + px[p + 1] * 0.587 + px[p + 2] * 0.114 + const light = force ? force === 'light' : lum / (px.length / 4) / 255 > 0.6 + g.save() + g.beginPath() + if (g.roundRect) g.roundRect(x0, y0, bw, bh, Math.min(bw, bh) * 0.45) + else g.rect(x0, y0, bw, bh) + g.clip() + g.drawImage(blurBox, 0, 0, sw, sh, x0 - bw * 0.04, y0 - bh * 0.04, bw * 1.08, bh * 1.08) + g.fillStyle = light ? 'rgba(255,255,255,.34)' : 'rgba(0,0,0,.32)' + g.fillRect(x0, y0, bw, bh) + g.restore() + return light ? [0.08, 0.08, 0.09] : [0.98, 0.97, 0.95] + } + } else { + g.fillStyle = css(base) + g.fillRect(0, 0, w, STRIP_H) + g.fillStyle = css(accent) + g.fillRect(0, STRIP_H * 0.045, w, STRIP_H * 0.012) + g.fillRect(0, STRIP_H * 0.86, w, STRIP_H * 0.012) + } + g.textAlign = 'center' + g.textBaseline = 'middle' + // El título, en la mitad de abajo para no tapar la cara: primero se reduce la letra; si aun así no cabe, se estrecha. + const from = art ? 0.4 : 0.07, to = art ? 0.865 : 0.85, room = STRIP_H * (to - from) + const logo = logos.get(it.work.id) + if (logo) { + // El logo de la obra en lugar del texto, girado como el título; la pastilla contrasta con el propio logo. + const lw = logo.im.naturalWidth, lh = logo.im.naturalHeight, sc = Math.min(room / lw, (wText * 0.74) / lh), len = lw * sc, thick = lh * sc + const mid = art ? to - len / STRIP_H / 2 - 0.012 : (from + to) / 2 + if (backing) { const bw = Math.min(w * 0.96, (thick + wText * 0.2) * sx), pad = wText * 0.16; ink = backing((w - bw) / 2, STRIP_H * mid - len / 2 - pad, bw, len + pad * 2, logo.light > 0.55 ? 'dark' : 'light') } + g.save() + g.scale(sx, 1) + g.translate(wText / 2, STRIP_H * mid) + g.rotate(Math.PI / 2) + if (!backing) { g.shadowColor = logo.light > 0.55 ? 'rgba(0,0,0,.6)' : 'rgba(255,255,255,.6)'; g.shadowBlur = 4 } + g.imageSmoothingEnabled = true + g.imageSmoothingQuality = 'high' + g.drawImage(logo.im, -len / 2, -thick / 2, len, thick) + g.restore() + } + let size = wText * (art ? 0.44 : 0.5) + g.font = `700 ${Math.round(size)}px system-ui, sans-serif` + let long = g.measureText(it.work.title).width + if (long > room) { size = Math.max(wText * 0.32, (size * room) / long); g.font = `700 ${Math.round(size)}px system-ui, sans-serif`; long = g.measureText(it.work.title).width } + const len = Math.min(long, room), mid = art ? to - len / STRIP_H / 2 - 0.012 : (from + to) / 2 + if (backing && !logo) { + const bw = size * 1.45 * sx, pad = size * 0.55 + ink = backing((w - bw) / 2, STRIP_H * mid - len / 2 - pad, bw, len + pad * 2) + } + g.save() + g.scale(sx, 1) + if (logo) g.globalAlpha = 0 // ya está el logo + g.fillStyle = css(ink) + if (backing) { g.shadowColor = ink[0] > 0.5 ? 'rgba(0,0,0,.55)' : 'rgba(255,255,255,.5)'; g.shadowBlur = 3 } + g.translate(wText / 2, STRIP_H * mid) + g.rotate(Math.PI / 2) + g.fillText(it.work.title, 0, 0, room) + g.restore() + if (it.vol.vol != null) { + const nSize = wText * 0.5, label = String(it.vol.vol), cy = STRIP_H * 0.935 + g.font = `800 ${Math.round(nSize)}px system-ui, sans-serif` + let numInk = ink + if (backing) { + const tw = Math.min(g.measureText(label).width, wText * 0.86) * sx, bw = Math.min(w * 0.94, tw + nSize * 0.6 * sx), bh = nSize * 1.35 + numInk = backing((w - bw) / 2, cy - bh / 2, bw, bh) + } + g.save() + g.scale(sx, 1) + g.fillStyle = css(numInk) + if (backing) { g.shadowColor = numInk[0] > 0.5 ? 'rgba(0,0,0,.55)' : 'rgba(255,255,255,.5)'; g.shadowBlur = 3 } + g.fillText(label, wText / 2, cy, wText * 0.86) + g.restore() + } + // Redondez del lomo: se oscurece hacia los bordes. + const round = g.createLinearGradient(0, 0, w, 0) + round.addColorStop(0, 'rgba(0,0,0,.38)'); round.addColorStop(0.16, 'rgba(0,0,0,.05)'); round.addColorStop(0.42, 'rgba(255,255,255,.12)'); round.addColorStop(0.8, 'rgba(0,0,0,.05)'); round.addColorStop(1, 'rgba(0,0,0,.42)') + g.fillStyle = round + g.fillRect(0, 0, w, STRIP_H) + } + g.setTransform(1, 0, 0, 1, 0, 0) + } + function buildRow(k) { + const row = rows[k], base = rowBase(k), F = SHELF.FRONT, strip = Object.assign(document.createElement('canvas'), { width: stripWidth(), height: STRIP_H }) + const spines = new Shape(), paper = new Shape(), faces = [] + for (const it of row) { + const x0 = it.x, x1 = it.x + it.w, U0 = x0 / rowWidth, U1 = x1 / rowWidth, H = it.h, D = it.d + if (it.face) { + const z = SHELF.FACE_Z, t = it.t, sh = new Shape(), edge = new Shape(), pages = new Shape() + quad(sh, [x0, base + H, z], [it.w, 0, 0], [0, -H, 0], [0, 0, 1]) + quad(edge, [x0, base, z - t], [0, 0, t], [0, H, 0], [-1, 0, 0]) + quad(edge, [x1, base, z], [0, 0, -t], [0, H, 0], [1, 0, 0]) + quad(pages, [x0, base + H, z - t], [it.w, 0, 0], [0, 0, t], [0, 1, 0], [0, 0, it.w, t]) + faces.push({ it, mesh: makeMesh(sh), edge: makeMesh(edge), pages: makeMesh(pages) }) + continue + } + quad(spines, [x0, base + H, F], [it.w, 0, 0], [0, -H, 0], [0, 0, 1], [U0, 0, U1, 1]) + // Los costados son las tapas: un solo color del lomo, no una columna de la tira estirada a lo largo del fondo. + const Um = (U0 + U1) / 2, side = [Um, 0.02, Um, 0.02] + quad(spines, [x0, base, F - D], [0, 0, D], [0, H, 0], [-1, 0, 0], side) + quad(spines, [x1, base, F], [0, 0, -D], [0, H, 0], [1, 0, 0], side) + quad(paper, [x0, base + H, F - D], [it.w, 0, 0], [0, 0, D], [0, 1, 0], [0, it.w, it.w, it.w]) // u: a lo ancho, en el mueble; v: el grosor + } + paintStrip(row, strip) + const b = { k, key: rowKey(row), strip, tex: texFrom(strip), spines: spines.i.length ? makeMesh(spines) : null, paper: paper.i.length ? makeMesh(paper) : null, faces, stale: 0 } + built.set(k, b) + buildNotes(b, k) + // Colores y portadas, de poco en poco: lo de fuera de la vista espera. + for (const it of row) { + const url = it.face ? urlOf(it) : artOf(it) + if (!url) { + if (it.face) plainFor(coverKey(it))(it) + } + // A intervalos, y no durante un gesto: repintar y subir la tira en pleno arrastre daría tirones. + const repaint = () => { + if (built.get(k) !== b || b.stale) return + b.stale = setTimeout(() => { + b.stale = 0 + if (built.get(k) !== b || dead) return + if (pointers.size || velocity) return repaint() + paintStrip(row, strip) + texFrom(strip, b.tex) + draw() + }, 400) + } + b.repaint = repaint + if (!it.face && !it.vol.art && !it.vol.thumb) findPanel(it, () => { + const panel = artOf(it) + if (panel && !arts.has(panel)) keptArt(panel, ok => ok ? repaint() : loadImage(panel, im => { if (im) { learnPalette(panel, im); learnArt(panel, im, true); repaint() } })) + else repaint() + }) + if (!url) continue + if (it.face) { + if (!covers.has(url)) loadImage(url, im => { + if (!im) return plainFor(url)(it) + learnSize(it, url, im) + learnPalette(url, im) + learnArt(url, im) + covers.set(url, texFrom(im)) + draw() + }, true) + } else if (arts.has(url)) { + if (palettes[`face:${url}`] === undefined) askFace(url) // su arte salió de la portada de frente, donde no se buscan rostros + } else keptArt(url, ok => { + if (ok) { + if (palettes[`face:${url}`] === undefined) askFace(url) + return repaint() + } + loadImage(url, im => { + if (!im) return + learnSize(it, url, im) + learnPalette(url, im) + learnArt(url, im, true) + repaint() + }) + }) + } + return b + } + function freeRow(b) { + clearTimeout(b.stale) + gl.deleteTexture(b.tex) + freeMesh(b.spines); freeMesh(b.paper) + for (const f of b.faces) { freeMesh(f.mesh); freeMesh(f.edge); freeMesh(f.pages) } + if (b.notes) { freeMesh(b.notes.mesh); gl.deleteTexture(b.notes.tex) } + built.delete(b.k) + } + const rowKey = row => row ? row.map(i => `${i.work.id}/${i.vol.vol}/${i.x.toFixed(4)}/${i.w.toFixed(4)}/${i.h.toFixed(3)}/${i.d.toFixed(3)}/${i.face ? 1 : 0}/${urlOf(i)}`).join() : '' + function dropAllRows() { for (const b of [...built.values()]) freeRow(b) } + + // Lo que se puede recorrer depende del zoom: de cerca hay más mueble fuera de la pantalla. + function limits() { + const half = visibleH / 2 / zoom + top = SHELF.BOARD + 0.045 - half + bottom = Math.min(top, rowBase(Math.max(rows.length, 1) - 1) - SHELF.BOARD - 0.045 + half) + } + function clampView() { + const hit = y > top || y < bottom + y = Math.max(bottom, Math.min(top, y)) + if (hit) velocity = 0 + const room = (visibleH * (size.w / size.h) / 2) * (1 - 1 / zoom) + x = Math.max(-room, Math.min(room, x)) + } + function setZoom(next, cx = size.w / 2, cy = size.h / 2) { + const z = Math.max(1, Math.min(ZOOM_MAX, next)) + if (z === zoom) return + // El punto bajo los dedos no se mueve. + const per = visibleH / size.h, dx = cx - size.w / 2, dy = cy - size.h / 2 + x += dx * per * (1 / zoom - 1 / z) + y -= dy * per * (1 / zoom - 1 / z) + zoom = z + limits() + clampView() + draw() + } + function layout() { + const rect = container.getBoundingClientRect() + size = { w: Math.max(1, rect.width), h: Math.max(1, rect.height) } + const aspect = size.w / size.h + visibleH = (size.w >= size.h ? rowsLandscape : rowsPortrait) * SHELF.PITCH + rowWidth = visibleH * aspect - 2 * SHELF.SIDE - 0.05 + rows = layoutShelf(works, rowWidth, sizeOf) + // Solo se rehace lo que cambia: al aprender tamaños, casi todas las filas siguen igual. + const key = `${rowWidth.toFixed(3)}:${rows.length}` + if (key !== rowsKey) { + rowsKey = key + dropAllRows() + buildFurniture() + } else for (const b of [...built.values()]) if (b.key !== rowKey(rows[b.k])) freeRow(b) + const was = top + limits() + y = y === was || !y || toTopNext ? top : y + toTopNext = false + if (focusId) { const k = rows.findIndex(r => r.some(i => i.work.id === focusId)); if (k >= 0) y = rowBase(k) + SHELF.PITCH * 0.42 } + clampView() + } + + // ----- Dibujo ----- + function draw() { + if (dead || raf) return + raf = requestAnimationFrame(paint) + } + function stepInertia(now) { + if (!velocity || pointers.size) return false + const dt = Math.min(120, now - lastMove) + lastMove = now + y += velocity * dt + velocity *= Math.exp(-dt / FRICTION_MS) + clampView() + if (Math.abs(velocity) < 0.00004) velocity = 0 + return !!velocity + } + function stepTilt(now) { + tilt.fade(now) + const on = !still && zoom === 1 + const want = on ? { x: tilt.target.x / MAX_DEG, y: tilt.target.y / MAX_DEG } : { x: 0, y: 0 } + let moving = !!(tilt.target.x || tilt.target.y) && on + for (const k of ['x', 'y']) { + const d = want[k] - tiltNow[k] + if (Math.abs(d) < 0.002) tiltNow[k] = want[k] + else { tiltNow[k] += d * 0.25; moving = true } + } + return moving + } + function paint() { + raf = 0 + if (dead) return + const rect = container.getBoundingClientRect(), dpr = Math.min(devicePixelRatio || 1, 2.5) + const w = Math.max(1, Math.round(rect.width * dpr)), h = Math.max(1, Math.round(rect.height * dpr)) + if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h } + if (rect.width !== size.w || rect.height !== size.h) layout() + const now = performance.now() + let again = stepInertia(now) + if (stepTilt(now)) again = true + if (stepPeek(now)) again = true + gl.viewport(0, 0, w, h) + gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT) + if (again) draw() + if (!furniture) return + const aspect = rect.width / rect.height + const round = Math.max(Math.abs(tiltNow.x), Math.abs(tiltNow.y)) + const dist = (visibleH / 2 / Math.tan(FOV / 2) / zoom) * (1 + ORBIT_BACK * round) + const M = cameraMatrix([rowWidth / 2 + x, y, SHELF.DEPTH], dist, aspect, ((REST_TILT - tiltNow.x * ORBIT_TILT) * Math.PI) / 180, (-tiltNow.y * ORBIT_YAW * Math.PI) / 180) + gl.uniformMatrix4fv(u.uM, false, M) + lastM = M + gl.uniform2f(u.uSize, w, h) + gl.uniform1f(u.uDim, peek ? (1 - 0.42 * ease(peek.t)) * (1 - 0.6 * ease(peek.fly?.t || 0)) : 1) + if (peek) gl.uniform4f(u.uHide, peek.it.x, peek.it.x + peek.it.w, peek.base - 0.002, peek.base + peek.it.h + 0.002) + gl.activeTexture(gl.TEXTURE0) + gl.uniform1i(u.uKind, 0) + gl.bindTexture(gl.TEXTURE_2D, wood) + drawMesh(furniture) + const nearest = Math.round((-y - SHELF.PITCH / 2) / SHELF.PITCH) + const reach = Math.ceil(visibleH / zoom / SHELF.PITCH / 2) + 1 + for (const b of [...built.values()]) if (Math.abs(b.k - nearest) > reach + 2) freeRow(b) + for (let k = Math.max(0, nearest - reach); k <= Math.min(rows.length - 1, nearest + reach); k++) { + const b = built.get(k) || buildRow(k) + if (b.spines) { + gl.uniform1i(u.uKind, 1) + gl.bindTexture(gl.TEXTURE_2D, b.tex) + drawMesh(b.spines) + } + if (b.notes) { + gl.uniform1i(u.uKind, 1) + gl.bindTexture(gl.TEXTURE_2D, b.notes.tex) + drawMesh(b.notes.mesh) + } + gl.uniform1i(u.uKind, 3) + if (b.paper) drawMesh(b.paper) + for (const f of b.faces) { + const tex = covers.get(coverKey(f.it)), { base } = paletteOf(f.it) + gl.uniform1i(u.uKind, 2) + gl.uniform1f(u.uHas, tex ? 1 : 0) + gl.uniform3fv(u.uColor, base) + if (tex) gl.bindTexture(gl.TEXTURE_2D, tex) + drawMesh(f.mesh) + gl.uniform1i(u.uKind, 4) + drawMesh(f.edge) + gl.uniform1i(u.uKind, 5) + drawMesh(f.pages) + } + } + if (peek) { + // Ya delante, el tomo no sigue al giroscopio: su cámara pierde la inclinación a medida que se acerca. + const k = 1 - ease(peek.t), dk = (visibleH / 2 / Math.tan(FOV / 2) / zoom) * (1 + ORBIT_BACK * round * k) + peekM = cameraMatrix([rowWidth / 2 + x, y, SHELF.DEPTH], dk, aspect, ((REST_TILT - tiltNow.x * ORBIT_TILT * k) * Math.PI) / 180, (-tiltNow.y * ORBIT_YAW * k * Math.PI) / 180) + gl.uniformMatrix4fv(u.uM, false, peekM) + if (peek.fly) drawTable() + drawPeek() + if (peek.fly && peek.fly.t >= 1 && !peek.fly.shot) { + // Último fotograma, tomado ahora que el lienzo aún lo tiene: tapa el cambio al lector. + peek.fly.shot = true + let snap = null + try { snap = canvas.toDataURL('image/jpeg', 0.9) } catch {} + const done = peek.fly.done + setTimeout(() => done?.(snap), 0) + } + } + } + + // ----- Vistazo: el tomo sale recto de la fila, gira y se acerca enseñando la portada ----- + let peek = null, lastM = null, peekM = null, deferLayout = false + const ease = t => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2) + const lerp3 = (a, b, t) => a.map((v, i) => v + (b[i] - v) * t) + // Caja centrada en el origen con la portada hacia +z y el lomo a la derecha (+x), como un manga: en la fila, la portada mira a la izquierda. + function peekBox(W, H, T, U0, U1) { + const cover = new Shape(), spine = new Shape(), back = new Shape(), paper = new Shape(), X = W / 2, Y = H / 2, Z = T / 2 + quad(cover, [-X, Y, Z], [W, 0, 0], [0, -H, 0], [0, 0, 1]) + quad(spine, [X, Y, Z], [0, 0, -T], [0, -H, 0], [1, 0, 0], [U0, 0, U1, 1]) // en la fila, con el giro, coincide con su trozo de la tira + quad(back, [X, Y, -Z], [-W, 0, 0], [0, -H, 0], [0, 0, -1]) + quad(paper, [-X, Y, -Z], [W, 0, 0], [0, 0, T], [0, 1, 0], [0, 0, W, T]) + quad(paper, [-X, -Y, -Z], [W, 0, 0], [0, 0, T], [0, -1, 0], [0, 0, W, T]) + quad(paper, [-X, -Y, -Z], [0, H, 0], [0, 0, T], [-1, 0, 0], [0, 0, H, T]) + return [cover, spine, back, paper].map(makeMesh) + } + // Dónde queda delante de la cámara: a un lado de la ficha en apaisado, encima en vertical. + function peekTarget(W, H) { + const aspect = size.w / size.h, tan = Math.tan(FOV / 2), dist = visibleH / 2 / tan / zoom, wide = aspect >= 1 + let dd = H / ((wide ? 0.62 : 0.42) * 2 * tan) + if (!wide) dd = Math.max(dd, W / (0.6 * 2 * tan * aspect)) + dd = Math.min(dd, dist * 0.92) + const vh = 2 * tan * dd, vw = vh * aspect + // Sobre el eje de la cámara en reposo (mira algo hacia abajo), y desplazado en su plano. + const tl = (REST_TILT * Math.PI) / 180, T = [rowWidth / 2 + x, y, SHELF.DEPTH], eye = [T[0], T[1] - dist * Math.sin(tl), T[2] + dist * Math.cos(tl)] + const f = [0, T[1] - eye[1], T[2] - eye[2]], n = Math.hypot(f[1], f[2]), F = [0, f[1] / n, f[2] / n], U = [0, -F[2], F[1]] // derecha: +x + const dx = wide ? -vw * 0.2 : 0, dy = wide ? 0 : vh * 0.16 + return [eye[0] + dx, eye[1] + F[1] * dd + U[1] * dy, eye[2] + F[2] * dd + U[2] * dy] + } + function peekInfo(it) { + const vols = it.work.vols || [], i = vols.indexOf(it.vol), ch = it.work.chapterId + const cur = ch ? vols.findIndex(v => (v.chapters || []).includes(ch)) : -1 + const state = !ch ? 'new' : !vols.length ? 'reading' : cur < 0 ? 'unknown' : i < cur ? 'read' : i === cur ? 'reading' : 'new' + return { work: it.work, vol: it.vol, index: i, count: vols.length, state } + } + function startPeek(it, k) { + const b = built.get(k) + if (!b) return + if (peek) freePeek() + const base = rowBase(k), out = !it.face, W = out ? it.d : it.w, H = it.h, T = out ? it.w : it.t + const from = out ? { pos: [it.x + it.w / 2, base + H / 2, SHELF.FRONT - it.d / 2], rot: -Math.PI / 2 } : { pos: [it.x + it.w / 2, base + H / 2, SHELF.FACE_Z - it.t / 2], rot: 0 } + const mid = { pos: [from.pos[0], from.pos[1] + 0.006, from.pos[2] + (out ? it.d + 0.03 : 0.14)], rot: from.rot } + peek = { it, k, b, base, out, W, H, T, from, mid, to: { pos: peekTarget(W, H), rot: 0 }, box: peekBox(W, H, T, it.x / rowWidth, (it.x + it.w) / rowWidth), t: 0, dir: 1, at: performance.now(), tex: null, rank: -1 } + velocity = 0 + // Portada: primero la que ya hay, luego la grande. + const p = peek, shared = covers.get(coverKey(it)) + if (shared) p.shared = shared + else { p.tex = texFrom(plainCover(it)); p.rank = 0 } + ;[it.vol.thumb || it.work.cover, it.vol.art, it.vol.front].forEach((url, rank) => { + if (url) loadImage(url, im => { + if (peek !== p || !im || rank + 1 <= p.rank) return + if (p.tex) gl.deleteTexture(p.tex) + p.tex = texFrom(im); p.rank = rank + 1; p.shared = null + draw() + }) + }) + onPeek?.(peekInfo(it)) + draw() + } + function freePeek() { + if (!peek) return + for (const m of peek.box) freeMesh(m) + if (peek.tex && !gl.isContextLost()) gl.deleteTexture(peek.tex) + peek = null + if (deferLayout) { deferLayout = false; api.relayout() } + } + function unpeek() { + if (!peek || peek.dir < 0 || peek.fly) return + peek.dir = -1 + peek.at = performance.now() + onPeek?.(null) + draw() + } + function peekPose(p) { + if (p.fly) { + const a = p.fly.from, e = ease(p.fly.t) + return { pos: lerp3(a.pos, p.fly.to.pos, e), rot: a.rot + (p.fly.to.rot - a.rot) * e, lean: (a.lean || 0) + (p.fly.to.lean - (a.lean || 0)) * e } + } + const t = p.t + if (t <= PEEK_SLIDE) return { pos: lerp3(p.from.pos, p.mid.pos, ease(t / PEEK_SLIDE)), rot: p.from.rot } + const e = ease((t - PEEK_SLIDE) / (1 - PEEK_SLIDE)) + return { pos: lerp3(p.mid.pos, p.to.pos, e), rot: p.from.rot + (p.to.rot - p.from.rot) * e } + } + function stepPeek(now) { + if (!peek) return false + if (peek.fly) { + const f = peek.fly + f.t = still ? 1 : Math.min(1, f.t + (now - f.at) / FLY_MS) + f.at = now + return f.t < 1 || !f.shot + } + const dt = still ? 1 : (now - peek.at) / (peek.dir > 0 ? PEEK_OPEN_MS : PEEK_CLOSE_MS) + peek.at = now + peek.t = Math.max(0, Math.min(1, peek.t + peek.dir * dt)) + if (peek.dir < 0 && peek.t === 0) { freePeek(); return true } + return peek.t < 1 || peek.dir < 0 + } + function drawPeek() { + const p = peek, pose = peekPose(p), [cover, spine, back, paper] = p.box + gl.uniform4f(u.uHide, 0, 0, 0, 0) + gl.uniform1f(u.uDim, 1) + gl.uniformMatrix4fv(u.uModel, false, modelOf(pose)) + const tex = p.tex || p.shared + gl.uniform3fv(u.uColor, paletteOf(p.it).base) + gl.uniform1i(u.uKind, 2) + gl.uniform1f(u.uHas, tex ? 1 : 0) + if (tex) gl.bindTexture(gl.TEXTURE_2D, tex) + drawMesh(cover) + if (p.out && built.get(p.k) === p.b) { gl.uniform1i(u.uKind, 1); gl.bindTexture(gl.TEXTURE_2D, p.b.tex) } else gl.uniform1i(u.uKind, 4) + drawMesh(spine) + gl.uniform1i(u.uKind, 4) + drawMesh(back) + gl.uniform1i(u.uKind, 5) + drawMesh(paper) + gl.uniformMatrix4fv(u.uModel, false, IDENTITY) + } + // Giro del tomo: primero sobre su eje vertical (sacarlo y enseñar la portada), luego hacia atrás (posarlo en la mesa). + function modelOf({ pos, rot, lean = 0 }) { + const cy = Math.cos(rot), sy = Math.sin(rot), cx = Math.cos(lean), sx = Math.sin(lean) + return new Float32Array([cy, sx * sy, -cx * sy, 0, 0, cx, sx, 0, sy, -sx * cy, cx * cy, 0, pos[0], pos[1], pos[2], 1]) + } + // ----- A la mesa: el tomo se centra y se recuesta como en el lector, y la mesa aparece detrás ----- + let tableTex = null, tableAspect = 1, tableMesh = null + if (table) loadImage(table, im => { if (im && !dead) { tableTex = texFrom(im); tableAspect = im.naturalWidth / im.naturalHeight; draw() } }) + function flyTarget(W, H) { + // Como el encuadre del tomo cerrado en el lector: centrado, con un margen de 14 a 40 px. + const aspect = size.w / size.h, tan = Math.tan(FOV / 2), dist = visibleH / 2 / tan / zoom + const m = Math.max(14, Math.min(40, Math.min(size.w, size.h) * 0.04)) + let dd = (H * 1.04) / (2 * tan * (1 - (2 * m) / size.h)) + dd = Math.max(dd, (W * 1.06) / (2 * tan * aspect * (1 - (2 * m) / size.w))) + dd = Math.min(dd, dist * 0.95) + const tl = (REST_TILT * Math.PI) / 180, T = [rowWidth / 2 + x, y, SHELF.DEPTH], eye = [T[0], T[1] - dist * Math.sin(tl), T[2] + dist * Math.cos(tl)] + const f = [T[1] - eye[1], T[2] - eye[2]], n = Math.hypot(f[0], f[1]) + return { pos: [eye[0], eye[1] + (f[0] / n) * dd, eye[2] + (f[1] / n) * dd], rot: 0, lean: (-(14 - REST_TILT) * Math.PI) / 180 } // en el lector la mesa se ve a 14° + } + function drawTable() { + const f = peek.fly, a = Math.min(1, Math.max(0, (f.t - 0.1) / 0.75)) + if (a <= 0) return + const pose = peekPose(peek), Tz = peek.T / 2 + 0.003 + if (!tableMesh) { + const sh = new Shape(), S = 14 + quad(sh, [-S, S, 0], [2 * S, 0, 0], [0, -2 * S, 0], [0, 0, 1], [-S, S, S, -S]) + tableMesh = makeMesh(sh) + } + gl.uniform4f(u.uHide, 0, 0, 0, 0) // el hueco del tomo en la fila no recorta la mesa + const M = modelOf(pose) + // La mesa queda bajo el tomo: se baja a lo largo de su normal. + M[12] -= M[8] * Tz; M[13] -= M[9] * Tz; M[14] -= M[10] * Tz + gl.uniformMatrix4fv(u.uModel, false, M) + gl.uniform4f(u.uSpot, pose.pos[0], pose.pos[1], pose.pos[2], Math.max(peek.W, peek.H) * 0.9) + const tex = tableTex || wood, k = Math.max(peek.H * 1.6, (peek.W * 2.2) / tableAspect) + gl.uniform1f(u.uHas, tableTex ? 1 : 0) + gl.uniform2f(u.uTab, 1 / (k * tableAspect), -1 / k) + gl.bindTexture(gl.TEXTURE_2D, tex) + gl.uniform1i(u.uKind, 6) + gl.uniform1f(u.uAlpha, ease(a)) + // De fondo, aunque al recostarse se meta tras el mueble; luego el tomo se dibuja encima sin que el mueble lo tape. + gl.enable(gl.BLEND) + gl.disable(gl.DEPTH_TEST) + drawMesh(tableMesh) + gl.enable(gl.DEPTH_TEST) + gl.disable(gl.BLEND) + gl.clear(gl.DEPTH_BUFFER_BIT) + gl.uniform1f(u.uAlpha, 1) + } + function fly(done) { + if (!peek || peek.dir < 0 || peek.fly) return false + const from = peekPose(peek) + peek.fly = { t: 0, at: performance.now(), from, to: flyTarget(peek.W, peek.H), done, shot: false } + onPeek?.(null) + draw() + return true + } + + // Pantalla: píxeles CSS dentro del lienzo. + const toScreen = (q, m = lastM) => { const [nx, ny] = project(m, q); return [((nx + 1) / 2) * size.w, ((1 - ny) / 2) * size.h] } + function itemRect(it, k) { + const base = rowBase(k), z = it.face ? SHELF.FACE_Z : SHELF.FRONT, [ax, ay] = toScreen([it.x, base + it.h, z]), [bx, by] = toScreen([it.x + it.w, base, z]) + return { l: Math.min(ax, bx), r: Math.max(ax, bx), t: Math.min(ay, by), b: Math.max(ay, by) } + } + // El lomo bajo el dedo; con lomos estrechos, el de centro más cercano. + function pickAt(px, py) { + if (!lastM) return null + let best = null + for (const b of built.values()) for (const it of rows[b.k] || []) { + const r = itemRect(it, b.k), slack = 4 + if (px < r.l - slack || px > r.r + slack || py < r.t || py > r.b) continue + const d = Math.abs(px - (r.l + r.r) / 2) / Math.max(1, r.r - r.l) + if (!best || d < best.d) best = { it, k: b.k, d } + } + return best + } + function peekRect() { + if (!peekM || !peek) return null + const { pos } = peekPose(peek), [ax, ay] = toScreen([pos[0] - peek.W / 2, pos[1] + peek.H / 2, pos[2]], peekM), [bx, by] = toScreen([pos[0] + peek.W / 2, pos[1] - peek.H / 2, pos[2]], peekM) + return { l: Math.min(ax, bx), r: Math.max(ax, bx), t: Math.min(ay, by), b: Math.max(ay, by) } + } + function onPeekBook(px, py) { + const r = peekRect() + return !!r && px >= r.l && px <= r.r && py >= r.t && py <= r.b + } + function onTap(cx, cy, wasMoving) { + const r = canvas.getBoundingClientRect(), px = cx - r.left, py = cy - r.top + if (peek?.fly) return + if (peek) { + if (peek.dir > 0 && peek.t >= 1 && onPeekBook(px, py) && onOpen) return onOpen(peekInfo(peek.it)) + return unpeek() + } + if (wasMoving) return // ese toque solo frenó la inercia + const hit = pickAt(px, py) + if (hit) startPeek(hit.it, hit.k) + } + + // ----- Gestos: un dedo recorre, dos acercan ----- + const pointers = new Map() + let pinch = null, track = null, tap = null + const unitsPerPx = () => visibleH / Math.max(1, size.h) / zoom + const spread = () => { const [a, b] = [...pointers.values()]; return { d: Math.hypot(a.x - b.x, a.y - b.y), cx: (a.x + b.x) / 2, cy: (a.y + b.y) / 2 } } + function onDown(e) { + pointers.set(e.pointerId, { x: e.clientX, y: e.clientY }) + try { canvas.setPointerCapture(e.pointerId) } catch {} + tap = pointers.size === 1 ? { x: e.clientX, y: e.clientY, t: e.timeStamp, moving: !!velocity } : null + focusId = null + velocity = 0 + track = null + if (pointers.size === 2) { const s = spread(); pinch = { ...s, zoom } } + else if (pointers.size === 1) track = { x: e.clientX, y: e.clientY, t: e.timeStamp, v: 0 } + draw() + } + function onMove(e) { + const p = pointers.get(e.pointerId) + if (!p) return + const dx = e.clientX - p.x, dy = e.clientY - p.y + p.x = e.clientX + p.y = e.clientY + if (tap && Math.hypot(e.clientX - tap.x, e.clientY - tap.y) > 8) tap = null + if (peek) return // con un tomo fuera, el mueble se queda quieto + if (pinch && pointers.size >= 2) { + const s = spread() + setZoom(pinch.zoom * (s.d / Math.max(1, pinch.d)), s.cx, s.cy) + return + } + if (pointers.size !== 1) return + y += dy * unitsPerPx() + x -= dx * unitsPerPx() + clampView() + if (track) { + const dt = Math.max(1, e.timeStamp - track.t) + track.v = track.v * 0.6 + ((dy * unitsPerPx()) / dt) * 0.4 + track.t = e.timeStamp + } + draw() + } + function onUp(e) { + if (!pointers.delete(e.pointerId)) return + if (pointers.size < 2) pinch = null + if (tap && !pointers.size && e.type === 'pointerup' && e.timeStamp - tap.t < 450) { const { moving } = tap; tap = null; track = null; return onTap(e.clientX, e.clientY, moving) } + tap = null + if (!pointers.size && track && e.timeStamp - track.t < 80 && Math.abs(track.v) > 0.0002) { + velocity = track.v + lastMove = performance.now() + draw() + } + if (pointers.size) track = null + } + canvas.addEventListener('pointerdown', onDown) + canvas.addEventListener('pointermove', onMove) + canvas.addEventListener('pointerup', onUp) + canvas.addEventListener('pointercancel', onUp) + canvas.addEventListener('wheel', e => { + e.preventDefault() + if (peek) return + focusId = null + velocity = 0 + if (e.ctrlKey) return setZoom(zoom * Math.exp(-e.deltaY * 0.01), e.clientX, e.clientY) // pellizco en el panel táctil + y -= e.deltaY * unitsPerPx() + x += e.deltaX * unitsPerPx() + clampView() + draw() + }, { passive: false }) + const onVis = () => { if (document.visibilityState === 'visible') { if (!still) tilt.permit() } else tilt.listen(false) } + document.addEventListener('visibilitychange', onVis) + + const ro = new ResizeObserver(() => { layout(); draw() }) + ro.observe(container) + canvas.addEventListener('webglcontextlost', e => { + e.preventDefault() + built.clear(); covers.clear(); furniture = null; rowsKey = '' + if (peek) { const f = peek.fly; peek = null; onPeek?.(null); f?.done?.(null) } // sus mallas y texturas se perdieron con el contexto + tableMesh = null; tableTex = null + }) + canvas.addEventListener('webglcontextrestored', () => { initGL(); layout(); draw() }) + initGL() + layout() + if (!still) tilt.permit() + draw() + + const api = { + setWorks(next) { works = next; api.relayout() }, + toTop() { focusId = null; velocity = 0; toTopNext = true; api.relayout() }, // tras cambiar el orden + relayout() { + clearTimeout(layoutTimer) + layoutTimer = setTimeout(() => { + if (dead) return + if (peek) { deferLayout = true; return } // con un tomo fuera, el mueble no se reordena hasta que vuelva + layout(); draw() + }, 350) + }, + setVolumes(id, vols) { + const w = works.find(x => x.id === id) + if (!w) return + w.vols = vols + api.relayout() + }, + unpeek, + setLogo, + fly, + focus(id) { focusId = id; layout(); draw() }, + peeked: () => (peek && peek.dir > 0 ? peekInfo(peek.it) : null), + // Centro en pantalla de un tomo (para las pruebas y para enfocar). + screenOf(id, vol) { + if (!lastM) return null + for (const b of built.values()) for (const it of rows[b.k] || []) if (it.work.id === id && (vol === undefined || it.vol.vol === vol)) { const r = itemRect(it, b.k); return { x: (r.l + r.r) / 2, y: (r.t + r.b) / 2, w: r.r - r.l, h: r.b - r.t } } + return null + }, + scrollTo(next) { velocity = 0; y = next; clampView(); draw() }, + setZoom, + draw, + // Para las pruebas. + debug: () => ({ faces: Object.entries(palettes).filter(([k, v]) => k.startsWith('face:') && v).length, facesAsked: Object.keys(palettes).filter(k => k.startsWith('face:')).length, faceWaiting: faceWaiting.length, faceBusy, faceWorker: faceWorker === false ? 'off' : !!faceWorker, rows: rows.map(r => r.map(i => ({ id: i.work.id, vol: i.vol.vol, x: i.x, w: i.w, h: i.h, d: i.d, face: i.face }))), rowWidth, y, x, zoom, velocity, top, bottom, peek: peek && { id: peek.it.work.id, vol: peek.it.vol.vol, t: peek.t, dir: peek.dir, rank: peek.rank, rect: peekRect(), fly: peek.fly ? peek.fly.t : null }, focusId, notes: [...built.values()].map(b => ({ k: b.k, notes: (b.notes?.list || []).map(n => ({ id: n.work.id, x: n.x, w: n.w })) })), logos: logos.size, tilt: { ...tiltNow }, listening: tilt.listening, arts: arts.size, panels: panels.size, built: [...built.keys()], covers: covers.size, palettes: Object.keys(palettes).length, waiting: waiting.length, busy: imgBusy, ctx: !gl.isContextLost() }), + destroy() { + if (faceWorker) faceWorker.terminate() + dead = true + cancelAnimationFrame(raf) + clearTimeout(layoutTimer) + clearTimeout(saveTimer) + ro.disconnect() + tilt.stop() + document.removeEventListener('visibilitychange', onVis) + try { localStorage.setItem(STORE, JSON.stringify(palettes)) } catch {} + freePeek() + freeMesh(tableMesh) + if (tableTex) gl.deleteTexture(tableTex) + dropAllRows() + for (const t of covers.values()) gl.deleteTexture(t) + covers.clear() + freeMesh(furniture) + gl.deleteTexture(wood) + gl.deleteProgram(prog) + gl.getExtension('WEBGL_lose_context')?.loseContext() + canvas.remove() + }, + } + canvas.shelf = api + return api +} diff --git a/app/public/style.css b/app/public/style.css new file mode 100644 index 0000000..b6ff979 --- /dev/null +++ b/app/public/style.css @@ -0,0 +1,513 @@ +[hidden] { display: none !important; } +:root { + --bg: #14121a; + --surface: #1e1b26; + --surface-2: #2a2635; + --text: #ece8f4; + --muted: #9a93ab; + --accent: #bf78fa; + --accent-ink: #14121a; + --danger: #ff6b81; + --radius: 10px; + color-scheme: dark; +} +* { box-sizing: border-box; } +html, body { margin: 0; background: var(--bg); color: var(--text); font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; } +a { color: inherit; text-decoration: none; } +button, input, select { font: inherit; color: inherit; } +img { display: block; } + +#topbar { position: sticky; top: 0; z-index: 20; display: flex; align-items: center; justify-content: space-between; gap: 12px; + padding: 10px 16px; background: rgba(20, 18, 26, .92); backdrop-filter: blur(8px); border-bottom: 1px solid var(--surface-2); } +.brand { display: flex; align-items: center; gap: 8px; font-weight: 700; } +#topbar nav { display: flex; gap: 4px; } +#topbar nav a { padding: 6px 10px; border-radius: 8px; color: var(--muted); } +#topbar nav a.active, #topbar nav a:hover { color: var(--text); background: var(--surface-2); } +body.reading #topbar { display: none; } + +main { max-width: 1200px; margin: 0 auto; padding: 16px; } +body.reading main { max-width: none; padding: 0; } +h1 { font-size: 1.6rem; margin: 0 0 8px; } +h2 { font-size: 1.15rem; margin: 24px 0 12px; } +.muted { color: var(--muted); } +.row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } + +.btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 9px 16px; border: 1px solid var(--surface-2); + border-radius: var(--radius); background: var(--surface); cursor: pointer; } +.btn:hover { background: var(--surface-2); } +.btn.primary { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); font-weight: 600; } +.btn.primary:hover { filter: brightness(1.08); } +.btn.small { padding: 5px 10px; font-size: .88rem; } +.btn.danger { color: var(--danger); } +input[type=text], input[type=password], input[type=search], select { + padding: 9px 12px; border-radius: var(--radius); border: 1px solid var(--surface-2); background: var(--surface); outline: none; } +input:focus, select:focus { border-color: var(--accent); } + +.auth { max-width: 360px; margin: 10vh auto; background: var(--surface); padding: 24px; border-radius: 14px; } +.auth form { display: grid; gap: 12px; } +.error { color: var(--danger); min-height: 1.2em; margin: 0; } + +.search { display: flex; gap: 8px; margin-bottom: 8px; } +.search input { flex: 1; min-width: 0; } +.tabs { display: flex; gap: 6px; margin: 12px 0; flex-wrap: wrap; } +.tabs button { padding: 6px 12px; border-radius: 999px; border: 1px solid var(--surface-2); background: transparent; cursor: pointer; color: var(--muted); } +.tabs button.on { background: var(--surface-2); color: var(--text); } + +.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 14px; } +.card { position: relative; display: block; border-radius: var(--radius); overflow: hidden; background: var(--surface); } +.card .cover { aspect-ratio: 2 / 3; width: 100%; object-fit: cover; background: var(--surface-2); } +/* Margen (no padding) para que el recorte de 2 líneas no deje asomar la 3.ª */ +.card .t { padding: 0; margin: 8px 10px 10px; font-size: .9rem; line-height: 1.3; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } +.card .sub { padding: 0 10px 10px; font-size: .8rem; color: var(--muted); margin-top: -6px; } +.bar { height: 4px; background: var(--surface-2); } +.bar > i { display: block; height: 100%; background: var(--accent); } + +.continue { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(260px, 320px); gap: 12px; overflow-x: auto; padding-bottom: 6px; } +.cont { display: flex; gap: 12px; padding: 10px; border-radius: var(--radius); background: var(--surface); align-items: center; } +.cont img { width: 56px; height: 84px; object-fit: cover; border-radius: 6px; background: var(--surface-2); flex: none; } +.cont .info { min-width: 0; flex: 1; } +.cont .info b { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.cont .bar { margin-top: 6px; border-radius: 2px; } + +.detail { display: grid; grid-template-columns: 220px 1fr; gap: 24px; } +.detail .cover { width: 100%; border-radius: var(--radius); aspect-ratio: 2/3; object-fit: cover; background: var(--surface-2); } +.desc { white-space: pre-line; color: #cfc8dc; max-height: 12em; overflow: auto; } +.tags { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0; } +.tag { font-size: .78rem; padding: 2px 8px; border-radius: 999px; background: var(--surface-2); color: var(--muted); } +.chapters { list-style: none; padding: 0; margin: 0; display: grid; gap: 6px; } +.chapters a { display: flex; justify-content: space-between; gap: 12px; padding: 10px 12px; border-radius: 8px; background: var(--surface); align-items: center; } +.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; } +.history .cont { justify-content: space-between; } + +.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); } + +/* ---- Lector ---- */ +.reader { min-height: 100vh; background: #000; position: relative; } +/* Filtro de luz azul: tono ámbar multiplicado sobre las páginas (bajo las barras del lector). */ +.reader.bluelight::after { content: ''; position: fixed; inset: 0; z-index: 25; pointer-events: none; + background: #ff9a3c; mix-blend-mode: multiply; opacity: .42; } +.rbar { position: fixed; left: 0; right: 0; z-index: 30; display: flex; align-items: center; gap: 8px; padding: 8px 12px; + background: rgba(10, 9, 14, .9); backdrop-filter: blur(6px); transition: transform .2s; flex-wrap: wrap; } +.rbar.top { top: 0; padding-top: max(8px, env(safe-area-inset-top)); } +.rbar.bottom { bottom: 0; justify-content: center; padding-bottom: max(8px, env(safe-area-inset-bottom)); } +.rbar.bottom.rtl, .rbar.bottom.rtl a.btn { flex-direction: row-reverse; } +/* Móvil: arriba la página y la barra de progreso; abajo cada botón en su extremo. */ +@media (max-width: 640px) { + .rbar.bottom { justify-content: space-between; } + .rbar.bottom #pnum { order: -2; } + .rbar.bottom input[type=range] { order: -1; flex: 1 1 calc(100% - 80px); max-width: none; } + .rbar.bottom .chnext { margin-left: auto; } + .rbar.bottom.rtl .chnext { margin-left: 0; margin-right: auto; } +} +.rbar.bottom.rtl a.btn .ic, .paged[data-dir="rtl"] .chapter-end a.primary .ic { transform: scaleX(-1); } +.reader.hide-ui .rbar.top { transform: translateY(-110%); } +.reader.hide-ui .rbar.bottom { transform: translateY(110%); } +.rbar .ttl { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: .9rem; } +.rbar select { padding: 5px 8px; } +.rbar input[type=range] { flex: 1; max-width: 520px; accent-color: var(--accent); } + +#pages img { filter: var(--page-filter, none); transition: filter .2s, opacity .25s; } +/* Página que aún no ha llegado: indicador de carga y fundido al aparecer */ +#pages .stage img.pending { opacity: 0; } +.paged.waiting::after, .paged .stage.loading::after { content: ''; position: absolute; left: 50%; top: 50%; width: 34px; height: 34px; + margin: -17px 0 0 -17px; border-radius: 50%; border: 3px solid rgba(255,255,255,.18); border-top-color: var(--accent); + animation: pgspin .8s linear infinite; z-index: 6; pointer-events: none; } +.paged .stage.loading { min-height: 100%; } +@keyframes pgspin { to { transform: rotate(360deg); } } +.paged { height: 100vh; height: 100dvh; display: flex; align-items: center; justify-content: center; user-select: none; } +.paged img { max-width: 100%; max-height: 100%; object-fit: contain; } +.paged.fit-width { height: auto; min-height: 100vh; align-items: flex-start; } +.paged.fit-width img { max-height: none; width: 100%; max-width: 900px; } +.vertical { display: flex; flex-direction: column; align-items: center; } +.vertical img { width: 100%; max-width: 900px; min-height: 300px; background: #111; } +.vertical img[data-loaded] { min-height: 0; } +/* Vertical para mangas: cada página entera en pantalla, pegadas */ +.vertical.fit-height img { width: auto; max-width: 100%; max-height: 100vh; max-height: 100dvh; } +.vertical.fit-height img:not([data-loaded]) { width: min(100%, 640px); } +.chapter-end { padding: 40px 16px 120px; text-align: center; display: grid; gap: 12px; justify-items: center; } + +#toast { position: fixed; bottom: 80px; left: 50%; transform: translateX(-50%); z-index: 50; background: var(--surface-2); + padding: 10px 16px; border-radius: var(--radius); box-shadow: 0 6px 20px rgba(0,0,0,.4); } + +@media (max-width: 640px) { + .detail { grid-template-columns: 1fr; } + .detail .cover { max-width: 180px; } + .grid { grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 10px; } + #topbar .brand span { display: none; } + #topbar nav a { padding: 6px 8px; } +} + +/* Categorías y descargas */ +.cats h3 { font-size: .95rem; color: var(--muted); margin: 18px 0 8px; } +a.tag { cursor: pointer; } +a.tag:hover, .tag.on { background: var(--accent); color: var(--accent-ink); } +.tag.big { font-size: .88rem; padding: 5px 12px; } +.dlbox { display: grid; gap: 6px; margin-top: 10px; max-width: 520px; } +.dlbar { height: 8px; border-radius: 4px; overflow: hidden; } +.dlbar > i { transition: width .6s ease; } +.history .cont a.info { display: block; min-width: 0; flex: 1; } + +/* Iconos */ +.ic { width: 1.15em; height: 1.15em; flex: none; vertical-align: -0.2em; } +#topbar nav a { display: inline-flex; align-items: center; gap: 6px; } +.tabs button { display: inline-flex; align-items: center; gap: 6px; } +.chapters .meta .ic { width: 1em; height: 1em; } +@media (max-width: 560px) { + #topbar nav a span { display: none; } /* en móvil solo iconos en el menú */ + #topbar nav a { padding: 8px 10px; } + #topbar nav a .ic { width: 1.35em; height: 1.35em; } + .search .btn .lbl { display: none; } +} + +/* Lector: escenario de páginas, doble página y zoom */ +.paged { overflow: hidden; touch-action: none; } +.paged.fit-width:not(.zoomed) { overflow: visible; touch-action: pan-y; } +.paged .stage { display: flex; justify-content: center; align-items: center; width: 100%; height: 100%; transform-origin: 0 0; will-change: transform; } +.paged.fit-width .stage { height: auto; align-items: flex-start; } +.paged[data-dir="rtl"] .stage { flex-direction: row-reverse; } +.paged .stage.two img { max-width: 50%; } +.paged.fit-width .stage.two img { width: 50%; max-width: 700px; } +.paged img { -webkit-user-drag: none; user-select: none; } +.paged.zoomed { cursor: grab; } + +/* Insignias y botón Seguir */ +.hd { display: inline-block; font-size: .65rem; font-weight: 800; letter-spacing: .03em; padding: 0 5px; border-radius: 4px; border: 1px solid var(--accent); color: var(--accent); vertical-align: middle; } +.chapters .meta .queued { opacity: .55; } +.hd.pending { border-style: dashed; color: var(--muted); border-color: var(--muted); } +.badge { display: inline-block; font-size: .72rem; font-weight: 700; padding: 1px 7px; border-radius: 999px; background: var(--accent); color: var(--accent-ink); vertical-align: middle; } +.btn.on { border-color: var(--accent); color: var(--accent); } +h2 .ic { color: var(--accent); } + +/* Estadísticas */ +:root { --viz-1: #5b3f8c; --viz-2: #7d4fc0; --viz-3: #a067e3; --viz-4: #c993ff; --viz-5: #ecd6ff; } +.tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 12px; margin: 16px 0; } +.tile { background: var(--surface); border-radius: var(--radius); padding: 14px 16px; } +.tile .tv { font-size: 1.6rem; font-weight: 700; line-height: 1.2; } +.tile .tl { color: var(--muted); font-size: .88rem; } +.tile .ts { color: var(--muted); font-size: .78rem; margin-top: 4px; } +.charts { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 12px; } +.panel { background: var(--surface); border-radius: var(--radius); padding: 14px 16px; margin-bottom: 12px; min-width: 0; } +.panel h3 { margin: 0 0 10px; font-size: .95rem; } +.chart { width: 100%; height: auto; display: block; } +.chart .grid { stroke: var(--surface-2); stroke-width: 1; } +.chart .axis { fill: var(--muted); font-size: 11px; } +.chart .mark { fill: var(--accent); } +.chart .hit { fill: transparent; } +.chart .col:hover .hit { fill: rgba(255, 255, 255, .04); } +.heat-wrap { overflow-x: auto; } +.heat { display: grid; grid-auto-flow: column; grid-template-rows: repeat(7, 14px); grid-auto-columns: 14px; gap: 3px; } +.cell { display: inline-block; width: 14px; height: 14px; border-radius: 3px; background: var(--surface-2); } +.cell.empty { background: transparent; } +.cell.l1 { background: var(--viz-1); } .cell.l2 { background: var(--viz-2); } .cell.l3 { background: var(--viz-3); } +.cell.l4 { background: var(--viz-4); } .cell.l5 { background: var(--viz-5); } +.heat-legend { display: flex; align-items: center; gap: 3px; font-size: .8rem; margin-top: 8px; } +.hbars { display: grid; gap: 8px; } +.hbar { display: grid; grid-template-columns: minmax(90px, 140px) 1fr 3em; gap: 8px; align-items: center; font-size: .88rem; } +.hbar .hl { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.hbar .ht { height: 10px; background: var(--surface-2); border-radius: 0 4px 4px 0; } +.hbar .ht i { display: block; height: 100%; background: var(--accent); border-radius: 0 4px 4px 0; } +.hbar .hv { text-align: right; color: var(--muted); } +.toplist { display: grid; gap: 8px; } +.toplist .cont { padding: 6px 8px; background: var(--bg); } +.toplist .cont img { width: 36px; height: 54px; } +.toplist .rank { width: 1.4em; text-align: center; color: var(--muted); font-weight: 700; } +details.panel summary { cursor: pointer; color: var(--muted); } +table.data { width: 100%; border-collapse: collapse; margin-top: 10px; font-size: .88rem; } +table.data th, table.data td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--surface-2); } +table.data td:not(:first-child), table.data th:not(:first-child) { text-align: right; font-variant-numeric: tabular-nums; } +#tip { position: fixed; z-index: 60; pointer-events: none; background: #000; color: var(--text); padding: 6px 10px; border-radius: 8px; font-size: .82rem; box-shadow: 0 4px 14px rgba(0,0,0,.5); max-width: 280px; } +.rpanel { position: fixed; top: 52px; right: 8px; left: auto; z-index: 31; display: grid; gap: 8px; padding: 12px; width: min(320px, calc(100vw - 16px)); + background: rgba(10, 9, 14, .96); border: 1px solid var(--surface-2); border-radius: var(--radius); box-shadow: 0 8px 24px rgba(0,0,0,.5); } +.rpanel select, .rpanel label { width: 100%; } +.rpanel p { margin: 0; font-size: .78rem; } +.reader.hide-ui .rpanel { display: none; } +.chart .axis { font-size: 12px; } + +/* Ficha: acciones, menú ⋯ y panel de biblioteca local */ +.detail-body { min-width: 0; } +.actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; } +.btn.on { border-color: var(--accent); color: var(--accent); } +.facts { display: flex; flex-wrap: wrap; gap: 2px 16px; font-size: .85rem; margin-top: 10px; } +.menu { position: relative; } +.menu > summary { list-style: none; padding-left: 12px; padding-right: 12px; } +.menu > summary::-webkit-details-marker { display: none; } +.menu[open] > summary { background: var(--surface-2); } +.menu-list { position: absolute; left: 0; top: calc(100% + 6px); z-index: 15; min-width: 230px; display: grid; padding: 6px; + background: var(--surface); border: 1px solid var(--surface-2); border-radius: var(--radius); box-shadow: 0 10px 28px rgba(0,0,0,.45); } +.menu-list a, .menu-list button { display: flex; align-items: center; gap: 10px; padding: 9px 10px; border: 0; border-radius: 8px; + background: none; text-align: left; cursor: pointer; white-space: nowrap; } +.menu-list a:hover, .menu-list button:hover { background: var(--surface-2); } +.menu-list .danger { color: var(--danger); } + +.libpanel { margin-top: 20px; max-width: 640px; background: var(--surface); border: 1px solid var(--surface-2); border-radius: 12px; } +.psec, .psec-grid { display: grid; gap: 10px; } +.psec { padding: 14px 16px; } +.psec + .psec { border-top: 1px solid var(--surface-2); } +.psec-head { display: flex; justify-content: space-between; align-items: baseline; flex-wrap: wrap; gap: 4px 16px; } +.psec-title { display: flex; align-items: center; gap: 8px; font-weight: 600; } +.psec-title .ic { color: var(--accent); } +.psec-status { font-size: .85rem; display: inline-flex; align-items: center; gap: 4px; } +.psec-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; } +.psec-actions .push { margin-left: auto; } +.note { font-size: .83rem; margin: 0; } +.psec .dlbar { height: 6px; } +.check { display: flex; align-items: center; gap: 8px; font-size: .85rem; color: var(--muted); cursor: pointer; } +input[type=checkbox] { accent-color: var(--accent); width: 16px; height: 16px; margin: 0; flex: none; } +.btn:disabled { opacity: .45; cursor: not-allowed; filter: none; } + +.hdform { display: grid; gap: 12px; padding: 12px; border-radius: 10px; background: var(--bg); border: 1px solid var(--surface-2); } +.seg { display: inline-flex; flex-wrap: wrap; gap: 2px; padding: 3px; justify-self: start; border-radius: 10px; background: var(--surface); border: 1px solid var(--surface-2); } +.seg button { border: 0; background: none; padding: 5px 12px; border-radius: 8px; cursor: pointer; color: var(--muted); font-size: .88rem; } +.seg button.on { background: var(--surface-2); color: var(--text); } +.range label { display: inline-flex; align-items: center; gap: 8px; font-size: .9rem; } +.range select { padding: 6px 10px; } +.sum { font-size: .9rem; font-weight: 600; } + +.list-head { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 8px 12px; margin: 28px 0 12px; } +.list-head h2 { margin: 0; } +.list-head select { padding: 5px 10px; font-size: .88rem; } +.chapters label.pick { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border-radius: 8px; background: var(--surface); cursor: pointer; user-select: none; } +.chapters label.pick:hover { background: var(--surface-2); } +.chapters label.pick .t { flex: 1; min-width: 0; } +.chapters label.pick.off { opacity: .4; cursor: default; } +.chapters label.pick:has(input:checked) { outline: 1px solid var(--accent); } +#selbar { position: fixed; left: 50%; bottom: 16px; transform: translateX(-50%); z-index: 25; display: flex; align-items: center; gap: 10px; + max-width: calc(100% - 32px); padding: 8px 8px 8px 16px; border-radius: 12px; background: var(--surface-2); border: 1px solid #3a3548; + box-shadow: 0 10px 28px rgba(0,0,0,.5); font-size: .9rem; } +#selbar span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.chapters.picking { padding-bottom: 72px; } +@media (max-width: 640px) { + .actions .btn.primary { flex: 1 1 100%; } + .libpanel { max-width: none; } + .psec-actions .push { margin-left: 0; } + .seg { display: grid; grid-template-columns: repeat(3, 1fr); justify-self: stretch; } + #selbar { left: 12px; right: 12px; transform: none; max-width: none; } + #selbar span { flex: 1; white-space: normal; font-size: .82rem; line-height: 1.3; } +} + +/* Recortes: herramienta del lector */ +.cropov { position: fixed; inset: 0; z-index: 40; cursor: crosshair; touch-action: none; background: rgba(0, 0, 0, .25); user-select: none; } +.crop-bar { position: fixed; left: 50%; bottom: max(14px, env(safe-area-inset-bottom)); transform: translateX(-50%); z-index: 1; + display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-radius: 12px; background: rgba(20, 18, 26, .94); + box-shadow: 0 6px 24px rgba(0,0,0,.5); cursor: default; white-space: nowrap; max-width: calc(100vw - 24px); } +.crop-bar span { font-size: .85rem; color: var(--muted); display: inline-flex; gap: 6px; align-items: center; overflow: hidden; text-overflow: ellipsis; } +.crop-rect { position: fixed; border: 2px solid var(--accent); box-shadow: 0 0 0 9999px rgba(0, 0, 0, .45); pointer-events: none; } +.crop-h { position: absolute; width: 44px; height: 44px; pointer-events: auto; touch-action: none; } +.crop-h::after { content: ''; position: absolute; left: 15px; top: 15px; width: 14px; height: 14px; box-sizing: border-box; border-radius: 50%; background: var(--accent); border: 2px solid #fff; box-shadow: 0 1px 4px rgba(0,0,0,.6); } +.crop-h[data-h="nw"] { left: -23px; top: -23px; cursor: nwse-resize; } +.crop-h[data-h="ne"] { right: -23px; top: -23px; cursor: nesw-resize; } +.crop-h[data-h="sw"] { left: -23px; bottom: -23px; cursor: nesw-resize; } +.crop-h[data-h="se"] { right: -23px; bottom: -23px; cursor: nwse-resize; } +.crop-bar em { font-style: normal; } +@media (max-width: 560px) { .crop-bar span { display: none; } } + +/* Recortes: galería */ +.clips { columns: 220px; column-gap: 12px; } +.clip { break-inside: avoid; margin: 0 0 12px; background: var(--surface); border-radius: var(--radius); overflow: hidden; cursor: pointer; } +.clip img { display: block; width: 100%; height: auto; max-height: 520px; object-fit: cover; object-position: top; background: var(--surface-2); } +.clip figcaption { padding: 6px 10px 8px; display: grid; font-size: .8rem; } +.clip figcaption b { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-weight: 600; font-size: .85rem; } +.clip figcaption span { color: var(--muted); } +.clip figcaption i { color: var(--text); opacity: .85; } +.clip:hover { outline: 1px solid var(--accent); } +@media (max-width: 560px) { .clips { columns: 2; column-gap: 8px; } .clip { margin-bottom: 8px; } } +.lightbox { position: fixed; inset: 0; z-index: 45; background: rgba(8, 7, 12, .94); display: grid; grid-template-columns: 1fr 320px; } +.lightbox[hidden] { display: none; } +.lb-img { display: flex; align-items: center; justify-content: center; padding: 16px; min-height: 0; overflow: auto; } +.lb-img img { max-width: 100%; max-height: 100%; object-fit: contain; border-radius: 6px; } +/* Recorte largo de lectura vertical: a su ancho y con desplazamiento; entero en pantalla sería una tira ilegible. */ +.lb-img.long { align-items: flex-start; } +.lb-img.long img { max-height: none; height: auto; flex: none; } +.lb-side { background: var(--surface); padding: 16px; display: flex; flex-direction: column; gap: 10px; overflow: auto; } +.lb-side h2 a { color: var(--text); } +body.noscroll { overflow: hidden; } +@media (max-width: 760px) { + .lightbox { grid-template-columns: 1fr; grid-template-rows: minmax(0, 1fr) auto; } + .lb-img { padding: 10px; } + .lb-side { max-height: 48vh; padding: 12px 14px max(12px, env(safe-area-inset-bottom)); } +} + +/* Historial: tarjetas verticales */ +.hcard .bar { margin: 0; height: 5px; } +.hcard .t { margin-bottom: 4px; } +.hcard .sub { margin-top: 0; } +.hcard .corner { position: absolute; top: 8px; left: 8px; } +.hdel { position: absolute; top: 6px; right: 6px; display: grid; place-items: center; width: 32px; height: 32px; border-radius: 8px; border: 0; + background: rgba(20, 18, 26, .75); color: var(--muted); cursor: pointer; opacity: 0; transition: opacity .15s; } +.hcard:hover .hdel, .hdel:focus-visible { opacity: 1; } +.hdel:hover { color: var(--danger); } +@media (hover: none) { .hdel { opacity: 1; } } + +/* Colas: tarjetas verticales como en Historial */ +.qsec { margin-bottom: 26px; } +.qsec h2 { display: flex; align-items: center; gap: 8px; } +.qcard { display: flex; flex-direction: column; } +.qcard > a { color: inherit; } +.qcard .sub { display: grid; gap: 1px; padding-bottom: 8px; } +.qcard .sub .error { font-size: .78rem; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; } +.qact { display: flex; flex-wrap: wrap; gap: 6px; padding: 0 10px 10px; margin-top: auto; } +.qact .btn { flex: 1; padding: 6px 8px; font-size: .8rem; } +.qpos { width: 26px; height: 26px; border-radius: 50%; display: grid; place-items: center; background: rgba(20, 18, 26, .85); color: var(--text); font-size: .8rem; font-weight: 700; } +.qpos .ic { color: var(--accent); width: 15px; height: 15px; } +.small { font-size: .82rem; } +@media (max-width: 640px) { .qgrid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); } } + +/* Librería */ +.libtabs button .muted { font-size: .78rem; } +.lhead { gap: 8px; flex-wrap: wrap; margin: -4px 0 12px; } +.lhead .small { flex: 1; min-width: 200px; } +.lcheck { padding: 8px 10px; border-radius: 8px; cursor: pointer; } +.lcheck:hover { background: var(--surface-2); } +.chapters .meta .dev .ic { color: var(--accent); } +/* Recomendaciones: fila desplazable */ +.rhead { gap: 10px; flex-wrap: wrap; align-items: baseline; } +.rhead h2 { margin-bottom: 4px; } +.rhead > span.small { flex: 1; min-width: 180px; } +.rhead .small a { color: var(--accent); } +.recrow { display: grid; grid-auto-flow: column; grid-auto-columns: 140px; gap: 12px; overflow-x: auto; padding-bottom: 8px; scroll-snap-type: x mandatory; } +.recrow .card { scroll-snap-align: start; } +@media (max-width: 640px) { .recrow { grid-auto-columns: 115px; gap: 10px; } } +.banlist { display: grid; gap: 6px; margin-top: 8px; } .banlist .row { gap: 8px; flex-wrap: wrap; } + +/* Aviso: el servidor no tiene internet */ +#netbanner { position: sticky; top: 53px; z-index: 19; display: flex; align-items: center; gap: 10px; padding: 8px 16px; + background: #3a2a12; color: #ffd9a0; border-bottom: 1px solid #5a4220; font-size: .9rem; } +#netbanner svg { flex: none; width: 18px; height: 18px; } +body.reading #netbanner { display: none; } + +/* Animación al pasar página (modos por página) */ +.paged { position: relative; perspective: 2200px; } +.paged .stage.leaving { position: absolute; inset: 0; z-index: 1; pointer-events: none; background: #000; } +.paged.fit-width .stage.leaving { bottom: auto; } +.paged .curlgl { position: absolute; left: 0; top: 0; z-index: 3; pointer-events: none; filter: var(--page-filter, none); } +.paged .curl { position: absolute; z-index: 3; transform-style: preserve-3d; pointer-events: none; } +.paged .curl .strip { position: absolute; top: 0; transform-style: preserve-3d; will-change: transform; } +.paged .curl .sf { position: absolute; top: 0; bottom: 0; overflow: hidden; backface-visibility: hidden; -webkit-backface-visibility: hidden; } +.paged .curl .sf img { position: absolute; top: 0; max-width: none; max-height: none; object-fit: fill; display: block; } +.paged .curl .back { transform: rotateY(180deg); } +.paged .curl .back.paper { background: #e9e5dc; } +.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 */ +.chapters .meta .cdate { white-space: nowrap; } +.banner { height: 180px; margin: -16px -16px 16px; background: center / cover no-repeat; mask-image: linear-gradient(#000 40%, transparent); -webkit-mask-image: linear-gradient(#000 40%, transparent); opacity: .7; } +.banner[hidden] { display: none; } +.info-block h2 { margin: 26px 0 10px; font-size: 1.1rem; } +.detail-body .info-block h2:first-child { margin-top: 18px; } +.detail-body .info-block + .tags { margin-top: 14px; } +.drawer .kv { margin: 0; } +.info-block .desc.long { max-height: none; } +.info-block .more summary { cursor: pointer; color: var(--muted); margin-top: 14px; } +.info-block > .note { margin-top: 16px; } +.kv:empty, #mscores:empty { display: none; } +.hrow { display: grid; grid-auto-flow: column; grid-auto-columns: 130px; gap: 12px; justify-content: start; overflow-x: auto; padding-bottom: 8px; overscroll-behavior-x: contain; } +.list-head { scroll-margin-top: 64px; } +/* Menú lateral de la ficha (botón ⋯) */ +.drawer { position: fixed; top: 0; right: 0; bottom: 0; z-index: 35; width: min(440px, 100%); padding: 14px 14px max(14px, env(safe-area-inset-bottom)); display: grid; align-content: start; gap: 12px; + overflow-y: auto; overscroll-behavior: contain; background: var(--bg); border-left: 1px solid var(--surface-2); box-shadow: -10px 0 28px rgba(0,0,0,.45); + transform: translateX(100%); visibility: hidden; transition: transform .22s ease, visibility .22s; } +.drawer.open { transform: none; visibility: visible; } +.drawer-head { display: flex; justify-content: space-between; align-items: center; font-size: 1.05rem; padding-top: env(safe-area-inset-top); } +.drawer-opts { display: grid; gap: 8px; } +.drawer-opts .btn { width: 100%; justify-content: flex-start; box-sizing: border-box; } +.drawer-opts .menu > summary { padding-left: 16px; padding-right: 16px; } +.drawer-opts .menu-list { position: static; margin-top: 6px; box-shadow: none; min-width: 0; } +.drawer .libpanel { margin-top: 0; max-width: none; min-width: 0; } +.drawer-back { position: fixed; inset: 0; z-index: 34; background: rgba(0,0,0,.5); touch-action: none; } +.drawer-back[hidden] { display: none; } +.libbusy { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); animation: libpulse 1.2s ease-in-out infinite; } +@keyframes libpulse { 50% { opacity: .25; } } +.scores { display: flex; flex-wrap: wrap; gap: 10px; margin: 12px 0 6px; } +.score { background: var(--surface); border-radius: 10px; padding: 8px 12px; display: grid; } +.score b { font-size: 1.25rem; } +.score span { font-size: .75rem; color: var(--muted); } +.kv { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; margin: 14px 0; font-size: .92rem; } +.kv dt { color: var(--muted); } +.kv dd { margin: 0; min-width: 0; overflow-wrap: anywhere; } +.tag.soft { background: transparent; border: 1px solid var(--surface-2); } +.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, como un pósit 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; + position: fixed; z-index: 3; right: calc(4vw + env(safe-area-inset-right)); top: 50%; width: min(36vw, 360px); transform: translate(14px, -50%) rotate(var(--nt)); opacity: 0; transition: opacity .22s, transform .26s; + padding: 30px 20px 18px; border-radius: 2px 2px 3px 18px / 2px 2px 3px 10px; color: var(--ni); font-family: var(--nf), cursive; font-weight: var(--nw); + background: linear-gradient(180deg, rgb(0 0 0 / .07) 0 22px, transparent 22px), 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 30px 6px 0; font-family: inherit; font-weight: inherit; font-size: calc(1.75rem * var(--ns)); line-height: 1.05; overflow-wrap: anywhere; } +.shelfpeek .psub, .shelfpeek .pwhere { margin: 4px 0; font-size: calc(1.15rem * var(--ns)); line-height: 1.2; opacity: .85; } +.shelfpeek .pbar { height: 5px; margin: 12px 0 6px; border-radius: 3px; background: color-mix(in srgb, var(--ni) 16%, transparent); overflow: hidden; } +.shelfpeek .pbar i { display: block; height: 100%; background: var(--ni); opacity: .75; border-radius: 3px; } +.shelfpeek .pacts { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 14px; } +.shelfpeek .pacts .btn { flex: 1 1 auto; justify-content: center; font-family: inherit; font-weight: inherit; font-size: calc(1.15rem * var(--ns)); background: transparent; color: var(--ni); border: 2px solid var(--ni); border-radius: 14px 10px 16px 9px / 10px 15px 9px 14px; box-shadow: none; } +.shelfpeek .pacts .btn.primary { background: var(--ni); color: var(--np); } +.shelfpeek .peekx { position: absolute; top: 4px; right: 6px; width: 32px; height: 32px; display: grid; place-items: center; border: 0; border-radius: 50%; background: transparent; color: inherit; cursor: pointer; opacity: .7; } +@media (orientation: portrait) { + .shelfpeek { left: calc(14px + env(safe-area-inset-left)); right: calc(14px + env(safe-area-inset-right)); top: auto; bottom: calc(14px + env(safe-area-inset-bottom)); width: auto; transform: translateY(14px) rotate(var(--nt)); } + .shelfpeek.on { transform: rotate(var(--nt)); } +} +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; } } diff --git a/app/public/sw.js b/app/public/sw.js new file mode 100644 index 0000000..b0783e6 --- /dev/null +++ b/app/public/sw.js @@ -0,0 +1,80 @@ +// Service worker: la app abre sin conexión (shell en caché) y los capítulos guardados "en este +// 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', '/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). +async function installShell() { + const c = await caches.open(SHELL) + const html = await fetch('/', { cache: 'reload' }) + const text = await html.clone().text() + const linked = [...text.matchAll(/(?:src|href)="(\/(?:app\.js|style\.css)\?v=[^"]+)"/g)].map(m => m[1]) + const files = [...linked, ...SHELL_FILES.filter(f => f !== '/' && !linked.some(l => l.startsWith(f + '?')))] + for (const f of files) await c.delete(f, { ignoreSearch: true }) + await c.addAll(files.map(u => new Request(u, { cache: 'reload' }))) + await c.put('/', html) +} +self.addEventListener('install', e => { + e.waitUntil(installShell().then(() => self.skipWaiting())) +}) +self.addEventListener('activate', e => { + e.waitUntil(caches.keys() + .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())) +}) + +// Red primero (así siempre se ve la última versión) y la caché si no hay conexión. Si hay copia guardada +// y el servidor no contesta en NET_WAIT ms (red que no llega, servidor caído o colgado), se usa la copia +// sin esperar a que el navegador dé la conexión por perdida; la petición sigue y actualiza la caché. +const NET_WAIT = 3000 +const DOWN_FOR = 20e3 +let downUntil = 0 +// La app distingue así una copia guardada de una respuesta real del servidor. +async function fromCache(res) { + const headers = new Headers(res.headers) + headers.set('x-from-cache', '1') + return new Response(await res.blob(), { status: res.status, statusText: res.statusText, headers }) +} +// single: guardar solo la última versión (app.js?v=… sustituye a la anterior). +async function networkFirst(req, cacheName, { store = true, ignoreSearch = false, fallback, single = false } = {}) { + const cached = () => caches.match(req, { ignoreSearch, cacheName }).then(hit => hit || (fallback && caches.match(fallback))) + const net = fetch(req).then(async res => { + if (store && res.ok) { + const c = await caches.open(cacheName) + if (single) await c.delete(req, { ignoreSearch: true }) + if (store === true || (await c.match(req, { ignoreSearch }))) await c.put(req, res.clone()) + } + return res + }) + net.then(() => { downUntil = 0 }, () => {}) + const hit = await cached() + if (!hit) return net // sin copia: solo queda esperar a la red + // Si hace poco que el servidor no contestaba, la copia directamente (la red sigue intentándolo). + if (Date.now() < downUntil) return fromCache(hit) + let timer + const timeout = new Promise(r => { timer = setTimeout(() => r(null), NET_WAIT) }) + const res = await Promise.race([net.catch(() => null), timeout]) + clearTimeout(timer) + if (!res) downUntil = Date.now() + DOWN_FOR + return res || fromCache(hit) +} + +self.addEventListener('fetch', e => { + const req = e.request + if (req.method !== 'GET') return + const url = new URL(req.url) + if (url.origin !== location.origin) return + const p = url.pathname + if (req.mode === 'navigate') return e.respondWith(networkFirst(req, SHELL, { fallback: '/' })) + if (SHELL_FILES.includes(p) || p === '/sw.js') return e.respondWith(networkFirst(req, SHELL, { ignoreSearch: true, single: true })) + if (p === '/api/me') return e.respondWith(networkFirst(req, SHELL)) + // Ficha y capítulo: solo se guardan si el usuario los guardó (store: 'existing' = refrescar si ya estaban). + 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/') || p.startsWith('/api/book/')) { + e.respondWith(caches.open(OFFLINE).then(c => c.match(req)).then(hit => hit || fetch(req))) + } +}) diff --git a/app/security.js b/app/security.js new file mode 100644 index 0000000..adad7f0 --- /dev/null +++ b/app/security.js @@ -0,0 +1,148 @@ +// Seguridad para exponer el servidor a internet: IP real del cliente (detrás de un proxy de +// confianza), baneo de IP tras varios intentos fallidos, cabeceras de seguridad y protección SSRF +// del proxy de imágenes. +import net from 'node:net' +import dns from 'node:dns/promises' + +const MAX_FAILS = Number(process.env.BAN_MAX_FAILS || 6) // intentos fallidos… +const FAIL_WINDOW = Number(process.env.BAN_WINDOW_MIN || 60) * 60e3 // …dentro de esta ventana +const BAN_MS = Number(process.env.BAN_HOURS || 24) * 3600e3 // duración del baneo +// Cabecera con la IP real que pone el proxy (p. ej. x-forwarded-for, cf-connecting-ip, x-real-ip). +// Solo se cree si la conexión viene de una IP privada (el proxy), nunca directamente de internet. +const REAL_IP_HEADER = (process.env.REAL_IP_HEADER || '').toLowerCase().trim() + +const PRIVATE_V4 = [['10.0.0.0', 8], ['127.0.0.0', 8], ['169.254.0.0', 16], ['172.16.0.0', 12], ['192.168.0.0', 16], ['100.64.0.0', 10], ['0.0.0.0', 8]] +const v4num = ip => ip.split('.').reduce((n, x) => n * 256 + Number(x), 0) +export function isPrivateIp(ip) { + if (!ip) return true + ip = ip.replace(/^::ffff:/i, '') + if (net.isIPv4(ip)) { + const n = v4num(ip) + return PRIVATE_V4.some(([base, bits]) => Math.floor(n / 2 ** (32 - bits)) === Math.floor(v4num(base) / 2 ** (32 - bits))) + } + const l = ip.toLowerCase() + return l === '::1' || l === '::' || /^f[cd]/.test(l) || /^fe[89ab]/.test(l) +} +// IPs que nunca se banean: loopback, red local, Tailscale y lo que se añada en BAN_IGNORE. +const IGNORE = (process.env.BAN_IGNORE || '').split(',').map(s => s.trim()).filter(Boolean) +const neverBan = ip => isPrivateIp(ip) || IGNORE.includes(ip) + +export function setupSecurity({ db }) { + db.exec(` + CREATE TABLE IF NOT EXISTS ip_bans ( + ip TEXT PRIMARY KEY, + until INTEGER NOT NULL, + reason TEXT, + created_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS ip_fails ( + ip TEXT NOT NULL, + at INTEGER NOT NULL, + what TEXT + ); + CREATE INDEX IF NOT EXISTS idx_ip_fails ON ip_fails(ip, at); + `) + const prune = () => { + db.prepare('DELETE FROM ip_bans WHERE until < ?').run(Date.now()) + db.prepare('DELETE FROM ip_fails WHERE at < ?').run(Date.now() - Math.max(FAIL_WINDOW, 24 * 3600e3)) + } + prune() + setInterval(prune, 3600e3).unref() + + function clientIp(req) { + const direct = (req.socket.remoteAddress || '').replace(/^::ffff:/i, '') + if (REAL_IP_HEADER && isPrivateIp(direct)) { + // x-forwarded-for: "cliente, proxy1, proxy2". Cada proxy añade al final la IP que le habló, así + // que se recorre desde la derecha saltando los proxies propios (IPs privadas: VPS por Tailscale, + // Nginx Proxy Manager en Docker…). La primera pública es el cliente real; lo que haya más a la + // izquierda lo pudo escribir el propio cliente y no se cree. + const chain = String(req.headers[REAL_IP_HEADER] || '').split(',').map(s => s.trim().replace(/^::ffff:/i, '')).filter(s => net.isIP(s)) + const ip = chain.findLast(s => !isPrivateIp(s)) ?? chain[0] + if (ip) return ip + } + return direct + } + // HTTPS real (directo o por el proxy de confianza): cookie Secure y HSTS. + const isHttps = req => !!req.socket.encrypted || (isPrivateIp(req.socket.remoteAddress) && String(req.headers['x-forwarded-proto'] || '').split(',')[0].trim() === 'https') + + const banned = ip => db.prepare('SELECT until FROM ip_bans WHERE ip = ? AND until > ?').get(ip, Date.now()) + // Intento fallido (contraseña incorrecta, registro cerrado…). Al llegar a MAX_FAILS, baneo. + function fail(ip, what) { + if (neverBan(ip)) return + const now = Date.now() + db.prepare('INSERT INTO ip_fails (ip, at, what) VALUES (?, ?, ?)').run(ip, now, what) + const n = db.prepare('SELECT COUNT(*) n FROM ip_fails WHERE ip = ? AND at > ?').get(ip, now - FAIL_WINDOW).n + if (n >= MAX_FAILS) { + db.prepare(`INSERT INTO ip_bans (ip, until, reason, created_at) VALUES (?, ?, ?, ?) + ON CONFLICT (ip) DO UPDATE SET until = excluded.until, reason = excluded.reason, created_at = excluded.created_at`) + .run(ip, now + BAN_MS, `${n} intentos fallidos (${what})`, now) + db.prepare('DELETE FROM ip_fails WHERE ip = ?').run(ip) + console.warn(`Seguridad: IP ${ip} baneada ${BAN_MS / 3600e3} h tras ${n} intentos fallidos (${what})`) + } + } + const success = ip => db.prepare('DELETE FROM ip_fails WHERE ip = ?').run(ip) + const remaining = ip => (neverBan(ip) ? null : Math.max(0, MAX_FAILS - db.prepare('SELECT COUNT(*) n FROM ip_fails WHERE ip = ? AND at > ?').get(ip, Date.now() - FAIL_WINDOW).n)) + + // Cabeceras para todas las respuestas. + function headers(req, res) { + res.setHeader('X-Content-Type-Options', 'nosniff') + res.setHeader('X-Frame-Options', 'DENY') + res.setHeader('Referrer-Policy', 'same-origin') + res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=()') + res.setHeader('Cross-Origin-Opener-Policy', 'same-origin') + res.setHeader('Content-Security-Policy', [ + "default-src 'self'", "script-src 'self'", "style-src 'self' 'unsafe-inline'", "img-src 'self' data: blob:", + "connect-src 'self'", "worker-src 'self'", "manifest-src 'self'", "font-src 'self'", + "object-src 'none'", "base-uri 'none'", "form-action 'self'", "frame-ancestors 'none'", + ].join('; ')) + if (isHttps(req)) res.setHeader('Strict-Transport-Security', 'max-age=15552000') + } + + function route(p, method, req, res, json) { + if (p === '/api/bans' && method === 'GET') { + return json(res, 200, { + bans: db.prepare('SELECT ip, until, reason, created_at FROM ip_bans WHERE until > ? ORDER BY created_at DESC').all(Date.now()), + fails: db.prepare('SELECT ip, COUNT(*) n, MAX(at) last FROM ip_fails WHERE at > ? GROUP BY ip ORDER BY last DESC LIMIT 50').all(Date.now() - FAIL_WINDOW), + maxFails: MAX_FAILS, banHours: BAN_MS / 3600e3, windowMin: FAIL_WINDOW / 60e3, trustedHeader: REAL_IP_HEADER || null, + you: { ip: clientIp(req), protected: neverBan(clientIp(req)) }, + }), true + } + const m = /^\/api\/bans\/([^/]+)$/.exec(p) + if (m && method === 'DELETE') { + const ip = decodeURIComponent(m[1]) + db.prepare('DELETE FROM ip_bans WHERE ip = ?').run(ip) + db.prepare('DELETE FROM ip_fails WHERE ip = ?').run(ip) + return json(res, 200, { ok: true }), true + } + return false + } + + return { clientIp, isHttps, banned, fail, success, remaining, headers, route } +} + +// ---------- SSRF: el proxy de imágenes no debe alcanzar la red interna ---------- +const dnsCache = new Map() +async function hostIsPublic(hostname) { + if (net.isIP(hostname)) return !isPrivateIp(hostname) + if (/^(localhost|.*\.local|.*\.internal|.*\.lan)$/i.test(hostname)) return false + const hit = dnsCache.get(hostname) + if (hit && hit.exp > Date.now()) return hit.ok + const addrs = await dns.lookup(hostname, { all: true }).catch(() => []) + 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 +} +// fetch que solo va a hosts públicos y comprueba también cada redirección. +export async function safeFetch(url, opts = {}, redirects = 4) { + for (let i = 0; ; i++) { + const u = new URL(url) + if (!/^https?:$/.test(u.protocol) || !(await hostIsPublic(u.hostname))) throw new Error('Host no permitido') + const r = await fetch(u, { ...opts, redirect: 'manual' }) + const loc = r.status >= 300 && r.status < 400 && r.headers.get('location') + if (!loc) return r + if (i >= redirects) throw new Error('Demasiadas redirecciones') + url = new URL(loc, u).toString() + } +} diff --git a/app/server.js b/app/server.js new file mode 100644 index 0000000..218ded3 --- /dev/null +++ b/app/server.js @@ -0,0 +1,1222 @@ +// Manga Reader: lector web con fuentes MangaDex, LeerCapitulo y M440 +// con usuarios y historial de lectura persistente en SQLite. +import http from 'node:http' +import fs from 'node:fs' +import path from 'node:path' +import crypto from 'node:crypto' +import { DatabaseSync } from 'node:sqlite' +import { writeCbz, readCbzIndex, sendCbzEntry, imageType } from './cbz.js' +import { setupFeatures } from './features.js' +import { setupUpscale } from './upscale.js' +import { setupClips } from './clips.js' +import { setupCovers } from './covers.js' +import { setupLibrary } from './library.js' +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' +const MANGA_DIR = process.env.MANGA_DIR || '/mangas' +const ALLOW_SIGNUP = process.env.ALLOW_SIGNUP === 'true' // cerrado salvo que se pida (el primer usuario siempre puede) +const PUBLIC_DIR = path.resolve(import.meta.dirname, 'public') +const MD_API = 'https://api.mangadex.org' +const LC_BASE = 'https://www.leercapitulo.co' +const BROWSER_UA = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Safari/537.36' +const UA = 'manga-reader-selfhosted/1.0' +const SESSION_DAYS = 120 + +// ---------- Base de datos ---------- +// Comprobación de permisos al arrancar: con un UID distinto en el host, el error de SQLite sería críptico. +function checkWritable(dir, fatal) { + try { + fs.mkdirSync(dir, { recursive: true }) + const probe = path.join(dir, `.write-test-${process.pid}`) + fs.writeFileSync(probe, '') + fs.rmSync(probe) + return true + } catch (e) { + const uid = process.getuid?.(), gid = process.getgid?.() + console.error(`${fatal ? 'ERROR' : 'AVISO'}: no se puede escribir en ${dir} (${e.code || e.message}) como uid ${uid}:${gid}. + Solución: en el host, "sudo chown -R ${uid}:${gid} " o pon PUID/PGID en .env con el dueño de la carpeta (id -u / id -g).`) + if (fatal) process.exit(1) + return false + } +} +checkWritable(DATA_DIR, true) +checkWritable(MANGA_DIR, false) // sin esto solo fallan las descargas +const db = new DatabaseSync(path.join(DATA_DIR, 'manga.db')) +const SECRET_FILE = path.join(DATA_DIR, 'secret') +if (!fs.existsSync(SECRET_FILE)) fs.writeFileSync(SECRET_FILE, crypto.randomBytes(32).toString('hex'), { mode: 0o600 }) +const SECRET = fs.readFileSync(SECRET_FILE, 'utf8').trim() +db.exec(` +PRAGMA journal_mode = WAL; +PRAGMA foreign_keys = ON; +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL COLLATE NOCASE, + pass_hash TEXT NOT NULL, + lang TEXT NOT NULL DEFAULT 'es-la', + created_at INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS sessions ( + token TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS progress ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + manga_id TEXT NOT NULL, + manga_title TEXT NOT NULL, + cover TEXT, + chapter_id TEXT NOT NULL, + chapter_num TEXT, + chapter_title TEXT, + lang TEXT, + page INTEGER NOT NULL DEFAULT 0, + total_pages INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL, + PRIMARY KEY (user_id, manga_id) +); +CREATE TABLE IF NOT EXISTS chapter_reads ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + chapter_id TEXT NOT NULL, + manga_id TEXT NOT NULL, + page INTEGER NOT NULL DEFAULT 0, + total_pages INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL, + PRIMARY KEY (user_id, chapter_id) +); +CREATE INDEX IF NOT EXISTS idx_reads_manga ON chapter_reads(user_id, manga_id); +CREATE TABLE IF NOT EXISTS downloads ( + manga_id TEXT PRIMARY KEY, + title TEXT NOT NULL, + folder TEXT NOT NULL UNIQUE, + lang TEXT, + status TEXT NOT NULL, -- queued | downloading | done | error | cancelled + done INTEGER NOT NULL DEFAULT 0, + total INTEGER NOT NULL DEFAULT 0, + failed INTEGER NOT NULL DEFAULT 0, + error TEXT, + meta TEXT, -- JSON de la ficha, para abrirla sin conexión + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS downloaded_chapters ( + chapter_id TEXT PRIMARY KEY, + manga_id TEXT NOT NULL REFERENCES downloads(manga_id) ON DELETE CASCADE, + num TEXT, + title TEXT, + file TEXT NOT NULL, + pages INTEGER NOT NULL, + created_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_dlch_manga ON downloaded_chapters(manga_id); +`) +db.prepare('DELETE FROM sessions WHERE expires_at < ?').run(Date.now()) +// Descargas parciales: scope 'some' baja solo los capítulos pedidos (download_wanted); 'all', el manga entero. +if (!db.prepare('PRAGMA table_info(downloads)').all().some(c => c.name === 'scope')) db.exec("ALTER TABLE downloads ADD COLUMN scope TEXT NOT NULL DEFAULT 'all'") +db.exec(`CREATE TABLE IF NOT EXISTS download_wanted ( + chapter_id TEXT PRIMARY KEY, + manga_id TEXT NOT NULL REFERENCES downloads(manga_id) ON DELETE CASCADE, + created_at INTEGER NOT NULL +)`) +// Usuarios de cada manga descargado: quién lo pidió (descarga, reescalado, descarga anticipada) o lo +// empezó a leer ya descargado. Colas y "Biblioteca descargada" muestran solo lo de cada usuario; los +// archivos son uno para todos, y se borran cuando ya no lo tiene nadie. +db.exec(`CREATE TABLE IF NOT EXISTS download_users ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + manga_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + PRIMARY KEY (user_id, manga_id) +)`) +const linkDownload = (userId, mangaId) => db.prepare('INSERT OR IGNORE INTO download_users (user_id, manga_id, created_at) VALUES (?, ?, ?)').run(userId, mangaId, Date.now()) +const sharedWith = (userId, mangaId) => !!db.prepare('SELECT 1 FROM download_users WHERE manga_id = ? AND user_id != ?').get(mangaId, userId) +const MY_DOWNLOADS = 'manga_id IN (SELECT manga_id FROM download_users WHERE user_id = ?)' +// finished_at: cuándo se terminó la última página del último capítulo disponible (null = en curso). +if (!db.prepare('PRAGMA table_info(progress)').all().some(c => c.name === 'finished_at')) db.exec('ALTER TABLE progress ADD COLUMN finished_at INTEGER') + +// ---------- Contraseñas y sesiones ---------- +function hashPassword(pw) { + const salt = crypto.randomBytes(16) + const hash = crypto.scryptSync(pw, salt, 64) + return `${salt.toString('hex')}:${hash.toString('hex')}` +} +function verifyPassword(pw, stored) { + const [saltHex, hashHex] = stored.split(':') + const hash = crypto.scryptSync(pw, Buffer.from(saltHex, 'hex'), 64) + return crypto.timingSafeEqual(hash, Buffer.from(hashHex, 'hex')) +} +function createSession(userId) { + const token = crypto.randomBytes(32).toString('base64url') + db.prepare('INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)') + .run(token, userId, Date.now() + SESSION_DAYS * 86400e3) + return token +} +function sessionCookie(token, maxAge, secure = false) { + return `sid=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}${secure ? '; Secure' : ''}` +} +// Hash de una contraseña cualquiera para que un usuario inexistente tarde lo mismo que uno real. +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, 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) { + const hit = cache.get(key) + if (hit && hit.exp > Date.now()) return hit.data + let data + try { + data = await fn() + } catch (e) { + // Fuente caída (p. ej. 522 de Cloudflare en LeerCapitulo): mejor lo último que se tenía que un error. + if (hit && (e.status ?? 500) >= 500) { console.warn(`Usando copia anterior de ${key}: ${e.message}`); return hit.data } + throw e + } + cache.delete(key) + cache.set(key, { data, exp: Date.now() + ttl }) + if (cache.size > 500) cache.delete(cache.keys().next().value) + return data +} + +// Las imágenes pasan por /api/img firmadas con HMAC: el proxy solo descarga URLs generadas por este servidor. +const sign = u => crypto.createHmac('sha256', SECRET).update(u).digest('base64url').slice(0, 22) +const imgUrl = u => `/api/img?u=${encodeURIComponent(u)}&s=${sign(u)}` +// Portadas: &m= deja al proxy servir (y generar) la versión reescalada si el manga está en el +// historial o en seguidos del usuario; las demás (búsquedas) se sirven tal cual. +const coverUrl = (u, mangaId) => `${imgUrl(u)}&m=${encodeURIComponent(mangaId)}` + +// Conexión a internet del servidor (app/connectivity.js). Sin ella, las fuentes fallan al instante. +const net = setupConnectivity({ httpError: (status, message) => new HttpError(status, message) }) + +async function md(pathname, params = {}, ttl = 5 * 60e3) { + const url = new URL(MD_API + pathname) + for (const [k, v] of Object.entries(params)) { + if (Array.isArray(v)) v.forEach(x => url.searchParams.append(k, x)) + else if (v !== undefined && v !== '') url.searchParams.set(k, v) + } + return cached(url.toString(), ttl, async () => { + net.guard() + const res = await fetch(url, { headers: { 'User-Agent': UA }, signal: AbortSignal.timeout(30e3) }).catch(() => { net.failed(); throw new HttpError(502, 'No se pudo conectar con MangaDex') }) + if (!res.ok) throw new HttpError(502, `MangaDex respondió ${res.status}`) + return res.json() + }) +} + +const pick = (obj, langs) => { + if (!obj) return '' + for (const l of langs) if (obj[l]) return obj[l] + return Object.values(obj)[0] ?? '' +} +function mangaSummary(m, lang) { + const a = m.attributes + const langs = [lang, 'es', 'es-la', 'en', 'ja-ro'] + let title = a.title.en ?? Object.values(a.title)[0] + const alt = (a.altTitles || []).find(t => t[lang] || t.es || t['es-la']) + if (alt) title = pick(alt, [lang, 'es-la', 'es']) + const coverRel = m.relationships?.find(r => r.type === 'cover_art') + const cover = coverRel?.attributes?.fileName + ? coverUrl(`https://uploads.mangadex.org/covers/${m.id}/${coverRel.attributes.fileName}.512.jpg`, m.id) + : null + return { + id: m.id, + source: 'md', + title, + description: pick(a.description, langs), + status: a.status, + year: a.year, + tags: (a.tags || []).map(t => ({ id: t.id, name: pick(t.attributes.name, ['es', 'en']) })).slice(0, 12), + authors: (m.relationships || []).filter(r => r.type === 'author' && r.attributes).map(r => r.attributes.name), + languages: a.availableTranslatedLanguages || [], + type: { ja: 'Manga', ko: 'Manhwa', zh: 'Manhua', 'zh-hk': 'Manhua' }[a.originalLanguage] || 'Cómic', + cover, + } +} +const RATINGS = ['safe', 'suggestive'] + +const PAGE_SIZE = 30 +async function searchManga(q, lang, mode, tag, page = 1) { + const params = { + limit: PAGE_SIZE, + offset: (page - 1) * PAGE_SIZE, + 'includedTags[]': tag ? [tag] : undefined, + 'includes[]': ['cover_art'], + 'contentRating[]': RATINGS, + 'availableTranslatedLanguage[]': lang ? [lang] : undefined, + } + if (q) { params.title = q; params['order[relevance]'] = 'desc' } + else if (mode === 'latest') params['order[latestUploadedChapter]'] = 'desc' + else params['order[followedCount]'] = 'desc' + const data = await md('/manga', params) + return data.data.map(m => mangaSummary(m, lang)) +} + +const MD_GROUPS = { genre: 'Géneros', theme: 'Temáticas', format: 'Formato', content: 'Contenido' } +async function mdCategories() { + const data = await md('/manga/tag', {}, 24 * 3600e3) + return data.data + .map(t => ({ id: t.id, name: pick(t.attributes.name, ['es', 'en']), group: MD_GROUPS[t.attributes.group] || t.attributes.group })) + .sort((a, b) => a.group.localeCompare(b.group) || a.name.localeCompare(b.name)) +} + +async function getManga(id, lang) { + const data = await md(`/manga/${id}`, { 'includes[]': ['cover_art', 'author'] }, 30 * 60e3) + return mangaSummary(data.data, lang) +} + +// Lista de capítulos en un idioma, un registro por número de capítulo (como Tanko). +// El español junta es-la y es, prefiriendo la variante elegida. +async function getChapters(mangaId, lang) { + const langs = lang === 'es-la' ? ['es-la', 'es'] : lang === 'es' ? ['es', 'es-la'] : [lang] + const all = [] + for (let offset = 0; ; offset += 500) { + const data = await md(`/manga/${mangaId}/feed`, { + limit: 500, offset, + 'translatedLanguage[]': langs, + 'order[volume]': 'asc', 'order[chapter]': 'asc', + 'contentRating[]': [...RATINGS, 'erotica'], + includeExternalUrl: 0, includeEmptyPages: 0, includeFuturePublishAt: 0, + 'includes[]': ['scanlation_group'], + }) + all.push(...data.data) + if (offset + 500 >= data.total || data.data.length === 0) break + } + const byNum = new Map() + for (const c of all) { + const num = c.attributes.chapter ?? `oneshot-${c.id}` + const prev = byNum.get(num) + if (prev && langs.indexOf(prev.lang) <= langs.indexOf(c.attributes.translatedLanguage)) continue + byNum.set(num, { + id: c.id, + num: c.attributes.chapter, + volume: c.attributes.volume, + title: c.attributes.title || '', + pages: c.attributes.pages, + group: c.relationships.find(r => r.type === 'scanlation_group')?.attributes?.name || '', + publishAt: c.attributes.publishAt, + lang: c.attributes.translatedLanguage, + }) + } + const out = [...byNum.values()] + out.sort((a, b) => (parseFloat(a.num) || 0) - (parseFloat(b.num) || 0)) + return out +} + +// Idiomas con capítulos legibles de verdad (availableTranslatedLanguages incluye capítulos retirados o externos). +async function getChapterLangs(mangaId) { + const data = await md(`/manga/${mangaId}/feed`, { + limit: 500, includeExternalUrl: 0, includeEmptyPages: 0, includeFuturePublishAt: 0, + 'contentRating[]': [...RATINGS, 'erotica'], + }, 30 * 60e3) + const count = {} + for (const c of data.data) count[c.attributes.translatedLanguage] = (count[c.attributes.translatedLanguage] || 0) + 1 + return Object.entries(count).sort((a, b) => b[1] - a[1]).map(([l]) => l) +} + +async function getPageUrls(chapterId, saver, ttl = 5 * 60e3) { + // Las URLs de at-home caducan a los ~15 min: caché corta. + const data = await md(`/at-home/server/${chapterId}`, {}, ttl) + const { baseUrl, chapter } = data + const files = saver ? chapter.dataSaver : chapter.data + const kind = saver ? 'data-saver' : 'data' + return files.map(f => `${baseUrl}/${kind}/${chapter.hash}/${f}`) +} +const getPages = async (chapterId, saver) => (await getPageUrls(chapterId, saver)).map(imgUrl) + +// ---------- Cliente LeerCapitulo (Tanko src/servers/leerCapitulo.ts, adaptado al sitio actual) ---------- +// IDs: manga "lc~~", capítulo "lc~~~". Ya no hace falta navegador: +// las páginas del capítulo vienen en el HTML como . +const ENTITIES = { + amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', lsaquo: '‹', rsaquo: '›', laquo: '«', raquo: '»', iexcl: '¡', iquest: '¿', + hellip: '…', mdash: '—', ndash: '–', ldquo: '“', rdquo: '”', lsquo: '‘', rsquo: '’', middot: '·', deg: '°', copy: '©', reg: '®', trade: '™', + aacute: 'á', eacute: 'é', iacute: 'í', oacute: 'ó', uacute: 'ú', ntilde: 'ñ', uuml: 'ü', ccedil: 'ç', agrave: 'à', egrave: 'è', ograve: 'ò', + Aacute: 'Á', Eacute: 'É', Iacute: 'Í', Oacute: 'Ó', Uacute: 'Ú', Ntilde: 'Ñ', Uuml: 'Ü', Ccedil: 'Ç', +} +const decode = s => String(s ?? '').replace(/<[^>]+>/g, '').replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (all, e) => { + if (e[0] !== '#') return ENTITIES[e] ?? ENTITIES[e.toLowerCase()] ?? 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) : all +}).trim() +async function lc(pathname, ttl = 10 * 60e3, asJson = false) { + return cached(`lc:${pathname}`, ttl, async () => { + // Cloudflare de LeerCapitulo da 52x/503 a ratos cuando su servidor no responde: hasta 3 intentos. + for (let i = 0; ; i++) { + net.guard() + const res = await fetch(LC_BASE + pathname, { headers: { 'User-Agent': BROWSER_UA, 'Accept-Language': 'es' }, signal: AbortSignal.timeout(30e3) }) + .catch(() => { net.failed(); return null }) + if (res?.ok) return asJson ? res.json() : res.text() + if (res?.status === 404) throw new HttpError(404, 'LeerCapitulo respondió 404') + const down = !res || res.status >= 500 + if (!down || i >= 2) { + throw new HttpError(502, down ? `LeerCapitulo no responde ahora mismo${res ? ` (error ${res.status} de su servidor)` : ''}. Prueba en un rato.` : `LeerCapitulo respondió ${res.status}`) + } + await new Promise(r => setTimeout(r, 1500 * (i + 1))) + } + }) +} +const lcCover = (src, id) => (src ? coverUrl(new URL(src, LC_BASE).toString(), id) : null) +const lcId = uri => { + const m = /\/manga\/([a-z0-9]+)\/([a-z0-9-]+)\//.exec(uri || '') + return m ? `lc~${m[1]}~${m[2]}` : null +} +function lcParseCards(html) { + const re = /class="lc-(?:slide|side-item|release|card)(?:-cover)?" href="(\/manga\/[a-z0-9]+\/[a-z0-9-]+\/)"[^>]*>\s*]*>([^<]+)/g + const out = [] + const seen = new Set() + for (const m of html.matchAll(re)) { + const id = lcId(m[1]) + if (!id || seen.has(id)) continue + seen.add(id) + out.push({ id, source: 'lc', title: decode(m[3]), cover: lcCover(m[2], id) }) + } + return out +} +const LC_TAG = /^(genre|theme):([a-z0-9-]+)$/ +async function lcCategories() { + const html = await lc('/manga/', 24 * 3600e3) + const out = [] + const seen = new Set() + for (const m of html.matchAll(/(?:href|value)="\/manga\/\?(genre|theme)=([a-z0-9-]+)"[^>]*>([^<]+) a.group.localeCompare(b.group) || a.name.localeCompare(b.name)) +} +async function lcSearch(q, mode, tag, page = 1) { + const t = LC_TAG.exec(tag || '') + if (t) { + const html = await lc(`/manga/?${t[1]}=${t[2]}${q ? `&q=${encodeURIComponent(q)}` : ''}${page > 1 ? `&page=${page}` : ''}`) + return lcParseCards(html.slice(html.indexOf(''))) + } + if (q) { + const list = await lc(`/search/?q=${encodeURIComponent(q)}`, 10 * 60e3, true) + return list.map(e => ({ id: lcId(e.uri), source: 'lc', title: decode(e.name), cover: lcCover(e.cover_uri, lcId(e.uri)), sub: e.last_chapter_name })) + .filter(e => e.id) + } + const html = await lc('/') + const section = (from, to) => { + const i = html.indexOf(from) + if (i < 0) return '' + const j = html.indexOf(to, i + from.length) + return html.slice(i, j < 0 ? undefined : j) + } + if (mode === 'latest') return lcParseCards(section('

      Ultimos capitulos', 'Tendencias', '')), ...lcParseCards(section('>Populares

      ', 'lc-panel-title'))] + return popular.filter((m, i) => popular.findIndex(x => x.id === m.id) === i) +} +async function lcManga(id) { + const [, mid, slug] = id.split('~') + const html = await lc(`/manga/${mid}/${slug}/`) + const fact = k => decode(new RegExp(`${k}\\s*<(?:a|span)[^>]*>([^<]*)<`).exec(html)?.[1]) + const sinopsis = /id="sinopsis"[\s\S]*?]*>([\s\S]*?)<\/p>/.exec(html)?.[1] + const chapters = [] + const seen = new Set() + const re = new RegExp(`href="/leer/${mid}/${slug}/([0-9a-z.-]+)/">\\s*([^<]*)[\\s\\S]*?([^<]*)`, 'g') + for (const m of html.matchAll(re)) { + if (seen.has(m[1])) continue + seen.add(m[1]) + const title = decode(m[2]).replace(/^Cap[ií]tulo\s+[0-9.]+\s*[:-]?\s*/i, '') + chapters.push({ id: `${id}~${m[1]}`, num: m[1], volume: null, title, pages: null, group: m[3], publishAt: m[3] }) + } + chapters.sort((a, b) => (parseFloat(a.num) || 0) - (parseFloat(b.num) || 0)) + const manga = { + id, + source: 'lc', + title: decode(/]*>([^<]+)/.exec(html)?.[1]) || slug, + description: decode(sinopsis?.replace(//g, '\n')), + status: fact('Estado'), + type: fact('Tipo') || null, + year: null, + tags: [...html.matchAll(/class="badge [^"]*" href="\/manga\/\?(genre|theme)=([a-z0-9-]+)">([^<]+) ({ id: `${m[1]}:${m[2]}`, name: decode(m[3]) })).slice(0, 12), + authors: fact('Autor') ? [fact('Autor')] : [], + languages: ['es'], + cover: lcCover(/class="lc-cover-lg">\s* decode(m[1])) + if (!pages.length) throw new HttpError(502, 'No se encontraron páginas en LeerCapitulo') + return pages +} +const lcPages = async chapterId => (await lcPageUrls(chapterId)).map(imgUrl) + +// ---------- M440 (app/m440.js) ---------- +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' : /^(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. +// isCover: portada a servir en HD; coverish: portada sin HD (caché corta por si luego se sigue el manga); +// banner: imagen de fondo de la ficha, siempre en HD. +async function proxyImage(req, res, u, s, isCover = false, coverish = false, banner = false) { + let url + try { url = new URL(u) } catch { throw new HttpError(400, 'URL inválida') } + const expected = sign(u) + if (typeof s !== 'string' || s.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected))) throw new HttpError(403, 'Firma inválida') + if (!/^https?:$/.test(url.protocol) || /^(localhost|\d+\.\d+\.\d+\.\d+|\[.*\])$/.test(url.hostname)) throw new HttpError(403, 'Host no permitido') + const hdKey = banner ? bannerKey(u) : isCover ? u : null + if (hdKey && covers.sendCached(res, hdKey)) return + // Los CDN de las fuentes fallan a ratos (520, conexión cortada, respuestas que no llegan): hasta 3 + // intentos con tiempo límite, para no dejar al navegador esperando para siempre. + net.guard() // sin internet: fallar ya (la portada local entra como respaldo), sin reintentos + const r = await withRetry(async () => { + const r = await fetchImage(url, AbortSignal.timeout(60e3)) + if (r.status >= 500) throw new HttpError(502, `Imagen ${r.status}`) + return r + }).catch(e => { throw e instanceof HttpError ? e : new HttpError(502, `Imagen: ${e.message}`) }) + if (!r.ok || !r.body) throw new HttpError(502, `Imagen ${r.status}`) + if (hdKey) { + const buf = Buffer.from(await r.arrayBuffer()) + res.writeHead(200, { 'Content-Type': r.headers.get('content-type') || 'image/jpeg', 'Cache-Control': covers.pendingCache(banner) }) + res.end(buf) + return covers.enqueue(hdKey, buf, banner) + } + res.writeHead(200, { + 'Content-Type': r.headers.get('content-type') || 'image/jpeg', + 'Cache-Control': coverish ? 'private, max-age=3600' : 'private, max-age=604800, immutable', + }) + for await (const chunk of r.body) res.write(chunk) + res.end() +} + +const bannerKey = u => `banner:${u}` +// Reescala un banner sin esperar a que alguien abra la ficha (recibe la URL del proxy, como se guarda). +async function prepareBanner(proxyUrl) { + const u = rawFromProxy(proxyUrl) + if (!u || !covers.wants(bannerKey(u))) return + const r = await fetchImage(new URL(u), AbortSignal.timeout(60e3)) + if (r.ok) covers.enqueue(bannerKey(u), Buffer.from(await r.arrayBuffer()), true) +} + +function fetchImage(url, signal) { + net.guard() + const host = new URL(url).hostname + const isMd = /(^|\.)mangadex\.(org|network)$/.test(host) + const referer = isMd ? null : /(^|\.)m440\.in$/.test(host) ? MI_BASE + '/' : LC_BASE + '/' + return safeFetch(url, { signal, headers: { 'User-Agent': isMd ? UA : BROWSER_UA, ...(referer ? { Referer: referer } : {}) } }) + .catch(e => { if (e.message !== 'Host no permitido') net.failed(); throw e }) +} + +// ---------- Descargas a MANGA_DIR//Capitulo NNNN.cbz ---------- +const sleep = ms => new Promise(r => setTimeout(r, ms)) +const safeName = s => String(s).normalize('NFC').replace(/[\/\\:*?"<>|\x00-\x1f]/g, ' ').replace(/\s+/g, ' ').replace(/^[.\s]+|[.\s]+$/g, '').slice(0, 150) || 'Sin titulo' +const xmlEsc = s => String(s ?? '').replace(/[<>&"']/g, c => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' }[c])) +const rawFromProxy = u => (u?.startsWith('/api/img?') ? new URLSearchParams(u.slice(9)).get('u') : null) +const chapterFileName = c => { + if (!c.num) return `Oneshot ${c.id.slice(-8)}.cbz` + const [int, dec] = String(c.num).split('.') + return safeName(`Capitulo ${int.padStart(4, '0')}${dec ? '.' + dec : ''}`) + '.cbz' +} +async function withRetry(fn, tries = 3) { + for (let i = 1; ; i++) { + try { return await fn() } catch (e) { if (i >= tries) throw e; await sleep(1500 * i) } + } +} +async function downloadBuffer(url) { + return withRetry(async () => { + const r = await fetchImage(url, AbortSignal.timeout(60e3)) + if (!r.ok) throw new Error(`HTTP ${r.status} en ${url}`) + return { data: Buffer.from(await r.arrayBuffer()), type: r.headers.get('content-type') || '' } + }) +} +const EXT_BY_TYPE = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/webp': 'webp', 'image/gif': 'gif', 'image/avif': 'avif' } + +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) } +} + +// Progreso del capítulo en curso (solo en memoria): manga_id -> { chapter, pages, pagesTotal } +const dlCurrent = new Map() + +async function downloadChapter(dl, manga, c, dir) { + dlCurrent.set(dl.manga_id, { chapter: c.num, pages: 0, pagesTotal: 0 }) + const urls = await withRetry(() => (manga.source === 'lc' ? lcPageUrls(c.id) : manga.source === 'mi' ? mi.pageUrls(c.id) : getPageUrls(c.id, false, 0))) + dlCurrent.set(dl.manga_id, { chapter: c.num, pages: 0, pagesTotal: urls.length }) + const entries = [] + // 4 imágenes en paralelo. + for (let i = 0; i < urls.length; i += 4) { + const batch = await Promise.all(urls.slice(i, i + 4).map(downloadBuffer)) + batch.forEach(({ data, type }, k) => { + const n = i + k + const ext = EXT_BY_TYPE[type.split(';')[0]] || urls[n].split('?')[0].split('.').pop().toLowerCase().replace(/[^a-z]/g, '') || 'jpg' + entries.push({ name: `${String(n + 1).padStart(3, '0')}.${ext}`, data }) + }) + dlCurrent.set(dl.manga_id, { chapter: c.num, pages: entries.length, pagesTotal: urls.length }) + if (isCancelled(dl.manga_id)) throw new Error('cancelada') + } + entries.push({ name: 'ComicInfo.xml', data: Buffer.from(` + + ${xmlEsc(manga.title)} + ${c.num ? `${xmlEsc(c.num)}` : ''} + ${c.volume ? `${xmlEsc(c.volume)}` : ''} + ${c.title ? `${xmlEsc(c.title)}` : ''} + ${xmlEsc(manga.description)} + ${xmlEsc(manga.authors.join(', '))} + ${xmlEsc(manga.tags.map(t => t.name ?? t).join(', '))} + ${xmlEsc((dl.lang || 'es').split('-')[0])} + ${urls.length} + YesAndRightToLeft + +`) }) + const file = chapterFileName(c) + writeCbz(path.join(dir, file), entries) + if (!db.prepare('SELECT 1 FROM downloads WHERE manga_id = ?').get(dl.manga_id)) { + // La copia local se eliminó mientras se descargaba este capítulo. + fs.rmSync(path.join(dir, file), { force: true }) + throw new Error('eliminada') + } + db.prepare(`INSERT OR REPLACE INTO downloaded_chapters (chapter_id, manga_id, num, title, file, pages, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`) + .run(c.id, dl.manga_id, c.num ?? null, c.title || '', file, urls.length, Date.now()) + db.prepare('DELETE FROM download_wanted WHERE chapter_id = ?').run(c.id) + upscale.onChapterDownloaded(dl.manga_id, c.id) +} + +const dlUpdate = (mangaId, fields) => { + const keys = Object.keys(fields) + db.prepare(`UPDATE downloads SET ${keys.map(k => `${k} = ?`).join(', ')}, updated_at = ? WHERE manga_id = ?`) + .run(...keys.map(k => fields[k]), Date.now(), mangaId) +} +// Cancelada o eliminada (sin fila) cuentan igual: hay que parar. +const isCancelled = mangaId => (db.prepare('SELECT status FROM downloads WHERE manga_id = ?').get(mangaId)?.status ?? 'cancelled') === 'cancelled' + +function mangaDirOf(folder) { + const dir = path.resolve(MANGA_DIR, folder) + if (!folder || !dir.startsWith(path.resolve(MANGA_DIR) + path.sep)) throw new HttpError(400, 'Carpeta inválida') + return dir +} +function dirSize(dir) { + let total = 0 + try { + for (const f of fs.readdirSync(dir, { withFileTypes: true })) total += f.isDirectory() ? dirSize(path.join(dir, f.name)) : fs.statSync(path.join(dir, f.name)).size + } catch {} + return total +} +function deleteDownload(mangaId) { + const dl = db.prepare('SELECT folder FROM downloads WHERE manga_id = ?').get(mangaId) + if (!dl) return false + const dir = mangaDirOf(dl.folder) + upscale.cancel(mangaId) + db.prepare('DELETE FROM downloads WHERE manga_id = ?').run(mangaId) // borra también downloaded_chapters (CASCADE) + db.prepare('DELETE FROM download_users WHERE manga_id = ?').run(mangaId) + dlCurrent.delete(mangaId) + fs.rmSync(dir, { recursive: true, force: true }) + return true +} + +async function runDownload(dl) { + const { manga, chapters } = await fetchMangaFull(dl.manga_id, dl.lang) + const dir = path.join(MANGA_DIR, dl.folder) + fs.mkdirSync(dir, { recursive: true }) + const coverRaw = rawFromProxy(manga.cover) + if (coverRaw && !fs.existsSync(path.join(dir, 'cover.jpg'))) { + try { fs.writeFileSync(path.join(dir, 'cover.jpg'), (await downloadBuffer(coverRaw)).data) } catch (e) { console.warn('Portada:', e.message) } + } + const have = new Set(db.prepare('SELECT chapter_id, file FROM downloaded_chapters WHERE manga_id = ?').all(dl.manga_id) + .filter(r => fs.existsSync(path.join(dir, r.file))).map(r => r.chapter_id)) + let pending = chapters.filter(c => !have.has(c.id)) + let total = chapters.length + if (dl.scope === 'some') { + const wanted = new Set(db.prepare('SELECT chapter_id FROM download_wanted WHERE manga_id = ?').all(dl.manga_id).map(r => r.chapter_id)) + pending = pending.filter(c => wanted.has(c.id)) + total = have.size + pending.length + } + dlUpdate(dl.manga_id, { status: 'downloading', title: manga.title, meta: JSON.stringify({ manga, chapters }), total, done: total - pending.length, failed: 0, error: null }) + let failed = 0 + let lastError = null + for (const c of pending) { + if (isCancelled(dl.manga_id)) break + try { + await downloadChapter(dl, manga, c, dir) + } catch (e) { + if (isCancelled(dl.manga_id)) break + // Se cayó internet: no cuenta como fallo; la descarga queda en cola y sigue al volver la conexión. + if (!(await net.check())) { + dlCurrent.delete(dl.manga_id) + dlUpdate(dl.manga_id, { status: 'queued', error: null }) + return + } + failed++ + lastError = `Cap. ${c.num ?? '?'}: ${e.message}` + console.warn('Descarga', manga.title, lastError) + } + const done = db.prepare('SELECT COUNT(*) n FROM downloaded_chapters WHERE manga_id = ?').get(dl.manga_id).n + dlUpdate(dl.manga_id, { done, failed, error: lastError }) + // MangaDex limita /at-home/server a 40 peticiones por minuto. + await sleep(manga.source === 'md' ? 1600 : manga.source === 'mi' ? 1000 : 500) + } + dlCurrent.delete(dl.manga_id) + if (isCancelled(dl.manga_id)) { + if (!db.prepare('SELECT 1 FROM downloads WHERE manga_id = ?').get(dl.manga_id)) fs.rmSync(dir, { recursive: true, force: true }) + return + } + dlUpdate(dl.manga_id, { status: failed ? 'error' : 'done', error: failed ? `${failed} capítulo(s) fallaron. Último error: ${lastError}` : null }) +} + +let pumping = false +async function pumpDownloads() { + if (pumping) return + pumping = true + try { + for (;;) { + if (!net.online()) break // se reanuda con net.whenOnline + const dl = db.prepare("SELECT * FROM downloads WHERE status IN ('downloading', 'queued') ORDER BY status = 'downloading' DESC, created_at LIMIT 1").get() + if (!dl) break + requeue.delete(dl.manga_id) + try { + await runDownload(dl) + if (!net.online()) break + } catch (e) { + if (!net.online()) { dlUpdate(dl.manga_id, { status: 'queued', error: null }); break } + console.error('Descarga fallida', dl.manga_id, e) + if (!isCancelled(dl.manga_id)) dlUpdate(dl.manga_id, { status: 'error', error: e.message }) + } + // Pidieron más capítulos mientras se bajaba: otra vuelta. + if (requeue.delete(dl.manga_id) && !isCancelled(dl.manga_id)) dlUpdate(dl.manga_id, { status: 'queued' }) + } + } finally { + pumping = false + } +} + +// chapterIds: solo esos capítulos (si el manga ya se baja entero, no cambia nada); sin ellos, el manga entero. +function enqueueDownload(mangaId, title, lang, chapterIds = null) { + const existing = db.prepare('SELECT * FROM downloads WHERE manga_id = ?').get(mangaId) + if (existing) { + if (!chapterIds && existing.scope !== 'all') dlUpdate(mangaId, { scope: 'all' }) + // Si ya se estaba descargando, runDownload no verá lo nuevo: se vuelve a encolar al acabar. + if (!['queued', 'downloading'].includes(existing.status)) dlUpdate(mangaId, { status: 'queued', lang: lang || existing.lang, error: null }) + else if (existing.status === 'downloading') requeue.add(mangaId) + } else { + let folder = safeName(title) + for (let i = 2; db.prepare('SELECT 1 FROM downloads WHERE folder = ?').get(folder); i++) folder = `${safeName(title)} (${i})` + const now = Date.now() + db.prepare('INSERT INTO downloads (manga_id, title, folder, lang, status, scope, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)') + .run(mangaId, title, folder, lang, 'queued', chapterIds ? 'some' : 'all', now, now) + } + if (chapterIds) { + const ins = db.prepare('INSERT OR IGNORE INTO download_wanted (chapter_id, manga_id, created_at) VALUES (?, ?, ?)') + const now = Date.now() + for (const id of chapterIds) ins.run(id, mangaId, now) + } + pumpDownloads() +} +const requeue = new Set() + +const cbzIndexCache = new Map() +// hd: usar la versión reescalada si existe. +function localChapter(chapterId, hd = false) { + const row = db.prepare(`SELECT c.file, c.hd_file, d.folder FROM downloaded_chapters c JOIN downloads d ON d.manga_id = c.manga_id WHERE c.chapter_id = ?`).get(chapterId) + if (!row) return null + const file = (hd && upscale.hdFileOf(chapterId)) || path.join(MANGA_DIR, row.folder, row.file) + let st + try { st = fs.statSync(file) } catch { return null } + const key = `${file}:${st.mtimeMs}` + if (!cbzIndexCache.has(key)) { + cbzIndexCache.set(key, { file, entries: readCbzIndex(file) }) + if (cbzIndexCache.size > 100) cbzIndexCache.delete(cbzIndexCache.keys().next().value) + } + return cbzIndexCache.get(key) +} +// Portada de un manga descargado: la cover.jpg de su carpeta, la de la ficha guardada o la del historial. +// Las URLs del proxy se vuelven a firmar (con &m= para la versión HD) por si se guardaron con otra clave. +function downloadCover(mangaId, folder, metaCover) { + if (fs.existsSync(path.join(mangaDirOf(folder), 'cover.jpg'))) return `/api/local-cover/${mangaId}` + const c = metaCover || db.prepare('SELECT cover FROM progress WHERE manga_id = ? AND cover IS NOT NULL UNION ALL SELECT cover FROM follows WHERE manga_id = ? AND cover IS NOT NULL LIMIT 1').get(mangaId, mangaId)?.cover + const u = rawFromProxy(c) + return u ? coverUrl(u, mangaId) : c || null +} +// Sin conexión: la búsqueda va sobre lo descargado en el servidor. +const fold = s => String(s).toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '') +function localSearch(text) { + const words = fold(text).split(/\s+/).filter(Boolean) + return db.prepare(`SELECT manga_id, title, folder, done, json_extract(meta, '$.manga.cover') meta_cover FROM downloads + WHERE done > 0 ORDER BY updated_at DESC`).all() + .filter(d => words.every(w => fold(d.title).includes(w))) + .map(d => ({ id: d.manga_id, source: sourceOf(d.manga_id), title: d.title, cover: downloadCover(d.manga_id, d.folder, d.meta_cover), sub: `${d.done} cap. descargado${d.done === 1 ? '' : 's'}` })) +} +const withCurrent = d => (d ? { ...d, current: ['queued', 'downloading'].includes(d.status) ? dlCurrent.get(d.manga_id) ?? null : null } : null) +const downloadInfo = (mangaId, size = false) => { + const d = withCurrent(db.prepare('SELECT manga_id, title, folder, lang, status, scope, done, total, failed, error, updated_at FROM downloads WHERE manga_id = ?').get(mangaId)) + if (d && size) d.size = dirSize(mangaDirOf(d.folder)) + if (d) d.wanted = db.prepare('SELECT chapter_id FROM download_wanted WHERE manga_id = ?').all(mangaId).map(r => r.chapter_id) + return d +} + +// ---------- HTTP ---------- +class HttpError extends Error { + constructor(status, message) { super(message); this.status = status } +} +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 = [] + for await (const c of req) { + size += c.length + if (size > 64 * 1024) throw new HttpError(413, 'Cuerpo demasiado grande') + chunks.push(c) + } + try { return JSON.parse(Buffer.concat(chunks).toString() || '{}') } catch { throw new HttpError(400, 'JSON inválido') } +} +const MANGA_ID = /^([0-9a-f-]{36}|lc~[a-z0-9]+~[a-z0-9-]+|mi~[a-z0-9._-]+|gb~\d{1,7}|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 = ? + UNION ALL SELECT 1 FROM follows WHERE user_id = ? AND manga_id = ? AND active = 1 LIMIT 1`).get(userId, mangaId, userId, mangaId) +const requireUser = req => { + const u = getUser(req) + if (!u) throw new HttpError(401, 'No autenticado') + 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', '.json': 'application/json', '.woff2': 'font/woff2', '.txt': 'text/plain; charset=utf-8' } +// index.html enlaza app.js y style.css con ?v=: 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. +const assetVersion = name => crypto.createHash('sha256').update(fs.readFileSync(path.join(PUBLIC_DIR, name))).digest('hex').slice(0, 10) +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('', ``) + .replace('', ``) + .replace('', ``) +function serveStatic(res, pathname) { + let file = path.join(PUBLIC_DIR, path.normalize(pathname).replace(/^(\.\.[/\\])+/, '')) + if (!file.startsWith(PUBLIC_DIR) || !fs.existsSync(file) || fs.statSync(file).isDirectory()) file = path.join(PUBLIC_DIR, 'index.html') + res.writeHead(200, { 'Content-Type': MIME[path.extname(file)] || 'application/octet-stream', 'Cache-Control': 'no-cache' }) + if (file === path.join(PUBLIC_DIR, 'index.html')) return res.end(INDEX_HTML) + fs.createReadStream(file).pipe(res) +} + +const loginAttempts = new Map() +function rateLimit(ip) { + const now = Date.now() + const list = (loginAttempts.get(ip) || []).filter(t => now - t < 10 * 60e3) + if (list.length >= 10) throw new HttpError(429, 'Demasiados intentos, espera unos minutos') + list.push(now) + loginAttempts.set(ip, list) +} + +async function route(req, res) { + const ip = security.clientIp(req) + security.headers(req, res) + if (security.banned(ip)) { + res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' }) + return res.end('Acceso bloqueado temporalmente por demasiados intentos fallidos.') + } + const url = new URL(req.url, 'http://x') + const p = url.pathname + const q = url.searchParams + const method = req.method + + if (!p.startsWith('/api/')) return serveStatic(res, p) + + // --- Auth --- + if (p === '/api/me' && method === 'GET') { + const userCount = db.prepare('SELECT COUNT(*) n FROM users').get().n + 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) + const { username, password } = await readBody(req) + if (typeof username !== 'string' || typeof password !== 'string') throw new HttpError(400, 'Datos incompletos') + let user + if (p === '/api/register') { + const userCount = db.prepare('SELECT COUNT(*) n FROM users').get().n + if (!ALLOW_SIGNUP && userCount > 0) { security.fail(ip, 'registro cerrado'); throw new HttpError(403, 'El registro está deshabilitado') } + if (!/^[\w.-]{3,32}$/.test(username)) throw new HttpError(400, 'Usuario: 3-32 caracteres (letras, números, . _ -)') + if (password.length < 8) throw new HttpError(400, 'La contraseña debe tener al menos 8 caracteres') + if (db.prepare('SELECT 1 FROM users WHERE username = ?').get(username)) throw new HttpError(409, 'Ese usuario ya existe') + const r = db.prepare('INSERT INTO users (username, pass_hash, created_at) VALUES (?, ?, ?)') + .run(username, hashPassword(password), Date.now()) + user = { id: Number(r.lastInsertRowid) } + } else { + const row = db.prepare('SELECT id, pass_hash FROM users WHERE username = ?').get(username.slice(0, 64)) + if (!verifyPassword(password.slice(0, 256), row?.pass_hash || DUMMY_HASH) || !row) { + security.fail(ip, 'contraseña incorrecta') + const left = security.remaining(ip) + throw new HttpError(401, `Usuario o contraseña incorrectos${left != null ? ` (${left ? `quedan ${left} intento${left > 1 ? 's' : ''} antes de bloquear esta IP` : 'IP bloqueada'})` : ''}`) + } + user = row + } + security.success(ip) + const token = createSession(user.id) + return json(res, 200, { ok: true }, { 'Set-Cookie': sessionCookie(token, SESSION_DAYS * 86400, security.isHttps(req)) }) + } + if (p === '/api/logout' && method === 'POST') { + const m = /(?:^|;\s*)sid=([^;]+)/.exec(req.headers.cookie || '') + if (m) db.prepare('DELETE FROM sessions WHERE token = ?').run(m[1]) + return json(res, 200, { ok: true }, { 'Set-Cookie': sessionCookie('', 0, security.isHttps(req)) }) + } + + // 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] || '')) && !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') { + const body = await readBody(req) + if (body.lang !== undefined) { + 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') } + if (String(body.newPassword).length < 8 || String(body.newPassword).length > 256) throw new HttpError(400, 'La contraseña debe tener entre 8 y 256 caracteres') + db.prepare('UPDATE users SET pass_hash = ? WHERE id = ?').run(hashPassword(String(body.newPassword)), user.id) + // Cerrar las demás sesiones (por si la contraseña se cambió por estar comprometida). + const mine = /(?:^|;\s*)sid=([^;]+)/.exec(req.headers.cookie || '')?.[1] + db.prepare('DELETE FROM sessions WHERE user_id = ? AND token != ?').run(user.id, mine || '') + } + return json(res, 200, { ok: true }) + } + + if (p === '/api/img' && method === 'GET') { + const mid = MANGA_ID.test(q.get('m') || '') ? q.get('m') : null + try { + return await proxyImage(req, res, q.get('u') || '', q.get('s'), !!mid && tracked(user.id, mid), q.has('m'), q.has('b')) + } catch (e) { + // Portada sin conexión con la fuente: la cover.jpg de la copia local, si la hay. + if (mid && !res.headersSent && e.status !== 403) { + const dl = downloadInfo(mid) + const file = dl && path.join(MANGA_DIR, dl.folder, 'cover.jpg') + if (file && fs.existsSync(file)) { + res.writeHead(200, { 'Content-Type': imageType(file), 'Cache-Control': 'private, max-age=300' }) + return fs.createReadStream(file).pipe(res) + } + } + throw e + } + } + + 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() : ['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' + const mdTag = /^[0-9a-f-]{36}$/.test(tag) ? tag : null + return json(res, 200, await searchManga((q.get('q') || '').slice(0, 100), anyLang ? null : lang, q.get('mode'), mdTag, page)) + } + + 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 { + ({ manga, chapters } = await fetchMangaFull(m[1], lang)) + } catch (e) { + // Sin conexión con la fuente: usar la ficha guardada y solo los capítulos descargados. + const dl = db.prepare('SELECT meta FROM downloads WHERE manga_id = ? AND meta IS NOT NULL').get(m[1]) + if (!dl) throw net.online() ? e : new HttpError(503, 'El servidor no tiene conexión a internet y este manga no está descargado en el servidor.') + const saved = JSON.parse(dl.meta) + const have = new Set(db.prepare('SELECT chapter_id FROM downloaded_chapters WHERE manga_id = ?').all(m[1]).map(r => r.chapter_id)) + manga = { ...saved.manga, cover: `/api/local-cover/${m[1]}` } + chapters = saved.chapters.filter(c => have.has(c.id)) + offline = true + } + const downloaded = db.prepare('SELECT chapter_id FROM downloaded_chapters WHERE manga_id = ?').all(m[1]).map(r => r.chapter_id) + 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, 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) + if (!local && !net.online()) throw new HttpError(503, 'El servidor no tiene conexión a internet y este capítulo no está descargado en el servidor.') + const pages = local + ? local.entries.map((_, i) => `/api/local/${m[1]}/${i}${hd ? '?hd=1' : ''}`) + : m[1].startsWith('lc~') ? await lcPages(m[1]) : m[1].startsWith('mi~') ? await miPages(m[1]) : await getPages(m[1], q.get('saver') === '1') + const read = db.prepare('SELECT page FROM chapter_reads WHERE user_id = ? AND chapter_id = ?').get(user.id, m[1]) + return json(res, 200, { pages, savedPage: read?.page ?? 0, local: !!local, hd, hdAvailable }) + } + if ((m = /^\/api\/local\/([^/]+)\/(\d+)$/.exec(p)) && CHAPTER_ID.test(m[1]) && method === 'GET') { + const local = localChapter(m[1], q.get('hd') === '1') + const entry = local?.entries[Number(m[2])] + if (!entry) throw new HttpError(404, 'Página no descargada') + return sendCbzEntry(res, local.file, entry) + } + if ((m = /^\/api\/local-cover\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'GET') { + const dl = downloadInfo(m[1]) + const file = dl && path.join(MANGA_DIR, dl.folder, 'cover.jpg') + if (!file || !fs.existsSync(file)) throw new HttpError(404, 'Sin portada') + if (!tracked(user.id, m[1])) { + res.writeHead(200, { 'Content-Type': imageType(file), 'Cache-Control': 'private, max-age=86400' }) + return fs.createReadStream(file).pipe(res) + } + const key = `local:${file}:${fs.statSync(file).mtimeMs}` + if (covers.sendCached(res, key)) return + const buf = fs.readFileSync(file) + res.writeHead(200, { 'Content-Type': imageType(file), 'Cache-Control': covers.pendingCache() }) + res.end(buf) + return covers.enqueue(key, buf) + } + + // --- Descargas --- + if (p === '/api/status' && method === 'GET') return json(res, 200, net.status()) + if (p === '/api/queues' && method === 'GET') { + const downloads = db.prepare(`SELECT manga_id, title, folder, lang, status, scope, done, total, failed, error, updated_at, json_extract(meta, '$.manga.cover') meta_cover + FROM downloads WHERE status IN ('downloading', 'queued') AND ${MY_DOWNLOADS} ORDER BY status = 'downloading' DESC, created_at`).all(user.id) + .map(({ meta_cover, ...d }) => ({ ...withCurrent(d), cover: downloadCover(d.manga_id, d.folder, meta_cover) })) + const c = clips.queueInfo(user.id) + const up = upscale.queue() + const mine = new Set(db.prepare('SELECT manga_id FROM download_users WHERE user_id = ?').all(user.id).map(r => r.manga_id)) + up.groups = up.groups.filter(g => mine.has(g.mangaId)) + if (up.current && !mine.has(up.current.mangaId)) up.current = null + for (const g of up.groups) { + const d = db.prepare(`SELECT folder, json_extract(meta, '$.manga.cover') meta_cover FROM downloads WHERE manga_id = ?`).get(g.mangaId) + g.cover = d ? downloadCover(g.mangaId, d.folder, d.meta_cover) : null + } + return json(res, 200, { downloads, upscale: up, clips: { queued: c.queued || 0, running: c.running || 0, errors: c.errors || 0 }, covers: covers.pendingCount() }) + } + if (p === '/api/downloads' && method === 'GET') { + return json(res, 200, db.prepare(`SELECT manga_id, title, folder, lang, status, done, total, failed, error, updated_at, json_extract(meta, '$.manga.cover') meta_cover + FROM downloads WHERE ${MY_DOWNLOADS} ORDER BY updated_at DESC`).all(user.id) + .map(({ meta_cover, ...d }) => ({ ...withCurrent(d), size: dirSize(mangaDirOf(d.folder)), cover: downloadCover(d.manga_id, d.folder, meta_cover), shared: sharedWith(user.id, d.manga_id) }))) + } + if ((m = /^\/api\/download\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'GET') { + const d = downloadInfo(m[1], true) + 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 + const ids = Array.isArray(b.chapterIds) ? b.chapterIds.map(String).filter(id => CHAPTER_ID.test(id)).slice(0, 5000) : null + if (ids && !ids.length) throw new HttpError(400, 'Ningún capítulo válido') + enqueueDownload(m[1], title, dlLang, ids) + linkDownload(user.id, m[1]) + return json(res, 200, downloadInfo(m[1], true)) + } + if ((m = /^\/api\/download\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'DELETE') { + // Si otro usuario también lo tiene, solo se quita de la biblioteca de este (los archivos se quedan). + if (sharedWith(user.id, m[1]) && db.prepare('DELETE FROM download_users WHERE user_id = ? AND manga_id = ?').run(user.id, m[1]).changes) return json(res, 200, { ok: true, kept: true }) + if (!deleteDownload(m[1])) throw new HttpError(404, 'No hay copia local') + return json(res, 200, { ok: true }) + } + if ((m = /^\/api\/download\/([^/]+)\/cancel$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'POST') { + db.prepare("UPDATE downloads SET status = 'cancelled', updated_at = ? WHERE manga_id = ? AND status IN ('queued', 'downloading')").run(Date.now(), m[1]) + return json(res, 200, downloadInfo(m[1])) + } + + // --- Historial --- + if (p === '/api/history' && method === 'GET') { + // Un manga terminado vuelve a "en curso" si después aparecieron capítulos nuevos. + const rows = db.prepare(`SELECT p.*, f.last_new_at FROM progress p + LEFT JOIN follows f ON f.user_id = p.user_id AND f.manga_id = p.manga_id AND f.active = 1 + WHERE p.user_id = ? ORDER BY p.updated_at DESC LIMIT 200`).all(user.id) + return json(res, 200, rows.map(({ last_new_at, ...r }) => { + const hasNew = !!(r.finished_at && last_new_at && last_new_at > r.finished_at) + return { ...r, finished: !!r.finished_at && !hasNew, hasNew } + })) + } + if (p === '/api/progress' && method === 'POST') { + const b = await readBody(req) + if (!MANGA_ID.test(b.mangaId || '') || !CHAPTER_ID.test(b.chapterId || '') || !b.chapterId.startsWith(spanishOnly(b.mangaId) ? b.mangaId + '~' : '')) throw new HttpError(400, 'IDs inválidos') + const page = Math.max(0, Number(b.page) | 0) + const total = Math.max(0, Number(b.totalPages) | 0) + const now = Date.now() + const finishedAt = b.isLast === true && total && page >= total - 1 ? now : null + const prevPage = db.prepare('SELECT page FROM chapter_reads WHERE user_id = ? AND chapter_id = ?').get(user.id, b.chapterId)?.page + db.prepare(`INSERT INTO progress (user_id, manga_id, manga_title, cover, chapter_id, chapter_num, chapter_title, lang, page, total_pages, updated_at, finished_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (user_id, manga_id) DO UPDATE SET manga_title = excluded.manga_title, cover = excluded.cover, + chapter_id = excluded.chapter_id, chapter_num = excluded.chapter_num, chapter_title = excluded.chapter_title, + lang = excluded.lang, page = excluded.page, total_pages = excluded.total_pages, updated_at = excluded.updated_at, + finished_at = excluded.finished_at`) + .run(user.id, b.mangaId, String(b.mangaTitle || '').slice(0, 300), b.cover ? String(b.cover).slice(0, 1000) : null, + b.chapterId, b.chapterNum != null ? String(b.chapterNum) : null, String(b.chapterTitle || '').slice(0, 300), + LANG.test(b.lang || '') ? b.lang : null, page, total, now, finishedAt) + db.prepare(`INSERT INTO chapter_reads (user_id, chapter_id, manga_id, page, total_pages, updated_at) VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT (user_id, chapter_id) DO UPDATE SET page = excluded.page, total_pages = excluded.total_pages, updated_at = excluded.updated_at`) + .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) + 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') { + db.prepare('DELETE FROM progress WHERE user_id = ? AND manga_id = ?').run(user.id, m[1]) + db.prepare('DELETE FROM chapter_reads WHERE user_id = ? AND manga_id = ?').run(user.id, m[1]) + features.unfollow(user.id, m[1]) + return json(res, 200, { ok: true }) + } + + throw new HttpError(404, 'No encontrado') +} + +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 }) +const library = setupLibrary({ db, HttpError, json, readBody, MANGA_ID, CHAPTER_ID, fetchMangaFull, enqueueDownload, linkDownload, upscale, stats: features.stats, searchManga, mdCategories, net }) +// 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 +// lista; si no es de nadie, al primer usuario (el que montó el servidor). +if (!db.prepare("SELECT 1 FROM kv WHERE k = 'download_users_init'").get()) { + const first = db.prepare('SELECT id FROM users ORDER BY id LIMIT 1').get()?.id + for (const { manga_id } of db.prepare('SELECT manga_id FROM downloads').all()) { + const users = db.prepare(`SELECT user_id FROM progress WHERE manga_id = ? UNION SELECT user_id FROM follows WHERE manga_id = ? + UNION SELECT l.user_id FROM list_items i JOIN lists l ON l.id = i.list_id WHERE i.manga_id = ?`).all(manga_id, manga_id, manga_id).map(r => r.user_id) + for (const u of users.length ? users : first ? [first] : []) linkDownload(u, manga_id) + } + if (first) db.prepare("INSERT INTO kv (k, v) VALUES ('download_users_init', '1')").run() +} + +// Migración: portadas guardadas sin &m= (reescalado de portadas) y con la miniatura de 256 px de MangaDex. +for (const t of ['progress', 'follows', 'clips']) { + for (const row of db.prepare(`SELECT DISTINCT manga_id, cover FROM ${t} WHERE cover LIKE '/api/img?u=%&s=%' AND cover NOT LIKE '%&m=%'`).all()) { + const u = new URLSearchParams(row.cover.slice('/api/img?'.length)).get('u') + if (u) db.prepare(`UPDATE ${t} SET cover = ? WHERE cover = ? AND manga_id = ?`).run(coverUrl(u.replace(/(\/covers\/[^/]+\/[^/]+)\.256\.jpg$/, '$1.512.jpg'), row.manga_id), row.cover, row.manga_id) + } +} +// Migración: portadas guardadas antes de firmar las URLs del proxy. +for (const row of db.prepare("SELECT user_id, manga_id, cover FROM progress WHERE cover LIKE '/api/img?u=%' AND cover NOT LIKE '%&s=%'").all()) { + const u = new URLSearchParams(row.cover.slice('/api/img?'.length)).get('u') + if (u) db.prepare('UPDATE progress SET cover = ? WHERE user_id = ? AND manga_id = ?').run(coverUrl(u, row.manga_id), row.user_id, row.manga_id) +} + +http.createServer(async (req, res) => { + try { + await route(req, res) + } catch (err) { + const status = err instanceof HttpError ? err.status : 500 + // Sin internet los 503 son lo esperado: no llenar el log de trazas. + if (status >= 500 && !(status === 503 && !net.online())) console.error(req.method, req.url, err) + // Los errores internos no se detallan al cliente (rutas, nombres de archivo…): solo en el log. + if (!res.headersSent) json(res, status, { error: status >= 500 && status !== 502 && status !== 503 ? 'Error interno del servidor' : err.message || 'Error' }) + else res.end() + } +}).listen(PORT, () => { + console.log(`Manga Reader escuchando en :${PORT} (registro ${ALLOW_SIGNUP ? 'abierto' : 'cerrado'}, descargas en ${MANGA_DIR})`) + pumpDownloads() // reanuda descargas pendientes tras un reinicio +}) diff --git a/app/shelf.js b/app/shelf.js new file mode 100644 index 0000000..f764faf --- /dev/null +++ b/app/shelf.js @@ -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, 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, 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, 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, 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 } +} diff --git a/app/upscale.js b/app/upscale.js new file mode 100644 index 0000000..7502a0b --- /dev/null +++ b/app/upscale.js @@ -0,0 +1,280 @@ +// Reescalado (HD) de capítulos descargados con Real-ESRGAN (ncnn-vulkan, GPU por /dev/dri). +// El original no se toca: la versión HD va a /.hd/.cbz (oculta para Komga/Kavita). +// Una cola persistente en SQLite procesa un capítulo cada vez. +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { spawn } from 'node:child_process' +import { writeCbz, readCbzIndex, readCbzEntry } from './cbz.js' + +export const BIN = process.env.UPSCALER_BIN || '/opt/realesrgan/realesrgan-ncnn-vulkan' +export const MODELS = process.env.UPSCALER_MODELS || path.join(path.dirname(BIN), 'models') +export const MODEL = process.env.UPSCALER_MODEL || 'realesr-animevideov3' +const SCALE = Number(process.env.UPSCALER_SCALE || 2) +const WEBP_QUALITY = Number(process.env.UPSCALER_QUALITY || 85) +// WebP no admite más de 16383 px por lado: las tiras de webtoon muy altas se pasan al reescalarlas. +const WEBP_MAX_SIDE = 16383 +// Ancho y alto de un PNG (cabecera IHDR). +function pngSize(file) { + const fd = fs.openSync(file, 'r') + try { + const b = Buffer.alloc(24) + fs.readSync(fd, b, 0, 24, 0) + return b.readUInt32BE(0) === 0x89504e47 ? { w: b.readUInt32BE(16), h: b.readUInt32BE(20) } : null + } finally { fs.closeSync(fd) } +} +const HD_DIR = '.hd' +const UPSCALABLE = /\.(jpe?g|png|webp)$/i // lo demás (gif, avif) se copia tal cual + +export function run(cmd, args, onSpawn) { + return new Promise((resolve, reject) => { + const p = spawn(cmd, args, { stdio: ['ignore', 'ignore', 'pipe'] }) + onSpawn?.(p) + let err = '' + p.stderr.on('data', d => { err = (err + d).slice(-2000) }) + p.on('error', reject) + p.on('close', (code, signal) => (code === 0 ? resolve() : reject(new Error(signal ? `interrumpido (${signal})` : `${path.basename(cmd)} salió con ${code}: ${err.trim().split('\n').filter(l => !/^\d+(\.\d+)?%$/.test(l)).slice(-2).join(' ')}`)))) + }) +} + +export function setupUpscale({ db, MANGA_DIR, HttpError, json, readBody, MANGA_ID }) { + const cols = t => db.prepare(`PRAGMA table_info(${t})`).all().map(c => c.name) + if (!cols('downloaded_chapters').includes('hd_file')) db.exec('ALTER TABLE downloaded_chapters ADD COLUMN hd_file TEXT') + if (!cols('downloads').includes('hd_auto')) db.exec('ALTER TABLE downloads ADD COLUMN hd_auto INTEGER NOT NULL DEFAULT 0') + db.exec(` + CREATE TABLE IF NOT EXISTS upscale_jobs ( + chapter_id TEXT PRIMARY KEY REFERENCES downloaded_chapters(chapter_id) ON DELETE CASCADE, + manga_id TEXT NOT NULL, + status TEXT NOT NULL, -- queued | running | error + error TEXT, + created_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_upscale_manga ON upscale_jobs(manga_id); + `) + db.prepare("UPDATE upscale_jobs SET status = 'queued' WHERE status = 'running'").run() // reinicio a mitad + // Capítulos aún sin descargar que deben reescalarse en cuanto lleguen (descarga anticipada con HD). + db.exec('CREATE TABLE IF NOT EXISTS upscale_wanted (chapter_id TEXT PRIMARY KEY, created_at INTEGER NOT NULL)') + db.exec('CREATE TABLE IF NOT EXISTS kv (k TEXT PRIMARY KEY, v TEXT)') + // Segundos por página medidos (media móvil), para estimar cuánto tardará una cola. + let secPerPage = Number(db.prepare("SELECT v FROM kv WHERE k = 'hd_sec_per_page'").get()?.v) || 13 + + let hasCwebp = false + const available = () => fs.existsSync(BIN) && fs.existsSync(path.join(MODELS, `${MODEL}-x${SCALE}.bin`)) && fs.existsSync('/dev/dri') && hasCwebp + run('cwebp', ['-version']).then(() => { hasCwebp = true; if (available()) pump() }, () => {}) + + // Capítulo en curso (solo en memoria). + let current = null // { chapterId, mangaId, num, pages, total, proc } + + const chapterRow = chapterId => db.prepare(`SELECT c.chapter_id, c.manga_id, c.num, c.file, c.hd_file, d.folder FROM downloaded_chapters c + JOIN downloads d ON d.manga_id = c.manga_id WHERE c.chapter_id = ?`).get(chapterId) + + async function upscaleChapter(job) { + const row = chapterRow(job.chapter_id) + if (!row) return + const dir = path.join(MANGA_DIR, row.folder) + const src = path.join(dir, row.file) + const entries = readCbzIndex(src) + current = { chapterId: row.chapter_id, mangaId: row.manga_id, num: row.num, pages: 0, total: entries.length, proc: null } + const work = fs.mkdtempSync(path.join(os.tmpdir(), 'hd-')) + try { + const inDir = path.join(work, 'in'), outDir = path.join(work, 'out') + fs.mkdirSync(inDir); fs.mkdirSync(outDir) + const names = entries.map((e, i) => { + const ext = e.name.split('.').pop().toLowerCase() + const name = `${String(i + 1).padStart(4, '0')}.${ext}` + fs.writeFileSync(path.join(inDir, name), readCbzEntry(src, e)) + return name + }) + // Real-ESRGAN procesa la carpeta entera; el progreso se mide contando las salidas. + const poll = setInterval(() => { try { current.pages = fs.readdirSync(outDir).length } catch {} }, 1500) + try { + await run(BIN, ['-i', inDir, '-o', outDir, '-m', MODELS, '-n', MODEL, '-s', String(SCALE), '-f', 'png'], p => { current.proc = p }) + } finally { clearInterval(poll) } + if (!db.prepare('SELECT 1 FROM upscale_jobs WHERE chapter_id = ?').get(row.chapter_id)) return // cancelado + const out = [] + for (const name of names) { + const base = name.replace(/\.[^.]+$/, '') + const png = path.join(outDir, `${base}.png`) + if (UPSCALABLE.test(name) && fs.existsSync(png)) { + const webp = path.join(work, `${base}.webp`) + // Demasiado grande para WebP: se reduce justo hasta el límite (sigue mejor que la original). + const size = pngSize(png) + const fit = size && Math.max(size.w, size.h) > WEBP_MAX_SIDE ? (size.h >= size.w ? ['-resize', '0', String(WEBP_MAX_SIDE)] : ['-resize', String(WEBP_MAX_SIDE), '0']) : [] + await run('cwebp', ['-quiet', '-q', String(WEBP_QUALITY), '-m', '4', '-mt', ...fit, png, '-o', webp]) + out.push({ name: `${base}.webp`, data: fs.readFileSync(webp) }) + fs.rmSync(webp); fs.rmSync(png) + } else { + out.push({ name, data: fs.readFileSync(path.join(inDir, name)) }) // sin reescalar + } + } + if (!chapterRow(row.chapter_id)) return // la descarga se borró mientras tanto + fs.mkdirSync(path.join(dir, HD_DIR), { recursive: true }) + const hdFile = path.join(HD_DIR, row.file) + writeCbz(path.join(dir, hdFile), out) + const ok = db.prepare('UPDATE downloaded_chapters SET hd_file = ? WHERE chapter_id = ?').run(hdFile, row.chapter_id).changes + if (!ok) fs.rmSync(path.join(dir, hdFile), { force: true }) // la descarga se borró mientras tanto + return entries.length + } finally { + fs.rmSync(work, { recursive: true, force: true }) + current = null + } + } + + let pumping = false + async function pump() { + if (pumping || !available()) return + pumping = true + try { + for (;;) { + const job = db.prepare("SELECT * FROM upscale_jobs WHERE status = 'queued' ORDER BY created_at, rowid LIMIT 1").get() + if (!job) break + db.prepare("UPDATE upscale_jobs SET status = 'running' WHERE chapter_id = ?").run(job.chapter_id) + const t = Date.now() + try { + const pages = await upscaleChapter(job) + db.prepare('DELETE FROM upscale_jobs WHERE chapter_id = ?').run(job.chapter_id) + const secs = (Date.now() - t) / 1000 + if (pages) { + secPerPage = secPerPage * 0.7 + (secs / pages) * 0.3 + db.prepare("INSERT INTO kv (k, v) VALUES ('hd_sec_per_page', ?) ON CONFLICT (k) DO UPDATE SET v = excluded.v").run(secPerPage.toFixed(2)) + } + console.log(`HD: ${job.chapter_id} en ${Math.round(secs)} s (${pages || 0} págs.)`) + } catch (e) { + console.warn('HD', job.chapter_id, e.message) + db.prepare("UPDATE upscale_jobs SET status = 'error', error = ? WHERE chapter_id = ?").run(e.message.slice(0, 500), job.chapter_id) + } + } + } finally { + pumping = false + } + } + + function enqueue(chapterIds) { + const ins = db.prepare(`INSERT INTO upscale_jobs (chapter_id, manga_id, status, created_at) + SELECT chapter_id, manga_id, 'queued', ? FROM downloaded_chapters WHERE chapter_id = ? AND hd_file IS NULL + ON CONFLICT (chapter_id) DO UPDATE SET status = 'queued', error = NULL WHERE status = 'error'`) + const now = Date.now() + let n = 0 + for (const id of chapterIds) n += ins.run(now, id).changes + pump() + return n + } + + function cancel(mangaId) { + db.prepare('DELETE FROM upscale_jobs WHERE manga_id = ?').run(mangaId) + if (current?.mangaId === mangaId) current.proc?.kill('SIGTERM') + } + + // Ruta absoluta del CBZ HD de un capítulo, o null. + function hdFileOf(chapterId) { + const row = chapterRow(chapterId) + if (!row?.hd_file) return null + const file = path.join(MANGA_DIR, row.folder, row.hd_file) + return fs.existsSync(file) ? file : null + } + + function info(mangaId) { + const dl = db.prepare('SELECT folder, hd_auto FROM downloads WHERE manga_id = ?').get(mangaId) + if (!dl) return { available: available() } + const rows = db.prepare('SELECT chapter_id, pages, hd_file FROM downloaded_chapters WHERE manga_id = ?').all(mangaId) + const jobs = db.prepare('SELECT chapter_id, status, error FROM upscale_jobs WHERE manga_id = ?').all(mangaId) + let size = 0 + try { for (const f of fs.readdirSync(path.join(MANGA_DIR, dl.folder, HD_DIR))) size += fs.statSync(path.join(MANGA_DIR, dl.folder, HD_DIR, f)).size } catch {} + const cur = current?.mangaId === mangaId ? { chapterId: current.chapterId, num: current.num, pages: current.pages, total: current.total } : null + return { + available: available(), auto: !!dl.hd_auto, downloaded: rows.length, hd: rows.filter(r => r.hd_file).length, size, current: cur, + chapters: rows.filter(r => r.hd_file).map(r => r.chapter_id), + pages: Object.fromEntries(rows.map(r => [r.chapter_id, r.pages])), // para estimar tiempos + secPerPage: Math.round(secPerPage * 10) / 10, + jobs: Object.fromEntries(jobs.map(j => [j.chapter_id, j.status])), + error: (j => j && `Cap. ${db.prepare('SELECT num FROM downloaded_chapters WHERE chapter_id = ?').get(j.chapter_id)?.num ?? '?'}: ${j.error}`)(jobs.find(j => j.status === 'error')) || null, + } + } + + // Cola global (página "Colas"): por manga, en el orden en que se procesan. + function queue() { + const rows = db.prepare(`SELECT j.chapter_id, j.manga_id, j.status, j.error, j.created_at, c.num, c.pages, d.title + FROM upscale_jobs j JOIN downloaded_chapters c ON c.chapter_id = j.chapter_id JOIN downloads d ON d.manga_id = j.manga_id + ORDER BY j.created_at, j.rowid`).all() + const groups = new Map() + for (const r of rows) { + let g = groups.get(r.manga_id) + if (!g) groups.set(r.manga_id, g = { mangaId: r.manga_id, title: r.title, queued: 0, pages: 0, errors: [], next: [] }) + if (r.status === 'error') g.errors.push({ chapterId: r.chapter_id, num: r.num, error: r.error }) + else { + g.queued++ + g.pages += r.pages || 0 + if (g.next.length < 5 && r.chapter_id !== current?.chapterId) g.next.push(r.num) + } + } + const cur = current && { mangaId: current.mangaId, chapterId: current.chapterId, num: current.num, pages: current.pages, total: current.total } + // Páginas que faltan (la del capítulo en curso cuenta solo lo que le queda). + for (const g of groups.values()) { + if (cur?.mangaId === g.mangaId) g.pages = Math.max(0, g.pages - (rows.find(r => r.chapter_id === cur.chapterId)?.pages || 0) + (cur.total - cur.pages)) + g.eta = Math.round(g.pages * secPerPage) + } + const list = [...groups.values()].sort((a, b) => (b.mangaId === cur?.mangaId) - (a.mangaId === cur?.mangaId)) + return { available: available(), current: cur, groups: list, secPerPage: Math.round(secPerPage * 10) / 10, eta: list.reduce((n, g) => n + g.eta, 0) } + } + + // Llamado al terminar de descargar un capítulo: si el manga está marcado como HD, encolarlo. + function onChapterDownloaded(mangaId, chapterId) { + const wanted = db.prepare('DELETE FROM upscale_wanted WHERE chapter_id = ?').run(chapterId).changes + if (wanted || db.prepare('SELECT hd_auto FROM downloads WHERE manga_id = ?').get(mangaId)?.hd_auto) enqueue([chapterId]) + } + + async function route(p, method, req, res) { + const m = /^\/api\/upscale\/([^/]+)(\/cancel|\/auto)?$/.exec(p) + if (!m || !MANGA_ID.test(m[1])) return false + const mangaId = m[1] + if (method === 'GET' && !m[2]) return json(res, 200, info(mangaId)), true + if (method === 'POST' && m[2] === '/auto') { + const b = await readBody(req) + db.prepare('UPDATE downloads SET hd_auto = ? WHERE manga_id = ?').run(b.auto ? 1 : 0, mangaId) + return json(res, 200, info(mangaId)), true + } + if (method === 'POST' && m[2]) { + cancel(mangaId) + db.prepare('UPDATE downloads SET hd_auto = 0 WHERE manga_id = ?').run(mangaId) + return json(res, 200, info(mangaId)), true + } + if (method === 'POST') { + if (!available()) throw new HttpError(503, 'El reescalado no está disponible (falta Real-ESRGAN o la GPU en el contenedor)') + const b = await readBody(req) + let ids + if (b.chapterId) ids = [String(b.chapterId)] + else if (Array.isArray(b.chapterIds)) ids = b.chapterIds.slice(0, 5000).map(String) // selección o rango + else { + // Todo el manga: lo descargado y, en adelante, lo que se descargue. + db.prepare('UPDATE downloads SET hd_auto = 1 WHERE manga_id = ?').run(mangaId) + ids = db.prepare('SELECT chapter_id FROM downloaded_chapters WHERE manga_id = ? ORDER BY CAST(num AS REAL), created_at').all(mangaId).map(r => r.chapter_id) + } + if (!db.prepare('SELECT 1 FROM downloads WHERE manga_id = ?').get(mangaId)) throw new HttpError(404, 'Este manga no está descargado') + enqueue(ids) + return json(res, 200, info(mangaId)), true + } + if (method === 'DELETE') { + // Quitar la versión HD (el original se queda). + cancel(mangaId) + db.prepare('UPDATE downloads SET hd_auto = 0 WHERE manga_id = ?').run(mangaId) + const dl = db.prepare('SELECT folder FROM downloads WHERE manga_id = ?').get(mangaId) + if (dl) fs.rmSync(path.join(MANGA_DIR, dl.folder, HD_DIR), { recursive: true, force: true }) + db.prepare('UPDATE downloaded_chapters SET hd_file = NULL WHERE manga_id = ?').run(mangaId) + return json(res, 200, info(mangaId)), true + } + return false + } + + if (!fs.existsSync(BIN)) console.log('HD: Real-ESRGAN no instalado, reescalado desactivado') + else if (!fs.existsSync('/dev/dri')) console.log('HD: sin /dev/dri en el contenedor, reescalado desactivado') + // Reescalar estos capítulos: los descargados ya, el resto al terminar su descarga. + function request(chapterIds) { + const have = db.prepare('SELECT 1 FROM downloaded_chapters WHERE chapter_id = ?') + const later = db.prepare('INSERT OR IGNORE INTO upscale_wanted (chapter_id, created_at) VALUES (?, ?)') + const now = [] + for (const id of chapterIds) have.get(id) ? now.push(id) : later.run(id, Date.now()) + if (now.length) enqueue(now) + } + + return { info, hdFileOf, onChapterDownloaded, cancel, route, pump, available, queue, request } +} diff --git a/app/volumes.js b/app/volumes.js new file mode 100644 index 0000000..a8f05be --- /dev/null +++ b/app/volumes.js @@ -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 /tomes//-.img. +import fs from 'node:fs' +import path from 'node:path' +import crypto from 'node:crypto' + +const DAY = 24 * 3600e3 +const NDL = 'https://ndlsearch.ndl.go.jp/api/opensearch' +const OPEN_LIBRARY = 'https://openlibrary.org/search.json' +const AMAZON = 'https://m.media-amazon.com/images/P' +const WIKIPEDIA = 'https://en.wikipedia.org/w/api.php' +const MAX_IMAGE = 30 * 1024 * 1024 +const MIN_IMAGE = 8000 // por debajo son el píxel transparente o una miniatura de Amazon +const VOL = /^[\p{L}\p{N}._-]{1,16}$/u +const MD_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/ + +// "01" -> "1", "1.50" -> "1.5", "Vol. 3" no es un número y se queda igual. +export const volKey = v => { + const s = String(v ?? '').trim() + if (!s || s.toLowerCase() === 'none') return null + return /^\d+(\.\d+)?$/.test(s) ? String(parseFloat(s)) : s +} +const numKey = n => { const f = parseFloat(n); return Number.isFinite(f) ? String(f) : null } +const volOrder = (a, b) => (parseFloat(a) || Infinity) - (parseFloat(b) || Infinity) || String(a).localeCompare(String(b)) + +// Título comparable: sin signos, espacios ni diferencias de anchura. +export const titleKey = s => String(s || '').normalize('NFKC').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '') +export function ndlVolume(s) { + const m = /^(?:vol\.?|第)?\s*(\d{1,4})\s*巻?$/i.exec(String(s || '').normalize('NFKC').trim()) + return m ? String(parseInt(m[1], 10)) : null +} +export function isbn10(raw) { + const s = String(raw || '').replace(/[^0-9Xx]/g, '').toUpperCase() + if (/^\d{9}[\dX]$/.test(s)) return s + if (!/^978\d{10}$/.test(s)) return null + const body = s.slice(3, 12) + let sum = 0 + for (let i = 0; i < 9; i++) sum += (10 - i) * Number(body[i]) + const c = (11 - (sum % 11)) % 11 + return body + (c === 10 ? 'X' : String(c)) +} +const xmlText = s => String(s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").trim() + +// De los registros de la NDL, los tomos de esta obra: mismo título exacto, tomo numérico, clasificado como manga y del +// autor con más tomos. Si el primer tomo sale muy lejos del año de la obra, es otra obra con el mismo título. +export function pickIsbns(xmlPages, titles, year) { + const keys = new Set(titles.map(titleKey).filter(Boolean)) + const groups = new Map() + for (const xml of xmlPages) { + for (const [, it] of xml.matchAll(/([\s\S]*?)<\/item>/g)) { + if (!keys.has(titleKey(xmlText(/([\s\S]*?)<\/dc:title>/.exec(it)?.[1])))) continue + const vol = ndlVolume(xmlText(/([\s\S]*?)<\/dcndl:volume>/.exec(it)?.[1])) + if (!vol) continue + const isbns = [...it.matchAll(/xsi:type="dcndl:ISBN">([^<]+) isbn10(m[1])).filter(Boolean) + if (!isbns.length) continue + const author = titleKey(xmlText(/([\s\S]*?)<\/author>/.exec(it)?.[1]).split(/[, ]/)[0]) || '?' + const when = parseInt(/]*>(\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 } } +} diff --git a/data/.keep b/data/.keep new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4e191dd --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,56 @@ +# Todo lo que depende de la máquina va en .env (ver .env.example). +# Sin .env también arranca: puerto 3352 en todas las interfaces, descargas en ./mangas. +services: + manga-reader: + build: . + image: manga-reader:local + container_name: manga-reader + restart: unless-stopped + # Dueño de ./data y de la carpeta de mangas en el host (id -u / id -g). + user: "${PUID:-1000}:${PGID:-1000}" + environment: + - TZ=${TZ:-America/El_Salvador} + - PORT=3352 + - DATA_DIR=/app/data + - MANGA_DIR=/mangas + # Cada cuántas horas se buscan capítulos nuevos de los mangas seguidos. + - CHECK_INTERVAL_HOURS=${CHECK_INTERVAL_HOURS:-3} + # URL con la que se abre la web desde las notificaciones. + - PUBLIC_URL=${PUBLIC_URL:-http://localhost:3352} + # Registro de cuentas nuevas. Con la base vacía el primer usuario siempre puede registrarse. + - ALLOW_SIGNUP=${ALLOW_SIGNUP:-false} + # Baneo de IP: tras BAN_MAX_FAILS intentos fallidos en BAN_WINDOW_MIN minutos, BAN_HOURS horas. + - BAN_MAX_FAILS=${BAN_MAX_FAILS:-6} + - BAN_WINDOW_MIN=${BAN_WINDOW_MIN:-60} + - BAN_HOURS=${BAN_HOURS:-24} + - BAN_IGNORE=${BAN_IGNORE:-} + # Detrás de un proxy inverso (Caddy, Nginx, Cloudflare Tunnel…): cabecera con la IP real del + # cliente, p. ej. x-forwarded-for o cf-connecting-ip. Vacío = conexión directa. + - REAL_IP_HEADER=${REAL_IP_HEADER:-} + volumes: + # SQLite con usuarios, sesiones, historial, estadísticas y clave del proxy de imágenes. + - ./data:/app/data + # Mangas descargados: /Capitulo NNNN.cbz + cover.jpg + - ${MANGA_DIR_HOST:-./mangas}:/mangas + # GPU para el reescalado HD (Real-ESRGAN por Vulkan). Sin /dev/dri en el host, borra estas líneas + # y el reescalado queda desactivado. RENDER_GID = grupo "render" del host (getent group render). + devices: + - /dev/dri:/dev/dri + group_add: + - "${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.0.2.10 en .env. + - "${BIND_IP:-0.0.0.0}:${HOST_PORT:-3352}:3352" + read_only: true + tmpfs: + - /tmp + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" diff --git a/mangas/.keep b/mangas/.keep new file mode 100644 index 0000000..e69de29