Descarga de mangas completos a /zdata/media/mangas en CBZ

- Cola de descargas en segundo plano (se reanuda tras reinicios; repetirla
  solo baja capítulos nuevos; se puede cancelar).
- <Título>/Capitulo NNNN.cbz con ComicInfo.xml + cover.jpg (Kavita/Komga/Mihon).
- Los capítulos descargados se leen desde disco; sin conexión la ficha usa
  los datos guardados.
- Página "Descargas" y marcas ⬇ en la lista de capítulos.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-30 23:43:13 -06:00
co-authored by Claude Opus 5.5
parent 57a37c67c0
commit fea5ec0c65
6 changed files with 440 additions and 19 deletions
+248 -16
View File
@@ -5,9 +5,11 @@ import fs from 'node:fs'
import path from 'node:path'
import crypto from 'node:crypto'
import { DatabaseSync } from 'node:sqlite'
import { writeCbz, readCbzIndex, sendCbzEntry, imageType } from './cbz.js'
const PORT = Number(process.env.PORT || 3352)
const DATA_DIR = process.env.DATA_DIR || '/app/data'
const MANGA_DIR = process.env.MANGA_DIR || '/mangas'
const ALLOW_SIGNUP = (process.env.ALLOW_SIGNUP ?? 'true') !== 'false'
const PUBLIC_DIR = path.resolve(import.meta.dirname, 'public')
const MD_API = 'https://api.mangadex.org'
@@ -61,6 +63,30 @@ CREATE TABLE IF NOT EXISTS chapter_reads (
PRIMARY KEY (user_id, chapter_id)
);
CREATE INDEX IF NOT EXISTS idx_reads_manga ON chapter_reads(user_id, manga_id);
CREATE TABLE IF NOT EXISTS downloads (
manga_id TEXT PRIMARY KEY,
title TEXT NOT NULL,
folder TEXT NOT NULL UNIQUE,
lang TEXT,
status TEXT NOT NULL, -- queued | downloading | done | error | cancelled
done INTEGER NOT NULL DEFAULT 0,
total INTEGER NOT NULL DEFAULT 0,
failed INTEGER NOT NULL DEFAULT 0,
error TEXT,
meta TEXT, -- JSON de la ficha, para abrirla sin conexión
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS downloaded_chapters (
chapter_id TEXT PRIMARY KEY,
manga_id TEXT NOT NULL REFERENCES downloads(manga_id) ON DELETE CASCADE,
num TEXT,
title TEXT,
file TEXT NOT NULL,
pages INTEGER NOT NULL,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_dlch_manga ON downloaded_chapters(manga_id);
`)
db.prepare('DELETE FROM sessions WHERE expires_at < ?').run(Date.now())
@@ -113,7 +139,7 @@ async function md(pathname, params = {}, ttl = 5 * 60e3) {
else if (v !== undefined && v !== '') url.searchParams.set(k, v)
}
return cached(url.toString(), ttl, async () => {
const res = await fetch(url, { headers: { 'User-Agent': UA } })
const res = await fetch(url, { headers: { 'User-Agent': UA } }).catch(() => { throw new HttpError(502, 'No se pudo conectar con MangaDex') })
if (!res.ok) throw new HttpError(502, `MangaDex respondió ${res.status}`)
return res.json()
})
@@ -217,14 +243,15 @@ async function getChapterLangs(mangaId) {
return Object.entries(count).sort((a, b) => b[1] - a[1]).map(([l]) => l)
}
async function getPages(chapterId, saver) {
async function getPageUrls(chapterId, saver, ttl = 5 * 60e3) {
// Las URLs de at-home caducan a los ~15 min: caché corta.
const data = await md(`/at-home/server/${chapterId}`, {}, 5 * 60e3)
const data = await md(`/at-home/server/${chapterId}`, {}, ttl)
const { baseUrl, chapter } = data
const files = saver ? chapter.dataSaver : chapter.data
const kind = saver ? 'data-saver' : 'data'
return files.map(f => imgUrl(`${baseUrl}/${kind}/${chapter.hash}/${f}`))
return files.map(f => `${baseUrl}/${kind}/${chapter.hash}/${f}`)
}
const getPages = async (chapterId, saver) => (await getPageUrls(chapterId, saver)).map(imgUrl)
// ---------- Cliente LeerCapitulo (Tanko src/servers/leerCapitulo.ts, adaptado al sitio actual) ----------
// IDs: manga "lc~<id>~<slug>", capítulo "lc~<id>~<slug>~<número>". Ya no hace falta navegador:
@@ -237,6 +264,7 @@ const decode = s => String(s ?? '').replace(/<[^>]+>/g, '').replace(/&(#x?[0-9a-
async function lc(pathname, ttl = 10 * 60e3, asJson = false) {
return cached(`lc:${pathname}`, ttl, async () => {
const res = await fetch(LC_BASE + pathname, { headers: { 'User-Agent': BROWSER_UA, 'Accept-Language': 'es' } })
.catch(() => { throw new HttpError(502, 'No se pudo conectar con LeerCapitulo') })
if (!res.ok) throw new HttpError(res.status === 404 ? 404 : 502, `LeerCapitulo respondió ${res.status}`)
return asJson ? res.json() : res.text()
})
@@ -304,14 +332,15 @@ async function lcManga(id) {
}
return { manga, chapters }
}
async function lcPages(chapterId) {
async function lcPageUrls(chapterId) {
const [, mid, slug, num] = chapterId.split('~')
const html = await lc(`/leer/${mid}/${slug}/${num}/`, 30 * 60e3)
const main = html.slice(html.indexOf('id="lcPages"'))
const pages = [...main.matchAll(/<img data-src="(https?:\/\/[^"]+)"/g)].map(m => imgUrl(decode(m[1])))
const pages = [...main.matchAll(/<img data-src="(https?:\/\/[^"]+)"/g)].map(m => decode(m[1]))
if (!pages.length) throw new HttpError(502, 'No se encontraron páginas en LeerCapitulo')
return pages
}
const lcPages = async chapterId => (await lcPageUrls(chapterId)).map(imgUrl)
// Proxy de imágenes: MangaDex bloquea el hotlinking de portadas y así el navegador no depende de CDNs ajenos.
async function proxyImage(req, res, u, s) {
@@ -320,8 +349,7 @@ async function proxyImage(req, res, u, s) {
const expected = sign(u)
if (typeof s !== 'string' || s.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected))) throw new HttpError(403, 'Firma inválida')
if (!/^https?:$/.test(url.protocol) || /^(localhost|\d+\.\d+\.\d+\.\d+|\[.*\])$/.test(url.hostname)) throw new HttpError(403, 'Host no permitido')
const isMd = /(^|\.)mangadex\.(org|network)$/.test(url.hostname)
const r = await fetch(url, { headers: { 'User-Agent': isMd ? UA : BROWSER_UA, ...(isMd ? {} : { Referer: LC_BASE + '/' }) } })
const r = await fetchImage(url)
if (!r.ok || !r.body) throw new HttpError(502, `Imagen ${r.status}`)
res.writeHead(200, {
'Content-Type': r.headers.get('content-type') || 'image/jpeg',
@@ -331,6 +359,161 @@ async function proxyImage(req, res, u, s) {
res.end()
}
function fetchImage(url, signal) {
const isMd = /(^|\.)mangadex\.(org|network)$/.test(new URL(url).hostname)
return fetch(url, { signal, headers: { 'User-Agent': isMd ? UA : BROWSER_UA, ...(isMd ? {} : { Referer: LC_BASE + '/' }) } })
}
// ---------- Descargas a MANGA_DIR/<Título>/Capitulo NNNN.cbz ----------
const sleep = ms => new Promise(r => setTimeout(r, ms))
const safeName = s => String(s).normalize('NFC').replace(/[\/\\:*?"<>|\x00-\x1f]/g, ' ').replace(/\s+/g, ' ').replace(/^[.\s]+|[.\s]+$/g, '').slice(0, 150) || 'Sin titulo'
const xmlEsc = s => String(s ?? '').replace(/[<>&"']/g, c => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&apos;' }[c]))
const rawFromProxy = u => (u?.startsWith('/api/img?') ? new URLSearchParams(u.slice(9)).get('u') : null)
const chapterFileName = c => {
if (!c.num) return `Oneshot ${c.id.slice(-8)}.cbz`
const [int, dec] = String(c.num).split('.')
return safeName(`Capitulo ${int.padStart(4, '0')}${dec ? '.' + dec : ''}`) + '.cbz'
}
async function withRetry(fn, tries = 3) {
for (let i = 1; ; i++) {
try { return await fn() } catch (e) { if (i >= tries) throw e; await sleep(1500 * i) }
}
}
async function downloadBuffer(url) {
return withRetry(async () => {
const r = await fetchImage(url, AbortSignal.timeout(60e3))
if (!r.ok) throw new Error(`HTTP ${r.status} en ${url}`)
return { data: Buffer.from(await r.arrayBuffer()), type: r.headers.get('content-type') || '' }
})
}
const EXT_BY_TYPE = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/webp': 'webp', 'image/gif': 'gif', 'image/avif': 'avif' }
async function fetchMangaFull(mangaId, lang) {
return mangaId.startsWith('lc~')
? lcManga(mangaId)
: { manga: await getManga(mangaId, lang), chapters: await getChapters(mangaId, lang) }
}
async function downloadChapter(dl, manga, c, dir) {
const urls = await withRetry(() => (manga.source === 'lc' ? lcPageUrls(c.id) : getPageUrls(c.id, false, 0)))
const entries = []
// 4 imágenes en paralelo.
for (let i = 0; i < urls.length; i += 4) {
const batch = await Promise.all(urls.slice(i, i + 4).map(downloadBuffer))
batch.forEach(({ data, type }, k) => {
const n = i + k
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 })
})
}
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">
<Series>${xmlEsc(manga.title)}</Series>
${c.num ? `<Number>${xmlEsc(c.num)}</Number>` : ''}
${c.volume ? `<Volume>${xmlEsc(c.volume)}</Volume>` : ''}
${c.title ? `<Title>${xmlEsc(c.title)}</Title>` : ''}
<Summary>${xmlEsc(manga.description)}</Summary>
<Writer>${xmlEsc(manga.authors.join(', '))}</Writer>
<Genre>${xmlEsc(manga.tags.join(', '))}</Genre>
<LanguageISO>${xmlEsc((dl.lang || 'es').split('-')[0])}</LanguageISO>
<PageCount>${urls.length}</PageCount>
<Manga>YesAndRightToLeft</Manga>
</ComicInfo>
`) })
const file = chapterFileName(c)
writeCbz(path.join(dir, file), entries)
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())
}
const dlUpdate = (mangaId, fields) => {
const keys = Object.keys(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'
async function runDownload(dl) {
const { manga, chapters } = await fetchMangaFull(dl.manga_id, dl.lang)
const dir = path.join(MANGA_DIR, dl.folder)
fs.mkdirSync(dir, { recursive: true })
const coverRaw = rawFromProxy(manga.cover)
if (coverRaw && !fs.existsSync(path.join(dir, 'cover.jpg'))) {
try { fs.writeFileSync(path.join(dir, 'cover.jpg'), (await downloadBuffer(coverRaw)).data) } catch (e) { console.warn('Portada:', e.message) }
}
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 failed = 0
let lastError = null
for (const c of pending) {
if (isCancelled(dl.manga_id)) return
try {
await downloadChapter(dl, manga, c, dir)
} catch (e) {
failed++
lastError = `Cap. ${c.num ?? '?'}: ${e.message}`
console.warn('Descarga', manga.title, lastError)
}
const done = db.prepare('SELECT COUNT(*) n FROM downloaded_chapters WHERE manga_id = ?').get(dl.manga_id).n
dlUpdate(dl.manga_id, { done, failed, error: lastError })
// MangaDex limita /at-home/server a 40 peticiones por minuto.
await sleep(manga.source === 'lc' ? 500 : 1600)
}
dlUpdate(dl.manga_id, { status: failed ? 'error' : 'done', error: failed ? `${failed} capítulo(s) fallaron. Último error: ${lastError}` : null })
}
let pumping = false
async function pumpDownloads() {
if (pumping) return
pumping = true
try {
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
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 })
}
}
} finally {
pumping = false
}
}
function enqueueDownload(mangaId, title, lang) {
const existing = db.prepare('SELECT * FROM downloads WHERE manga_id = ?').get(mangaId)
if (existing) {
if (!['queued', 'downloading'].includes(existing.status)) dlUpdate(mangaId, { status: 'queued', lang: lang || existing.lang, error: null })
} 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)
}
pumpDownloads()
}
const cbzIndexCache = new Map()
function localChapter(chapterId) {
const row = db.prepare(`SELECT c.file, d.folder FROM downloaded_chapters c JOIN downloads d ON d.manga_id = c.manga_id WHERE c.chapter_id = ?`).get(chapterId)
if (!row) return null
const file = path.join(MANGA_DIR, row.folder, row.file)
let st
try { st = fs.statSync(file) } catch { return null }
const key = `${file}:${st.mtimeMs}`
if (!cbzIndexCache.has(key)) {
cbzIndexCache.set(key, { file, entries: readCbzIndex(file) })
if (cbzIndexCache.size > 100) cbzIndexCache.delete(cbzIndexCache.keys().next().value)
}
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
// ---------- HTTP ----------
class HttpError extends Error {
constructor(status, message) { super(message); this.status = status }
@@ -446,18 +629,64 @@ async function route(req, res) {
let m
if ((m = /^\/api\/manga\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'GET') {
const { manga, chapters } = m[1].startsWith('lc~')
? await lcManga(m[1])
: { manga: await getManga(m[1], lang), chapters: await getChapters(m[1], lang) }
let manga, chapters, offline = false
try {
({ manga, chapters } = await fetchMangaFull(m[1], lang))
} catch (e) {
// Sin conexión con la fuente: usar la ficha guardada y solo los capítulos descargados.
const dl = db.prepare('SELECT meta FROM downloads WHERE manga_id = ? AND meta IS NOT NULL').get(m[1])
if (!dl) throw e
const saved = JSON.parse(dl.meta)
const have = new Set(db.prepare('SELECT chapter_id FROM downloaded_chapters WHERE manga_id = ?').all(m[1]).map(r => r.chapter_id))
manga = { ...saved.manga, cover: `/api/local-cover/${m[1]}` }
chapters = saved.chapters.filter(c => have.has(c.id))
offline = true
}
const downloaded = db.prepare('SELECT chapter_id FROM downloaded_chapters WHERE manga_id = ?').all(m[1]).map(r => r.chapter_id)
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' ? [] : await getChapterLangs(m[1])
return json(res, 200, { manga, chapters, lang, reads, progress, chapterLangs })
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]) })
}
if ((m = /^\/api\/chapter\/([^/]+)$/.exec(p)) && CHAPTER_ID.test(m[1]) && method === 'GET') {
const pages = m[1].startsWith('lc~') ? await lcPages(m[1]) : await getPages(m[1], q.get('saver') === '1')
const local = localChapter(m[1])
const pages = local
? local.entries.map((_, i) => `/api/local/${m[1]}/${i}`)
: m[1].startsWith('lc~') ? await lcPages(m[1]) : await getPages(m[1], q.get('saver') === '1')
const read = db.prepare('SELECT page FROM chapter_reads WHERE user_id = ? AND chapter_id = ?').get(user.id, m[1])
return json(res, 200, { pages, savedPage: read?.page ?? 0 })
return json(res, 200, { pages, savedPage: read?.page ?? 0, local: !!local })
}
if ((m = /^\/api\/local\/([^/]+)\/(\d+)$/.exec(p)) && CHAPTER_ID.test(m[1]) && method === 'GET') {
const local = localChapter(m[1])
const entry = local?.entries[Number(m[2])]
if (!entry) throw new HttpError(404, 'Página no descargada')
return sendCbzEntry(res, local.file, entry)
}
if ((m = /^\/api\/local-cover\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'GET') {
const dl = downloadInfo(m[1])
const file = dl && path.join(MANGA_DIR, dl.folder, 'cover.jpg')
if (!file || !fs.existsSync(file)) throw new HttpError(404, 'Sin portada')
res.writeHead(200, { 'Content-Type': imageType(file), 'Cache-Control': 'private, max-age=86400' })
return fs.createReadStream(file).pipe(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())
}
if ((m = /^\/api\/download\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'GET') {
return json(res, 200, downloadInfo(m[1]))
}
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]))
}
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]))
}
// --- Historial ---
@@ -507,4 +736,7 @@ http.createServer(async (req, res) => {
if (!res.headersSent) json(res, status, { error: err.message || 'Error' })
else res.end()
}
}).listen(PORT, () => console.log(`Manga Reader escuchando en :${PORT} (registro ${ALLOW_SIGNUP ? 'abierto' : 'cerrado'})`))
}).listen(PORT, () => {
console.log(`Manga Reader escuchando en :${PORT} (registro ${ALLOW_SIGNUP ? 'abierto' : 'cerrado'}, descargas en ${MANGA_DIR})`)
pumpDownloads() // reanuda descargas pendientes tras un reinicio
})