diff --git a/client/pages/audiobook/_id/edit.vue b/client/pages/audiobook/_id/edit.vue index 7969932a..e15d5ad3 100644 --- a/client/pages/audiobook/_id/edit.vue +++ b/client/pages/audiobook/_id/edit.vue @@ -38,7 +38,9 @@ -
  • +
  • +
    +
    {{ audio.include ? index - numExcluded + 1 : -1 }}
    @@ -71,12 +73,18 @@
    {{ audio.error }}
    -
    - +
    + +

    Encoding

    +
  • + +
    + Encode metadata in audio files (experimental) +
    @@ -125,10 +133,18 @@ export default { ghostClass: 'ghost' }, saving: false, - currentSort: 'current' + currentSort: 'current', + updatingMetadata: false, + audiofilesEncoding: {} } }, computed: { + showExperimentalFeatures() { + return this.$store.state.showExperimentalFeatures + }, + isRootUser() { + return this.$store.getters['user/getIsRoot'] + }, media() { return this.libraryItem.media || {} }, @@ -162,12 +178,23 @@ export default { }, streamLibraryItem() { return this.$store.state.streamLibraryItem - }, - showExperimentalFeatures() { - return this.$store.state.showExperimentalFeatures } }, methods: { + updateAudioFileMetadata() { + if (confirm(`Warning!\n\nThis will modify the audio files for this audiobook.\nMake sure your audio files are backed up before using this feature.`)) { + this.updatingMetadata = true + this.$axios + .$get(`/api/items/${this.libraryItemId}/audio-metadata`) + .then(() => { + console.log('Audio metadata encode started') + }) + .catch((error) => { + console.error('Audio metadata encode failed', error) + this.updatingMetadata = false + }) + } + }, draggableUpdate(e) { this.currentSort = '' }, @@ -242,7 +269,33 @@ export default { } else { return 'check_circle' } + }, + audioMetadataStarted(data) { + console.log('audio metadata started', data) + if (data.libraryItemId !== this.libraryItemId) return + this.updatingMetadata = true + }, + audioMetadataFinished(data) { + console.log('audio metadata finished', data) + if (data.libraryItemId !== this.libraryItemId) return + this.updatingMetadata = false + this.audiofilesEncoding = {} + this.$toast.success('Audio file metadata updated') + }, + audiofileMetadataStarted(data) { + if (data.libraryItemId !== this.libraryItemId) return + this.$set(this.audiofilesEncoding, data.ino, true) + }, + audiofileMetadataFinished(data) { + if (data.libraryItemId !== this.libraryItemId) return + this.$set(this.audiofilesEncoding, data.ino, false) } + }, + mounted() { + this.$root.socket.on('audio_metadata_started', this.audioMetadataStarted) + this.$root.socket.on('audio_metadata_finished', this.audioMetadataFinished) + this.$root.socket.on('audiofile_metadata_started', this.audiofileMetadataStarted) + this.$root.socket.on('audiofile_metadata_finished', this.audiofileMetadataFinished) } } diff --git a/server/Server.js b/server/Server.js index 4692814d..06b131ec 100644 --- a/server/Server.js +++ b/server/Server.js @@ -30,6 +30,7 @@ const LogManager = require('./managers/LogManager') const BackupManager = require('./managers/BackupManager') const PlaybackSessionManager = require('./managers/PlaybackSessionManager') const PodcastManager = require('./managers/PodcastManager') +const AudioMetadataMangaer = require('./managers/AudioMetadataManager') class Server { constructor(PORT, HOST, UID, GID, CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH) { @@ -72,11 +73,12 @@ class Server { this.playbackSessionManager = new PlaybackSessionManager(this.db, this.emitter.bind(this), this.clientEmitter.bind(this)) this.coverManager = new CoverManager(this.db, this.cacheManager) this.podcastManager = new PodcastManager(this.db, this.watcher, this.emitter.bind(this)) + this.audioMetadataManager = new AudioMetadataMangaer(this.db, this.emitter.bind(this), this.clientEmitter.bind(this)) this.scanner = new Scanner(this.db, this.coverManager, this.emitter.bind(this)) // Routers - this.apiRouter = new ApiRouter(this.db, this.auth, this.scanner, this.playbackSessionManager, this.abMergeManager, this.coverManager, this.backupManager, this.watcher, this.cacheManager, this.podcastManager, this.emitter.bind(this), this.clientEmitter.bind(this)) + this.apiRouter = new ApiRouter(this.db, this.auth, this.scanner, this.playbackSessionManager, this.abMergeManager, this.coverManager, this.backupManager, this.watcher, this.cacheManager, this.podcastManager, this.audioMetadataManager, this.emitter.bind(this), this.clientEmitter.bind(this)) this.hlsRouter = new HlsRouter(this.db, this.auth, this.playbackSessionManager, this.emitter.bind(this)) this.staticRouter = new StaticRouter(this.db) diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index 2189a2b4..e89605ba 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -354,6 +354,22 @@ class LibraryItemController { }) } + // POST: api/items/:id/audio-metadata + async updateAudioFileMetadata(req, res) { + if (!req.user.isRoot) { + Logger.error(`[LibraryItemController] Non-root user attempted to update audio metadata`, req.user) + return res.sendStatus(403) + } + + if (req.libraryItem.isMissing || !req.libraryItem.hasAudioFiles || !req.libraryItem.isBook) { + Logger.error(`[LibraryItemController] Invalid library item`) + return res.sendStatus(500) + } + + this.audioMetadataManager.updateAudioFileMetadataForItem(req.user, req.libraryItem) + res.sendStatus(200) + } + middleware(req, res, next) { var item = this.db.libraryItems.find(li => li.id === req.params.id) if (!item || !item.media) return res.sendStatus(404) diff --git a/server/managers/AbMergeManager.js b/server/managers/AbMergeManager.js index cd20b33a..84fcac38 100644 --- a/server/managers/AbMergeManager.js +++ b/server/managers/AbMergeManager.js @@ -121,7 +121,7 @@ class AbMergeManager { '-acodec aac', '-ac 2', '-b:a 64k', - '-id3v2_version 3' + '-movflags use_metadata_tags' ]) } else { ffmpegOptions.push('-max_muxing_queue_size 1000') diff --git a/server/managers/AudioMetadataManager.js b/server/managers/AudioMetadataManager.js new file mode 100644 index 00000000..deaa3cc6 --- /dev/null +++ b/server/managers/AudioMetadataManager.js @@ -0,0 +1,137 @@ +const Path = require('path') +const fs = require('fs-extra') +const workerThreads = require('worker_threads') +const Logger = require('../Logger') +const filePerms = require('../utils/filePerms') +const { secondsToTimestamp } = require('../utils/index') +const { writeMetadataFile } = require('../utils/ffmpegHelpers') + +class AudioMetadataMangaer { + constructor(db, emitter, clientEmitter) { + this.db = db + this.emitter = emitter + this.clientEmitter = clientEmitter + } + + async updateAudioFileMetadataForItem(user, libraryItem) { + var audioFiles = libraryItem.media.audioFiles + + const itemAudioMetadataPayload = { + userId: user.id, + libraryItemId: libraryItem.id, + startedAt: Date.now(), + audioFiles: audioFiles.map(af => ({ index: af.index, ino: af.ino, filename: af.metadata.filename })) + } + + this.emitter('audio_metadata_started', itemAudioMetadataPayload) + + var downloadsPath = Path.join(global.MetadataPath, 'downloads') + var outputDir = Path.join(downloadsPath, libraryItem.id) + await fs.ensureDir(outputDir) + + var metadataFilePath = Path.join(outputDir, 'metadata.txt') + await writeMetadataFile(libraryItem, metadataFilePath) + + const proms = audioFiles.map(af => { + return this.updateAudioFileMetadata(libraryItem.id, af, outputDir, metadataFilePath) + }) + + const results = await Promise.all(proms) + + Logger.debug(`[AudioMetadataManager] Finished`, results) + + await fs.remove(outputDir) + + const elapsed = Date.now() - itemAudioMetadataPayload.startedAt + Logger.debug(`[AudioMetadataManager] Elapsed ${secondsToTimestamp(elapsed)}`) + itemAudioMetadataPayload.results = results + itemAudioMetadataPayload.elapsed = elapsed + itemAudioMetadataPayload.finishedAt = Date.now() + this.emitter('audio_metadata_finished', itemAudioMetadataPayload) + } + + updateAudioFileMetadata(libraryItemId, audioFile, outputDir, metadataFilePath) { + return new Promise((resolve) => { + const resultPayload = { + libraryItemId, + index: audioFile.index, + ino: audioFile.ino, + filename: audioFile.metadata.filename + } + this.emitter('audiofile_metadata_started', resultPayload) + + Logger.debug(`[AudioFileMetadataManager] Starting audio file metadata encode for "${audioFile.metadata.filename}"`) + + var outputPath = Path.join(outputDir, audioFile.metadata.filename) + var inputPath = audioFile.metadata.path + const isM4b = audioFile.metadata.format === 'm4b' + const ffmpegInputs = [ + { + input: inputPath, + options: isM4b ? ['-f mp4'] : [] + }, + { + input: metadataFilePath + } + ] + + /* + Mp4 doesnt support writing custom tags by default. Supported tags are itunes tags: https://git.videolan.org/?p=ffmpeg.git;a=blob;f=libavformat/movenc.c;h=b6821d447c92183101086cb67099b2f4804293de;hb=HEAD#l2905 + + Workaround -movflags use_metadata_tags found here: https://superuser.com/a/1208277 + + Ffmpeg premapped id3 tags: https://wiki.multimedia.cx/index.php/FFmpeg_Metadata + */ + + const ffmpegOptions = ['-c copy', '-map_metadata 1', `-metadata track=${audioFile.index}`, '-write_id3v2 1', '-movflags use_metadata_tags'] + var workerData = { + inputs: ffmpegInputs, + options: ffmpegOptions, + outputOptions: isM4b ? ['-f mp4'] : [], + output: outputPath, + } + var workerPath = Path.join(global.appRoot, 'server/utils/downloadWorker.js') + var worker = new workerThreads.Worker(workerPath, { workerData }) + + worker.on('message', async (message) => { + if (message != null && typeof message === 'object') { + if (message.type === 'RESULT') { + Logger.debug(message) + + if (message.success) { + Logger.debug(`[AudioFileMetadataManager] Metadata encode SUCCESS for "${audioFile.metadata.filename}"`) + + await filePerms.setDefault(outputPath, true) + + fs.move(outputPath, inputPath, { overwrite: true }).then(() => { + Logger.debug(`[AudioFileMetadataManager] Audio file replaced successfully "${inputPath}"`) + + resultPayload.success = true + this.emitter('audiofile_metadata_finished', resultPayload) + resolve(resultPayload) + }).catch((error) => { + Logger.error(`[AudioFileMetadataManager] Audio file failed to move "${inputPath}"`, error) + resultPayload.success = false + this.emitter('audiofile_metadata_finished', resultPayload) + resolve(resultPayload) + }) + } else { + Logger.debug(`[AudioFileMetadataManager] Metadata encode FAILED for "${audioFile.metadata.filename}"`) + + resultPayload.success = false + this.emitter('audiofile_metadata_finished', resultPayload) + resolve(resultPayload) + } + } else if (message.type === 'FFMPEG') { + if (Logger[message.level]) { + Logger[message.level](message.log) + } + } + } else { + Logger.error('Invalid worker message', message) + } + }) + }) + } +} +module.exports = AudioMetadataMangaer \ No newline at end of file diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index e322a476..bfd25b0e 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -25,7 +25,7 @@ const Series = require('../objects/entities/Series') const FileSystemController = require('../controllers/FileSystemController') class ApiRouter { - constructor(db, auth, scanner, playbackSessionManager, abMergeManager, coverManager, backupManager, watcher, cacheManager, podcastManager, emitter, clientEmitter) { + constructor(db, auth, scanner, playbackSessionManager, abMergeManager, coverManager, backupManager, watcher, cacheManager, podcastManager, audioMetadataManager, emitter, clientEmitter) { this.db = db this.auth = auth this.scanner = scanner @@ -36,6 +36,7 @@ class ApiRouter { this.watcher = watcher this.cacheManager = cacheManager this.podcastManager = podcastManager + this.audioMetadataManager = audioMetadataManager this.emitter = emitter this.clientEmitter = clientEmitter @@ -91,6 +92,7 @@ class ApiRouter { this.router.patch('/items/:id/episodes', LibraryItemController.middleware.bind(this), LibraryItemController.updateEpisodes.bind(this)) this.router.delete('/items/:id/episode/:episodeId', LibraryItemController.middleware.bind(this), LibraryItemController.removeEpisode.bind(this)) this.router.get('/items/:id/scan', LibraryItemController.middleware.bind(this), LibraryItemController.scan.bind(this)) // Root only + this.router.get('/items/:id/audio-metadata', LibraryItemController.middleware.bind(this), LibraryItemController.updateAudioFileMetadata.bind(this)) // Root only this.router.post('/items/batch/delete', LibraryItemController.batchDelete.bind(this)) this.router.post('/items/batch/update', LibraryItemController.batchUpdate.bind(this)) diff --git a/server/utils/downloadWorker.js b/server/utils/downloadWorker.js index 531d52c3..dfb89d15 100644 --- a/server/utils/downloadWorker.js +++ b/server/utils/downloadWorker.js @@ -39,7 +39,7 @@ async function runFfmpeg() { ffmpegCommand.on('stderr', (stdErrline) => { parentPort.postMessage({ type: 'FFMPEG', - level: 'error', + level: 'debug', log: '[DownloadWorker] Ffmpeg Stderr: ' + stdErrline }) }) diff --git a/server/utils/ffmpegHelpers.js b/server/utils/ffmpegHelpers.js index fc85d379..a2de5175 100644 --- a/server/utils/ffmpegHelpers.js +++ b/server/utils/ffmpegHelpers.js @@ -48,10 +48,33 @@ async function writeMetadataFile(libraryItem, outputPath) { `artist=${libraryItem.media.metadata.authorName}`, `album_artist=${libraryItem.media.metadata.authorName}`, `date=${libraryItem.media.metadata.publishedYear || ''}`, - `description=${libraryItem.media.metadata.description}`, - `genre=${libraryItem.media.metadata.genres.join(';')}` + `description=${libraryItem.media.metadata.description || ''}`, + `genre=${libraryItem.media.metadata.genres.join(';')}`, + `performer=${libraryItem.media.metadata.narratorName || ''}`, + `encoded_by=audiobookshelf:${package.version}` ] + if (libraryItem.media.metadata.asin) { + inputstrs.push(`ASIN=${libraryItem.media.metadata.asin}`) + } + if (libraryItem.media.metadata.isbn) { + inputstrs.push(`ISBN=${libraryItem.media.metadata.isbn}`) + } + if (libraryItem.media.metadata.language) { + inputstrs.push(`language=${libraryItem.media.metadata.language}`) + } + if (libraryItem.media.metadata.series.length) { + // Only uses first series + var firstSeries = libraryItem.media.metadata.series[0] + inputstrs.push(`series=${firstSeries.name}`) + if (firstSeries.sequence) { + inputstrs.push(`series-part=${firstSeries.sequence}`) + } + } + if (libraryItem.media.metadata.subtitle) { + inputstrs.push(`subtitle=${libraryItem.media.metadata.subtitle}`) + } + if (libraryItem.media.chapters) { libraryItem.media.chapters.forEach((chap) => { const chapterstrs = [ diff --git a/server/utils/prober.js b/server/utils/prober.js index a8053a5d..a3ebb182 100644 --- a/server/utils/prober.js +++ b/server/utils/prober.js @@ -204,12 +204,12 @@ function parseTags(format, verbose) { } } - var keysToLookOutFor = ['file_tag_genre1', 'file_tag_genre2', 'file_tag_series', 'file_tag_seriespart', 'file_tag_movement', 'file_tag_movementname', 'file_tag_wwwaudiofile', 'file_tag_contentgroup', 'file_tag_releasetime', 'file_tag_isbn'] - keysToLookOutFor.forEach((key) => { - if (tags[key]) { - Logger.debug(`Notable! ${key} => ${tags[key]}`) - } - }) + // var keysToLookOutFor = ['file_tag_genre1', 'file_tag_genre2', 'file_tag_series', 'file_tag_seriespart', 'file_tag_movement', 'file_tag_movementname', 'file_tag_wwwaudiofile', 'file_tag_contentgroup', 'file_tag_releasetime', 'file_tag_isbn'] + // keysToLookOutFor.forEach((key) => { + // if (tags[key]) { + // Logger.debug(`Notable! ${key} => ${tags[key]}`) + // } + // }) return tags }