diff --git a/client/pages/audiobook/_id/manage.vue b/client/pages/audiobook/_id/manage.vue index 9142c1d3..d2c24e90 100644 --- a/client/pages/audiobook/_id/manage.vue +++ b/client/pages/audiobook/_id/manage.vue @@ -63,6 +63,10 @@
+ + +
+ {{ $strings.ButtonStartMetadataEmbed }}

{{ $strings.MessageEmbedFinished }}

@@ -104,7 +108,7 @@

-
+
star

A backup of your original audio files will be stored in /metadata/cache/items/{{ libraryItemId }}/. Make sure to periodically purge items cache. @@ -171,7 +175,7 @@ export default { if (!store.getters['user/getIsAdminOrUp']) { return redirect('/?error=unauthorized') } - var libraryItem = await app.$axios.$get(`/api/items/${params.id}?expanded=1`).catch((error) => { + const libraryItem = await app.$axios.$get(`/api/items/${params.id}?expanded=1`).catch((error) => { console.error('Failed', error) return false }) @@ -201,6 +205,7 @@ export default { selectedTool: 'embed', isCancelingEncode: false, showEncodeOptions: false, + shouldBackupAudioFiles: true, encodingOptions: { bitrate: '64k', channels: '2', @@ -275,6 +280,9 @@ export default { } }, methods: { + toggleBackupAudioFiles(val) { + localStorage.setItem('embedMetadataShouldBackup', val ? 1 : 0) + }, cancelEncodeClick() { this.isCancelingEncode = true this.$axios @@ -332,7 +340,7 @@ export default { updateAudioFileMetadata() { this.processing = true this.$axios - .$post(`/api/tools/item/${this.libraryItemId}/embed-metadata?tone=1`) + .$post(`/api/tools/item/${this.libraryItemId}/embed-metadata?backup=${this.shouldBackupAudioFiles ? 1 : 0}`) .then(() => { console.log('Audio metadata encode started') }) @@ -350,9 +358,14 @@ export default { console.log('audio metadata finished', data) if (data.libraryItemId !== this.libraryItemId) return this.processing = false - this.isFinished = true this.audiofilesEncoding = {} - this.$toast.success('Audio file metadata updated') + + if (data.failed) { + this.$toast.error(data.error) + } else { + this.isFinished = true + this.$toast.success('Audio file metadata updated') + } }, audiofileMetadataStarted(data) { if (data.libraryItemId !== this.libraryItemId) return @@ -378,6 +391,9 @@ export default { } if (this.task) this.taskUpdated(this.task) + + const shouldBackupAudioFiles = localStorage.getItem('embedMetadataShouldBackup') + this.shouldBackupAudioFiles = shouldBackupAudioFiles != 0 }, fetchToneObject() { this.$axios diff --git a/server/controllers/ToolsController.js b/server/controllers/ToolsController.js index b0433b26..1243175e 100644 --- a/server/controllers/ToolsController.js +++ b/server/controllers/ToolsController.js @@ -47,7 +47,6 @@ class ToolsController { res.sendStatus(200) } - // POST: api/tools/item/:id/embed-metadata async embedAudioFileMetadata(req, res) { if (!req.user.isAdminOrUp) { @@ -60,9 +59,11 @@ class ToolsController { return res.sendStatus(500) } - const useTone = req.query.tone === '1' - const forceEmbedChapters = req.query.forceEmbedChapters === '1' - this.audioMetadataManager.updateMetadataForItem(req.user, req.libraryItem, useTone, forceEmbedChapters) + const options = { + forceEmbedChapters: req.query.forceEmbedChapters === '1', + backup: req.query.backup === '1' + } + this.audioMetadataManager.updateMetadataForItem(req.user, req.libraryItem, options) res.sendStatus(200) } diff --git a/server/managers/AudioMetadataManager.js b/server/managers/AudioMetadataManager.js index 8ba03858..641a7e1c 100644 --- a/server/managers/AudioMetadataManager.js +++ b/server/managers/AudioMetadataManager.js @@ -1,16 +1,13 @@ const Path = require('path') -const workerThreads = require('worker_threads') const SocketAuthority = require('../SocketAuthority') const Logger = require('../Logger') const fs = require('../libs/fsExtra') -const filePerms = require('../utils/filePerms') const { secondsToTimestamp } = require('../utils/index') -const { filePathToPOSIX } = require('../utils/fileUtils') -const { writeMetadataFile } = require('../utils/ffmpegHelpers') const toneHelpers = require('../utils/toneHelpers') +const filePerms = require('../utils/filePerms') class AudioMetadataMangaer { constructor(db, taskManager) { @@ -18,23 +15,15 @@ class AudioMetadataMangaer { this.taskManager = taskManager } - updateMetadataForItem(user, libraryItem, useTone, forceEmbedChapters) { - if (useTone) { - this.updateMetadataForItemWithTone(user, libraryItem, forceEmbedChapters) - } else { - this.updateMetadataForItemWithFfmpeg(user, libraryItem) - } - } - - // - // TONE - // getToneMetadataObjectForApi(libraryItem) { return toneHelpers.getToneMetadataObject(libraryItem) } - async updateMetadataForItemWithTone(user, libraryItem, forceEmbedChapters) { - var audioFiles = libraryItem.media.includedAudioFiles + async updateMetadataForItem(user, libraryItem, options = {}) { + const forceEmbedChapters = !!options.forceEmbedChapters + const backupFiles = !!options.backup + + const audioFiles = libraryItem.media.includedAudioFiles const itemAudioMetadataPayload = { userId: user.id, @@ -45,35 +34,55 @@ class AudioMetadataMangaer { SocketAuthority.emitter('audio_metadata_started', itemAudioMetadataPayload) - // Write chapters file - var toneJsonPath = null + // Ensure folder for backup files const itemCacheDir = Path.join(global.MetadataPath, `cache/items/${libraryItem.id}`) - await fs.ensureDir(itemCacheDir) + let cacheDirCreated = false + if (!await fs.pathExists(itemCacheDir)) { + await fs.mkdir(itemCacheDir) + await filePerms.setDefault(itemCacheDir, true) + cacheDirCreated = true + } + + // Write chapters file + const toneJsonPath = Path.join(itemCacheDir, 'metadata.json') 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 + + itemAudioMetadataPayload.failed = true + itemAudioMetadataPayload.error = 'Failed to write metadata.json' + SocketAuthority.emitter('audio_metadata_finished', itemAudioMetadataPayload) + return } const results = [] for (const af of audioFiles) { - const result = await this.updateAudioFileMetadataWithTone(libraryItem.id, af, toneJsonPath, itemCacheDir) + const result = await this.updateAudioFileMetadataWithTone(libraryItem.id, af, toneJsonPath, itemCacheDir, backupFiles) results.push(result) } + // Remove temp cache file/folder if not backing up + if (!backupFiles) { + // If cache dir was created from this then remove it + if (cacheDirCreated) { + await fs.remove(itemCacheDir) + } else { + await fs.remove(toneJsonPath) + } + } + const elapsed = Date.now() - itemAudioMetadataPayload.startedAt - Logger.debug(`[AudioMetadataManager] Elapsed ${secondsToTimestamp(elapsed)}`) + Logger.debug(`[AudioMetadataManager] Elapsed ${secondsToTimestamp(elapsed / 1000, true)}`) itemAudioMetadataPayload.results = results itemAudioMetadataPayload.elapsed = elapsed itemAudioMetadataPayload.finishedAt = Date.now() SocketAuthority.emitter('audio_metadata_finished', itemAudioMetadataPayload) } - async updateAudioFileMetadataWithTone(libraryItemId, audioFile, toneJsonPath, itemCacheDir) { + async updateAudioFileMetadataWithTone(libraryItemId, audioFile, toneJsonPath, itemCacheDir, backupFiles) { const resultPayload = { libraryItemId, index: audioFile.index, @@ -83,12 +92,14 @@ class AudioMetadataMangaer { SocketAuthority.emitter('audiofile_metadata_started', resultPayload) // Backup audio file - try { - const backupFilePath = Path.join(itemCacheDir, audioFile.metadata.filename) - await fs.copy(audioFile.metadata.path, backupFilePath) - Logger.debug(`[AudioMetadataManager] Backed up audio file at "${backupFilePath}"`) - } catch (err) { - Logger.error(`[AudioMetadataManager] Failed to backup audio file "${audioFile.metadata.path}"`, err) + if (backupFiles) { + try { + const backupFilePath = Path.join(itemCacheDir, audioFile.metadata.filename) + await fs.copy(audioFile.metadata.path, backupFilePath) + Logger.debug(`[AudioMetadataManager] Backed up audio file at "${backupFilePath}"`) + } catch (err) { + Logger.error(`[AudioMetadataManager] Failed to backup audio file "${audioFile.metadata.path}"`, err) + } } const _toneMetadataObject = { @@ -104,161 +115,5 @@ class AudioMetadataMangaer { SocketAuthority.emitter('audiofile_metadata_finished', resultPayload) return resultPayload } - - // - // FFMPEG - // - async updateMetadataForItemWithFfmpeg(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 })) - } - - SocketAuthority.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) - - if (libraryItem.media.coverPath != null) { - var coverPath = filePathToPOSIX(libraryItem.media.coverPath) - } - - const proms = audioFiles.map(af => { - return this.updateAudioFileMetadataWithFfmpeg(libraryItem.id, af, outputDir, metadataFilePath, coverPath) - }) - - const results = await Promise.all(proms) - - Logger.debug(`[AudioMetadataManager] Finished`) - - 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() - SocketAuthority.emitter('audio_metadata_finished', itemAudioMetadataPayload) - } - - updateAudioFileMetadataWithFfmpeg(libraryItemId, audioFile, outputDir, metadataFilePath, coverPath = '') { - return new Promise((resolve) => { - const resultPayload = { - libraryItemId, - index: audioFile.index, - ino: audioFile.ino, - filename: audioFile.metadata.filename - } - SocketAuthority.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_chapters 1', '-map_metadata 1', `-metadata track=${audioFile.index}`, '-write_id3v2 1', '-movflags use_metadata_tags'] - - if (coverPath != '') { - var ffmpegCoverPathInput = { - input: coverPath, - options: ['-f image2pipe'] - } - var ffmpegCoverPathOptions = [ - '-c:v copy', - '-map 2:v', - '-map 0:a' - ] - - ffmpegInputs.push(ffmpegCoverPathInput) - Logger.debug(`[AudioFileMetaDataManager] Cover found for "${audioFile.metadata.filename}". Cover will be merged to metadata`) - } else { - // remove the video stream to account for the user getting rid an existing cover in abs - var ffmpegCoverPathOptions = [ - '-map 0', - '-map -0:v' - ] - - Logger.debug(`[AudioFileMetaDataManager] No cover found for "${audioFile.metadata.filename}". Cover will be skipped or removed from metadata`) - } - - ffmpegOptions.push(...ffmpegCoverPathOptions) - - 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 - SocketAuthority.emitter('audiofile_metadata_finished', resultPayload) - resolve(resultPayload) - }).catch((error) => { - Logger.error(`[AudioFileMetadataManager] Audio file failed to move "${inputPath}"`, error) - resultPayload.success = false - SocketAuthority.emitter('audiofile_metadata_finished', resultPayload) - resolve(resultPayload) - }) - } else { - Logger.debug(`[AudioFileMetadataManager] Metadata encode FAILED for "${audioFile.metadata.filename}"`) - - resultPayload.success = false - SocketAuthority.emitter('audiofile_metadata_finished', resultPayload) - resolve(resultPayload) - } - } else if (message.type === 'FFMPEG') { - if (message.level === 'debug' && process.env.NODE_ENV === 'production') { - // stderr is not necessary in production - } else if (Logger[message.level]) { - Logger[message.level](message.log) - } - } - } else { - Logger.error('Invalid worker message', message) - } - }) - }) - } } module.exports = AudioMetadataMangaer diff --git a/server/utils/ffmpegHelpers.js b/server/utils/ffmpegHelpers.js index 0775292d..72c778d2 100644 --- a/server/utils/ffmpegHelpers.js +++ b/server/utils/ffmpegHelpers.js @@ -1,7 +1,6 @@ const Ffmpeg = require('../libs/fluentFfmpeg') const fs = require('../libs/fsExtra') const Path = require('path') -const package = require('../../package.json') const Logger = require('../Logger') const { filePathToPOSIX } = require('./fileUtils') @@ -41,59 +40,6 @@ async function writeConcatFile(tracks, outputPath, startTime = 0) { } module.exports.writeConcatFile = writeConcatFile - -async function writeMetadataFile(libraryItem, outputPath) { - var inputstrs = [ - ';FFMETADATA1', - `title=${libraryItem.media.metadata.title}`, - `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(';')}`, - `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 = [ - '[CHAPTER]', - 'TIMEBASE=1/1000', - `START=${Math.round(chap.start * 1000)}`, - `END=${Math.round(chap.end * 1000)}`, - `title=${chap.title}` - ] - inputstrs = inputstrs.concat(chapterstrs) - }) - } - - await fs.writeFile(outputPath, inputstrs.join('\n')) - return inputstrs -} -module.exports.writeMetadataFile = writeMetadataFile - async function extractCoverArt(filepath, outputpath) { var dirname = Path.dirname(outputpath) await fs.ensureDir(dirname) diff --git a/server/utils/index.js b/server/utils/index.js index 503788ce..034e3beb 100644 --- a/server/utils/index.js +++ b/server/utils/index.js @@ -100,8 +100,6 @@ function secondsToTimestamp(seconds, includeMs = false, alwaysIncludeHours = fal } module.exports.secondsToTimestamp = secondsToTimestamp -module.exports.msToTimestamp = (ms, includeMs) => secondsToTimestamp(ms / 1000, includeMs) - module.exports.reqSupportsWebp = (req) => { if (!req || !req.headers || !req.headers.accept) return false return req.headers.accept.includes('image/webp') || req.headers.accept === '*/*'