Categorías, tags clicables, progreso de descarga y eliminar copia local

- Pestaña "Categorías" (géneros/temáticas de LeerCapitulo, tags de MangaDex)
  con búsqueda dentro de la categoría y "Cargar más".
- Los tags de la ficha llevan a su categoría.
- Barra de progreso de descarga con el capítulo y la página en curso.
- Eliminar la copia local (también a mitad de descarga) desde la ficha y
  desde la página Descargas, mostrando el espacio ocupado.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 00:04:09 -06:00
co-authored by Claude Opus 5.5
parent fea5ec0c65
commit f294c1cdf4
3 changed files with 201 additions and 39 deletions
+94 -26
View File
@@ -12,6 +12,18 @@ const FILTERS = {
vivid: ['Colores vivos', 'saturate(1.45) contrast(1.05)'], vivid: ['Colores vivos', 'saturate(1.45) contrast(1.05)'],
} }
const DL_ACTIVE = ['queued', 'downloading'] const DL_ACTIVE = ['queued', 'downloading']
const fmtSize = b => (b >= 1e9 ? `${(b / 1e9).toFixed(1)} GB` : b >= 1e6 ? `${Math.round(b / 1e6)} MB` : `${Math.round((b || 0) / 1e3)} KB`)
// Progreso global contando la fracción del capítulo en curso.
function downloadPct(d) {
if (!d?.total) return 0
const frac = d.current?.pagesTotal ? d.current.pages / d.current.pagesTotal : 0
return Math.min(100, ((d.done + frac) / d.total) * 100)
}
function downloadDetail(d) {
const c = d?.current
if (!c) return d?.status === 'queued' ? 'Esperando turno…' : ''
return c.pagesTotal ? `Cap. ${esc(c.chapter ?? '?')} · pág. ${c.pages}/${c.pagesTotal}` : `Cap. ${esc(c.chapter ?? '?')} · preparando…`
}
function downloadLabel(d) { function downloadLabel(d) {
if (!d) return '' if (!d) return ''
const n = `${d.done}/${d.total || '?'}` const n = `${d.done}/${d.total || '?'}`
@@ -135,39 +147,73 @@ function continueCard(h) {
<div class="muted" style="font-size:.8rem">${timeAgo(h.updated_at)}</div> <div class="muted" style="font-size:.8rem">${timeAgo(h.updated_at)}</div>
<div class="bar"><i style="width:${pct}%"></i></div></div></a>` <div class="bar"><i style="width:${pct}%"></i></div></div></a>`
} }
const tagHref = (source, t) => `#/?source=${source}&tag=${encodeURIComponent(t.id)}&tagName=${encodeURIComponent(t.name)}`
async function viewHome(params) { async function viewHome(params) {
const q = params.get('q') || '' const q = params.get('q') || ''
const mode = params.get('mode') || 'popular' const tag = params.get('tag') || ''
const tagName = params.get('tagName') || tag
const mode = tag ? 'cats' : params.get('mode') || 'popular'
const anyLang = params.get('any') === '1' const anyLang = params.get('any') === '1'
const source = SOURCES[params.get('source')] ? params.get('source') : store.get('source', 'lc') const source = SOURCES[params.get('source')] ? params.get('source') : store.get('source', 'lc')
store.set('source', source) store.set('source', source)
const showCats = mode === 'cats' && !tag
app.innerHTML = ` app.innerHTML = `
<section id="cont"></section> <section id="cont"></section>
<div class="tabs" id="sources">${Object.entries(SOURCES).map(([k, v]) => `<button data-s="${k}" class="${k === source ? 'on' : ''}">${v}</button>`).join('')}</div> <div class="tabs" id="sources">${Object.entries(SOURCES).map(([k, v]) => `<button data-s="${k}" class="${k === source ? 'on' : ''}">${v}</button>`).join('')}</div>
<form class="search"><input type="search" name="q" placeholder="Buscar en ${esc(SOURCES[source])}…" value="${esc(q)}"><button class="btn primary">Buscar</button></form> <form class="search"><input type="search" name="q" placeholder="Buscar ${tag ? `en «${esc(tagName)}»` : `en ${esc(SOURCES[source])}`}…" value="${esc(q)}"><button class="btn primary">Buscar</button></form>
<label class="muted row" style="font-size:.88rem" ${source === 'md' ? '' : 'hidden'}><input type="checkbox" name="any" ${anyLang ? 'checked' : ''}> Incluir mangas sin traducción a ${esc(LANGS[me.lang] || me.lang)}</label> <label class="muted row" style="font-size:.88rem" ${source === 'md' ? '' : 'hidden'}><input type="checkbox" name="any" ${anyLang ? 'checked' : ''}> Incluir mangas sin traducción a ${esc(LANGS[me.lang] || me.lang)}</label>
${q ? `<h2>Resultados para “${esc(q)}”</h2>` : `<div class="tabs" id="modes"><button data-m="popular" class="${mode === 'popular' ? 'on' : ''}">Populares</button><button data-m="latest" class="${mode === 'latest' ? 'on' : ''}">Actualizados recientemente</button></div>`} ${q && !tag ? `<h2>Resultados para “${esc(q)}”</h2>` : `<div class="tabs" id="modes">
<div id="results" class="loading">Cargando…</div>` <button data-m="popular" class="${mode === 'popular' ? 'on' : ''}">Populares</button>
<button data-m="latest" class="${mode === 'latest' ? 'on' : ''}">Actualizados recientemente</button>
<button data-m="cats" class="${mode === 'cats' ? 'on' : ''}">Categorías</button></div>`}
${tag ? `<h2>Categoría: <a class="tag on" href="#/?source=${source}&mode=cats" title="Quitar filtro">${esc(tagName)} ✕</a>${q ? ` · “${esc(q)}”` : ''}</h2>` : ''}
<div id="results" class="loading">Cargando…</div>
<div class="row" style="justify-content:center;margin:16px 0"><button class="btn" id="more" hidden>Cargar más</button></div>`
const form = $('form', app) const form = $('form', app)
const go = (extra = {}) => { const go = (extra = {}) => {
const p = new URLSearchParams({ source, ...(form.q.value ? { q: form.q.value } : { mode }), ...($('[name=any]').checked ? { any: '1' } : {}), ...extra }) const p = new URLSearchParams({ source, ...(form.q.value ? { q: form.q.value } : { mode }), ...(tag ? { tag, tagName } : {}), ...($('[name=any]').checked ? { any: '1' } : {}), ...extra })
location.hash = `#/?${p}` location.hash = `#/?${p}`
} }
form.addEventListener('submit', e => { e.preventDefault(); go() }) form.addEventListener('submit', e => { e.preventDefault(); go() })
$('[name=any]').addEventListener('change', () => go()) $('[name=any]').addEventListener('change', () => go())
app.querySelectorAll('#modes button').forEach(b => b.addEventListener('click', () => { location.hash = `#/?source=${source}&mode=${b.dataset.m}${anyLang ? '&any=1' : ''}` })) app.querySelectorAll('#modes button').forEach(b => b.addEventListener('click', () => { location.hash = `#/?source=${source}&mode=${b.dataset.m}${anyLang ? '&any=1' : ''}` }))
app.querySelectorAll('#sources button').forEach(b => b.addEventListener('click', () => go({ source: b.dataset.s }))) app.querySelectorAll('#sources button').forEach(b => b.addEventListener('click', () => { location.hash = `#/?source=${b.dataset.s}&mode=${mode}${anyLang ? '&any=1' : ''}` }))
api('/api/history').then(hist => { api('/api/history').then(hist => {
if (!hist.length || q) return if (!hist.length || q || tag || showCats) return
$('#cont').innerHTML = `<h2>Continuar leyendo</h2><div class="continue">${hist.slice(0, 12).map(continueCard).join('')}</div>` $('#cont').innerHTML = `<h2>Continuar leyendo</h2><div class="continue">${hist.slice(0, 12).map(continueCard).join('')}</div>`
}).catch(() => {}) }).catch(() => {})
const qs = new URLSearchParams({ q, mode, source, ...(anyLang ? { anyLang: '1' } : {}) })
const list = await api(`/api/search?${qs}`)
const res = $('#results') const res = $('#results')
res.className = list.length ? 'grid' : 'loading' if (showCats) {
res.innerHTML = list.length ? list.map(mangaCard).join('') : 'Sin resultados.' const cats = await api(`/api/categories?source=${source}`)
const groups = cats.reduce((m, c) => m.set(c.group, [...(m.get(c.group) || []), c]), new Map())
res.className = 'cats'
res.innerHTML = [...groups].map(([g, list]) => `<h3>${esc(g)}</h3><div class="tags">${list.map(t => `<a class="tag big" href="${tagHref(source, t)}">${esc(t.name)}</a>`).join('')}</div>`).join('')
return
}
// Paginación: MangaDex siempre; LeerCapitulo solo en categorías (sus otros listados no paginan).
const pageable = source === 'md' || !!tag
const seen = new Set()
let page = 1
const load = async () => {
const qs = new URLSearchParams({ q, mode, source, page, ...(tag ? { tag } : {}), ...(anyLang ? { anyLang: '1' } : {}) })
const list = (await api(`/api/search?${qs}`)).filter(m => !seen.has(m.id) && seen.add(m.id))
if (page === 1) {
res.className = list.length ? 'grid' : 'loading'
res.innerHTML = list.length ? '' : 'Sin resultados.'
}
res.insertAdjacentHTML('beforeend', list.map(mangaCard).join(''))
$('#more').hidden = !pageable || list.length < 20
}
$('#more').addEventListener('click', async e => {
e.target.disabled = true
page++
try { await load() } catch (err) { toast(err.message) }
e.target.disabled = false
})
await load()
} }
// ---------- Ficha del manga ---------- // ---------- Ficha del manga ----------
@@ -196,16 +242,16 @@ async function viewManga(id, langOverride) {
<div> <div>
<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">${esc(manga.authors.join(', '))}${manga.year ? ` · ${manga.year}` : ''}${manga.status ? ` · ${esc(manga.status)}` : ''}</div>
<div class="tags">${manga.tags.map(t => `<span class="tag">${esc(t)}</span>`).join('')}</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> <p class="desc">${esc(manga.description)}</p>
<div class="row">${mainBtn} <div class="row">${mainBtn}
<span id="dl" class="row"></span>
${progress || reads.length ? '<button class="btn danger" id="drop">Dejar de leer</button>' : ''} ${progress || reads.length ? '<button class="btn danger" id="drop">Dejar de leer</button>' : ''}
${isLc ? '' : `<select id="lang">${langOpts.map(l => `<option value="${esc(l)}" ${l === lang ? 'selected' : ''}>${esc(LANGS[l] || l)}</option>`).join('')}</select> ${isLc ? '' : `<select id="lang">${langOpts.map(l => `<option value="${esc(l)}" ${l === lang ? 'selected' : ''}>${esc(LANGS[l] || l)}</option>`).join('')}</select>
<a class="btn" href="${altSearch}">Buscar en LeerCapitulo</a>`} <a class="btn" href="${altSearch}">Buscar en LeerCapitulo</a>`}
</div> </div>
<div class="muted" style="font-size:.85rem;margin-top:6px">Fuente: ${esc(SOURCES[manga.source])}${download ? ` · Carpeta: mangas/${esc(download.folder)}` : ''}</div> <div class="muted" style="font-size:.85rem;margin-top:6px">Fuente: ${esc(SOURCES[manga.source])}${download ? ` · Carpeta: mangas/${esc(download.folder)}` : ''}</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 id="dl" class="dlbox"></div>
<p class="error" id="dlerr" style="font-size:.85rem"></p> <p class="error" id="dlerr" style="font-size:.85rem"></p>
</div> </div>
</div> </div>
@@ -220,10 +266,12 @@ 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)
const delBtn = d ? `<button class="btn small danger" id="dldel">Eliminar copia local${d.size ? ` (${fmtSize(d.size)})` : ''}</button>` : ''
const bar = d && (active || d.done < d.total) ? `<div class="bar dlbar"><i style="width:${downloadPct(d).toFixed(1)}%"></i></div>` : ''
let html let html
if (!d || d.status === 'cancelled') html = `<button class="btn" id="dlgo">⬇ Descargar${d ? ` (llevas ${d.done}/${d.total})` : ''}</button>` if (!d || d.status === 'cancelled') html = `<div class="row"><button class="btn" id="dlgo">⬇ Descargar${d ? ` (llevas ${d.done}/${d.total})` : ''}</button>${delBtn}</div>${bar}`
else if (active) html = `<span class="muted">${downloadLabel(d)}</span><button class="btn small" id="dlcancel">Cancelar</button>` else if (active) html = `<div class="row"><span>${downloadLabel(d)} · ${Math.floor(downloadPct(d))}%</span><button class="btn small" id="dlcancel">Cancelar</button>${delBtn}</div>${bar}<div class="muted" style="font-size:.8rem">${downloadDetail(d)}</div>`
else html = `<span class="muted">✓ ${downloadLabel(d)}</span><button class="btn small" id="dlgo" title="Descarga los capítulos que falten">${d.status === 'error' ? 'Reintentar' : 'Buscar nuevos'}</button>` else html = `<div class="row"><span class="muted">✓ ${downloadLabel(d)}</span><button class="btn small" id="dlgo" title="Descarga los capítulos que falten">${d.status === 'error' ? 'Reintentar' : 'Buscar nuevos'}</button>${delBtn}</div>${bar}`
box.innerHTML = offline ? (d ? `<span class="muted">${downloadLabel(d)}</span>` : '') : html box.innerHTML = offline ? (d ? `<span class="muted">${downloadLabel(d)}</span>` : '') : html
$('#dlerr').textContent = d?.status === 'error' ? d.error || '' : '' $('#dlerr').textContent = d?.status === 'error' ? d.error || '' : ''
$('#dlgo')?.addEventListener('click', async () => { $('#dlgo')?.addEventListener('click', async () => {
@@ -231,13 +279,19 @@ async function viewManga(id, langOverride) {
toast(`Descargando en mangas/${manga.title}`) toast(`Descargando en mangas/${manga.title}`)
}) })
$('#dlcancel')?.addEventListener('click', async () => renderDl(await api(`/api/download/${id}/cancel`, { method: 'POST' }))) $('#dlcancel')?.addEventListener('click', async () => renderDl(await api(`/api/download/${id}/cancel`, { method: 'POST' })))
$('#dldel')?.addEventListener('click', async () => {
if (!confirm(`¿Eliminar la copia local de «${manga.title}»?\nSe borrará la carpeta mangas/${d.folder}${d.size ? ` (${fmtSize(d.size)})` : ''}.\nTu historial de lectura no se toca.`)) return
await api(`/api/download/${id}`, { method: 'DELETE' })
toast('Copia local eliminada')
viewManga(id, langOverride)
})
clearTimeout(dlTimer) clearTimeout(dlTimer)
if (active) dlTimer = setTimeout(async () => { if (active) dlTimer = setTimeout(async () => {
const next = await api(`/api/download/${id}`).catch(() => d) const next = await api(`/api/download/${id}`).catch(() => d)
if (!$('#dl')) return if (!$('#dl')) return
if (next && next.done !== d.done) { const fresh = await api(`/api/manga/${id}?lang=${encodeURIComponent(lang)}`).catch(() => null); fresh?.downloaded.forEach(x => dlSet.add(x)); renderList() } if (next && next.done !== d.done) { const fresh = await api(`/api/manga/${id}?lang=${encodeURIComponent(lang)}`).catch(() => null); fresh?.downloaded.forEach(x => dlSet.add(x)); renderList(); if (fresh?.download) next.size = fresh.download.size }
renderDl(next) renderDl(next)
}, 3000) }, 2000)
} }
renderDl(download) renderDl(download)
cleanup = () => clearTimeout(dlTimer) cleanup = () => clearTimeout(dlTimer)
@@ -468,21 +522,35 @@ async function viewHistory() {
// ---------- Descargas ---------- // ---------- Descargas ----------
async function viewDownloads() { async function viewDownloads() {
app.innerHTML = '<div class="loading">Cargando…</div>' if (cleanup) { cleanup(); cleanup = null }
const list = await api('/api/downloads') const list = await api('/api/downloads')
if (location.hash !== '#/downloads') return
const total = list.reduce((n, d) => n + (d.size || 0), 0)
app.innerHTML = `<h1>Descargas</h1> app.innerHTML = `<h1>Descargas</h1>
<p class="muted">Se guardan en <code>/zdata/media/mangas</code> como CBZ (compatibles con Kavita, Komga, Mihon…). Los capítulos descargados se leen desde el disco.</p> <p class="muted">Se guardan en <code>/zdata/media/mangas</code> como CBZ (compatibles con Kavita, Komga, Mihon…). Los capítulos descargados se leen desde el disco.${list.length ? ` Total: <b>${fmtSize(total)}</b>.` : ''}</p>
${list.length ? '' : '<p class="muted">Aún no has descargado nada. Usa “⬇ Descargar” en la ficha de un manga.</p>'} ${list.length ? '' : '<p class="muted">Aún no has descargado nada. Usa “⬇ Descargar” en la ficha de un manga.</p>'}
<div class="history">${list.map(d => { <div class="history">${list.map(d => {
const pct = d.total ? Math.round((d.done / d.total) * 100) : 0 const active = DL_ACTIVE.includes(d.status)
return `<a class="cont" href="#/manga/${d.manga_id}"> return `<div class="cont">
<div class="info"><b>${esc(d.title)}</b> <a class="info" href="#/manga/${d.manga_id}"><b>${esc(d.title)}</b>
<div class="muted">${esc(downloadLabel(d))} · mangas/${esc(d.folder)}</div> <div class="muted">${esc(downloadLabel(d))}${active ? ` · ${Math.floor(downloadPct(d))}%` : ''} · ${fmtSize(d.size)} · mangas/${esc(d.folder)}</div>
${active ? `<div class="muted" style="font-size:.8rem">${downloadDetail(d)}</div>` : ''}
${d.status === 'error' ? `<div class="error" style="font-size:.8rem">${esc(d.error || '')}</div>` : ''} ${d.status === 'error' ? `<div class="error" style="font-size:.8rem">${esc(d.error || '')}</div>` : ''}
<div class="bar"><i style="width:${pct}%"></i></div></div></a>` <div class="bar dlbar"><i style="width:${downloadPct(d).toFixed(1)}%"></i></div></a>
<div class="row" style="flex:none">
${active ? `<button class="btn small" data-cancel="${esc(d.manga_id)}">Cancelar</button>` : ''}
<button class="btn small danger" data-del="${esc(d.manga_id)}" data-title="${esc(d.title)}" data-size="${d.size || 0}">Eliminar</button>
</div></div>`
}).join('')}</div>` }).join('')}</div>`
app.querySelectorAll('[data-cancel]').forEach(b => b.addEventListener('click', async () => { await api(`/api/download/${b.dataset.cancel}/cancel`, { method: 'POST' }); viewDownloads() }))
app.querySelectorAll('[data-del]').forEach(b => b.addEventListener('click', async () => {
if (!confirm(`¿Eliminar la copia local de «${b.dataset.title}» (${fmtSize(Number(b.dataset.size))})?\nTu historial de lectura no se toca.`)) return
await api(`/api/download/${b.dataset.del}`, { method: 'DELETE' })
toast('Copia local eliminada')
viewDownloads()
}))
if (list.some(d => DL_ACTIVE.includes(d.status))) { if (list.some(d => DL_ACTIVE.includes(d.status))) {
const t = setTimeout(() => location.hash === '#/downloads' && viewDownloads(), 4000) const t = setTimeout(() => location.hash === '#/downloads' && viewDownloads(), 2000)
cleanup = () => clearTimeout(t) cleanup = () => clearTimeout(t)
} }
} }
+10
View File
@@ -119,3 +119,13 @@ input:focus, select:focus { border-color: var(--accent); }
#topbar .brand span { display: none; } #topbar .brand span { display: none; }
#topbar nav a { padding: 6px 8px; } #topbar nav a { padding: 6px 8px; }
} }
/* Categorías y descargas */
.cats h3 { font-size: .95rem; color: var(--muted); margin: 18px 0 8px; }
a.tag { cursor: pointer; }
a.tag:hover, .tag.on { background: var(--accent); color: var(--accent-ink); }
.tag.big { font-size: .88rem; padding: 5px 12px; }
.dlbox { display: grid; gap: 6px; margin-top: 10px; max-width: 520px; }
.dlbar { height: 8px; border-radius: 4px; overflow: hidden; }
.dlbar > i { transition: width .6s ease; }
.history .cont a.info { display: block; min-width: 0; flex: 1; }
+97 -13
View File
@@ -167,7 +167,7 @@ function mangaSummary(m, lang) {
description: pick(a.description, langs), description: pick(a.description, langs),
status: a.status, status: a.status,
year: a.year, year: a.year,
tags: (a.tags || []).map(t => pick(t.attributes.name, ['es', 'en'])).slice(0, 8), tags: (a.tags || []).map(t => ({ id: t.id, name: pick(t.attributes.name, ['es', 'en']) })).slice(0, 12),
authors: (m.relationships || []).filter(r => r.type === 'author' && r.attributes).map(r => r.attributes.name), authors: (m.relationships || []).filter(r => r.type === 'author' && r.attributes).map(r => r.attributes.name),
languages: a.availableTranslatedLanguages || [], languages: a.availableTranslatedLanguages || [],
cover, cover,
@@ -175,9 +175,12 @@ function mangaSummary(m, lang) {
} }
const RATINGS = ['safe', 'suggestive'] const RATINGS = ['safe', 'suggestive']
async function searchManga(q, lang, mode) { const PAGE_SIZE = 30
async function searchManga(q, lang, mode, tag, page = 1) {
const params = { const params = {
limit: 30, limit: PAGE_SIZE,
offset: (page - 1) * PAGE_SIZE,
'includedTags[]': tag ? [tag] : undefined,
'includes[]': ['cover_art'], 'includes[]': ['cover_art'],
'contentRating[]': RATINGS, 'contentRating[]': RATINGS,
'availableTranslatedLanguage[]': lang ? [lang] : undefined, 'availableTranslatedLanguage[]': lang ? [lang] : undefined,
@@ -189,6 +192,14 @@ async function searchManga(q, lang, mode) {
return data.data.map(m => mangaSummary(m, lang)) return data.data.map(m => mangaSummary(m, lang))
} }
const MD_GROUPS = { genre: 'Géneros', theme: 'Temáticas', format: 'Formato', content: 'Contenido' }
async function mdCategories() {
const data = await md('/manga/tag', {}, 24 * 3600e3)
return data.data
.map(t => ({ id: t.id, name: pick(t.attributes.name, ['es', 'en']), group: MD_GROUPS[t.attributes.group] || t.attributes.group }))
.sort((a, b) => a.group.localeCompare(b.group) || a.name.localeCompare(b.name))
}
async function getManga(id, lang) { async function getManga(id, lang) {
const data = await md(`/manga/${id}`, { 'includes[]': ['cover_art', 'author'] }, 30 * 60e3) const data = await md(`/manga/${id}`, { 'includes[]': ['cover_art', 'author'] }, 30 * 60e3)
return mangaSummary(data.data, lang) return mangaSummary(data.data, lang)
@@ -286,7 +297,25 @@ function lcParseCards(html) {
} }
return out return out
} }
async function lcSearch(q, mode) { const LC_TAG = /^(genre|theme):([a-z0-9-]+)$/
async function lcCategories() {
const html = await lc('/manga/', 24 * 3600e3)
const out = []
const seen = new Set()
for (const m of html.matchAll(/(?:href|value)="\/manga\/\?(genre|theme)=([a-z0-9-]+)"[^>]*>([^<]+)</g)) {
const id = `${m[1]}:${m[2]}`
if (seen.has(id)) continue
seen.add(id)
out.push({ id, name: decode(m[3]), group: m[1] === 'genre' ? 'Géneros' : 'Temáticas' })
}
return out.sort((a, b) => a.group.localeCompare(b.group) || a.name.localeCompare(b.name))
}
async function lcSearch(q, mode, tag, page = 1) {
const t = LC_TAG.exec(tag || '')
if (t) {
const html = await lc(`/manga/?${t[1]}=${t[2]}${q ? `&q=${encodeURIComponent(q)}` : ''}${page > 1 ? `&page=${page}` : ''}`)
return lcParseCards(html.slice(html.indexOf('</form>')))
}
if (q) { if (q) {
const list = await lc(`/search/?q=${encodeURIComponent(q)}`, 10 * 60e3, true) const list = await lc(`/search/?q=${encodeURIComponent(q)}`, 10 * 60e3, true)
return list.map(e => ({ id: lcId(e.uri), source: 'lc', title: decode(e.name), cover: lcCover(e.cover_uri), sub: e.last_chapter_name })) return list.map(e => ({ id: lcId(e.uri), source: 'lc', title: decode(e.name), cover: lcCover(e.cover_uri), sub: e.last_chapter_name }))
@@ -325,7 +354,7 @@ async function lcManga(id) {
description: decode(sinopsis?.replace(/<br\s*\/?>/g, '\n')), description: decode(sinopsis?.replace(/<br\s*\/?>/g, '\n')),
status: fact('Estado'), status: fact('Estado'),
year: null, year: null,
tags: [...html.matchAll(/class="badge [^"]*" href="\/manga\/\?(?:genre|theme)=[^"]+">([^<]+)</g)].map(m => decode(m[1])).slice(0, 10), tags: [...html.matchAll(/class="badge [^"]*" href="\/manga\/\?(genre|theme)=([a-z0-9-]+)">([^<]+)</g)].map(m => ({ id: `${m[1]}:${m[2]}`, name: decode(m[3]) })).slice(0, 12),
authors: fact('Autor') ? [fact('Autor')] : [], authors: fact('Autor') ? [fact('Autor')] : [],
languages: ['es'], languages: ['es'],
cover: lcCover(/class="lc-cover-lg">\s*<img src="([^"]+)"/.exec(html)?.[1]), cover: lcCover(/class="lc-cover-lg">\s*<img src="([^"]+)"/.exec(html)?.[1]),
@@ -394,8 +423,13 @@ async function fetchMangaFull(mangaId, lang) {
: { manga: await getManga(mangaId, lang), chapters: await getChapters(mangaId, lang) } : { manga: await getManga(mangaId, lang), chapters: await getChapters(mangaId, lang) }
} }
// Progreso del capítulo en curso (solo en memoria): manga_id -> { chapter, pages, pagesTotal }
const dlCurrent = new Map()
async function downloadChapter(dl, manga, c, dir) { async function downloadChapter(dl, manga, c, dir) {
dlCurrent.set(dl.manga_id, { chapter: c.num, pages: 0, pagesTotal: 0 })
const urls = await withRetry(() => (manga.source === 'lc' ? lcPageUrls(c.id) : getPageUrls(c.id, false, 0))) const urls = await withRetry(() => (manga.source === 'lc' ? lcPageUrls(c.id) : getPageUrls(c.id, false, 0)))
dlCurrent.set(dl.manga_id, { chapter: c.num, pages: 0, pagesTotal: urls.length })
const entries = [] const entries = []
// 4 imágenes en paralelo. // 4 imágenes en paralelo.
for (let i = 0; i < urls.length; i += 4) { for (let i = 0; i < urls.length; i += 4) {
@@ -405,6 +439,8 @@ async function downloadChapter(dl, manga, c, dir) {
const ext = EXT_BY_TYPE[type.split(';')[0]] || urls[n].split('?')[0].split('.').pop().toLowerCase().replace(/[^a-z]/g, '') || 'jpg' const ext = EXT_BY_TYPE[type.split(';')[0]] || urls[n].split('?')[0].split('.').pop().toLowerCase().replace(/[^a-z]/g, '') || 'jpg'
entries.push({ name: `${String(n + 1).padStart(3, '0')}.${ext}`, data }) entries.push({ name: `${String(n + 1).padStart(3, '0')}.${ext}`, data })
}) })
dlCurrent.set(dl.manga_id, { chapter: c.num, pages: entries.length, pagesTotal: urls.length })
if (isCancelled(dl.manga_id)) throw new Error('cancelada')
} }
entries.push({ name: 'ComicInfo.xml', data: Buffer.from(`<?xml version="1.0" encoding="utf-8"?> entries.push({ name: 'ComicInfo.xml', data: Buffer.from(`<?xml version="1.0" encoding="utf-8"?>
<ComicInfo xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema"> <ComicInfo xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
@@ -414,7 +450,7 @@ async function downloadChapter(dl, manga, c, dir) {
${c.title ? `<Title>${xmlEsc(c.title)}</Title>` : ''} ${c.title ? `<Title>${xmlEsc(c.title)}</Title>` : ''}
<Summary>${xmlEsc(manga.description)}</Summary> <Summary>${xmlEsc(manga.description)}</Summary>
<Writer>${xmlEsc(manga.authors.join(', '))}</Writer> <Writer>${xmlEsc(manga.authors.join(', '))}</Writer>
<Genre>${xmlEsc(manga.tags.join(', '))}</Genre> <Genre>${xmlEsc(manga.tags.map(t => t.name ?? t).join(', '))}</Genre>
<LanguageISO>${xmlEsc((dl.lang || 'es').split('-')[0])}</LanguageISO> <LanguageISO>${xmlEsc((dl.lang || 'es').split('-')[0])}</LanguageISO>
<PageCount>${urls.length}</PageCount> <PageCount>${urls.length}</PageCount>
<Manga>YesAndRightToLeft</Manga> <Manga>YesAndRightToLeft</Manga>
@@ -422,6 +458,11 @@ async function downloadChapter(dl, manga, c, dir) {
`) }) `) })
const file = chapterFileName(c) const file = chapterFileName(c)
writeCbz(path.join(dir, file), entries) writeCbz(path.join(dir, file), entries)
if (!db.prepare('SELECT 1 FROM downloads WHERE manga_id = ?').get(dl.manga_id)) {
// La copia local se eliminó mientras se descargaba este capítulo.
fs.rmSync(path.join(dir, file), { force: true })
throw new Error('eliminada')
}
db.prepare(`INSERT OR REPLACE INTO downloaded_chapters (chapter_id, manga_id, num, title, file, pages, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`) db.prepare(`INSERT OR REPLACE INTO downloaded_chapters (chapter_id, manga_id, num, title, file, pages, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`)
.run(c.id, dl.manga_id, c.num ?? null, c.title || '', file, urls.length, Date.now()) .run(c.id, dl.manga_id, c.num ?? null, c.title || '', file, urls.length, Date.now())
} }
@@ -431,7 +472,28 @@ const dlUpdate = (mangaId, fields) => {
db.prepare(`UPDATE downloads SET ${keys.map(k => `${k} = ?`).join(', ')}, updated_at = ? WHERE manga_id = ?`) db.prepare(`UPDATE downloads SET ${keys.map(k => `${k} = ?`).join(', ')}, updated_at = ? WHERE manga_id = ?`)
.run(...keys.map(k => fields[k]), Date.now(), mangaId) .run(...keys.map(k => fields[k]), Date.now(), mangaId)
} }
const isCancelled = mangaId => db.prepare('SELECT status FROM downloads WHERE manga_id = ?').get(mangaId)?.status === 'cancelled' // Cancelada o eliminada (sin fila) cuentan igual: hay que parar.
const isCancelled = mangaId => (db.prepare('SELECT status FROM downloads WHERE manga_id = ?').get(mangaId)?.status ?? 'cancelled') === 'cancelled'
function mangaDirOf(folder) {
const dir = path.resolve(MANGA_DIR, folder)
if (!folder || !dir.startsWith(path.resolve(MANGA_DIR) + path.sep)) throw new HttpError(400, 'Carpeta inválida')
return dir
}
function dirSize(dir) {
let total = 0
try { for (const f of fs.readdirSync(dir)) total += fs.statSync(path.join(dir, f)).size } catch {}
return total
}
function deleteDownload(mangaId) {
const dl = db.prepare('SELECT folder FROM downloads WHERE manga_id = ?').get(mangaId)
if (!dl) return false
const dir = mangaDirOf(dl.folder)
db.prepare('DELETE FROM downloads WHERE manga_id = ?').run(mangaId) // borra también downloaded_chapters (CASCADE)
dlCurrent.delete(mangaId)
fs.rmSync(dir, { recursive: true, force: true })
return true
}
async function runDownload(dl) { async function runDownload(dl) {
const { manga, chapters } = await fetchMangaFull(dl.manga_id, dl.lang) const { manga, chapters } = await fetchMangaFull(dl.manga_id, dl.lang)
@@ -448,10 +510,11 @@ async function runDownload(dl) {
let failed = 0 let failed = 0
let lastError = null let lastError = null
for (const c of pending) { for (const c of pending) {
if (isCancelled(dl.manga_id)) return if (isCancelled(dl.manga_id)) break
try { try {
await downloadChapter(dl, manga, c, dir) await downloadChapter(dl, manga, c, dir)
} catch (e) { } catch (e) {
if (isCancelled(dl.manga_id)) break
failed++ failed++
lastError = `Cap. ${c.num ?? '?'}: ${e.message}` lastError = `Cap. ${c.num ?? '?'}: ${e.message}`
console.warn('Descarga', manga.title, lastError) console.warn('Descarga', manga.title, lastError)
@@ -461,6 +524,11 @@ async function runDownload(dl) {
// MangaDex limita /at-home/server a 40 peticiones por minuto. // MangaDex limita /at-home/server a 40 peticiones por minuto.
await sleep(manga.source === 'lc' ? 500 : 1600) await sleep(manga.source === 'lc' ? 500 : 1600)
} }
dlCurrent.delete(dl.manga_id)
if (isCancelled(dl.manga_id)) {
if (!db.prepare('SELECT 1 FROM downloads WHERE manga_id = ?').get(dl.manga_id)) fs.rmSync(dir, { recursive: true, force: true })
return
}
dlUpdate(dl.manga_id, { status: failed ? 'error' : 'done', error: failed ? `${failed} capítulo(s) fallaron. Último error: ${lastError}` : null }) dlUpdate(dl.manga_id, { status: failed ? 'error' : 'done', error: failed ? `${failed} capítulo(s) fallaron. Último error: ${lastError}` : null })
} }
@@ -512,7 +580,12 @@ function localChapter(chapterId) {
} }
return cbzIndexCache.get(key) return cbzIndexCache.get(key)
} }
const downloadInfo = mangaId => db.prepare('SELECT manga_id, title, folder, lang, status, done, total, failed, error, updated_at FROM downloads WHERE manga_id = ?').get(mangaId) ?? null const withCurrent = d => (d ? { ...d, current: ['queued', 'downloading'].includes(d.status) ? dlCurrent.get(d.manga_id) ?? null : null } : null)
const downloadInfo = (mangaId, size = false) => {
const d = withCurrent(db.prepare('SELECT manga_id, title, folder, lang, status, done, total, failed, error, updated_at FROM downloads WHERE manga_id = ?').get(mangaId))
if (d && size) d.size = dirSize(mangaDirOf(d.folder))
return d
}
// ---------- HTTP ---------- // ---------- HTTP ----------
class HttpError extends Error { class HttpError extends Error {
@@ -621,10 +694,16 @@ async function route(req, res) {
const lang = LANG.test(q.get('lang') || '') ? q.get('lang') : user.lang const lang = LANG.test(q.get('lang') || '') ? q.get('lang') : user.lang
if (p === '/api/categories' && method === 'GET') {
return json(res, 200, q.get('source') === 'lc' ? await lcCategories() : await mdCategories())
}
if (p === '/api/search' && method === 'GET') { if (p === '/api/search' && method === 'GET') {
if (q.get('source') === 'lc') return json(res, 200, await lcSearch((q.get('q') || '').slice(0, 100), q.get('mode'))) const page = Math.min(Math.max(Number(q.get('page')) | 0, 1), 500)
const tag = q.get('tag') || ''
if (q.get('source') === 'lc') return json(res, 200, await lcSearch((q.get('q') || '').slice(0, 100), q.get('mode'), tag, page))
const anyLang = q.get('anyLang') === '1' const anyLang = q.get('anyLang') === '1'
return json(res, 200, await searchManga((q.get('q') || '').slice(0, 100), anyLang ? null : lang, q.get('mode'))) const mdTag = /^[0-9a-f-]{36}$/.test(tag) ? tag : null
return json(res, 200, await searchManga((q.get('q') || '').slice(0, 100), anyLang ? null : lang, q.get('mode'), mdTag, page))
} }
let m let m
@@ -646,7 +725,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 === 'lc' || offline ? [] : await getChapterLangs(m[1]).catch(() => []) const chapterLangs = chapters.length || manga.source === 'lc' || offline ? [] : await getChapterLangs(m[1]).catch(() => [])
return json(res, 200, { manga, chapters, lang, reads, progress, chapterLangs, offline, downloaded, download: downloadInfo(m[1]) }) return json(res, 200, { manga, chapters, lang, reads, progress, chapterLangs, offline, downloaded, download: downloadInfo(m[1], true) })
} }
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 local = localChapter(m[1]) const local = localChapter(m[1])
@@ -672,7 +751,8 @@ async function route(req, res) {
// --- Descargas --- // --- Descargas ---
if (p === '/api/downloads' && method === 'GET') { if (p === '/api/downloads' && method === 'GET') {
return json(res, 200, db.prepare('SELECT manga_id, title, folder, lang, status, done, total, failed, error, updated_at FROM downloads ORDER BY updated_at DESC').all()) return json(res, 200, db.prepare('SELECT manga_id, title, folder, lang, status, done, total, failed, error, updated_at FROM downloads ORDER BY updated_at DESC').all()
.map(d => ({ ...withCurrent(d), size: dirSize(mangaDirOf(d.folder)) })))
} }
if ((m = /^\/api\/download\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'GET') { if ((m = /^\/api\/download\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'GET') {
return json(res, 200, downloadInfo(m[1])) return json(res, 200, downloadInfo(m[1]))
@@ -684,6 +764,10 @@ async function route(req, res) {
enqueueDownload(m[1], title, dlLang) enqueueDownload(m[1], title, dlLang)
return json(res, 200, downloadInfo(m[1])) return json(res, 200, downloadInfo(m[1]))
} }
if ((m = /^\/api\/download\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'DELETE') {
if (!deleteDownload(m[1])) throw new HttpError(404, 'No hay copia local')
return json(res, 200, { ok: true })
}
if ((m = /^\/api\/download\/([^/]+)\/cancel$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'POST') { if ((m = /^\/api\/download\/([^/]+)\/cancel$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'POST') {
db.prepare("UPDATE downloads SET status = 'cancelled', updated_at = ? WHERE manga_id = ? AND status IN ('queued', 'downloading')").run(Date.now(), m[1]) db.prepare("UPDATE downloads SET status = 'cancelled', updated_at = ? WHERE manga_id = ? AND status IN ('queued', 'downloading')").run(Date.now(), m[1])
return json(res, 200, downloadInfo(m[1])) return json(res, 200, downloadInfo(m[1]))