diff --git a/client/pages/audiobook/_id/manage.vue b/client/pages/audiobook/_id/manage.vue index 9f858a81..a45979dc 100644 --- a/client/pages/audiobook/_id/manage.vue +++ b/client/pages/audiobook/_id/manage.vue @@ -91,6 +91,10 @@ A backup of your original audio files will be stored in /metadata/cache/items/{{ libraryItemId }}/. Make sure to periodically purge items cache.

+
+ star +

Chapters are not embedded in multi-track audiobooks.

+
star

Encoding can take up to 30 minutes.

diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index b1f3f77d..56b181c2 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -398,7 +398,8 @@ class LibraryItemController { } const useTone = req.query.tone === '1' - this.audioMetadataManager.updateMetadataForItem(req.user, req.libraryItem, useTone) + const forceEmbedChapters = req.query.forceEmbedChapters === '1' + this.audioMetadataManager.updateMetadataForItem(req.user, req.libraryItem, useTone, forceEmbedChapters) res.sendStatus(200) } diff --git a/server/managers/AbMergeManager.js b/server/managers/AbMergeManager.js index 1b3801c0..72343d50 100644 --- a/server/managers/AbMergeManager.js +++ b/server/managers/AbMergeManager.js @@ -62,7 +62,7 @@ class AbMergeManager { targetFilename, targetFilepath: Path.join(libraryItem.path, targetFilename), itemCachePath, - toneMetadataObject: null + toneJsonObject: null } const taskDescription = `Encoding audiobook "${libraryItem.media.metadata.title}" into a single m4b file.` task.setData('encode-m4b', 'Encoding M4b', taskDescription, taskData) @@ -120,22 +120,19 @@ class AbMergeManager { } } - var chaptersFilePath = null - if (libraryItem.media.chapters.length) { - chaptersFilePath = Path.join(task.data.itemCachePath, 'chapters.txt') - try { - await toneHelpers.writeToneChaptersFile(libraryItem.media.chapters, chaptersFilePath) - } catch (error) { - Logger.error(`[AbMergeManager] Write chapters.txt failed`, error) - chaptersFilePath = null - } + var toneJsonPath = null + try { + toneJsonPath = Path.join(task.data.itemCachePath, 'metadata.json') + await toneHelpers.writeToneMetadataJsonFile(libraryItem, libraryItem.media.chapters, toneJsonPath, 1) + } catch (error) { + Logger.error(`[AbMergeManager] Write metadata.json failed`, error) + toneJsonPath = null } - const toneMetadataObject = toneHelpers.getToneMetadataObject(libraryItem, chaptersFilePath) - toneMetadataObject.TrackNumber = 1 - task.data.toneMetadataObject = toneMetadataObject - - Logger.debug(`[AbMergeManager] Book "${libraryItem.media.metadata.title}" tone metadata object=`, toneMetadataObject) + task.data.toneJsonObject = { + 'ToneJsonFile': toneJsonPath, + 'TrackNumber': 1, + } var workerData = { inputs: ffmpegInputs, @@ -190,7 +187,7 @@ class AbMergeManager { } // Write metadata to merged file - const success = await toneHelpers.tagAudioFile(task.data.tempFilepath, task.data.toneMetadataObject) + const success = await toneHelpers.tagAudioFile(task.data.tempFilepath, task.data.toneJsonObject) if (!success) { Logger.error(`[AbMergeManager] Failed to write metadata to file "${task.data.tempFilepath}"`) task.setFailed('Failed to write metadata to m4b file') diff --git a/server/managers/AudioMetadataManager.js b/server/managers/AudioMetadataManager.js index 02a44ce1..25a93583 100644 --- a/server/managers/AudioMetadataManager.js +++ b/server/managers/AudioMetadataManager.js @@ -15,9 +15,9 @@ class AudioMetadataMangaer { this.clientEmitter = clientEmitter } - updateMetadataForItem(user, libraryItem, useTone = true) { + updateMetadataForItem(user, libraryItem, useTone, forceEmbedChapters) { if (useTone) { - this.updateMetadataForItemWithTone(user, libraryItem) + this.updateMetadataForItemWithTone(user, libraryItem, forceEmbedChapters) } else { this.updateMetadataForItemWithFfmpeg(user, libraryItem) } @@ -30,7 +30,7 @@ class AudioMetadataMangaer { return toneHelpers.getToneMetadataObject(libraryItem) } - async updateMetadataForItemWithTone(user, libraryItem) { + async updateMetadataForItemWithTone(user, libraryItem, forceEmbedChapters) { var audioFiles = libraryItem.media.includedAudioFiles const itemAudioMetadataPayload = { @@ -43,26 +43,22 @@ class AudioMetadataMangaer { this.emitter('audio_metadata_started', itemAudioMetadataPayload) // Write chapters file - var chaptersFilePath = null + var toneJsonPath = null const itemCacheDir = Path.join(global.MetadataPath, `cache/items/${libraryItem.id}`) await fs.ensureDir(itemCacheDir) - if (libraryItem.media.chapters.length) { - chaptersFilePath = Path.join(itemCacheDir, 'chapters.txt') - try { - await toneHelpers.writeToneChaptersFile(libraryItem.media.chapters, chaptersFilePath) - } catch (error) { - Logger.error(`[AudioMetadataManager] Write chapters.txt failed`, error) - chaptersFilePath = null - } + try { + toneJsonPath = Path.join(itemCacheDir, 'metadata.json') + const chapters = (audioFiles.length == 1 || forceEmbedChapters) ? libraryItem.media.chapters : null + await toneHelpers.writeToneMetadataJsonFile(libraryItem, chapters, toneJsonPath, audioFiles.length) + } catch (error) { + Logger.error(`[AudioMetadataManager] Write metadata.json failed`, error) + toneJsonPath = null } - const toneMetadataObject = toneHelpers.getToneMetadataObject(libraryItem, chaptersFilePath) - Logger.debug(`[AudioMetadataManager] Book "${libraryItem.media.metadata.title}" tone metadata object=`, toneMetadataObject) - const results = [] for (const af of audioFiles) { - const result = await this.updateAudioFileMetadataWithTone(libraryItem.id, af, toneMetadataObject, itemCacheDir) + const result = await this.updateAudioFileMetadataWithTone(libraryItem.id, af, toneJsonPath, itemCacheDir) results.push(result) } @@ -74,7 +70,7 @@ class AudioMetadataMangaer { this.emitter('audio_metadata_finished', itemAudioMetadataPayload) } - async updateAudioFileMetadataWithTone(libraryItemId, audioFile, toneMetadataObject, itemCacheDir) { + async updateAudioFileMetadataWithTone(libraryItemId, audioFile, toneJsonPath, itemCacheDir) { const resultPayload = { libraryItemId, index: audioFile.index, @@ -93,8 +89,8 @@ class AudioMetadataMangaer { } const _toneMetadataObject = { - ...toneMetadataObject, - 'TrackNumber': audioFile.index + 'ToneJsonFile': toneJsonPath, + 'TrackNumber': audioFile.index, } resultPayload.success = await toneHelpers.tagAudioFile(audioFile.metadata.path, _toneMetadataObject) diff --git a/server/utils/toneHelpers.js b/server/utils/toneHelpers.js index fc88c169..dac6fa40 100644 --- a/server/utils/toneHelpers.js +++ b/server/utils/toneHelpers.js @@ -72,6 +72,70 @@ module.exports.getToneMetadataObject = (libraryItem, chaptersFile) => { return metadataObject } +module.exports.writeToneMetadataJsonFile = (libraryItem, chapters, filePath, trackTotal) => { + const bookMetadata = libraryItem.media.metadata + const coverPath = libraryItem.media.coverPath + + const metadataObject = { + 'album': bookMetadata.title || '', + 'title': bookMetadata.title || '', + 'trackTotal': trackTotal, + 'additionalFields': {} + } + if (bookMetadata.subtitle) { + metadataObject['subtitle'] = bookMetadata.subtitle + } + if (bookMetadata.authorName) { + metadataObject['artist'] = bookMetadata.authorName + metadataObject['albumArtist'] = bookMetadata.authorName + } + if (bookMetadata.description) { + metadataObject['comment'] = bookMetadata.description + metadataObject['description'] = bookMetadata.description + } + if (bookMetadata.narratorName) { + metadataObject['narrator'] = bookMetadata.narratorName + metadataObject['composer'] = bookMetadata.narratorName + } + if (bookMetadata.firstSeriesName) { + metadataObject['movementName'] = bookMetadata.firstSeriesName + } + if (bookMetadata.firstSeriesSequence) { + metadataObject['movement'] = bookMetadata.firstSeriesSequence + } + if (bookMetadata.genres.length) { + metadataObject['genre'] = bookMetadata.genres.join('/') + } + if (bookMetadata.publisher) { + metadataObject['publisher'] = bookMetadata.publisher + } + if (bookMetadata.asin) { + metadataObject.additionalFields['asin'] = bookMetadata.asin + } + if (bookMetadata.isbn) { + metadataObject.additionalFields['isbn'] = bookMetadata.isbn + } + if (coverPath) { + metadataObject['coverFile'] = coverPath + } + if (parsePublishedYear(bookMetadata.publishedYear)) { + metadataObject['publishingDate'] = parsePublishedYear(bookMetadata.publishedYear) + } + if (chapters && chapters.length > 0) { + let metadataChapters = [] + for (const chapter of chapters) { + metadataChapters.push({ + start: Math.round(chapter.start * 1000), + length: Math.round((chapter.end - chapter.start) * 1000), + title: chapter.title, + }) + } + metadataObject['chapters'] = metadataChapters + } + + return fs.writeFile(filePath, JSON.stringify({ meta: metadataObject }, null, 2)) +} + module.exports.tagAudioFile = (filePath, payload) => { return tone.tag(filePath, payload).then((data) => { return true