Categorías, tags clicables, progreso de descarga y eliminar copia local
- 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 <noreply@anthropic.com>
This commit is contained in:
+97
-13
@@ -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-]+)"[^>]*>([^<]+)</g)) {
|
||||
const id = `${m[1]}:${m[2]}`
|
||||
if (seen.has(id)) continue
|
||||
seen.add(id)
|
||||
out.push({ id, name: decode(m[3]), group: m[1] === 'genre' ? 'Géneros' : 'Temáticas' })
|
||||
}
|
||||
return out.sort((a, b) => 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('</form>')))
|
||||
}
|
||||
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(/<br\s*\/?>/g, '\n')),
|
||||
status: fact('Estado'),
|
||||
year: null,
|
||||
tags: [...html.matchAll(/class="badge [^"]*" href="\/manga\/\?(?:genre|theme)=[^"]+">([^<]+)</g)].map(m => decode(m[1])).slice(0, 10),
|
||||
tags: [...html.matchAll(/class="badge [^"]*" href="\/manga\/\?(genre|theme)=([a-z0-9-]+)">([^<]+)</g)].map(m => ({ 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*<img src="([^"]+)"/.exec(html)?.[1]),
|
||||
@@ -394,8 +423,13 @@ async function fetchMangaFull(mangaId, lang) {
|
||||
: { manga: await getManga(mangaId, lang), chapters: await getChapters(mangaId, lang) }
|
||||
}
|
||||
|
||||
// Progreso del capítulo en curso (solo en memoria): manga_id -> { 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(`<?xml version="1.0" encoding="utf-8"?>
|
||||
<ComicInfo xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
|
||||
@@ -414,7 +450,7 @@ async function downloadChapter(dl, manga, c, dir) {
|
||||
${c.title ? `<Title>${xmlEsc(c.title)}</Title>` : ''}
|
||||
<Summary>${xmlEsc(manga.description)}</Summary>
|
||||
<Writer>${xmlEsc(manga.authors.join(', '))}</Writer>
|
||||
<Genre>${xmlEsc(manga.tags.join(', '))}</Genre>
|
||||
<Genre>${xmlEsc(manga.tags.map(t => t.name ?? t).join(', '))}</Genre>
|
||||
<LanguageISO>${xmlEsc((dl.lang || 'es').split('-')[0])}</LanguageISO>
|
||||
<PageCount>${urls.length}</PageCount>
|
||||
<Manga>YesAndRightToLeft</Manga>
|
||||
@@ -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]))
|
||||
|
||||
Reference in New Issue
Block a user