Merge pull request #3111 from mikiher/tone-replacement

Replace tone with ffmpeg for metadata and cover embedding
This commit is contained in:
advplyr 2024-07-06 16:03:17 -05:00 committed by GitHub
commit 9a4c5a16ef
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 542 additions and 448 deletions

View File

@ -10,6 +10,3 @@ RUN apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get -y install --no-install-recommends \
curl tzdata ffmpeg && \
rm -rf /var/lib/apt/lists/*
# Move tone executable to appropriate directory
COPY --from=sandreas/tone:v0.1.5 /usr/local/bin/tone /usr/local/bin/

View File

@ -6,7 +6,6 @@ RUN npm ci && npm cache clean --force
RUN npm run generate
### STAGE 1: Build server ###
FROM sandreas/tone:v0.1.5 AS tone
FROM node:20-alpine
ENV NODE_ENV=production
@ -21,7 +20,6 @@ RUN apk update && \
g++ \
tini
COPY --from=tone /usr/local/bin/tone /usr/local/bin/
COPY --from=build /client/dist /client/dist
COPY index.js package* /
COPY server server

View File

@ -50,7 +50,6 @@ install_ffmpeg() {
echo "Starting FFMPEG Install"
WGET="wget https://johnvansickle.com/ffmpeg/builds/ffmpeg-git-amd64-static.tar.xz --output-document=ffmpeg-git-amd64-static.tar.xz"
WGET_TONE="wget https://github.com/sandreas/tone/releases/download/v0.1.5/tone-0.1.5-linux-x64.tar.gz --output-document=tone-0.1.5-linux-x64.tar.gz"
if ! cd "$FFMPEG_INSTALL_DIR"; then
echo "Creating ffmpeg install dir at $FFMPEG_INSTALL_DIR"
@ -63,13 +62,7 @@ install_ffmpeg() {
tar xvf ffmpeg-git-amd64-static.tar.xz --strip-components=1 --no-same-owner
rm ffmpeg-git-amd64-static.tar.xz
# Temp downloading tone library to the ffmpeg dir
echo "Getting tone.."
$WGET_TONE
tar xvf tone-0.1.5-linux-x64.tar.gz --strip-components=1 --no-same-owner
rm tone-0.1.5-linux-x64.tar.gz
echo "Good to go on Ffmpeg (& tone)... hopefully"
echo "Good to go on Ffmpeg... hopefully"
}
setup_config() {
@ -77,12 +70,6 @@ setup_config() {
echo "Existing config found."
cat $CONFIG_PATH
# TONE_PATH variable added in 2.1.6, if it doesnt exist then add it
if ! grep -q "TONE_PATH" "$CONFIG_PATH"; then
echo "Adding TONE_PATH to existing config"
echo "TONE_PATH=$FFMPEG_INSTALL_DIR/tone" >> "$CONFIG_PATH"
fi
else
if [ ! -d "$DEFAULT_DATA_DIR" ]; then
@ -98,7 +85,6 @@ setup_config() {
CONFIG_PATH=$DEFAULT_DATA_DIR/config
FFMPEG_PATH=$FFMPEG_INSTALL_DIR/ffmpeg
FFPROBE_PATH=$FFMPEG_INSTALL_DIR/ffprobe
TONE_PATH=$FFMPEG_INSTALL_DIR/tone
PORT=$DEFAULT_PORT
HOST=$DEFAULT_HOST"

View File

@ -2,11 +2,8 @@
<div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6">
<p class="text-xl font-semibold mb-2">{{ $strings.HeaderAudiobookTools }}</p>
<!-- alert for windows install -->
<widgets-alert v-if="isWindowsInstall" type="warning" class="my-8 text-base">Not supported for the Windows install yet</widgets-alert>
<!-- Merge to m4b -->
<div v-if="showM4bDownload && !isWindowsInstall" class="w-full border border-black-200 p-4 my-8">
<div v-if="showM4bDownload" class="w-full border border-black-200 p-4 my-8">
<div class="flex flex-wrap items-center">
<div>
<p class="text-lg">{{ $strings.LabelToolsMakeM4b }}</p>
@ -23,7 +20,7 @@
</div>
<!-- Embed Metadata -->
<div v-if="mediaTracks.length && !isWindowsInstall" class="w-full border border-black-200 p-4 my-8">
<div v-if="mediaTracks.length" class="w-full border border-black-200 p-4 my-8">
<div class="flex items-center">
<div>
<p class="text-lg">{{ $strings.LabelToolsEmbedMetadata }}</p>
@ -111,12 +108,6 @@ export default {
},
isEncodeTaskRunning() {
return this.encodeTask && !this.encodeTask?.isFinished
},
isWindowsInstall() {
return this.Source == 'windows'
},
Source() {
return this.$store.state.Source
}
},
methods: {
@ -141,4 +132,4 @@ export default {
}
}
}
</script>
</script>

View File

@ -11,10 +11,9 @@
</div>
</div>
<div class="flex justify-center">
<div class="flex justify-center mb-2">
<div class="w-full max-w-2xl">
<p class="text-xl mb-1">{{ $strings.HeaderMetadataToEmbed }}</p>
<p class="mb-2 text-base text-gray-300">audiobookshelf uses <a href="https://github.com/sandreas/tone" target="_blank" class="hover:underline text-blue-400 hover:text-blue-300">tone</a> to write metadata.</p>
<p class="text-xl">{{ $strings.HeaderMetadataToEmbed }}</p>
</div>
<div class="w-full max-w-2xl"></div>
</div>
@ -26,7 +25,7 @@
<div class="w-2/3 text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelValue }}</div>
</div>
<div class="w-full max-h-72 overflow-auto">
<template v-for="(value, key, index) in toneObject">
<template v-for="(value, key, index) in metadataObject">
<div :key="key" class="flex py-1 px-4 text-sm" :class="index % 2 === 0 ? 'bg-primary bg-opacity-25' : ''">
<div class="w-1/3 font-semibold">{{ key }}</div>
<div class="w-2/3">
@ -208,7 +207,7 @@ export default {
processing: false,
audiofilesEncoding: {},
audiofilesFinished: {},
toneObject: null,
metadataObject: null,
selectedTool: 'embed',
isCancelingEncode: false,
showEncodeOptions: false,
@ -387,7 +386,7 @@ export default {
window.history.replaceState({ path: newurl }, '', newurl)
},
init() {
this.fetchToneObject()
this.fetchMetadataEmbedObject()
if (this.$route.query.tool === 'm4b') {
if (this.availableTools.some((t) => t.value === 'm4b')) {
this.selectedTool = 'm4b'
@ -401,15 +400,14 @@ export default {
const shouldBackupAudioFiles = localStorage.getItem('embedMetadataShouldBackup')
this.shouldBackupAudioFiles = shouldBackupAudioFiles != 0
},
fetchToneObject() {
fetchMetadataEmbedObject() {
this.$axios
.$get(`/api/items/${this.libraryItemId}/tone-object`)
.then((toneObject) => {
delete toneObject.CoverFile
this.toneObject = toneObject
.$get(`/api/items/${this.libraryItemId}/metadata-object`)
.then((metadataObject) => {
this.metadataObject = metadataObject
})
.catch((error) => {
console.error('Failed to fetch tone object', error)
console.error('Failed to fetch metadata object', error)
})
},
taskUpdated(task) {
@ -426,4 +424,4 @@ export default {
this.$root.socket.off('audiofile_metadata_finished', this.audiofileMetadataFinished)
}
}
</script>
</script>

6
package-lock.json generated
View File

@ -16,7 +16,6 @@
"graceful-fs": "^4.2.10",
"htmlparser2": "^8.0.1",
"lru-cache": "^10.0.3",
"node-tone": "^1.0.1",
"nodemailer": "^6.9.13",
"openid-client": "^5.6.1",
"p-throttle": "^4.1.1",
@ -3661,11 +3660,6 @@
"integrity": "sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ==",
"dev": true
},
"node_modules/node-tone": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/node-tone/-/node-tone-1.0.1.tgz",
"integrity": "sha512-wi7L0taDZMN6tM5l85TDKHsYzdhqJTtPNgvgpk2zHeZzPt6ZIUZ9vBLTJRRDpm0xzCvbsvFHjAaudeQjLHTE4w=="
},
"node_modules/nodemailer": {
"version": "6.9.13",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.13.tgz",

View File

@ -42,7 +42,6 @@
"graceful-fs": "^4.2.10",
"htmlparser2": "^8.0.1",
"lru-cache": "^10.0.3",
"node-tone": "^1.0.1",
"nodemailer": "^6.9.13",
"openid-client": "^5.6.1",
"p-throttle": "^4.1.1",

View File

@ -31,7 +31,7 @@ Audiobookshelf is a self-hosted audiobook and podcast server.
- Fetch metadata and cover art from several sources
- Chapter editor and chapter lookup (using [Audnexus API](https://audnex.us/))
- Merge your audio files into a single m4b
- Embed metadata and cover image into your audio files (using [Tone](https://github.com/sandreas/tone))
- Embed metadata and cover image into your audio files
- Basic ebook support and ereader
- Epub, pdf, cbr, cbz
- Send ebook to device (i.e. Kindle)

View File

@ -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

View File

@ -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)
})
}
}

View File

@ -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.`)

View File

@ -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

View File

@ -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))

View File

@ -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

View File

@ -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

View File

@ -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}`
}

View File

@ -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
}
})
}

View File

@ -0,0 +1,249 @@
const { expect } = require('chai')
const sinon = require('sinon')
const { generateFFMetadata, addCoverAndMetadataToFile } = require('../../../server/utils/ffmpegHelpers')
const fs = require('../../../server/libs/fsExtra')
const EventEmitter = require('events')
global.isWin = process.platform === 'win32'
describe('generateFFMetadata', () => {
function createTestSetup() {
const metadata = {
title: 'My Audiobook',
artist: 'John Doe',
album: 'Best Audiobooks'
}
const chapters = [
{ start: 0, end: 1000, title: 'Chapter 1' },
{ start: 1000, end: 2000, title: 'Chapter 2' }
]
return { metadata, chapters }
}
let metadata = null
let chapters = null
beforeEach(() => {
const input = createTestSetup()
metadata = input.metadata
chapters = input.chapters
})
it('should generate ffmetadata content with chapters', () => {
const result = generateFFMetadata(metadata, chapters)
expect(result).to.equal(';FFMETADATA1\ntitle=My Audiobook\nartist=John Doe\nalbum=Best Audiobooks\n\n[CHAPTER]\nTIMEBASE=1/1000\nSTART=0\nEND=1000000\ntitle=Chapter 1\n\n[CHAPTER]\nTIMEBASE=1/1000\nSTART=1000000\nEND=2000000\ntitle=Chapter 2\n')
})
it('should generate ffmetadata content without chapters', () => {
chapters = null
const result = generateFFMetadata(metadata, chapters)
expect(result).to.equal(';FFMETADATA1\ntitle=My Audiobook\nartist=John Doe\nalbum=Best Audiobooks\n')
})
it('should handle chapters with no title', () => {
chapters = [
{ start: 0, end: 1000 },
{ start: 1000, end: 2000 }
]
const result = generateFFMetadata(metadata, chapters)
expect(result).to.equal(';FFMETADATA1\ntitle=My Audiobook\nartist=John Doe\nalbum=Best Audiobooks\n\n[CHAPTER]\nTIMEBASE=1/1000\nSTART=0\nEND=1000000\n\n[CHAPTER]\nTIMEBASE=1/1000\nSTART=1000000\nEND=2000000\n')
})
it('should handle metadata escaping special characters (=, ;, #, and a newline)', () => {
metadata.title = 'My Audiobook; with = special # characters\n'
chapters[0].title = 'Chapter #1'
const result = generateFFMetadata(metadata, chapters)
expect(result).to.equal(';FFMETADATA1\ntitle=My Audiobook\\; with \\= special \\# characters\\\n\nartist=John Doe\nalbum=Best Audiobooks\n\n[CHAPTER]\nTIMEBASE=1/1000\nSTART=0\nEND=1000000\ntitle=Chapter \\#1\n\n[CHAPTER]\nTIMEBASE=1/1000\nSTART=1000000\nEND=2000000\ntitle=Chapter 2\n')
})
})
describe('addCoverAndMetadataToFile', () => {
function createTestSetup() {
const audioFilePath = '/path/to/audio/file.mp3'
const coverFilePath = '/path/to/cover/image.jpg'
const metadataFilePath = '/path/to/metadata/file.txt'
const track = 1
const mimeType = 'audio/mpeg'
const ffmpegStub = new EventEmitter()
ffmpegStub.input = sinon.stub().returnsThis()
ffmpegStub.outputOptions = sinon.stub().returnsThis()
ffmpegStub.output = sinon.stub().returnsThis()
ffmpegStub.input = sinon.stub().returnsThis()
ffmpegStub.run = sinon.stub().callsFake(() => {
ffmpegStub.emit('end')
})
const fsCopyFileSyncStub = sinon.stub(fs, 'copyFileSync')
const fsUnlinkSyncStub = sinon.stub(fs, 'unlinkSync')
return { audioFilePath, coverFilePath, metadataFilePath, track, mimeType, ffmpegStub, fsCopyFileSyncStub, fsUnlinkSyncStub }
}
let audioFilePath = null
let coverFilePath = null
let metadataFilePath = null
let track = null
let mimeType = null
let ffmpegStub = null
let fsCopyFileSyncStub = null
let fsUnlinkSyncStub = null
beforeEach(() => {
const input = createTestSetup()
audioFilePath = input.audioFilePath
coverFilePath = input.coverFilePath
metadataFilePath = input.metadataFilePath
track = input.track
mimeType = input.mimeType
ffmpegStub = input.ffmpegStub
fsCopyFileSyncStub = input.fsCopyFileSyncStub
fsUnlinkSyncStub = input.fsUnlinkSyncStub
})
it('should add cover image and metadata to audio file', async () => {
// Act
const result = await addCoverAndMetadataToFile(audioFilePath, coverFilePath, metadataFilePath, track, mimeType, ffmpegStub)
// Assert
expect(result).to.be.true
expect(ffmpegStub.input.calledThrice).to.be.true
expect(ffmpegStub.input.getCall(0).args[0]).to.equal(audioFilePath)
expect(ffmpegStub.input.getCall(1).args[0]).to.equal(metadataFilePath)
expect(ffmpegStub.input.getCall(2).args[0]).to.equal(coverFilePath)
expect(ffmpegStub.outputOptions.callCount).to.equal(4)
expect(ffmpegStub.outputOptions.getCall(0).args[0]).to.deep.equal(['-map 0:a', '-map_metadata 1', '-map_metadata 0', '-map_chapters 1', '-c copy'])
expect(ffmpegStub.outputOptions.getCall(1).args[0]).to.deep.equal(['-metadata track=1'])
expect(ffmpegStub.outputOptions.getCall(2).args[0]).to.deep.equal(['-id3v2_version 3'])
expect(ffmpegStub.outputOptions.getCall(3).args[0]).to.deep.equal(['-map 2:v', '-disposition:v:0 attached_pic', '-metadata:s:v', 'title=Cover', '-metadata:s:v', 'comment=Cover'])
expect(ffmpegStub.output.calledOnce).to.be.true
expect(ffmpegStub.output.firstCall.args[0]).to.equal('/path/to/audio/file.tmp.mp3')
expect(ffmpegStub.run.calledOnce).to.be.true
expect(fsCopyFileSyncStub.calledOnce).to.be.true
expect(fsCopyFileSyncStub.firstCall.args[0]).to.equal('/path/to/audio/file.tmp.mp3')
expect(fsCopyFileSyncStub.firstCall.args[1]).to.equal('/path/to/audio/file.mp3')
expect(fsUnlinkSyncStub.calledOnce).to.be.true
expect(fsUnlinkSyncStub.firstCall.args[0]).to.equal('/path/to/audio/file.tmp.mp3')
// Restore the stub
sinon.restore()
})
it('should handle missing cover image', async () => {
// Arrange
coverFilePath = null
// Act
const result = await addCoverAndMetadataToFile(audioFilePath, coverFilePath, metadataFilePath, track, mimeType, ffmpegStub)
// Assert
expect(result).to.be.true
expect(ffmpegStub.input.calledTwice).to.be.true
expect(ffmpegStub.input.getCall(0).args[0]).to.equal(audioFilePath)
expect(ffmpegStub.input.getCall(1).args[0]).to.equal(metadataFilePath)
expect(ffmpegStub.outputOptions.callCount).to.equal(4)
expect(ffmpegStub.outputOptions.getCall(0).args[0]).to.deep.equal(['-map 0:a', '-map_metadata 1', '-map_metadata 0', '-map_chapters 1', '-c copy'])
expect(ffmpegStub.outputOptions.getCall(1).args[0]).to.deep.equal(['-metadata track=1'])
expect(ffmpegStub.outputOptions.getCall(2).args[0]).to.deep.equal(['-id3v2_version 3'])
expect(ffmpegStub.outputOptions.getCall(3).args[0]).to.deep.equal(['-map 0:v?'])
expect(ffmpegStub.output.calledOnce).to.be.true
expect(ffmpegStub.output.firstCall.args[0]).to.equal('/path/to/audio/file.tmp.mp3')
expect(ffmpegStub.run.calledOnce).to.be.true
expect(fsCopyFileSyncStub.calledOnce).to.be.true
expect(fsCopyFileSyncStub.firstCall.args[0]).to.equal('/path/to/audio/file.tmp.mp3')
expect(fsCopyFileSyncStub.firstCall.args[1]).to.equal('/path/to/audio/file.mp3')
expect(fsUnlinkSyncStub.calledOnce).to.be.true
expect(fsUnlinkSyncStub.firstCall.args[0]).to.equal('/path/to/audio/file.tmp.mp3')
// Restore the stub
sinon.restore()
})
it('should handle error during ffmpeg execution', async () => {
// Arrange
ffmpegStub.run = sinon.stub().callsFake(() => {
ffmpegStub.emit('error', new Error('FFmpeg error'))
})
// Act
const result = await addCoverAndMetadataToFile(audioFilePath, coverFilePath, metadataFilePath, track, mimeType, ffmpegStub)
// Assert
expect(result).to.be.false
expect(ffmpegStub.input.calledThrice).to.be.true
expect(ffmpegStub.input.getCall(0).args[0]).to.equal(audioFilePath)
expect(ffmpegStub.input.getCall(1).args[0]).to.equal(metadataFilePath)
expect(ffmpegStub.input.getCall(2).args[0]).to.equal(coverFilePath)
expect(ffmpegStub.outputOptions.callCount).to.equal(4)
expect(ffmpegStub.outputOptions.getCall(0).args[0]).to.deep.equal(['-map 0:a', '-map_metadata 1', '-map_metadata 0', '-map_chapters 1', '-c copy'])
expect(ffmpegStub.outputOptions.getCall(1).args[0]).to.deep.equal(['-metadata track=1'])
expect(ffmpegStub.outputOptions.getCall(2).args[0]).to.deep.equal(['-id3v2_version 3'])
expect(ffmpegStub.outputOptions.getCall(3).args[0]).to.deep.equal(['-map 2:v', '-disposition:v:0 attached_pic', '-metadata:s:v', 'title=Cover', '-metadata:s:v', 'comment=Cover'])
expect(ffmpegStub.output.calledOnce).to.be.true
expect(ffmpegStub.output.firstCall.args[0]).to.equal('/path/to/audio/file.tmp.mp3')
expect(ffmpegStub.run.calledOnce).to.be.true
expect(fsCopyFileSyncStub.called).to.be.false
expect(fsUnlinkSyncStub.called).to.be.false
// Restore the stub
sinon.restore()
})
it('should handle m4b embedding', async () => {
// Arrange
mimeType = 'audio/mp4'
audioFilePath = '/path/to/audio/file.m4b'
// Act
const result = await addCoverAndMetadataToFile(audioFilePath, coverFilePath, metadataFilePath, track, mimeType, ffmpegStub)
// Assert
expect(result).to.be.true
expect(ffmpegStub.input.calledThrice).to.be.true
expect(ffmpegStub.input.getCall(0).args[0]).to.equal(audioFilePath)
expect(ffmpegStub.input.getCall(1).args[0]).to.equal(metadataFilePath)
expect(ffmpegStub.input.getCall(2).args[0]).to.equal(coverFilePath)
expect(ffmpegStub.outputOptions.callCount).to.equal(4)
expect(ffmpegStub.outputOptions.getCall(0).args[0]).to.deep.equal(['-map 0:a', '-map_metadata 1', '-map_metadata 0', '-map_chapters 1', '-c copy'])
expect(ffmpegStub.outputOptions.getCall(1).args[0]).to.deep.equal(['-metadata track=1'])
expect(ffmpegStub.outputOptions.getCall(2).args[0]).to.deep.equal(['-f mp4'])
expect(ffmpegStub.outputOptions.getCall(3).args[0]).to.deep.equal(['-map 2:v', '-disposition:v:0 attached_pic', '-metadata:s:v', 'title=Cover', '-metadata:s:v', 'comment=Cover'])
expect(ffmpegStub.output.calledOnce).to.be.true
expect(ffmpegStub.output.firstCall.args[0]).to.equal('/path/to/audio/file.tmp.m4b')
expect(ffmpegStub.run.calledOnce).to.be.true
expect(fsCopyFileSyncStub.calledOnce).to.be.true
expect(fsCopyFileSyncStub.firstCall.args[0]).to.equal('/path/to/audio/file.tmp.m4b')
expect(fsCopyFileSyncStub.firstCall.args[1]).to.equal('/path/to/audio/file.m4b')
expect(fsUnlinkSyncStub.calledOnce).to.be.true
expect(fsUnlinkSyncStub.firstCall.args[0]).to.equal('/path/to/audio/file.tmp.m4b')
// Restore the stub
sinon.restore()
})
})