diff --git a/server/Db.js b/server/Db.js index 3f430953..38a39d76 100644 --- a/server/Db.js +++ b/server/Db.js @@ -172,7 +172,15 @@ class Db { } } - updateAudiobook(audiobook) { + async updateAudiobook(audiobook) { + if (audiobook && audiobook.saveAbMetadata) { + // TODO: Book may have updates where this save is not necessary + // add check first if metadata update is needed + await audiobook.saveAbMetadata() + } else { + Logger.error(`[Db] Invalid audiobook object passed to updateAudiobook`, audiobook) + } + return this.audiobooksDb.update((record) => record.id === audiobook.id, () => audiobook).then((results) => { Logger.debug(`[DB] Audiobook updated ${results.updated}`) return true @@ -182,6 +190,27 @@ class Db { }) } + insertAudiobook(audiobook) { + return this.insertAudiobooks([audiobook]) + } + + async insertAudiobooks(audiobooks) { + // TODO: Books may have updates where this save is not necessary + // add check first if metadata update is needed + await Promise.all(audiobooks.map(async (ab) => { + if (ab && ab.saveAbMetadata) return ab.saveAbMetadata() + return null + })) + + return this.audiobooksDb.insert(audiobooks).then((results) => { + Logger.debug(`[DB] Audiobooks inserted ${results.updated}`) + return true + }).catch((error) => { + Logger.error(`[DB] Audiobooks insert failed ${error}`) + return false + }) + } + updateUserStream(userId, streamId) { return this.usersDb.update((record) => record.id === userId, (user) => { user.stream = streamId diff --git a/server/objects/Audiobook.js b/server/objects/Audiobook.js index 7ee9f23a..60f26818 100644 --- a/server/objects/Audiobook.js +++ b/server/objects/Audiobook.js @@ -428,10 +428,6 @@ class Audiobook { if (payload.book && this.book.update(payload.book)) { hasUpdates = true - - // TODO: Book may have updates where this save is not necessary - // add check first if metadata update is needed - this.saveAbMetadata() } if (hasUpdates) { @@ -526,12 +522,13 @@ class Audiobook { } // On scan check other files found with other files saved - async syncOtherFiles(newOtherFiles, opfMetadataOverrideDetails, forceRescan = false) { + async syncOtherFiles(newOtherFiles, opfMetadataOverrideDetails) { var hasUpdates = false var currOtherFileNum = this.otherFiles.length var otherFilenamesAlreadyInBook = this.otherFiles.map(ofile => ofile.filename) + var alreadyHasAbsMetadata = otherFilenamesAlreadyInBook.includes('metadata.abs') var alreadyHasDescTxt = otherFilenamesAlreadyInBook.includes('desc.txt') var alreadyHasReaderTxt = otherFilenamesAlreadyInBook.includes('reader.txt') @@ -543,9 +540,9 @@ class Audiobook { hasUpdates = true } - // If desc.txt is new or forcing rescan then read it and update description (will overwrite) + // If desc.txt is new then read it and update description (will overwrite) var descriptionTxt = newOtherFiles.find(file => file.filename === 'desc.txt') - if (descriptionTxt && (!alreadyHasDescTxt || forceRescan)) { + if (descriptionTxt && !alreadyHasDescTxt) { var newDescription = await readTextFile(descriptionTxt.fullPath) if (newDescription) { Logger.debug(`[Audiobook] Sync Other File desc.txt: ${newDescription}`) @@ -553,9 +550,9 @@ class Audiobook { hasUpdates = true } } - // If reader.txt is new or forcing rescan then read it and update narrator (will overwrite) + // If reader.txt is new then read it and update narrator (will overwrite) var readerTxt = newOtherFiles.find(file => file.filename === 'reader.txt') - if (readerTxt && (!alreadyHasReaderTxt || forceRescan)) { + if (readerTxt && !alreadyHasReaderTxt) { var newReader = await readTextFile(readerTxt.fullPath) if (newReader) { Logger.debug(`[Audiobook] Sync Other File reader.txt: ${newReader}`) @@ -564,7 +561,24 @@ class Audiobook { } } - // If OPF file and was not already there + + // If metadata.abs is new then read it and set all defined keys (will overwrite) + var metadataAbs = newOtherFiles.find(file => file.filename === 'metadata.abs') + if (metadataAbs && !alreadyHasAbsMetadata) { + var abmetadataText = await readTextFile(metadataAbs.fullPath) + if (abmetadataText) { + var metadataUpdateObject = abmetadataGenerator.parse(abmetadataText) + if (metadataUpdateObject && metadataUpdateObject.book) { + Logger.debug(`[Audiobook] Updating book "${this.title}" details from metadata.abs file`, metadataUpdateObject) + if (this.update(metadataUpdateObject)) { + Logger.debug(`[Audiobook] Some details were updated from metadata.abs for "${this.title}"`) + hasUpdates = true + } + } + } + } + + // If OPF file and was not already there OR prefer opf metadata var metadataOpf = newOtherFiles.find(file => file.ext === '.opf' || file.filename === 'metadata.xml') if (metadataOpf && (!otherFilenamesAlreadyInBook.includes(metadataOpf.filename) || opfMetadataOverrideDetails)) { var xmlText = await readTextFile(metadataOpf.fullPath) @@ -800,9 +814,10 @@ class Audiobook { return false } - // Look for desc.txt and reader.txt and update details if found + // Look for desc.txt, reader.txt, metadata.abs and opf file then update details if found async saveDataFromTextFiles(opfMetadataOverrideDetails) { var bookUpdatePayload = {} + var descriptionText = await this.fetchTextFromTextFile('desc.txt') if (descriptionText) { Logger.debug(`[Audiobook] "${this.title}" found desc.txt updating description with "${descriptionText.slice(0, 20)}..."`) @@ -814,6 +829,22 @@ class Audiobook { bookUpdatePayload.narrator = readerText } + // abmetadata will always overwrite + var abmetadataText = await this.fetchTextFromTextFile('metadata.abs') + if (abmetadataText) { + var metadataUpdateObject = abmetadataGenerator.parse(abmetadataText) + if (metadataUpdateObject && metadataUpdateObject.book) { + Logger.debug(`[Audiobook] "${this.title}" found book details from metadata.abs file`, metadataUpdateObject) + for (const key in metadataUpdateObject.book) { + var value = metadataUpdateObject.book[key] + if (key && value !== undefined) { + bookUpdatePayload[key] = value + } + } + } + } + + // Opf only overwrites if detail is empty var metadataOpf = this.otherFiles.find(file => file.isOPFFile || file.filename === 'metadata.xml') if (metadataOpf) { var xmlText = await readTextFile(metadataOpf.fullPath) @@ -1048,6 +1079,7 @@ class Audiobook { metadataPath = Path.join(metadataPath, 'metadata.abs') return abmetadataGenerator.generate(this, metadataPath).then((success) => { + this.isSavingMetadata = false if (!success) Logger.error(`[Audiobook] Failed saving abmetadata to "${metadataPath}"`) else Logger.debug(`[Audiobook] Success saving abmetadata to "${metadataPath}"`) return success diff --git a/server/scanner/Scanner.js b/server/scanner/Scanner.js index b5dcaf9c..635fda0c 100644 --- a/server/scanner/Scanner.js +++ b/server/scanner/Scanner.js @@ -117,7 +117,7 @@ class Scanner { if (hasUpdated) { this.emitter('audiobook_updated', audiobook.toJSONExpanded()) - await this.db.updateEntity('audiobook', audiobook) + await this.db.updateAudiobook(audiobook) return ScanResult.UPDATED } return ScanResult.UPTODATE @@ -314,7 +314,7 @@ class Scanner { })) newAudiobooks = newAudiobooks.filter(ab => ab) // Filter out nulls libraryScan.resultsAdded += newAudiobooks.length - await this.db.insertEntities('audiobook', newAudiobooks) + await this.db.insertAudiobooks(newAudiobooks) this.emitter('audiobooks_added', newAudiobooks.map(ab => ab.toJSONExpanded())) } @@ -522,7 +522,7 @@ class Scanner { Logger.debug(`[Scanner] Folder update group must be a new book "${bookDir}" in library "${library.name}"`) var newAudiobook = await this.scanPotentialNewAudiobook(folder, fullPath) if (newAudiobook) { - await this.db.insertEntity('audiobook', newAudiobook) + await this.db.insertAudiobook(newAudiobook) this.emitter('audiobook_added', newAudiobook.toJSONExpanded()) } bookGroupingResults[bookDir] = newAudiobook ? ScanResult.ADDED : ScanResult.NOTHING @@ -612,7 +612,7 @@ class Scanner { } Logger.warn('Found duplicate ID - updating from', ab.id, 'to', abCopy.id) await this.db.removeEntity('audiobook', ab.id) - await this.db.insertEntity('audiobook', abCopy) + await this.db.insertAudiobook(abCopy) audiobooksUpdated++ } else { ids[ab.id] = true @@ -665,7 +665,7 @@ class Scanner { } if (hasUpdated) { - await this.db.updateEntity('audiobook', audiobook) + await this.db.updateAudiobook(audiobook) this.emitter('audiobook_updated', audiobook.toJSONExpanded()) } diff --git a/server/utils/abmetadataGenerator.js b/server/utils/abmetadataGenerator.js index 39fd0005..8409ab96 100644 --- a/server/utils/abmetadataGenerator.js +++ b/server/utils/abmetadataGenerator.js @@ -45,4 +45,56 @@ function generate(audiobook, outputPath) { return false }) } -module.exports.generate = generate \ No newline at end of file +module.exports.generate = generate + +function parseAbMetadataText(text) { + if (!text) return null + var lines = text.split(/\r?\n/) + + // Check first line and get abmetadata version number + var firstLine = lines.shift().toLowerCase() + if (!firstLine.startsWith(';abmetadata')) { + Logger.error(`Invalid abmetadata file first line is not ;abmetadata "${firstLine}"`) + return null + } + var abmetadataVersion = Number(firstLine.replace(';abmetadata', '').trim()) + if (isNaN(abmetadataVersion)) { + Logger.warn(`Invalid abmetadata version ${abmetadataVersion} - using 1`) + abmetadataVersion = 1 + } + + // Remove comments and empty lines + const ignoreFirstChars = [' ', '#', ';'] // Ignore any line starting with the following + lines = lines.filter(line => !!line.trim() && !ignoreFirstChars.includes(line[0])) + + // Get lines that map to book details (all lines before the first chapter section) + var firstSectionLine = lines.findIndex(l => l.startsWith('[')) + var detailLines = firstSectionLine > 0 ? lines.slice(0, firstSectionLine) : lines + + // Put valid book detail values into map + const bookDetails = {} + for (let i = 0; i < detailLines.length; i++) { + var line = detailLines[i] + var keyValue = line.split('=') + if (keyValue.length < 2) { + Logger.warn('abmetadata invalid line has no =', line) + } else if (!bookKeyMap[keyValue[0].trim()]) { + Logger.warn(`abmetadata key "${keyValue[0].trim()}" is not a valid book detail key`) + } else { + var key = keyValue[0].trim() + bookDetails[key] = keyValue[1].trim() + + // Genres convert to array of strings + if (key === 'genres' && bookDetails[key]) { + bookDetails[key] = bookDetails[key].split(',').map(genre => genre.trim()) + } + } + } + + // TODO: Chapter support + + return { + book: bookDetails + } +} +module.exports.parse = parseAbMetadataText \ No newline at end of file