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:
+115
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user