mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2024-12-20 19:06:06 +01:00
Add:Scanner extracts cover from comic files #1837 and ComicInfo.xml parser
This commit is contained in:
parent
e76af3bfc2
commit
f5545cd3f4
21
server/libs/libarchive/LICENSE
Normal file
21
server/libs/libarchive/LICENSE
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2018 ნიკა
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
262
server/libs/libarchive/archive.js
Normal file
262
server/libs/libarchive/archive.js
Normal file
@ -0,0 +1,262 @@
|
||||
/**
|
||||
* Modified from https://github.com/nika-begiashvili/libarchivejs
|
||||
*/
|
||||
|
||||
const Path = require('path')
|
||||
const { Worker } = require('worker_threads')
|
||||
|
||||
/**
|
||||
* Represents compressed file before extraction
|
||||
*/
|
||||
class CompressedFile {
|
||||
|
||||
constructor(name, size, path, archiveRef) {
|
||||
this._name = name
|
||||
this._size = size
|
||||
this._path = path
|
||||
this._archiveRef = archiveRef
|
||||
}
|
||||
|
||||
/**
|
||||
* file name
|
||||
*/
|
||||
get name() {
|
||||
return this._name
|
||||
}
|
||||
/**
|
||||
* file size
|
||||
*/
|
||||
get size() {
|
||||
return this._size
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract file from archive
|
||||
* @returns {Promise<File>} extracted file
|
||||
*/
|
||||
extract() {
|
||||
return this._archiveRef.extractSingleFile(this._path)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class Archive {
|
||||
/**
|
||||
* Creates new archive instance from browser native File object
|
||||
* @param {Buffer} fileBuffer
|
||||
* @param {object} options
|
||||
* @returns {Archive}
|
||||
*/
|
||||
static open(fileBuffer) {
|
||||
const arch = new Archive(fileBuffer, { workerUrl: Path.join(__dirname, 'libarchiveWorker.js') })
|
||||
return arch.open()
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new archive
|
||||
* @param {File} file
|
||||
* @param {Object} options
|
||||
*/
|
||||
constructor(file, options) {
|
||||
this._worker = new Worker(options.workerUrl)
|
||||
this._worker.on('message', this._workerMsg.bind(this))
|
||||
|
||||
this._callbacks = []
|
||||
this._content = {}
|
||||
this._processed = 0
|
||||
this._file = file
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares file for reading
|
||||
* @returns {Promise<Archive>} archive instance
|
||||
*/
|
||||
async open() {
|
||||
await this._postMessage({ type: 'HELLO' }, (resolve, reject, msg) => {
|
||||
if (msg.type === 'READY') {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
return await this._postMessage({ type: 'OPEN', file: this._file }, (resolve, reject, msg) => {
|
||||
if (msg.type === 'OPENED') {
|
||||
resolve(this)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminate worker to free up memory
|
||||
*/
|
||||
close() {
|
||||
this._worker.terminate()
|
||||
this._worker = null
|
||||
}
|
||||
|
||||
/**
|
||||
* detect if archive has encrypted data
|
||||
* @returns {boolean|null} null if could not be determined
|
||||
*/
|
||||
hasEncryptedData() {
|
||||
return this._postMessage({ type: 'CHECK_ENCRYPTION' },
|
||||
(resolve, reject, msg) => {
|
||||
if (msg.type === 'ENCRYPTION_STATUS') {
|
||||
resolve(msg.status)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* set password to be used when reading archive
|
||||
*/
|
||||
usePassword(archivePassword) {
|
||||
return this._postMessage({ type: 'SET_PASSPHRASE', passphrase: archivePassword },
|
||||
(resolve, reject, msg) => {
|
||||
if (msg.type === 'PASSPHRASE_STATUS') {
|
||||
resolve(msg.status)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns object containing directory structure and file information
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
getFilesObject() {
|
||||
if (this._processed > 0) {
|
||||
return Promise.resolve().then(() => this._content)
|
||||
}
|
||||
return this._postMessage({ type: 'LIST_FILES' }, (resolve, reject, msg) => {
|
||||
if (msg.type === 'ENTRY') {
|
||||
const entry = msg.entry
|
||||
const [target, prop] = this._getProp(this._content, entry.path)
|
||||
if (entry.type === 'FILE') {
|
||||
target[prop] = new CompressedFile(entry.fileName, entry.size, entry.path, this)
|
||||
}
|
||||
return true
|
||||
} else if (msg.type === 'END') {
|
||||
this._processed = 1
|
||||
resolve(this._cloneContent(this._content))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
getFilesArray() {
|
||||
return this.getFilesObject().then((obj) => {
|
||||
return this._objectToArray(obj)
|
||||
})
|
||||
}
|
||||
|
||||
extractSingleFile(target) {
|
||||
// Prevent extraction if worker already terminated
|
||||
if (this._worker === null) {
|
||||
throw new Error("Archive already closed")
|
||||
}
|
||||
|
||||
return this._postMessage({ type: 'EXTRACT_SINGLE_FILE', target: target },
|
||||
(resolve, reject, msg) => {
|
||||
if (msg.type === 'FILE') {
|
||||
resolve(msg.entry)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns object containing directory structure and extracted File objects
|
||||
* @param {Function} extractCallback
|
||||
*
|
||||
*/
|
||||
extractFiles(extractCallback) {
|
||||
if (this._processed > 1) {
|
||||
return Promise.resolve().then(() => this._content)
|
||||
}
|
||||
return this._postMessage({ type: 'EXTRACT_FILES' }, (resolve, reject, msg) => {
|
||||
if (msg.type === 'ENTRY') {
|
||||
const [target, prop] = this._getProp(this._content, msg.entry.path)
|
||||
if (msg.entry.type === 'FILE') {
|
||||
target[prop] = msg.entry
|
||||
if (extractCallback !== undefined) {
|
||||
setTimeout(extractCallback.bind(null, {
|
||||
file: target[prop],
|
||||
path: msg.entry.path,
|
||||
}))
|
||||
}
|
||||
}
|
||||
return true
|
||||
} else if (msg.type === 'END') {
|
||||
this._processed = 2
|
||||
this._worker.terminate()
|
||||
resolve(this._cloneContent(this._content))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
_cloneContent(obj) {
|
||||
if (obj instanceof CompressedFile || obj === null) return obj
|
||||
const o = {}
|
||||
for (const prop of Object.keys(obj)) {
|
||||
o[prop] = this._cloneContent(obj[prop])
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
_objectToArray(obj, path = '') {
|
||||
const files = []
|
||||
for (const key of Object.keys(obj)) {
|
||||
if (obj[key] instanceof CompressedFile || obj[key] === null) {
|
||||
files.push({
|
||||
file: obj[key] || key,
|
||||
path: path
|
||||
})
|
||||
} else {
|
||||
files.push(...this._objectToArray(obj[key], `${path}${key}/`))
|
||||
}
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
_getProp(obj, path) {
|
||||
const parts = path.split('/')
|
||||
if (parts[parts.length - 1] === '') parts.pop()
|
||||
let cur = obj, prev = null
|
||||
for (const part of parts) {
|
||||
cur[part] = cur[part] || {}
|
||||
prev = cur
|
||||
cur = cur[part]
|
||||
}
|
||||
return [prev, parts[parts.length - 1]]
|
||||
}
|
||||
|
||||
_postMessage(msg, callback) {
|
||||
this._worker.postMessage(msg)
|
||||
return new Promise((resolve, reject) => {
|
||||
this._callbacks.push(this._msgHandler.bind(this, callback, resolve, reject))
|
||||
})
|
||||
}
|
||||
|
||||
_msgHandler(callback, resolve, reject, msg) {
|
||||
if (!msg) {
|
||||
reject('invalid msg')
|
||||
return
|
||||
}
|
||||
if (msg.type === 'BUSY') {
|
||||
reject('worker is busy')
|
||||
} else if (msg.type === 'ERROR') {
|
||||
reject(msg.error)
|
||||
} else {
|
||||
return callback(resolve, reject, msg)
|
||||
}
|
||||
}
|
||||
|
||||
_workerMsg(msg) {
|
||||
const callback = this._callbacks[this._callbacks.length - 1]
|
||||
const next = callback(msg)
|
||||
if (!next) {
|
||||
this._callbacks.pop()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
module.exports = Archive
|
72
server/libs/libarchive/libarchiveWorker.js
Normal file
72
server/libs/libarchive/libarchiveWorker.js
Normal file
@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Modified from https://github.com/nika-begiashvili/libarchivejs
|
||||
*/
|
||||
|
||||
const { parentPort } = require('worker_threads')
|
||||
const { getArchiveReader } = require('./wasm-module')
|
||||
|
||||
let reader = null
|
||||
let busy = false
|
||||
|
||||
getArchiveReader((_reader) => {
|
||||
reader = _reader
|
||||
busy = false
|
||||
parentPort.postMessage({ type: 'READY' })
|
||||
})
|
||||
|
||||
parentPort.on('message', async msg => {
|
||||
if (busy) {
|
||||
parentPort.postMessage({ type: 'BUSY' })
|
||||
return
|
||||
}
|
||||
|
||||
let skipExtraction = false
|
||||
busy = true
|
||||
try {
|
||||
switch (msg.type) {
|
||||
case 'HELLO': // module will respond READY when it's ready
|
||||
break
|
||||
case 'OPEN':
|
||||
await reader.open(msg.file)
|
||||
parentPort.postMessage({ type: 'OPENED' })
|
||||
break
|
||||
case 'LIST_FILES':
|
||||
skipExtraction = true
|
||||
// eslint-disable-next-line no-fallthrough
|
||||
case 'EXTRACT_FILES':
|
||||
for (const entry of reader.entries(skipExtraction)) {
|
||||
parentPort.postMessage({ type: 'ENTRY', entry })
|
||||
}
|
||||
parentPort.postMessage({ type: 'END' })
|
||||
break
|
||||
case 'EXTRACT_SINGLE_FILE':
|
||||
for (const entry of reader.entries(true, msg.target)) {
|
||||
if (entry.fileData) {
|
||||
parentPort.postMessage({ type: 'FILE', entry })
|
||||
}
|
||||
}
|
||||
break
|
||||
case 'CHECK_ENCRYPTION':
|
||||
parentPort.postMessage({ type: 'ENCRYPTION_STATUS', status: reader.hasEncryptedData() })
|
||||
break
|
||||
case 'SET_PASSPHRASE':
|
||||
reader.setPassphrase(msg.passphrase)
|
||||
parentPort.postMessage({ type: 'PASSPHRASE_STATUS', status: true })
|
||||
break
|
||||
default:
|
||||
throw new Error('Invalid Command')
|
||||
}
|
||||
} catch (err) {
|
||||
parentPort.postMessage({
|
||||
type: 'ERROR',
|
||||
error: {
|
||||
message: err.message,
|
||||
name: err.name,
|
||||
stack: err.stack
|
||||
}
|
||||
})
|
||||
} finally {
|
||||
// eslint-disable-next-line require-atomic-updates
|
||||
busy = false
|
||||
}
|
||||
})
|
18
server/libs/libarchive/wasm-libarchive.js
Normal file
18
server/libs/libarchive/wasm-libarchive.js
Normal file
File diff suppressed because one or more lines are too long
235
server/libs/libarchive/wasm-module.js
Normal file
235
server/libs/libarchive/wasm-module.js
Normal file
@ -0,0 +1,235 @@
|
||||
/**
|
||||
* Modified from https://github.com/nika-begiashvili/libarchivejs
|
||||
*/
|
||||
|
||||
const Path = require('path')
|
||||
const libarchive = require('./wasm-libarchive')
|
||||
|
||||
const TYPE_MAP = {
|
||||
32768: 'FILE',
|
||||
16384: 'DIR',
|
||||
40960: 'SYMBOLIC_LINK',
|
||||
49152: 'SOCKET',
|
||||
8192: 'CHARACTER_DEVICE',
|
||||
24576: 'BLOCK_DEVICE',
|
||||
4096: 'NAMED_PIPE',
|
||||
}
|
||||
|
||||
class ArchiveReader {
|
||||
/**
|
||||
* archive reader
|
||||
* @param {WasmModule} wasmModule emscripten module
|
||||
*/
|
||||
constructor(wasmModule) {
|
||||
this._wasmModule = wasmModule
|
||||
this._runCode = wasmModule.runCode
|
||||
this._file = null
|
||||
this._passphrase = null
|
||||
}
|
||||
|
||||
/**
|
||||
* open archive, needs to closed manually
|
||||
* @param {File} file
|
||||
*/
|
||||
open(file) {
|
||||
if (this._file !== null) {
|
||||
console.warn('Closing previous file')
|
||||
this.close()
|
||||
}
|
||||
const { promise, resolve, reject } = this._promiseHandles()
|
||||
this._file = file
|
||||
this._loadFile(file, resolve, reject)
|
||||
return promise
|
||||
}
|
||||
|
||||
/**
|
||||
* close archive
|
||||
*/
|
||||
close() {
|
||||
this._runCode.closeArchive(this._archive)
|
||||
this._wasmModule._free(this._filePtr)
|
||||
this._file = null
|
||||
this._filePtr = null
|
||||
this._archive = null
|
||||
}
|
||||
|
||||
/**
|
||||
* detect if archive has encrypted data
|
||||
* @returns {boolean|null} null if could not be determined
|
||||
*/
|
||||
hasEncryptedData() {
|
||||
this._archive = this._runCode.openArchive(this._filePtr, this._fileLength, this._passphrase)
|
||||
this._runCode.getNextEntry(this._archive)
|
||||
const status = this._runCode.hasEncryptedEntries(this._archive)
|
||||
if (status === 0) {
|
||||
return false
|
||||
} else if (status > 0) {
|
||||
return true
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* set passphrase to be used with archive
|
||||
* @param {*} passphrase
|
||||
*/
|
||||
setPassphrase(passphrase) {
|
||||
this._passphrase = passphrase
|
||||
}
|
||||
|
||||
/**
|
||||
* get archive entries
|
||||
* @param {boolean} skipExtraction
|
||||
* @param {string} except don't skip this entry
|
||||
*/
|
||||
*entries(skipExtraction = false, except = null) {
|
||||
this._archive = this._runCode.openArchive(this._filePtr, this._fileLength, this._passphrase)
|
||||
let entry
|
||||
while (true) {
|
||||
entry = this._runCode.getNextEntry(this._archive)
|
||||
if (entry === 0) break
|
||||
|
||||
const entryData = {
|
||||
size: this._runCode.getEntrySize(entry),
|
||||
path: this._runCode.getEntryName(entry),
|
||||
type: TYPE_MAP[this._runCode.getEntryType(entry)],
|
||||
ref: entry,
|
||||
}
|
||||
|
||||
if (entryData.type === 'FILE') {
|
||||
let fileName = entryData.path.split('/')
|
||||
entryData.fileName = fileName[fileName.length - 1]
|
||||
}
|
||||
|
||||
if (skipExtraction && except !== entryData.path) {
|
||||
this._runCode.skipEntry(this._archive)
|
||||
} else {
|
||||
const ptr = this._runCode.getFileData(this._archive, entryData.size)
|
||||
if (ptr < 0) {
|
||||
throw new Error(this._runCode.getError(this._archive))
|
||||
}
|
||||
entryData.fileData = this._wasmModule.HEAP8.slice(ptr, ptr + entryData.size)
|
||||
this._wasmModule._free(ptr)
|
||||
}
|
||||
yield entryData
|
||||
}
|
||||
}
|
||||
|
||||
_loadFile(fileBuffer, resolve, reject) {
|
||||
try {
|
||||
const array = new Uint8Array(fileBuffer)
|
||||
this._fileLength = array.length
|
||||
this._filePtr = this._runCode.malloc(this._fileLength)
|
||||
this._wasmModule.HEAP8.set(array, this._filePtr)
|
||||
resolve()
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
}
|
||||
|
||||
_promiseHandles() {
|
||||
let resolve = null, reject = null
|
||||
const promise = new Promise((_resolve, _reject) => {
|
||||
resolve = _resolve
|
||||
reject = _reject
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class WasmModule {
|
||||
constructor() {
|
||||
this.preRun = []
|
||||
this.postRun = []
|
||||
this.totalDependencies = 0
|
||||
}
|
||||
|
||||
print(...text) {
|
||||
console.log(text)
|
||||
}
|
||||
|
||||
printErr(...text) {
|
||||
console.error(text)
|
||||
}
|
||||
|
||||
initFunctions() {
|
||||
this.runCode = {
|
||||
// const char * get_version()
|
||||
getVersion: this.cwrap('get_version', 'string', []),
|
||||
// void * archive_open( const void * buffer, size_t buffer_size)
|
||||
// retuns archive pointer
|
||||
openArchive: this.cwrap('archive_open', 'number', ['number', 'number', 'string']),
|
||||
// void * get_entry(void * archive)
|
||||
// return archive entry pointer
|
||||
getNextEntry: this.cwrap('get_next_entry', 'number', ['number']),
|
||||
// void * get_filedata( void * archive, size_t bufferSize )
|
||||
getFileData: this.cwrap('get_filedata', 'number', ['number', 'number']),
|
||||
// int archive_read_data_skip(struct archive *_a)
|
||||
skipEntry: this.cwrap('archive_read_data_skip', 'number', ['number']),
|
||||
// void archive_close( void * archive )
|
||||
closeArchive: this.cwrap('archive_close', null, ['number']),
|
||||
// la_int64_t archive_entry_size( struct archive_entry * )
|
||||
getEntrySize: this.cwrap('archive_entry_size', 'number', ['number']),
|
||||
// const char * archive_entry_pathname( struct archive_entry * )
|
||||
getEntryName: this.cwrap('archive_entry_pathname', 'string', ['number']),
|
||||
// __LA_MODE_T archive_entry_filetype( struct archive_entry * )
|
||||
/*
|
||||
#define AE_IFMT ((__LA_MODE_T)0170000)
|
||||
#define AE_IFREG ((__LA_MODE_T)0100000) // Regular file
|
||||
#define AE_IFLNK ((__LA_MODE_T)0120000) // Sybolic link
|
||||
#define AE_IFSOCK ((__LA_MODE_T)0140000) // Socket
|
||||
#define AE_IFCHR ((__LA_MODE_T)0020000) // Character device
|
||||
#define AE_IFBLK ((__LA_MODE_T)0060000) // Block device
|
||||
#define AE_IFDIR ((__LA_MODE_T)0040000) // Directory
|
||||
#define AE_IFIFO ((__LA_MODE_T)0010000) // Named pipe
|
||||
*/
|
||||
getEntryType: this.cwrap('archive_entry_filetype', 'number', ['number']),
|
||||
// const char * archive_error_string(struct archive *);
|
||||
getError: this.cwrap('archive_error_string', 'string', ['number']),
|
||||
|
||||
/*
|
||||
* Returns 1 if the archive contains at least one encrypted entry.
|
||||
* If the archive format not support encryption at all
|
||||
* ARCHIVE_READ_FORMAT_ENCRYPTION_UNSUPPORTED is returned.
|
||||
* If for any other reason (e.g. not enough data read so far)
|
||||
* we cannot say whether there are encrypted entries, then
|
||||
* ARCHIVE_READ_FORMAT_ENCRYPTION_DONT_KNOW is returned.
|
||||
* In general, this function will return values below zero when the
|
||||
* reader is uncertain or totally incapable of encryption support.
|
||||
* When this function returns 0 you can be sure that the reader
|
||||
* supports encryption detection but no encrypted entries have
|
||||
* been found yet.
|
||||
*
|
||||
* NOTE: If the metadata/header of an archive is also encrypted, you
|
||||
* cannot rely on the number of encrypted entries. That is why this
|
||||
* function does not return the number of encrypted entries but#
|
||||
* just shows that there are some.
|
||||
*/
|
||||
// __LA_DECL int archive_read_has_encrypted_entries(struct archive *);
|
||||
entryIsEncrypted: this.cwrap('archive_entry_is_encrypted', 'number', ['number']),
|
||||
hasEncryptedEntries: this.cwrap('archive_read_has_encrypted_entries', 'number', ['number']),
|
||||
// __LA_DECL int archive_read_add_passphrase(struct archive *, const char *);
|
||||
addPassphrase: this.cwrap('archive_read_add_passphrase', 'number', ['number', 'string']),
|
||||
//this.stringToUTF(str), //
|
||||
string: (str) => this.allocate(this.intArrayFromString(str), 'i8', 0),
|
||||
malloc: this.cwrap('malloc', 'number', ['number']),
|
||||
free: this.cwrap('free', null, ['number']),
|
||||
}
|
||||
}
|
||||
|
||||
monitorRunDependencies() { }
|
||||
|
||||
locateFile(path /* ,prefix */) {
|
||||
const wasmFilepath = Path.join(__dirname, `../../../client/dist/libarchive/wasm-gen/${path}`)
|
||||
return wasmFilepath
|
||||
}
|
||||
}
|
||||
|
||||
module.exports.getArchiveReader = (cb) => {
|
||||
libarchive(new WasmModule()).then((module) => {
|
||||
module.initFunctions()
|
||||
cb(new ArchiveReader(module))
|
||||
})
|
||||
}
|
@ -681,7 +681,7 @@ class BookScanner {
|
||||
const bookTitle = this.bookMetadata.title || this.libraryItemData.mediaMetadata.title
|
||||
AudioFileScanner.setBookMetadataFromAudioMetaTags(bookTitle, this.audioFiles, this.bookMetadata, this.libraryScan)
|
||||
} else if (this.ebookFileScanData) {
|
||||
const ebookMetdataObject = this.ebookFileScanData.metadata
|
||||
const ebookMetdataObject = this.ebookFileScanData.metadata || {}
|
||||
for (const key in ebookMetdataObject) {
|
||||
if (key === 'tags') {
|
||||
if (ebookMetdataObject.tags.length) {
|
||||
|
35
server/utils/parsers/parseComicInfoMetadata.js
Normal file
35
server/utils/parsers/parseComicInfoMetadata.js
Normal file
@ -0,0 +1,35 @@
|
||||
|
||||
/**
|
||||
* TODO: Add more fields
|
||||
* @see https://anansi-project.github.io/docs/comicinfo/intro
|
||||
*
|
||||
* @param {Object} comicInfoJson
|
||||
* @returns {import('../../scanner/BookScanner').BookMetadataObject}
|
||||
*/
|
||||
module.exports.parse = (comicInfoJson) => {
|
||||
if (!comicInfoJson?.ComicInfo) return null
|
||||
|
||||
const ComicSeries = comicInfoJson.ComicInfo.Series?.[0]?.trim() || null
|
||||
const ComicNumber = comicInfoJson.ComicInfo.Number?.[0]?.trim() || null
|
||||
const ComicSummary = comicInfoJson.ComicInfo.Summary?.[0]?.trim() || null
|
||||
|
||||
let title = null
|
||||
const series = []
|
||||
if (ComicSeries) {
|
||||
series.push({
|
||||
name: ComicSeries,
|
||||
sequence: ComicNumber
|
||||
})
|
||||
|
||||
title = ComicSeries
|
||||
if (ComicNumber) {
|
||||
title += ` ${ComicNumber}`
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
series,
|
||||
description: ComicSummary
|
||||
}
|
||||
}
|
109
server/utils/parsers/parseComicMetadata.js
Normal file
109
server/utils/parsers/parseComicMetadata.js
Normal file
@ -0,0 +1,109 @@
|
||||
const Path = require('path')
|
||||
const globals = require('../globals')
|
||||
const fs = require('../../libs/fsExtra')
|
||||
const Logger = require('../../Logger')
|
||||
const Archive = require('../../libs/libarchive/archive')
|
||||
const { xmlToJSON } = require('../index')
|
||||
const parseComicInfoMetadata = require('./parseComicInfoMetadata')
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} filepath
|
||||
* @returns {Promise<Buffer>}
|
||||
*/
|
||||
async function getComicFileBuffer(filepath) {
|
||||
if (!await fs.pathExists(filepath)) {
|
||||
Logger.error(`Comic path does not exist "${filepath}"`)
|
||||
return null
|
||||
}
|
||||
try {
|
||||
return fs.readFile(filepath)
|
||||
} catch (error) {
|
||||
Logger.error(`Failed to read comic at "${filepath}"`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract cover image from comic return true if success
|
||||
*
|
||||
* @param {string} comicPath
|
||||
* @param {string} comicImageFilepath
|
||||
* @param {string} outputCoverPath
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async function extractCoverImage(comicPath, comicImageFilepath, outputCoverPath) {
|
||||
const comicFileBuffer = await getComicFileBuffer(comicPath)
|
||||
if (!comicFileBuffer) return null
|
||||
|
||||
const archive = await Archive.open(comicFileBuffer)
|
||||
const fileEntry = await archive.extractSingleFile(comicImageFilepath)
|
||||
|
||||
if (!fileEntry?.fileData) {
|
||||
Logger.error(`[parseComicMetadata] Invalid file entry data for comicPath "${comicPath}"/${comicImageFilepath}`)
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.writeFile(outputCoverPath, fileEntry.fileData)
|
||||
return true
|
||||
} catch (error) {
|
||||
Logger.error(`[parseComicMetadata] Failed to extract image from comicPath "${comicPath}"`, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
module.exports.extractCoverImage = extractCoverImage
|
||||
|
||||
/**
|
||||
* Parse metadata from comic
|
||||
*
|
||||
* @param {import('../../models/Book').EBookFileObject} ebookFile
|
||||
* @returns {Promise<import('./parseEbookMetadata').EBookFileScanData>}
|
||||
*/
|
||||
async function parse(ebookFile) {
|
||||
const comicPath = ebookFile.metadata.path
|
||||
Logger.debug(`Parsing metadata from comic at "${comicPath}"`)
|
||||
|
||||
const comicFileBuffer = await getComicFileBuffer(comicPath)
|
||||
if (!comicFileBuffer) return null
|
||||
|
||||
const archive = await Archive.open(comicFileBuffer)
|
||||
|
||||
const fileObjects = await archive.getFilesArray()
|
||||
|
||||
fileObjects.sort((a, b) => {
|
||||
return a.file.name.localeCompare(b.file.name, undefined, {
|
||||
numeric: true,
|
||||
sensitivity: 'base'
|
||||
})
|
||||
})
|
||||
|
||||
let metadata = null
|
||||
const comicInfo = fileObjects.find(fo => fo.file.name === 'ComicInfo.xml')
|
||||
if (comicInfo) {
|
||||
const comicInfoEntry = await comicInfo.file.extract()
|
||||
if (comicInfoEntry?.fileData) {
|
||||
const comicInfoStr = new TextDecoder().decode(comicInfoEntry.fileData)
|
||||
const comicInfoJson = await xmlToJSON(comicInfoStr)
|
||||
if (comicInfoJson) {
|
||||
metadata = parseComicInfoMetadata.parse(comicInfoJson)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const payload = {
|
||||
path: comicPath,
|
||||
ebookFormat: ebookFile.ebookFormat,
|
||||
metadata
|
||||
}
|
||||
|
||||
const firstImage = fileObjects.find(fo => globals.SupportedImageTypes.includes(Path.extname(fo.file.name).toLowerCase().slice(1)))
|
||||
if (firstImage?.file?._path) {
|
||||
payload.ebookCoverPath = firstImage.file._path
|
||||
} else {
|
||||
Logger.warn(`Cover image not found in comic at "${comicPath}"`)
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
module.exports.parse = parse
|
@ -1,4 +1,5 @@
|
||||
const parseEpubMetadata = require('./parseEpubMetadata')
|
||||
const parseComicMetadata = require('./parseComicMetadata')
|
||||
|
||||
/**
|
||||
* @typedef EBookFileScanData
|
||||
@ -18,7 +19,9 @@ async function parse(ebookFile) {
|
||||
if (!ebookFile) return null
|
||||
|
||||
if (ebookFile.ebookFormat === 'epub') {
|
||||
return parseEpubMetadata.parse(ebookFile.metadata.path)
|
||||
return parseEpubMetadata.parse(ebookFile)
|
||||
} else if (['cbz', 'cbr'].includes(ebookFile.ebookFormat)) {
|
||||
return parseComicMetadata.parse(ebookFile)
|
||||
}
|
||||
return null
|
||||
}
|
||||
@ -36,6 +39,8 @@ async function extractCoverImage(ebookFileScanData, outputCoverPath) {
|
||||
|
||||
if (ebookFileScanData.ebookFormat === 'epub') {
|
||||
return parseEpubMetadata.extractCoverImage(ebookFileScanData.path, ebookFileScanData.ebookCoverPath, outputCoverPath)
|
||||
} else if (['cbz', 'cbr'].includes(ebookFileScanData.ebookFormat)) {
|
||||
return parseComicMetadata.extractCoverImage(ebookFileScanData.path, ebookFileScanData.ebookCoverPath, outputCoverPath)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
@ -60,10 +60,11 @@ module.exports.extractCoverImage = extractCoverImage
|
||||
/**
|
||||
* Parse metadata from epub
|
||||
*
|
||||
* @param {string} epubPath
|
||||
* @param {import('../../models/Book').EBookFileObject} ebookFile
|
||||
* @returns {Promise<import('./parseEbookMetadata').EBookFileScanData>}
|
||||
*/
|
||||
async function parse(epubPath) {
|
||||
async function parse(ebookFile) {
|
||||
const epubPath = ebookFile.metadata.path
|
||||
Logger.debug(`Parsing metadata from epub at "${epubPath}"`)
|
||||
// Entrypoint of the epub that contains the filepath to the package document (opf file)
|
||||
const containerJson = await extractXmlToJson(epubPath, 'META-INF/container.xml')
|
||||
|
Loading…
Reference in New Issue
Block a user