La ficha del manga incluye la info de la obra, menú lateral y banners reescalados
- Ficha e Info son una sola página: banner, puntuaciones, sinopsis, obras relacionadas y recomendaciones. - La info se guarda una vez por manga para todos los usuarios y se actualiza en segundo plano; los mangas seguidos la mantienen al día solos. Si una fuente falla, se conserva lo guardado. - Menú lateral (⋯) con seguir, listas, recortes, otras fuentes, descargas y datos de la obra. - Banners reescalados con Real-ESRGAN; si la obra no tiene, se usa un recorte del propio usuario. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -79,16 +79,28 @@ El primer usuario puede registrarse siempre; después el registro queda cerrado
|
|||||||
|
|
||||||
## Info de la obra y fechas de los capítulos
|
## Info de la obra y fechas de los capítulos
|
||||||
|
|
||||||
El botón **Info** de cada manga muestra datos de [AniList](https://anilist.co) y
|
La ficha de cada manga incluye datos de [AniList](https://anilist.co) y
|
||||||
[MangaUpdates](https://www.mangaupdates.com): autores, revista y editorial, fechas de publicación, tomos,
|
[MangaUpdates](https://www.mangaupdates.com): autores, revista y editorial, fechas de publicación, tomos,
|
||||||
puntuaciones, rankings, adaptaciones al anime, obras relacionadas, recomendaciones y enlaces oficiales.
|
puntuaciones, rankings, adaptaciones al anime, obras relacionadas, recomendaciones, enlaces oficiales y la
|
||||||
|
imagen de fondo de la obra.
|
||||||
La obra se identifica por los enlaces de MangaDex; si el manga viene de LeerCapitulo o M440, por su título
|
La obra se identifica por los enlaces de MangaDex; si el manga viene de LeerCapitulo o M440, por su título
|
||||||
(solo si coincide, para no mostrar datos de otra obra).
|
(solo si coincide, para no mostrar datos de otra obra).
|
||||||
|
|
||||||
Las fechas de la lista de capítulos son las de la **primera traducción conocida** de cada capítulo
|
Las fechas de la lista de capítulos son las de la **primera traducción conocida** de cada capítulo
|
||||||
(MangaDex en cualquier idioma y MangaUpdates), que en series en curso es muy cercana a la de publicación;
|
(MangaDex en cualquier idioma y MangaUpdates), que en series en curso es muy cercana a la de publicación;
|
||||||
los sitios en español suelen poner la fecha en que ellos lo subieron. Las marcadas con «≈» son estimadas
|
los sitios en español suelen poner la fecha en que ellos lo subieron. Las marcadas con «≈» son estimadas
|
||||||
entre dos capítulos con fecha. Todo se guarda en la base de datos (ficha 7 días, fechas 1 día).
|
entre dos capítulos con fecha.
|
||||||
|
|
||||||
|
Todo se guarda en la base de datos, una vez por manga y para todos los usuarios: lo guardado se muestra sin
|
||||||
|
consultar nada y, si es antiguo (info 30 días, fechas 1 día), se actualiza en segundo plano. Los mangas que
|
||||||
|
sigue algún usuario se completan y se mantienen al día solos, aunque nadie abra su ficha.
|
||||||
|
|
||||||
|
La imagen de fondo de la ficha se reescala con Real-ESRGAN (×4 si mide menos de 1000 px de ancho, ×2 si no). Si la obra
|
||||||
|
no tiene, se usa uno de tus recortes de ese manga; sin recortes, la ficha va sin imagen de fondo.
|
||||||
|
|
||||||
|
En la ficha solo quedan a la vista «Continuar/Empezar a leer» y «Capítulos». El botón **⋯** abre el menú lateral con
|
||||||
|
seguir, listas, recortes, buscar en otras fuentes, dejar de leer, la copia local, el reescalado HD, «En este dispositivo»
|
||||||
|
y los datos de la obra (autores, editorial, publicación…).
|
||||||
|
|
||||||
## Sin internet en el servidor
|
## Sin internet en el servidor
|
||||||
|
|
||||||
|
|||||||
+11
-1
@@ -98,6 +98,16 @@ export function setupClips({ db, DATA_DIR, HttpError, json, readBody, MANGA_ID,
|
|||||||
}
|
}
|
||||||
const count = (userId, mangaId) => db.prepare('SELECT COUNT(*) n FROM clips WHERE user_id = ? AND manga_id = ?').get(userId, mangaId).n
|
const count = (userId, mangaId) => db.prepare('SELECT COUNT(*) n FROM clips WHERE user_id = ? AND manga_id = ?').get(userId, mangaId).n
|
||||||
|
|
||||||
|
// Banner de la ficha cuando la obra no tiene: un recorte del propio usuario, el más apaisado (es el que mejor
|
||||||
|
// llena la franja) y, si los hay, de entre los ya reescalados.
|
||||||
|
function bannerFor(userId, mangaId) {
|
||||||
|
const c = db.prepare(`SELECT * FROM clips WHERE user_id = ? AND manga_id = ?
|
||||||
|
ORDER BY status = 'done' DESC, CAST(width AS REAL) / height DESC, id DESC LIMIT 1`).get(userId, mangaId)
|
||||||
|
if (!c) return null
|
||||||
|
const v = view(c)
|
||||||
|
return v.hd || v.img
|
||||||
|
}
|
||||||
|
|
||||||
async function readImage(req) {
|
async function readImage(req) {
|
||||||
let size = 0
|
let size = 0
|
||||||
const chunks = []
|
const chunks = []
|
||||||
@@ -178,5 +188,5 @@ export function setupClips({ db, DATA_DIR, HttpError, json, readBody, MANGA_ID,
|
|||||||
|
|
||||||
const queueInfo = userId => db.prepare(`SELECT SUM(status = 'queued') queued, SUM(status = 'running') running, SUM(status = 'error') errors
|
const queueInfo = userId => db.prepare(`SELECT SUM(status = 'queued') queued, SUM(status = 'running') running, SUM(status = 'error') errors
|
||||||
FROM clips WHERE user_id = ?`).get(userId)
|
FROM clips WHERE user_id = ?`).get(userId)
|
||||||
return { route, count, pump, queueInfo }
|
return { route, count, bannerFor, pump, queueInfo }
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-8
@@ -1,6 +1,7 @@
|
|||||||
// Portadas en HD: cada portada que se sirve (de la fuente o la cover.jpg de una descarga) se encola para
|
// Portadas en HD: cada portada que se sirve (de la fuente o la cover.jpg de una descarga) se encola para
|
||||||
// reescalarla con Real-ESRGAN (×4 si es pequeña, ×2 si no). Mientras tanto se sirve la original con
|
// reescalarla con Real-ESRGAN (×4 si es pequeña, ×2 si no). Mientras tanto se sirve la original con
|
||||||
// caché corta; en cuanto existe la versión HD se sirve esa. Caché en <DATA_DIR>/covers/<hash>.webp.
|
// caché corta; en cuanto existe la versión HD se sirve esa. Caché en <DATA_DIR>/covers/<hash>.webp.
|
||||||
|
// Los banners de la ficha pasan por lo mismo, pero se reescalan siempre: se ven a todo el ancho de la pantalla.
|
||||||
import fs from 'node:fs'
|
import fs from 'node:fs'
|
||||||
import os from 'node:os'
|
import os from 'node:os'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
@@ -11,6 +12,7 @@ const MAX_QUEUE = 300
|
|||||||
const X4_MAX_SIDE = 400
|
const X4_MAX_SIDE = 400
|
||||||
const MAX_SIDE = 1600 // más grande ya no hace falta reescalarla
|
const MAX_SIDE = 1600 // más grande ya no hace falta reescalarla
|
||||||
const WEBP_QUALITY = 88
|
const WEBP_QUALITY = 88
|
||||||
|
const BANNER_X4_MAX_WIDTH = 1000
|
||||||
|
|
||||||
// Ancho/alto de un JPEG, PNG o WebP sin decodificarlo (null si no se reconoce).
|
// Ancho/alto de un JPEG, PNG o WebP sin decodificarlo (null si no se reconoce).
|
||||||
export function imageSize(b) {
|
export function imageSize(b) {
|
||||||
@@ -53,23 +55,26 @@ export function setupCovers({ DATA_DIR, upscale }) {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
// Cabecera de caché para la original mientras no hay HD (corta, para que el navegador pida luego la HD).
|
// Cabecera de caché para la original mientras no hay HD (corta, para que el navegador pida luego la HD).
|
||||||
const pendingCache = () => (upscale.available() ? 'private, max-age=300' : 'private, max-age=604800, immutable')
|
// Un banner no se guarda: en la siguiente visita ya se ve el reescalado.
|
||||||
|
const pendingCache = (banner = false) => (!upscale.available() ? 'private, max-age=604800, immutable' : banner ? 'no-store' : 'private, max-age=300')
|
||||||
|
|
||||||
function enqueue(key, buf) {
|
// ¿Falta generar esta imagen? (para no descargarla si no hace falta)
|
||||||
if (!upscale.available() || pending.has(key) || failed.has(key) || queue.length >= MAX_QUEUE || fs.existsSync(fileOf(key))) return
|
const wants = key => upscale.available() && !pending.has(key) && !failed.has(key) && queue.length < MAX_QUEUE && !fs.existsSync(fileOf(key))
|
||||||
|
function enqueue(key, buf, banner = false) {
|
||||||
|
if (!wants(key)) return
|
||||||
pending.add(key)
|
pending.add(key)
|
||||||
queue.push({ key, buf })
|
queue.push({ key, buf, banner })
|
||||||
pump()
|
pump()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function processOne({ key, buf }) {
|
async function processOne({ key, buf, banner }) {
|
||||||
const size = imageSize(buf)
|
const size = imageSize(buf)
|
||||||
if (!size) throw new Error('formato no reconocido')
|
if (!size) throw new Error('formato no reconocido')
|
||||||
const work = fs.mkdtempSync(path.join(os.tmpdir(), 'cover-'))
|
const work = fs.mkdtempSync(path.join(os.tmpdir(), 'cover-'))
|
||||||
try {
|
try {
|
||||||
const out = path.join(work, 'out.png')
|
const out = path.join(work, 'out.png')
|
||||||
const tmp = `${fileOf(key)}.tmp`
|
const tmp = `${fileOf(key)}.tmp`
|
||||||
if (Math.max(size.w, size.h) > MAX_SIDE) {
|
if (!banner && Math.max(size.w, size.h) > MAX_SIDE) {
|
||||||
// Ya es grande: solo se recomprime para no volver a intentarlo.
|
// Ya es grande: solo se recomprime para no volver a intentarlo.
|
||||||
const inp = path.join(work, `in.${size.type}`)
|
const inp = path.join(work, `in.${size.type}`)
|
||||||
fs.writeFileSync(inp, buf)
|
fs.writeFileSync(inp, buf)
|
||||||
@@ -78,7 +83,8 @@ export function setupCovers({ DATA_DIR, upscale }) {
|
|||||||
} else {
|
} else {
|
||||||
const inp = path.join(work, `in.${size.type}`)
|
const inp = path.join(work, `in.${size.type}`)
|
||||||
fs.writeFileSync(inp, buf)
|
fs.writeFileSync(inp, buf)
|
||||||
const scale = Math.max(size.w, size.h) <= X4_MAX_SIDE && fs.existsSync(path.join(MODELS, `${MODEL}-x4.bin`)) ? 4 : 2
|
const small = banner ? size.w < BANNER_X4_MAX_WIDTH : Math.max(size.w, size.h) <= X4_MAX_SIDE
|
||||||
|
const scale = small && fs.existsSync(path.join(MODELS, `${MODEL}-x4.bin`)) ? 4 : 2
|
||||||
await run(BIN, ['-i', inp, '-o', out, '-m', MODELS, '-n', MODEL, '-s', String(scale), '-f', 'png'])
|
await run(BIN, ['-i', inp, '-o', out, '-m', MODELS, '-n', MODEL, '-s', String(scale), '-f', 'png'])
|
||||||
await run('cwebp', ['-quiet', '-q', String(WEBP_QUALITY), '-m', '4', '-mt', out, '-o', tmp])
|
await run('cwebp', ['-quiet', '-q', String(WEBP_QUALITY), '-m', '4', '-mt', out, '-o', tmp])
|
||||||
}
|
}
|
||||||
@@ -105,5 +111,5 @@ export function setupCovers({ DATA_DIR, upscale }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { sendCached, enqueue, pendingCache, pendingCount: () => pending.size }
|
return { sendCached, wants, enqueue, pendingCache, pendingCount: () => pending.size }
|
||||||
}
|
}
|
||||||
|
|||||||
+81
-41
@@ -9,8 +9,12 @@
|
|||||||
// misma para cientos). Se toma la primera fecha conocida de cada número de capítulo en cualquier
|
// misma para cientos). Se toma la primera fecha conocida de cada número de capítulo en cualquier
|
||||||
// idioma (MangaDex) o grupo (MangaUpdates): es la fecha de la primera traducción, muy cercana a la de
|
// idioma (MangaDex) o grupo (MangaUpdates): es la fecha de la primera traducción, muy cercana a la de
|
||||||
// publicación en series en curso.
|
// publicación en series en curso.
|
||||||
// Todo se guarda en la base de datos (si una fuente falla se usa lo último que se tenía).
|
// Todo se guarda en la base de datos, una vez por manga y para todos los usuarios: lo guardado se sirve sin
|
||||||
|
// consultar nada y, si es antiguo, se actualiza en segundo plano (si una fuente falla, se queda lo que había).
|
||||||
|
// Los mangas seguidos por alguien se mantienen siempre al día, aunque nadie abra su ficha.
|
||||||
const DAY = 24 * 3600e3
|
const DAY = 24 * 3600e3
|
||||||
|
const INFO_TTL = v => (v?.found ? 30 * DAY : 3 * DAY)
|
||||||
|
const SYNC_EVERY = 3600e3
|
||||||
const ANILIST = 'https://graphql.anilist.co'
|
const ANILIST = 'https://graphql.anilist.co'
|
||||||
const MU_API = 'https://api.mangaupdates.com/v1'
|
const MU_API = 'https://api.mangaupdates.com/v1'
|
||||||
const MD_RATINGS = ['safe', 'suggestive', 'erotica', 'pornographic']
|
const MD_RATINGS = ['safe', 'suggestive', 'erotica', 'pornographic']
|
||||||
@@ -34,29 +38,30 @@ const numKey = n => { const f = parseFloat(n); return Number.isFinite(f) ? Strin
|
|||||||
const toText = (html, decode) => decode(String(html || '').replace(/\r/g, '').replace(/<br\s*\/?>\s*/gi, '\n').replace(/<\/p>/gi, '\n\n').replace(/<[^>]+>/g, ''))
|
const toText = (html, decode) => decode(String(html || '').replace(/\r/g, '').replace(/<br\s*\/?>\s*/gi, '\n').replace(/<\/p>/gi, '\n\n').replace(/<[^>]+>/g, ''))
|
||||||
.split('\n').map(l => l.trim()).join('\n').replace(/\n{3,}/g, '\n\n').trim()
|
.split('\n').map(l => l.trim()).join('\n').replace(/\n{3,}/g, '\n\n').trim()
|
||||||
|
|
||||||
export function setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, net, decode, imgUrl, UA }) {
|
export function setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, net, decode, imgUrl, UA, prepareBanner }) {
|
||||||
db.exec(`CREATE TABLE IF NOT EXISTS meta_cache (key TEXT PRIMARY KEY, value TEXT NOT NULL, at INTEGER NOT NULL)`)
|
db.exec(`CREATE TABLE IF NOT EXISTS meta_cache (key TEXT PRIMARY KEY, value TEXT NOT NULL, at INTEGER NOT NULL)`)
|
||||||
|
|
||||||
// Guardado en la base de datos con caducidad; ttl puede depender del valor (p. ej. "no encontrado" dura menos).
|
// Guardado en la base de datos con caducidad; ttl puede depender del valor (p. ej. "no encontrado" dura menos).
|
||||||
|
// Si caducó se devuelve lo guardado y se actualiza en segundo plano; con wait se espera a la actualización.
|
||||||
const inflight = new Map()
|
const inflight = new Map()
|
||||||
function stored(key, ttl, fn) {
|
function stored(key, ttl, fn, wait = false) {
|
||||||
const row = db.prepare('SELECT value, at FROM meta_cache WHERE key = ?').get(key)
|
const row = db.prepare('SELECT value, at FROM meta_cache WHERE key = ?').get(key)
|
||||||
const old = row ? JSON.parse(row.value) : undefined
|
const old = row ? JSON.parse(row.value) : undefined
|
||||||
const life = v => (typeof ttl === 'function' ? ttl(v) : ttl)
|
const life = v => (typeof ttl === 'function' ? ttl(v) : ttl)
|
||||||
if (row && Date.now() - row.at < life(old)) return Promise.resolve(old)
|
if (row && Date.now() - row.at < life(old)) return Promise.resolve(old)
|
||||||
if (inflight.has(key)) return inflight.get(key)
|
if (!inflight.has(key)) {
|
||||||
const p = (async () => {
|
inflight.set(key, (async () => {
|
||||||
try {
|
try {
|
||||||
const v = await fn()
|
const v = await fn(old)
|
||||||
db.prepare('INSERT INTO meta_cache (key, value, at) VALUES (?, ?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value, at = excluded.at').run(key, JSON.stringify(v), Date.now())
|
db.prepare('INSERT INTO meta_cache (key, value, at) VALUES (?, ?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value, at = excluded.at').run(key, JSON.stringify(v), Date.now())
|
||||||
return v
|
return v
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (old !== undefined) return old
|
if (old !== undefined) return old
|
||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
})().finally(() => inflight.delete(key))
|
})().finally(() => inflight.delete(key)))
|
||||||
inflight.set(key, p)
|
}
|
||||||
return p
|
return old !== undefined && !wait ? Promise.resolve(old) : inflight.get(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Peticiones a AniList/MangaUpdates, de una en una con una pausa (los dos limitan la frecuencia).
|
// Peticiones a AniList/MangaUpdates, de una en una con una pausa (los dos limitan la frecuencia).
|
||||||
@@ -87,7 +92,7 @@ export function setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, n
|
|||||||
}
|
}
|
||||||
const mdTitles = m => [...Object.values(m.attributes.title || {}), ...(m.attributes.altTitles || []).flatMap(t => Object.values(t))]
|
const mdTitles = m => [...Object.values(m.attributes.title || {}), ...(m.attributes.altTitles || []).flatMap(t => Object.values(t))]
|
||||||
|
|
||||||
async function links(mangaId) {
|
async function links(mangaId, wait = false) {
|
||||||
return stored(`links:${mangaId}`, v => (v?.md || v?.al || v?.mu ? 30 * DAY : 3 * DAY), async () => {
|
return stored(`links:${mangaId}`, v => (v?.md || v?.al || v?.mu ? 30 * DAY : 3 * DAY), async () => {
|
||||||
if (sourceOf(mangaId) === 'md') {
|
if (sourceOf(mangaId) === 'md') {
|
||||||
const d = await md(`/manga/${mangaId}`, {}, 30 * 60e3)
|
const d = await md(`/manga/${mangaId}`, {}, 30 * 60e3)
|
||||||
@@ -96,18 +101,22 @@ export function setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, n
|
|||||||
const { manga } = await fetchMangaFull(mangaId, 'es')
|
const { manga } = await fetchMangaFull(mangaId, 'es')
|
||||||
const title = manga.title
|
const title = manga.title
|
||||||
// MangaDex: el que tenga ese título (o alternativo); entre varios, el que más enlaces tenga.
|
// MangaDex: el que tenga ese título (o alternativo); entre varios, el que más enlaces tenga.
|
||||||
const r = await md('/manga', { title, limit: 10, 'contentRating[]': MD_RATINGS }, 6 * 3600e3).catch(() => null)
|
// Una fuente caída no significa "no encontrado": no se guarda y se reintenta.
|
||||||
|
let failed = false
|
||||||
|
const soft = p => p.catch(() => { failed = true; return null })
|
||||||
|
const r = await soft(md('/manga', { title, limit: 10, 'contentRating[]': MD_RATINGS }, 6 * 3600e3))
|
||||||
const hits = (r?.data || []).filter(m => mdTitles(m).some(t => similar(t, title)))
|
const hits = (r?.data || []).filter(m => mdTitles(m).some(t => similar(t, title)))
|
||||||
hits.sort((a, b) => Object.keys(b.attributes.links || {}).length - Object.keys(a.attributes.links || {}).length)
|
hits.sort((a, b) => Object.keys(b.attributes.links || {}).length - Object.keys(a.attributes.links || {}).length)
|
||||||
if (hits[0]) return { ...mdLinks(hits[0]), via: 'MangaDex', title: mdTitles(hits[0])[0] }
|
if (hits[0]) return { ...mdLinks(hits[0]), via: 'MangaDex', title: mdTitles(hits[0])[0] }
|
||||||
const a = await anilist(`query($s:String){Page(perPage:6){media(search:$s,type:MANGA){id idMal title{romaji english native} synonyms}}}`, { s: title }).catch(() => null)
|
const a = await soft(anilist(`query($s:String){Page(perPage:6){media(search:$s,type:MANGA){id idMal title{romaji english native} synonyms}}}`, { s: title }))
|
||||||
const am = a?.Page?.media?.find(m => [m.title.romaji, m.title.english, m.title.native, ...(m.synonyms || [])].some(t => similar(t, title)))
|
const am = a?.Page?.media?.find(m => [m.title.romaji, m.title.english, m.title.native, ...(m.synonyms || [])].some(t => similar(t, title)))
|
||||||
if (am) return { md: null, al: am.id, mu: null, mal: am.idMal || null, via: 'AniList', title: am.title.romaji }
|
if (am) return { md: null, al: am.id, mu: null, mal: am.idMal || null, via: 'AniList', title: am.title.romaji }
|
||||||
const s = await mu('/series/search', { search: title, perpage: 8 }).catch(() => null)
|
const s = await soft(mu('/series/search', { search: title, perpage: 8 }))
|
||||||
const sm = s?.results?.find(x => similar(decode(x.record.title), title) || similar(decode(x.hit_title), title))
|
const sm = s?.results?.find(x => similar(decode(x.record.title), title) || similar(decode(x.hit_title), title))
|
||||||
if (sm) return { md: null, al: null, mu: sm.record.series_id, mal: null, via: 'MangaUpdates', title: decode(sm.record.title) }
|
if (sm) return { md: null, al: null, mu: sm.record.series_id, mal: null, via: 'MangaUpdates', title: decode(sm.record.title) }
|
||||||
|
if (failed) throw new HttpError(502, 'No se pudo identificar la obra')
|
||||||
return {}
|
return {}
|
||||||
})
|
}, wait)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Ficha ----------
|
// ---------- Ficha ----------
|
||||||
@@ -124,15 +133,18 @@ export function setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, n
|
|||||||
const date = d => (d?.year ? [d.year, d.month, d.day].filter(Boolean).map((x, i) => (i ? String(x).padStart(2, '0') : x)).join('-') : null)
|
const date = d => (d?.year ? [d.year, d.month, d.day].filter(Boolean).map((x, i) => (i ? String(x).padStart(2, '0') : x)).join('-') : null)
|
||||||
const cover = u => (u ? imgUrl(u) : null)
|
const cover = u => (u ? imgUrl(u) : null)
|
||||||
|
|
||||||
async function info(mangaId) {
|
async function info(mangaId, wait = false) {
|
||||||
const l = await links(mangaId)
|
const l = await links(mangaId, wait)
|
||||||
return stored(`info:${mangaId}`, 7 * DAY, async () => {
|
return stored(`info:${mangaId}`, INFO_TTL, async old => {
|
||||||
|
let failed = false
|
||||||
const [al, ms] = await Promise.all([
|
const [al, ms] = await Promise.all([
|
||||||
l.al ? anilist(`query($id:Int){Media(id:$id,type:MANGA){${AL_FIELDS}}}`, { id: l.al })
|
l.al ? anilist(`query($id:Int){Media(id:$id,type:MANGA){${AL_FIELDS}}}`, { id: l.al })
|
||||||
: l.mal ? anilist(`query($id:Int){Media(idMal:$id,type:MANGA){${AL_FIELDS}}}`, { id: l.mal }) : null,
|
: l.mal ? anilist(`query($id:Int){Media(idMal:$id,type:MANGA){${AL_FIELDS}}}`, { id: l.mal }) : null,
|
||||||
l.mu ? mu(`/series/${l.mu}`) : null,
|
l.mu ? mu(`/series/${l.mu}`) : null,
|
||||||
].map(p => Promise.resolve(p).catch(e => { console.warn('Info:', e.message); return null })))
|
].map(p => Promise.resolve(p).catch(e => { console.warn('Info:', e.message); failed = true; return null })))
|
||||||
const a = al?.Media
|
const a = al?.Media
|
||||||
|
// Con una fuente caída no se pisa lo que había ni se da la obra por no encontrada.
|
||||||
|
if (failed && (old?.found || (!a && !ms))) throw new HttpError(502, 'No se pudo consultar la información de la obra')
|
||||||
if (!a && !ms) return { found: false }
|
if (!a && !ms) return { found: false }
|
||||||
const staff = (a?.staff?.edges || []).filter(e => /^(Story|Art|Original)/i.test(e.role)).map(e => ({ role: e.role, name: e.node.name.full, native: e.node.name.native }))
|
const staff = (a?.staff?.edges || []).filter(e => /^(Story|Art|Original)/i.test(e.role)).map(e => ({ role: e.role, name: e.node.name.full, native: e.node.name.native }))
|
||||||
const muAuthors = (ms?.authors || []).map(x => ({ role: x.type === 'Artist' ? 'Art' : 'Story', name: decode(x.name) }))
|
const muAuthors = (ms?.authors || []).map(x => ({ role: x.type === 'Artist' ? 'Art' : 'Story', name: decode(x.name) }))
|
||||||
@@ -181,14 +193,24 @@ export function setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, n
|
|||||||
].filter(x => x && /^https?:\/\//.test(x.url)).slice(0, 14),
|
].filter(x => x && /^https?:\/\//.test(x.url)).slice(0, 14),
|
||||||
matched: { via: l.via || null, title: l.title || null },
|
matched: { via: l.via || null, title: l.title || null },
|
||||||
}
|
}
|
||||||
})
|
}, wait)
|
||||||
|
}
|
||||||
|
|
||||||
|
// La ficha guardada, sin esperar a ninguna fuente (null si aún no hay). Si caducó, se actualiza por detrás.
|
||||||
|
function cachedInfo(mangaId) {
|
||||||
|
const row = db.prepare('SELECT value FROM meta_cache WHERE key = ?').get(`info:${mangaId}`)
|
||||||
|
if (!row) return null
|
||||||
|
info(mangaId).catch(() => {})
|
||||||
|
return JSON.parse(row.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Fechas de los capítulos ----------
|
// ---------- Fechas de los capítulos ----------
|
||||||
async function dates(mangaId) {
|
async function dates(mangaId, wait = false) {
|
||||||
const l = await links(mangaId)
|
const l = await links(mangaId, wait)
|
||||||
if (!l.md && !l.mu) return { dates: {}, via: [] }
|
if (!l.md && !l.mu) return { dates: {}, via: [] }
|
||||||
return stored(`dates:${mangaId}`, DAY, async () => {
|
return stored(`dates:${mangaId}`, DAY, async old => {
|
||||||
|
let failed = false
|
||||||
|
const soft = p => p.catch(() => { failed = true; return null })
|
||||||
const out = {}
|
const out = {}
|
||||||
const via = []
|
const via = []
|
||||||
const add = (n, d) => {
|
const add = (n, d) => {
|
||||||
@@ -200,10 +222,10 @@ export function setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, n
|
|||||||
if (l.md) {
|
if (l.md) {
|
||||||
let n = 0
|
let n = 0
|
||||||
for (let offset = 0; offset < 10000; offset += 500) {
|
for (let offset = 0; offset < 10000; offset += 500) {
|
||||||
const d = await md(`/manga/${l.md}/feed`, {
|
const d = await soft(md(`/manga/${l.md}/feed`, {
|
||||||
limit: 500, offset, 'order[chapter]': 'asc', 'contentRating[]': MD_RATINGS,
|
limit: 500, offset, 'order[chapter]': 'asc', 'contentRating[]': MD_RATINGS,
|
||||||
includeExternalUrl: 1, includeEmptyPages: 1, includeFuturePublishAt: 0, includeUnavailable: 1,
|
includeExternalUrl: 1, includeEmptyPages: 1, includeFuturePublishAt: 0, includeUnavailable: 1,
|
||||||
}, 3600e3).catch(() => null)
|
}, 3600e3))
|
||||||
for (const c of d?.data || []) { add(c.attributes.chapter, c.attributes.publishAt || c.attributes.createdAt); n++ }
|
for (const c of d?.data || []) { add(c.attributes.chapter, c.attributes.publishAt || c.attributes.createdAt); n++ }
|
||||||
if (!d || offset + 500 >= d.total) break
|
if (!d || offset + 500 >= d.total) break
|
||||||
}
|
}
|
||||||
@@ -212,7 +234,7 @@ export function setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, n
|
|||||||
if (l.mu) {
|
if (l.mu) {
|
||||||
let n = 0
|
let n = 0
|
||||||
for (let page = 1; page <= 40; page++) {
|
for (let page = 1; page <= 40; page++) {
|
||||||
const r = await mu('/releases/search', { search: String(l.mu), search_type: 'series', perpage: 100, page, orderby: 'date', asc: 'asc' }).catch(() => null)
|
const r = await soft(mu('/releases/search', { search: String(l.mu), search_type: 'series', perpage: 100, page, orderby: 'date', asc: 'asc' }))
|
||||||
for (const x of r?.results || []) {
|
for (const x of r?.results || []) {
|
||||||
const ch = String(x.record.chapter || '')
|
const ch = String(x.record.chapter || '')
|
||||||
const d = x.record.release_date
|
const d = x.record.release_date
|
||||||
@@ -229,23 +251,41 @@ export function setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, n
|
|||||||
}
|
}
|
||||||
if (n) via.push('MangaUpdates')
|
if (n) via.push('MangaUpdates')
|
||||||
}
|
}
|
||||||
|
if (failed && (old?.via?.length || !Object.keys(out).length)) throw new HttpError(502, 'No se pudieron consultar las fechas')
|
||||||
return { dates: out, via }
|
return { dates: out, via }
|
||||||
})
|
}, wait)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Mangas seguidos: siempre con su información ----------
|
||||||
|
// Cada hora se completa lo que falte o haya caducado, de uno en uno (también lo seguido al empezar a leer),
|
||||||
|
// y se reescalan los banners guardados que aún no lo estén.
|
||||||
|
let syncing = false
|
||||||
|
async function syncFollowed() {
|
||||||
|
if (syncing || !net.online()) return
|
||||||
|
syncing = true
|
||||||
|
try {
|
||||||
|
for (const { manga_id: id } of db.prepare('SELECT DISTINCT manga_id FROM follows WHERE active = 1').all()) {
|
||||||
|
if (!net.online()) break
|
||||||
|
await info(id, true).catch(e => console.warn('Info de seguidos', id, e.message))
|
||||||
|
await dates(id, true).catch(e => console.warn('Fechas de seguidos', id, e.message))
|
||||||
|
}
|
||||||
|
for (const { value } of db.prepare("SELECT value FROM meta_cache WHERE key LIKE 'info:%'").all()) {
|
||||||
|
const { banner } = JSON.parse(value)
|
||||||
|
if (banner) await prepareBanner(banner).catch(e => console.warn('Banner', e.message))
|
||||||
|
}
|
||||||
|
} finally { syncing = false }
|
||||||
|
}
|
||||||
|
setTimeout(syncFollowed, 60e3).unref()
|
||||||
|
setInterval(syncFollowed, SYNC_EVERY).unref()
|
||||||
|
net.whenOnline(syncFollowed)
|
||||||
|
|
||||||
async function route(p, method, res) {
|
async function route(p, method, res) {
|
||||||
const m = /^\/api\/meta\/([^/]+)(\/dates)?$/.exec(p)
|
const m = /^\/api\/meta\/([^/]+)(\/dates)?$/.exec(p)
|
||||||
if (!m || method !== 'GET') return false
|
if (!m || method !== 'GET') return false
|
||||||
const id = decodeURIComponent(m[1])
|
const id = decodeURIComponent(m[1])
|
||||||
if (!net.online()) throw new HttpError(503, 'El servidor no tiene conexión a internet: la información extra no está disponible.')
|
json(res, 200, await (m[2] ? dates(id) : info(id)))
|
||||||
if (m[2]) json(res, 200, await dates(id))
|
|
||||||
else {
|
|
||||||
// La ficha de la fuente (título, sinopsis en español, portada) va aparte de lo externo.
|
|
||||||
const [src, ext] = await Promise.all([fetchMangaFull(id, 'es').then(r => r.manga, () => null), info(id)])
|
|
||||||
json(res, 200, { ...ext, source: src && { title: src.title, description: src.description, cover: src.cover, authors: src.authors, source: src.source } })
|
|
||||||
}
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
return { route, links, info, dates }
|
return { route, links, info, cachedInfo, dates }
|
||||||
}
|
}
|
||||||
|
|||||||
+120
-75
@@ -44,7 +44,6 @@ const ICONS = {
|
|||||||
phone: '<rect x="6" y="2" width="12" height="20" rx="2.5"/><path d="M11 18h2"/>',
|
phone: '<rect x="6" y="2" width="12" height="20" rx="2.5"/><path d="M11 18h2"/>',
|
||||||
plus: '<path d="M12 5v14M5 12h14"/>',
|
plus: '<path d="M12 5v14M5 12h14"/>',
|
||||||
pencil: '<path d="M4 20h4L19 9l-4-4L4 16z"/><path d="m13.5 6.5 4 4"/>',
|
pencil: '<path d="M4 20h4L19 9l-4-4L4 16z"/><path d="m13.5 6.5 4 4"/>',
|
||||||
info: '<circle cx="12" cy="12" r="9"/><path d="M12 11v6"/><path d="M12 7.5h.01"/>',
|
|
||||||
sparkles: '<path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8z"/><path d="M19 15l.8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8z"/>',
|
sparkles: '<path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8z"/><path d="M19 15l.8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8z"/>',
|
||||||
}
|
}
|
||||||
const icon = name => `<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${ICONS[name]}</svg>`
|
const icon = name => `<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${ICONS[name]}</svg>`
|
||||||
@@ -368,7 +367,7 @@ async function router() {
|
|||||||
try {
|
try {
|
||||||
if (parts[0] === 'login') return (location.hash = '#/')
|
if (parts[0] === 'login') return (location.hash = '#/')
|
||||||
if (parts[0] === 'manga') return await viewManga(parts[1])
|
if (parts[0] === 'manga') return await viewManga(parts[1])
|
||||||
if (parts[0] === 'info') return await viewInfo(parts[1])
|
if (parts[0] === 'info') return (location.hash = `#/manga/${parts[1]}`) // la ficha ya incluye la info
|
||||||
if (parts[0] === 'read') return await viewReader(parts[1], parts[2], Number(params.get('page') || 0) - 1)
|
if (parts[0] === 'read') return await viewReader(parts[1], parts[2], Number(params.get('page') || 0) - 1)
|
||||||
if (parts[0] === 'history') return (location.hash = '#/library') // antiguo nombre
|
if (parts[0] === 'history') return (location.hash = '#/library') // antiguo nombre
|
||||||
if (parts[0] === 'library') return await viewLibrary(params)
|
if (parts[0] === 'library') return await viewLibrary(params)
|
||||||
@@ -588,7 +587,7 @@ async function viewHome(params) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Ficha del manga ----------
|
// ---------- Ficha del manga ----------
|
||||||
// ---------- Info: datos de la obra de AniList y MangaUpdates ----------
|
// ---------- Datos de la obra de AniList y MangaUpdates (parte de la ficha) ----------
|
||||||
const AL_FORMAT = { MANGA: 'Manga', NOVEL: 'Novela ligera', ONE_SHOT: 'Oneshot', Manga: 'Manga', Manhwa: 'Manhwa', Manhua: 'Manhua' }
|
const AL_FORMAT = { MANGA: 'Manga', NOVEL: 'Novela ligera', ONE_SHOT: 'Oneshot', Manga: 'Manga', Manhwa: 'Manhwa', Manhua: 'Manhua' }
|
||||||
const AL_STATUS = { FINISHED: 'Terminado', RELEASING: 'En publicación', NOT_YET_RELEASED: 'Sin estrenar', CANCELLED: 'Cancelado', HIATUS: 'En pausa' }
|
const AL_STATUS = { FINISHED: 'Terminado', RELEASING: 'En publicación', NOT_YET_RELEASED: 'Sin estrenar', CANCELLED: 'Cancelado', HIATUS: 'En pausa' }
|
||||||
const AL_COUNTRY = { JP: 'Japón', KR: 'Corea del Sur', CN: 'China', TW: 'Taiwán' }
|
const AL_COUNTRY = { JP: 'Japón', KR: 'Corea del Sur', CN: 'China', TW: 'Taiwán' }
|
||||||
@@ -597,74 +596,55 @@ const AL_REL = { SEQUEL: 'Secuela', PREQUEL: 'Precuela', SIDE_STORY: 'Historia p
|
|||||||
const AL_ANIME = { TV: 'Serie TV', TV_SHORT: 'Serie corta', MOVIE: 'Película', OVA: 'OVA', ONA: 'ONA', SPECIAL: 'Especial', MUSIC: 'Vídeo musical' }
|
const AL_ANIME = { TV: 'Serie TV', TV_SHORT: 'Serie corta', MOVIE: 'Película', OVA: 'OVA', ONA: 'ONA', SPECIAL: 'Especial', MUSIC: 'Vídeo musical' }
|
||||||
const fmtFuzzy = d => (!d ? '' : d.length === 4 ? d : d.length === 7 ? new Date(`${d}-15T12:00:00`).toLocaleDateString('es', { month: 'long', year: 'numeric' }) : fmtDate(d))
|
const fmtFuzzy = d => (!d ? '' : d.length === 4 ? d : d.length === 7 ? new Date(`${d}-15T12:00:00`).toLocaleDateString('es', { month: 'long', year: 'numeric' }) : fmtDate(d))
|
||||||
|
|
||||||
async function viewInfo(id) {
|
// Lo que la ficha pinta a partir de la información guardada de la obra (d.found).
|
||||||
if (cleanup) { cleanup(); cleanup = null }
|
function infoParts(d, manga) {
|
||||||
app.innerHTML = '<div class="loading">Buscando información de la obra…</div>'
|
|
||||||
const d = await api(`/api/meta/${id}`)
|
|
||||||
const src = d.source || {}
|
|
||||||
const back = `<a class="btn small" href="#/manga/${id}">${icon('back')} Volver</a>`
|
|
||||||
if (!d.found) {
|
|
||||||
app.innerHTML = `<div class="info-page">${back}<h1>${esc(src.title || '')}</h1>
|
|
||||||
<p class="muted">No se encontró esta obra en AniList ni en MangaUpdates. Puede tener otro título allí: prueba a buscarla en
|
|
||||||
<a class="tag" href="https://anilist.co/search/manga?search=${encodeURIComponent(src.title || '')}" target="_blank" rel="noopener">AniList</a>.</p></div>`
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const row = (k, v) => (v ? `<dt>${k}</dt><dd>${v}</dd>` : '')
|
const row = (k, v) => (v ? `<dt>${k}</dt><dd>${v}</dd>` : '')
|
||||||
const list = a => a.map(esc).join(', ')
|
|
||||||
const t = d.titles
|
const t = d.titles
|
||||||
const people = d.staff.reduce((m, p) => m.set(p.name, [...(m.get(p.name) || []), AL_ROLE(p.role)]), new Map())
|
const people = d.staff.reduce((m, p) => m.set(p.name, [...(m.get(p.name) || []), AL_ROLE(p.role)]), new Map())
|
||||||
const dates = d.start ? `${fmtFuzzy(d.start)}${d.end ? ` – ${fmtFuzzy(d.end)}` : d.status === 'RELEASING' ? ' – actualidad' : ''}` : ''
|
const dates = d.start ? `${fmtFuzzy(d.start)}${d.end ? ` – ${fmtFuzzy(d.end)}` : d.status === 'RELEASING' ? ' – actualidad' : ''}` : ''
|
||||||
const size = [d.volumes ? `${d.volumes} tomos` : '', d.chapters ? `${d.chapters} capítulos` : d.latestChapter ? `${d.latestChapter} capítulos (hasta ahora)` : ''].filter(Boolean).join(' · ')
|
const size = [d.volumes ? `${d.volumes} tomos` : '', d.chapters ? `${d.chapters} capítulos` : d.latestChapter ? `${d.latestChapter} capítulos (hasta ahora)` : ''].filter(Boolean).join(' · ')
|
||||||
const ranks = d.rankings.map(r => `#${r.rank} ${r.type === 'RATED' ? 'mejor valorado' : 'más popular'}${r.allTime ? ' de siempre' : ` de ${r.year}`}`)
|
const ranks = d.rankings.map(r => `#${r.rank} ${r.type === 'RATED' ? 'mejor valorado' : 'más popular'}${r.allTime ? ' de siempre' : ` de ${r.year}`}`)
|
||||||
const search = title => `#/?q=${encodeURIComponent(title)}`
|
const cardOf = r => `<a class="card" href="#/?q=${encodeURIComponent(r.title)}" title="Buscar «${esc(r.title)}»">
|
||||||
const cardOf = r => `<a class="card" href="${search(r.title)}" title="Buscar «${esc(r.title)}»">
|
|
||||||
<img class="cover" loading="lazy" src="${esc(r.cover || '/icon.svg')}" alt="" data-fallback>
|
<img class="cover" loading="lazy" src="${esc(r.cover || '/icon.svg')}" alt="" data-fallback>
|
||||||
<div class="t">${esc(r.title)}</div><div class="sub">${esc([r.relation ? AL_REL[r.relation] || r.relation : '', r.year || '', r.score ? `${r.score}%` : ''].filter(Boolean).join(' · '))}</div></a>`
|
<div class="t">${esc(r.title)}</div><div class="sub">${esc([r.relation ? AL_REL[r.relation] || r.relation : '', r.year || '', r.score ? `${r.score}%` : ''].filter(Boolean).join(' · '))}</div></a>`
|
||||||
const srcDesc = (src.description || '').trim()
|
const seen = new Set([manga.title.toLowerCase()])
|
||||||
app.innerHTML = `
|
const altTitles = [t.english, t.romaji, t.native].filter(x => x && !seen.has(x.toLowerCase()) && seen.add(x.toLowerCase()))
|
||||||
<div class="info-page">
|
const srcDesc = (manga.description || '').trim()
|
||||||
${d.banner ? `<div class="banner" style="background-image:url('${esc(d.banner)}')"></div>` : ''}
|
return {
|
||||||
<div class="row info-top">${back}</div>
|
banner: d.banner,
|
||||||
<div class="detail">
|
sub: altTitles.join(' · '),
|
||||||
<img class="cover" src="${esc(d.cover || src.cover || '/icon.svg')}" alt="" data-fallback>
|
scores: `<div class="scores">
|
||||||
<div class="detail-body">
|
${d.score != null ? `<div class="score"><b>${d.score}%</b><span>AniList</span></div>` : ''}
|
||||||
<h1>${esc(t.english || t.romaji || src.title)}</h1>
|
${d.muScore != null ? `<div class="score"><b>${Number(d.muScore).toFixed(1)}</b><span>MangaUpdates${d.muVotes ? ` · ${fmtNum(d.muVotes)} votos` : ''}</span></div>` : ''}
|
||||||
<div class="muted">${esc([t.romaji !== (t.english || t.romaji) ? t.romaji : '', t.native].filter(Boolean).join(' · '))}</div>
|
${d.popularity ? `<div class="score"><b>${fmtNum(d.popularity)}</b><span>lectores en AniList</span></div>` : ''}
|
||||||
<div class="scores">
|
|
||||||
${d.score != null ? `<div class="score"><b>${d.score}%</b><span>AniList</span></div>` : ''}
|
|
||||||
${d.muScore != null ? `<div class="score"><b>${Number(d.muScore).toFixed(1)}</b><span>MangaUpdates${d.muVotes ? ` · ${fmtNum(d.muVotes)} votos` : ''}</span></div>` : ''}
|
|
||||||
${d.popularity ? `<div class="score"><b>${fmtNum(d.popularity)}</b><span>lectores en AniList</span></div>` : ''}
|
|
||||||
</div>
|
|
||||||
${ranks.length ? `<div class="tags">${ranks.map(r => `<span class="tag hot">${esc(r)}</span>`).join('')}</div>` : ''}
|
|
||||||
<dl class="kv">
|
|
||||||
${[...people].map(([name, roles]) => row(esc(roles.join(' y ')), esc(name))).join('')}
|
|
||||||
${row('Revista', d.magazines.map(m => esc(m.name) + (m.publisher ? ` <span class="muted">(${esc(m.publisher)})</span>` : '')).join(', '))}
|
|
||||||
${row('Editorial', d.publishers.filter(p => p.type === 'Original').map(p => esc(p.name)).join(', '))}
|
|
||||||
${row('En inglés', d.publishers.filter(p => p.type === 'English').map(p => esc(p.name)).join(', '))}
|
|
||||||
${row('Tipo', esc([AL_FORMAT[d.format] || d.format, AL_COUNTRY[d.country]].filter(Boolean).join(' · ')))}
|
|
||||||
${row('Publicación', esc(dates))}
|
|
||||||
${row('Estado', esc([AL_STATUS[d.status], d.statusDetail].filter(Boolean).join(' · ')))}
|
|
||||||
${row('Extensión', esc(size))}
|
|
||||||
${row('Anime', [...d.adaptations.map(a => esc(`${AL_ANIME[a.format] || a.format || 'Anime'}${a.year ? ` (${a.year})` : ''}`)), d.anime ? `<span class="muted">adapta: ${esc(d.anime.start)}${d.anime.end ? ` → ${esc(d.anime.end)}` : ''}</span>` : ''].filter(Boolean).join('<br>'))}
|
|
||||||
</dl>
|
|
||||||
<div class="tags">${d.genres.map(g => `<span class="tag">${esc(g)}</span>`).join('')}${d.tags.map(g => `<span class="tag soft">${esc(g)}</span>`).join('')}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
${ranks.length ? `<div class="tags">${ranks.map(r => `<span class="tag hot">${esc(r)}</span>`).join('')}</div>` : ''}`,
|
||||||
${srcDesc ? `<h2>Sinopsis</h2><p class="desc long">${esc(srcDesc)}</p>` : ''}
|
kv: `${people.size ? [...people].map(([name, roles]) => row(esc(roles.join(' y ')), esc(name))).join('') : row('Autor', esc(manga.authors.join(', ')))}
|
||||||
${d.description ? (srcDesc ? `<details class="more"><summary>Sinopsis de AniList (en inglés)</summary><p class="desc long">${esc(d.description)}</p></details>` : `<h2>Sinopsis <span class="muted">(en inglés)</span></h2><p class="desc long">${esc(d.description)}</p>`) : ''}
|
${row('Revista', d.magazines.map(m => esc(m.name) + (m.publisher ? ` <span class="muted">(${esc(m.publisher)})</span>` : '')).join(', '))}
|
||||||
${t.other.length ? `<h2>Otros títulos</h2><p class="muted">${list(t.other)}</p>` : ''}
|
${row('Editorial', d.publishers.filter(p => p.type === 'Original').map(p => esc(p.name)).join(', '))}
|
||||||
${d.related.length ? `<h2>Obras relacionadas</h2><div class="grid">${d.related.map(cardOf).join('')}</div>` : ''}
|
${row('En inglés', d.publishers.filter(p => p.type === 'English').map(p => esc(p.name)).join(', '))}
|
||||||
${d.recommendations.length ? `<h2>Si te gusta, prueba</h2><div class="grid">${d.recommendations.map(cardOf).join('')}</div>` : ''}
|
${row('Tipo', esc([AL_FORMAT[d.format] || d.format, AL_COUNTRY[d.country]].filter(Boolean).join(' · ')))}
|
||||||
${d.links.length ? `<h2>Enlaces</h2><div class="tags">${d.links.map(l => `<a class="tag" href="${esc(l.url)}" target="_blank" rel="noopener noreferrer">${esc(l.site)}</a>`).join('')}</div>` : ''}
|
${row('Publicación', esc(dates || manga.year || ''))}
|
||||||
<p class="muted note">Datos de AniList y MangaUpdates${d.matched?.title ? ` (identificado por título como «${esc(d.matched.title)}»)` : ''}. Las fechas de los capítulos son las de la primera traducción conocida.</p>
|
${row('Estado', esc([AL_STATUS[d.status], d.statusDetail].filter(Boolean).join(' · ') || manga.status || ''))}
|
||||||
</div>`
|
${row('Extensión', esc(size))}
|
||||||
|
${row('Anime', [...d.adaptations.map(a => esc(`${AL_ANIME[a.format] || a.format || 'Anime'}${a.year ? ` (${a.year})` : ''}`)), d.anime ? `<span class="muted">adapta: ${esc(d.anime.start)}${d.anime.end ? ` → ${esc(d.anime.end)}` : ''}</span>` : ''].filter(Boolean).join('<br>'))}`,
|
||||||
|
genres: d.genres.map(g => `<span class="tag">${esc(g)}</span>`).join(''),
|
||||||
|
softTags: d.tags.map(g => `<span class="tag soft">${esc(g)}</span>`).join(''),
|
||||||
|
synopsis: `${srcDesc ? `<h2>Sinopsis</h2><p class="desc long">${esc(srcDesc)}</p>` : ''}
|
||||||
|
${d.description ? (srcDesc ? `<details class="more"><summary>Sinopsis de AniList (en inglés)</summary><p class="desc long">${esc(d.description)}</p></details>` : `<h2>Sinopsis <span class="muted">(en inglés)</span></h2><p class="desc long">${esc(d.description)}</p>`) : ''}`,
|
||||||
|
extra: `${t.other.length ? `<h2>Otros títulos</h2><p class="muted">${t.other.map(esc).join(', ')}</p>` : ''}
|
||||||
|
${d.related.length ? `<h2>Obras relacionadas</h2><div class="hrow">${d.related.map(cardOf).join('')}</div>` : ''}
|
||||||
|
${d.recommendations.length ? `<h2>Si te gusta, prueba</h2><div class="hrow">${d.recommendations.map(cardOf).join('')}</div>` : ''}
|
||||||
|
${d.links.length ? `<h2>Enlaces</h2><div class="tags">${d.links.map(l => `<a class="tag" href="${esc(l.url)}" target="_blank" rel="noopener noreferrer">${esc(l.site)}</a>`).join('')}</div>` : ''}
|
||||||
|
<p class="muted note">Datos de AniList y MangaUpdates${d.matched?.title ? ` (identificado por título como «${esc(d.matched.title)}»)` : ''}. Las fechas de los capítulos son las de la primera traducción conocida.</p>`,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function viewManga(id, langOverride) {
|
async function viewManga(id, langOverride) {
|
||||||
if (cleanup) { cleanup(); cleanup = null }
|
if (cleanup) { cleanup(); cleanup = null }
|
||||||
app.innerHTML = '<div class="loading">Cargando…</div>'
|
app.innerHTML = '<div class="loading">Cargando…</div>'
|
||||||
const lang = langOverride || store.get(`lang:${id}`, me.lang)
|
const lang = langOverride || store.get(`lang:${id}`, me.lang)
|
||||||
const { manga, chapters, reads, progress, chapterLangs, offline, downloaded, download, upscale, following, clips: clipCount = 0, lists: inLists = [], newKeys = [] } = await api(`/api/manga/${id}?lang=${encodeURIComponent(lang)}`)
|
const { manga, chapters, reads, progress, chapterLangs, offline, downloaded, download, upscale, following, clips: clipCount = 0, lists: inLists = [], newKeys = [], info: savedInfo = null, clipBanner = null } = await api(`/api/manga/${id}?lang=${encodeURIComponent(lang)}`)
|
||||||
const dlSet = new Set(downloaded)
|
const dlSet = new Set(downloaded)
|
||||||
const newSet = new Set(newKeys.map(String))
|
const newSet = new Set(newKeys.map(String))
|
||||||
let isFollowing = following
|
let isFollowing = following
|
||||||
@@ -683,30 +663,43 @@ 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 = [
|
||||||
manga.source === 'lc' ? '' : `<a href="${altSearch}">${icon('search')} Buscar en LeerCapitulo</a>`,
|
manga.source === 'lc' ? '' : `<a class="btn" href="${altSearch}">${icon('search')} Buscar en LeerCapitulo</a>`,
|
||||||
manga.source === 'mi' ? '' : `<a href="${altSearchMi}">${icon('search')} Buscar en M440</a>`,
|
manga.source === 'mi' ? '' : `<a class="btn" 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="btn danger">${icon('stopRead')} Dejar de leer…</button>` : '',
|
||||||
].join('')
|
].join('')
|
||||||
|
|
||||||
|
const srcTags = manga.tags.map(t => (t.id ? `<a class="tag" href="${tagHref(manga.source, t)}">${esc(t.name)}</a>` : `<span class="tag">${esc(t.name ?? t)}</span>`)).join('')
|
||||||
|
const srcSub = `${esc(manga.authors.join(', '))}${manga.year ? ` · ${manga.year}` : ''}${manga.status ? ` · ${esc(manga.status)}` : ''}`
|
||||||
app.innerHTML = `
|
app.innerHTML = `
|
||||||
|
<div class="banner" id="mbanner" hidden></div>
|
||||||
<div class="detail">
|
<div class="detail">
|
||||||
<img class="cover" src="${esc(manga.cover || '/icon.svg')}" alt="">
|
<img class="cover" id="mcover" src="${esc(manga.cover || '/icon.svg')}" alt="">
|
||||||
<div class="detail-body">
|
<div class="detail-body">
|
||||||
<h1>${esc(manga.title)}</h1>
|
<h1>${esc(manga.title)}</h1>
|
||||||
<div class="muted">${esc(manga.authors.join(', '))}${manga.year ? ` · ${manga.year}` : ''}${manga.status ? ` · ${esc(manga.status)}` : ''}</div>
|
<div class="muted" id="msub">${srcSub}</div>
|
||||||
<div class="tags">${manga.tags.map(t => (t.id ? `<a class="tag" href="${tagHref(manga.source, t)}">${esc(t.name)}</a>` : `<span class="tag">${esc(t.name ?? t)}</span>`)).join('')}</div>
|
<div id="mscores"></div>
|
||||||
<p class="desc">${esc(manga.description)}</p>
|
|
||||||
<div class="actions">${mainBtn}
|
<div class="actions">${mainBtn}
|
||||||
<button class="btn" id="follow"></button>
|
${chapters.length ? `<button class="btn" id="tochap" title="Ir a la lista de capítulos">${icon('queue')} Capítulos</button>` : ''}
|
||||||
${offline ? '' : `<a class="btn" href="#/info/${id}" title="Autor, revista, puntuación, adaptaciones…">${icon('info')} Info</a>`}
|
<button class="btn" id="libbtn" title="Más opciones" aria-label="Más opciones"></button>
|
||||||
${offline ? '' : `<details class="menu" id="lmenu"><summary class="btn" id="lbtn" title="Añadir a tus listas">${icon('bookmark')} Listas</summary><div class="menu-list" id="lmlist"><div class="muted note">Cargando…</div></div></details>`}
|
|
||||||
${menuItems ? `<details class="menu"><summary class="btn" title="Más opciones" aria-label="Más opciones">${icon('dots')}</summary><div class="menu-list">${menuItems}</div></details>` : ''}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="facts muted"><span>Fuente: ${esc(SOURCES[manga.source])}</span>${download ? `<span>Carpeta: mangas/${esc(download.folder)}</span>` : ''}${clipCount ? `<a href="#/clips?manga=${encodeURIComponent(id)}">${icon('scissors')} ${clipCount} recorte${clipCount > 1 ? 's' : ''}</a>` : ''}</div>
|
|
||||||
${offline ? '<p class="error">Sin conexión con la fuente: mostrando solo los capítulos descargados.</p>' : ''}
|
${offline ? '<p class="error">Sin conexión con la fuente: mostrando solo los capítulos descargados.</p>' : ''}
|
||||||
<div class="libpanel"><div class="psec" id="dl"></div><div class="psec" id="hd" hidden></div><div class="psec" id="off" hidden></div></div>
|
<div class="info-block" id="msyn"></div>
|
||||||
|
<div class="tags" id="mtags"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="info-block" id="mextra"></div>
|
||||||
|
<div class="drawer-back" id="libback" hidden></div>
|
||||||
|
<aside class="drawer" id="libdrawer" aria-label="Más opciones" inert>
|
||||||
|
<div class="drawer-head"><b>Opciones</b><button class="btn small" id="libclose" title="Cerrar" aria-label="Cerrar">${icon('x')}</button></div>
|
||||||
|
<div class="drawer-opts">
|
||||||
|
<button class="btn" id="follow"></button>
|
||||||
|
${offline ? '' : `<details class="menu" id="lmenu"><summary class="btn" id="lbtn" title="Añadir a tus listas">${icon('bookmark')} Listas</summary><div class="menu-list" id="lmlist"><div class="muted note">Cargando…</div></div></details>`}
|
||||||
|
${clipCount ? `<a class="btn" href="#/clips?manga=${encodeURIComponent(id)}">${icon('scissors')} ${clipCount} recorte${clipCount > 1 ? 's' : ''}</a>` : ''}
|
||||||
|
${menuItems}
|
||||||
|
</div>
|
||||||
|
<div class="libpanel"><div class="psec" id="dl"></div><div class="psec" id="hd" hidden></div><div class="psec" id="off" hidden></div></div>
|
||||||
|
<div class="libpanel" id="mdata"><div class="psec"><div class="psec-title">${icon('book')} Datos de la obra</div><dl class="kv" id="mkv"></dl></div></div>
|
||||||
|
</aside>
|
||||||
<div class="list-head">
|
<div class="list-head">
|
||||||
<h2>Capítulos <span class="muted">(${chapters.length})</span>${newSet.size ? ` <span class="badge">${newSet.size} ${newSet.size === 1 ? 'nuevo' : 'nuevos'}</span>` : ''}</h2>
|
<h2>Capítulos <span class="muted">(${chapters.length})</span>${newSet.size ? ` <span class="badge">${newSet.size} ${newSet.size === 1 ? 'nuevo' : 'nuevos'}</span>` : ''}</h2>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
@@ -719,6 +712,54 @@ async function viewManga(id, langOverride) {
|
|||||||
${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>`
|
||||||
|
|
||||||
|
// --- Información de la obra: la guardada llega con la ficha; si aún no hay, se pide y se pinta al llegar.
|
||||||
|
const synBox = $('#msyn')
|
||||||
|
const paintInfo = d => {
|
||||||
|
if (!synBox.isConnected) return // ya se cambió de página
|
||||||
|
const parts = d?.found ? infoParts(d, manga) : null
|
||||||
|
const banner = $('#mbanner')
|
||||||
|
// El de AniList (&b=1: reescalado) o, si no tiene, uno de tus recortes; sin ninguno, nada.
|
||||||
|
const bannerUrl = parts?.banner ? `${parts.banner}&b=1` : clipBanner
|
||||||
|
banner.hidden = !bannerUrl
|
||||||
|
banner.style.backgroundImage = bannerUrl ? `url("${bannerUrl}")` : ''
|
||||||
|
if (!manga.cover && d?.cover) $('#mcover').src = d.cover
|
||||||
|
$('#msub').innerHTML = parts ? esc(parts.sub) : srcSub
|
||||||
|
$('#mscores').innerHTML = parts?.scores || ''
|
||||||
|
const folder = download ? `<dt>Carpeta</dt><dd>mangas/${esc(download.folder)}</dd>` : ''
|
||||||
|
$('#mkv').innerHTML = (parts?.kv || '') + folder
|
||||||
|
$('#mdata').hidden = !parts && !folder
|
||||||
|
$('#mtags').innerHTML = (srcTags || parts?.genres || '') + (parts?.softTags || '')
|
||||||
|
const srcDesc = (manga.description || '').trim()
|
||||||
|
synBox.innerHTML = parts?.synopsis || (srcDesc ? `<h2>Sinopsis</h2><p class="desc long">${esc(srcDesc)}</p>` : '')
|
||||||
|
$('#mextra').innerHTML = parts?.extra || (d ? `<p class="muted note">No se encontró esta obra en AniList ni en MangaUpdates. Puede tener otro título allí: prueba a buscarla en
|
||||||
|
<a class="tag" href="https://anilist.co/search/manga?search=${encodeURIComponent(manga.title)}" target="_blank" rel="noopener">AniList</a>.</p>` : '')
|
||||||
|
}
|
||||||
|
paintInfo(savedInfo)
|
||||||
|
if (!savedInfo) {
|
||||||
|
$('#mextra').innerHTML = '<p class="muted note">Buscando información de la obra…</p>'
|
||||||
|
api(`/api/meta/${id}`).then(paintInfo, () => { if (synBox.isConnected) $('#mextra').innerHTML = '' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Menú lateral (botón ⋯): seguir, listas, recortes, otras fuentes, copia local, reescalado HD, este dispositivo y datos de la obra.
|
||||||
|
const openLib = open => {
|
||||||
|
$('#libdrawer').classList.toggle('open', open)
|
||||||
|
$('#libdrawer').inert = !open
|
||||||
|
$('#libback').hidden = !open
|
||||||
|
}
|
||||||
|
// El ⋯ avisa con un punto si hay algo en curso (descarga, reescalado o guardado en el dispositivo).
|
||||||
|
const libBusy = {}
|
||||||
|
const markLib = (kind, busy) => {
|
||||||
|
libBusy[kind] = busy
|
||||||
|
$('#libbtn').innerHTML = `${icon('dots')}${Object.values(libBusy).some(Boolean) ? ' <span class="libbusy" title="En curso"></span>' : ''}`
|
||||||
|
}
|
||||||
|
const toChapters = () => $('.list-head')?.scrollIntoView({ behavior: 'smooth' })
|
||||||
|
$('#libbtn').addEventListener('click', () => openLib(true))
|
||||||
|
$('#libclose').addEventListener('click', () => openLib(false))
|
||||||
|
$('#libback').addEventListener('click', () => openLib(false))
|
||||||
|
$('#tochap')?.addEventListener('click', toChapters)
|
||||||
|
const onLibKey = e => { if (e.key === 'Escape') openLib(false) }
|
||||||
|
document.addEventListener('keydown', onLibKey)
|
||||||
|
|
||||||
const secHead = (ic, title, status) => `<div class="psec-head"><div class="psec-title">${icon(ic)} ${title}</div>${status ? `<div class="psec-status muted">${status}</div>` : ''}</div>`
|
const secHead = (ic, title, status) => `<div class="psec-head"><div class="psec-title">${icon(ic)} ${title}</div>${status ? `<div class="psec-status muted">${status}</div>` : ''}</div>`
|
||||||
|
|
||||||
// --- Selector de capítulos compartido por "Copia local" (kind 'dl') y "Reescalado HD" (kind 'hd'):
|
// --- Selector de capítulos compartido por "Copia local" (kind 'dl') y "Reescalado HD" (kind 'hd'):
|
||||||
@@ -823,6 +864,7 @@ async function viewManga(id, langOverride) {
|
|||||||
if (prevKind && prevKind !== next?.kind) renderSelForm(prevKind) // cerrar el otro formulario
|
if (prevKind && prevKind !== next?.kind) renderSelForm(prevKind) // cerrar el otro formulario
|
||||||
if (next) renderSelForm(next.kind)
|
if (next) renderSelForm(next.kind)
|
||||||
if (wasPick !== (next?.mode === 'pick') || (wasPick && prevKind !== next?.kind)) renderList()
|
if (wasPick !== (next?.mode === 'pick') || (wasPick && prevKind !== next?.kind)) renderList()
|
||||||
|
if (next?.mode === 'pick' && !wasPick) { openLib(false); toChapters() } // se marca en la lista: hay que verla
|
||||||
$('#dlopen')?.classList.toggle('on', sel?.kind === 'dl')
|
$('#dlopen')?.classList.toggle('on', sel?.kind === 'dl')
|
||||||
$('#hdopen')?.classList.toggle('on', sel?.kind === 'hd')
|
$('#hdopen')?.classList.toggle('on', sel?.kind === 'hd')
|
||||||
$('#offopen')?.classList.toggle('on', sel?.kind === 'off')
|
$('#offopen')?.classList.toggle('on', sel?.kind === 'off')
|
||||||
@@ -867,6 +909,7 @@ async function viewManga(id, langOverride) {
|
|||||||
const box = $('#dl')
|
const box = $('#dl')
|
||||||
if (!box) return
|
if (!box) return
|
||||||
const active = d && DL_ACTIVE.includes(d.status)
|
const active = d && DL_ACTIVE.includes(d.status)
|
||||||
|
markLib('dl', !!active)
|
||||||
const some = d?.scope === 'some'
|
const some = d?.scope === 'some'
|
||||||
const partial = d && d.done < d.total
|
const partial = d && d.done < d.total
|
||||||
const bar = d && (active || partial) ? `<div class="bar dlbar"><i style="width:${downloadPct(d).toFixed(1)}%"></i></div>` : ''
|
const bar = d && (active || partial) ? `<div class="bar dlbar"><i style="width:${downloadPct(d).toFixed(1)}%"></i></div>` : ''
|
||||||
@@ -941,6 +984,7 @@ async function viewManga(id, langOverride) {
|
|||||||
hdInfo = u
|
hdInfo = u
|
||||||
const show = !!(u?.available && u.downloaded && !offline)
|
const show = !!(u?.available && u.downloaded && !offline)
|
||||||
box.hidden = !show
|
box.hidden = !show
|
||||||
|
markLib('hd', show && (!!u.current || Object.values(u.jobs).some(s => s !== 'error')))
|
||||||
if (show) {
|
if (show) {
|
||||||
const queued = Object.values(u.jobs).filter(s => s !== 'error').length
|
const queued = Object.values(u.jobs).filter(s => s !== 'error').length
|
||||||
const active = !!u.current || queued > 0
|
const active = !!u.current || queued > 0
|
||||||
@@ -991,6 +1035,7 @@ async function viewManga(id, langOverride) {
|
|||||||
const bytes = mine.reduce((n, [, c]) => n + (c.bytes || 0), 0)
|
const bytes = mine.reduce((n, [, c]) => n + (c.bytes || 0), 0)
|
||||||
const run = offState.running?.mangaId === id ? offState.running : null
|
const run = offState.running?.mangaId === id ? offState.running : null
|
||||||
const queued = offState.queue.filter(q => q.mangaId === id).length
|
const queued = offState.queue.filter(q => q.mangaId === id).length
|
||||||
|
markLib('off', !!(run || queued))
|
||||||
box.hidden = false
|
box.hidden = false
|
||||||
// Siguientes 3 sin guardar a partir de donde vas (o desde el principio).
|
// Siguientes 3 sin guardar a partir de donde vas (o desde el principio).
|
||||||
const from = Math.max(0, chapters.findIndex(c => c.id === progress?.chapter_id))
|
const from = Math.max(0, chapters.findIndex(c => c.id === progress?.chapter_id))
|
||||||
@@ -1088,10 +1133,10 @@ async function viewManga(id, langOverride) {
|
|||||||
const back = prevHash && !prevHash.startsWith('#/read/') && !prevHash.startsWith(`#/manga/${id}`) && prevHash !== '#/login'
|
const back = prevHash && !prevHash.startsWith('#/read/') && !prevHash.startsWith(`#/manga/${id}`) && prevHash !== '#/login'
|
||||||
location.hash = back ? prevHash : '#/'
|
location.hash = back ? prevHash : '#/'
|
||||||
})
|
})
|
||||||
// Cerrar el menú ⋯ al pulsar fuera.
|
// Cerrar el desplegable de listas al pulsar fuera.
|
||||||
const closeMenu = e => app.querySelectorAll('.menu[open]').forEach(menu => { if (!menu.contains(e.target)) menu.open = false })
|
const closeMenu = e => app.querySelectorAll('.menu[open]').forEach(menu => { if (!menu.contains(e.target)) menu.open = false })
|
||||||
document.addEventListener('click', closeMenu)
|
document.addEventListener('click', closeMenu)
|
||||||
cleanup = () => { clearTimeout(dlTimer); clearTimeout(hdTimer); document.removeEventListener('click', closeMenu); offState.listeners.delete(onOff) }
|
cleanup = () => { clearTimeout(dlTimer); clearTimeout(hdTimer); document.removeEventListener('click', closeMenu); document.removeEventListener('keydown', onLibKey); offState.listeners.delete(onOff) }
|
||||||
app.querySelectorAll('[data-lang]').forEach(a => a.addEventListener('click', e => { e.preventDefault(); store.set(`lang:${id}`, a.dataset.lang); viewManga(id, a.dataset.lang) }))
|
app.querySelectorAll('[data-lang]').forEach(a => a.addEventListener('click', e => { e.preventDefault(); store.set(`lang:${id}`, a.dataset.lang); viewManga(id, a.dataset.lang) }))
|
||||||
|
|
||||||
const ul = $('.chapters', app)
|
const ul = $('.chapters', app)
|
||||||
@@ -1179,7 +1224,7 @@ async function viewManga(id, langOverride) {
|
|||||||
updateSummary()
|
updateSummary()
|
||||||
})
|
})
|
||||||
renderList()
|
renderList()
|
||||||
if (!offline && serverOnline) api(`/api/meta/${id}/dates`).then(r => { extDates = r.dates || {}; if (Object.keys(extDates).length) { buildDates(); renderList() } }, () => {})
|
api(`/api/meta/${id}/dates`).then(r => { extDates = r.dates || {}; if (Object.keys(extDates).length) { buildDates(); renderList() } }, () => {})
|
||||||
renderDl(download)
|
renderDl(download)
|
||||||
renderHd(upscale)
|
renderHd(upscale)
|
||||||
renderOff()
|
renderOff()
|
||||||
@@ -2341,7 +2386,7 @@ async function viewLibrary(params) {
|
|||||||
sub: `${plural(m.chapters.length, 'capítulo')} · ${fmtSize(m.bytes)}<br>${esc(last.map(c => c.num ?? '·').slice(0, 8).join(', '))}${last.length > 8 ? '…' : ''}`,
|
sub: `${plural(m.chapters.length, 'capítulo')} · ${fmtSize(m.bytes)}<br>${esc(last.map(c => c.num ?? '·').slice(0, 8).join(', '))}${last.length > 8 ? '…' : ''}`,
|
||||||
del: { attrs: `data-deldev="${m.mangaId}"`, title: 'Borrar del dispositivo' },
|
del: { attrs: `data-deldev="${m.mangaId}"`, title: 'Borrar del dispositivo' },
|
||||||
})
|
})
|
||||||
}), 'No hay nada guardado en este dispositivo. En la ficha de un manga, «En este dispositivo» guarda capítulos para leerlos sin internet.')
|
}), 'No hay nada guardado en este dispositivo. En la ficha de un manga, «⋯ → En este dispositivo» guarda capítulos para leerlos sin internet.')
|
||||||
} else if (curList) {
|
} else if (curList) {
|
||||||
const l = await api(`/api/lists/${curList.id}`).catch(err => (toast(err.message), { items: [] }))
|
const l = await api(`/api/lists/${curList.id}`).catch(err => (toast(err.message), { items: [] }))
|
||||||
body.innerHTML = grid(l.items.map(it => libCard({
|
body.innerHTML = grid(l.items.map(it => libCard({
|
||||||
@@ -2475,7 +2520,7 @@ async function viewQueues() {
|
|||||||
${rest ? `<div>${cur ? `${rest} más` : plural(rest, 'capítulo')} en cola</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>`,
|
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>'}`
|
}).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>
|
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" id="devq">${devHtml()}</section>` : `<h1>Colas</h1>
|
||||||
@@ -2696,7 +2741,7 @@ function viewSettings() {
|
|||||||
<label class="row muted" style="font-size:.88rem"><input type="checkbox" id="pfhd"> Reescalarlos también a HD</label>
|
<label class="row muted" style="font-size:.88rem"><input type="checkbox" id="pfhd"> Reescalarlos también a HD</label>
|
||||||
</div></section>
|
</div></section>
|
||||||
<section><b>${icon('phone')} App y lectura sin conexión</b>
|
<section><b>${icon('phone')} App y lectura sin conexión</b>
|
||||||
${OFFLINE_OK ? `<p class="muted" style="margin:0;font-size:.88rem">Capítulos guardados en este dispositivo: <b id="offsum">…</b>. Se guardan desde la ficha de cada manga («En este dispositivo») o desde el lector.</p>
|
${OFFLINE_OK ? `<p class="muted" style="margin:0;font-size:.88rem">Capítulos guardados en este dispositivo: <b id="offsum">…</b>. Se guardan desde la ficha de cada manga («⋯ → En este dispositivo») o desde el lector.</p>
|
||||||
<div class="row" style="flex-wrap:wrap;gap:8px"><a class="btn small" href="#/library?tab=device">${icon('library')} Ver lo guardado</a>
|
<div class="row" style="flex-wrap:wrap;gap:8px"><a class="btn small" href="#/library?tab=device">${icon('library')} Ver lo guardado</a>
|
||||||
<button class="btn small danger" id="offclear">${icon('trash')} Borrar todo del dispositivo</button>
|
<button class="btn small danger" id="offclear">${icon('trash')} Borrar todo del dispositivo</button>
|
||||||
<button class="btn small" id="install" hidden>${icon('download')} Instalar como app</button></div>`
|
<button class="btn small" id="install" hidden>${icon('download')} Instalar como app</button></div>`
|
||||||
|
|||||||
+28
-8
@@ -380,13 +380,33 @@ body.reading #netbanner { display: none; }
|
|||||||
|
|
||||||
/* Info de la obra */
|
/* Info de la obra */
|
||||||
.chapters .meta .cdate { white-space: nowrap; }
|
.chapters .meta .cdate { white-space: nowrap; }
|
||||||
.info-page { position: relative; }
|
.banner { height: 180px; margin: -16px -16px 16px; background: center / cover no-repeat; mask-image: linear-gradient(#000 40%, transparent); -webkit-mask-image: linear-gradient(#000 40%, transparent); opacity: .7; }
|
||||||
.info-page .banner { height: 180px; margin: -16px -16px 16px; background: center / cover no-repeat; mask-image: linear-gradient(#000 40%, transparent); -webkit-mask-image: linear-gradient(#000 40%, transparent); opacity: .7; }
|
.banner[hidden] { display: none; }
|
||||||
.info-page .info-top { margin-bottom: 14px; }
|
.info-block h2 { margin: 26px 0 10px; font-size: 1.1rem; }
|
||||||
.info-page .banner + .info-top { position: absolute; top: 12px; left: 0; }
|
.detail-body .info-block h2:first-child { margin-top: 18px; }
|
||||||
.info-page h2 { margin: 26px 0 10px; font-size: 1.1rem; }
|
.detail-body .info-block + .tags { margin-top: 14px; }
|
||||||
.info-page .desc.long { max-height: none; }
|
.drawer .kv { margin: 0; }
|
||||||
.info-page .more summary { cursor: pointer; color: var(--muted); margin-top: 14px; }
|
.info-block .desc.long { max-height: none; }
|
||||||
|
.info-block .more summary { cursor: pointer; color: var(--muted); margin-top: 14px; }
|
||||||
|
.info-block > .note { margin-top: 16px; }
|
||||||
|
.kv:empty, #mscores:empty { display: none; }
|
||||||
|
.hrow { display: grid; grid-auto-flow: column; grid-auto-columns: 130px; gap: 12px; justify-content: start; overflow-x: auto; padding-bottom: 8px; overscroll-behavior-x: contain; }
|
||||||
|
.list-head { scroll-margin-top: 64px; }
|
||||||
|
/* Menú lateral de la ficha (botón ⋯) */
|
||||||
|
.drawer { position: fixed; top: 0; right: 0; bottom: 0; z-index: 35; width: min(440px, 100%); padding: 14px 14px max(14px, env(safe-area-inset-bottom)); display: grid; align-content: start; gap: 12px;
|
||||||
|
overflow-y: auto; overscroll-behavior: contain; background: var(--bg); border-left: 1px solid var(--surface-2); box-shadow: -10px 0 28px rgba(0,0,0,.45);
|
||||||
|
transform: translateX(100%); visibility: hidden; transition: transform .22s ease, visibility .22s; }
|
||||||
|
.drawer.open { transform: none; visibility: visible; }
|
||||||
|
.drawer-head { display: flex; justify-content: space-between; align-items: center; font-size: 1.05rem; padding-top: env(safe-area-inset-top); }
|
||||||
|
.drawer-opts { display: grid; gap: 8px; }
|
||||||
|
.drawer-opts .btn { width: 100%; justify-content: flex-start; box-sizing: border-box; }
|
||||||
|
.drawer-opts .menu > summary { padding-left: 16px; padding-right: 16px; }
|
||||||
|
.drawer-opts .menu-list { position: static; margin-top: 6px; box-shadow: none; min-width: 0; }
|
||||||
|
.drawer .libpanel { margin-top: 0; max-width: none; min-width: 0; }
|
||||||
|
.drawer-back { position: fixed; inset: 0; z-index: 34; background: rgba(0,0,0,.5); touch-action: none; }
|
||||||
|
.drawer-back[hidden] { display: none; }
|
||||||
|
.libbusy { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); animation: libpulse 1.2s ease-in-out infinite; }
|
||||||
|
@keyframes libpulse { 50% { opacity: .25; } }
|
||||||
.scores { display: flex; flex-wrap: wrap; gap: 10px; margin: 12px 0 6px; }
|
.scores { display: flex; flex-wrap: wrap; gap: 10px; margin: 12px 0 6px; }
|
||||||
.score { background: var(--surface); border-radius: 10px; padding: 8px 12px; display: grid; }
|
.score { background: var(--surface); border-radius: 10px; padding: 8px 12px; display: grid; }
|
||||||
.score b { font-size: 1.25rem; }
|
.score b { font-size: 1.25rem; }
|
||||||
@@ -396,5 +416,5 @@ body.reading #netbanner { display: none; }
|
|||||||
.kv dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
|
.kv dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
|
||||||
.tag.soft { background: transparent; border: 1px solid var(--surface-2); }
|
.tag.soft { background: transparent; border: 1px solid var(--surface-2); }
|
||||||
.tag.hot { color: var(--accent); }
|
.tag.hot { color: var(--accent); }
|
||||||
@media (max-width: 640px) { .kv { grid-template-columns: 1fr; gap: 0; } .kv dd { margin-bottom: 8px; } .info-page .banner { height: 120px; } }
|
@media (max-width: 640px) { .kv { grid-template-columns: 1fr; gap: 0; } .kv dd { margin-bottom: 8px; } .banner { height: 120px; } }
|
||||||
.chapters .meta .cdate.approx { opacity: .7; }
|
.chapters .meta .cdate.approx { opacity: .7; }
|
||||||
|
|||||||
+20
-9
@@ -460,14 +460,16 @@ const sourceOf = id => (id.startsWith('lc~') ? 'lc' : id.startsWith('mi~') ? 'mi
|
|||||||
const spanishOnly = id => sourceOf(id) !== '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) {
|
// banner: imagen de fondo de la ficha, siempre en HD.
|
||||||
|
async function proxyImage(req, res, u, s, isCover = false, coverish = false, banner = false) {
|
||||||
let url
|
let url
|
||||||
try { url = new URL(u) } catch { throw new HttpError(400, 'URL inválida') }
|
try { url = new URL(u) } catch { throw new HttpError(400, 'URL inválida') }
|
||||||
const expected = sign(u)
|
const expected = sign(u)
|
||||||
if (typeof s !== 'string' || s.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected))) throw new HttpError(403, 'Firma inválida')
|
if (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
|
const hdKey = banner ? bannerKey(u) : isCover ? u : null
|
||||||
|
if (hdKey && covers.sendCached(res, hdKey)) return
|
||||||
// Los CDN de las fuentes fallan a ratos (520, conexión cortada, respuestas que no llegan): hasta 3
|
// 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.
|
// 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
|
net.guard() // sin internet: fallar ya (la portada local entra como respaldo), sin reintentos
|
||||||
@@ -477,11 +479,11 @@ async function proxyImage(req, res, u, s, isCover = false, coverish = false) {
|
|||||||
return r
|
return r
|
||||||
}).catch(e => { throw e instanceof HttpError ? e : new HttpError(502, `Imagen: ${e.message}`) })
|
}).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 (hdKey) {
|
||||||
const buf = Buffer.from(await r.arrayBuffer())
|
const buf = Buffer.from(await r.arrayBuffer())
|
||||||
res.writeHead(200, { 'Content-Type': r.headers.get('content-type') || 'image/jpeg', 'Cache-Control': covers.pendingCache() })
|
res.writeHead(200, { 'Content-Type': r.headers.get('content-type') || 'image/jpeg', 'Cache-Control': covers.pendingCache(banner) })
|
||||||
res.end(buf)
|
res.end(buf)
|
||||||
return covers.enqueue(u, buf)
|
return covers.enqueue(hdKey, buf, banner)
|
||||||
}
|
}
|
||||||
res.writeHead(200, {
|
res.writeHead(200, {
|
||||||
'Content-Type': r.headers.get('content-type') || 'image/jpeg',
|
'Content-Type': r.headers.get('content-type') || 'image/jpeg',
|
||||||
@@ -491,6 +493,15 @@ async function proxyImage(req, res, u, s, isCover = false, coverish = false) {
|
|||||||
res.end()
|
res.end()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const bannerKey = u => `banner:${u}`
|
||||||
|
// Reescala un banner sin esperar a que alguien abra la ficha (recibe la URL del proxy, como se guarda).
|
||||||
|
async function prepareBanner(proxyUrl) {
|
||||||
|
const u = rawFromProxy(proxyUrl)
|
||||||
|
if (!u || !covers.wants(bannerKey(u))) return
|
||||||
|
const r = await fetchImage(new URL(u), AbortSignal.timeout(60e3))
|
||||||
|
if (r.ok) covers.enqueue(bannerKey(u), Buffer.from(await r.arrayBuffer()), true)
|
||||||
|
}
|
||||||
|
|
||||||
function fetchImage(url, signal) {
|
function fetchImage(url, signal) {
|
||||||
net.guard()
|
net.guard()
|
||||||
const host = new URL(url).hostname
|
const host = new URL(url).hostname
|
||||||
@@ -890,7 +901,7 @@ async function route(req, res) {
|
|||||||
if (p === '/api/img' && method === 'GET') {
|
if (p === '/api/img' && method === 'GET') {
|
||||||
const mid = MANGA_ID.test(q.get('m') || '') ? q.get('m') : null
|
const mid = MANGA_ID.test(q.get('m') || '') ? q.get('m') : null
|
||||||
try {
|
try {
|
||||||
return await proxyImage(req, res, q.get('u') || '', q.get('s'), !!mid && tracked(user.id, mid), q.has('m'))
|
return await proxyImage(req, res, q.get('u') || '', q.get('s'), !!mid && tracked(user.id, mid), q.has('m'), q.has('b'))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Portada sin conexión con la fuente: la cover.jpg de la copia local, si la hay.
|
// Portada sin conexión con la fuente: la cover.jpg de la copia local, si la hay.
|
||||||
if (mid && !res.headersSent && e.status !== 403) {
|
if (mid && !res.headersSent && e.status !== 403) {
|
||||||
@@ -940,7 +951,7 @@ async function route(req, res) {
|
|||||||
const reads = db.prepare('SELECT chapter_id, page, total_pages FROM chapter_reads WHERE user_id = ? AND manga_id = ?').all(user.id, m[1])
|
const 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 !== 'md' || 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]), info: meta.cachedInfo(m[1]), clipBanner: clips.bannerFor(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])
|
||||||
@@ -1075,7 +1086,7 @@ const covers = setupCovers({ DATA_DIR, upscale })
|
|||||||
const library = setupLibrary({ db, HttpError, json, readBody, MANGA_ID, CHAPTER_ID, fetchMangaFull, enqueueDownload, linkDownload, upscale, stats: features.stats, searchManga, mdCategories, net })
|
const library = setupLibrary({ db, HttpError, json, readBody, MANGA_ID, CHAPTER_ID, fetchMangaFull, enqueueDownload, linkDownload, upscale, stats: features.stats, searchManga, mdCategories, net })
|
||||||
// Al volver internet: reanudar las descargas en cola.
|
// Al volver internet: reanudar las descargas en cola.
|
||||||
net.whenOnline(() => pumpDownloads())
|
net.whenOnline(() => pumpDownloads())
|
||||||
const meta = setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, net, decode, imgUrl, UA })
|
const meta = setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, net, decode, imgUrl, UA, prepareBanner })
|
||||||
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: lo descargado antes de que hubiera dueños pasa a quien lo lee, lo sigue o lo tiene en una
|
// Migración: lo descargado antes de que hubiera dueños pasa a quien lo lee, lo sigue o lo tiene en una
|
||||||
|
|||||||
Reference in New Issue
Block a user