Descarga de mangas completos a /zdata/media/mangas en CBZ
- Cola de descargas en segundo plano (se reanuda tras reinicios; repetirla solo baja capítulos nuevos; se puede cancelar). - <Título>/Capitulo NNNN.cbz con ComicInfo.xml + cover.jpg (Kavita/Komga/Mihon). - Los capítulos descargados se leen desde disco; sin conexión la ficha usa los datos guardados. - Página "Descargas" y marcas ⬇ en la lista de capítulos. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+69
-3
@@ -11,6 +11,18 @@ const FILTERS = {
|
||||
contrast: ['Alto contraste', 'contrast(1.35) brightness(1.03)'],
|
||||
vivid: ['Colores vivos', 'saturate(1.45) contrast(1.05)'],
|
||||
}
|
||||
const DL_ACTIVE = ['queued', 'downloading']
|
||||
function downloadLabel(d) {
|
||||
if (!d) return ''
|
||||
const n = `${d.done}/${d.total || '?'}`
|
||||
return {
|
||||
queued: 'En cola…',
|
||||
downloading: `Descargando ${n}`,
|
||||
done: `Descargado (${d.done} caps.)`,
|
||||
error: `Descarga incompleta ${n}`,
|
||||
cancelled: `Descarga cancelada ${n}`,
|
||||
}[d.status] || d.status
|
||||
}
|
||||
const LANGS = { 'es-la': 'Español (Latam)', es: 'Español (España)', en: 'English', 'pt-br': 'Português (BR)', fr: 'Français', it: 'Italiano', ja: '日本語' }
|
||||
let me = null
|
||||
let cleanup = null
|
||||
@@ -70,6 +82,7 @@ async function router() {
|
||||
if (parts[0] === 'manga') return await viewManga(parts[1])
|
||||
if (parts[0] === 'read') return await viewReader(parts[1], parts[2], Number(params.get('page') || 0) - 1)
|
||||
if (parts[0] === 'history') return await viewHistory()
|
||||
if (parts[0] === 'downloads') return await viewDownloads()
|
||||
if (parts[0] === 'settings') return viewSettings()
|
||||
return await viewHome(params)
|
||||
} catch (e) {
|
||||
@@ -159,9 +172,11 @@ async function viewHome(params) {
|
||||
|
||||
// ---------- Ficha del manga ----------
|
||||
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 } = await api(`/api/manga/${id}?lang=${encodeURIComponent(lang)}`)
|
||||
const { manga, chapters, reads, progress, chapterLangs, offline, downloaded, download } = await api(`/api/manga/${id}?lang=${encodeURIComponent(lang)}`)
|
||||
const dlSet = new Set(downloaded)
|
||||
const readMap = new Map(reads.map(r => [r.chapter_id, r]))
|
||||
const isLc = manga.source === 'lc'
|
||||
const langOpts = [...new Set([lang, me.lang, ...chapterLangs, ...manga.languages])].filter(Boolean)
|
||||
@@ -184,11 +199,14 @@ async function viewManga(id, langOverride) {
|
||||
<div class="tags">${manga.tags.map(t => `<span class="tag">${esc(t)}</span>`).join('')}</div>
|
||||
<p class="desc">${esc(manga.description)}</p>
|
||||
<div class="row">${mainBtn}
|
||||
<span id="dl" class="row"></span>
|
||||
${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>
|
||||
<a class="btn" href="${altSearch}">Buscar en LeerCapitulo</a>`}
|
||||
</div>
|
||||
<div class="muted" style="font-size:.85rem;margin-top:6px">Fuente: ${esc(SOURCES[manga.source])}</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>' : ''}
|
||||
<p class="error" id="dlerr" style="font-size:.85rem"></p>
|
||||
</div>
|
||||
</div>
|
||||
<h2>Capítulos <span class="muted">(${chapters.length})</span>
|
||||
@@ -196,6 +214,33 @@ async function viewManga(id, langOverride) {
|
||||
${chapters.length ? '<ul class="chapters"></ul>' : isLc ? '<p class="muted">Este manga no tiene capítulos.</p>' : `<p class="muted">No hay capítulos legibles en ${esc(LANGS[lang] || lang)} (MangaDex retira algunos por licencia).
|
||||
Prueba a <a class="tag" href="${altSearch}">buscarlo en LeerCapitulo</a>.
|
||||
${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>`}`
|
||||
// Botón de descarga con sondeo del progreso mientras está activa.
|
||||
let dlTimer = null
|
||||
const renderDl = d => {
|
||||
const box = $('#dl')
|
||||
if (!box) return
|
||||
const active = d && DL_ACTIVE.includes(d.status)
|
||||
let html
|
||||
if (!d || d.status === 'cancelled') html = `<button class="btn" id="dlgo">⬇ Descargar${d ? ` (llevas ${d.done}/${d.total})` : ''}</button>`
|
||||
else if (active) html = `<span class="muted">${downloadLabel(d)}</span><button class="btn small" id="dlcancel">Cancelar</button>`
|
||||
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>`
|
||||
box.innerHTML = offline ? (d ? `<span class="muted">${downloadLabel(d)}</span>` : '') : html
|
||||
$('#dlerr').textContent = d?.status === 'error' ? d.error || '' : ''
|
||||
$('#dlgo')?.addEventListener('click', async () => {
|
||||
renderDl(await api(`/api/download/${id}`, { method: 'POST', body: { lang, title: manga.title } }))
|
||||
toast(`Descargando en mangas/${manga.title}`)
|
||||
})
|
||||
$('#dlcancel')?.addEventListener('click', async () => renderDl(await api(`/api/download/${id}/cancel`, { method: 'POST' })))
|
||||
clearTimeout(dlTimer)
|
||||
if (active) dlTimer = setTimeout(async () => {
|
||||
const next = await api(`/api/download/${id}`).catch(() => d)
|
||||
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() }
|
||||
renderDl(next)
|
||||
}, 3000)
|
||||
}
|
||||
renderDl(download)
|
||||
cleanup = () => clearTimeout(dlTimer)
|
||||
$('#drop')?.addEventListener('click', async () => {
|
||||
if (!confirm(`¿Dejar de leer «${manga.title}»?\nSe quitará del historial y se borrará el progreso de sus capítulos.`)) return
|
||||
await api(`/api/history/${id}`, { method: 'DELETE' })
|
||||
@@ -213,7 +258,7 @@ async function viewManga(id, langOverride) {
|
||||
const r = readMap.get(c.id)
|
||||
const done = r && r.total_pages && r.page >= r.total_pages - 1
|
||||
const cls = progress?.chapter_id === c.id ? 'current' : done ? 'read' : ''
|
||||
const meta = r ? (done ? 'Leído' : `pág. ${r.page + 1}/${r.total_pages}`) : esc(c.group)
|
||||
const meta = (dlSet.has(c.id) ? '<span title="Descargado">⬇</span> ' : '') + (r ? (done ? 'Leído' : `pág. ${r.page + 1}/${r.total_pages}`) : esc(c.group))
|
||||
return `<li><a class="${cls}" href="${readUrl(id, c.id, r && !done ? r.page : (done ? 0 : null))}"><span>${esc(chapLabel(c))}</span><span class="meta">${meta}</span></a></li>`
|
||||
}).join('')
|
||||
}
|
||||
@@ -421,6 +466,27 @@ async function viewHistory() {
|
||||
}))
|
||||
}
|
||||
|
||||
// ---------- Descargas ----------
|
||||
async function viewDownloads() {
|
||||
app.innerHTML = '<div class="loading">Cargando…</div>'
|
||||
const list = await api('/api/downloads')
|
||||
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>
|
||||
${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 => {
|
||||
const pct = d.total ? Math.round((d.done / d.total) * 100) : 0
|
||||
return `<a class="cont" href="#/manga/${d.manga_id}">
|
||||
<div class="info"><b>${esc(d.title)}</b>
|
||||
<div class="muted">${esc(downloadLabel(d))} · mangas/${esc(d.folder)}</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>`
|
||||
}).join('')}</div>`
|
||||
if (list.some(d => DL_ACTIVE.includes(d.status))) {
|
||||
const t = setTimeout(() => location.hash === '#/downloads' && viewDownloads(), 4000)
|
||||
cleanup = () => clearTimeout(t)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Ajustes ----------
|
||||
function viewSettings() {
|
||||
app.innerHTML = `<h1>Ajustes</h1>
|
||||
|
||||
Reference in New Issue
Block a user