From aabb87f6c14a01bbf810522b0d9f170263fa8431 Mon Sep 17 00:00:00 2001 From: Nino Date: Sat, 31 Oct 2026 11:24:38 -0600 Subject: [PATCH] =?UTF-8?q?Descarga=20de=20cap=C3=ADtulos=20sueltos:=20ran?= =?UTF-8?q?go=20o=20selecci=C3=B3n=20en=20la=20lista?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Las descargas tienen modo 'all' (manga entero, también lo nuevo) o 'some' (solo los capítulos pedidos, en download_wanted). El selector de capítulos se comparte con el reescalado HD. Co-Authored-By: Claude Opus 5.5 --- app/features.js | 3 +- app/public/app.js | 286 ++++++++++++++++++++++++++----------------- app/public/style.css | 1 + app/server.js | 48 ++++++-- 4 files changed, 215 insertions(+), 123 deletions(-) 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'} + + ` + $('#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) => `` + 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]) => ``).join('')}
+ ${sel.mode === 'range' ? `
+
` : ''} + ${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.
+ ` : ''} +
+
` + 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 ? `` : '') const delBtn = d ? `` : '' 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 = `` + 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 = `${pickBtn('Elegir capítulos…')}` } else if (active) { status = `${downloadLabel(d)} · ${Math.floor(downloadPct(d))}%` body = `${bar}
${downloadDetail(d)}
` - actions = `${delBtn}` + actions = `${pickBtn('Añadir capítulos…')}${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 = `${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 ? `` : ''}${pickBtn('Descargar más…')}${delBtn}` + : `${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'} - - ` - $('#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) => `` - const from = cand[0] || dls[0], to = cand[Math.min(cand.length, 10) - 1] || dls.at(-1) - box.innerHTML = ` -
- - - -
- ${hdForm === 'range' ? `
-
` : ''} - ${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.
- ` : ''} -
-
` - 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 ? `` : '', + canAdd ? `` : '', active ? `` : '', u.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 `
  • ` } @@ -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')