mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2024-12-20 19:06:06 +01: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
@ -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/
|
||||
|
@ -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
|
||||
|
@ -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"
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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
6
package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
}
|
||||
})
|
||||
}
|
249
test/server/utils/ffmpegHelpers.test.js
Normal file
249
test/server/utils/ffmpegHelpers.test.js
Normal 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()
|
||||
})
|
||||
})
|
Loading…
Reference in New Issue
Block a user