Descarga de capítulos sueltos: rango o selección en la lista
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 <noreply@anthropic.com>
This commit is contained in:
+2
-1
@@ -177,7 +177,8 @@ export function setupFeatures({ db, fetchMangaFull, enqueueDownload, downloadInf
|
|||||||
}
|
}
|
||||||
// Autodescarga: si el manga ya tiene copia local, bajar también lo nuevo.
|
// Autodescarga: si el manga ya tiene copia local, bajar también lo nuevo.
|
||||||
const dl = anyNew && downloadInfo(mangaId)
|
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
|
return anyNew
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+173
-113
@@ -304,7 +304,6 @@ async function viewManga(id, langOverride) {
|
|||||||
const lang = langOverride || store.get(`lang:${id}`, me.lang)
|
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 { manga, chapters, reads, progress, chapterLangs, offline, downloaded, download, upscale, following, newKeys = [] } = await api(`/api/manga/${id}?lang=${encodeURIComponent(lang)}`)
|
||||||
const dlSet = new Set(downloaded)
|
const dlSet = new Set(downloaded)
|
||||||
let hdSet = new Set(upscale?.chapters || []), hdJobs = upscale?.jobs || {}
|
|
||||||
const newSet = new Set(newKeys.map(String))
|
const newSet = new Set(newKeys.map(String))
|
||||||
let isFollowing = following
|
let isFollowing = following
|
||||||
const readMap = new Map(reads.map(r => [r.chapter_id, r]))
|
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 => `<a class="tag" href="#" data-lang="${esc(l)}">${esc(LANGS[l] || l)}</a>`).join(' ')}` : 'No hay capítulos en ningún idioma.'}</p>`}
|
${chapterLangs.length ? `Disponibles en: ${chapterLangs.map(l => `<a class="tag" href="#" data-lang="${esc(l)}">${esc(LANGS[l] || l)}</a>`).join(' ')}` : 'No hay capítulos en ningún idioma.'}</p>`}
|
||||||
<div id="selbar" hidden></div>`
|
<div id="selbar" hidden></div>`
|
||||||
|
|
||||||
|
const secHead = (ic, title, status) => `<div class="psec-head"><div class="psec-title">${icon(ic)} ${title}</div>${status ? `<div class="psec-status muted">${status}</div>` : ''}</div>`
|
||||||
|
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 = `<span>${ids.length ? K.summary(ids) : 'Ningún capítulo marcado'}</span>
|
||||||
|
<button class="btn small primary" id="selrun" ${ids.length ? '' : 'disabled'}>${icon(K.ic)} ${K.verb}</button>
|
||||||
|
<button class="btn small" id="selexit" title="Salir de la selección">${icon('x')}</button>`
|
||||||
|
$('#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) => `<option value="${c.id}" ${on ? 'selected' : ''}>${esc(c.num ? `Cap. ${c.num}` : 'Oneshot')}${K.mark(c)}</option>`
|
||||||
|
const from = cand[0] || base[0], to = cand[Math.min(cand.length, 10) - 1] || base.at(-1)
|
||||||
|
box.innerHTML = `
|
||||||
|
<div class="seg" role="tablist">${Object.entries(K.modes).map(([m, label]) => `<button data-sm="${m}" class="${sel.mode === m ? 'on' : ''}">${label}</button>`).join('')}</div>
|
||||||
|
${sel.mode === 'range' ? `<div class="row range"><label>Desde <select id="selfrom">${base.map(c => opt(c, c === from)).join('')}</select></label>
|
||||||
|
<label>hasta <select id="selto">${base.map(c => opt(c, c === to)).join('')}</select></label></div>` : ''}
|
||||||
|
${sel.mode === 'pick' ? '<div class="note muted">Marca los capítulos en la lista de abajo. Mayús + clic marca un tramo entero.</div>' : ''}
|
||||||
|
${sel.mode === 'pending' ? `<div class="note muted">Todos los capítulos descargados que aún no están en HD.</div>
|
||||||
|
<label class="check"><input type="checkbox" id="hdautoform" ${hdInfo?.auto ? 'checked' : ''}> Reescalar también los que se descarguen en el futuro</label>` : ''}
|
||||||
|
<div class="row"><b id="selsum" class="sum"></b></div>
|
||||||
|
<div class="psec-actions"><button class="btn small primary" id="selgo">${icon(K.ic)} ${K.verb}</button><button class="btn small" id="selclose">Cerrar</button></div>`
|
||||||
|
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.
|
// --- Copia local (descarga) con sondeo del progreso mientras está activa.
|
||||||
let dlTimer = null
|
let dlTimer = null
|
||||||
const secHead = (ic, title, status) => `<div class="psec-head"><div class="psec-title">${icon(ic)} ${title}</div>${status ? `<div class="psec-status muted">${status}</div>` : ''}</div>`
|
|
||||||
const renderDl = d => {
|
const renderDl = d => {
|
||||||
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 some = d?.scope === 'some'
|
||||||
const partial = d && d.done < d.total
|
const partial = d && d.done < d.total
|
||||||
const bar = d && (active || partial) ? `<div class="bar dlbar"><i style="width:${downloadPct(d).toFixed(1)}%"></i></div>` : ''
|
const bar = d && (active || partial) ? `<div class="bar dlbar"><i style="width:${downloadPct(d).toFixed(1)}%"></i></div>` : ''
|
||||||
|
const canPick = !offline && chapters.some(c => !dlSet.has(c.id) && !wantedSet.has(c.id) && !(d?.wanted || []).includes(c.id))
|
||||||
|
const pickBtn = label => (canPick ? `<button class="btn small ${sel?.kind === 'dl' ? 'on' : ''}" id="dlopen">${icon('download')} ${label}</button>` : '')
|
||||||
const delBtn = d ? `<button class="btn small danger push" id="dldel">${icon('trash')} Eliminar copia${d.size ? ` · ${fmtSize(d.size)}` : ''}</button>` : ''
|
const delBtn = d ? `<button class="btn small danger push" id="dldel">${icon('trash')} Eliminar copia${d.size ? ` · ${fmtSize(d.size)}` : ''}</button>` : ''
|
||||||
let status = '', body = '', actions = ''
|
let status = '', body = '', actions = ''
|
||||||
if (!d) {
|
if (!d) {
|
||||||
body = '<div class="note muted">Guarda los capítulos en CBZ en el servidor para leerlos sin conexión (y poder reescalarlos a HD).</div>'
|
body = '<div class="note muted">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.</div>'
|
||||||
actions = `<button class="btn small" id="dlgo">${icon('download')} Descargar manga</button>`
|
actions = `<button class="btn small" id="dlall">${icon('download')} Descargar todo</button>${pickBtn('Elegir capítulos…')}`
|
||||||
} else if (active) {
|
} else if (active) {
|
||||||
status = `${downloadLabel(d)} · ${Math.floor(downloadPct(d))}%`
|
status = `${downloadLabel(d)} · ${Math.floor(downloadPct(d))}%`
|
||||||
body = `${bar}<div class="note muted">${downloadDetail(d)}</div>`
|
body = `${bar}<div class="note muted">${downloadDetail(d)}</div>`
|
||||||
actions = `<button class="btn small" id="dlcancel">${icon('stop')} Cancelar</button>${delBtn}`
|
actions = `${pickBtn('Añadir capítulos…')}<button class="btn small" id="dlcancel">${icon('stop')} Cancelar</button>${delBtn}`
|
||||||
} else {
|
} 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 ? `<div class="error note">${esc(d.error)}</div>` : '')
|
body = bar + (d.status === 'error' && d.error ? `<div class="error note">${esc(d.error)}</div>` : '')
|
||||||
actions = `<button class="btn small" id="dlgo" title="Descarga los capítulos que falten">${icon(d.status === 'cancelled' ? 'download' : 'refresh')} ${d.status === 'error' ? 'Reintentar' : d.status === 'cancelled' ? 'Continuar descarga' : 'Buscar capítulos nuevos'}</button>${delBtn}`
|
+ (some && d.status === 'done' ? '<div class="note muted">Solo capítulos elegidos: los nuevos no se descargan solos.</div>' : '')
|
||||||
|
const resume = d.status === 'error' ? 'Reintentar' : d.status === 'cancelled' ? 'Continuar descarga' : null
|
||||||
|
actions = some
|
||||||
|
? `${resume ? `<button class="btn small" id="dlgo">${icon('refresh')} ${resume}</button>` : ''}${pickBtn('Descargar más…')}<button class="btn small" id="dlall" title="Bajar también todos los capítulos que falten y, en adelante, los nuevos">${icon('download')} Descargar todo</button>${delBtn}`
|
||||||
|
: `<button class="btn small" id="dlgo" title="Descarga los capítulos que falten">${icon(resume ? 'download' : 'refresh')} ${resume || 'Buscar capítulos nuevos'}</button>${delBtn}`
|
||||||
}
|
}
|
||||||
box.innerHTML = secHead('download', 'Copia local', status) + body + (offline ? '' : `<div class="psec-actions">${actions}</div>`)
|
if (!$('#dltop')) box.innerHTML = '<div id="dltop" class="psec-grid"></div><div id="dlform" class="hdform" hidden></div>'
|
||||||
|
$('#dltop').innerHTML = secHead('download', 'Copia local', status) + body + (offline ? '' : `<div class="psec-actions">${actions}</div>`)
|
||||||
$('#dlgo')?.addEventListener('click', async () => {
|
$('#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 } }))
|
renderDl(await api(`/api/download/${id}`, { method: 'POST', body: { lang, title: manga.title } }))
|
||||||
toast(`Descargando en mangas/${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' })))
|
$('#dlcancel')?.addEventListener('click', async () => renderDl(await api(`/api/download/${id}/cancel`, { method: 'POST' })))
|
||||||
$('#dldel')?.addEventListener('click', async () => {
|
$('#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
|
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')
|
toast('Copia local eliminada')
|
||||||
viewManga(id, langOverride)
|
viewManga(id, langOverride)
|
||||||
})
|
})
|
||||||
|
const wantedBefore = wantedSet.size
|
||||||
|
wantedSet = new Set(d?.wanted || [])
|
||||||
|
if (wantedSet.size !== wantedBefore) refreshSel()
|
||||||
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)
|
||||||
@@ -398,103 +548,14 @@ async function viewManga(id, langOverride) {
|
|||||||
fresh?.downloaded.forEach(x => dlSet.add(x))
|
fresh?.downloaded.forEach(x => dlSet.add(x))
|
||||||
if (fresh?.download) next.size = fresh.download.size
|
if (fresh?.download) next.size = fresh.download.size
|
||||||
if (fresh?.upscale) renderHd(fresh.upscale)
|
if (fresh?.upscale) renderHd(fresh.upscale)
|
||||||
renderList()
|
refreshSel()
|
||||||
}
|
}
|
||||||
renderDl(next)
|
renderDl(next)
|
||||||
}, 2000)
|
}, 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 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 = `<span>${ids.length ? estimate(ids) : 'Ningún capítulo marcado'}</span>
|
|
||||||
<button class="btn small primary" id="selrun" ${ids.length ? '' : 'disabled'}>${icon('sparkles')} Reescalar</button>
|
|
||||||
<button class="btn small" id="selexit" title="Salir de la selección">${icon('x')}</button>`
|
|
||||||
$('#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) => `<option value="${c.id}" ${sel ? 'selected' : ''}>${esc(c.num ? `Cap. ${c.num}` : 'Oneshot')}${hdSet.has(c.id) ? ' (HD)' : ''}</option>`
|
|
||||||
const from = cand[0] || dls[0], to = cand[Math.min(cand.length, 10) - 1] || dls.at(-1)
|
|
||||||
box.innerHTML = `
|
|
||||||
<div class="seg" role="tablist">
|
|
||||||
<button data-hm="pending" class="${hdForm === 'pending' ? 'on' : ''}">Pendientes</button>
|
|
||||||
<button data-hm="range" class="${hdForm === 'range' ? 'on' : ''}">Rango</button>
|
|
||||||
<button data-hm="pick" class="${hdForm === 'pick' ? 'on' : ''}">Elegir</button>
|
|
||||||
</div>
|
|
||||||
${hdForm === 'range' ? `<div class="row range"><label>Desde <select id="hdfrom">${dls.map(c => opt(c, c === from)).join('')}</select></label>
|
|
||||||
<label>hasta <select id="hdto">${dls.map(c => opt(c, c === to)).join('')}</select></label></div>` : ''}
|
|
||||||
${hdForm === 'pick' ? '<div class="note muted">Marca los capítulos en la lista de abajo. Mayús + clic marca un tramo entero.</div>' : ''}
|
|
||||||
${hdForm === 'pending' ? `<div class="note muted">Todos los capítulos descargados que aún no están en HD.</div>
|
|
||||||
<label class="check"><input type="checkbox" id="hdautoform" ${hdInfo?.auto ? 'checked' : ''}> Reescalar también los que se descarguen en el futuro</label>` : ''}
|
|
||||||
<div class="row"><b id="hdsum" class="sum"></b></div>
|
|
||||||
<div class="psec-actions"><button class="btn small primary" id="hdrun">${icon('sparkles')} Reescalar</button><button class="btn small" id="hdclose">Cerrar</button></div>`
|
|
||||||
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 renderHd = u => {
|
||||||
const box = $('#hd')
|
const box = $('#hd')
|
||||||
if (!box) return
|
if (!box) return
|
||||||
@@ -517,17 +578,17 @@ async function viewManga(id, langOverride) {
|
|||||||
body = `<div class="note muted">Mejora la nitidez con IA (Real-ESRGAN ×2). Unos ${u.secPerPage} s por página; el original no se toca.</div>`
|
body = `<div class="note muted">Mejora la nitidez con IA (Real-ESRGAN ×2). Unos ${u.secPerPage} s por página; el original no se toca.</div>`
|
||||||
}
|
}
|
||||||
if (u.error) body += `<div class="error note">Falló algún capítulo: ${esc(u.error)}</div>`
|
if (u.error) body += `<div class="error note">Falló algún capítulo: ${esc(u.error)}</div>`
|
||||||
const canAdd = hdCandidates().length > 0
|
const canAdd = KINDS.hd.candidates().length > 0
|
||||||
const actions = [
|
const actions = [
|
||||||
canAdd ? `<button class="btn small ${hdForm ? 'on' : ''}" id="hdopen" aria-expanded="${!!hdForm}">${icon('sparkles')} ${active ? 'Añadir capítulos…' : 'Reescalar…'}</button>` : '',
|
canAdd ? `<button class="btn small ${sel?.kind === 'hd' ? 'on' : ''}" id="hdopen">${icon('sparkles')} ${active ? 'Añadir capítulos…' : 'Reescalar…'}</button>` : '',
|
||||||
active ? `<button class="btn small" id="hdcancel">${icon('stop')} Detener</button>` : '',
|
active ? `<button class="btn small" id="hdcancel">${icon('stop')} Detener</button>` : '',
|
||||||
u.hd ? `<button class="btn small danger push" id="hddel">${icon('trash')} Quitar HD</button>` : '',
|
u.hd ? `<button class="btn small danger push" id="hddel">${icon('trash')} Quitar HD</button>` : '',
|
||||||
].join('')
|
].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 = '<div id="hdtop" class="psec-grid"></div><div id="hdform" class="hdform" hidden></div>'
|
if (!$('#hdtop')) box.innerHTML = '<div id="hdtop" class="psec-grid"></div><div id="hdform" class="hdform" hidden></div>'
|
||||||
$('#hdtop').innerHTML = secHead('sparkles', 'Reescalado HD', status) + body + (actions ? `<div class="psec-actions">${actions}</div>` : '')
|
$('#hdtop').innerHTML = secHead('sparkles', 'Reescalado HD', status) + body + (actions ? `<div class="psec-actions">${actions}</div>` : '')
|
||||||
+ (u.auto ? '<div class="note muted">Los capítulos que se descarguen también se reescalan.</div>' : '')
|
+ (u.auto ? '<div class="note muted">Los capítulos que se descarguen también se reescalan.</div>' : '')
|
||||||
$('#hdopen')?.addEventListener('click', () => setForm(hdForm ? null : 'pending'))
|
$('#hdopen')?.addEventListener('click', () => setSel(sel?.kind === 'hd' ? null : { kind: 'hd', mode: 'pending' }))
|
||||||
$('#hdcancel')?.addEventListener('click', async () => {
|
$('#hdcancel')?.addEventListener('click', async () => {
|
||||||
if (!confirm('¿Detener el reescalado?\nSe vacía la cola de este manga; lo ya reescalado se queda.')) return
|
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' }))
|
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)
|
const changed = (u?.chapters?.length ?? 0) !== hdSet.size || JSON.stringify(u?.jobs || {}) !== JSON.stringify(hdJobs)
|
||||||
hdSet = new Set(u?.chapters || []); hdJobs = u?.jobs || {}
|
hdSet = new Set(u?.chapters || []); hdJobs = u?.jobs || {}
|
||||||
if (changed) { renderList(); if (hdForm && hdForm !== 'pick') renderHdForm(); updateSummary() }
|
if (changed) refreshSel()
|
||||||
if (hdForm && !hdCandidates().length) setForm(null)
|
|
||||||
}
|
}
|
||||||
cleanup = () => { clearTimeout(dlTimer); clearTimeout(hdTimer) }
|
|
||||||
|
|
||||||
const renderFollow = () => {
|
const renderFollow = () => {
|
||||||
const b = $('#follow')
|
const b = $('#follow')
|
||||||
@@ -576,21 +635,22 @@ async function viewManga(id, langOverride) {
|
|||||||
const ul = $('.chapters', app)
|
const ul = $('.chapters', app)
|
||||||
let desc = store.get('chapterOrderDesc', true)
|
let desc = store.get('chapterOrderDesc', true)
|
||||||
const chapMeta = (c, r, done) => (newSet.has(String(c.num ?? c.id)) ? '<span class="badge">NUEVO</span> ' : '')
|
const chapMeta = (c, r, done) => (newSet.has(String(c.num ?? c.id)) ? '<span class="badge">NUEVO</span> ' : '')
|
||||||
+ (dlSet.has(c.id) ? `<span title="Descargado">${icon('download')}</span> ` : '')
|
+ (dlSet.has(c.id) ? `<span title="Descargado">${icon('download')}</span> ` : wantedSet.has(c.id) ? `<span class="queued" title="En cola de descarga">${icon('download')}…</span> ` : '')
|
||||||
+ (hdSet.has(c.id) ? '<span class="hd" title="Reescalado a HD">HD</span> '
|
+ (hdSet.has(c.id) ? '<span class="hd" title="Reescalado a HD">HD</span> '
|
||||||
: hdJobs[c.id] ? `<span class="hd pending" title="${hdJobs[c.id] === 'error' ? 'Falló el reescalado' : 'En cola para HD'}">HD${hdJobs[c.id] === 'error' ? '!' : '…'}</span> ` : '')
|
: hdJobs[c.id] ? `<span class="hd pending" title="${hdJobs[c.id] === 'error' ? 'Falló el reescalado' : 'En cola para HD'}">HD${hdJobs[c.id] === 'error' ? '!' : '…'}</span> ` : '')
|
||||||
+ (r ? (done ? 'Leído' : `pág. ${r.page + 1}/${r.total_pages}`) : esc(c.group))
|
+ (r ? (done ? 'Leído' : `pág. ${r.page + 1}/${r.total_pages}`) : esc(c.group))
|
||||||
function renderList() {
|
function renderList() {
|
||||||
if (!ul) return
|
if (!ul) return
|
||||||
const list = desc ? [...chapters].reverse() : chapters
|
const list = desc ? [...chapters].reverse() : chapters
|
||||||
const pickable = new Set(hdForm === 'pick' ? hdCandidates().map(c => c.id) : [])
|
const picking = sel?.mode === 'pick'
|
||||||
ul.classList.toggle('picking', hdForm === '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) => {
|
ul.innerHTML = list.map((c, i) => {
|
||||||
const r = readMap.get(c.id)
|
const r = readMap.get(c.id)
|
||||||
const done = r && r.total_pages && r.page >= r.total_pages - 1
|
const done = r && r.total_pages && r.page >= r.total_pages - 1
|
||||||
if (hdForm === 'pick') {
|
if (picking) {
|
||||||
const ok = pickable.has(c.id)
|
const ok = pickable.has(c.id)
|
||||||
return `<li><label class="pick ${ok ? '' : 'off'}" title="${ok ? '' : dlSet.has(c.id) ? 'Ya está en HD o en cola' : 'No está descargado'}">
|
return `<li><label class="pick ${ok ? '' : 'off'}" title="${ok ? '' : KINDS[sel.kind].offTitle(c)}">
|
||||||
<input type="checkbox" data-i="${i}" data-cid="${c.id}" ${ok ? '' : 'disabled'} ${picked.has(c.id) ? 'checked' : ''}>
|
<input type="checkbox" data-i="${i}" data-cid="${c.id}" ${ok ? '' : 'disabled'} ${picked.has(c.id) ? 'checked' : ''}>
|
||||||
<span class="t">${esc(chapLabel(c))}</span><span class="meta">${chapMeta(c, r, done)}</span></label></li>`
|
<span class="t">${esc(chapLabel(c))}</span><span class="meta">${chapMeta(c, r, done)}</span></label></li>`
|
||||||
}
|
}
|
||||||
@@ -598,7 +658,7 @@ async function viewManga(id, langOverride) {
|
|||||||
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">${chapMeta(c, r, done)}</span></a></li>`
|
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">${chapMeta(c, r, done)}</span></a></li>`
|
||||||
}).join('')
|
}).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 => {
|
ul?.addEventListener('click', e => {
|
||||||
const box = e.target.closest('input[data-cid]')
|
const box = e.target.closest('input[data-cid]')
|
||||||
if (!box) return
|
if (!box) return
|
||||||
@@ -613,8 +673,8 @@ async function viewManga(id, langOverride) {
|
|||||||
lastPick = i
|
lastPick = i
|
||||||
updateSummary()
|
updateSummary()
|
||||||
})
|
})
|
||||||
renderDl(download)
|
|
||||||
renderList()
|
renderList()
|
||||||
|
renderDl(download)
|
||||||
renderHd(upscale)
|
renderHd(upscale)
|
||||||
$('#order')?.addEventListener('click', () => { desc = !desc; lastPick = null; store.set('chapterOrderDesc', desc); renderList() })
|
$('#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) })
|
$('#lang')?.addEventListener('change', e => { store.set(`lang:${id}`, e.target.value); viewManga(id, e.target.value) })
|
||||||
|
|||||||
@@ -160,6 +160,7 @@ a.tag:hover, .tag.on { background: var(--accent); color: var(--accent-ink); }
|
|||||||
|
|
||||||
/* Insignias y botón Seguir */
|
/* 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; }
|
.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); }
|
.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; }
|
.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); }
|
.btn.on { border-color: var(--accent); color: var(--accent); }
|
||||||
|
|||||||
+39
-9
@@ -108,6 +108,13 @@ CREATE TABLE IF NOT EXISTS downloaded_chapters (
|
|||||||
CREATE INDEX IF NOT EXISTS idx_dlch_manga ON downloaded_chapters(manga_id);
|
CREATE INDEX IF NOT EXISTS idx_dlch_manga ON downloaded_chapters(manga_id);
|
||||||
`)
|
`)
|
||||||
db.prepare('DELETE FROM sessions WHERE expires_at < ?').run(Date.now())
|
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).
|
// 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')
|
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 (?, ?, ?, ?, ?, ?, ?)`)
|
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())
|
||||||
|
db.prepare('DELETE FROM download_wanted WHERE chapter_id = ?').run(c.id)
|
||||||
upscale.onChapterDownloaded(dl.manga_id, 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)
|
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))
|
.filter(r => fs.existsSync(path.join(dir, r.file))).map(r => r.chapter_id))
|
||||||
const pending = chapters.filter(c => !have.has(c.id))
|
let 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 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 failed = 0
|
||||||
let lastError = null
|
let lastError = null
|
||||||
for (const c of pending) {
|
for (const c of pending) {
|
||||||
@@ -567,31 +581,44 @@ async function pumpDownloads() {
|
|||||||
for (;;) {
|
for (;;) {
|
||||||
const dl = db.prepare("SELECT * FROM downloads WHERE status IN ('downloading', 'queued') ORDER BY status = 'downloading' DESC, created_at LIMIT 1").get()
|
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
|
if (!dl) break
|
||||||
|
requeue.delete(dl.manga_id)
|
||||||
try {
|
try {
|
||||||
await runDownload(dl)
|
await runDownload(dl)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Descarga fallida', dl.manga_id, e)
|
console.error('Descarga fallida', dl.manga_id, e)
|
||||||
if (!isCancelled(dl.manga_id)) dlUpdate(dl.manga_id, { status: 'error', error: e.message })
|
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 {
|
} finally {
|
||||||
pumping = false
|
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)
|
const existing = db.prepare('SELECT * FROM downloads WHERE manga_id = ?').get(mangaId)
|
||||||
if (existing) {
|
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 })
|
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 {
|
} else {
|
||||||
let folder = safeName(title)
|
let folder = safeName(title)
|
||||||
for (let i = 2; db.prepare('SELECT 1 FROM downloads WHERE folder = ?').get(folder); i++) folder = `${safeName(title)} (${i})`
|
for (let i = 2; db.prepare('SELECT 1 FROM downloads WHERE folder = ?').get(folder); i++) folder = `${safeName(title)} (${i})`
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
db.prepare('INSERT INTO downloads (manga_id, title, folder, lang, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)')
|
db.prepare('INSERT INTO downloads (manga_id, title, folder, lang, status, scope, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
|
||||||
.run(mangaId, title, folder, lang, 'queued', now, now)
|
.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()
|
pumpDownloads()
|
||||||
}
|
}
|
||||||
|
const requeue = new Set()
|
||||||
|
|
||||||
const cbzIndexCache = new Map()
|
const cbzIndexCache = new Map()
|
||||||
// hd: usar la versión reescalada si existe.
|
// 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 withCurrent = d => (d ? { ...d, current: ['queued', 'downloading'].includes(d.status) ? dlCurrent.get(d.manga_id) ?? null : null } : null)
|
||||||
const downloadInfo = (mangaId, size = false) => {
|
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 && 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
|
return d
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -787,14 +815,16 @@ async function route(req, res) {
|
|||||||
.map(d => ({ ...withCurrent(d), size: dirSize(mangaDirOf(d.folder)) })))
|
.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], true))
|
||||||
}
|
}
|
||||||
if ((m = /^\/api\/download\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'POST') {
|
if ((m = /^\/api\/download\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'POST') {
|
||||||
const b = await readBody(req)
|
const b = await readBody(req)
|
||||||
const dlLang = m[1].startsWith('lc~') ? 'es' : LANG.test(b.lang || '') ? b.lang : user.lang
|
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
|
const title = String(b.title || '').slice(0, 300) || (await fetchMangaFull(m[1], dlLang)).manga.title
|
||||||
enqueueDownload(m[1], title, dlLang)
|
const ids = Array.isArray(b.chapterIds) ? b.chapterIds.map(String).filter(id => CHAPTER_ID.test(id)).slice(0, 5000) : null
|
||||||
return json(res, 200, downloadInfo(m[1]))
|
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 ((m = /^\/api\/download\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'DELETE') {
|
||||||
if (!deleteDownload(m[1])) throw new HttpError(404, 'No hay copia local')
|
if (!deleteDownload(m[1])) throw new HttpError(404, 'No hay copia local')
|
||||||
|
|||||||
Reference in New Issue
Block a user