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
+4
View File
@@ -11,6 +11,10 @@ Lector web de manga autoalojado, con las dos fuentes de
- Lector por página (toques/flechas, izq→der o der→izq) o en vertical, modo ahorro de datos.
- Usuarios con contraseña; el **historial y la página exacta** se guardan en el servidor
(`data/manga.db`), así que puedes seguir desde cualquier dispositivo/sesión.
- **Descargas**: botón "⬇ Descargar" en la ficha. Baja el manga entero en segundo plano a
`/zdata/media/mangas/<Título>/Capitulo NNNN.cbz` (+ `cover.jpg`, `ComicInfo.xml` en cada CBZ),
compatible con Kavita/Komga/Mihon. Se reanuda tras reinicios; repetirla baja solo los capítulos
nuevos. Los capítulos descargados se leen desde disco y, sin conexión, la ficha usa los datos guardados.
- Las imágenes pasan por el servidor (`/api/img`), solo con URLs firmadas por él (clave en `data/secret`).
## Uso
+115
View File
@@ -0,0 +1,115 @@
// Lectura/escritura mínima de CBZ (ZIP) sin dependencias.
// Escribe sin compresión (las imágenes ya vienen comprimidas); lee entradas "stored" y "deflate".
import fs from 'node:fs'
import zlib from 'node:zlib'
const IMAGE_EXT = /\.(jpe?g|png|webp|gif|avif)$/i
// entries: [{ name, data: Buffer }]. Escribe a un temporal y renombra para no dejar CBZ a medias.
export function writeCbz(file, entries) {
const parts = []
const central = []
let offset = 0
for (const { name, data } of entries) {
const nameBuf = Buffer.from(name, 'utf8')
const crc = zlib.crc32(data)
const local = Buffer.alloc(30)
local.writeUInt32LE(0x04034b50, 0)
local.writeUInt16LE(20, 4) // versión necesaria
local.writeUInt16LE(0x0800, 6) // nombres en UTF-8
local.writeUInt16LE(0, 8) // sin compresión
local.writeUInt16LE(0, 10) // hora DOS
local.writeUInt16LE(0x21, 12) // fecha DOS (1980-01-01)
local.writeUInt32LE(crc, 14)
local.writeUInt32LE(data.length, 18)
local.writeUInt32LE(data.length, 22)
local.writeUInt16LE(nameBuf.length, 26)
local.writeUInt16LE(0, 28)
parts.push(local, nameBuf, data)
const cd = Buffer.alloc(46)
cd.writeUInt32LE(0x02014b50, 0)
cd.writeUInt16LE(20, 4)
cd.writeUInt16LE(20, 6)
cd.writeUInt16LE(0x0800, 8)
cd.writeUInt16LE(0, 10)
cd.writeUInt16LE(0, 12)
cd.writeUInt16LE(0x21, 14)
cd.writeUInt32LE(crc, 16)
cd.writeUInt32LE(data.length, 20)
cd.writeUInt32LE(data.length, 24)
cd.writeUInt16LE(nameBuf.length, 28)
cd.writeUInt32LE(offset, 42)
central.push(cd, nameBuf)
offset += 30 + nameBuf.length + data.length
}
const cdSize = central.reduce((n, b) => n + b.length, 0)
const end = Buffer.alloc(22)
end.writeUInt32LE(0x06054b50, 0)
end.writeUInt16LE(entries.length, 8)
end.writeUInt16LE(entries.length, 10)
end.writeUInt32LE(cdSize, 12)
end.writeUInt32LE(offset, 16)
const tmp = `${file}.part`
fs.writeFileSync(tmp, Buffer.concat([...parts, ...central, end]))
fs.renameSync(tmp, file)
}
// Devuelve las imágenes del CBZ ordenadas por nombre: [{ name, method, start, size }]
export function readCbzIndex(file) {
const fd = fs.openSync(file, 'r')
try {
const { size } = fs.fstatSync(fd)
const tailLen = Math.min(size, 65557)
const tail = Buffer.alloc(tailLen)
fs.readSync(fd, tail, 0, tailLen, size - tailLen)
const eocd = tail.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06]))
if (eocd < 0) throw new Error('CBZ inválido')
const count = tail.readUInt16LE(eocd + 10)
const cdSize = tail.readUInt32LE(eocd + 12)
const cdOffset = tail.readUInt32LE(eocd + 16)
const cd = Buffer.alloc(cdSize)
fs.readSync(fd, cd, 0, cdSize, cdOffset)
const entries = []
const lh = Buffer.alloc(30)
for (let p = 0, i = 0; i < count; i++) {
const method = cd.readUInt16LE(p + 10)
const compSize = cd.readUInt32LE(p + 20)
const nameLen = cd.readUInt16LE(p + 28)
const extraLen = cd.readUInt16LE(p + 30)
const commentLen = cd.readUInt16LE(p + 32)
const localOffset = cd.readUInt32LE(p + 42)
const name = cd.toString('utf8', p + 46, p + 46 + nameLen)
p += 46 + nameLen + extraLen + commentLen
if (!IMAGE_EXT.test(name) || name.startsWith('__MACOSX')) continue
fs.readSync(fd, lh, 0, 30, localOffset)
const start = localOffset + 30 + lh.readUInt16LE(26) + lh.readUInt16LE(28)
entries.push({ name, method, start, size: compSize })
}
return entries.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }))
} finally {
fs.closeSync(fd)
}
}
export function imageType(name) {
const ext = name.split('.').pop().toLowerCase()
return { jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', webp: 'image/webp', gif: 'image/gif', avif: 'image/avif' }[ext] || 'application/octet-stream'
}
// Envía una entrada del CBZ por la respuesta HTTP.
export function sendCbzEntry(res, file, entry) {
const headers = { 'Content-Type': imageType(entry.name), 'Cache-Control': 'private, max-age=604800' }
if (entry.method === 0) {
res.writeHead(200, { ...headers, 'Content-Length': entry.size })
fs.createReadStream(file, { start: entry.start, end: entry.start + entry.size - 1 }).pipe(res)
return
}
if (entry.method !== 8) throw new Error(`Compresión ZIP no soportada (${entry.method})`)
const fd = fs.openSync(file, 'r')
const buf = Buffer.alloc(entry.size)
try { fs.readSync(fd, buf, 0, entry.size, entry.start) } finally { fs.closeSync(fd) }
res.writeHead(200, headers)
res.end(zlib.inflateRawSync(buf))
}
+69 -3
View File
@@ -11,6 +11,18 @@ const FILTERS = {
contrast: ['Alto contraste', 'contrast(1.35) brightness(1.03)'],
vivid: ['Colores vivos', 'saturate(1.45) contrast(1.05)'],
}
const DL_ACTIVE = ['queued', 'downloading']
function downloadLabel(d) {
if (!d) return ''
const n = `${d.done}/${d.total || '?'}`
return {
queued: 'En cola…',
downloading: `Descargando ${n}`,
done: `Descargado (${d.done} caps.)`,
error: `Descarga incompleta ${n}`,
cancelled: `Descarga cancelada ${n}`,
}[d.status] || d.status
}
const LANGS = { 'es-la': 'Español (Latam)', es: 'Español (España)', en: 'English', 'pt-br': 'Português (BR)', fr: 'Français', it: 'Italiano', ja: '日本語' }
let me = null
let cleanup = null
@@ -70,6 +82,7 @@ async function router() {
if (parts[0] === 'manga') return await viewManga(parts[1])
if (parts[0] === 'read') return await viewReader(parts[1], parts[2], Number(params.get('page') || 0) - 1)
if (parts[0] === 'history') return await viewHistory()
if (parts[0] === 'downloads') return await viewDownloads()
if (parts[0] === 'settings') return viewSettings()
return await viewHome(params)
} catch (e) {
@@ -159,9 +172,11 @@ async function viewHome(params) {
// ---------- Ficha del manga ----------
async function viewManga(id, langOverride) {
if (cleanup) { cleanup(); cleanup = null }
app.innerHTML = '<div class="loading">Cargando…</div>'
const lang = langOverride || store.get(`lang:${id}`, me.lang)
const { manga, chapters, reads, progress, chapterLangs } = await api(`/api/manga/${id}?lang=${encodeURIComponent(lang)}`)
const { manga, chapters, reads, progress, chapterLangs, offline, downloaded, download } = await api(`/api/manga/${id}?lang=${encodeURIComponent(lang)}`)
const dlSet = new Set(downloaded)
const readMap = new Map(reads.map(r => [r.chapter_id, r]))
const isLc = manga.source === 'lc'
const langOpts = [...new Set([lang, me.lang, ...chapterLangs, ...manga.languages])].filter(Boolean)
@@ -184,11 +199,14 @@ async function viewManga(id, langOverride) {
<div class="tags">${manga.tags.map(t => `<span class="tag">${esc(t)}</span>`).join('')}</div>
<p class="desc">${esc(manga.description)}</p>
<div class="row">${mainBtn}
<span id="dl" class="row"></span>
${progress || reads.length ? '<button class="btn danger" id="drop">Dejar de leer</button>' : ''}
${isLc ? '' : `<select id="lang">${langOpts.map(l => `<option value="${esc(l)}" ${l === lang ? 'selected' : ''}>${esc(LANGS[l] || l)}</option>`).join('')}</select>
<a class="btn" href="${altSearch}">Buscar en LeerCapitulo</a>`}
</div>
<div class="muted" style="font-size:.85rem;margin-top:6px">Fuente: ${esc(SOURCES[manga.source])}</div>
<div class="muted" style="font-size:.85rem;margin-top:6px">Fuente: ${esc(SOURCES[manga.source])}${download ? ` · Carpeta: mangas/${esc(download.folder)}` : ''}</div>
${offline ? '<p class="error">Sin conexión con la fuente: mostrando solo los capítulos descargados.</p>' : ''}
<p class="error" id="dlerr" style="font-size:.85rem"></p>
</div>
</div>
<h2>Capítulos <span class="muted">(${chapters.length})</span>
@@ -196,6 +214,33 @@ async function viewManga(id, langOverride) {
${chapters.length ? '<ul class="chapters"></ul>' : isLc ? '<p class="muted">Este manga no tiene capítulos.</p>' : `<p class="muted">No hay capítulos legibles en ${esc(LANGS[lang] || lang)} (MangaDex retira algunos por licencia).
Prueba a <a class="tag" href="${altSearch}">buscarlo en LeerCapitulo</a>.
${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>`}`
// Botón de descarga con sondeo del progreso mientras está activa.
let dlTimer = null
const renderDl = d => {
const box = $('#dl')
if (!box) return
const active = d && DL_ACTIVE.includes(d.status)
let html
if (!d || d.status === 'cancelled') html = `<button class="btn" id="dlgo">⬇ Descargar${d ? ` (llevas ${d.done}/${d.total})` : ''}</button>`
else if (active) html = `<span class="muted">${downloadLabel(d)}</span><button class="btn small" id="dlcancel">Cancelar</button>`
else html = `<span class="muted">✓ ${downloadLabel(d)}</span><button class="btn small" id="dlgo" title="Descarga los capítulos que falten">${d.status === 'error' ? 'Reintentar' : 'Buscar nuevos'}</button>`
box.innerHTML = offline ? (d ? `<span class="muted">${downloadLabel(d)}</span>` : '') : html
$('#dlerr').textContent = d?.status === 'error' ? d.error || '' : ''
$('#dlgo')?.addEventListener('click', async () => {
renderDl(await api(`/api/download/${id}`, { method: 'POST', body: { lang, title: manga.title } }))
toast(`Descargando en mangas/${manga.title}`)
})
$('#dlcancel')?.addEventListener('click', async () => renderDl(await api(`/api/download/${id}/cancel`, { method: 'POST' })))
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() }
renderDl(next)
}, 3000)
}
renderDl(download)
cleanup = () => clearTimeout(dlTimer)
$('#drop')?.addEventListener('click', async () => {
if (!confirm(`¿Dejar de leer «${manga.title}»?\nSe quitará del historial y se borrará el progreso de sus capítulos.`)) return
await api(`/api/history/${id}`, { method: 'DELETE' })
@@ -213,7 +258,7 @@ async function viewManga(id, langOverride) {
const r = readMap.get(c.id)
const done = r && r.total_pages && r.page >= r.total_pages - 1
const cls = progress?.chapter_id === c.id ? 'current' : done ? 'read' : ''
const meta = r ? (done ? 'Leído' : `pág. ${r.page + 1}/${r.total_pages}`) : esc(c.group)
const meta = (dlSet.has(c.id) ? '<span title="Descargado">⬇</span> ' : '') + (r ? (done ? 'Leído' : `pág. ${r.page + 1}/${r.total_pages}`) : esc(c.group))
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">${meta}</span></a></li>`
}).join('')
}
@@ -421,6 +466,27 @@ async function viewHistory() {
}))
}
// ---------- Descargas ----------
async function viewDownloads() {
app.innerHTML = '<div class="loading">Cargando…</div>'
const list = await api('/api/downloads')
app.innerHTML = `<h1>Descargas</h1>
<p class="muted">Se guardan en <code>/zdata/media/mangas</code> como CBZ (compatibles con Kavita, Komga, Mihon…). Los capítulos descargados se leen desde el disco.</p>
${list.length ? '' : '<p class="muted">Aún no has descargado nada. Usa “⬇ Descargar” en la ficha de un manga.</p>'}
<div class="history">${list.map(d => {
const pct = d.total ? Math.round((d.done / d.total) * 100) : 0
return `<a class="cont" href="#/manga/${d.manga_id}">
<div class="info"><b>${esc(d.title)}</b>
<div class="muted">${esc(downloadLabel(d))} · mangas/${esc(d.folder)}</div>
${d.status === 'error' ? `<div class="error" style="font-size:.8rem">${esc(d.error || '')}</div>` : ''}
<div class="bar"><i style="width:${pct}%"></i></div></div></a>`
}).join('')}</div>`
if (list.some(d => DL_ACTIVE.includes(d.status))) {
const t = setTimeout(() => location.hash === '#/downloads' && viewDownloads(), 4000)
cleanup = () => clearTimeout(t)
}
}
// ---------- Ajustes ----------
function viewSettings() {
app.innerHTML = `<h1>Ajustes</h1>
+1
View File
@@ -14,6 +14,7 @@
<nav>
<a href="#/">Inicio</a>
<a href="#/history">Historial</a>
<a href="#/downloads">Descargas</a>
<a href="#/settings">Ajustes</a>
</nav>
</header>
+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
})
+3
View File
@@ -7,12 +7,15 @@ services:
- TZ=America/El_Salvador
- PORT=3352
- DATA_DIR=/app/data
- MANGA_DIR=/mangas
# Pon "false" cuando ya hayas creado las cuentas que necesites.
# (Si no hay ningún usuario, el primero siempre puede registrarse.)
- ALLOW_SIGNUP=${ALLOW_SIGNUP:-true}
volumes:
# SQLite con usuarios, sesiones e historial de lectura.
- ./data:/app/data
# Mangas descargados: <Título>/Capitulo NNNN.cbz + cover.jpg
- /zdata/media/mangas:/mangas
ports:
# Local del servidor, LAN y Tailscale.
- "127.0.0.1:3352:3352"