Fuente M440, modo sin conexión (servidor sin internet y app sin servidor), cola del dispositivo en Colas y arreglo del reescalado de tiras altas

- M440 (m440.in, español): búsqueda, populares, actualizados paginados, ficha, capítulos, descargas y seguimiento. La lista de capítulos viene cifrada: la clave se saca de un script ofuscado del sitio ejecutándolo en un proceso aislado con permisos mínimos (m440-key.mjs) y se renueva sola.
- Servidor sin internet (connectivity.js): se detecta en segundos; las fuentes fallan al instante, la portada muestra lo descargado en el servidor con buscador, portadas locales de respaldo, descargas y comprobaciones en pausa y reanudación automática al volver la conexión.
- App sin conexión con el servidor: el service worker usa la copia guardada si el servidor no contesta en 3 s, las peticiones comprueban si el servidor sigue vivo en vez de quedarse colgadas, aviso arriba e inicio con lo guardado en el dispositivo; al volver, se reconecta sola y envía el progreso pendiente.
- Colas: sección «En este dispositivo» con el guardado en curso en directo y cancelación (sin dejar capítulos a medias); también visible sin servidor.
- Imágenes: el proxy y el guardado en el dispositivo reintentan con tiempo límite cuando el CDN de la fuente falla.
- Reescalado HD: las páginas que superan 16383 px al reescalar (tiras de webtoon) se reducen al límite de WebP en vez de hacer fallar el capítulo.
- Entidades HTML con nombre en las sinopsis; pestañas de fuentes cortas en móvil; portadas rotas con icono de respaldo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-01 09:52:19 -06:00
co-authored by Claude Opus 5.5
parent d3e196a2f3
commit 86999b5725
11 changed files with 712 additions and 78 deletions
+26 -3
View File
@@ -1,12 +1,17 @@
# Manga Reader # Manga Reader
Lector web de manga autoalojado, con las dos fuentes de Lector web de manga autoalojado, con las fuentes de
[Tanko](https://github.com/Alexandro521/Tanko) (`src/servers/`): [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 - **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). 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 - **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. 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. - Búsqueda, populares y actualizados recientemente.
- Lector por página (toques/flechas, izq→der o der→izq) o en vertical, modo ahorro de datos. - 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 - Usuarios con contraseña; el **historial y la página exacta** se guardan en el servidor
@@ -70,6 +75,24 @@ Sin `.env` arranca igual: puerto 3352 en todas las interfaces y descargas en `./
El primer usuario puede registrarse siempre; después el registro queda cerrado salvo El primer usuario puede registrarse siempre; después el registro queda cerrado salvo
`ALLOW_SIGNUP=true` en `.env`. `ALLOW_SIGNUP=true` en `.env`.
## 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 ## Exponerlo a internet
- **Pon HTTPS delante** (Caddy, Nginx, Cloudflare Tunnel o `tailscale funnel`): sin HTTPS la contraseña - **Pon HTTPS delante** (Caddy, Nginx, Cloudflare Tunnel o `tailscale funnel`): sin HTTPS la contraseña
+53
View File
@@ -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),
}
}
+8 -4
View File
@@ -41,7 +41,7 @@ const MODES = ['paged', 'double', 'vertical']
const FITS = ['height', 'width'] const FITS = ['height', 'width']
const DIRS = ['ltr', 'rtl'] const DIRS = ['ltr', 'rtl']
export function setupFeatures({ db, fetchMangaFull, enqueueDownload, downloadInfo, HttpError, json, readBody, MANGA_ID }) { export function setupFeatures({ db, fetchMangaFull, enqueueDownload, downloadInfo, HttpError, json, readBody, MANGA_ID, net }) {
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS follows ( CREATE TABLE IF NOT EXISTS follows (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@@ -182,14 +182,17 @@ export function setupFeatures({ db, fetchMangaFull, enqueueDownload, downloadInf
return anyNew return anyNew
} }
let skippedOffline = false
function checkFollows() { function checkFollows() {
if (state.checking) return state.checking 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 () => { state.checking = (async () => {
const groups = db.prepare('SELECT manga_id, lang FROM follows WHERE active = 1 GROUP BY manga_id, lang').all() const groups = db.prepare('SELECT manga_id, lang FROM follows WHERE active = 1 GROUP BY manga_id, lang').all()
let found = 0 let found = 0
for (const g of groups) { 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) } try { found += await checkManga(g.manga_id, g.lang) } catch (e) { console.warn('Comprobación', g.manga_id, e.message) }
await sleep(g.manga_id.startsWith('lc~') ? 1500 : 2500) await sleep(/^(lc|mi)~/.test(g.manga_id) ? 1500 : 2500)
} }
state.lastCheckAt = Date.now() state.lastCheckAt = Date.now()
if (groups.length) console.log(`Comprobados ${groups.length} mangas seguidos: ${found} capítulo(s) nuevo(s)`) if (groups.length) console.log(`Comprobados ${groups.length} mangas seguidos: ${found} capítulo(s) nuevo(s)`)
@@ -199,6 +202,7 @@ export function setupFeatures({ db, fetchMangaFull, enqueueDownload, downloadInf
}) })
return state.checking return state.checking
} }
net.whenOnline(() => { if (skippedOffline) { skippedOffline = false; checkFollows() } })
state.nextCheckAt = Date.now() + 90e3 state.nextCheckAt = Date.now() + 90e3
setTimeout(() => { setTimeout(() => {
checkFollows() checkFollows()
@@ -217,7 +221,7 @@ export function setupFeatures({ db, fetchMangaFull, enqueueDownload, downloadInf
// Seguir automáticamente lo que se empieza a leer (salvo que se haya dejado de seguir). // Seguir automáticamente lo que se empieza a leer (salvo que se haya dejado de seguir).
db.prepare('INSERT OR IGNORE INTO follows (user_id, manga_id, title, cover, lang, created_at) VALUES (?, ?, ?, ?, ?, ?)') db.prepare('INSERT OR IGNORE INTO follows (user_id, manga_id, title, cover, lang, created_at) VALUES (?, ?, ?, ?, ?, ?)')
.run(userId, b.mangaId, String(b.mangaTitle || '').slice(0, 300), b.cover ? String(b.cover).slice(0, 1000) : null, .run(userId, b.mangaId, String(b.mangaTitle || '').slice(0, 300), b.cover ? String(b.cover).slice(0, 1000) : null,
b.mangaId.startsWith('lc~') ? 'es' : b.lang || null, Date.now()) /^(lc|mi)~/.test(b.mangaId) ? 'es' : b.lang || null, Date.now())
} }
// Datos extra de la ficha; marca como vistos los capítulos nuevos. // Datos extra de la ficha; marca como vistos los capítulos nuevos.
@@ -321,7 +325,7 @@ export function setupFeatures({ db, fetchMangaFull, enqueueDownload, downloadInf
} }
if ((m = /^\/api\/follow\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'POST') { if ((m = /^\/api\/follow\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'POST') {
const b = await readBody(req) const b = await readBody(req)
const lang = m[1].startsWith('lc~') ? 'es' : /^[a-z]{2}(-[a-z]{2})?$/.test(b.lang || '') ? b.lang : user.lang 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, ?) 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, 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`) known = CASE WHEN follows.lang IS excluded.lang THEN follows.known ELSE NULL END`)
+5 -4
View File
@@ -6,7 +6,7 @@ const REC_TTL = 6 * 3600e3
import { genreName } from './features.js' import { genreName } from './features.js'
const norm = s => String(s).trim().toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '') const norm = s => String(s).trim().toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '')
export function setupLibrary({ db, HttpError, json, readBody, MANGA_ID, fetchMangaFull, enqueueDownload, upscale, stats, searchManga, mdCategories }) { export function setupLibrary({ db, HttpError, json, readBody, MANGA_ID, fetchMangaFull, enqueueDownload, upscale, stats, searchManga, mdCategories, net }) {
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS lists ( CREATE TABLE IF NOT EXISTS lists (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -54,12 +54,12 @@ export function setupLibrary({ db, HttpError, json, readBody, MANGA_ID, fetchMan
// Al pasar la mitad de un capítulo, encola los N siguientes (y, si se pidió, su versión HD). // 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) { function onProgress(userId, b, page, total) {
const cfg = prefetchCfg(userId) const cfg = prefetchCfg(userId)
if (!cfg.n || !total || page < Math.floor(total / 2)) return if (!cfg.n || !total || page < Math.floor(total / 2) || !net.online()) return
const key = `${b.mangaId}|${b.chapterId}|${cfg.n}|${cfg.hd}` const key = `${b.mangaId}|${b.chapterId}|${cfg.n}|${cfg.hd}`
if (Date.now() - (recent.get(key) || 0) < 30 * 60e3) return if (Date.now() - (recent.get(key) || 0) < 30 * 60e3) return
recent.set(key, Date.now()) recent.set(key, Date.now())
if (recent.size > 2000) recent.delete(recent.keys().next().value) if (recent.size > 2000) recent.delete(recent.keys().next().value)
const lang = b.mangaId.startsWith('lc~') ? 'es' : b.lang const lang = /^(lc|mi)~/.test(b.mangaId) ? 'es' : b.lang
fetchMangaFull(b.mangaId, lang).then(({ manga, chapters }) => { fetchMangaFull(b.mangaId, lang).then(({ manga, chapters }) => {
const i = chapters.findIndex(c => c.id === b.chapterId) const i = chapters.findIndex(c => c.id === b.chapterId)
if (i < 0) return if (i < 0) return
@@ -82,6 +82,7 @@ export function setupLibrary({ db, HttpError, json, readBody, MANGA_ID, fetchMan
async function recommendations(userId, refresh) { async function recommendations(userId, refresh) {
const hit = recCache.get(userId) const hit = recCache.get(userId)
if (hit && !refresh && Date.now() - hit.at < REC_TTL) return hit.data 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 lang = db.prepare('SELECT lang FROM users WHERE id = ?').get(userId)?.lang || 'es-la'
const genres = stats(userId).genres.slice(0, 5) const genres = stats(userId).genres.slice(0, 5)
const cats = (await mdCategories().catch(() => [])).filter(c => c.group === 'Géneros' || c.group === 'Temáticas') const cats = (await mdCategories().catch(() => [])).filter(c => c.group === 'Géneros' || c.group === 'Temáticas')
@@ -105,7 +106,7 @@ export function setupLibrary({ db, HttpError, json, readBody, MANGA_ID, fetchMan
const items = [...score.values()].sort((a, b) => b.pts - a.pts).slice(0, 18) 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 ')}` })) .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 } const data = { genres: picked.map(x => ({ name: x.g.name, id: x.cat.id, source: 'md' })), items }
recCache.set(userId, { at: Date.now(), data }) if (items.length) recCache.set(userId, { at: Date.now(), data }) // vacío (fuente caída): no guardarlo 6 h
return data return data
} }
+55
View File
@@ -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)
+190
View File
@@ -0,0 +1,190 @@
// Fuente M440.in (MangasIn, tema MMRCMS), en español.
// IDs: manga "mi~<slug>", capítulo "mi~<slug>~<slug del capítulo>".
// - 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 <img data-src> (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 = /<img[^>]+src="([^"]+)"/.exec(e.label || '')?.[1]
const status = /<span class="label[^"]*" title="([^"]+)"/.exec(e.label || '')?.[1]
return { id, source: 'mi', title: decode(e.value), cover: cover(img ? img.replace('cover_thumb', 'cover_250x350') : coverOf(e.data), id), sub: status ? decode(status) : undefined }
})
}
if (mode === 'latest') {
const d = await get(`/lasted?p=${Math.max(1, page | 0)}`, 10 * 60e3, true)
return (d?.data || []).filter(e => /^[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 class="label[^"]*" href="([^"]+)"[^>]*>([^<]+)<\/a>[\s\S]*?<img src='([^']+)'[\s\S]*?<i class="fa fa-book"><\/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(`<dt>${label}</dt>\\s*<dd[^>]*>([\\s\\S]*?)</dd>`).exec(html)
return m ? m[1] : ''
}
const links = s => [...s.matchAll(/<a [^>]*>([^<]+)<\/a>/g)].map(m => decode(m[1])).filter(Boolean)
const well = /<div class="well">\s*<h5>[\s\S]*?Sinopsis[\s\S]*?<\/h5>([\s\S]*?)<\/div>/.exec(html)?.[1] || ''
// Los saltos del código fuente no cuentan: solo <br> (línea) y </p> (párrafo).
const description = decode(well.replace(/\s+/g, ' ').replace(/<br\s*\/?>/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(/<h2 class="widget-title"[^>]*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]*?<img class="img-responsive" src='([^']+)'/.exec(html)?.[1] || coverOf(slug), id),
},
chapters,
}
}
async function pageUrls(chapterId) {
const [, slug, ch] = chapterId.split('~')
const html = await get(`/manga/${slug}/${ch}`, 30 * 60e3)
const main = html.slice(Math.max(0, html.indexOf('id="all"')))
const pages = [...main.matchAll(/<img[^>]*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 }
}
+228 -31
View File
@@ -24,6 +24,7 @@ const ICONS = {
flame: '<path d="M12 22c3.9 0 7-2.8 7-7 0-3.5-2.4-5.6-3.6-8.6-.9 1.8-1.9 2.9-3 3.2.2-3.1-.9-5.6-3-7.6.3 4-4.4 6.8-4.4 12.6C5 19.2 8.1 22 12 22z"/>', flame: '<path d="M12 22c3.9 0 7-2.8 7-7 0-3.5-2.4-5.6-3.6-8.6-.9 1.8-1.9 2.9-3 3.2.2-3.1-.9-5.6-3-7.6.3 4-4.4 6.8-4.4 12.6C5 19.2 8.1 22 12 22z"/>',
clock: '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>', clock: '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
grid: '<rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/>', grid: '<rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/>',
wifiOff: '<path d="M2 2l20 20"/><path d="M8.5 16.4a5 5 0 0 1 7 0"/><path d="M5 12.9a10 10 0 0 1 5.2-2.8"/><path d="M14.4 10.2A10 10 0 0 1 19 12.9"/><path d="M2 8.8a15 15 0 0 1 4.2-2.7"/><path d="M10.7 5A15 15 0 0 1 22 8.8"/><path d="M12 20h.01"/>',
globe: '<circle cx="12" cy="12" r="9"/><path d="M3 12h18"/><path d="M12 3a14 14 0 0 1 0 18 14 14 0 0 1 0-18z"/>', globe: '<circle cx="12" cy="12" r="9"/><path d="M3 12h18"/><path d="M12 3a14 14 0 0 1 0 18 14 14 0 0 1 0-18z"/>',
key: '<circle cx="8" cy="15" r="4"/><path d="m11 12 9-9M17 6l3 3"/>', key: '<circle cx="8" cy="15" r="4"/><path d="m11 12 9-9M17 6l3 3"/>',
bell: '<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.9 1.9 0 0 0 3.4 0"/>', bell: '<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.9 1.9 0 0 0 3.4 0"/>',
@@ -49,7 +50,8 @@ const icon = name => `<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="cu
// Iconos del menú superior (index.html). // Iconos del menú superior (index.html).
document.querySelectorAll('#topbar nav a[data-icon]').forEach(a => { a.innerHTML = `${icon(a.dataset.icon)}<span>${a.textContent}</span>` }) document.querySelectorAll('#topbar nav a[data-icon]').forEach(a => { a.innerHTML = `${icon(a.dataset.icon)}<span>${a.textContent}</span>` })
const SOURCES = { lc: 'LeerCapitulo (español)', md: 'MangaDex (multi-idioma)' } const SOURCES = { lc: 'LeerCapitulo (español)', mi: 'M440 (español)', md: 'MangaDex (multi-idioma)' }
const NO_CATS = new Set(['mi']) // fuentes sin listado por categorías
// Filtros de imagen del lector (CSS filter); el brillo se suma aparte. // Filtros de imagen del lector (CSS filter); el brillo se suma aparte.
const FILTERS = { const FILTERS = {
none: ['Sin filtro', ''], none: ['Sin filtro', ''],
@@ -112,17 +114,82 @@ function toast(msg) {
clearTimeout(toast.t) clearTimeout(toast.t)
toast.t = setTimeout(() => (t.hidden = true), 2500) toast.t = setTimeout(() => (t.hidden = true), 2500)
} }
const NO_SERVER = 'Sin conexión con el servidor'
async function api(path, opts = {}) { async function api(path, opts = {}) {
const res = await fetch(path, { // Si tarda, se comprueba si el servidor sigue ahí (una fuente lenta no es motivo para cortar, un
...opts, // servidor que no contesta sí): así nada se queda en «Cargando…» para siempre sin red.
headers: opts.body ? { 'Content-Type': 'application/json' } : undefined, const ctrl = new AbortController()
body: opts.body ? JSON.stringify(opts.body) : undefined, 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(() => ({})) const data = await res.json().catch(() => ({}))
if (res.status === 401 && !path.startsWith('/api/login')) { me = null; location.hash = '#/login'; throw new Error('No autenticado') } 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}`) if (!res.ok) throw new Error(data.error || `Error ${res.status}`)
return data 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')}<span><b>${NO_SERVER}.</b> ${OFFLINE_OK ? 'Puedes leer lo guardado en este dispositivo; ' : ''}se reconecta solo.</span>`
: !serverOnline ? `${icon('wifiOff')}<span><b>El servidor no tiene internet.</b> Puedes leer lo descargado en el servidor; se reconecta solo.</span>` : ''
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) { function timeAgo(ts) {
const s = (Date.now() - ts) / 1000 const s = (Date.now() - ts) / 1000
if (s < 60) return 'hace un momento' if (s < 60) return 'hace un momento'
@@ -140,7 +207,7 @@ const OFF_CACHE = 'offline-v1'
if (OFFLINE_OK) navigator.serviceWorker.register('/sw.js').catch(() => {}) if (OFFLINE_OK) navigator.serviceWorker.register('/sw.js').catch(() => {})
const offIdx = () => store.get('offlineChapters', {}) // chapterId -> { mangaId, mangaTitle, cover, lang, label, num, pages, bytes, at } const offIdx = () => store.get('offlineChapters', {}) // chapterId -> { mangaId, mangaTitle, cover, lang, label, num, pages, bytes, at }
const offHas = id => !!offIdx()[id] const offHas = id => !!offIdx()[id]
const offState = { running: null, queue: [], listeners: new Set() } const offState = { running: null, queue: [], listeners: new Set(), cancelled: new Set(), abort: null }
const offEmit = () => offState.listeners.forEach(f => { try { f() } catch {} }) const offEmit = () => offState.listeners.forEach(f => { try { f() } catch {} })
// Capítulos guardados agrupados por manga. // Capítulos guardados agrupados por manga.
function offByManga() { function offByManga() {
@@ -165,19 +232,40 @@ async function offRun() {
const it = offState.queue.shift() const it = offState.queue.shift()
offState.running = { ...it, done: 0, total: 0 } offState.running = { ...it, done: 0, total: 0 }
offEmit() offEmit()
try { await offSaveChapter(it) } catch (err) { toast(`No se pudo guardar ${it.label}: ${err.message}`) } 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 offState.running = null
offEmit() 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) { async function offSaveChapter(it) {
const cache = await caches.open(OFF_CACHE) 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 hd = store.get('readerSettings', {}).hd !== false
const chapUrl = `/api/chapter/${it.chapterId}?saver=0&hd=${hd ? 1 : 0}` const chapUrl = `/api/chapter/${it.chapterId}?saver=0&hd=${hd ? 1 : 0}`
const res = await fetch(chapUrl) const res = await fetch(chapUrl, { signal })
if (!res.ok) throw new Error(`Error ${res.status}`) if (!res.ok) throw new Error(`Error ${res.status}`)
const data = await res.clone().json() const data = await res.clone().json()
await cache.put(chapUrl, res) await cache.put(chapUrl, res)
saved.push(chapUrl)
const mUrl = `/api/manga/${it.mangaId}?lang=${encodeURIComponent(it.lang || '')}` const mUrl = `/api/manga/${it.mangaId}?lang=${encodeURIComponent(it.lang || '')}`
const mres = await fetch(mUrl).catch(() => null) const mres = await fetch(mUrl).catch(() => null)
if (mres?.ok) await cache.put(mUrl, mres) if (mres?.ok) await cache.put(mUrl, mres)
@@ -186,17 +274,33 @@ async function offSaveChapter(it) {
let bytes = 0, i = 0 let bytes = 0, i = 0
const worker = async () => { const worker = async () => {
while (i < data.pages.length) { while (i < data.pages.length) {
stop()
const u = data.pages[i++] const u = data.pages[i++]
const r = await fetch(u) // Hasta 3 intentos por página (las fuentes fallan a ratos), cada uno con tiempo límite.
if (!r.ok) throw new Error(`página ${r.status}`) let blob, type
const blob = await r.blob() 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 bytes += blob.size
await cache.put(u, new Response(blob, { headers: { 'Content-Type': r.headers.get('Content-Type') || 'image/jpeg' } })) stop()
await cache.put(u, new Response(blob, { headers: { 'Content-Type': type || 'image/jpeg' } }))
saved.push(u)
offState.running.done++ offState.running.done++
offEmit() offEmit()
} }
} }
await Promise.all([worker(), worker(), worker()]) await Promise.all([worker(), worker(), worker()])
stop()
const idx = offIdx() 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() } 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) store.set('offlineChapters', idx)
@@ -246,7 +350,7 @@ async function router() {
document.querySelectorAll('#topbar nav a').forEach(a => a.classList.toggle('active', a.getAttribute('href') === `#/${parts[0] ?? ''}`)) document.querySelectorAll('#topbar nav a').forEach(a => a.classList.toggle('active', a.getAttribute('href') === `#/${parts[0] ?? ''}`))
if (!me) { if (!me) {
const r = await api('/api/me').catch(() => ({})) const [r] = await Promise.all([api('/api/me').catch(() => ({})), netChecked ? null : refreshNet()])
me = r.user me = r.user
me && (me.signup = r.signup) me && (me.signup = r.signup)
if (!me) return viewAuth(r.signup) if (!me) return viewAuth(r.signup)
@@ -301,14 +405,14 @@ function viewAuth(signup) {
// ---------- Inicio ---------- // ---------- Inicio ----------
function mangaCard(m) { function mangaCard(m) {
return `<a class="card" href="#/manga/${m.id}"> return `<a class="card" href="#/manga/${m.id}">
<img class="cover" loading="lazy" src="${esc(m.cover || '/icon.svg')}" alt=""> <img class="cover" loading="lazy" src="${esc(m.cover || '/icon.svg')}" alt="" data-fallback>
<div class="t">${esc(m.title)}</div>${m.sub ? `<div class="sub">${esc(m.sub)}</div>` : ''}</a>` <div class="t">${esc(m.title)}</div>${m.sub ? `<div class="sub">${esc(m.sub)}</div>` : ''}</a>`
} }
function continueCard(h) { function continueCard(h) {
const pct = h.total_pages ? Math.round(((h.page + 1) / h.total_pages) * 100) : 0 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. // Terminado con capítulos nuevos: a la ficha, para elegir el siguiente.
return `<a class="cont" href="${h.hasNew ? `#/manga/${h.manga_id}` : readUrl(h.manga_id, h.chapter_id, h.page)}"> return `<a class="cont" href="${h.hasNew ? `#/manga/${h.manga_id}` : readUrl(h.manga_id, h.chapter_id, h.page)}">
<img src="${esc(h.cover || '/icon.svg')}" alt="" loading="lazy"> <img src="${esc(h.cover || '/icon.svg')}" alt="" loading="lazy" data-fallback>
<div class="info"><b>${esc(h.manga_title)}</b> <div class="info"><b>${esc(h.manga_title)}</b>
<div class="muted">${h.chapter_num ? `Cap. ${esc(h.chapter_num)}` : 'Oneshot'} · pág. ${h.page + 1}/${h.total_pages || '?'}</div> <div class="muted">${h.chapter_num ? `Cap. ${esc(h.chapter_num)}` : 'Oneshot'} · pág. ${h.page + 1}/${h.total_pages || '?'}</div>
${h.finished ? '<div><span class="badge">Terminado</span></div>' : h.hasNew ? '<div><span class="badge">Caps. nuevos</span></div>' : ''} ${h.finished ? '<div><span class="badge">Terminado</span></div>' : h.hasNew ? '<div><span class="badge">Caps. nuevos</span></div>' : ''}
@@ -363,25 +467,61 @@ async function loadRecs(refresh = false) {
<div class="recrow">${r.items.map(mangaCard).join('')}</div>` <div class="recrow">${r.items.map(mangaCard).join('')}</div>`
$('#recref', box).addEventListener('click', e => { e.currentTarget.disabled = true; loadRecs(true) }) $('#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 = `
<section id="cont"></section>
<h2>${icon('download')} Descargado en el servidor</h2>
<form class="search"><input type="search" name="q" placeholder="Buscar en lo descargado…" value="${esc(params.get('q') || '')}" autocomplete="off"></form>
<div id="results" class="loading">Cargando…</div>`
const [list, hist] = await Promise.all([api('/api/search?q='), api('/api/history').catch(() => [])])
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 = `<h2>Continuar leyendo</h2><div class="continue">${cont.slice(0, 12).map(continueCard).join('')}</div>`
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 = `<h2>${icon('phone')} En este dispositivo</h2>` + (dev.length
? `<div class="grid">${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('')}</div>`
: `<p class="muted">${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.'}</p>`)
}
async function viewHome(params) { async function viewHome(params) {
if (!serverReachable) return viewHomeDevice()
if (!serverOnline) return viewHomeOffline(params)
const q = params.get('q') || '' const q = params.get('q') || ''
const tag = params.get('tag') || '' const tag = params.get('tag') || ''
const tagName = params.get('tagName') || tag const tagName = params.get('tagName') || tag
const mode = tag ? 'cats' : params.get('mode') || 'popular'
const anyLang = params.get('any') === '1' const anyLang = params.get('any') === '1'
const source = SOURCES[params.get('source')] ? params.get('source') : store.get('source', 'lc') const source = SOURCES[params.get('source')] ? params.get('source') : store.get('source', 'lc')
let mode = tag ? 'cats' : params.get('mode') || 'popular'
if (mode === 'cats' && NO_CATS.has(source)) mode = 'popular'
store.set('source', source) store.set('source', source)
const showCats = mode === 'cats' && !tag const showCats = mode === 'cats' && !tag
app.innerHTML = ` app.innerHTML = `
<section id="cont"></section> <section id="cont"></section>
<section id="recs"></section> <section id="recs"></section>
<div class="tabs" id="sources">${Object.entries(SOURCES).map(([k, v]) => `<button data-s="${k}" class="${k === source ? 'on' : ''}">${v}</button>`).join('')}</div> <div class="tabs" id="sources">${Object.entries(SOURCES).map(([k, v]) => `<button data-s="${k}" class="${k === source ? 'on' : ''}" title="${esc(v)}">${esc(v.replace(/\s*\(.*\)$/, ''))}</button>`).join('')}</div>
<form class="search"><input type="search" name="q" placeholder="Buscar ${tag ? `en «${esc(tagName)}»` : `en ${esc(SOURCES[source])}`}…" value="${esc(q)}"><button class="btn primary">${icon('search')}<span class="lbl">Buscar</span></button></form> <form class="search"><input type="search" name="q" placeholder="Buscar ${tag ? `en «${esc(tagName)}»` : `en ${esc(SOURCES[source])}`}…" value="${esc(q)}"><button class="btn primary">${icon('search')}<span class="lbl">Buscar</span></button></form>
<label class="muted row" style="font-size:.88rem" ${source === 'md' ? '' : 'hidden'}><input type="checkbox" name="any" ${anyLang ? 'checked' : ''}> Incluir mangas sin traducción a ${esc(LANGS[me.lang] || me.lang)}</label> <label class="muted row" style="font-size:.88rem" ${source === 'md' ? '' : 'hidden'}><input type="checkbox" name="any" ${anyLang ? 'checked' : ''}> Incluir mangas sin traducción a ${esc(LANGS[me.lang] || me.lang)}</label>
${q && !tag ? `<h2>Resultados para “${esc(q)}”</h2>` : `<div class="tabs" id="modes"> ${q && !tag ? `<h2>Resultados para “${esc(q)}”</h2>` : `<div class="tabs" id="modes">
<button data-m="popular" class="${mode === 'popular' ? 'on' : ''}">${icon('flame')} Populares</button> <button data-m="popular" class="${mode === 'popular' ? 'on' : ''}">${icon('flame')} Populares</button>
<button data-m="latest" class="${mode === 'latest' ? 'on' : ''}">${icon('clock')} Actualizados recientemente</button> <button data-m="latest" class="${mode === 'latest' ? 'on' : ''}">${icon('clock')} Actualizados recientemente</button>
<button data-m="cats" class="${mode === 'cats' ? 'on' : ''}">${icon('grid')} Categorías</button></div>`} ${NO_CATS.has(source) ? '' : `<button data-m="cats" class="${mode === 'cats' ? 'on' : ''}">${icon('grid')} Categorías</button>`}</div>`}
${tag ? `<h2>Categoría: <a class="tag on" href="#/?source=${source}&mode=cats" title="Quitar filtro">${esc(tagName)} ✕</a>${q ? ` · “${esc(q)}”` : ''}</h2>` : ''} ${tag ? `<h2>Categoría: <a class="tag on" href="#/?source=${source}&mode=cats" title="Quitar filtro">${esc(tagName)} ✕</a>${q ? ` · “${esc(q)}”` : ''}</h2>` : ''}
<div id="results" class="loading">Cargando…</div> <div id="results" class="loading">Cargando…</div>
<div class="row" style="justify-content:center;margin:16px 0"><button class="btn" id="more" hidden>${icon('more')} Cargar más</button></div>` <div class="row" style="justify-content:center;margin:16px 0"><button class="btn" id="more" hidden>${icon('more')} Cargar más</button></div>`
@@ -413,8 +553,8 @@ async function viewHome(params) {
return return
} }
// Paginación: MangaDex siempre; LeerCapitulo solo en categorías (sus otros listados no paginan). // Paginación: MangaDex siempre; LeerCapitulo solo en categorías; M440 en "Actualizados" (sus otros listados no paginan).
const pageable = source === 'md' || !!tag const pageable = source === 'md' || !!tag || (source === 'mi' && mode === 'latest' && !q)
const seen = new Set() const seen = new Set()
let page = 1 let page = 1
const load = async () => { const load = async () => {
@@ -446,9 +586,10 @@ async function viewManga(id, langOverride) {
const newSet = new Set(newKeys.map(String)) const newSet = new Set(newKeys.map(String))
let isFollowing = following let isFollowing = following
const readMap = new Map(reads.map(r => [r.chapter_id, r])) const readMap = new Map(reads.map(r => [r.chapter_id, r]))
const isLc = manga.source === 'lc' const isLc = manga.source !== 'md' // sitios en español sin selector de idioma
const langOpts = [...new Set([lang, me.lang, ...chapterLangs, ...manga.languages])].filter(Boolean) const langOpts = [...new Set([lang, me.lang, ...chapterLangs, ...manga.languages])].filter(Boolean)
const altSearch = `#/?source=lc&q=${encodeURIComponent(manga.title)}` const altSearch = `#/?source=lc&q=${encodeURIComponent(manga.title)}`
const altSearchMi = `#/?source=mi&q=${encodeURIComponent(manga.title)}`
const first = chapters[0] const first = chapters[0]
let mainBtn = '' let mainBtn = ''
if (progress && chapters.some(c => c.id === progress.chapter_id)) { if (progress && chapters.some(c => c.id === progress.chapter_id)) {
@@ -459,7 +600,8 @@ async function viewManga(id, langOverride) {
if (first && !mainBtn) mainBtn = `<a class="btn primary" href="${readUrl(id, first.id)}">${icon('book')} Empezar a leer</a>` if (first && !mainBtn) mainBtn = `<a class="btn primary" href="${readUrl(id, first.id)}">${icon('book')} Empezar a leer</a>`
const menuItems = [ const menuItems = [
isLc ? '' : `<a href="${altSearch}">${icon('search')} Buscar en LeerCapitulo</a>`, manga.source === 'lc' ? '' : `<a href="${altSearch}">${icon('search')} Buscar en LeerCapitulo</a>`,
manga.source === 'mi' ? '' : `<a href="${altSearchMi}">${icon('search')} Buscar en M440</a>`,
progress || reads.length ? `<button id="drop" class="danger">${icon('stopRead')} Dejar de leer…</button>` : '', progress || reads.length ? `<button id="drop" class="danger">${icon('stopRead')} Dejar de leer…</button>` : '',
].join('') ].join('')
@@ -489,7 +631,7 @@ async function viewManga(id, langOverride) {
</div> </div>
</div> </div>
${chapters.length ? '<ul class="chapters"></ul>' : isLc ? '<p class="muted">Este manga no tiene capítulos.</p>' : `<p class="muted">No hay capítulos legibles en ${esc(LANGS[lang] || lang)} (MangaDex retira algunos por licencia). ${chapters.length ? '<ul class="chapters"></ul>' : isLc ? '<p class="muted">Este manga no tiene capítulos.</p>' : `<p class="muted">No hay capítulos legibles en ${esc(LANGS[lang] || lang)} (MangaDex retira algunos por licencia).
Prueba a <a class="tag" href="${altSearch}">buscarlo en LeerCapitulo</a>. Prueba a buscarlo en <a class="tag" href="${altSearch}">LeerCapitulo</a> o <a class="tag" href="${altSearchMi}">M440</a>.
${chapterLangs.length ? `Disponibles en: ${chapterLangs.map(l => `<a class="tag" href="#" data-lang="${esc(l)}">${esc(LANGS[l] || l)}</a>`).join(' ')}` : 'No hay capítulos en ningún idioma.'}</p>`} ${chapterLangs.length ? `Disponibles en: ${chapterLangs.map(l => `<a class="tag" href="#" data-lang="${esc(l)}">${esc(LANGS[l] || l)}</a>`).join(' ')}` : 'No hay capítulos en ningún idioma.'}</p>`}
<div id="selbar" hidden></div>` <div id="selbar" hidden></div>`
@@ -955,7 +1097,7 @@ async function viewReader(mangaId, chapterId, startPage) {
let saveTimer = null let saveTimer = null
const payload = () => ({ const payload = () => ({
mangaId, chapterId, mangaTitle: manga.title, cover: manga.cover, chapterNum: chapter.num ?? null, mangaId, chapterId, mangaTitle: manga.title, cover: manga.cover, chapterNum: chapter.num ?? null,
chapterTitle: chapter.title, lang: manga.source === 'lc' ? 'es' : chapter.lang || lang, page: lastShown(), totalPages: total, chapterTitle: chapter.title, lang: manga.source !== 'md' ? 'es' : chapter.lang || lang, page: lastShown(), totalPages: total,
isLast: idx >= 0 && !next, // al terminarlo, el manga sale de "Continuar leyendo" isLast: idx >= 0 && !next, // al terminarlo, el manga sale de "Continuar leyendo"
}) })
const saveNow = () => { clearTimeout(saveTimer); saveTimer = null; const p = payload(); api('/api/progress', { method: 'POST', body: p }).catch(() => queueProgress(p)) } const saveNow = () => { clearTimeout(saveTimer); saveTimer = null; const p = payload(); api('/api/progress', { method: 'POST', body: p }).catch(() => queueProgress(p)) }
@@ -983,7 +1125,7 @@ async function viewReader(mangaId, chapterId, startPage) {
<select id="dir" title="Dirección (se recuerda para este manga)"><option value="ltr">Izq → Der</option><option value="rtl">Der → Izq</option></select> <select id="dir" title="Dirección (se recuerda para este manga)"><option value="ltr">Izq → Der</option><option value="rtl">Der → Izq</option></select>
<select id="filter" title="Filtro">${Object.entries(FILTERS).map(([k, [label]]) => `<option value="${k}">${label}</option>`).join('')}</select> <select id="filter" title="Filtro">${Object.entries(FILTERS).map(([k, [label]]) => `<option value="${k}">${label}</option>`).join('')}</select>
<label class="row muted" style="font-size:.85rem" title="Brillo">☀ <input type="range" id="bright" min="40" max="120" step="5" style="width:90px"></label> <label class="row muted" style="font-size:.85rem" title="Brillo">☀ <input type="range" id="bright" min="40" max="120" step="5" style="width:90px"></label>
${manga.source === 'lc' || chap.local ? '' : '<label class="row muted" style="font-size:.85rem"><input type="checkbox" id="saver"> Ahorro datos</label>'} ${manga.source !== 'md' || chap.local ? '' : '<label class="row muted" style="font-size:.85rem"><input type="checkbox" id="saver"> Ahorro datos</label>'}
${chap.hdAvailable ? '<label class="row muted" style="font-size:.85rem" title="Usar la versión reescalada de este capítulo"><input type="checkbox" id="hd"> Versión HD</label>' ${chap.hdAvailable ? '<label class="row muted" style="font-size:.85rem" title="Usar la versión reescalada de este capítulo"><input type="checkbox" id="hd"> Versión HD</label>'
: chap.local && info.upscale?.available ? `<button class="btn small" id="hdone" title="Reescala este capítulo con IA en segundo plano (unos minutos)">${icon('sparkles')} Reescalar a HD</button>` : ''} : chap.local && info.upscale?.available ? `<button class="btn small" id="hdone" title="Reescala este capítulo con IA en segundo plano (unos minutos)">${icon('sparkles')} Reescalar a HD</button>` : ''}
${OFFLINE_OK ? `<button class="btn small" id="offone" ${offHas(chapterId) ? 'disabled' : ''} title="Guarda las páginas en este dispositivo para leerlas sin internet">${icon('phone')} ${offHas(chapterId) ? 'En el dispositivo' : 'Guardar sin conexión'}</button>` : ''} ${OFFLINE_OK ? `<button class="btn small" id="offone" ${offHas(chapterId) ? 'disabled' : ''} title="Guarda las páginas en este dispositivo para leerlas sin internet">${icon('phone')} ${offHas(chapterId) ? 'En el dispositivo' : 'Guardar sin conexión'}</button>` : ''}
@@ -1581,7 +1723,9 @@ const libCard = ({ href, cover, title, sub, pct, badge, del }) => `<a class="car
async function viewLibrary(params) { async function viewLibrary(params) {
const tab = params.get('tab') || 'reading' const tab = params.get('tab') || 'reading'
app.innerHTML = '<div class="loading">Cargando…</div>' app.innerHTML = '<div class="loading">Cargando…</div>'
const [hist, fol, lists] = await Promise.all([api('/api/history').catch(() => null), api('/api/follows').catch(() => null), api('/api/lists').catch(() => [])]) 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 if (!location.hash.startsWith('#/library')) return
const online = !!hist const online = !!hist
const dev = OFFLINE_OK ? offByManga() : [] const dev = OFFLINE_OK ? offByManga() : []
@@ -1712,7 +1856,15 @@ async function viewQueues() {
if (cleanup) { cleanup(); cleanup = null } if (cleanup) { cleanup(); cleanup = null }
const here = () => location.hash === '#/queues' const here = () => location.hash === '#/queues'
if (!$('#qwrap')) app.innerHTML = '<div class="loading">Cargando…</div>' if (!$('#qwrap')) app.innerHTML = '<div class="loading">Cargando…</div>'
const [q, list] = await Promise.all([api('/api/queues'), api('/api/downloads')]) 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 if (!here()) return
const up = q.upscale const up = q.upscale
const library = list.filter(d => !DL_ACTIVE.includes(d.status)) const library = list.filter(d => !DL_ACTIVE.includes(d.status))
@@ -1733,7 +1885,36 @@ async function viewQueues() {
<div class="sub">${lines}</div> <div class="sub">${lines}</div>
${actions ? `<div class="qact">${actions}</div>` : ''} ${actions ? `<div class="qact">${actions}</div>` : ''}
</div>` </div>`
const html = `<h1>Colas</h1> // 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 `<h2>${icon('phone')} En este dispositivo ${items.length ? `<span class="badge">${items.length}</span>` : ''}</h2>
<p class="muted small">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: <a href="#/library?tab=device">${plural(saved.length, 'capítulo')} · ${fmtSize(bytes)}</a>.` : ''}</p>
${groups.size ? `<div class="grid qgrid">${[...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 ? `<div>${esc(cur.label)} · pág. ${cur.done}/${cur.total || '?'}</div>` : ''}
${rest ? `<div>${cur ? `${rest} más` : plural(rest, 'capítulo')} en cola</div>` : ''}`,
actions: `<button class="btn small" data-devcancel="${esc(g.mangaId)}">${icon('stop')} Cancelar</button>`,
})
}).join('')}</div>` : '<p class="muted">No se está guardando nada. Se añade desde la ficha («En este dispositivo») o desde el lector.</p>'}`
}
const html = q.offline ? `<h1>Colas</h1><p class="error">${esc(q.offline)}: no se pueden ver las colas del servidor.</p>
<section class="qsec" id="devq">${devHtml()}</section>` : `<h1>Colas</h1>
<section class="qsec"> <section class="qsec">
<h2>${icon('download')} Descargas ${q.downloads.length ? `<span class="badge">${q.downloads.length}</span>` : ''}</h2> <h2>${icon('download')} Descargas ${q.downloads.length ? `<span class="badge">${q.downloads.length}</span>` : ''}</h2>
${q.downloads.length ? `<div class="grid qgrid">${q.downloads.map((d, i) => qcard({ ${q.downloads.length ? `<div class="grid qgrid">${q.downloads.map((d, i) => qcard({
@@ -1744,6 +1925,7 @@ async function viewQueues() {
actions: `<button class="btn small" data-cancel="${esc(d.manga_id)}">${icon('stop')} Cancelar</button>`, actions: `<button class="btn small" data-cancel="${esc(d.manga_id)}">${icon('stop')} Cancelar</button>`,
})).join('')}</div>` : '<p class="muted">No hay descargas en curso. Se añaden desde la ficha de un manga.</p>'} })).join('')}</div>` : '<p class="muted">No hay descargas en curso. Se añaden desde la ficha de un manga.</p>'}
</section> </section>
${OFFLINE_OK ? `<section class="qsec" id="devq">${devHtml()}</section>` : ''}
<section class="qsec"> <section class="qsec">
<h2>${icon('sparkles')} Reescalado HD ${hdQueued ? `<span class="badge">${hdQueued}</span>` : ''}</h2> <h2>${icon('sparkles')} Reescalado HD ${hdQueued ? `<span class="badge">${hdQueued}</span>` : ''}</h2>
${up.available ? '' : '<p class="muted">El reescalado no está disponible en este servidor (falta Real-ESRGAN o la GPU).</p>'} ${up.available ? '' : '<p class="muted">El reescalado no está disponible en este servidor (falta Real-ESRGAN o la GPU).</p>'}
@@ -1788,6 +1970,7 @@ async function viewQueues() {
if (!b) return if (!b) return
if (b.dataset.cancel) act(() => api(`/api/download/${b.dataset.cancel}/cancel`, { method: 'POST' }), 'Descarga cancelada') 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.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.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) { else if (b.dataset.del) {
if (!confirm(`¿Eliminar la copia local de «${b.dataset.title}» (${fmtSize(Number(b.dataset.size))})?\nTu historial de lectura no se toca.`)) return if (!confirm(`¿Eliminar la copia local de «${b.dataset.title}» (${fmtSize(Number(b.dataset.size))})?\nTu historial de lectura no se toca.`)) return
@@ -1801,8 +1984,22 @@ async function viewQueues() {
} }
// Refresco: rápido mientras algo avanza, lento si no (por si llega algo nuevo). // 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 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(), busy ? 2000 : 15000) const t = setTimeout(() => here() && viewQueues(), q.offline ? 10000 : busy ? 2000 : 15000)
cleanup = () => clearTimeout(t) // 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 ---------- // ---------- Estadísticas ----------
+6
View File
@@ -346,3 +346,9 @@ body.noscroll { overflow: hidden; }
.recrow .card { scroll-snap-align: start; } .recrow .card { scroll-snap-align: start; }
@media (max-width: 640px) { .recrow { grid-auto-columns: 115px; gap: 10px; } } @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; } .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; }
+27 -9
View File
@@ -13,20 +13,38 @@ self.addEventListener('activate', e => {
.then(() => self.clients.claim())) .then(() => self.clients.claim()))
}) })
// Red primero (así siempre se ve la última versión) y la caché si no hay conexión. // 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 })
}
async function networkFirst(req, cacheName, { store = true, ignoreSearch = false, fallback } = {}) { async function networkFirst(req, cacheName, { store = true, ignoreSearch = false, fallback } = {}) {
try { const cached = () => caches.match(req, { ignoreSearch, cacheName }).then(hit => hit || (fallback && caches.match(fallback)))
const res = await fetch(req) const net = fetch(req).then(async res => {
if (store && res.ok) { if (store && res.ok) {
const c = await caches.open(cacheName) const c = await caches.open(cacheName)
if (store === true || (await c.match(req, { ignoreSearch }))) c.put(req, res.clone()) if (store === true || (await c.match(req, { ignoreSearch }))) await c.put(req, res.clone())
} }
return res return res
} catch (err) { })
const hit = await caches.match(req, { ignoreSearch, cacheName }) || (fallback && await caches.match(fallback)) net.then(() => { downUntil = 0 }, () => {})
if (hit) return hit const hit = await cached()
throw err 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 => { self.addEventListener('fetch', e => {
+99 -26
View File
@@ -1,4 +1,4 @@
// Manga Reader: lector web con las mismas fuentes que Tanko (MangaDex y LeerCapitulo) // Manga Reader: lector web con fuentes MangaDex, LeerCapitulo y M440
// con usuarios y historial de lectura persistente en SQLite. // con usuarios y historial de lectura persistente en SQLite.
import http from 'node:http' import http from 'node:http'
import fs from 'node:fs' import fs from 'node:fs'
@@ -12,6 +12,8 @@ import { setupClips } from './clips.js'
import { setupCovers } from './covers.js' import { setupCovers } from './covers.js'
import { setupLibrary } from './library.js' import { setupLibrary } from './library.js'
import { setupSecurity, safeFetch } from './security.js' import { setupSecurity, safeFetch } from './security.js'
import { setupM440, MI_BASE } from './m440.js'
import { setupConnectivity } from './connectivity.js'
const PORT = Number(process.env.PORT || 3352) const PORT = Number(process.env.PORT || 3352)
const DATA_DIR = process.env.DATA_DIR || '/app/data' const DATA_DIR = process.env.DATA_DIR || '/app/data'
@@ -177,6 +179,9 @@ const imgUrl = u => `/api/img?u=${encodeURIComponent(u)}&s=${sign(u)}`
// historial o en seguidos del usuario; las demás (búsquedas) se sirven tal cual. // historial o en seguidos del usuario; las demás (búsquedas) se sirven tal cual.
const coverUrl = (u, mangaId) => `${imgUrl(u)}&m=${encodeURIComponent(mangaId)}` 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) { async function md(pathname, params = {}, ttl = 5 * 60e3) {
const url = new URL(MD_API + pathname) const url = new URL(MD_API + pathname)
for (const [k, v] of Object.entries(params)) { for (const [k, v] of Object.entries(params)) {
@@ -184,7 +189,8 @@ async function md(pathname, params = {}, ttl = 5 * 60e3) {
else if (v !== undefined && v !== '') url.searchParams.set(k, v) else if (v !== undefined && v !== '') url.searchParams.set(k, v)
} }
return cached(url.toString(), ttl, async () => { return cached(url.toString(), ttl, async () => {
const res = await fetch(url, { headers: { 'User-Agent': UA } }).catch(() => { throw new HttpError(502, 'No se pudo conectar con MangaDex') }) 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}`) if (!res.ok) throw new HttpError(502, `MangaDex respondió ${res.status}`)
return res.json() return res.json()
}) })
@@ -313,17 +319,24 @@ const getPages = async (chapterId, saver) => (await getPageUrls(chapterId, saver
// ---------- Cliente LeerCapitulo (Tanko src/servers/leerCapitulo.ts, adaptado al sitio actual) ---------- // ---------- Cliente LeerCapitulo (Tanko src/servers/leerCapitulo.ts, adaptado al sitio actual) ----------
// IDs: manga "lc~<id>~<slug>", capítulo "lc~<id>~<slug>~<número>". Ya no hace falta navegador: // IDs: manga "lc~<id>~<slug>", capítulo "lc~<id>~<slug>~<número>". Ya no hace falta navegador:
// las páginas del capítulo vienen en el HTML como <img data-src>. // las páginas del capítulo vienen en el HTML como <img data-src>.
const decode = s => String(s ?? '').replace(/<[^>]+>/g, '').replace(/&(#x?[0-9a-f]+|amp|lt|gt|quot|apos|nbsp|lsaquo|rsaquo);/gi, (_, e) => { const ENTITIES = {
const named = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', lsaquo: '‹', rsaquo: '›' }[e.toLowerCase()] amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', lsaquo: '‹', rsaquo: '›', laquo: '«', raquo: '»', iexcl: '¡', iquest: '¿',
if (named) return named hellip: '…', mdash: '—', ndash: '–', ldquo: '“', rdquo: '”', lsquo: '‘', rsquo: '’', middot: '·', deg: '°', copy: '©', reg: '®', trade: '™',
return String.fromCodePoint(e[1].toLowerCase() === 'x' ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10)) 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() }).trim()
async function lc(pathname, ttl = 10 * 60e3, asJson = false) { async function lc(pathname, ttl = 10 * 60e3, asJson = false) {
return cached(`lc:${pathname}`, ttl, async () => { return cached(`lc:${pathname}`, ttl, async () => {
// Cloudflare de LeerCapitulo da 52x/503 a ratos cuando su servidor no responde: hasta 3 intentos. // Cloudflare de LeerCapitulo da 52x/503 a ratos cuando su servidor no responde: hasta 3 intentos.
for (let i = 0; ; i++) { 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) }) const res = await fetch(LC_BASE + pathname, { headers: { 'User-Agent': BROWSER_UA, 'Accept-Language': 'es' }, signal: AbortSignal.timeout(30e3) })
.catch(() => null) .catch(() => { net.failed(); return null })
if (res?.ok) return asJson ? res.json() : res.text() if (res?.ok) return asJson ? res.json() : res.text()
if (res?.status === 404) throw new HttpError(404, 'LeerCapitulo respondió 404') if (res?.status === 404) throw new HttpError(404, 'LeerCapitulo respondió 404')
const down = !res || res.status >= 500 const down = !res || res.status >= 500
@@ -426,6 +439,13 @@ async function lcPageUrls(chapterId) {
} }
const lcPages = async chapterId => (await lcPageUrls(chapterId)).map(imgUrl) 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' : 'md')
const spanishOnly = id => sourceOf(id) !== 'md'
// Proxy de imágenes: MangaDex bloquea el hotlinking de portadas y así el navegador no depende de CDNs ajenos. // Proxy de imágenes: MangaDex bloquea el hotlinking de portadas y así el navegador no depende de CDNs ajenos.
// isCover: portada a servir en HD; coverish: portada sin HD (caché corta por si luego se sigue el manga). // isCover: portada a servir en HD; coverish: portada sin HD (caché corta por si luego se sigue el manga).
async function proxyImage(req, res, u, s, isCover = false, coverish = false) { async function proxyImage(req, res, u, s, isCover = false, coverish = false) {
@@ -435,7 +455,14 @@ async function proxyImage(req, res, u, s, isCover = false, coverish = false) {
if (typeof s !== 'string' || s.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected))) throw new HttpError(403, 'Firma inválida') 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') if (!/^https?:$/.test(url.protocol) || /^(localhost|\d+\.\d+\.\d+\.\d+|\[.*\])$/.test(url.hostname)) throw new HttpError(403, 'Host no permitido')
if (isCover && covers.sendCached(res, u)) return if (isCover && covers.sendCached(res, u)) return
const r = await fetchImage(url) // 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 (!r.ok || !r.body) throw new HttpError(502, `Imagen ${r.status}`)
if (isCover) { if (isCover) {
const buf = Buffer.from(await r.arrayBuffer()) const buf = Buffer.from(await r.arrayBuffer())
@@ -452,8 +479,12 @@ async function proxyImage(req, res, u, s, isCover = false, coverish = false) {
} }
function fetchImage(url, signal) { function fetchImage(url, signal) {
const isMd = /(^|\.)mangadex\.(org|network)$/.test(new URL(url).hostname) net.guard()
return safeFetch(url, { signal, headers: { 'User-Agent': isMd ? UA : BROWSER_UA, ...(isMd ? {} : { Referer: LC_BASE + '/' }) } }) 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/<Título>/Capitulo NNNN.cbz ---------- // ---------- Descargas a MANGA_DIR/<Título>/Capitulo NNNN.cbz ----------
@@ -481,8 +512,9 @@ async function downloadBuffer(url) {
const EXT_BY_TYPE = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/webp': 'webp', 'image/gif': 'gif', 'image/avif': 'avif' } const EXT_BY_TYPE = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/webp': 'webp', 'image/gif': 'gif', 'image/avif': 'avif' }
async function fetchMangaFull(mangaId, lang) { async function fetchMangaFull(mangaId, lang) {
return mangaId.startsWith('lc~') const src = sourceOf(mangaId)
? lcManga(mangaId) return src === 'lc' ? lcManga(mangaId)
: src === 'mi' ? mi.manga(mangaId)
: { manga: await getManga(mangaId, lang), chapters: await getChapters(mangaId, lang) } : { manga: await getManga(mangaId, lang), chapters: await getChapters(mangaId, lang) }
} }
@@ -491,7 +523,7 @@ const dlCurrent = new Map()
async function downloadChapter(dl, manga, c, dir) { async function downloadChapter(dl, manga, c, dir) {
dlCurrent.set(dl.manga_id, { chapter: c.num, pages: 0, pagesTotal: 0 }) dlCurrent.set(dl.manga_id, { chapter: c.num, pages: 0, pagesTotal: 0 })
const urls = await withRetry(() => (manga.source === 'lc' ? lcPageUrls(c.id) : getPageUrls(c.id, false, 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 }) dlCurrent.set(dl.manga_id, { chapter: c.num, pages: 0, pagesTotal: urls.length })
const entries = [] const entries = []
// 4 imágenes en paralelo. // 4 imágenes en paralelo.
@@ -589,6 +621,12 @@ async function runDownload(dl) {
await downloadChapter(dl, manga, c, dir) await downloadChapter(dl, manga, c, dir)
} catch (e) { } catch (e) {
if (isCancelled(dl.manga_id)) break 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++ failed++
lastError = `Cap. ${c.num ?? '?'}: ${e.message}` lastError = `Cap. ${c.num ?? '?'}: ${e.message}`
console.warn('Descarga', manga.title, lastError) console.warn('Descarga', manga.title, lastError)
@@ -596,7 +634,7 @@ async function runDownload(dl) {
const done = db.prepare('SELECT COUNT(*) n FROM downloaded_chapters WHERE manga_id = ?').get(dl.manga_id).n 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 }) dlUpdate(dl.manga_id, { done, failed, error: lastError })
// MangaDex limita /at-home/server a 40 peticiones por minuto. // MangaDex limita /at-home/server a 40 peticiones por minuto.
await sleep(manga.source === 'lc' ? 500 : 1600) await sleep(manga.source === 'md' ? 1600 : manga.source === 'mi' ? 1000 : 500)
} }
dlCurrent.delete(dl.manga_id) dlCurrent.delete(dl.manga_id)
if (isCancelled(dl.manga_id)) { if (isCancelled(dl.manga_id)) {
@@ -612,12 +650,15 @@ async function pumpDownloads() {
pumping = true pumping = true
try { try {
for (;;) { 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() 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 if (!dl) break
requeue.delete(dl.manga_id) requeue.delete(dl.manga_id)
try { try {
await runDownload(dl) await runDownload(dl)
if (!net.online()) break
} catch (e) { } catch (e) {
if (!net.online()) { dlUpdate(dl.manga_id, { status: 'queued', error: null }); break }
console.error('Descarga fallida', dl.manga_id, e) console.error('Descarga fallida', dl.manga_id, e)
if (!isCancelled(dl.manga_id)) dlUpdate(dl.manga_id, { status: 'error', error: e.message }) if (!isCancelled(dl.manga_id)) dlUpdate(dl.manga_id, { status: 'error', error: e.message })
} }
@@ -676,6 +717,15 @@ function downloadCover(mangaId, folder, metaCover) {
const u = rawFromProxy(c) const u = rawFromProxy(c)
return u ? coverUrl(u, mangaId) : c || null 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 withCurrent = d => (d ? { ...d, current: ['queued', 'downloading'].includes(d.status) ? dlCurrent.get(d.manga_id) ?? null : null } : null)
const downloadInfo = (mangaId, size = false) => { 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)) 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))
@@ -702,8 +752,8 @@ async function readBody(req) {
} }
try { return JSON.parse(Buffer.concat(chunks).toString() || '{}') } catch { throw new HttpError(400, 'JSON inválido') } try { return JSON.parse(Buffer.concat(chunks).toString() || '{}') } catch { throw new HttpError(400, 'JSON inválido') }
} }
const MANGA_ID = /^([0-9a-f-]{36}|lc~[a-z0-9]+~[a-z0-9-]+)$/ const MANGA_ID = /^([0-9a-f-]{36}|lc~[a-z0-9]+~[a-z0-9-]+|mi~[a-z0-9._-]+)$/
const CHAPTER_ID = /^([0-9a-f-]{36}|lc~[a-z0-9]+~[a-z0-9-]+~[0-9a-z.-]+)$/ const CHAPTER_ID = /^([0-9a-f-]{36}|lc~[a-z0-9]+~[a-z0-9-]+~[0-9a-z.-]+|mi~[a-z0-9._-]+~[a-z0-9._-]+)$/
const LANG = /^[a-z]{2}(-[a-z]{2})?$/ const LANG = /^[a-z]{2}(-[a-z]{2})?$/
// Manga en el historial o en seguidos del usuario (sus portadas se reescalan). // Manga en el historial o en seguidos del usuario (sus portadas se reescalan).
const tracked = (userId, mangaId) => !!db.prepare(`SELECT 1 FROM progress WHERE user_id = ? AND manga_id = ? const tracked = (userId, mangaId) => !!db.prepare(`SELECT 1 FROM progress WHERE user_id = ? AND manga_id = ?
@@ -809,16 +859,34 @@ async function route(req, res) {
return json(res, 200, { ok: true }) return json(res, 200, { ok: true })
} }
if (p === '/api/img' && method === 'GET') return proxyImage(req, res, q.get('u') || '', q.get('s'), MANGA_ID.test(q.get('m') || '') && tracked(user.id, q.get('m')), q.has('m')) 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'))
} 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 const lang = LANG.test(q.get('lang') || '') ? q.get('lang') : user.lang
if (p === '/api/categories' && method === 'GET') { if (p === '/api/categories' && method === 'GET') {
return json(res, 200, q.get('source') === 'lc' ? await lcCategories() : await mdCategories()) return json(res, 200, !net.online() ? [] : q.get('source') === 'lc' ? await lcCategories() : q.get('source') === 'mi' ? [] : await mdCategories())
} }
if (p === '/api/search' && method === 'GET') { if (p === '/api/search' && method === 'GET') {
const page = Math.min(Math.max(Number(q.get('page')) | 0, 1), 500) const page = Math.min(Math.max(Number(q.get('page')) | 0, 1), 500)
const tag = q.get('tag') || '' const tag = q.get('tag') || ''
if (!net.online()) return json(res, 200, localSearch(q.get('q') || ''))
if (q.get('source') === 'mi') return json(res, 200, await mi.search((q.get('q') || '').slice(0, 100), q.get('mode'), page))
if (q.get('source') === 'lc') return json(res, 200, await lcSearch((q.get('q') || '').slice(0, 100), q.get('mode'), tag, page)) if (q.get('source') === 'lc') return json(res, 200, await lcSearch((q.get('q') || '').slice(0, 100), q.get('mode'), tag, page))
const anyLang = q.get('anyLang') === '1' const anyLang = q.get('anyLang') === '1'
const mdTag = /^[0-9a-f-]{36}$/.test(tag) ? tag : null const mdTag = /^[0-9a-f-]{36}$/.test(tag) ? tag : null
@@ -833,7 +901,7 @@ async function route(req, res) {
} catch (e) { } catch (e) {
// Sin conexión con la fuente: usar la ficha guardada y solo los capítulos descargados. // 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]) const dl = db.prepare('SELECT meta FROM downloads WHERE manga_id = ? AND meta IS NOT NULL').get(m[1])
if (!dl) throw e 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 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)) 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]}` } manga = { ...saved.manga, cover: `/api/local-cover/${m[1]}` }
@@ -843,16 +911,17 @@ async function route(req, res) {
const downloaded = db.prepare('SELECT chapter_id FROM downloaded_chapters WHERE manga_id = ?').all(m[1]).map(r => r.chapter_id) 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 reads = db.prepare('SELECT chapter_id, page, total_pages FROM chapter_reads WHERE user_id = ? AND manga_id = ?').all(user.id, m[1])
const progress = db.prepare('SELECT * FROM progress WHERE user_id = ? AND manga_id = ?').get(user.id, m[1]) ?? null const progress = db.prepare('SELECT * FROM progress WHERE user_id = ? AND manga_id = ?').get(user.id, m[1]) ?? null
const chapterLangs = chapters.length || manga.source === 'lc' || offline ? [] : await getChapterLangs(m[1]).catch(() => []) const chapterLangs = chapters.length || manga.source !== 'md' || offline ? [] : await getChapterLangs(m[1]).catch(() => [])
return json(res, 200, { manga, chapters, lang, reads, progress, chapterLangs, offline, downloaded, download: downloadInfo(m[1], true), upscale: upscale.info(m[1]), clips: clips.count(user.id, m[1]), lists: library.listsWith(user.id, m[1]), ...features.mangaExtras(user.id, manga) }) return json(res, 200, { manga, chapters, lang, reads, progress, chapterLangs, offline, downloaded, download: downloadInfo(m[1], true), upscale: upscale.info(m[1]), clips: clips.count(user.id, m[1]), lists: library.listsWith(user.id, m[1]), ...features.mangaExtras(user.id, manga) })
} }
if ((m = /^\/api\/chapter\/([^/]+)$/.exec(p)) && CHAPTER_ID.test(m[1]) && method === 'GET') { if ((m = /^\/api\/chapter\/([^/]+)$/.exec(p)) && CHAPTER_ID.test(m[1]) && method === 'GET') {
const hdAvailable = !!upscale.hdFileOf(m[1]) const hdAvailable = !!upscale.hdFileOf(m[1])
const hd = hdAvailable && q.get('hd') !== '0' const hd = hdAvailable && q.get('hd') !== '0'
const local = localChapter(m[1], hd) const local = localChapter(m[1], hd)
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 const pages = local
? local.entries.map((_, i) => `/api/local/${m[1]}/${i}${hd ? '?hd=1' : ''}`) ? local.entries.map((_, i) => `/api/local/${m[1]}/${i}${hd ? '?hd=1' : ''}`)
: m[1].startsWith('lc~') ? await lcPages(m[1]) : await getPages(m[1], q.get('saver') === '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]) 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 }) return json(res, 200, { pages, savedPage: read?.page ?? 0, local: !!local, hd, hdAvailable })
} }
@@ -879,6 +948,7 @@ async function route(req, res) {
} }
// --- Descargas --- // --- Descargas ---
if (p === '/api/status' && method === 'GET') return json(res, 200, net.status())
if (p === '/api/queues' && method === 'GET') { 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 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') ORDER BY status = 'downloading' DESC, created_at`).all() FROM downloads WHERE status IN ('downloading', 'queued') ORDER BY status = 'downloading' DESC, created_at`).all()
@@ -901,7 +971,7 @@ async function route(req, res) {
} }
if ((m = /^\/api\/download\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'POST') { if ((m = /^\/api\/download\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'POST') {
const b = await readBody(req) const b = await readBody(req)
const dlLang = m[1].startsWith('lc~') ? 'es' : LANG.test(b.lang || '') ? b.lang : user.lang const dlLang = spanishOnly(m[1]) ? 'es' : LANG.test(b.lang || '') ? b.lang : user.lang
const title = String(b.title || '').slice(0, 300) || (await fetchMangaFull(m[1], dlLang)).manga.title const title = String(b.title || '').slice(0, 300) || (await fetchMangaFull(m[1], dlLang)).manga.title
const ids = Array.isArray(b.chapterIds) ? b.chapterIds.map(String).filter(id => CHAPTER_ID.test(id)).slice(0, 5000) : null 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') if (ids && !ids.length) throw new HttpError(400, 'Ningún capítulo válido')
@@ -930,7 +1000,7 @@ async function route(req, res) {
} }
if (p === '/api/progress' && method === 'POST') { if (p === '/api/progress' && method === 'POST') {
const b = await readBody(req) const b = await readBody(req)
if (!MANGA_ID.test(b.mangaId || '') || !CHAPTER_ID.test(b.chapterId || '') || !b.chapterId.startsWith(b.mangaId.startsWith('lc~') ? b.mangaId + '~' : '')) throw new HttpError(400, 'IDs inválidos') 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 page = Math.max(0, Number(b.page) | 0)
const total = Math.max(0, Number(b.totalPages) | 0) const total = Math.max(0, Number(b.totalPages) | 0)
const now = Date.now() const now = Date.now()
@@ -963,10 +1033,12 @@ async function route(req, res) {
} }
const security = setupSecurity({ db }) const security = setupSecurity({ db })
const features = setupFeatures({ db, fetchMangaFull, enqueueDownload, downloadInfo, HttpError, json, readBody, MANGA_ID }) const features = setupFeatures({ db, fetchMangaFull, enqueueDownload, downloadInfo, HttpError, json, readBody, MANGA_ID, net })
const upscale = setupUpscale({ db, MANGA_DIR, HttpError, json, readBody, MANGA_ID }) const upscale = setupUpscale({ db, MANGA_DIR, HttpError, json, readBody, MANGA_ID })
const covers = setupCovers({ DATA_DIR, upscale }) const covers = setupCovers({ DATA_DIR, upscale })
const library = setupLibrary({ db, HttpError, json, readBody, MANGA_ID, CHAPTER_ID, fetchMangaFull, enqueueDownload, upscale, stats: features.stats, searchManga, mdCategories }) const library = setupLibrary({ db, HttpError, json, readBody, MANGA_ID, CHAPTER_ID, fetchMangaFull, enqueueDownload, upscale, stats: features.stats, searchManga, mdCategories, net })
// Al volver internet: reanudar las descargas en cola.
net.whenOnline(() => pumpDownloads())
const clips = setupClips({ db, DATA_DIR, HttpError, json, readBody, MANGA_ID, CHAPTER_ID, upscale }) const clips = setupClips({ db, DATA_DIR, HttpError, json, readBody, MANGA_ID, CHAPTER_ID, upscale })
// Migración: portadas guardadas sin &m= (reescalado de portadas) y con la miniatura de 256 px de MangaDex. // Migración: portadas guardadas sin &m= (reescalado de portadas) y con la miniatura de 256 px de MangaDex.
@@ -987,7 +1059,8 @@ http.createServer(async (req, res) => {
await route(req, res) await route(req, res)
} catch (err) { } catch (err) {
const status = err instanceof HttpError ? err.status : 500 const status = err instanceof HttpError ? err.status : 500
if (status >= 500) console.error(req.method, req.url, err) // 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. // 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' }) if (!res.headersSent) json(res, status, { error: status >= 500 && status !== 502 && status !== 503 ? 'Error interno del servidor' : err.message || 'Error' })
else res.end() else res.end()
+15 -1
View File
@@ -12,6 +12,17 @@ export const MODELS = process.env.UPSCALER_MODELS || path.join(path.dirname(BIN)
export const MODEL = process.env.UPSCALER_MODEL || 'realesr-animevideov3' export const MODEL = process.env.UPSCALER_MODEL || 'realesr-animevideov3'
const SCALE = Number(process.env.UPSCALER_SCALE || 2) const SCALE = Number(process.env.UPSCALER_SCALE || 2)
const WEBP_QUALITY = Number(process.env.UPSCALER_QUALITY || 85) 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 HD_DIR = '.hd'
const UPSCALABLE = /\.(jpe?g|png|webp)$/i // lo demás (gif, avif) se copia tal cual const UPSCALABLE = /\.(jpe?g|png|webp)$/i // lo demás (gif, avif) se copia tal cual
@@ -86,7 +97,10 @@ export function setupUpscale({ db, MANGA_DIR, HttpError, json, readBody, MANGA_I
const png = path.join(outDir, `${base}.png`) const png = path.join(outDir, `${base}.png`)
if (UPSCALABLE.test(name) && fs.existsSync(png)) { if (UPSCALABLE.test(name) && fs.existsSync(png)) {
const webp = path.join(work, `${base}.webp`) const webp = path.join(work, `${base}.webp`)
await run('cwebp', ['-quiet', '-q', String(WEBP_QUALITY), '-m', '4', '-mt', png, '-o', 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) }) out.push({ name: `${base}.webp`, data: fs.readFileSync(webp) })
fs.rmSync(webp); fs.rmSync(png) fs.rmSync(webp); fs.rmSync(png)
} else { } else {