From f294c1cdf424a393f349f372efe5436269134a6f Mon Sep 17 00:00:00 2001 From: Nino Date: Sat, 31 Oct 2026 00:04:09 -0600 Subject: [PATCH] =?UTF-8?q?Categor=C3=ADas,=20tags=20clicables,=20progreso?= =?UTF-8?q?=20de=20descarga=20y=20eliminar=20copia=20local?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Pestaña "Categorías" (géneros/temáticas de LeerCapitulo, tags de MangaDex) con búsqueda dentro de la categoría y "Cargar más". - Los tags de la ficha llevan a su categoría. - Barra de progreso de descarga con el capítulo y la página en curso. - Eliminar la copia local (también a mitad de descarga) desde la ficha y desde la página Descargas, mostrando el espacio ocupado. Co-Authored-By: Claude Opus 5.5 --- app/public/app.js | 120 +++++++++++++++++++++++++++++++++---------- app/public/style.css | 10 ++++ app/server.js | 110 ++++++++++++++++++++++++++++++++++----- 3 files changed, 201 insertions(+), 39 deletions(-) diff --git a/app/public/app.js b/app/public/app.js index a3143f2..c5c72b4 100644 --- a/app/public/app.js +++ b/app/public/app.js @@ -12,6 +12,18 @@ const FILTERS = { vivid: ['Colores vivos', 'saturate(1.45) contrast(1.05)'], } const DL_ACTIVE = ['queued', 'downloading'] +const fmtSize = b => (b >= 1e9 ? `${(b / 1e9).toFixed(1)} GB` : b >= 1e6 ? `${Math.round(b / 1e6)} MB` : `${Math.round((b || 0) / 1e3)} KB`) +// Progreso global contando la fracción del capítulo en curso. +function downloadPct(d) { + if (!d?.total) return 0 + const frac = d.current?.pagesTotal ? d.current.pages / d.current.pagesTotal : 0 + return Math.min(100, ((d.done + frac) / d.total) * 100) +} +function downloadDetail(d) { + const c = d?.current + if (!c) return d?.status === 'queued' ? 'Esperando turno…' : '' + return c.pagesTotal ? `Cap. ${esc(c.chapter ?? '?')} · pág. ${c.pages}/${c.pagesTotal}` : `Cap. ${esc(c.chapter ?? '?')} · preparando…` +} function downloadLabel(d) { if (!d) return '' const n = `${d.done}/${d.total || '?'}` @@ -135,39 +147,73 @@ function continueCard(h) {
${timeAgo(h.updated_at)}
` } +const tagHref = (source, t) => `#/?source=${source}&tag=${encodeURIComponent(t.id)}&tagName=${encodeURIComponent(t.name)}` async function viewHome(params) { const q = params.get('q') || '' - const mode = params.get('mode') || 'popular' + const tag = params.get('tag') || '' + const tagName = params.get('tagName') || tag + const mode = tag ? 'cats' : params.get('mode') || 'popular' const anyLang = params.get('any') === '1' const source = SOURCES[params.get('source')] ? params.get('source') : store.get('source', 'lc') store.set('source', source) + const showCats = mode === 'cats' && !tag app.innerHTML = `
${Object.entries(SOURCES).map(([k, v]) => ``).join('')}
- + - ${q ? `

Resultados para “${esc(q)}”

` : `
`} -
Cargando…
` + ${q && !tag ? `

Resultados para “${esc(q)}”

` : `
+ + +
`} + ${tag ? `

Categoría: ${esc(tagName)} ✕${q ? ` · “${esc(q)}”` : ''}

