Add:Option to disable backup of audio files in embed metadata tool #1370

This commit is contained in:
advplyr 2023-01-07 15:16:52 -06:00
parent 7ccf36a896
commit 5a26704c32
5 changed files with 68 additions and 252 deletions

View File

@ -63,6 +63,10 @@
<div class="w-full max-w-4xl mx-auto"> <div class="w-full max-w-4xl mx-auto">
<div v-if="isEmbedTool" class="w-full flex justify-end items-center mb-4"> <div v-if="isEmbedTool" class="w-full flex justify-end items-center mb-4">
<ui-checkbox v-if="!isFinished" v-model="shouldBackupAudioFiles" label="Backup audio files" medium checkbox-bg="bg" label-class="pl-2 text-base md:text-lg" @input="toggleBackupAudioFiles" />
<div class="flex-grow" />
<ui-btn v-if="!isFinished" color="primary" :loading="processing" @click.stop="embedClick">{{ $strings.ButtonStartMetadataEmbed }}</ui-btn> <ui-btn v-if="!isFinished" color="primary" :loading="processing" @click.stop="embedClick">{{ $strings.ButtonStartMetadataEmbed }}</ui-btn>
<p v-else class="text-success text-lg font-semibold">{{ $strings.MessageEmbedFinished }}</p> <p v-else class="text-success text-lg font-semibold">{{ $strings.MessageEmbedFinished }}</p>
</div> </div>
@ -104,7 +108,7 @@
</p> </p>
</div> </div>
<div class="flex items-start mb-2"> <div v-if="shouldBackupAudioFiles || isM4BTool" class="flex items-start mb-2">
<span class="material-icons text-base text-warning pt-1">star</span> <span class="material-icons text-base text-warning pt-1">star</span>
<p class="text-gray-200 ml-2"> <p class="text-gray-200 ml-2">
A backup of your original audio files will be stored in <span class="rounded-md bg-neutral-600 text-sm text-white py-0.5 px-1 font-mono">/metadata/cache/items/{{ libraryItemId }}/</span>. Make sure to periodically purge items cache. A backup of your original audio files will be stored in <span class="rounded-md bg-neutral-600 text-sm text-white py-0.5 px-1 font-mono">/metadata/cache/items/{{ libraryItemId }}/</span>. Make sure to periodically purge items cache.
@ -171,7 +175,7 @@ export default {
if (!store.getters['user/getIsAdminOrUp']) { if (!store.getters['user/getIsAdminOrUp']) {
return redirect('/?error=unauthorized') return redirect('/?error=unauthorized')
} }
var libraryItem = await app.$axios.$get(`/api/items/${params.id}?expanded=1`).catch((error) => { const libraryItem = await app.$axios.$get(`/api/items/${params.id}?expanded=1`).catch((error) => {
console.error('Failed', error) console.error('Failed', error)
return false return false
}) })
@ -201,6 +205,7 @@ export default {
selectedTool: 'embed', selectedTool: 'embed',
isCancelingEncode: false, isCancelingEncode: false,
showEncodeOptions: false, showEncodeOptions: false,
shouldBackupAudioFiles: true,
encodingOptions: { encodingOptions: {
bitrate: '64k', bitrate: '64k',
channels: '2', channels: '2',
@ -275,6 +280,9 @@ export default {
} }
}, },
methods: { methods: {
toggleBackupAudioFiles(val) {
localStorage.setItem('embedMetadataShouldBackup', val ? 1 : 0)
},
cancelEncodeClick() { cancelEncodeClick() {
this.isCancelingEncode = true this.isCancelingEncode = true
this.$axios this.$axios
@ -332,7 +340,7 @@ export default {
updateAudioFileMetadata() { updateAudioFileMetadata() {
this.processing = true this.processing = true
this.$axios this.$axios
.$post(`/api/tools/item/${this.libraryItemId}/embed-metadata?tone=1`) .$post(`/api/tools/item/${this.libraryItemId}/embed-metadata?backup=${this.shouldBackupAudioFiles ? 1 : 0}`)
.then(() => { .then(() => {
console.log('Audio metadata encode started') console.log('Audio metadata encode started')
}) })
@ -350,9 +358,14 @@ export default {
console.log('audio metadata finished', data) console.log('audio metadata finished', data)
if (data.libraryItemId !== this.libraryItemId) return if (data.libraryItemId !== this.libraryItemId) return
this.processing = false this.processing = false
this.isFinished = true
this.audiofilesEncoding = {} this.audiofilesEncoding = {}
if (data.failed) {
this.$toast.error(data.error)
} else {
this.isFinished = true
this.$toast.success('Audio file metadata updated') this.$toast.success('Audio file metadata updated')
}
}, },
audiofileMetadataStarted(data) { audiofileMetadataStarted(data) {
if (data.libraryItemId !== this.libraryItemId) return if (data.libraryItemId !== this.libraryItemId) return
@ -378,6 +391,9 @@ export default {
} }
if (this.task) this.taskUpdated(this.task) if (this.task) this.taskUpdated(this.task)
const shouldBackupAudioFiles = localStorage.getItem('embedMetadataShouldBackup')
this.shouldBackupAudioFiles = shouldBackupAudioFiles != 0
}, },
fetchToneObject() { fetchToneObject() {
this.$axios this.$axios

View File

@ -47,7 +47,6 @@ class ToolsController {
res.sendStatus(200) res.sendStatus(200)
} }
// POST: api/tools/item/:id/embed-metadata // POST: api/tools/item/:id/embed-metadata
async embedAudioFileMetadata(req, res) { async embedAudioFileMetadata(req, res) {
if (!req.user.isAdminOrUp) { if (!req.user.isAdminOrUp) {
@ -60,9 +59,11 @@ class ToolsController {
return res.sendStatus(500) return res.sendStatus(500)
} }
const useTone = req.query.tone === '1' const options = {
const forceEmbedChapters = req.query.forceEmbedChapters === '1' forceEmbedChapters: req.query.forceEmbedChapters === '1',
this.audioMetadataManager.updateMetadataForItem(req.user, req.libraryItem, useTone, forceEmbedChapters) backup: req.query.backup === '1'
}
this.audioMetadataManager.updateMetadataForItem(req.user, req.libraryItem, options)
res.sendStatus(200) res.sendStatus(200)
} }

View File

@ -1,16 +1,13 @@
const Path = require('path') const Path = require('path')
const workerThreads = require('worker_threads')
const SocketAuthority = require('../SocketAuthority') const SocketAuthority = require('../SocketAuthority')
const Logger = require('../Logger') const Logger = require('../Logger')
const fs = require('../libs/fsExtra') const fs = require('../libs/fsExtra')
const filePerms = require('../utils/filePerms')
const { secondsToTimestamp } = require('../utils/index') const { secondsToTimestamp } = require('../utils/index')
const { filePathToPOSIX } = require('../utils/fileUtils')
const { writeMetadataFile } = require('../utils/ffmpegHelpers')
const toneHelpers = require('../utils/toneHelpers') const toneHelpers = require('../utils/toneHelpers')
const filePerms = require('../utils/filePerms')
class AudioMetadataMangaer { class AudioMetadataMangaer {
constructor(db, taskManager) { constructor(db, taskManager) {
@ -18,23 +15,15 @@ class AudioMetadataMangaer {
this.taskManager = taskManager this.taskManager = taskManager
} }
updateMetadataForItem(user, libraryItem, useTone, forceEmbedChapters) {
if (useTone) {
this.updateMetadataForItemWithTone(user, libraryItem, forceEmbedChapters)
} else {
this.updateMetadataForItemWithFfmpeg(user, libraryItem)
}
}
//
// TONE
//
getToneMetadataObjectForApi(libraryItem) { getToneMetadataObjectForApi(libraryItem) {
return toneHelpers.getToneMetadataObject(libraryItem) return toneHelpers.getToneMetadataObject(libraryItem)
} }
async updateMetadataForItemWithTone(user, libraryItem, forceEmbedChapters) { async updateMetadataForItem(user, libraryItem, options = {}) {
var audioFiles = libraryItem.media.includedAudioFiles const forceEmbedChapters = !!options.forceEmbedChapters
const backupFiles = !!options.backup
const audioFiles = libraryItem.media.includedAudioFiles
const itemAudioMetadataPayload = { const itemAudioMetadataPayload = {
userId: user.id, userId: user.id,
@ -45,35 +34,55 @@ class AudioMetadataMangaer {
SocketAuthority.emitter('audio_metadata_started', itemAudioMetadataPayload) SocketAuthority.emitter('audio_metadata_started', itemAudioMetadataPayload)
// Write chapters file // Ensure folder for backup files
var toneJsonPath = null
const itemCacheDir = Path.join(global.MetadataPath, `cache/items/${libraryItem.id}`) const itemCacheDir = Path.join(global.MetadataPath, `cache/items/${libraryItem.id}`)
await fs.ensureDir(itemCacheDir) let cacheDirCreated = false
if (!await fs.pathExists(itemCacheDir)) {
await fs.mkdir(itemCacheDir)
await filePerms.setDefault(itemCacheDir, true)
cacheDirCreated = true
}
// Write chapters file
const toneJsonPath = Path.join(itemCacheDir, 'metadata.json')
try { try {
toneJsonPath = Path.join(itemCacheDir, 'metadata.json')
const chapters = (audioFiles.length == 1 || forceEmbedChapters) ? libraryItem.media.chapters : null const chapters = (audioFiles.length == 1 || forceEmbedChapters) ? libraryItem.media.chapters : null
await toneHelpers.writeToneMetadataJsonFile(libraryItem, chapters, toneJsonPath, audioFiles.length) await toneHelpers.writeToneMetadataJsonFile(libraryItem, chapters, toneJsonPath, audioFiles.length)
} catch (error) { } catch (error) {
Logger.error(`[AudioMetadataManager] Write metadata.json failed`, error) Logger.error(`[AudioMetadataManager] Write metadata.json failed`, error)
toneJsonPath = null
itemAudioMetadataPayload.failed = true
itemAudioMetadataPayload.error = 'Failed to write metadata.json'
SocketAuthority.emitter('audio_metadata_finished', itemAudioMetadataPayload)
return
} }
const results = [] const results = []
for (const af of audioFiles) { for (const af of audioFiles) {
const result = await this.updateAudioFileMetadataWithTone(libraryItem.id, af, toneJsonPath, itemCacheDir) const result = await this.updateAudioFileMetadataWithTone(libraryItem.id, af, toneJsonPath, itemCacheDir, backupFiles)
results.push(result) results.push(result)
} }
// Remove temp cache file/folder if not backing up
if (!backupFiles) {
// If cache dir was created from this then remove it
if (cacheDirCreated) {
await fs.remove(itemCacheDir)
} else {
await fs.remove(toneJsonPath)
}
}
const elapsed = Date.now() - itemAudioMetadataPayload.startedAt const elapsed = Date.now() - itemAudioMetadataPayload.startedAt
Logger.debug(`[AudioMetadataManager] Elapsed ${secondsToTimestamp(elapsed)}`) Logger.debug(`[AudioMetadataManager] Elapsed ${secondsToTimestamp(elapsed / 1000, true)}`)
itemAudioMetadataPayload.results = results itemAudioMetadataPayload.results = results
itemAudioMetadataPayload.elapsed = elapsed itemAudioMetadataPayload.elapsed = elapsed
itemAudioMetadataPayload.finishedAt = Date.now() itemAudioMetadataPayload.finishedAt = Date.now()
SocketAuthority.emitter('audio_metadata_finished', itemAudioMetadataPayload) SocketAuthority.emitter('audio_metadata_finished', itemAudioMetadataPayload)
} }
async updateAudioFileMetadataWithTone(libraryItemId, audioFile, toneJsonPath, itemCacheDir) { async updateAudioFileMetadataWithTone(libraryItemId, audioFile, toneJsonPath, itemCacheDir, backupFiles) {
const resultPayload = { const resultPayload = {
libraryItemId, libraryItemId,
index: audioFile.index, index: audioFile.index,
@ -83,6 +92,7 @@ class AudioMetadataMangaer {
SocketAuthority.emitter('audiofile_metadata_started', resultPayload) SocketAuthority.emitter('audiofile_metadata_started', resultPayload)
// Backup audio file // Backup audio file
if (backupFiles) {
try { try {
const backupFilePath = Path.join(itemCacheDir, audioFile.metadata.filename) const backupFilePath = Path.join(itemCacheDir, audioFile.metadata.filename)
await fs.copy(audioFile.metadata.path, backupFilePath) await fs.copy(audioFile.metadata.path, backupFilePath)
@ -90,6 +100,7 @@ class AudioMetadataMangaer {
} catch (err) { } catch (err) {
Logger.error(`[AudioMetadataManager] Failed to backup audio file "${audioFile.metadata.path}"`, err) Logger.error(`[AudioMetadataManager] Failed to backup audio file "${audioFile.metadata.path}"`, err)
} }
}
const _toneMetadataObject = { const _toneMetadataObject = {
'ToneJsonFile': toneJsonPath, 'ToneJsonFile': toneJsonPath,
@ -104,161 +115,5 @@ class AudioMetadataMangaer {
SocketAuthority.emitter('audiofile_metadata_finished', resultPayload) SocketAuthority.emitter('audiofile_metadata_finished', resultPayload)
return resultPayload return resultPayload
} }
//
// FFMPEG
//
async updateMetadataForItemWithFfmpeg(user, libraryItem) {
var audioFiles = libraryItem.media.audioFiles
const itemAudioMetadataPayload = {
userId: user.id,
libraryItemId: libraryItem.id,
startedAt: Date.now(),
audioFiles: audioFiles.map(af => ({ index: af.index, ino: af.ino, filename: af.metadata.filename }))
}
SocketAuthority.emitter('audio_metadata_started', itemAudioMetadataPayload)
var downloadsPath = Path.join(global.MetadataPath, 'downloads')
var outputDir = Path.join(downloadsPath, libraryItem.id)
await fs.ensureDir(outputDir)
var metadataFilePath = Path.join(outputDir, 'metadata.txt')
await writeMetadataFile(libraryItem, metadataFilePath)
if (libraryItem.media.coverPath != null) {
var coverPath = filePathToPOSIX(libraryItem.media.coverPath)
}
const proms = audioFiles.map(af => {
return this.updateAudioFileMetadataWithFfmpeg(libraryItem.id, af, outputDir, metadataFilePath, coverPath)
})
const results = await Promise.all(proms)
Logger.debug(`[AudioMetadataManager] Finished`)
await fs.remove(outputDir)
const elapsed = Date.now() - itemAudioMetadataPayload.startedAt
Logger.debug(`[AudioMetadataManager] Elapsed ${secondsToTimestamp(elapsed)}`)
itemAudioMetadataPayload.results = results
itemAudioMetadataPayload.elapsed = elapsed
itemAudioMetadataPayload.finishedAt = Date.now()
SocketAuthority.emitter('audio_metadata_finished', itemAudioMetadataPayload)
}
updateAudioFileMetadataWithFfmpeg(libraryItemId, audioFile, outputDir, metadataFilePath, coverPath = '') {
return new Promise((resolve) => {
const resultPayload = {
libraryItemId,
index: audioFile.index,
ino: audioFile.ino,
filename: audioFile.metadata.filename
}
SocketAuthority.emitter('audiofile_metadata_started', resultPayload)
Logger.debug(`[AudioFileMetadataManager] Starting audio file metadata encode for "${audioFile.metadata.filename}"`)
var outputPath = Path.join(outputDir, audioFile.metadata.filename)
var inputPath = audioFile.metadata.path
const isM4b = audioFile.metadata.format === 'm4b'
const ffmpegInputs = [
{
input: inputPath,
options: isM4b ? ['-f mp4'] : []
},
{
input: metadataFilePath
}
]
/*
Mp4 doesnt support writing custom tags by default. Supported tags are itunes tags: https://git.videolan.org/?p=ffmpeg.git;a=blob;f=libavformat/movenc.c;h=b6821d447c92183101086cb67099b2f4804293de;hb=HEAD#l2905
Workaround -movflags use_metadata_tags found here: https://superuser.com/a/1208277
Ffmpeg premapped id3 tags: https://wiki.multimedia.cx/index.php/FFmpeg_Metadata
*/
const ffmpegOptions = ['-c copy', '-map_chapters 1', '-map_metadata 1', `-metadata track=${audioFile.index}`, '-write_id3v2 1', '-movflags use_metadata_tags']
if (coverPath != '') {
var ffmpegCoverPathInput = {
input: coverPath,
options: ['-f image2pipe']
}
var ffmpegCoverPathOptions = [
'-c:v copy',
'-map 2:v',
'-map 0:a'
]
ffmpegInputs.push(ffmpegCoverPathInput)
Logger.debug(`[AudioFileMetaDataManager] Cover found for "${audioFile.metadata.filename}". Cover will be merged to metadata`)
} else {
// remove the video stream to account for the user getting rid an existing cover in abs
var ffmpegCoverPathOptions = [
'-map 0',
'-map -0:v'
]
Logger.debug(`[AudioFileMetaDataManager] No cover found for "${audioFile.metadata.filename}". Cover will be skipped or removed from metadata`)
}
ffmpegOptions.push(...ffmpegCoverPathOptions)
var workerData = {
inputs: ffmpegInputs,
options: ffmpegOptions,
outputOptions: isM4b ? ['-f mp4'] : [],
output: outputPath,
}
var workerPath = Path.join(global.appRoot, 'server/utils/downloadWorker.js')
var worker = new workerThreads.Worker(workerPath, { workerData })
worker.on('message', async (message) => {
if (message != null && typeof message === 'object') {
if (message.type === 'RESULT') {
Logger.debug(message)
if (message.success) {
Logger.debug(`[AudioFileMetadataManager] Metadata encode SUCCESS for "${audioFile.metadata.filename}"`)
await filePerms.setDefault(outputPath, true)
fs.move(outputPath, inputPath, { overwrite: true }).then(() => {
Logger.debug(`[AudioFileMetadataManager] Audio file replaced successfully "${inputPath}"`)
resultPayload.success = true
SocketAuthority.emitter('audiofile_metadata_finished', resultPayload)
resolve(resultPayload)
}).catch((error) => {
Logger.error(`[AudioFileMetadataManager] Audio file failed to move "${inputPath}"`, error)
resultPayload.success = false
SocketAuthority.emitter('audiofile_metadata_finished', resultPayload)
resolve(resultPayload)
})
} else {
Logger.debug(`[AudioFileMetadataManager] Metadata encode FAILED for "${audioFile.metadata.filename}"`)
resultPayload.success = false
SocketAuthority.emitter('audiofile_metadata_finished', resultPayload)
resolve(resultPayload)
}
} else if (message.type === 'FFMPEG') {
if (message.level === 'debug' && process.env.NODE_ENV === 'production') {
// stderr is not necessary in production
} else if (Logger[message.level]) {
Logger[message.level](message.log)
}
}
} else {
Logger.error('Invalid worker message', message)
}
})
})
}
} }
module.exports = AudioMetadataMangaer module.exports = AudioMetadataMangaer

View File

@ -1,7 +1,6 @@
const Ffmpeg = require('../libs/fluentFfmpeg') const Ffmpeg = require('../libs/fluentFfmpeg')
const fs = require('../libs/fsExtra') const fs = require('../libs/fsExtra')
const Path = require('path') const Path = require('path')
const package = require('../../package.json')
const Logger = require('../Logger') const Logger = require('../Logger')
const { filePathToPOSIX } = require('./fileUtils') const { filePathToPOSIX } = require('./fileUtils')
@ -41,59 +40,6 @@ async function writeConcatFile(tracks, outputPath, startTime = 0) {
} }
module.exports.writeConcatFile = writeConcatFile module.exports.writeConcatFile = writeConcatFile
async function writeMetadataFile(libraryItem, outputPath) {
var inputstrs = [
';FFMETADATA1',
`title=${libraryItem.media.metadata.title}`,
`artist=${libraryItem.media.metadata.authorName}`,
`album_artist=${libraryItem.media.metadata.authorName}`,
`date=${libraryItem.media.metadata.publishedYear || ''}`,
`description=${libraryItem.media.metadata.description || ''}`,
`genre=${libraryItem.media.metadata.genres.join(';')}`,
`performer=${libraryItem.media.metadata.narratorName || ''}`,
`encoded_by=audiobookshelf:${package.version}`
]
if (libraryItem.media.metadata.asin) {
inputstrs.push(`ASIN=${libraryItem.media.metadata.asin}`)
}
if (libraryItem.media.metadata.isbn) {
inputstrs.push(`ISBN=${libraryItem.media.metadata.isbn}`)
}
if (libraryItem.media.metadata.language) {
inputstrs.push(`language=${libraryItem.media.metadata.language}`)
}
if (libraryItem.media.metadata.series.length) {
// Only uses first series
var firstSeries = libraryItem.media.metadata.series[0]
inputstrs.push(`series=${firstSeries.name}`)
if (firstSeries.sequence) {
inputstrs.push(`series-part=${firstSeries.sequence}`)
}
}
if (libraryItem.media.metadata.subtitle) {
inputstrs.push(`subtitle=${libraryItem.media.metadata.subtitle}`)
}
if (libraryItem.media.chapters) {
libraryItem.media.chapters.forEach((chap) => {
const chapterstrs = [
'[CHAPTER]',
'TIMEBASE=1/1000',
`START=${Math.round(chap.start * 1000)}`,
`END=${Math.round(chap.end * 1000)}`,
`title=${chap.title}`
]
inputstrs = inputstrs.concat(chapterstrs)
})
}
await fs.writeFile(outputPath, inputstrs.join('\n'))
return inputstrs
}
module.exports.writeMetadataFile = writeMetadataFile
async function extractCoverArt(filepath, outputpath) { async function extractCoverArt(filepath, outputpath) {
var dirname = Path.dirname(outputpath) var dirname = Path.dirname(outputpath)
await fs.ensureDir(dirname) await fs.ensureDir(dirname)

View File

@ -100,8 +100,6 @@ function secondsToTimestamp(seconds, includeMs = false, alwaysIncludeHours = fal
} }
module.exports.secondsToTimestamp = secondsToTimestamp module.exports.secondsToTimestamp = secondsToTimestamp
module.exports.msToTimestamp = (ms, includeMs) => secondsToTimestamp(ms / 1000, includeMs)
module.exports.reqSupportsWebp = (req) => { module.exports.reqSupportsWebp = (req) => {
if (!req || !req.headers || !req.headers.accept) return false if (!req || !req.headers || !req.headers.accept) return false
return req.headers.accept.includes('image/webp') || req.headers.accept === '*/*' return req.headers.accept.includes('image/webp') || req.headers.accept === '*/*'