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:
+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
|
||||
|
||||
// Banner de la ficha cuando la obra no tiene: un recorte del propio usuario, el más apaisado (es el que mejor
|
||||
// llena la franja) y, si los hay, de entre los ya reescalados.
|
||||
function bannerFor(userId, mangaId) {
|
||||
const c = db.prepare(`SELECT * FROM clips WHERE user_id = ? AND manga_id = ?
|
||||
ORDER BY status = 'done' DESC, CAST(width AS REAL) / height DESC, id DESC LIMIT 1`).get(userId, mangaId)
|
||||
if (!c) return null
|
||||
const v = view(c)
|
||||
return v.hd || v.img
|
||||
}
|
||||
|
||||
async function readImage(req) {
|
||||
let size = 0
|
||||
const chunks = []
|
||||
@@ -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
|
||||
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
|
||||
// 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.
|
||||
// Los banners de la ficha pasan por lo mismo, pero se reescalan siempre: se ven a todo el ancho de la pantalla.
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
@@ -11,6 +12,7 @@ const MAX_QUEUE = 300
|
||||
const X4_MAX_SIDE = 400
|
||||
const MAX_SIDE = 1600 // más grande ya no hace falta reescalarla
|
||||
const WEBP_QUALITY = 88
|
||||
const BANNER_X4_MAX_WIDTH = 1000
|
||||
|
||||
// Ancho/alto de un JPEG, PNG o WebP sin decodificarlo (null si no se reconoce).
|
||||
export function imageSize(b) {
|
||||
@@ -53,23 +55,26 @@ export function setupCovers({ DATA_DIR, upscale }) {
|
||||
return true
|
||||
}
|
||||
// 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) {
|
||||
if (!upscale.available() || pending.has(key) || failed.has(key) || queue.length >= MAX_QUEUE || fs.existsSync(fileOf(key))) return
|
||||
// ¿Falta generar esta imagen? (para no descargarla si no hace falta)
|
||||
const wants = key => upscale.available() && !pending.has(key) && !failed.has(key) && queue.length < MAX_QUEUE && !fs.existsSync(fileOf(key))
|
||||
function enqueue(key, buf, banner = false) {
|
||||
if (!wants(key)) return
|
||||
pending.add(key)
|
||||
queue.push({ key, buf })
|
||||
queue.push({ key, buf, banner })
|
||||
pump()
|
||||
}
|
||||
|
||||
async function processOne({ key, buf }) {
|
||||
async function processOne({ key, buf, banner }) {
|
||||
const size = imageSize(buf)
|
||||
if (!size) throw new Error('formato no reconocido')
|
||||
const work = fs.mkdtempSync(path.join(os.tmpdir(), 'cover-'))
|
||||
try {
|
||||
const out = path.join(work, 'out.png')
|
||||
const tmp = `${fileOf(key)}.tmp`
|
||||
if (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.
|
||||
const inp = path.join(work, `in.${size.type}`)
|
||||
fs.writeFileSync(inp, buf)
|
||||
@@ -78,7 +83,8 @@ export function setupCovers({ DATA_DIR, upscale }) {
|
||||
} else {
|
||||
const inp = path.join(work, `in.${size.type}`)
|
||||
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('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
|
||||
// idioma (MangaDex) o grupo (MangaUpdates): es la fecha de la primera traducción, muy cercana a la de
|
||||
// publicación en series en curso.
|
||||
// Todo se guarda en la base de datos (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 INFO_TTL = v => (v?.found ? 30 * DAY : 3 * DAY)
|
||||
const SYNC_EVERY = 3600e3
|
||||
const ANILIST = 'https://graphql.anilist.co'
|
||||
const MU_API = 'https://api.mangaupdates.com/v1'
|
||||
const MD_RATINGS = ['safe', 'suggestive', 'erotica', 'pornographic']
|
||||
@@ -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, ''))
|
||||
.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)`)
|
||||
|
||||
// Guardado en la base de datos con caducidad; ttl puede depender del valor (p. ej. "no encontrado" dura menos).
|
||||
// Si caducó se devuelve lo guardado y se actualiza en segundo plano; con wait se espera a la actualización.
|
||||
const inflight = new Map()
|
||||
function stored(key, ttl, fn) {
|
||||
function stored(key, ttl, fn, wait = false) {
|
||||
const row = db.prepare('SELECT value, at FROM meta_cache WHERE key = ?').get(key)
|
||||
const old = row ? JSON.parse(row.value) : undefined
|
||||
const life = v => (typeof ttl === 'function' ? ttl(v) : ttl)
|
||||
if (row && Date.now() - row.at < life(old)) return Promise.resolve(old)
|
||||
if (inflight.has(key)) return inflight.get(key)
|
||||
const p = (async () => {
|
||||
try {
|
||||
const v = await fn()
|
||||
db.prepare('INSERT INTO meta_cache (key, value, at) VALUES (?, ?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value, at = excluded.at').run(key, JSON.stringify(v), Date.now())
|
||||
return v
|
||||
} catch (e) {
|
||||
if (old !== undefined) return old
|
||||
throw e
|
||||
}
|
||||
})().finally(() => inflight.delete(key))
|
||||
inflight.set(key, p)
|
||||
return p
|
||||
if (!inflight.has(key)) {
|
||||
inflight.set(key, (async () => {
|
||||
try {
|
||||
const v = await fn(old)
|
||||
db.prepare('INSERT INTO meta_cache (key, value, at) VALUES (?, ?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value, at = excluded.at').run(key, JSON.stringify(v), Date.now())
|
||||
return v
|
||||
} catch (e) {
|
||||
if (old !== undefined) return old
|
||||
throw e
|
||||
}
|
||||
})().finally(() => inflight.delete(key)))
|
||||
}
|
||||
return old !== undefined && !wait ? Promise.resolve(old) : inflight.get(key)
|
||||
}
|
||||
|
||||
// Peticiones a AniList/MangaUpdates, de una en una con una pausa (los dos limitan la frecuencia).
|
||||
@@ -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))]
|
||||
|
||||
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 () => {
|
||||
if (sourceOf(mangaId) === 'md') {
|
||||
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 title = manga.title
|
||||
// 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)))
|
||||
hits.sort((a, b) => Object.keys(b.attributes.links || {}).length - Object.keys(a.attributes.links || {}).length)
|
||||
if (hits[0]) return { ...mdLinks(hits[0]), via: 'MangaDex', title: mdTitles(hits[0])[0] }
|
||||
const a = await 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)))
|
||||
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))
|
||||
if (sm) return { md: null, al: null, mu: sm.record.series_id, mal: null, via: 'MangaUpdates', title: decode(sm.record.title) }
|
||||
if (failed) throw new HttpError(502, 'No se pudo identificar la obra')
|
||||
return {}
|
||||
})
|
||||
}, wait)
|
||||
}
|
||||
|
||||
// ---------- 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 cover = u => (u ? imgUrl(u) : null)
|
||||
|
||||
async function info(mangaId) {
|
||||
const l = await links(mangaId)
|
||||
return stored(`info:${mangaId}`, 7 * DAY, async () => {
|
||||
async function info(mangaId, wait = false) {
|
||||
const l = await links(mangaId, wait)
|
||||
return stored(`info:${mangaId}`, INFO_TTL, async old => {
|
||||
let failed = false
|
||||
const [al, ms] = await Promise.all([
|
||||
l.al ? anilist(`query($id:Int){Media(id:$id,type:MANGA){${AL_FIELDS}}}`, { id: l.al })
|
||||
: l.mal ? anilist(`query($id:Int){Media(idMal:$id,type:MANGA){${AL_FIELDS}}}`, { id: l.mal }) : null,
|
||||
l.mu ? mu(`/series/${l.mu}`) : null,
|
||||
].map(p => Promise.resolve(p).catch(e => { console.warn('Info:', e.message); return null })))
|
||||
].map(p => Promise.resolve(p).catch(e => { console.warn('Info:', e.message); failed = true; return null })))
|
||||
const a = al?.Media
|
||||
// Con una fuente caída no se pisa lo que había ni se da la obra por no encontrada.
|
||||
if (failed && (old?.found || (!a && !ms))) throw new HttpError(502, 'No se pudo consultar la información de la obra')
|
||||
if (!a && !ms) return { found: false }
|
||||
const staff = (a?.staff?.edges || []).filter(e => /^(Story|Art|Original)/i.test(e.role)).map(e => ({ role: e.role, name: e.node.name.full, native: e.node.name.native }))
|
||||
const muAuthors = (ms?.authors || []).map(x => ({ role: x.type === 'Artist' ? 'Art' : 'Story', name: decode(x.name) }))
|
||||
@@ -181,14 +193,24 @@ export function setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, n
|
||||
].filter(x => x && /^https?:\/\//.test(x.url)).slice(0, 14),
|
||||
matched: { via: l.via || null, title: l.title || null },
|
||||
}
|
||||
})
|
||||
}, wait)
|
||||
}
|
||||
|
||||
// La ficha guardada, sin esperar a ninguna fuente (null si aún no hay). Si caducó, se actualiza por detrás.
|
||||
function cachedInfo(mangaId) {
|
||||
const row = db.prepare('SELECT value FROM meta_cache WHERE key = ?').get(`info:${mangaId}`)
|
||||
if (!row) return null
|
||||
info(mangaId).catch(() => {})
|
||||
return JSON.parse(row.value)
|
||||
}
|
||||
|
||||
// ---------- Fechas de los capítulos ----------
|
||||
async function dates(mangaId) {
|
||||
const l = await links(mangaId)
|
||||
async function dates(mangaId, wait = false) {
|
||||
const l = await links(mangaId, wait)
|
||||
if (!l.md && !l.mu) return { dates: {}, via: [] }
|
||||
return stored(`dates:${mangaId}`, DAY, async () => {
|
||||
return stored(`dates:${mangaId}`, DAY, async old => {
|
||||
let failed = false
|
||||
const soft = p => p.catch(() => { failed = true; return null })
|
||||
const out = {}
|
||||
const via = []
|
||||
const add = (n, d) => {
|
||||
@@ -200,10 +222,10 @@ export function setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, n
|
||||
if (l.md) {
|
||||
let n = 0
|
||||
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,
|
||||
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++ }
|
||||
if (!d || offset + 500 >= d.total) break
|
||||
}
|
||||
@@ -212,7 +234,7 @@ export function setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, n
|
||||
if (l.mu) {
|
||||
let n = 0
|
||||
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 || []) {
|
||||
const ch = String(x.record.chapter || '')
|
||||
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 (failed && (old?.via?.length || !Object.keys(out).length)) throw new HttpError(502, 'No se pudieron consultar las fechas')
|
||||
return { dates: out, via }
|
||||
})
|
||||
}, wait)
|
||||
}
|
||||
|
||||
// ---------- Mangas seguidos: siempre con su información ----------
|
||||
// Cada hora se completa lo que falte o haya caducado, de uno en uno (también lo seguido al empezar a leer),
|
||||
// y se reescalan los banners guardados que aún no lo estén.
|
||||
let syncing = false
|
||||
async function syncFollowed() {
|
||||
if (syncing || !net.online()) return
|
||||
syncing = true
|
||||
try {
|
||||
for (const { manga_id: id } of db.prepare('SELECT DISTINCT manga_id FROM follows WHERE active = 1').all()) {
|
||||
if (!net.online()) break
|
||||
await info(id, true).catch(e => console.warn('Info de seguidos', id, e.message))
|
||||
await dates(id, true).catch(e => console.warn('Fechas de seguidos', id, e.message))
|
||||
}
|
||||
for (const { value } of db.prepare("SELECT value FROM meta_cache WHERE key LIKE 'info:%'").all()) {
|
||||
const { banner } = JSON.parse(value)
|
||||
if (banner) await prepareBanner(banner).catch(e => console.warn('Banner', e.message))
|
||||
}
|
||||
} finally { syncing = false }
|
||||
}
|
||||
setTimeout(syncFollowed, 60e3).unref()
|
||||
setInterval(syncFollowed, SYNC_EVERY).unref()
|
||||
net.whenOnline(syncFollowed)
|
||||
|
||||
async function route(p, method, res) {
|
||||
const m = /^\/api\/meta\/([^/]+)(\/dates)?$/.exec(p)
|
||||
if (!m || method !== 'GET') return false
|
||||
const id = decodeURIComponent(m[1])
|
||||
if (!net.online()) throw new HttpError(503, 'El servidor no tiene conexión a internet: la información extra no está disponible.')
|
||||
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 } })
|
||||
}
|
||||
json(res, 200, await (m[2] ? dates(id) : info(id)))
|
||||
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"/>',
|
||||
plus: '<path d="M12 5v14M5 12h14"/>',
|
||||
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"/>',
|
||||
}
|
||||
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 {
|
||||
if (parts[0] === 'login') return (location.hash = '#/')
|
||||
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] === 'history') return (location.hash = '#/library') // antiguo nombre
|
||||
if (parts[0] === 'library') return await viewLibrary(params)
|
||||
@@ -588,7 +587,7 @@ async function viewHome(params) {
|
||||
}
|
||||
|
||||
// ---------- 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_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' }
|
||||
@@ -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 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) {
|
||||
if (cleanup) { cleanup(); cleanup = null }
|
||||
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
|
||||
}
|
||||
// Lo que la ficha pinta a partir de la información guardada de la obra (d.found).
|
||||
function infoParts(d, manga) {
|
||||
const row = (k, v) => (v ? `<dt>${k}</dt><dd>${v}</dd>` : '')
|
||||
const list = a => a.map(esc).join(', ')
|
||||
const t = d.titles
|
||||
const people = d.staff.reduce((m, p) => m.set(p.name, [...(m.get(p.name) || []), AL_ROLE(p.role)]), new Map())
|
||||
const dates = d.start ? `${fmtFuzzy(d.start)}${d.end ? ` – ${fmtFuzzy(d.end)}` : d.status === 'RELEASING' ? ' – actualidad' : ''}` : ''
|
||||
const size = [d.volumes ? `${d.volumes} tomos` : '', d.chapters ? `${d.chapters} capítulos` : d.latestChapter ? `${d.latestChapter} capítulos (hasta ahora)` : ''].filter(Boolean).join(' · ')
|
||||
const ranks = d.rankings.map(r => `#${r.rank} ${r.type === 'RATED' ? 'mejor valorado' : 'más popular'}${r.allTime ? ' de siempre' : ` de ${r.year}`}`)
|
||||
const search = title => `#/?q=${encodeURIComponent(title)}`
|
||||
const cardOf = r => `<a class="card" href="${search(r.title)}" title="Buscar «${esc(r.title)}»">
|
||||
const cardOf = r => `<a class="card" href="#/?q=${encodeURIComponent(r.title)}" title="Buscar «${esc(r.title)}»">
|
||||
<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>`
|
||||
const srcDesc = (src.description || '').trim()
|
||||
app.innerHTML = `
|
||||
<div class="info-page">
|
||||
${d.banner ? `<div class="banner" style="background-image:url('${esc(d.banner)}')"></div>` : ''}
|
||||
<div class="row info-top">${back}</div>
|
||||
<div class="detail">
|
||||
<img class="cover" src="${esc(d.cover || src.cover || '/icon.svg')}" alt="" data-fallback>
|
||||
<div class="detail-body">
|
||||
<h1>${esc(t.english || t.romaji || src.title)}</h1>
|
||||
<div class="muted">${esc([t.romaji !== (t.english || t.romaji) ? t.romaji : '', t.native].filter(Boolean).join(' · '))}</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>
|
||||
const seen = new Set([manga.title.toLowerCase()])
|
||||
const altTitles = [t.english, t.romaji, t.native].filter(x => x && !seen.has(x.toLowerCase()) && seen.add(x.toLowerCase()))
|
||||
const srcDesc = (manga.description || '').trim()
|
||||
return {
|
||||
banner: d.banner,
|
||||
sub: altTitles.join(' · '),
|
||||
scores: `<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>
|
||||
</div>
|
||||
${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>`) : ''}
|
||||
${t.other.length ? `<h2>Otros títulos</h2><p class="muted">${list(t.other)}</p>` : ''}
|
||||
${d.related.length ? `<h2>Obras relacionadas</h2><div class="grid">${d.related.map(cardOf).join('')}</div>` : ''}
|
||||
${d.recommendations.length ? `<h2>Si te gusta, prueba</h2><div class="grid">${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>
|
||||
</div>`
|
||||
${ranks.length ? `<div class="tags">${ranks.map(r => `<span class="tag hot">${esc(r)}</span>`).join('')}</div>` : ''}`,
|
||||
kv: `${people.size ? [...people].map(([name, roles]) => row(esc(roles.join(' y ')), esc(name))).join('') : row('Autor', esc(manga.authors.join(', ')))}
|
||||
${row('Revista', d.magazines.map(m => esc(m.name) + (m.publisher ? ` <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 || manga.year || ''))}
|
||||
${row('Estado', esc([AL_STATUS[d.status], d.statusDetail].filter(Boolean).join(' · ') || manga.status || ''))}
|
||||
${row('Extensión', esc(size))}
|
||||
${row('Anime', [...d.adaptations.map(a => esc(`${AL_ANIME[a.format] || a.format || 'Anime'}${a.year ? ` (${a.year})` : ''}`)), d.anime ? `<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) {
|
||||
if (cleanup) { cleanup(); cleanup = null }
|
||||
app.innerHTML = '<div class="loading">Cargando…</div>'
|
||||
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 newSet = new Set(newKeys.map(String))
|
||||
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>`
|
||||
|
||||
const menuItems = [
|
||||
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>` : '',
|
||||
manga.source === 'lc' ? '' : `<a class="btn" href="${altSearch}">${icon('search')} Buscar en LeerCapitulo</a>`,
|
||||
manga.source === 'mi' ? '' : `<a class="btn" href="${altSearchMi}">${icon('search')} Buscar en M440</a>`,
|
||||
progress || reads.length ? `<button id="drop" class="btn danger">${icon('stopRead')} Dejar de leer…</button>` : '',
|
||||
].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 = `
|
||||
<div class="banner" id="mbanner" hidden></div>
|
||||
<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">
|
||||
<h1>${esc(manga.title)}</h1>
|
||||
<div class="muted">${esc(manga.authors.join(', '))}${manga.year ? ` · ${manga.year}` : ''}${manga.status ? ` · ${esc(manga.status)}` : ''}</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>
|
||||
<p class="desc">${esc(manga.description)}</p>
|
||||
<div class="muted" id="msub">${srcSub}</div>
|
||||
<div id="mscores"></div>
|
||||
<div class="actions">${mainBtn}
|
||||
<button class="btn" id="follow"></button>
|
||||
${offline ? '' : `<a class="btn" href="#/info/${id}" title="Autor, revista, puntuación, adaptaciones…">${icon('info')} Info</a>`}
|
||||
${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>` : ''}
|
||||
${chapters.length ? `<button class="btn" id="tochap" title="Ir a la lista de capítulos">${icon('queue')} Capítulos</button>` : ''}
|
||||
<button class="btn" id="libbtn" title="Más opciones" aria-label="Más opciones"></button>
|
||||
</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>' : ''}
|
||||
<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 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">
|
||||
<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">
|
||||
@@ -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>`}
|
||||
<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>`
|
||||
|
||||
// --- 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 (next) renderSelForm(next.kind)
|
||||
if (wasPick !== (next?.mode === 'pick') || (wasPick && prevKind !== next?.kind)) renderList()
|
||||
if (next?.mode === 'pick' && !wasPick) { openLib(false); toChapters() } // se marca en la lista: hay que verla
|
||||
$('#dlopen')?.classList.toggle('on', sel?.kind === 'dl')
|
||||
$('#hdopen')?.classList.toggle('on', sel?.kind === 'hd')
|
||||
$('#offopen')?.classList.toggle('on', sel?.kind === 'off')
|
||||
@@ -867,6 +909,7 @@ async function viewManga(id, langOverride) {
|
||||
const box = $('#dl')
|
||||
if (!box) return
|
||||
const active = d && DL_ACTIVE.includes(d.status)
|
||||
markLib('dl', !!active)
|
||||
const some = d?.scope === 'some'
|
||||
const partial = d && d.done < d.total
|
||||
const bar = d && (active || partial) ? `<div class="bar dlbar"><i style="width:${downloadPct(d).toFixed(1)}%"></i></div>` : ''
|
||||
@@ -941,6 +984,7 @@ async function viewManga(id, langOverride) {
|
||||
hdInfo = u
|
||||
const show = !!(u?.available && u.downloaded && !offline)
|
||||
box.hidden = !show
|
||||
markLib('hd', show && (!!u.current || Object.values(u.jobs).some(s => s !== 'error')))
|
||||
if (show) {
|
||||
const queued = Object.values(u.jobs).filter(s => s !== 'error').length
|
||||
const active = !!u.current || queued > 0
|
||||
@@ -991,6 +1035,7 @@ async function viewManga(id, langOverride) {
|
||||
const bytes = mine.reduce((n, [, c]) => n + (c.bytes || 0), 0)
|
||||
const run = offState.running?.mangaId === id ? offState.running : null
|
||||
const queued = offState.queue.filter(q => q.mangaId === id).length
|
||||
markLib('off', !!(run || queued))
|
||||
box.hidden = false
|
||||
// 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))
|
||||
@@ -1088,10 +1133,10 @@ async function viewManga(id, langOverride) {
|
||||
const back = prevHash && !prevHash.startsWith('#/read/') && !prevHash.startsWith(`#/manga/${id}`) && prevHash !== '#/login'
|
||||
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 })
|
||||
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) }))
|
||||
|
||||
const ul = $('.chapters', app)
|
||||
@@ -1179,7 +1224,7 @@ async function viewManga(id, langOverride) {
|
||||
updateSummary()
|
||||
})
|
||||
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)
|
||||
renderHd(upscale)
|
||||
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 ? '…' : ''}`,
|
||||
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) {
|
||||
const l = await api(`/api/lists/${curList.id}`).catch(err => (toast(err.message), { items: [] }))
|
||||
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>` : ''}`,
|
||||
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>
|
||||
<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>
|
||||
</div></section>
|
||||
<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>
|
||||
<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>`
|
||||
|
||||
+28
-8
@@ -380,13 +380,33 @@ body.reading #netbanner { display: none; }
|
||||
|
||||
/* Info de la obra */
|
||||
.chapters .meta .cdate { white-space: nowrap; }
|
||||
.info-page { position: relative; }
|
||||
.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; }
|
||||
.info-page .info-top { margin-bottom: 14px; }
|
||||
.info-page .banner + .info-top { position: absolute; top: 12px; left: 0; }
|
||||
.info-page h2 { margin: 26px 0 10px; font-size: 1.1rem; }
|
||||
.info-page .desc.long { max-height: none; }
|
||||
.info-page .more summary { cursor: pointer; color: var(--muted); margin-top: 14px; }
|
||||
.banner { height: 180px; margin: -16px -16px 16px; background: center / cover no-repeat; mask-image: linear-gradient(#000 40%, transparent); -webkit-mask-image: linear-gradient(#000 40%, transparent); opacity: .7; }
|
||||
.banner[hidden] { display: none; }
|
||||
.info-block h2 { margin: 26px 0 10px; font-size: 1.1rem; }
|
||||
.detail-body .info-block h2:first-child { margin-top: 18px; }
|
||||
.detail-body .info-block + .tags { margin-top: 14px; }
|
||||
.drawer .kv { margin: 0; }
|
||||
.info-block .desc.long { max-height: none; }
|
||||
.info-block .more summary { cursor: pointer; color: var(--muted); margin-top: 14px; }
|
||||
.info-block > .note { margin-top: 16px; }
|
||||
.kv:empty, #mscores:empty { display: none; }
|
||||
.hrow { display: grid; grid-auto-flow: column; grid-auto-columns: 130px; gap: 12px; justify-content: start; overflow-x: auto; padding-bottom: 8px; overscroll-behavior-x: contain; }
|
||||
.list-head { scroll-margin-top: 64px; }
|
||||
/* Menú lateral de la ficha (botón ⋯) */
|
||||
.drawer { position: fixed; top: 0; right: 0; bottom: 0; z-index: 35; width: min(440px, 100%); padding: 14px 14px max(14px, env(safe-area-inset-bottom)); display: grid; align-content: start; gap: 12px;
|
||||
overflow-y: auto; overscroll-behavior: contain; background: var(--bg); border-left: 1px solid var(--surface-2); box-shadow: -10px 0 28px rgba(0,0,0,.45);
|
||||
transform: translateX(100%); visibility: hidden; transition: transform .22s ease, visibility .22s; }
|
||||
.drawer.open { transform: none; visibility: visible; }
|
||||
.drawer-head { display: flex; justify-content: space-between; align-items: center; font-size: 1.05rem; padding-top: env(safe-area-inset-top); }
|
||||
.drawer-opts { display: grid; gap: 8px; }
|
||||
.drawer-opts .btn { width: 100%; justify-content: flex-start; box-sizing: border-box; }
|
||||
.drawer-opts .menu > summary { padding-left: 16px; padding-right: 16px; }
|
||||
.drawer-opts .menu-list { position: static; margin-top: 6px; box-shadow: none; min-width: 0; }
|
||||
.drawer .libpanel { margin-top: 0; max-width: none; min-width: 0; }
|
||||
.drawer-back { position: fixed; inset: 0; z-index: 34; background: rgba(0,0,0,.5); touch-action: none; }
|
||||
.drawer-back[hidden] { display: none; }
|
||||
.libbusy { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); animation: libpulse 1.2s ease-in-out infinite; }
|
||||
@keyframes libpulse { 50% { opacity: .25; } }
|
||||
.scores { display: flex; flex-wrap: wrap; gap: 10px; margin: 12px 0 6px; }
|
||||
.score { background: var(--surface); border-radius: 10px; padding: 8px 12px; display: grid; }
|
||||
.score b { font-size: 1.25rem; }
|
||||
@@ -396,5 +416,5 @@ body.reading #netbanner { display: none; }
|
||||
.kv dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
|
||||
.tag.soft { background: transparent; border: 1px solid var(--surface-2); }
|
||||
.tag.hot { color: var(--accent); }
|
||||
@media (max-width: 640px) { .kv { grid-template-columns: 1fr; gap: 0; } .kv dd { margin-bottom: 8px; } .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; }
|
||||
|
||||
+20
-9
@@ -460,14 +460,16 @@ const sourceOf = id => (id.startsWith('lc~') ? 'lc' : id.startsWith('mi~') ? 'mi
|
||||
const spanishOnly = id => sourceOf(id) !== 'md'
|
||||
|
||||
// Proxy de imágenes: MangaDex bloquea el hotlinking de portadas y así el navegador no depende de CDNs ajenos.
|
||||
// isCover: portada a servir en HD; coverish: portada sin HD (caché corta por si luego se sigue el manga).
|
||||
async function proxyImage(req, res, u, s, isCover = false, coverish = false) {
|
||||
// isCover: portada a servir en HD; coverish: portada sin HD (caché corta por si luego se sigue el manga);
|
||||
// banner: imagen de fondo de la ficha, siempre en HD.
|
||||
async function proxyImage(req, res, u, s, isCover = false, coverish = false, banner = false) {
|
||||
let url
|
||||
try { url = new URL(u) } catch { throw new HttpError(400, 'URL inválida') }
|
||||
const expected = sign(u)
|
||||
if (typeof s !== 'string' || s.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected))) throw new HttpError(403, 'Firma inválida')
|
||||
if (!/^https?:$/.test(url.protocol) || /^(localhost|\d+\.\d+\.\d+\.\d+|\[.*\])$/.test(url.hostname)) throw new HttpError(403, 'Host no permitido')
|
||||
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
|
||||
// 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
|
||||
@@ -477,11 +479,11 @@ async function proxyImage(req, res, u, s, isCover = false, coverish = false) {
|
||||
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 (isCover) {
|
||||
if (hdKey) {
|
||||
const buf = Buffer.from(await r.arrayBuffer())
|
||||
res.writeHead(200, { 'Content-Type': r.headers.get('content-type') || 'image/jpeg', 'Cache-Control': covers.pendingCache() })
|
||||
res.writeHead(200, { 'Content-Type': r.headers.get('content-type') || 'image/jpeg', 'Cache-Control': covers.pendingCache(banner) })
|
||||
res.end(buf)
|
||||
return covers.enqueue(u, buf)
|
||||
return covers.enqueue(hdKey, buf, banner)
|
||||
}
|
||||
res.writeHead(200, {
|
||||
'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()
|
||||
}
|
||||
|
||||
const bannerKey = u => `banner:${u}`
|
||||
// Reescala un banner sin esperar a que alguien abra la ficha (recibe la URL del proxy, como se guarda).
|
||||
async function prepareBanner(proxyUrl) {
|
||||
const u = rawFromProxy(proxyUrl)
|
||||
if (!u || !covers.wants(bannerKey(u))) return
|
||||
const r = await fetchImage(new URL(u), AbortSignal.timeout(60e3))
|
||||
if (r.ok) covers.enqueue(bannerKey(u), Buffer.from(await r.arrayBuffer()), true)
|
||||
}
|
||||
|
||||
function fetchImage(url, signal) {
|
||||
net.guard()
|
||||
const host = new URL(url).hostname
|
||||
@@ -890,7 +901,7 @@ async function route(req, res) {
|
||||
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'))
|
||||
return await proxyImage(req, res, q.get('u') || '', q.get('s'), !!mid && tracked(user.id, mid), q.has('m'), q.has('b'))
|
||||
} catch (e) {
|
||||
// Portada sin conexión con la fuente: la cover.jpg de la copia local, si la hay.
|
||||
if (mid && !res.headersSent && e.status !== 403) {
|
||||
@@ -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 progress = db.prepare('SELECT * FROM progress WHERE user_id = ? AND manga_id = ?').get(user.id, m[1]) ?? null
|
||||
const chapterLangs = chapters.length || manga.source !== 'md' || offline ? [] : await getChapterLangs(m[1]).catch(() => [])
|
||||
return json(res, 200, { manga, chapters, lang, reads, progress, chapterLangs, offline, downloaded, download: downloadInfo(m[1], true), upscale: upscale.info(m[1]), clips: clips.count(user.id, m[1]), lists: library.listsWith(user.id, m[1]), ...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') {
|
||||
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 })
|
||||
// Al volver internet: reanudar las descargas en cola.
|
||||
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 })
|
||||
|
||||
// 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