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