` : ''} +
Cargando…
+
` const form = $('form', app) const go = (extra = {}) => { - const p = new URLSearchParams({ source, ...(form.q.value ? { q: form.q.value } : { mode }), ...($('[name=any]').checked ? { any: '1' } : {}), ...extra }) + const p = new URLSearchParams({ source, ...(form.q.value ? { q: form.q.value } : { mode }), ...(tag ? { tag, tagName } : {}), ...($('[name=any]').checked ? { any: '1' } : {}), ...extra }) location.hash = `#/?${p}` } form.addEventListener('submit', e => { e.preventDefault(); go() }) $('[name=any]').addEventListener('change', () => go()) app.querySelectorAll('#modes button').forEach(b => b.addEventListener('click', () => { location.hash = `#/?source=${source}&mode=${b.dataset.m}${anyLang ? '&any=1' : ''}` })) - app.querySelectorAll('#sources button').forEach(b => b.addEventListener('click', () => go({ source: b.dataset.s }))) + app.querySelectorAll('#sources button').forEach(b => b.addEventListener('click', () => { location.hash = `#/?source=${b.dataset.s}&mode=${mode}${anyLang ? '&any=1' : ''}` })) api('/api/history').then(hist => { - if (!hist.length || q) return + if (!hist.length || q || tag || showCats) return $('#cont').innerHTML = `

Continuar leyendo

${hist.slice(0, 12).map(continueCard).join('')}
` }).catch(() => {}) - const qs = new URLSearchParams({ q, mode, source, ...(anyLang ? { anyLang: '1' } : {}) }) - const list = await api(`/api/search?${qs}`) const res = $('#results') - res.className = list.length ? 'grid' : 'loading' - res.innerHTML = list.length ? list.map(mangaCard).join('') : 'Sin resultados.' + if (showCats) { + const cats = await api(`/api/categories?source=${source}`) + const groups = cats.reduce((m, c) => m.set(c.group, [...(m.get(c.group) || []), c]), new Map()) + res.className = 'cats' + res.innerHTML = [...groups].map(([g, list]) => `

${esc(g)}

${list.map(t => `${esc(t.name)}`).join('')}
`).join('') + return + } + + // Paginación: MangaDex siempre; LeerCapitulo solo en categorías (sus otros listados no paginan). + const pageable = source === 'md' || !!tag + const seen = new Set() + let page = 1 + const load = async () => { + const qs = new URLSearchParams({ q, mode, source, page, ...(tag ? { tag } : {}), ...(anyLang ? { anyLang: '1' } : {}) }) + const list = (await api(`/api/search?${qs}`)).filter(m => !seen.has(m.id) && seen.add(m.id)) + if (page === 1) { + res.className = list.length ? 'grid' : 'loading' + res.innerHTML = list.length ? '' : 'Sin resultados.' + } + res.insertAdjacentHTML('beforeend', list.map(mangaCard).join('')) + $('#more').hidden = !pageable || list.length < 20 + } + $('#more').addEventListener('click', async e => { + e.target.disabled = true + page++ + try { await load() } catch (err) { toast(err.message) } + e.target.disabled = false + }) + await load() } // ---------- Ficha del manga ---------- @@ -196,16 +242,16 @@ async function viewManga(id, langOverride) {

${esc(manga.title)}

${esc(manga.authors.join(', '))}${manga.year ? ` · ${manga.year}` : ''}${manga.status ? ` · ${esc(manga.status)}` : ''}
-
${manga.tags.map(t => `${esc(t)}`).join('')}
+
${manga.tags.map(t => (t.id ? `${esc(t.name)}` : `${esc(t.name ?? t)}`)).join('')}

${esc(manga.description)}

