mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-01-26 00:14:49 +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