From 6fbbc65edf429e5d4f6ef3d05c8b31a4ea2bf94b Mon Sep 17 00:00:00 2001 From: mikiher Date: Sat, 29 Jun 2024 20:04:23 +0300 Subject: [PATCH] Replace tone with ffmpeg for metadata and cover embedding --- server/managers/AudioMetadataManager.js | 66 ++++++------- server/utils/ffmpegHelpers.js | 123 ++++++++++++++++++++++++ 2 files changed, 150 insertions(+), 39 deletions(-) diff --git a/server/managers/AudioMetadataManager.js b/server/managers/AudioMetadataManager.js index 11c82822..8b068223 100644 --- a/server/managers/AudioMetadataManager.js +++ b/server/managers/AudioMetadataManager.js @@ -5,7 +5,7 @@ const Logger = require('../Logger') const fs = require('../libs/fsExtra') -const toneHelpers = require('../utils/toneHelpers') +const ffmpegHelpers = require('../utils/ffmpegHelpers') const TaskManager = require('./TaskManager') @@ -21,22 +21,19 @@ class AudioMetadataMangaer { } /** - * Get queued task data - * @return {Array} - */ + * Get queued task data + * @return {Array} + */ getQueuedTaskData() { - return this.tasksQueued.map(t => t.data) + return this.tasksQueued.map((t) => t.data) } getIsLibraryItemQueuedOrProcessing(libraryItemId) { - return this.tasksQueued.some(t => t.data.libraryItemId === libraryItemId) || this.tasksRunning.some(t => t.data.libraryItemId === libraryItemId) + return this.tasksQueued.some((t) => t.data.libraryItemId === libraryItemId) || this.tasksRunning.some((t) => t.data.libraryItemId === libraryItemId) } getToneMetadataObjectForApi(libraryItem) { - const audioFiles = libraryItem.media.includedAudioFiles - let mimeType = audioFiles[0].mimeType - if (audioFiles.some(a => a.mimeType !== mimeType)) mimeType = null - return toneHelpers.getToneMetadataObject(libraryItem, libraryItem.media.chapters, libraryItem.media.tracks.length, mimeType) + return ffmpegHelpers.getFFMetadataObject(libraryItem, libraryItem.media.includedAudioFiles.length) } handleBatchEmbed(user, libraryItems, options = {}) { @@ -56,29 +53,28 @@ class AudioMetadataMangaer { const itemCachePath = Path.join(this.itemsCacheDir, libraryItem.id) // Only writing chapters for single file audiobooks - const chapters = (audioFiles.length == 1 || forceEmbedChapters) ? libraryItem.media.chapters.map(c => ({ ...c })) : null + const chapters = audioFiles.length == 1 || forceEmbedChapters ? libraryItem.media.chapters.map((c) => ({ ...c })) : null let mimeType = audioFiles[0].mimeType - if (audioFiles.some(a => a.mimeType !== mimeType)) mimeType = null + if (audioFiles.some((a) => a.mimeType !== mimeType)) mimeType = null // Create task const taskData = { libraryItemId: libraryItem.id, libraryItemPath: libraryItem.path, userId: user.id, - audioFiles: audioFiles.map(af => ( - { - index: af.index, - ino: af.ino, - filename: af.metadata.filename, - path: af.metadata.path, - cachePath: Path.join(itemCachePath, af.metadata.filename) - } - )), + audioFiles: audioFiles.map((af) => ({ + index: af.index, + ino: af.ino, + filename: af.metadata.filename, + path: af.metadata.path, + cachePath: Path.join(itemCachePath, af.metadata.filename) + })), coverPath: libraryItem.media.coverPath, - metadataObject: toneHelpers.getToneMetadataObject(libraryItem, chapters, audioFiles.length, mimeType), + metadataObject: ffmpegHelpers.getFFMetadataObject(libraryItem, audioFiles.length), itemCachePath, chapters, + mimeType, options: { forceEmbedChapters, backupFiles @@ -107,18 +103,19 @@ class AudioMetadataMangaer { // Ensure item cache dir exists let cacheDirCreated = false - if (!await fs.pathExists(task.data.itemCachePath)) { + if (!(await fs.pathExists(task.data.itemCachePath))) { await fs.mkdir(task.data.itemCachePath) cacheDirCreated = true } // Create metadata json file - const toneJsonPath = Path.join(task.data.itemCachePath, 'metadata.json') + const ffmetadataPath = Path.join(task.data.itemCachePath, 'ffmetadata.txt') try { - await fs.writeFile(toneJsonPath, JSON.stringify({ meta: task.data.metadataObject }, null, 2)) + await fs.writeFile(ffmetadataPath, ffmpegHelpers.generateFFMetadata(task.data.metadataObject, task.data.chapters)) + Logger.debug(`[AudioMetadataManager] Wrote ${ffmetadataPath}`) } catch (error) { - Logger.error(`[AudioMetadataManager] Write metadata.json failed`, error) - task.setFailed('Failed to write metadata.json') + Logger.error(`[AudioMetadataManager] Write ${ffmetadataPath} failed`, error) + task.setFailed('Failed to write file ffmetadata.txt') this.handleTaskFinished(task) return } @@ -141,16 +138,7 @@ class AudioMetadataMangaer { } } - const _toneMetadataObject = { - 'ToneJsonFile': toneJsonPath, - 'TrackNumber': af.index, - } - - if (task.data.coverPath) { - _toneMetadataObject['CoverFile'] = task.data.coverPath - } - - const success = await toneHelpers.tagAudioFile(af.path, _toneMetadataObject) + const success = await ffmpegHelpers.addCoverAndMetadataToFile(af.path, task.data.coverPath, ffmetadataPath, af.path) if (success) { Logger.info(`[AudioMetadataManager] Successfully tagged audio file "${af.path}"`) } @@ -167,7 +155,7 @@ class AudioMetadataMangaer { if (cacheDirCreated) { await fs.remove(task.data.itemCachePath) } else { - await fs.remove(toneJsonPath) + await fs.remove(ffmetadataPath) } } @@ -177,7 +165,7 @@ class AudioMetadataMangaer { handleTaskFinished(task) { TaskManager.taskFinished(task) - this.tasksRunning = this.tasksRunning.filter(t => t.id !== task.id) + this.tasksRunning = this.tasksRunning.filter((t) => t.id !== task.id) if (this.tasksRunning.length < this.MAX_CONCURRENT_TASKS && this.tasksQueued.length) { Logger.info(`[AudioMetadataManager] Task finished and dequeueing next task. ${this.tasksQueued} tasks queued.`) diff --git a/server/utils/ffmpegHelpers.js b/server/utils/ffmpegHelpers.js index 491f59dc..c4cf43b9 100644 --- a/server/utils/ffmpegHelpers.js +++ b/server/utils/ffmpegHelpers.js @@ -1,6 +1,7 @@ const axios = require('axios') const Ffmpeg = require('../libs/fluentFfmpeg') const fs = require('../libs/fsExtra') +const os = require('os') const Path = require('path') const Logger = require('../Logger') const { filePathToPOSIX } = require('./fileUtils') @@ -184,3 +185,125 @@ module.exports.downloadPodcastEpisode = (podcastEpisodeDownload) => { ffmpeg.run() }) } + +/** + * Generates ffmetadata file content from the provided metadata object and chapters array. + * @param {Object} metadata - The input metadata object. + * @param {Array} chapters - An array of chapter objects. + * @returns {string} - The ffmetadata file content. + */ +function generateFFMetadata(metadata, chapters) { + let ffmetadataContent = ';FFMETADATA1\n' + + // Add global metadata + for (const key in metadata) { + if (metadata[key]) { + ffmetadataContent += `${key}=${escapeFFMetadataValue(metadata[key])}\n` + } + } + + // Add chapters + chapters.forEach((chapter) => { + ffmetadataContent += '\n[CHAPTER]\n' + ffmetadataContent += `TIMEBASE=1/1000\n` + ffmetadataContent += `START=${Math.floor(chapter.start * 1000)}\n` + ffmetadataContent += `END=${Math.floor(chapter.end * 1000)}\n` + if (chapter.title) { + ffmetadataContent += `title=${escapeFFMetadataValue(chapter.title)}\n` + } + }) + + return ffmetadataContent +} + +module.exports.generateFFMetadata = generateFFMetadata + +/** + * Adds an ffmetadata and optionally a cover image to an audio file using fluent-ffmpeg. + * @param {string} audioFilePath - Path to the input audio file. + * @param {string|null} coverFilePath - Path to the cover image file. + * @param {string} metadataFilePath - Path to the ffmetadata file. + */ +async function addCoverAndMetadataToFile(audioFilePath, coverFilePath, metadataFilePath) { + return new Promise((resolve) => { + const tempFilePath = Path.join(os.tmpdir(), 'temp_output.m4b') + let ffmpeg = Ffmpeg() + ffmpeg.input(audioFilePath).input(metadataFilePath).outputOptions([ + '-map 0:a', // map audio stream from input file + '-map_metadata 1', // map metadata from metadata file + '-map_chapters 1', // map chapters from metadata file + '-c copy', // copy streams + '-f mp4' // force mp4 format + ]) + + if (coverFilePath) { + ffmpeg.input(coverFilePath).outputOptions([ + '-map 2:v', // map video stream from cover image file + '-disposition:v:0 attached_pic', // set cover image as attached picture + '-metadata:s:v', + 'title=Cover', // add title metadata to cover image stream + '-metadata:s:v', + 'comment=Cover' // add comment metadata to cover image stream + ]) + } else { + ffmpeg.outputOptions([ + '-map 0:v?' // retain video stream from input file if exists + ]) + } + + ffmpeg + .output(tempFilePath) + .on('start', function (commandLine) { + Logger.debug('[ffmpegHelpers] Spawned Ffmpeg with command: ' + commandLine) + }) + .on('end', (stdout, stderr) => { + Logger.debug('[ffmpegHelpers] ffmpeg stdout:', stdout) + Logger.debug('[ffmpegHelpers] ffmpeg stderr:', stderr) + fs.copyFileSync(tempFilePath, audioFilePath) + fs.unlinkSync(tempFilePath) + resolve(true) + }) + .on('error', (err, stdout, stderr) => { + Logger.error('Error adding cover image and metadata:', err) + Logger.error('ffmpeg stdout:', stdout) + Logger.error('ffmpeg stderr:', stderr) + resolve(false) + }) + + ffmpeg.run() + }) +} + +module.exports.addCoverAndMetadataToFile = addCoverAndMetadataToFile + +function escapeFFMetadataValue(value) { + return value.replace(/([;=\n\\#])/g, '\\$1') +} + +function getFFMetadataObject(libraryItem, audioFilesLength) { + const metadata = libraryItem.media.metadata + + const ffmetadata = { + title: metadata.title, + artist: metadata.authors?.map((a) => a.name).join(', '), + album_artist: metadata.authors?.map((a) => a.name).join(', '), + album: (metadata.title || '') + (metadata.subtitle ? `: ${metadata.subtitle}` : ''), + genre: metadata.genres?.join('; '), + date: metadata.publishedYear, + comment: metadata.description, + description: metadata.description, + composer: metadata.narratorName, + copyright: metadata.publisher, + grouping: metadata.series?.map((s) => s.name + (s.sequence ? ` #${s.sequence}` : '')).join(', ') + } + + Object.keys(ffmetadata).forEach((key) => { + if (!ffmetadata[key]) { + delete ffmetadata[key] + } + }) + + return ffmetadata +} + +module.exports.getFFMetadataObject = getFFMetadataObject