mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-12-24 04:39:40 +00:00
Merge pull request #3111 from mikiher/tone-replacement
Replace tone with ffmpeg for metadata and cover embedding
This commit is contained in:
commit
9a4c5a16ef
18 changed files with 542 additions and 448 deletions
|
|
@ -559,9 +559,9 @@ class LibraryItemController {
|
|||
})
|
||||
}
|
||||
|
||||
getToneMetadataObject(req, res) {
|
||||
getMetadataObject(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error(`[LibraryItemController] Non-admin user attempted to get tone metadata object`, req.user)
|
||||
Logger.error(`[LibraryItemController] Non-admin user attempted to get metadata object`, req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
|
|
@ -570,7 +570,7 @@ class LibraryItemController {
|
|||
return res.sendStatus(500)
|
||||
}
|
||||
|
||||
res.json(this.audioMetadataManager.getToneMetadataObjectForApi(req.libraryItem))
|
||||
res.json(this.audioMetadataManager.getMetadataObjectForApi(req.libraryItem))
|
||||
}
|
||||
|
||||
// POST: api/items/:id/chapters
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
|
||||
const Path = require('path')
|
||||
const fs = require('../libs/fsExtra')
|
||||
|
||||
|
|
@ -7,7 +6,7 @@ const Logger = require('../Logger')
|
|||
const TaskManager = require('./TaskManager')
|
||||
const Task = require('../objects/Task')
|
||||
const { writeConcatFile } = require('../utils/ffmpegHelpers')
|
||||
const toneHelpers = require('../utils/toneHelpers')
|
||||
const ffmpegHelpers = require('../utils/ffmpegHelpers')
|
||||
|
||||
class AbMergeManager {
|
||||
constructor() {
|
||||
|
|
@ -17,7 +16,7 @@ class AbMergeManager {
|
|||
}
|
||||
|
||||
getPendingTaskByLibraryItemId(libraryItemId) {
|
||||
return this.pendingTasks.find(t => t.task.data.libraryItemId === libraryItemId)
|
||||
return this.pendingTasks.find((t) => t.task.data.libraryItemId === libraryItemId)
|
||||
}
|
||||
|
||||
cancelEncode(task) {
|
||||
|
|
@ -31,23 +30,27 @@ class AbMergeManager {
|
|||
const targetFilename = audiobookDirname + '.m4b'
|
||||
const itemCachePath = Path.join(this.itemsCacheDir, libraryItem.id)
|
||||
const tempFilepath = Path.join(itemCachePath, targetFilename)
|
||||
const ffmetadataPath = Path.join(itemCachePath, 'ffmetadata.txt')
|
||||
const taskData = {
|
||||
libraryItemId: libraryItem.id,
|
||||
libraryItemPath: libraryItem.path,
|
||||
userId: user.id,
|
||||
originalTrackPaths: libraryItem.media.tracks.map(t => t.metadata.path),
|
||||
originalTrackPaths: libraryItem.media.tracks.map((t) => t.metadata.path),
|
||||
tempFilepath,
|
||||
targetFilename,
|
||||
targetFilepath: Path.join(libraryItem.path, targetFilename),
|
||||
itemCachePath,
|
||||
toneJsonObject: null
|
||||
ffmetadataObject: ffmpegHelpers.getFFMetadataObject(libraryItem, 1),
|
||||
chapters: libraryItem.media.chapters?.map((c) => ({ ...c })),
|
||||
coverPath: libraryItem.media.coverPath,
|
||||
ffmetadataPath
|
||||
}
|
||||
const taskDescription = `Encoding audiobook "${libraryItem.media.metadata.title}" into a single m4b file.`
|
||||
task.setData('encode-m4b', 'Encoding M4b', taskDescription, false, taskData)
|
||||
TaskManager.addTask(task)
|
||||
Logger.info(`Start m4b encode for ${libraryItem.id} - TaskId: ${task.id}`)
|
||||
|
||||
if (!await fs.pathExists(taskData.itemCachePath)) {
|
||||
if (!(await fs.pathExists(taskData.itemCachePath))) {
|
||||
await fs.mkdir(taskData.itemCachePath)
|
||||
}
|
||||
|
||||
|
|
@ -55,6 +58,15 @@ class AbMergeManager {
|
|||
}
|
||||
|
||||
async runAudiobookMerge(libraryItem, task, encodingOptions) {
|
||||
// Create ffmetadata file
|
||||
const success = await ffmpegHelpers.writeFFMetadataFile(task.data.metadataObject, task.data.chapters, task.data.ffmetadataPath)
|
||||
if (!success) {
|
||||
Logger.error(`[AudioMetadataManager] Failed to write ffmetadata file for audiobook "${task.data.libraryItemId}"`)
|
||||
task.setFailed('Failed to write metadata file.')
|
||||
this.removeTask(task, true)
|
||||
return
|
||||
}
|
||||
|
||||
const audioBitrate = encodingOptions.bitrate || '128k'
|
||||
const audioCodec = encodingOptions.codec || 'aac'
|
||||
const audioChannels = encodingOptions.channels || 2
|
||||
|
|
@ -90,12 +102,7 @@ class AbMergeManager {
|
|||
const ffmpegOutputOptions = ['-f mp4']
|
||||
|
||||
if (audioRequiresEncode) {
|
||||
ffmpegOptions = ffmpegOptions.concat([
|
||||
'-map 0:a',
|
||||
`-acodec ${audioCodec}`,
|
||||
`-ac ${audioChannels}`,
|
||||
`-b:a ${audioBitrate}`
|
||||
])
|
||||
ffmpegOptions = ffmpegOptions.concat(['-map 0:a', `-acodec ${audioCodec}`, `-ac ${audioChannels}`, `-b:a ${audioBitrate}`])
|
||||
} else {
|
||||
ffmpegOptions.push('-max_muxing_queue_size 1000')
|
||||
|
||||
|
|
@ -106,24 +113,6 @@ class AbMergeManager {
|
|||
}
|
||||
}
|
||||
|
||||
let toneJsonPath = null
|
||||
try {
|
||||
toneJsonPath = Path.join(task.data.itemCachePath, 'metadata.json')
|
||||
await toneHelpers.writeToneMetadataJsonFile(libraryItem, libraryItem.media.chapters, toneJsonPath, 1, 'audio/mp4')
|
||||
} catch (error) {
|
||||
Logger.error(`[AbMergeManager] Write metadata.json failed`, error)
|
||||
toneJsonPath = null
|
||||
}
|
||||
|
||||
task.data.toneJsonObject = {
|
||||
'ToneJsonFile': toneJsonPath,
|
||||
'TrackNumber': 1,
|
||||
}
|
||||
|
||||
if (libraryItem.media.coverPath) {
|
||||
task.data.toneJsonObject['CoverFile'] = libraryItem.media.coverPath
|
||||
}
|
||||
|
||||
const workerData = {
|
||||
inputs: ffmpegInputs,
|
||||
options: ffmpegOptions,
|
||||
|
|
@ -162,7 +151,7 @@ class AbMergeManager {
|
|||
|
||||
async sendResult(task, result) {
|
||||
// Remove pending task
|
||||
this.pendingTasks = this.pendingTasks.filter(d => d.id !== task.id)
|
||||
this.pendingTasks = this.pendingTasks.filter((d) => d.id !== task.id)
|
||||
|
||||
if (result.isKilled) {
|
||||
task.setFailed('Ffmpeg task killed')
|
||||
|
|
@ -177,7 +166,7 @@ class AbMergeManager {
|
|||
}
|
||||
|
||||
// Write metadata to merged file
|
||||
const success = await toneHelpers.tagAudioFile(task.data.tempFilepath, task.data.toneJsonObject)
|
||||
const success = await ffmpegHelpers.addCoverAndMetadataToFile(task.data.tempFilepath, task.data.coverPath, task.data.ffmetadataPath, 1, 'audio/mp4')
|
||||
if (!success) {
|
||||
Logger.error(`[AbMergeManager] Failed to write metadata to file "${task.data.tempFilepath}"`)
|
||||
task.setFailed('Failed to write metadata to m4b file')
|
||||
|
|
@ -199,6 +188,9 @@ class AbMergeManager {
|
|||
Logger.debug(`[AbMergeManager] Moving m4b from ${task.data.tempFilepath} to ${task.data.targetFilepath}`)
|
||||
await fs.move(task.data.tempFilepath, task.data.targetFilepath)
|
||||
|
||||
// Remove ffmetadata file
|
||||
await fs.remove(task.data.ffmetadataPath)
|
||||
|
||||
task.setFinished()
|
||||
await this.removeTask(task, false)
|
||||
Logger.info(`[AbMergeManager] Ab task finished ${task.id}`)
|
||||
|
|
@ -207,9 +199,9 @@ class AbMergeManager {
|
|||
async removeTask(task, removeTempFilepath = false) {
|
||||
Logger.info('[AbMergeManager] Removing task ' + task.id)
|
||||
|
||||
const pendingDl = this.pendingTasks.find(d => d.id === task.id)
|
||||
const pendingDl = this.pendingTasks.find((d) => d.id === task.id)
|
||||
if (pendingDl) {
|
||||
this.pendingTasks = this.pendingTasks.filter(d => d.id !== task.id)
|
||||
this.pendingTasks = this.pendingTasks.filter((d) => d.id !== task.id)
|
||||
if (pendingDl.worker) {
|
||||
Logger.warn(`[AbMergeManager] Removing download in progress - stopping worker`)
|
||||
try {
|
||||
|
|
@ -223,13 +215,27 @@ class AbMergeManager {
|
|||
}
|
||||
}
|
||||
|
||||
if (removeTempFilepath) { // On failed tasks remove the bad file if it exists
|
||||
if (removeTempFilepath) {
|
||||
// On failed tasks remove the bad file if it exists
|
||||
if (await fs.pathExists(task.data.tempFilepath)) {
|
||||
await fs.remove(task.data.tempFilepath).then(() => {
|
||||
Logger.info('[AbMergeManager] Deleted target file', task.data.tempFilepath)
|
||||
}).catch((err) => {
|
||||
Logger.error('[AbMergeManager] Failed to delete target file', err)
|
||||
})
|
||||
await fs
|
||||
.remove(task.data.tempFilepath)
|
||||
.then(() => {
|
||||
Logger.info('[AbMergeManager] Deleted target file', task.data.tempFilepath)
|
||||
})
|
||||
.catch((err) => {
|
||||
Logger.error('[AbMergeManager] Failed to delete target file', err)
|
||||
})
|
||||
}
|
||||
if (await fs.pathExists(task.data.ffmetadataPath)) {
|
||||
await fs
|
||||
.remove(task.data.ffmetadataPath)
|
||||
.then(() => {
|
||||
Logger.info('[AbMergeManager] Deleted ffmetadata file', task.data.ffmetadataPath)
|
||||
})
|
||||
.catch((err) => {
|
||||
Logger.error('[AbMergeManager] Failed to delete ffmetadata file', err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
getMetadataObjectForApi(libraryItem) {
|
||||
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,17 @@ 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')
|
||||
try {
|
||||
await fs.writeFile(toneJsonPath, JSON.stringify({ meta: task.data.metadataObject }, null, 2))
|
||||
} catch (error) {
|
||||
Logger.error(`[AudioMetadataManager] Write metadata.json failed`, error)
|
||||
task.setFailed('Failed to write metadata.json')
|
||||
// Create ffmetadata file
|
||||
const ffmetadataPath = Path.join(task.data.itemCachePath, 'ffmetadata.txt')
|
||||
const success = await ffmpegHelpers.writeFFMetadataFile(task.data.metadataObject, task.data.chapters, ffmetadataPath)
|
||||
if (!success) {
|
||||
Logger.error(`[AudioMetadataManager] Failed to write ffmetadata file for audiobook "${task.data.libraryItemId}"`)
|
||||
task.setFailed('Failed to write metadata file.')
|
||||
this.handleTaskFinished(task)
|
||||
return
|
||||
}
|
||||
|
|
@ -141,16 +136,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.index, task.data.mimeType)
|
||||
if (success) {
|
||||
Logger.info(`[AudioMetadataManager] Successfully tagged audio file "${af.path}"`)
|
||||
}
|
||||
|
|
@ -167,7 +153,7 @@ class AudioMetadataMangaer {
|
|||
if (cacheDirCreated) {
|
||||
await fs.remove(task.data.itemCachePath)
|
||||
} else {
|
||||
await fs.remove(toneJsonPath)
|
||||
await fs.remove(ffmetadataPath)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -177,7 +163,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.`)
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ class AudioMetaTags {
|
|||
|
||||
// Track ID3 tag might be "3/10" or just "3"
|
||||
if (this.tagTrack) {
|
||||
const trackParts = this.tagTrack.split('/').map(part => Number(part))
|
||||
const trackParts = this.tagTrack.split('/').map((part) => Number(part))
|
||||
if (trackParts.length > 0) {
|
||||
// Fractional track numbers not supported
|
||||
data.number = !isNaN(trackParts[0]) ? Math.trunc(trackParts[0]) : null
|
||||
|
|
@ -81,7 +81,7 @@ class AudioMetaTags {
|
|||
}
|
||||
|
||||
if (this.tagDisc) {
|
||||
const discParts = this.tagDisc.split('/').map(p => Number(p))
|
||||
const discParts = this.tagDisc.split('/').map((p) => Number(p))
|
||||
if (discParts.length > 0) {
|
||||
data.number = !isNaN(discParts[0]) ? Math.trunc(discParts[0]) : null
|
||||
}
|
||||
|
|
@ -93,10 +93,18 @@ class AudioMetaTags {
|
|||
return data
|
||||
}
|
||||
|
||||
get discNumber() { return this.discNumAndTotal.number }
|
||||
get discTotal() { return this.discNumAndTotal.total }
|
||||
get trackNumber() { return this.trackNumAndTotal.number }
|
||||
get trackTotal() { return this.trackNumAndTotal.total }
|
||||
get discNumber() {
|
||||
return this.discNumAndTotal.number
|
||||
}
|
||||
get discTotal() {
|
||||
return this.discNumAndTotal.total
|
||||
}
|
||||
get trackNumber() {
|
||||
return this.trackNumAndTotal.number
|
||||
}
|
||||
get trackTotal() {
|
||||
return this.trackNumAndTotal.total
|
||||
}
|
||||
|
||||
construct(metadata) {
|
||||
this.tagAlbum = metadata.tagAlbum || null
|
||||
|
|
@ -177,10 +185,6 @@ class AudioMetaTags {
|
|||
this.tagMusicBrainzArtistId = payload.file_tag_musicbrainz_artistid || null
|
||||
}
|
||||
|
||||
setDataFromTone(tags) {
|
||||
// TODO: Implement
|
||||
}
|
||||
|
||||
updateData(payload) {
|
||||
const dataMap = {
|
||||
tagAlbum: payload.file_tag_album || null,
|
||||
|
|
@ -243,4 +247,4 @@ class AudioMetaTags {
|
|||
return true
|
||||
}
|
||||
}
|
||||
module.exports = AudioMetaTags
|
||||
module.exports = AudioMetaTags
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@ class ApiRouter {
|
|||
this.router.post('/items/:id/play/:episodeId', LibraryItemController.middleware.bind(this), LibraryItemController.startEpisodePlaybackSession.bind(this))
|
||||
this.router.patch('/items/:id/tracks', LibraryItemController.middleware.bind(this), LibraryItemController.updateTracks.bind(this))
|
||||
this.router.post('/items/:id/scan', LibraryItemController.middleware.bind(this), LibraryItemController.scan.bind(this))
|
||||
this.router.get('/items/:id/tone-object', LibraryItemController.middleware.bind(this), LibraryItemController.getToneMetadataObject.bind(this))
|
||||
this.router.get('/items/:id/metadata-object', LibraryItemController.middleware.bind(this), LibraryItemController.getMetadataObject.bind(this))
|
||||
this.router.post('/items/:id/chapters', LibraryItemController.middleware.bind(this), LibraryItemController.updateMediaChapters.bind(this))
|
||||
this.router.get('/items/:id/ffprobe/:fileid', LibraryItemController.middleware.bind(this), LibraryItemController.getFFprobeData.bind(this))
|
||||
this.router.get('/items/:id/file/:fileid', LibraryItemController.middleware.bind(this), LibraryItemController.getLibraryFile.bind(this))
|
||||
|
|
|
|||
|
|
@ -63,15 +63,5 @@ class MediaProbeData {
|
|||
this.audioMetaTags = new AudioMetaTags()
|
||||
this.audioMetaTags.setData(data.tags)
|
||||
}
|
||||
|
||||
setDataFromTone(data) {
|
||||
// TODO: Implement
|
||||
|
||||
this.format = data.format
|
||||
this.duration = data.duration
|
||||
this.size = data.size
|
||||
this.audioMetaTags = new AudioMetaTags()
|
||||
this.audioMetaTags.setDataFromTone(data.tags)
|
||||
}
|
||||
}
|
||||
module.exports = MediaProbeData
|
||||
module.exports = MediaProbeData
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
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')
|
||||
const LibraryItem = require('../objects/LibraryItem')
|
||||
|
||||
function escapeSingleQuotes(path) {
|
||||
// return path.replace(/'/g, '\'\\\'\'')
|
||||
|
|
@ -184,3 +186,183 @@ 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|null} 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
|
||||
if (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
|
||||
|
||||
/**
|
||||
* Writes FFmpeg metadata file with the given metadata and chapters.
|
||||
*
|
||||
* @param {Object} metadata - The metadata object.
|
||||
* @param {Array} chapters - The array of chapter objects.
|
||||
* @param {string} ffmetadataPath - The path to the FFmpeg metadata file.
|
||||
* @returns {Promise<boolean>} - A promise that resolves to true if the file was written successfully, false otherwise.
|
||||
*/
|
||||
async function writeFFMetadataFile(metadata, chapters, ffmetadataPath) {
|
||||
try {
|
||||
await fs.writeFile(ffmetadataPath, generateFFMetadata(metadata, chapters))
|
||||
Logger.debug(`[ffmpegHelpers] Wrote ${ffmetadataPath}`)
|
||||
return true
|
||||
} catch (error) {
|
||||
Logger.error(`[ffmpegHelpers] Write ${ffmetadataPath} failed`, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
module.exports.writeFFMetadataFile = writeFFMetadataFile
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @param {number} track - The track number to embed in the audio file.
|
||||
* @param {string} mimeType - The MIME type of the audio file.
|
||||
* @param {Ffmpeg} ffmpeg - The Ffmpeg instance to use (optional). Used for dependency injection in tests.
|
||||
* @returns {Promise<boolean>} A promise that resolves to true if the operation is successful, false otherwise.
|
||||
*/
|
||||
async function addCoverAndMetadataToFile(audioFilePath, coverFilePath, metadataFilePath, track, mimeType, ffmpeg = Ffmpeg()) {
|
||||
const isMp4 = mimeType === 'audio/mp4'
|
||||
const isMp3 = mimeType === 'audio/mpeg'
|
||||
|
||||
const audioFileDir = Path.dirname(audioFilePath)
|
||||
const audioFileExt = Path.extname(audioFilePath)
|
||||
const audioFileBaseName = Path.basename(audioFilePath, audioFileExt)
|
||||
const tempFilePath = filePathToPOSIX(Path.join(audioFileDir, `${audioFileBaseName}.tmp${audioFileExt}`))
|
||||
|
||||
return new Promise((resolve) => {
|
||||
ffmpeg.input(audioFilePath).input(metadataFilePath).outputOptions([
|
||||
'-map 0:a', // map audio stream from input file
|
||||
'-map_metadata 1', // map metadata tags from metadata file first
|
||||
'-map_metadata 0', // add additional metadata tags from input file
|
||||
'-map_chapters 1', // map chapters from metadata file
|
||||
'-c copy' // copy streams
|
||||
])
|
||||
|
||||
if (track && !isNaN(track)) {
|
||||
ffmpeg.outputOptions(['-metadata track=' + track])
|
||||
}
|
||||
|
||||
if (isMp4) {
|
||||
ffmpeg.outputOptions([
|
||||
'-f mp4' // force output format to mp4
|
||||
])
|
||||
} else if (isMp3) {
|
||||
ffmpeg.outputOptions([
|
||||
'-id3v2_version 3' // set ID3v2 version to 3
|
||||
])
|
||||
}
|
||||
|
||||
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')
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the FFmpeg metadata object for a given library item.
|
||||
*
|
||||
* @param {LibraryItem} libraryItem - The library item containing the media metadata.
|
||||
* @param {number} audioFilesLength - The length of the audio files.
|
||||
* @returns {Object} - The FFmpeg metadata object.
|
||||
*/
|
||||
function getFFMetadataObject(libraryItem, audioFilesLength) {
|
||||
const metadata = libraryItem.media.metadata
|
||||
|
||||
const ffmetadata = {
|
||||
title: metadata.title,
|
||||
artist: metadata.authorName,
|
||||
album_artist: metadata.authorName,
|
||||
album: (metadata.title || '') + (metadata.subtitle ? `: ${metadata.subtitle}` : ''),
|
||||
TIT3: metadata.subtitle, // mp3 only
|
||||
genre: metadata.genres?.join('; '),
|
||||
date: metadata.publishedYear,
|
||||
comment: metadata.description,
|
||||
description: metadata.description,
|
||||
composer: metadata.narratorName,
|
||||
copyright: metadata.publisher,
|
||||
publisher: metadata.publisher, // mp3 only
|
||||
TRACKTOTAL: `${audioFilesLength}`, // mp3 only
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,113 +0,0 @@
|
|||
const tone = require('node-tone')
|
||||
const fs = require('../libs/fsExtra')
|
||||
const Logger = require('../Logger')
|
||||
|
||||
function getToneMetadataObject(libraryItem, chapters, trackTotal, mimeType = null) {
|
||||
const bookMetadata = libraryItem.media.metadata
|
||||
const coverPath = libraryItem.media.coverPath
|
||||
|
||||
const isMp4 = mimeType === 'audio/mp4'
|
||||
const isMp3 = mimeType === 'audio/mpeg'
|
||||
|
||||
const metadataObject = {
|
||||
'album': bookMetadata.title || '',
|
||||
'title': bookMetadata.title || '',
|
||||
'trackTotal': trackTotal,
|
||||
'additionalFields': {}
|
||||
}
|
||||
if (bookMetadata.subtitle) {
|
||||
metadataObject['subtitle'] = bookMetadata.subtitle
|
||||
}
|
||||
if (bookMetadata.authorName) {
|
||||
metadataObject['artist'] = bookMetadata.authorName
|
||||
metadataObject['albumArtist'] = bookMetadata.authorName
|
||||
}
|
||||
if (bookMetadata.description) {
|
||||
metadataObject['comment'] = bookMetadata.description
|
||||
metadataObject['description'] = bookMetadata.description
|
||||
}
|
||||
if (bookMetadata.narratorName) {
|
||||
metadataObject['narrator'] = bookMetadata.narratorName
|
||||
metadataObject['composer'] = bookMetadata.narratorName
|
||||
}
|
||||
if (bookMetadata.firstSeriesName) {
|
||||
if (!isMp3) {
|
||||
metadataObject.additionalFields['----:com.pilabor.tone:SERIES'] = bookMetadata.firstSeriesName
|
||||
}
|
||||
metadataObject['movementName'] = bookMetadata.firstSeriesName
|
||||
}
|
||||
if (bookMetadata.firstSeriesSequence) {
|
||||
// Non-mp3
|
||||
if (!isMp3) {
|
||||
metadataObject.additionalFields['----:com.pilabor.tone:PART'] = bookMetadata.firstSeriesSequence
|
||||
}
|
||||
// MP3 Files with non-integer sequence
|
||||
const isNonIntegerSequence = String(bookMetadata.firstSeriesSequence).includes('.') || isNaN(bookMetadata.firstSeriesSequence)
|
||||
if (isMp3 && isNonIntegerSequence) {
|
||||
metadataObject.additionalFields['PART'] = bookMetadata.firstSeriesSequence
|
||||
}
|
||||
if (!isNonIntegerSequence) {
|
||||
metadataObject['movement'] = bookMetadata.firstSeriesSequence
|
||||
}
|
||||
}
|
||||
if (bookMetadata.genres.length) {
|
||||
metadataObject['genre'] = bookMetadata.genres.join('/')
|
||||
}
|
||||
if (bookMetadata.publisher) {
|
||||
metadataObject['publisher'] = bookMetadata.publisher
|
||||
}
|
||||
if (bookMetadata.asin) {
|
||||
if (!isMp3) {
|
||||
metadataObject.additionalFields['----:com.pilabor.tone:AUDIBLE_ASIN'] = bookMetadata.asin
|
||||
}
|
||||
if (!isMp4) {
|
||||
metadataObject.additionalFields['asin'] = bookMetadata.asin
|
||||
}
|
||||
}
|
||||
if (bookMetadata.isbn) {
|
||||
metadataObject.additionalFields['isbn'] = bookMetadata.isbn
|
||||
}
|
||||
if (coverPath) {
|
||||
metadataObject['coverFile'] = coverPath
|
||||
}
|
||||
if (parsePublishedYear(bookMetadata.publishedYear)) {
|
||||
metadataObject['publishingDate'] = parsePublishedYear(bookMetadata.publishedYear)
|
||||
}
|
||||
if (chapters && chapters.length > 0) {
|
||||
let metadataChapters = []
|
||||
for (const chapter of chapters) {
|
||||
metadataChapters.push({
|
||||
start: Math.round(chapter.start * 1000),
|
||||
length: Math.round((chapter.end - chapter.start) * 1000),
|
||||
title: chapter.title,
|
||||
})
|
||||
}
|
||||
metadataObject['chapters'] = metadataChapters
|
||||
}
|
||||
|
||||
return metadataObject
|
||||
}
|
||||
module.exports.getToneMetadataObject = getToneMetadataObject
|
||||
|
||||
module.exports.writeToneMetadataJsonFile = (libraryItem, chapters, filePath, trackTotal, mimeType) => {
|
||||
const metadataObject = getToneMetadataObject(libraryItem, chapters, trackTotal, mimeType)
|
||||
return fs.writeFile(filePath, JSON.stringify({ meta: metadataObject }, null, 2))
|
||||
}
|
||||
|
||||
module.exports.tagAudioFile = (filePath, payload) => {
|
||||
if (process.env.TONE_PATH) {
|
||||
tone.TONE_PATH = process.env.TONE_PATH
|
||||
}
|
||||
|
||||
return tone.tag(filePath, payload).then((data) => {
|
||||
return true
|
||||
}).catch((error) => {
|
||||
Logger.error(`[toneHelpers] tagAudioFile: Failed for "${filePath}"`, error)
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
function parsePublishedYear(publishedYear) {
|
||||
if (isNaN(publishedYear) || !publishedYear || Number(publishedYear) <= 0) return null
|
||||
return `01/01/${publishedYear}`
|
||||
}
|
||||
|
|
@ -1,173 +0,0 @@
|
|||
const tone = require('node-tone')
|
||||
const MediaProbeData = require('../scanner/MediaProbeData')
|
||||
const Logger = require('../Logger')
|
||||
|
||||
/*
|
||||
Sample dump from tone
|
||||
{
|
||||
"audio": {
|
||||
"bitrate": 17,
|
||||
"format": "MPEG-4 Part 14",
|
||||
"formatShort": "MPEG-4",
|
||||
"sampleRate": 44100.0,
|
||||
"duration": 209284.0,
|
||||
"channels": {
|
||||
"count": 2,
|
||||
"description": "Stereo (2/0.0)"
|
||||
},
|
||||
"frames": {
|
||||
"offset": 42168,
|
||||
"length": 446932
|
||||
"metaFormat": [
|
||||
"mp4"
|
||||
]
|
||||
},
|
||||
"meta": {
|
||||
"album": "node-tone",
|
||||
"albumArtist": "advplyr",
|
||||
"artist": "advplyr",
|
||||
"composer": "Composer 5",
|
||||
"comment": "testing out tone metadata",
|
||||
"encodingTool": "audiobookshelf",
|
||||
"genre": "abs",
|
||||
"itunesCompilation": "no",
|
||||
"itunesMediaType": "audiobook",
|
||||
"itunesPlayGap": "noGap",
|
||||
"narrator": "Narrator 5",
|
||||
"recordingDate": "2022-09-10T00:00:00",
|
||||
"title": "Test 5",
|
||||
"trackNumber": 5,
|
||||
"chapters": [
|
||||
{
|
||||
"start": 0,
|
||||
"length": 500,
|
||||
"title": "chapter 1"
|
||||
},
|
||||
{
|
||||
"start": 500,
|
||||
"length": 500,
|
||||
"title": "chapter 2"
|
||||
},
|
||||
{
|
||||
"start": 1000,
|
||||
"length": 208284,
|
||||
"title": "chapter 3"
|
||||
}
|
||||
],
|
||||
"embeddedPictures": [
|
||||
{
|
||||
"code": 14,
|
||||
"mimetype": "image/png",
|
||||
"data": "..."
|
||||
},
|
||||
"additionalFields": {
|
||||
"test": "Test 5"
|
||||
}
|
||||
},
|
||||
"file": {
|
||||
"size": 530793,
|
||||
"created": "2022-09-10T13:32:51.1942586-05:00",
|
||||
"modified": "2022-09-10T14:09:19.366071-05:00",
|
||||
"accessed": "2022-09-11T13:00:56.5097533-05:00",
|
||||
"path": "C:\\Users\\Coop\\Documents\\NodeProjects\\node-tone\\samples",
|
||||
"name": "m4b.m4b"
|
||||
}
|
||||
|
||||
*/
|
||||
|
||||
function bitrateKilobitToBit(bitrate) {
|
||||
if (isNaN(bitrate) || !bitrate) return 0
|
||||
return Number(bitrate) * 1000
|
||||
}
|
||||
|
||||
function msToSeconds(ms) {
|
||||
if (isNaN(ms) || !ms) return 0
|
||||
return Number(ms) / 1000
|
||||
}
|
||||
|
||||
function parseProbeDump(dumpPayload) {
|
||||
const audioMetadata = dumpPayload.audio
|
||||
const audioChannels = audioMetadata.channels || {}
|
||||
const audio_stream = {
|
||||
bit_rate: bitrateKilobitToBit(audioMetadata.bitrate), // tone uses Kbps but ffprobe uses bps so convert to bits
|
||||
codec: null,
|
||||
time_base: null,
|
||||
language: null,
|
||||
channel_layout: audioChannels.description || null,
|
||||
channels: audioChannels.count || null,
|
||||
sample_rate: audioMetadata.sampleRate || null
|
||||
}
|
||||
|
||||
let chapterIndex = 0
|
||||
const chapters = (dumpPayload.meta.chapters || []).map(chap => {
|
||||
return {
|
||||
id: chapterIndex++,
|
||||
start: msToSeconds(chap.start),
|
||||
end: msToSeconds(chap.start + chap.length),
|
||||
title: chap.title || ''
|
||||
}
|
||||
})
|
||||
|
||||
var video_stream = null
|
||||
if (dumpPayload.meta.embeddedPictures && dumpPayload.meta.embeddedPictures.length) {
|
||||
const mimetype = dumpPayload.meta.embeddedPictures[0].mimetype
|
||||
video_stream = {
|
||||
codec: mimetype === 'image/png' ? 'png' : 'jpeg'
|
||||
}
|
||||
}
|
||||
|
||||
const tags = { ...dumpPayload.meta }
|
||||
delete tags.chapters
|
||||
delete tags.embeddedPictures
|
||||
|
||||
const fileMetadata = dumpPayload.file
|
||||
var sizeBytes = !isNaN(fileMetadata.size) ? Number(fileMetadata.size) : null
|
||||
var sizeMb = sizeBytes !== null ? Number((sizeBytes / (1024 * 1024)).toFixed(2)) : null
|
||||
return {
|
||||
format: audioMetadata.format || 'Unknown',
|
||||
duration: msToSeconds(audioMetadata.duration),
|
||||
size: sizeBytes,
|
||||
sizeMb,
|
||||
bit_rate: audio_stream.bit_rate,
|
||||
audio_stream,
|
||||
video_stream,
|
||||
chapters,
|
||||
tags
|
||||
}
|
||||
}
|
||||
|
||||
module.exports.probe = (filepath, verbose = false) => {
|
||||
if (process.env.TONE_PATH) {
|
||||
tone.TONE_PATH = process.env.TONE_PATH
|
||||
}
|
||||
|
||||
return tone.dump(filepath).then((dumpPayload) => {
|
||||
if (verbose) {
|
||||
Logger.debug(`[toneProber] dump for file "${filepath}"`, dumpPayload)
|
||||
}
|
||||
const rawProbeData = parseProbeDump(dumpPayload)
|
||||
const probeData = new MediaProbeData()
|
||||
probeData.setDataFromTone(rawProbeData)
|
||||
return probeData
|
||||
}).catch((error) => {
|
||||
Logger.error(`[toneProber] Failed to probe file at path "${filepath}"`, error)
|
||||
return {
|
||||
error
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
module.exports.rawProbe = (filepath) => {
|
||||
if (process.env.TONE_PATH) {
|
||||
tone.TONE_PATH = process.env.TONE_PATH
|
||||
}
|
||||
|
||||
return tone.dump(filepath).then((dumpPayload) => {
|
||||
return dumpPayload
|
||||
}).catch((error) => {
|
||||
Logger.error(`[toneProber] Failed to probe file at path "${filepath}"`, error)
|
||||
return {
|
||||
error
|
||||
}
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue