diff --git a/app/features.js b/app/features.js
index d0d0d56..6e6ba07 100644
--- a/app/features.js
+++ b/app/features.js
@@ -177,7 +177,8 @@ export function setupFeatures({ db, fetchMangaFull, enqueueDownload, downloadInf
}
// Autodescarga: si el manga ya tiene copia local, bajar también lo nuevo.
const dl = anyNew && downloadInfo(mangaId)
- if (dl && !['queued', 'downloading'].includes(dl.status)) enqueueDownload(mangaId, dl.title, dl.lang)
+ // Solo si se baja el manga entero; con capítulos sueltos, lo nuevo no se descarga solo.
+ if (dl && dl.scope !== 'some' && !['queued', 'downloading'].includes(dl.status)) enqueueDownload(mangaId, dl.title, dl.lang)
return anyNew
}
diff --git a/app/public/app.js b/app/public/app.js
index fffc2fd..212be55 100644
--- a/app/public/app.js
+++ b/app/public/app.js
@@ -304,7 +304,6 @@ async function viewManga(id, langOverride) {
const lang = langOverride || store.get(`lang:${id}`, me.lang)
const { manga, chapters, reads, progress, chapterLangs, offline, downloaded, download, upscale, following, newKeys = [] } = await api(`/api/manga/${id}?lang=${encodeURIComponent(lang)}`)
const dlSet = new Set(downloaded)
- let hdSet = new Set(upscale?.chapters || []), hdJobs = upscale?.jobs || {}
const newSet = new Set(newKeys.map(String))
let isFollowing = following
const readMap = new Map(reads.map(r => [r.chapter_id, r]))
@@ -354,34 +353,182 @@ async function viewManga(id, langOverride) {
${chapterLangs.length ? `Disponibles en: ${chapterLangs.map(l => `${esc(LANGS[l] || l)} `).join(' ')}` : 'No hay capítulos en ningún idioma.'}
`}
`
+ const secHead = (ic, title, status) => `${icon(ic)} ${title}
${status ? `
${status}
` : ''}
`
+ const plural = (n, w) => `${n} ${w}${n === 1 ? '' : 's'}`
+ const fmtDur = sec => sec < 90 ? '≈ 1 min' : sec < 3600 ? `≈ ${Math.round(sec / 60)} min` : `≈ ${Math.floor(sec / 3600)} h ${Math.round((sec % 3600) / 60)} min`
+
+ // --- Selector de capítulos compartido por "Copia local" (kind 'dl') y "Reescalado HD" (kind 'hd'):
+ // todo lo pendiente (solo HD), un rango o una selección hecha en la lista de abajo.
+ let wantedSet = new Set(download?.wanted || [])
+ let hdSet = new Set(upscale?.chapters || []), hdJobs = upscale?.jobs || {}
+ let hdInfo = upscale
+ let sel = null // null | { kind: 'dl' | 'hd', mode: 'pending' | 'range' | 'pick' }
+ const picked = new Set()
+ let lastPick = null
+ const KINDS = {
+ dl: {
+ verb: 'Descargar', ic: 'download', modes: { range: 'Rango', pick: 'Elegir' },
+ // Sin descargar y sin estar ya en cola.
+ candidates: () => chapters.filter(c => !dlSet.has(c.id) && !wantedSet.has(c.id)),
+ rangeBase: () => chapters,
+ mark: c => (dlSet.has(c.id) ? ' (descargado)' : ''),
+ summary: ids => {
+ const pages = ids.reduce((n, cid) => n + (chapters.find(c => c.id === cid)?.pages || 0), 0)
+ return plural(ids.length, 'capítulo') + (pages ? ` · ${pages} págs.` : '')
+ },
+ offTitle: c => (dlSet.has(c.id) ? 'Ya descargado' : 'Ya está en cola'),
+ run: async ids => {
+ renderDl(await api(`/api/download/${id}`, { method: 'POST', body: { lang, title: manga.title, chapterIds: ids } }))
+ toast(`${plural(ids.length, 'capítulo')} en cola de descarga`)
+ },
+ },
+ hd: {
+ verb: 'Reescalar', ic: 'sparkles', modes: { pending: 'Pendientes', range: 'Rango', pick: 'Elegir' },
+ // Descargados, sin HD y sin estar ya en cola (los fallidos sí, para reintentar).
+ candidates: () => chapters.filter(c => dlSet.has(c.id) && !hdSet.has(c.id) && (!hdJobs[c.id] || hdJobs[c.id] === 'error')),
+ rangeBase: () => chapters.filter(c => dlSet.has(c.id)),
+ mark: c => (hdSet.has(c.id) ? ' (HD)' : ''),
+ summary: ids => {
+ const pages = ids.reduce((n, cid) => n + (hdInfo?.pages?.[cid] || 0), 0)
+ return `${plural(ids.length, 'capítulo')} · ${pages} págs. · ${fmtDur(pages * (hdInfo?.secPerPage || 13))}`
+ },
+ offTitle: c => (!dlSet.has(c.id) ? 'No está descargado' : 'Ya está en HD o en cola'),
+ run: async ids => {
+ renderHd(await api(`/api/upscale/${id}`, { method: 'POST', body: { chapterIds: ids } }))
+ toast(`${plural(ids.length, 'capítulo')} en cola para HD`)
+ },
+ },
+ }
+ const selIds = () => {
+ if (!sel) return []
+ const K = KINDS[sel.kind]
+ const cand = K.candidates()
+ if (sel.mode === 'pending') return cand.map(c => c.id)
+ if (sel.mode === 'pick') return cand.filter(c => picked.has(c.id)).map(c => c.id)
+ const base = K.rangeBase()
+ let a = base.findIndex(c => c.id === $('#selfrom')?.value), b = base.findIndex(c => c.id === $('#selto')?.value)
+ if (a < 0 || b < 0) return []
+ if (a > b) [a, b] = [b, a]
+ const inRange = new Set(base.slice(a, b + 1).map(c => c.id))
+ return cand.filter(c => inRange.has(c.id)).map(c => c.id)
+ }
+ const updateSummary = () => {
+ const ids = selIds()
+ const K = sel && KINDS[sel.kind]
+ if ($('#selsum')) $('#selsum').textContent = ids.length ? K.summary(ids) : sel?.mode === 'pick' ? 'Marca capítulos en la lista de abajo.' : 'No hay capítulos pendientes en esa selección.'
+ if ($('#selgo')) $('#selgo').disabled = !ids.length
+ const bar = $('#selbar')
+ if (!bar) return
+ bar.hidden = sel?.mode !== 'pick'
+ if (sel?.mode !== 'pick') return
+ bar.innerHTML = `${ids.length ? K.summary(ids) : 'Ningún capítulo marcado'}
+ ${icon(K.ic)} ${K.verb}
+ ${icon('x')} `
+ $('#selrun').addEventListener('click', runSel)
+ $('#selexit').addEventListener('click', () => setSel(null))
+ }
+ async function runSel() {
+ const ids = selIds()
+ if (!ids.length) return
+ const K = KINDS[sel.kind]
+ try {
+ if (sel.kind === 'hd' && sel.mode === 'pending' && $('#hdautoform') && $('#hdautoform').checked !== !!hdInfo?.auto) {
+ await api(`/api/upscale/${id}/auto`, { method: 'POST', body: { auto: $('#hdautoform').checked } }).catch(() => {})
+ }
+ picked.clear()
+ setSel(null)
+ await K.run(ids)
+ } catch (err) { toast(err.message) }
+ }
+ function setSel(next) {
+ const wasPick = sel?.mode === 'pick', prevKind = sel?.kind
+ if (next?.kind !== prevKind) picked.clear()
+ sel = next
+ 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()
+ $('#dlopen')?.classList.toggle('on', sel?.kind === 'dl')
+ $('#hdopen')?.classList.toggle('on', sel?.kind === 'hd')
+ updateSummary()
+ }
+ function renderSelForm(kind) {
+ const box = $(`#${kind}form`)
+ if (!box) return
+ const open = sel?.kind === kind
+ box.hidden = !open
+ if (!open) { box.innerHTML = ''; return }
+ const K = KINDS[kind]
+ const base = K.rangeBase(), cand = K.candidates()
+ const opt = (c, on) => `${esc(c.num ? `Cap. ${c.num}` : 'Oneshot')}${K.mark(c)} `
+ const from = cand[0] || base[0], to = cand[Math.min(cand.length, 10) - 1] || base.at(-1)
+ box.innerHTML = `
+ ${Object.entries(K.modes).map(([m, label]) => `${label} `).join('')}
+ ${sel.mode === 'range' ? `Desde ${base.map(c => opt(c, c === from)).join('')}
+ hasta ${base.map(c => opt(c, c === to)).join('')}
` : ''}
+ ${sel.mode === 'pick' ? 'Marca los capítulos en la lista de abajo. Mayús + clic marca un tramo entero.
' : ''}
+ ${sel.mode === 'pending' ? `Todos los capítulos descargados que aún no están en HD.
+ Reescalar también los que se descarguen en el futuro ` : ''}
+
+ ${icon(K.ic)} ${K.verb} Cerrar
`
+ box.querySelectorAll('[data-sm]').forEach(b => b.addEventListener('click', () => setSel({ kind, mode: b.dataset.sm })))
+ $('#selfrom')?.addEventListener('change', updateSummary)
+ $('#selto')?.addEventListener('change', updateSummary)
+ $('#selclose').addEventListener('click', () => setSel(null))
+ $('#selgo').addEventListener('click', runSel)
+ }
+ // Tras un sondeo: repintar lo que dependa de los conjuntos y cerrar el selector si ya no queda nada.
+ const refreshSel = () => {
+ renderList()
+ if (sel && sel.mode !== 'pick') renderSelForm(sel.kind)
+ if (sel && !KINDS[sel.kind].candidates().length) setSel(null)
+ else updateSummary()
+ }
+
// --- Copia local (descarga) con sondeo del progreso mientras está activa.
let dlTimer = null
- const secHead = (ic, title, status) => `${icon(ic)} ${title}
${status ? `
${status}
` : ''}
`
const renderDl = d => {
const box = $('#dl')
if (!box) return
const active = d && DL_ACTIVE.includes(d.status)
+ const some = d?.scope === 'some'
const partial = d && d.done < d.total
const bar = d && (active || partial) ? `
` : ''
+ const canPick = !offline && chapters.some(c => !dlSet.has(c.id) && !wantedSet.has(c.id) && !(d?.wanted || []).includes(c.id))
+ const pickBtn = label => (canPick ? `${icon('download')} ${label} ` : '')
const delBtn = d ? `${icon('trash')} Eliminar copia${d.size ? ` · ${fmtSize(d.size)}` : ''} ` : ''
let status = '', body = '', actions = ''
if (!d) {
- body = 'Guarda los capítulos en CBZ en el servidor para leerlos sin conexión (y poder reescalarlos a HD).
'
- actions = `${icon('download')} Descargar manga `
+ body = 'Guarda capítulos en CBZ en el servidor para leerlos sin conexión (y poder reescalarlos a HD). Puedes bajar el manga entero o solo algunos capítulos.
'
+ actions = `${icon('download')} Descargar todo ${pickBtn('Elegir capítulos…')}`
} else if (active) {
status = `${downloadLabel(d)} · ${Math.floor(downloadPct(d))}%`
body = `${bar}${downloadDetail(d)}
`
- actions = `${icon('stop')} Cancelar ${delBtn}`
+ actions = `${pickBtn('Añadir capítulos…')}${icon('stop')} Cancelar ${delBtn}`
} else {
- status = d.status === 'done' ? `${icon('check')} ${d.done} capítulos${d.size ? ` · ${fmtSize(d.size)}` : ''}` : downloadLabel(d)
+ const have = dlSet.size
+ status = d.status === 'done'
+ ? `${icon('check')} ${some ? `${have} de ${chapters.length}` : have} capítulos${d.size ? ` · ${fmtSize(d.size)}` : ''}`
+ : downloadLabel(d)
body = bar + (d.status === 'error' && d.error ? `${esc(d.error)}
` : '')
- actions = `${icon(d.status === 'cancelled' ? 'download' : 'refresh')} ${d.status === 'error' ? 'Reintentar' : d.status === 'cancelled' ? 'Continuar descarga' : 'Buscar capítulos nuevos'} ${delBtn}`
+ + (some && d.status === 'done' ? 'Solo capítulos elegidos: los nuevos no se descargan solos.
' : '')
+ const resume = d.status === 'error' ? 'Reintentar' : d.status === 'cancelled' ? 'Continuar descarga' : null
+ actions = some
+ ? `${resume ? `${icon('refresh')} ${resume} ` : ''}${pickBtn('Descargar más…')}${icon('download')} Descargar todo ${delBtn}`
+ : `${icon(resume ? 'download' : 'refresh')} ${resume || 'Buscar capítulos nuevos'} ${delBtn}`
}
- box.innerHTML = secHead('download', 'Copia local', status) + body + (offline ? '' : `${actions}
`)
+ if (!$('#dltop')) box.innerHTML = '
'
+ $('#dltop').innerHTML = secHead('download', 'Copia local', status) + body + (offline ? '' : `${actions}
`)
$('#dlgo')?.addEventListener('click', async () => {
+ // Reanudar respeta el modo (todo o capítulos elegidos).
+ renderDl(await api(`/api/download/${id}`, { method: 'POST', body: { lang, title: manga.title, ...(some ? { chapterIds: d.wanted?.length ? d.wanted : [] } : {}) } }).catch(err => (toast(err.message), d)))
+ })
+ $('#dlall')?.addEventListener('click', async () => {
+ const missing = chapters.filter(c => !dlSet.has(c.id)).length
+ if (d && !confirm(`¿Descargar todo «${manga.title}»?\nSe bajarán ${plural(missing, 'capítulo')} que faltan y, en adelante, los nuevos.`)) return
renderDl(await api(`/api/download/${id}`, { method: 'POST', body: { lang, title: manga.title } }))
toast(`Descargando en mangas/${manga.title}`)
})
+ $('#dlopen')?.addEventListener('click', () => setSel(sel?.kind === 'dl' ? null : { kind: 'dl', mode: 'range' }))
$('#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)})` : ''}, incluida la versión HD.\nTu historial de lectura no se toca.`)) return
@@ -389,6 +536,9 @@ async function viewManga(id, langOverride) {
toast('Copia local eliminada')
viewManga(id, langOverride)
})
+ const wantedBefore = wantedSet.size
+ wantedSet = new Set(d?.wanted || [])
+ if (wantedSet.size !== wantedBefore) refreshSel()
clearTimeout(dlTimer)
if (active) dlTimer = setTimeout(async () => {
const next = await api(`/api/download/${id}`).catch(() => d)
@@ -398,103 +548,14 @@ async function viewManga(id, langOverride) {
fresh?.downloaded.forEach(x => dlSet.add(x))
if (fresh?.download) next.size = fresh.download.size
if (fresh?.upscale) renderHd(fresh.upscale)
- renderList()
+ refreshSel()
}
renderDl(next)
}, 2000)
}
- // --- Reescalado HD: todo lo pendiente, un rango o una selección hecha en la lista.
+ // --- Reescalado HD de lo descargado.
let hdTimer = null
- let hdInfo = upscale
- let hdForm = null // null (cerrado) | 'pending' | 'range' | 'pick'
- const picked = new Set()
- let lastPick = null
- const fmtDur = sec => sec < 90 ? '≈ 1 min' : sec < 3600 ? `≈ ${Math.round(sec / 60)} min` : `≈ ${Math.floor(sec / 3600)} h ${Math.round((sec % 3600) / 60)} min`
- // Descargados, sin HD y sin estar ya en cola (los fallidos sí, para reintentar). En orden de lectura.
- const hdCandidates = () => chapters.filter(c => dlSet.has(c.id) && !hdSet.has(c.id) && (!hdJobs[c.id] || hdJobs[c.id] === 'error'))
- const estimate = ids => {
- const pages = ids.reduce((n, cid) => n + (hdInfo?.pages?.[cid] || 0), 0)
- return `${ids.length} capítulo${ids.length === 1 ? '' : 's'} · ${pages} págs. · ${fmtDur(pages * (hdInfo?.secPerPage || 13))}`
- }
- const formIds = () => {
- const cand = hdCandidates()
- if (hdForm === 'pending') return cand.map(c => c.id)
- if (hdForm === 'pick') return cand.filter(c => picked.has(c.id)).map(c => c.id)
- if (hdForm === 'range') {
- const dls = chapters.filter(c => dlSet.has(c.id))
- let a = dls.findIndex(c => c.id === $('#hdfrom')?.value), b = dls.findIndex(c => c.id === $('#hdto')?.value)
- if (a < 0 || b < 0) return []
- if (a > b) [a, b] = [b, a]
- const inRange = new Set(dls.slice(a, b + 1).map(c => c.id))
- return cand.filter(c => inRange.has(c.id)).map(c => c.id)
- }
- return []
- }
- const updateSummary = () => {
- const ids = formIds()
- const sum = $('#hdsum')
- if (sum) sum.textContent = ids.length ? estimate(ids) : hdForm === 'pick' ? 'Marca capítulos en la lista de abajo.' : 'No hay capítulos pendientes en esa selección.'
- if ($('#hdrun')) $('#hdrun').disabled = !ids.length
- const bar = $('#selbar')
- if (bar) {
- bar.hidden = hdForm !== 'pick'
- if (hdForm === 'pick') bar.innerHTML = `${ids.length ? estimate(ids) : 'Ningún capítulo marcado'}
- ${icon('sparkles')} Reescalar
- ${icon('x')} `
- $('#selrun')?.addEventListener('click', runHd)
- $('#selexit')?.addEventListener('click', () => setForm(null))
- }
- }
- async function runHd() {
- const ids = formIds()
- if (!ids.length) return
- try {
- const u = await api(`/api/upscale/${id}`, { method: 'POST', body: { chapterIds: ids } })
- toast(`${ids.length} capítulo${ids.length === 1 ? '' : 's'} en cola para HD`)
- picked.clear()
- setForm(null)
- renderHd(u)
- } catch (err) { toast(err.message) }
- }
- function setForm(mode) {
- const wasPick = hdForm === 'pick'
- hdForm = mode
- renderHdForm()
- if (wasPick !== (mode === 'pick')) renderList()
- updateSummary()
- }
- function renderHdForm() {
- const box = $('#hdform')
- if (!box) return
- box.hidden = !hdForm
- if (!hdForm) { box.innerHTML = ''; return }
- const dls = chapters.filter(c => dlSet.has(c.id))
- const cand = hdCandidates()
- const opt = (c, sel) => `${esc(c.num ? `Cap. ${c.num}` : 'Oneshot')}${hdSet.has(c.id) ? ' (HD)' : ''} `
- const from = cand[0] || dls[0], to = cand[Math.min(cand.length, 10) - 1] || dls.at(-1)
- box.innerHTML = `
-
- Pendientes
- Rango
- Elegir
-
- ${hdForm === 'range' ? `Desde ${dls.map(c => opt(c, c === from)).join('')}
- hasta ${dls.map(c => opt(c, c === to)).join('')}
` : ''}
- ${hdForm === 'pick' ? 'Marca los capítulos en la lista de abajo. Mayús + clic marca un tramo entero.
' : ''}
- ${hdForm === 'pending' ? `Todos los capítulos descargados que aún no están en HD.
- Reescalar también los que se descarguen en el futuro ` : ''}
-
- ${icon('sparkles')} Reescalar Cerrar
`
- box.querySelectorAll('[data-hm]').forEach(b => b.addEventListener('click', () => setForm(b.dataset.hm)))
- $('#hdfrom')?.addEventListener('change', updateSummary)
- $('#hdto')?.addEventListener('change', updateSummary)
- $('#hdclose').addEventListener('click', () => setForm(null))
- $('#hdrun').addEventListener('click', async () => {
- if (hdForm === 'pending' && $('#hdautoform')?.checked !== !!hdInfo?.auto) await api(`/api/upscale/${id}/auto`, { method: 'POST', body: { auto: $('#hdautoform').checked } }).catch(() => {})
- runHd()
- })
- }
const renderHd = u => {
const box = $('#hd')
if (!box) return
@@ -517,17 +578,17 @@ async function viewManga(id, langOverride) {
body = `Mejora la nitidez con IA (Real-ESRGAN ×2). Unos ${u.secPerPage} s por página; el original no se toca.
`
}
if (u.error) body += `Falló algún capítulo: ${esc(u.error)}
`
- const canAdd = hdCandidates().length > 0
+ const canAdd = KINDS.hd.candidates().length > 0
const actions = [
- canAdd ? `${icon('sparkles')} ${active ? 'Añadir capítulos…' : 'Reescalar…'} ` : '',
+ canAdd ? `${icon('sparkles')} ${active ? 'Añadir capítulos…' : 'Reescalar…'} ` : '',
active ? `${icon('stop')} Detener ` : '',
u.hd ? `${icon('trash')} Quitar HD ` : '',
].join('')
- // El formulario se conserva entre sondeos: solo se repinta la parte de arriba.
+ // Los formularios se conservan entre sondeos: solo se repinta la parte de arriba.
if (!$('#hdtop')) box.innerHTML = '
'
$('#hdtop').innerHTML = secHead('sparkles', 'Reescalado HD', status) + body + (actions ? `${actions}
` : '')
+ (u.auto ? 'Los capítulos que se descarguen también se reescalan.
' : '')
- $('#hdopen')?.addEventListener('click', () => setForm(hdForm ? null : 'pending'))
+ $('#hdopen')?.addEventListener('click', () => setSel(sel?.kind === 'hd' ? null : { kind: 'hd', mode: 'pending' }))
$('#hdcancel')?.addEventListener('click', async () => {
if (!confirm('¿Detener el reescalado?\nSe vacía la cola de este manga; lo ya reescalado se queda.')) return
renderHd(await api(`/api/upscale/${id}/cancel`, { method: 'POST' }))
@@ -541,10 +602,8 @@ async function viewManga(id, langOverride) {
}
const changed = (u?.chapters?.length ?? 0) !== hdSet.size || JSON.stringify(u?.jobs || {}) !== JSON.stringify(hdJobs)
hdSet = new Set(u?.chapters || []); hdJobs = u?.jobs || {}
- if (changed) { renderList(); if (hdForm && hdForm !== 'pick') renderHdForm(); updateSummary() }
- if (hdForm && !hdCandidates().length) setForm(null)
+ if (changed) refreshSel()
}
- cleanup = () => { clearTimeout(dlTimer); clearTimeout(hdTimer) }
const renderFollow = () => {
const b = $('#follow')
@@ -576,21 +635,22 @@ async function viewManga(id, langOverride) {
const ul = $('.chapters', app)
let desc = store.get('chapterOrderDesc', true)
const chapMeta = (c, r, done) => (newSet.has(String(c.num ?? c.id)) ? 'NUEVO ' : '')
- + (dlSet.has(c.id) ? `${icon('download')} ` : '')
+ + (dlSet.has(c.id) ? `${icon('download')} ` : wantedSet.has(c.id) ? `${icon('download')}… ` : '')
+ (hdSet.has(c.id) ? 'HD '
: hdJobs[c.id] ? `HD${hdJobs[c.id] === 'error' ? '!' : '…'} ` : '')
+ (r ? (done ? 'Leído' : `pág. ${r.page + 1}/${r.total_pages}`) : esc(c.group))
function renderList() {
if (!ul) return
const list = desc ? [...chapters].reverse() : chapters
- const pickable = new Set(hdForm === 'pick' ? hdCandidates().map(c => c.id) : [])
- ul.classList.toggle('picking', hdForm === 'pick')
+ const picking = sel?.mode === 'pick'
+ const pickable = new Set(picking ? KINDS[sel.kind].candidates().map(c => c.id) : [])
+ ul.classList.toggle('picking', picking)
ul.innerHTML = list.map((c, i) => {
const r = readMap.get(c.id)
const done = r && r.total_pages && r.page >= r.total_pages - 1
- if (hdForm === 'pick') {
+ if (picking) {
const ok = pickable.has(c.id)
- return `
+ return `
${esc(chapLabel(c))} ${chapMeta(c, r, done)} `
}
@@ -598,7 +658,7 @@ async function viewManga(id, langOverride) {
return `${esc(chapLabel(c))} ${chapMeta(c, r, done)} `
}).join('')
}
- // Selección para HD (Mayús + clic marca el tramo desde el último marcado).
+ // Selección en la lista (Mayús + clic marca el tramo desde el último marcado).
ul?.addEventListener('click', e => {
const box = e.target.closest('input[data-cid]')
if (!box) return
@@ -613,8 +673,8 @@ async function viewManga(id, langOverride) {
lastPick = i
updateSummary()
})
- renderDl(download)
renderList()
+ renderDl(download)
renderHd(upscale)
$('#order')?.addEventListener('click', () => { desc = !desc; lastPick = null; store.set('chapterOrderDesc', desc); renderList() })
$('#lang')?.addEventListener('change', e => { store.set(`lang:${id}`, e.target.value); viewManga(id, e.target.value) })
diff --git a/app/public/style.css b/app/public/style.css
index 7b268e7..c506a6e 100644
--- a/app/public/style.css
+++ b/app/public/style.css
@@ -160,6 +160,7 @@ a.tag:hover, .tag.on { background: var(--accent); color: var(--accent-ink); }
/* Insignias y botón Seguir */
.hd { display: inline-block; font-size: .65rem; font-weight: 800; letter-spacing: .03em; padding: 0 5px; border-radius: 4px; border: 1px solid var(--accent); color: var(--accent); vertical-align: middle; }
+.chapters .meta .queued { opacity: .55; }
.hd.pending { border-style: dashed; color: var(--muted); border-color: var(--muted); }
.badge { display: inline-block; font-size: .72rem; font-weight: 700; padding: 1px 7px; border-radius: 999px; background: var(--accent); color: var(--accent-ink); vertical-align: middle; }
.btn.on { border-color: var(--accent); color: var(--accent); }
diff --git a/app/server.js b/app/server.js
index bad43f2..fb12ad3 100644
--- a/app/server.js
+++ b/app/server.js
@@ -108,6 +108,13 @@ CREATE TABLE IF NOT EXISTS downloaded_chapters (
CREATE INDEX IF NOT EXISTS idx_dlch_manga ON downloaded_chapters(manga_id);
`)
db.prepare('DELETE FROM sessions WHERE expires_at < ?').run(Date.now())
+// Descargas parciales: scope 'some' baja solo los capítulos pedidos (download_wanted); 'all', el manga entero.
+if (!db.prepare('PRAGMA table_info(downloads)').all().some(c => c.name === 'scope')) db.exec("ALTER TABLE downloads ADD COLUMN scope TEXT NOT NULL DEFAULT 'all'")
+db.exec(`CREATE TABLE IF NOT EXISTS download_wanted (
+ chapter_id TEXT PRIMARY KEY,
+ manga_id TEXT NOT NULL REFERENCES downloads(manga_id) ON DELETE CASCADE,
+ created_at INTEGER NOT NULL
+)`)
// finished_at: cuándo se terminó la última página del último capítulo disponible (null = en curso).
if (!db.prepare('PRAGMA table_info(progress)').all().some(c => c.name === 'finished_at')) db.exec('ALTER TABLE progress ADD COLUMN finished_at INTEGER')
@@ -488,6 +495,7 @@ async function downloadChapter(dl, manga, c, dir) {
}
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())
+ db.prepare('DELETE FROM download_wanted WHERE chapter_id = ?').run(c.id)
upscale.onChapterDownloaded(dl.manga_id, c.id)
}
@@ -532,8 +540,14 @@ async function runDownload(dl) {
}
const have = new Set(db.prepare('SELECT chapter_id, file FROM downloaded_chapters WHERE manga_id = ?').all(dl.manga_id)
.filter(r => fs.existsSync(path.join(dir, r.file))).map(r => r.chapter_id))
- const pending = chapters.filter(c => !have.has(c.id))
- dlUpdate(dl.manga_id, { status: 'downloading', title: manga.title, meta: JSON.stringify({ manga, chapters }), total: chapters.length, done: chapters.length - pending.length, failed: 0, error: null })
+ let pending = chapters.filter(c => !have.has(c.id))
+ let total = chapters.length
+ if (dl.scope === 'some') {
+ const wanted = new Set(db.prepare('SELECT chapter_id FROM download_wanted WHERE manga_id = ?').all(dl.manga_id).map(r => r.chapter_id))
+ pending = pending.filter(c => wanted.has(c.id))
+ total = have.size + pending.length
+ }
+ dlUpdate(dl.manga_id, { status: 'downloading', title: manga.title, meta: JSON.stringify({ manga, chapters }), total, done: total - pending.length, failed: 0, error: null })
let failed = 0
let lastError = null
for (const c of pending) {
@@ -567,31 +581,44 @@ async function pumpDownloads() {
for (;;) {
const dl = db.prepare("SELECT * FROM downloads WHERE status IN ('downloading', 'queued') ORDER BY status = 'downloading' DESC, created_at LIMIT 1").get()
if (!dl) break
+ requeue.delete(dl.manga_id)
try {
await runDownload(dl)
} catch (e) {
console.error('Descarga fallida', dl.manga_id, e)
if (!isCancelled(dl.manga_id)) dlUpdate(dl.manga_id, { status: 'error', error: e.message })
}
+ // Pidieron más capítulos mientras se bajaba: otra vuelta.
+ if (requeue.delete(dl.manga_id) && !isCancelled(dl.manga_id)) dlUpdate(dl.manga_id, { status: 'queued' })
}
} finally {
pumping = false
}
}
-function enqueueDownload(mangaId, title, lang) {
+// chapterIds: solo esos capítulos (si el manga ya se baja entero, no cambia nada); sin ellos, el manga entero.
+function enqueueDownload(mangaId, title, lang, chapterIds = null) {
const existing = db.prepare('SELECT * FROM downloads WHERE manga_id = ?').get(mangaId)
if (existing) {
+ if (!chapterIds && existing.scope !== 'all') dlUpdate(mangaId, { scope: 'all' })
+ // Si ya se estaba descargando, runDownload no verá lo nuevo: se vuelve a encolar al acabar.
if (!['queued', 'downloading'].includes(existing.status)) dlUpdate(mangaId, { status: 'queued', lang: lang || existing.lang, error: null })
+ else if (existing.status === 'downloading') requeue.add(mangaId)
} else {
let folder = safeName(title)
for (let i = 2; db.prepare('SELECT 1 FROM downloads WHERE folder = ?').get(folder); i++) folder = `${safeName(title)} (${i})`
const now = Date.now()
- db.prepare('INSERT INTO downloads (manga_id, title, folder, lang, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)')
- .run(mangaId, title, folder, lang, 'queued', now, now)
+ db.prepare('INSERT INTO downloads (manga_id, title, folder, lang, status, scope, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
+ .run(mangaId, title, folder, lang, 'queued', chapterIds ? 'some' : 'all', now, now)
+ }
+ if (chapterIds) {
+ const ins = db.prepare('INSERT OR IGNORE INTO download_wanted (chapter_id, manga_id, created_at) VALUES (?, ?, ?)')
+ const now = Date.now()
+ for (const id of chapterIds) ins.run(id, mangaId, now)
}
pumpDownloads()
}
+const requeue = new Set()
const cbzIndexCache = new Map()
// hd: usar la versión reescalada si existe.
@@ -610,8 +637,9 @@ function localChapter(chapterId, hd = false) {
}
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))
+ const d = withCurrent(db.prepare('SELECT manga_id, title, folder, lang, status, scope, done, total, failed, error, updated_at FROM downloads WHERE manga_id = ?').get(mangaId))
if (d && size) d.size = dirSize(mangaDirOf(d.folder))
+ if (d) d.wanted = db.prepare('SELECT chapter_id FROM download_wanted WHERE manga_id = ?').all(mangaId).map(r => r.chapter_id)
return d
}
@@ -787,14 +815,16 @@ async function route(req, res) {
.map(d => ({ ...withCurrent(d), size: dirSize(mangaDirOf(d.folder)) })))
}
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], true))
}
if ((m = /^\/api\/download\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'POST') {
const b = await readBody(req)
const dlLang = m[1].startsWith('lc~') ? 'es' : LANG.test(b.lang || '') ? b.lang : user.lang
const title = String(b.title || '').slice(0, 300) || (await fetchMangaFull(m[1], dlLang)).manga.title
- enqueueDownload(m[1], title, dlLang)
- return json(res, 200, downloadInfo(m[1]))
+ const ids = Array.isArray(b.chapterIds) ? b.chapterIds.map(String).filter(id => CHAPTER_ID.test(id)).slice(0, 5000) : null
+ if (ids && !ids.length) throw new HttpError(400, 'Ningún capítulo válido')
+ enqueueDownload(m[1], title, dlLang, ids)
+ return json(res, 200, downloadInfo(m[1], true))
}
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')