From fea5ec0c6516b57ce4ebd3638c6a757ea54b1ca0 Mon Sep 17 00:00:00 2001 From: Nino Date: Fri, 30 Oct 2026 23:43:13 -0600 Subject: [PATCH] Descarga de mangas completos a /zdata/media/mangas en CBZ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cola de descargas en segundo plano (se reanuda tras reinicios; repetirla solo baja capítulos nuevos; se puede cancelar). - /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 --- README.md | 4 + app/cbz.js | 115 ++++++++++++++++++ app/public/app.js | 72 +++++++++++- app/public/index.html | 1 + app/server.js | 264 +++++++++++++++++++++++++++++++++++++++--- docker-compose.yml | 3 + 6 files changed, 440 insertions(+), 19 deletions(-) create mode 100644 app/cbz.js diff --git a/README.md b/README.md index 0e69862..381be82 100644 --- a/README.md +++ b/README.md @@ -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//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 diff --git a/app/cbz.js b/app/cbz.js new file mode 100644 index 0000000..aadd443 --- /dev/null +++ b/app/cbz.js @@ -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)) +} diff --git a/app/public/app.js b/app/public/app.js index ae5072b..a3143f2 100644 --- a/app/public/app.js +++ b/app/public/app.js @@ -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 = '
Cargando…
' 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) {
${manga.tags.map(t => `${esc(t)}`).join('')}

${esc(manga.description)}

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

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

' : ''} +

Capítulos (${chapters.length}) @@ -196,6 +214,33 @@ async function viewManga(id, langOverride) { ${chapters.length ? '
    ' : isLc ? '

    Este manga no tiene capítulos.

    ' : `

    No hay capítulos legibles en ${esc(LANGS[lang] || lang)} (MangaDex retira algunos por licencia). Prueba a buscarlo en LeerCapitulo. ${chapterLangs.length ? `Disponibles en: ${chapterLangs.map(l => `${esc(LANGS[l] || l)}`).join(' ')}` : 'No hay capítulos en ningún idioma.'}

    `}` + // 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 = `` + else if (active) html = `${downloadLabel(d)}` + else html = `✓ ${downloadLabel(d)}` + box.innerHTML = offline ? (d ? `${downloadLabel(d)}` : '') : 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) ? '⬇ ' : '') + (r ? (done ? 'Leído' : `pág. ${r.page + 1}/${r.total_pages}`) : esc(c.group)) return `
  • ${esc(chapLabel(c))}${meta}
  • ` }).join('') } @@ -421,6 +466,27 @@ async function viewHistory() { })) } +// ---------- Descargas ---------- +async function viewDownloads() { + app.innerHTML = '
    Cargando…
    ' + const list = await api('/api/downloads') + 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.

    + ${list.length ? '' : '

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

    '} +
    ${list.map(d => { + const pct = d.total ? Math.round((d.done / d.total) * 100) : 0 + return ` +
    ${esc(d.title)} +
    ${esc(downloadLabel(d))} · mangas/${esc(d.folder)}
    + ${d.status === 'error' ? `
    ${esc(d.error || '')}
    ` : ''} +
    ` + }).join('')}
    ` + 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 = `

    Ajustes

    diff --git a/app/public/index.html b/app/public/index.html index cd849a9..30b5385 100644 --- a/app/public/index.html +++ b/app/public/index.html @@ -14,6 +14,7 @@ diff --git a/app/server.js b/app/server.js index f73f5c8..93311c8 100644 --- a/app/server.js +++ b/app/server.js @@ -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~~", capítulo "lc~~~". 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(/ imgUrl(decode(m[1]))) + const pages = [...main.matchAll(/ 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//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 => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' }[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(` + + ${xmlEsc(manga.title)} + ${c.num ? `${xmlEsc(c.num)}` : ''} + ${c.volume ? `${xmlEsc(c.volume)}` : ''} + ${c.title ? `${xmlEsc(c.title)}` : ''} + ${xmlEsc(manga.description)} + ${xmlEsc(manga.authors.join(', '))} + ${xmlEsc(manga.tags.join(', '))} + ${xmlEsc((dl.lang || 'es').split('-')[0])} + ${urls.length} + YesAndRightToLeft + +`) }) + 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 +}) diff --git a/docker-compose.yml b/docker-compose.yml index 23faef8..7787df0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: /Capitulo NNNN.cbz + cover.jpg + - /zdata/media/mangas:/mangas ports: # Local del servidor, LAN y Tailscale. - "127.0.0.1:3352:3352"