${mainBtn} - ${progress || reads.length ? '' : ''} ${isLc ? '' : ` Buscar en LeerCapitulo`}
Fuente: ${esc(SOURCES[manga.source])}${download ? ` · Carpeta: mangas/${esc(download.folder)}` : ''}
${offline ? '

Sin conexión con la fuente: mostrando solo los capítulos descargados.

' : ''} +

@@ -220,10 +266,12 @@ async function viewManga(id, langOverride) { const box = $('#dl') if (!box) return const active = d && DL_ACTIVE.includes(d.status) + const delBtn = d ? `` : '' + const bar = d && (active || d.done < d.total) ? `
` : '' let html - if (!d || d.status === 'cancelled') html = `` - else if (active) html = `${downloadLabel(d)}` - else html = `✓ ${downloadLabel(d)}` + if (!d || d.status === 'cancelled') html = `
${delBtn}
${bar}` + else if (active) html = `
${downloadLabel(d)} · ${Math.floor(downloadPct(d))}%${delBtn}
${bar}
${downloadDetail(d)}
` + else html = `
✓ ${downloadLabel(d)}${delBtn}
${bar}` box.innerHTML = offline ? (d ? `${downloadLabel(d)}` : '') : html $('#dlerr').textContent = d?.status === 'error' ? d.error || '' : '' $('#dlgo')?.addEventListener('click', async () => { @@ -231,13 +279,19 @@ async function viewManga(id, langOverride) { toast(`Descargando en mangas/${manga.title}`) }) $('#dlcancel')?.addEventListener('click', async () => renderDl(await api(`/api/download/${id}/cancel`, { method: 'POST' }))) + $('#dldel')?.addEventListener('click', async () => { + if (!confirm(`¿Eliminar la copia local de «${manga.title}»?\nSe borrará la carpeta mangas/${d.folder}${d.size ? ` (${fmtSize(d.size)})` : ''}.\nTu historial de lectura no se toca.`)) return + await api(`/api/download/${id}`, { method: 'DELETE' }) + toast('Copia local eliminada') + viewManga(id, langOverride) + }) clearTimeout(dlTimer) if (active) dlTimer = setTimeout(async () => { const next = await api(`/api/download/${id}`).catch(() => d) if (!$('#dl')) return - if (next && next.done !== d.done) { const fresh = await api(`/api/manga/${id}?lang=${encodeURIComponent(lang)}`).catch(() => null); fresh?.downloaded.forEach(x => dlSet.add(x)); renderList() } + if (next && next.done !== d.done) { const fresh = await api(`/api/manga/${id}?lang=${encodeURIComponent(lang)}`).catch(() => null); fresh?.downloaded.forEach(x => dlSet.add(x)); renderList(); if (fresh?.download) next.size = fresh.download.size } renderDl(next) - }, 3000) + }, 2000) } renderDl(download) cleanup = () => clearTimeout(dlTimer) @@ -468,21 +522,35 @@ async function viewHistory() { // ---------- Descargas ---------- async function viewDownloads() { - app.innerHTML = '
Cargando…
' + if (cleanup) { cleanup(); cleanup = null } const list = await api('/api/downloads') + if (location.hash !== '#/downloads') return + const total = list.reduce((n, d) => n + (d.size || 0), 0) app.innerHTML = `

Descargas

-

Se guardan en /zdata/media/mangas como CBZ (compatibles con Kavita, Komga, Mihon…). Los capítulos descargados se leen desde el disco.

+

Se guardan en /zdata/media/mangas como CBZ (compatibles con Kavita, Komga, Mihon…). Los capítulos descargados se leen desde el disco.${list.length ? ` Total: ${fmtSize(total)}.` : ''}

${list.length ? '' : '

Aún no has descargado nada. Usa “⬇ Descargar” en la ficha de un manga.

'} ` + app.querySelectorAll('[data-cancel]').forEach(b => b.addEventListener('click', async () => { await api(`/api/download/${b.dataset.cancel}/cancel`, { method: 'POST' }); viewDownloads() })) + app.querySelectorAll('[data-del]').forEach(b => b.addEventListener('click', async () => { + if (!confirm(`¿Eliminar la copia local de «${b.dataset.title}» (${fmtSize(Number(b.dataset.size))})?\nTu historial de lectura no se toca.`)) return + await api(`/api/download/${b.dataset.del}`, { method: 'DELETE' }) + toast('Copia local eliminada') + viewDownloads() + })) if (list.some(d => DL_ACTIVE.includes(d.status))) { - const t = setTimeout(() => location.hash === '#/downloads' && viewDownloads(), 4000) + const t = setTimeout(() => location.hash === '#/downloads' && viewDownloads(), 2000) cleanup = () => clearTimeout(t) } } diff --git a/app/public/style.css b/app/public/style.css index 82a5744..f3c6feb 100644 --- a/app/public/style.css +++ b/app/public/style.css @@ -119,3 +119,13 @@ input:focus, select:focus { border-color: var(--accent); } #topbar .brand span { display: none; } #topbar nav a { padding: 6px 8px; } } + +/* Categorías y descargas */ +.cats h3 { font-size: .95rem; color: var(--muted); margin: 18px 0 8px; } +a.tag { cursor: pointer; } +a.tag:hover, .tag.on { background: var(--accent); color: var(--accent-ink); } +.tag.big { font-size: .88rem; padding: 5px 12px; } +.dlbox { display: grid; gap: 6px; margin-top: 10px; max-width: 520px; } +.dlbar { height: 8px; border-radius: 4px; overflow: hidden; } +.dlbar > i { transition: width .6s ease; } +.history .cont a.info { display: block; min-width: 0; flex: 1; } diff --git a/app/server.js b/app/server.js index 93311c8..cd6624f 100644 --- a/app/server.js +++ b/app/server.js @@ -167,7 +167,7 @@ function mangaSummary(m, lang) { description: pick(a.description, langs), status: a.status, year: a.year, - tags: (a.tags || []).map(t => pick(t.attributes.name, ['es', 'en'])).slice(0, 8), + tags: (a.tags || []).map(t => ({ id: t.id, name: pick(t.attributes.name, ['es', 'en']) })).slice(0, 12), authors: (m.relationships || []).filter(r => r.type === 'author' && r.attributes).map(r => r.attributes.name), languages: a.availableTranslatedLanguages || [], cover, @@ -175,9 +175,12 @@ function mangaSummary(m, lang) { } const RATINGS = ['safe', 'suggestive'] -async function searchManga(q, lang, mode) { +const PAGE_SIZE = 30 +async function searchManga(q, lang, mode, tag, page = 1) { const params = { - limit: 30, + limit: PAGE_SIZE, + offset: (page - 1) * PAGE_SIZE, + 'includedTags[]': tag ? [tag] : undefined, 'includes[]': ['cover_art'], 'contentRating[]': RATINGS, 'availableTranslatedLanguage[]': lang ? [lang] : undefined, @@ -189,6 +192,14 @@ async function searchManga(q, lang, mode) { return data.data.map(m => mangaSummary(m, lang)) } +const MD_GROUPS = { genre: 'Géneros', theme: 'Temáticas', format: 'Formato', content: 'Contenido' } +async function mdCategories() { + const data = await md('/manga/tag', {}, 24 * 3600e3) + return data.data + .map(t => ({ id: t.id, name: pick(t.attributes.name, ['es', 'en']), group: MD_GROUPS[t.attributes.group] || t.attributes.group })) + .sort((a, b) => a.group.localeCompare(b.group) || a.name.localeCompare(b.name)) +} + async function getManga(id, lang) { const data = await md(`/manga/${id}`, { 'includes[]': ['cover_art', 'author'] }, 30 * 60e3) return mangaSummary(data.data, lang) @@ -286,7 +297,25 @@ function lcParseCards(html) { } return out } -async function lcSearch(q, mode) { +const LC_TAG = /^(genre|theme):([a-z0-9-]+)$/ +async function lcCategories() { + const html = await lc('/manga/', 24 * 3600e3) + const out = [] + const seen = new Set() + for (const m of html.matchAll(/(?:href|value)="\/manga\/\?(genre|theme)=([a-z0-9-]+)"[^>]*>([^<]+) a.group.localeCompare(b.group) || a.name.localeCompare(b.name)) +} +async function lcSearch(q, mode, tag, page = 1) { + const t = LC_TAG.exec(tag || '') + if (t) { + const html = await lc(`/manga/?${t[1]}=${t[2]}${q ? `&q=${encodeURIComponent(q)}` : ''}${page > 1 ? `&page=${page}` : ''}`) + return lcParseCards(html.slice(html.indexOf(''))) + } if (q) { const list = await lc(`/search/?q=${encodeURIComponent(q)}`, 10 * 60e3, true) return list.map(e => ({ id: lcId(e.uri), source: 'lc', title: decode(e.name), cover: lcCover(e.cover_uri), sub: e.last_chapter_name })) @@ -325,7 +354,7 @@ async function lcManga(id) { description: decode(sinopsis?.replace(//g, '\n')), status: fact('Estado'), year: null, - tags: [...html.matchAll(/class="badge [^"]*" href="\/manga\/\?(?:genre|theme)=[^"]+">([^<]+) decode(m[1])).slice(0, 10), + tags: [...html.matchAll(/class="badge [^"]*" href="\/manga\/\?(genre|theme)=([a-z0-9-]+)">([^<]+) ({ id: `${m[1]}:${m[2]}`, name: decode(m[3]) })).slice(0, 12), authors: fact('Autor') ? [fact('Autor')] : [], languages: ['es'], cover: lcCover(/class="lc-cover-lg">\s* { chapter, pages, pagesTotal } +const dlCurrent = new Map() + async function downloadChapter(dl, manga, c, dir) { + dlCurrent.set(dl.manga_id, { chapter: c.num, pages: 0, pagesTotal: 0 }) const urls = await withRetry(() => (manga.source === 'lc' ? lcPageUrls(c.id) : getPageUrls(c.id, false, 0))) + dlCurrent.set(dl.manga_id, { chapter: c.num, pages: 0, pagesTotal: urls.length }) const entries = [] // 4 imágenes en paralelo. for (let i = 0; i < urls.length; i += 4) { @@ -405,6 +439,8 @@ async function downloadChapter(dl, manga, c, dir) { const ext = EXT_BY_TYPE[type.split(';')[0]] || urls[n].split('?')[0].split('.').pop().toLowerCase().replace(/[^a-z]/g, '') || 'jpg' entries.push({ name: `${String(n + 1).padStart(3, '0')}.${ext}`, data }) }) + dlCurrent.set(dl.manga_id, { chapter: c.num, pages: entries.length, pagesTotal: urls.length }) + if (isCancelled(dl.manga_id)) throw new Error('cancelada') } entries.push({ name: 'ComicInfo.xml', data: Buffer.from(` @@ -414,7 +450,7 @@ async function downloadChapter(dl, manga, c, dir) { ${c.title ? `${xmlEsc(c.title)}` : ''} ${xmlEsc(manga.description)} ${xmlEsc(manga.authors.join(', '))} - ${xmlEsc(manga.tags.join(', '))} + ${xmlEsc(manga.tags.map(t => t.name ?? t).join(', '))} ${xmlEsc((dl.lang || 'es').split('-')[0])} ${urls.length} YesAndRightToLeft @@ -422,6 +458,11 @@ async function downloadChapter(dl, manga, c, dir) { `) }) const file = chapterFileName(c) writeCbz(path.join(dir, file), entries) + if (!db.prepare('SELECT 1 FROM downloads WHERE manga_id = ?').get(dl.manga_id)) { + // La copia local se eliminó mientras se descargaba este capítulo. + fs.rmSync(path.join(dir, file), { force: true }) + throw new Error('eliminada') + } db.prepare(`INSERT OR REPLACE INTO downloaded_chapters (chapter_id, manga_id, num, title, file, pages, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`) .run(c.id, dl.manga_id, c.num ?? null, c.title || '', file, urls.length, Date.now()) } @@ -431,7 +472,28 @@ const dlUpdate = (mangaId, fields) => { db.prepare(`UPDATE downloads SET ${keys.map(k => `${k} = ?`).join(', ')}, updated_at = ? WHERE manga_id = ?`) .run(...keys.map(k => fields[k]), Date.now(), mangaId) } -const isCancelled = mangaId => db.prepare('SELECT status FROM downloads WHERE manga_id = ?').get(mangaId)?.status === 'cancelled' +// Cancelada o eliminada (sin fila) cuentan igual: hay que parar. +const isCancelled = mangaId => (db.prepare('SELECT status FROM downloads WHERE manga_id = ?').get(mangaId)?.status ?? 'cancelled') === 'cancelled' + +function mangaDirOf(folder) { + const dir = path.resolve(MANGA_DIR, folder) + if (!folder || !dir.startsWith(path.resolve(MANGA_DIR) + path.sep)) throw new HttpError(400, 'Carpeta inválida') + return dir +} +function dirSize(dir) { + let total = 0 + try { for (const f of fs.readdirSync(dir)) total += fs.statSync(path.join(dir, f)).size } catch {} + return total +} +function deleteDownload(mangaId) { + const dl = db.prepare('SELECT folder FROM downloads WHERE manga_id = ?').get(mangaId) + if (!dl) return false + const dir = mangaDirOf(dl.folder) + db.prepare('DELETE FROM downloads WHERE manga_id = ?').run(mangaId) // borra también downloaded_chapters (CASCADE) + dlCurrent.delete(mangaId) + fs.rmSync(dir, { recursive: true, force: true }) + return true +} async function runDownload(dl) { const { manga, chapters } = await fetchMangaFull(dl.manga_id, dl.lang) @@ -448,10 +510,11 @@ async function runDownload(dl) { let failed = 0 let lastError = null for (const c of pending) { - if (isCancelled(dl.manga_id)) return + if (isCancelled(dl.manga_id)) break try { await downloadChapter(dl, manga, c, dir) } catch (e) { + if (isCancelled(dl.manga_id)) break failed++ lastError = `Cap. ${c.num ?? '?'}: ${e.message}` console.warn('Descarga', manga.title, lastError) @@ -461,6 +524,11 @@ async function runDownload(dl) { // MangaDex limita /at-home/server a 40 peticiones por minuto. await sleep(manga.source === 'lc' ? 500 : 1600) } + dlCurrent.delete(dl.manga_id) + if (isCancelled(dl.manga_id)) { + if (!db.prepare('SELECT 1 FROM downloads WHERE manga_id = ?').get(dl.manga_id)) fs.rmSync(dir, { recursive: true, force: true }) + return + } dlUpdate(dl.manga_id, { status: failed ? 'error' : 'done', error: failed ? `${failed} capítulo(s) fallaron. Último error: ${lastError}` : null }) } @@ -512,7 +580,12 @@ function localChapter(chapterId) { } return cbzIndexCache.get(key) } -const downloadInfo = mangaId => db.prepare('SELECT manga_id, title, folder, lang, status, done, total, failed, error, updated_at FROM downloads WHERE manga_id = ?').get(mangaId) ?? null +const withCurrent = d => (d ? { ...d, current: ['queued', 'downloading'].includes(d.status) ? dlCurrent.get(d.manga_id) ?? null : null } : null) +const downloadInfo = (mangaId, size = false) => { + const d = withCurrent(db.prepare('SELECT manga_id, title, folder, lang, status, done, total, failed, error, updated_at FROM downloads WHERE manga_id = ?').get(mangaId)) + if (d && size) d.size = dirSize(mangaDirOf(d.folder)) + return d +} // ---------- HTTP ---------- class HttpError extends Error { @@ -621,10 +694,16 @@ async function route(req, res) { const lang = LANG.test(q.get('lang') || '') ? q.get('lang') : user.lang + if (p === '/api/categories' && method === 'GET') { + return json(res, 200, q.get('source') === 'lc' ? await lcCategories() : await mdCategories()) + } if (p === '/api/search' && method === 'GET') { - if (q.get('source') === 'lc') return json(res, 200, await lcSearch((q.get('q') || '').slice(0, 100), q.get('mode'))) + const page = Math.min(Math.max(Number(q.get('page')) | 0, 1), 500) + const tag = q.get('tag') || '' + if (q.get('source') === 'lc') return json(res, 200, await lcSearch((q.get('q') || '').slice(0, 100), q.get('mode'), tag, page)) const anyLang = q.get('anyLang') === '1' - return json(res, 200, await searchManga((q.get('q') || '').slice(0, 100), anyLang ? null : lang, q.get('mode'))) + const mdTag = /^[0-9a-f-]{36}$/.test(tag) ? tag : null + return json(res, 200, await searchManga((q.get('q') || '').slice(0, 100), anyLang ? null : lang, q.get('mode'), mdTag, page)) } let m @@ -646,7 +725,7 @@ async function route(req, res) { const reads = db.prepare('SELECT chapter_id, page, total_pages FROM chapter_reads WHERE user_id = ? AND manga_id = ?').all(user.id, m[1]) const progress = db.prepare('SELECT * FROM progress WHERE user_id = ? AND manga_id = ?').get(user.id, m[1]) ?? null const chapterLangs = chapters.length || manga.source === 'lc' || offline ? [] : await getChapterLangs(m[1]).catch(() => []) - return json(res, 200, { manga, chapters, lang, reads, progress, chapterLangs, offline, downloaded, download: downloadInfo(m[1]) }) + return json(res, 200, { manga, chapters, lang, reads, progress, chapterLangs, offline, downloaded, download: downloadInfo(m[1], true) }) } if ((m = /^\/api\/chapter\/([^/]+)$/.exec(p)) && CHAPTER_ID.test(m[1]) && method === 'GET') { const local = localChapter(m[1]) @@ -672,7 +751,8 @@ async function route(req, res) { // --- Descargas --- if (p === '/api/downloads' && method === 'GET') { - return json(res, 200, db.prepare('SELECT manga_id, title, folder, lang, status, done, total, failed, error, updated_at FROM downloads ORDER BY updated_at DESC').all()) + return json(res, 200, db.prepare('SELECT manga_id, title, folder, lang, status, done, total, failed, error, updated_at FROM downloads ORDER BY updated_at DESC').all() + .map(d => ({ ...withCurrent(d), size: dirSize(mangaDirOf(d.folder)) }))) } if ((m = /^\/api\/download\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'GET') { return json(res, 200, downloadInfo(m[1])) @@ -684,6 +764,10 @@ async function route(req, res) { enqueueDownload(m[1], title, dlLang) return json(res, 200, downloadInfo(m[1])) } + if ((m = /^\/api\/download\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'DELETE') { + if (!deleteDownload(m[1])) throw new HttpError(404, 'No hay copia local') + return json(res, 200, { ok: true }) + } if ((m = /^\/api\/download\/([^/]+)\/cancel$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'POST') { db.prepare("UPDATE downloads SET status = 'cancelled', updated_at = ? WHERE manga_id = ? AND status IN ('queued', 'downloading')").run(Date.now(), m[1]) return json(res, 200, downloadInfo(m[1]))