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:
2026-10-31 11:24:38 -06:00
co-authored by Claude Opus 5.5
parent bc84a9f6fe
commit aabb87f6c1
4 changed files with 215 additions and 123 deletions
+39 -9
View File
@@ -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')