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:
+248
-16
@@ -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 => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' }[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
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user