mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-10-27 11:18:14 +01:00 
			
		
		
		
	Replace tone with ffmpeg for metadata and cover embedding
This commit is contained in:
		
							parent
							
								
									04a65648a3
								
							
						
					
					
						commit
						6fbbc65edf
					
				| @ -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.`) | ||||
|  | ||||
| @ -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 | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user