diff --git a/client/components/modals/edit-tabs/Details.vue b/client/components/modals/edit-tabs/Details.vue index fe9ed33a..c893d0db 100644 --- a/client/components/modals/edit-tabs/Details.vue +++ b/client/components/modals/edit-tabs/Details.vue @@ -36,9 +36,15 @@
-
+
+
+ +
+
+ +
@@ -83,6 +89,8 @@ export default { series: null, volumeNumber: null, publishYear: null, + publisher: null, + isbn: null, genres: [] }, newTags: [], @@ -207,6 +215,8 @@ export default { this.details.series = this.book.series this.details.volumeNumber = this.book.volumeNumber this.details.publishYear = this.book.publishYear + this.details.publisher = this.book.publisher || null + this.details.isbn = this.book.isbn || null this.newTags = this.audiobook.tags || [] }, diff --git a/client/package.json b/client/package.json index 3debb175..77d13795 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf-client", - "version": "1.6.12", + "version": "1.6.13", "description": "Audiobook manager and player", "main": "index.js", "scripts": { diff --git a/client/pages/config/stats.vue b/client/pages/config/stats.vue index e8f6a5e1..8aba2fa6 100644 --- a/client/pages/config/stats.vue +++ b/client/pages/config/stats.vue @@ -86,7 +86,7 @@ export default { return Object.values(this.$store.state.user.user.audiobooks || {}) }, userAudiobooksRead() { - return this.userAudiobooks.map((ab) => !!ab.isRead) + return this.userAudiobooks.filter((ab) => !!ab.isRead) }, genresWithCount() { var genresMap = {} diff --git a/package-lock.json b/package-lock.json index 9506da17..b6750c0b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf", - "version": "1.6.8", + "version": "1.6.12", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 5516a093..8926318c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf", - "version": "1.6.12", + "version": "1.6.13", "description": "Self-hosted audiobook server for managing and playing audiobooks", "main": "index.js", "scripts": { @@ -45,7 +45,8 @@ "read-chunk": "^3.1.0", "recursive-readdir-async": "^1.1.8", "socket.io": "^4.1.3", - "watcher": "^1.2.0" + "watcher": "^1.2.0", + "xml2js": "^0.4.23" }, "devDependencies": {} } \ No newline at end of file diff --git a/server/objects/Audiobook.js b/server/objects/Audiobook.js index 69111c04..765280f6 100644 --- a/server/objects/Audiobook.js +++ b/server/objects/Audiobook.js @@ -2,6 +2,7 @@ const Path = require('path') const fs = require('fs-extra') const { bytesPretty, elapsedPretty, readTextFile } = require('../utils/fileUtils') const { comparePaths, getIno } = require('../utils/index') +const { parseOpfMetadataXML } = require('../utils/parseOpfMetadata') const { extractCoverArt } = require('../utils/ffmpegHelpers') const nfoGenerator = require('../utils/nfoGenerator') const Logger = require('../Logger') @@ -501,6 +502,7 @@ class Audiobook { var alreadyHasDescTxt = this.otherFiles.find(of => of.filename === 'desc.txt') var alreadyHasReaderTxt = this.otherFiles.find(of => of.filename === 'reader.txt') + var alreadyHasMetadataOpf = this.otherFiles.find(of => of.filename === 'metadata.opf') var newOtherFilePaths = newOtherFiles.map(f => f.path) this.otherFiles = this.otherFiles.filter(f => newOtherFilePaths.includes(f.path)) @@ -531,6 +533,27 @@ class Audiobook { hasUpdates = true } } + var metadataOpf = newOtherFiles.find(file => file.filename === 'metadata.opf' || file.filename === 'metadata.xml') + if (metadataOpf && (!alreadyHasMetadataOpf || forceRescan)) { + var xmlText = await readTextFile(metadataOpf.fullPath) + if (xmlText) { + var opfMetadata = await parseOpfMetadataXML(xmlText) + Logger.debug(`[Audiobook] Sync Other File ${metadataOpf.filename} parsed:`, opfMetadata) + if (opfMetadata) { + const bookUpdatePayload = {} + for (const key in opfMetadata) { + if (opfMetadata[key] && !this.book[key]) { + bookUpdatePayload[key] = opfMetadata[key] + } + } + if (Object.keys(bookUpdatePayload).length) { + Logger.debug(`[Audiobook] Using data found in metadata opf/xml`, bookUpdatePayload) + this.update({ book: bookUpdatePayload }) + hasUpdates = true + } + } + } + } newOtherFiles.forEach((file) => { var existingOtherFile = this.otherFiles.find(f => f.ino === file.ino) @@ -754,6 +777,23 @@ class Audiobook { Logger.debug(`[Audiobook] "${this.title}" found reader.txt updating narrator with "${readerText}"`) bookUpdatePayload.narrator = readerText } + + var metadataOpf = this.otherFiles.find(file => file.filename === 'metadata.opf' || file.filename === 'metadata.xml') + if (metadataOpf) { + var xmlText = await readTextFile(metadataOpf.fullPath) + if (xmlText) { + var opfMetadata = await parseOpfMetadataXML(xmlText) + Logger.debug(`[Audiobook] "${this.title}" found ${metadataOpf.filename} parsed:`, opfMetadata) + if (opfMetadata) { + for (const key in opfMetadata) { + if (opfMetadata[key] && !this.book[key] && !bookUpdatePayload[key]) { + bookUpdatePayload[key] = opfMetadata[key] + } + } + } + } + } + if (Object.keys(bookUpdatePayload).length) { return this.update({ book: bookUpdatePayload }) } diff --git a/server/utils/index.js b/server/utils/index.js index 317b4492..d693e0f0 100644 --- a/server/utils/index.js +++ b/server/utils/index.js @@ -1,6 +1,7 @@ const Path = require('path') const fs = require('fs') const Logger = require('../Logger') +const { parseString } = require("xml2js") const levenshteinDistance = (str1, str2, caseSensitive = false) => { if (!caseSensitive) { @@ -43,3 +44,17 @@ module.exports.getIno = (path) => { return null }) } + +const xmlToJSON = (xml) => { + return new Promise((resolve, reject) => { + parseString(xml, (err, results) => { + if (err) { + Logger.error(`[xmlToJSON] Error`, err) + resolve(null) + } else { + resolve(results) + } + }) + }) +} +module.exports.xmlToJSON = xmlToJSON diff --git a/server/utils/parseOpfMetadata.js b/server/utils/parseOpfMetadata.js new file mode 100644 index 00000000..01842aab --- /dev/null +++ b/server/utils/parseOpfMetadata.js @@ -0,0 +1,78 @@ +const { xmlToJSON } = require('./index') + +function parseCreators(metadata) { + if (!metadata['dc:creator']) return null + var creators = metadata['dc:creator'] + if (!creators.length) return null + return creators.map(c => { + if (typeof c !== 'object' || !c['$'] || !c['_']) return false + return { + value: c['_'], + role: c['$']['opf:role'] || null, + fileAs: c['$']['opf:file-as'] || null + } + }) +} + +function fetchCreator(creators, role) { + if (!creators || !creators.length) return null + var creator = creators.find(c => c.role === role) + return creator ? creator.value : null +} + +function fetchDate(metadata) { + if (!metadata['dc:date']) return null + var dates = metadata['dc:date'] + if (!dates.length || typeof dates[0] !== 'string') return null + var dateSplit = dates[0].split('-') + if (!dateSplit.length || dateSplit[0].length !== 4 || isNaN(dateSplit[0])) return null + return dateSplit[0] +} + +function fetchPublisher(metadata) { + if (!metadata['dc:publisher']) return null + var publishers = metadata['dc:publisher'] + if (!publishers.length || typeof publishers[0] !== 'string') return null + return publishers[0] +} + +function fetchISBN(metadata) { + if (!metadata['dc:identifier'] || !metadata['dc:identifier'].length) return null + var identifiers = metadata['dc:identifier'] + var isbnObj = identifiers.find(i => i['$'] && i['$']['opf:scheme'] === 'ISBN') + return isbnObj ? isbnObj['_'] || null : null +} + +function fetchTitle(metadata) { + if (!metadata['dc:title']) return null + var titles = metadata['dc:title'] + if (!titles.length) return null + if (typeof titles[0] === 'string') { + return titles[0] + } + if (titles[0]['_']) { + return titles[0]['_'] + } + return null +} + +module.exports.parseOpfMetadataXML = async (xml) => { + var json = await xmlToJSON(xml) + if (!json || !json.package || !json.package.metadata) return null + var metadata = json.package.metadata + if (Array.isArray(metadata)) { + if (!metadata.length) return null + metadata = metadata[0] + } + + var creators = parseCreators(metadata) + var data = { + title: fetchTitle(metadata), + author: fetchCreator(creators, 'aut'), + narrator: fetchCreator(creators, 'nrt'), + publishYear: fetchDate(metadata), + publisher: fetchPublisher(metadata), + isbn: fetchISBN(metadata) + } + return data +} \ No newline at end of file