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:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 ----------
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user