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 fs = require('../libs/fsExtra')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const toneHelpers = require('../utils/toneHelpers')
 | 
					const ffmpegHelpers = require('../utils/ffmpegHelpers')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const TaskManager = require('./TaskManager')
 | 
					const TaskManager = require('./TaskManager')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -25,18 +25,15 @@ class AudioMetadataMangaer {
 | 
				
			|||||||
   * @return {Array}
 | 
					   * @return {Array}
 | 
				
			||||||
   */
 | 
					   */
 | 
				
			||||||
  getQueuedTaskData() {
 | 
					  getQueuedTaskData() {
 | 
				
			||||||
    return this.tasksQueued.map(t => t.data)
 | 
					    return this.tasksQueued.map((t) => t.data)
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  getIsLibraryItemQueuedOrProcessing(libraryItemId) {
 | 
					  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) {
 | 
					  getToneMetadataObjectForApi(libraryItem) {
 | 
				
			||||||
    const audioFiles = libraryItem.media.includedAudioFiles
 | 
					    return ffmpegHelpers.getFFMetadataObject(libraryItem, libraryItem.media.includedAudioFiles.length)
 | 
				
			||||||
    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)
 | 
					 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  handleBatchEmbed(user, libraryItems, options = {}) {
 | 
					  handleBatchEmbed(user, libraryItems, options = {}) {
 | 
				
			||||||
@ -56,29 +53,28 @@ class AudioMetadataMangaer {
 | 
				
			|||||||
    const itemCachePath = Path.join(this.itemsCacheDir, libraryItem.id)
 | 
					    const itemCachePath = Path.join(this.itemsCacheDir, libraryItem.id)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // Only writing chapters for single file audiobooks
 | 
					    // 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
 | 
					    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
 | 
					    // Create task
 | 
				
			||||||
    const taskData = {
 | 
					    const taskData = {
 | 
				
			||||||
      libraryItemId: libraryItem.id,
 | 
					      libraryItemId: libraryItem.id,
 | 
				
			||||||
      libraryItemPath: libraryItem.path,
 | 
					      libraryItemPath: libraryItem.path,
 | 
				
			||||||
      userId: user.id,
 | 
					      userId: user.id,
 | 
				
			||||||
      audioFiles: audioFiles.map(af => (
 | 
					      audioFiles: audioFiles.map((af) => ({
 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
        index: af.index,
 | 
					        index: af.index,
 | 
				
			||||||
        ino: af.ino,
 | 
					        ino: af.ino,
 | 
				
			||||||
        filename: af.metadata.filename,
 | 
					        filename: af.metadata.filename,
 | 
				
			||||||
        path: af.metadata.path,
 | 
					        path: af.metadata.path,
 | 
				
			||||||
        cachePath: Path.join(itemCachePath, af.metadata.filename)
 | 
					        cachePath: Path.join(itemCachePath, af.metadata.filename)
 | 
				
			||||||
        }
 | 
					      })),
 | 
				
			||||||
      )),
 | 
					 | 
				
			||||||
      coverPath: libraryItem.media.coverPath,
 | 
					      coverPath: libraryItem.media.coverPath,
 | 
				
			||||||
      metadataObject: toneHelpers.getToneMetadataObject(libraryItem, chapters, audioFiles.length, mimeType),
 | 
					      metadataObject: ffmpegHelpers.getFFMetadataObject(libraryItem, audioFiles.length),
 | 
				
			||||||
      itemCachePath,
 | 
					      itemCachePath,
 | 
				
			||||||
      chapters,
 | 
					      chapters,
 | 
				
			||||||
 | 
					      mimeType,
 | 
				
			||||||
      options: {
 | 
					      options: {
 | 
				
			||||||
        forceEmbedChapters,
 | 
					        forceEmbedChapters,
 | 
				
			||||||
        backupFiles
 | 
					        backupFiles
 | 
				
			||||||
@ -107,18 +103,19 @@ class AudioMetadataMangaer {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    // Ensure item cache dir exists
 | 
					    // Ensure item cache dir exists
 | 
				
			||||||
    let cacheDirCreated = false
 | 
					    let cacheDirCreated = false
 | 
				
			||||||
    if (!await fs.pathExists(task.data.itemCachePath)) {
 | 
					    if (!(await fs.pathExists(task.data.itemCachePath))) {
 | 
				
			||||||
      await fs.mkdir(task.data.itemCachePath)
 | 
					      await fs.mkdir(task.data.itemCachePath)
 | 
				
			||||||
      cacheDirCreated = true
 | 
					      cacheDirCreated = true
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // Create metadata json file
 | 
					    // Create metadata json file
 | 
				
			||||||
    const toneJsonPath = Path.join(task.data.itemCachePath, 'metadata.json')
 | 
					    const ffmetadataPath = Path.join(task.data.itemCachePath, 'ffmetadata.txt')
 | 
				
			||||||
    try {
 | 
					    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) {
 | 
					    } catch (error) {
 | 
				
			||||||
      Logger.error(`[AudioMetadataManager] Write metadata.json failed`, error)
 | 
					      Logger.error(`[AudioMetadataManager] Write ${ffmetadataPath} failed`, error)
 | 
				
			||||||
      task.setFailed('Failed to write metadata.json')
 | 
					      task.setFailed('Failed to write file ffmetadata.txt')
 | 
				
			||||||
      this.handleTaskFinished(task)
 | 
					      this.handleTaskFinished(task)
 | 
				
			||||||
      return
 | 
					      return
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
@ -141,16 +138,7 @@ class AudioMetadataMangaer {
 | 
				
			|||||||
        }
 | 
					        }
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      const _toneMetadataObject = {
 | 
					      const success = await ffmpegHelpers.addCoverAndMetadataToFile(af.path, task.data.coverPath, ffmetadataPath, af.path)
 | 
				
			||||||
        'ToneJsonFile': toneJsonPath,
 | 
					 | 
				
			||||||
        'TrackNumber': af.index,
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      if (task.data.coverPath) {
 | 
					 | 
				
			||||||
        _toneMetadataObject['CoverFile'] = task.data.coverPath
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      const success = await toneHelpers.tagAudioFile(af.path, _toneMetadataObject)
 | 
					 | 
				
			||||||
      if (success) {
 | 
					      if (success) {
 | 
				
			||||||
        Logger.info(`[AudioMetadataManager] Successfully tagged audio file "${af.path}"`)
 | 
					        Logger.info(`[AudioMetadataManager] Successfully tagged audio file "${af.path}"`)
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
@ -167,7 +155,7 @@ class AudioMetadataMangaer {
 | 
				
			|||||||
      if (cacheDirCreated) {
 | 
					      if (cacheDirCreated) {
 | 
				
			||||||
        await fs.remove(task.data.itemCachePath)
 | 
					        await fs.remove(task.data.itemCachePath)
 | 
				
			||||||
      } else {
 | 
					      } else {
 | 
				
			||||||
        await fs.remove(toneJsonPath)
 | 
					        await fs.remove(ffmetadataPath)
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -177,7 +165,7 @@ class AudioMetadataMangaer {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  handleTaskFinished(task) {
 | 
					  handleTaskFinished(task) {
 | 
				
			||||||
    TaskManager.taskFinished(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) {
 | 
					    if (this.tasksRunning.length < this.MAX_CONCURRENT_TASKS && this.tasksQueued.length) {
 | 
				
			||||||
      Logger.info(`[AudioMetadataManager] Task finished and dequeueing next task. ${this.tasksQueued} tasks queued.`)
 | 
					      Logger.info(`[AudioMetadataManager] Task finished and dequeueing next task. ${this.tasksQueued} tasks queued.`)
 | 
				
			||||||
 | 
				
			|||||||
@ -1,6 +1,7 @@
 | 
				
			|||||||
const axios = require('axios')
 | 
					const axios = require('axios')
 | 
				
			||||||
const Ffmpeg = require('../libs/fluentFfmpeg')
 | 
					const Ffmpeg = require('../libs/fluentFfmpeg')
 | 
				
			||||||
const fs = require('../libs/fsExtra')
 | 
					const fs = require('../libs/fsExtra')
 | 
				
			||||||
 | 
					const os = require('os')
 | 
				
			||||||
const Path = require('path')
 | 
					const Path = require('path')
 | 
				
			||||||
const Logger = require('../Logger')
 | 
					const Logger = require('../Logger')
 | 
				
			||||||
const { filePathToPOSIX } = require('./fileUtils')
 | 
					const { filePathToPOSIX } = require('./fileUtils')
 | 
				
			||||||
@ -184,3 +185,125 @@ module.exports.downloadPodcastEpisode = (podcastEpisodeDownload) => {
 | 
				
			|||||||
    ffmpeg.run()
 | 
					    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