mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-01-03 00:06:46 +01:00
Add:Experimental embed metadata in audio files #141
This commit is contained in:
parent
5f0f8b92d1
commit
84c12a6e7e
@ -38,7 +38,9 @@
|
||||
</div>
|
||||
<draggable v-model="files" v-bind="dragOptions" class="list-group border border-gray-600" draggable=".item" tag="ul" @start="drag = true" @end="drag = false" @update="draggableUpdate">
|
||||
<transition-group type="transition" :name="!drag ? 'flip-list' : null">
|
||||
<li v-for="(audio, index) in files" :key="audio.ino" :class="audio.include ? 'item' : 'exclude'" class="w-full list-group-item flex items-center">
|
||||
<li v-for="(audio, index) in files" :key="audio.ino" :class="audio.include ? 'item' : 'exclude'" class="w-full list-group-item flex items-center relative">
|
||||
<div v-if="audiofilesEncoding[audio.ino]" class="absolute top-0 left-0 w-full h-full bg-success bg-opacity-25" />
|
||||
|
||||
<div class="font-book text-center px-4 py-1 w-12">
|
||||
{{ audio.include ? index - numExcluded + 1 : -1 }}
|
||||
</div>
|
||||
@ -71,12 +73,18 @@
|
||||
<div class="font-sans text-xs font-normal w-56">
|
||||
{{ audio.error }}
|
||||
</div>
|
||||
<div class="font-sans text-xs font-normal w-40 flex justify-center">
|
||||
<ui-toggle-switch v-model="audio.include" :off-color="'error'" @input="includeToggled(audio)" />
|
||||
<div class="font-sans text-xs font-normal w-40 flex items-center justify-center">
|
||||
<widgets-loading-spinner v-if="audiofilesEncoding[audio.ino]" />
|
||||
<p v-if="audiofilesEncoding[audio.ino]" class="text-warning pl-4 text-base">Encoding</p>
|
||||
<ui-toggle-switch v-else v-model="audio.include" :off-color="'error'" @input="includeToggled(audio)" />
|
||||
</div>
|
||||
</li>
|
||||
</transition-group>
|
||||
</draggable>
|
||||
|
||||
<div v-if="showExperimentalFeatures && isRootUser" class="w-full flex justify-end items-center py-6">
|
||||
<ui-btn color="primary" small :loading="updatingMetadata" @click="updateAudioFileMetadata">Encode metadata in audio files <span class="text-warning font-bold text-xs">(experimental)</span></ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -125,10 +133,18 @@ export default {
|
||||
ghostClass: 'ghost'
|
||||
},
|
||||
saving: false,
|
||||
currentSort: 'current'
|
||||
currentSort: 'current',
|
||||
updatingMetadata: false,
|
||||
audiofilesEncoding: {}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
showExperimentalFeatures() {
|
||||
return this.$store.state.showExperimentalFeatures
|
||||
},
|
||||
isRootUser() {
|
||||
return this.$store.getters['user/getIsRoot']
|
||||
},
|
||||
media() {
|
||||
return this.libraryItem.media || {}
|
||||
},
|
||||
@ -162,12 +178,23 @@ export default {
|
||||
},
|
||||
streamLibraryItem() {
|
||||
return this.$store.state.streamLibraryItem
|
||||
},
|
||||
showExperimentalFeatures() {
|
||||
return this.$store.state.showExperimentalFeatures
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
updateAudioFileMetadata() {
|
||||
if (confirm(`Warning!\n\nThis will modify the audio files for this audiobook.\nMake sure your audio files are backed up before using this feature.`)) {
|
||||
this.updatingMetadata = true
|
||||
this.$axios
|
||||
.$get(`/api/items/${this.libraryItemId}/audio-metadata`)
|
||||
.then(() => {
|
||||
console.log('Audio metadata encode started')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Audio metadata encode failed', error)
|
||||
this.updatingMetadata = false
|
||||
})
|
||||
}
|
||||
},
|
||||
draggableUpdate(e) {
|
||||
this.currentSort = ''
|
||||
},
|
||||
@ -242,7 +269,33 @@ export default {
|
||||
} else {
|
||||
return 'check_circle'
|
||||
}
|
||||
},
|
||||
audioMetadataStarted(data) {
|
||||
console.log('audio metadata started', data)
|
||||
if (data.libraryItemId !== this.libraryItemId) return
|
||||
this.updatingMetadata = true
|
||||
},
|
||||
audioMetadataFinished(data) {
|
||||
console.log('audio metadata finished', data)
|
||||
if (data.libraryItemId !== this.libraryItemId) return
|
||||
this.updatingMetadata = false
|
||||
this.audiofilesEncoding = {}
|
||||
this.$toast.success('Audio file metadata updated')
|
||||
},
|
||||
audiofileMetadataStarted(data) {
|
||||
if (data.libraryItemId !== this.libraryItemId) return
|
||||
this.$set(this.audiofilesEncoding, data.ino, true)
|
||||
},
|
||||
audiofileMetadataFinished(data) {
|
||||
if (data.libraryItemId !== this.libraryItemId) return
|
||||
this.$set(this.audiofilesEncoding, data.ino, false)
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.$root.socket.on('audio_metadata_started', this.audioMetadataStarted)
|
||||
this.$root.socket.on('audio_metadata_finished', this.audioMetadataFinished)
|
||||
this.$root.socket.on('audiofile_metadata_started', this.audiofileMetadataStarted)
|
||||
this.$root.socket.on('audiofile_metadata_finished', this.audiofileMetadataFinished)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
@ -30,6 +30,7 @@ const LogManager = require('./managers/LogManager')
|
||||
const BackupManager = require('./managers/BackupManager')
|
||||
const PlaybackSessionManager = require('./managers/PlaybackSessionManager')
|
||||
const PodcastManager = require('./managers/PodcastManager')
|
||||
const AudioMetadataMangaer = require('./managers/AudioMetadataManager')
|
||||
|
||||
class Server {
|
||||
constructor(PORT, HOST, UID, GID, CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH) {
|
||||
@ -72,11 +73,12 @@ class Server {
|
||||
this.playbackSessionManager = new PlaybackSessionManager(this.db, this.emitter.bind(this), this.clientEmitter.bind(this))
|
||||
this.coverManager = new CoverManager(this.db, this.cacheManager)
|
||||
this.podcastManager = new PodcastManager(this.db, this.watcher, this.emitter.bind(this))
|
||||
this.audioMetadataManager = new AudioMetadataMangaer(this.db, this.emitter.bind(this), this.clientEmitter.bind(this))
|
||||
|
||||
this.scanner = new Scanner(this.db, this.coverManager, this.emitter.bind(this))
|
||||
|
||||
// Routers
|
||||
this.apiRouter = new ApiRouter(this.db, this.auth, this.scanner, this.playbackSessionManager, this.abMergeManager, this.coverManager, this.backupManager, this.watcher, this.cacheManager, this.podcastManager, this.emitter.bind(this), this.clientEmitter.bind(this))
|
||||
this.apiRouter = new ApiRouter(this.db, this.auth, this.scanner, this.playbackSessionManager, this.abMergeManager, this.coverManager, this.backupManager, this.watcher, this.cacheManager, this.podcastManager, this.audioMetadataManager, this.emitter.bind(this), this.clientEmitter.bind(this))
|
||||
this.hlsRouter = new HlsRouter(this.db, this.auth, this.playbackSessionManager, this.emitter.bind(this))
|
||||
this.staticRouter = new StaticRouter(this.db)
|
||||
|
||||
|
@ -354,6 +354,22 @@ class LibraryItemController {
|
||||
})
|
||||
}
|
||||
|
||||
// POST: api/items/:id/audio-metadata
|
||||
async updateAudioFileMetadata(req, res) {
|
||||
if (!req.user.isRoot) {
|
||||
Logger.error(`[LibraryItemController] Non-root user attempted to update audio metadata`, req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
if (req.libraryItem.isMissing || !req.libraryItem.hasAudioFiles || !req.libraryItem.isBook) {
|
||||
Logger.error(`[LibraryItemController] Invalid library item`)
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
|
||||
this.audioMetadataManager.updateAudioFileMetadataForItem(req.user, req.libraryItem)
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
middleware(req, res, next) {
|
||||
var item = this.db.libraryItems.find(li => li.id === req.params.id)
|
||||
if (!item || !item.media) return res.sendStatus(404)
|
||||
|
@ -121,7 +121,7 @@ class AbMergeManager {
|
||||
'-acodec aac',
|
||||
'-ac 2',
|
||||
'-b:a 64k',
|
||||
'-id3v2_version 3'
|
||||
'-movflags use_metadata_tags'
|
||||
])
|
||||
} else {
|
||||
ffmpegOptions.push('-max_muxing_queue_size 1000')
|
||||
|
137
server/managers/AudioMetadataManager.js
Normal file
137
server/managers/AudioMetadataManager.js
Normal file
@ -0,0 +1,137 @@
|
||||
const Path = require('path')
|
||||
const fs = require('fs-extra')
|
||||
const workerThreads = require('worker_threads')
|
||||
const Logger = require('../Logger')
|
||||
const filePerms = require('../utils/filePerms')
|
||||
const { secondsToTimestamp } = require('../utils/index')
|
||||
const { writeMetadataFile } = require('../utils/ffmpegHelpers')
|
||||
|
||||
class AudioMetadataMangaer {
|
||||
constructor(db, emitter, clientEmitter) {
|
||||
this.db = db
|
||||
this.emitter = emitter
|
||||
this.clientEmitter = clientEmitter
|
||||
}
|
||||
|
||||
async updateAudioFileMetadataForItem(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 }))
|
||||
}
|
||||
|
||||
this.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)
|
||||
|
||||
const proms = audioFiles.map(af => {
|
||||
return this.updateAudioFileMetadata(libraryItem.id, af, outputDir, metadataFilePath)
|
||||
})
|
||||
|
||||
const results = await Promise.all(proms)
|
||||
|
||||
Logger.debug(`[AudioMetadataManager] Finished`, results)
|
||||
|
||||
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()
|
||||
this.emitter('audio_metadata_finished', itemAudioMetadataPayload)
|
||||
}
|
||||
|
||||
updateAudioFileMetadata(libraryItemId, audioFile, outputDir, metadataFilePath) {
|
||||
return new Promise((resolve) => {
|
||||
const resultPayload = {
|
||||
libraryItemId,
|
||||
index: audioFile.index,
|
||||
ino: audioFile.ino,
|
||||
filename: audioFile.metadata.filename
|
||||
}
|
||||
this.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_metadata 1', `-metadata track=${audioFile.index}`, '-write_id3v2 1', '-movflags use_metadata_tags']
|
||||
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
|
||||
this.emitter('audiofile_metadata_finished', resultPayload)
|
||||
resolve(resultPayload)
|
||||
}).catch((error) => {
|
||||
Logger.error(`[AudioFileMetadataManager] Audio file failed to move "${inputPath}"`, error)
|
||||
resultPayload.success = false
|
||||
this.emitter('audiofile_metadata_finished', resultPayload)
|
||||
resolve(resultPayload)
|
||||
})
|
||||
} else {
|
||||
Logger.debug(`[AudioFileMetadataManager] Metadata encode FAILED for "${audioFile.metadata.filename}"`)
|
||||
|
||||
resultPayload.success = false
|
||||
this.emitter('audiofile_metadata_finished', resultPayload)
|
||||
resolve(resultPayload)
|
||||
}
|
||||
} else if (message.type === 'FFMPEG') {
|
||||
if (Logger[message.level]) {
|
||||
Logger[message.level](message.log)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Logger.error('Invalid worker message', message)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
module.exports = AudioMetadataMangaer
|
@ -25,7 +25,7 @@ const Series = require('../objects/entities/Series')
|
||||
const FileSystemController = require('../controllers/FileSystemController')
|
||||
|
||||
class ApiRouter {
|
||||
constructor(db, auth, scanner, playbackSessionManager, abMergeManager, coverManager, backupManager, watcher, cacheManager, podcastManager, emitter, clientEmitter) {
|
||||
constructor(db, auth, scanner, playbackSessionManager, abMergeManager, coverManager, backupManager, watcher, cacheManager, podcastManager, audioMetadataManager, emitter, clientEmitter) {
|
||||
this.db = db
|
||||
this.auth = auth
|
||||
this.scanner = scanner
|
||||
@ -36,6 +36,7 @@ class ApiRouter {
|
||||
this.watcher = watcher
|
||||
this.cacheManager = cacheManager
|
||||
this.podcastManager = podcastManager
|
||||
this.audioMetadataManager = audioMetadataManager
|
||||
this.emitter = emitter
|
||||
this.clientEmitter = clientEmitter
|
||||
|
||||
@ -91,6 +92,7 @@ class ApiRouter {
|
||||
this.router.patch('/items/:id/episodes', LibraryItemController.middleware.bind(this), LibraryItemController.updateEpisodes.bind(this))
|
||||
this.router.delete('/items/:id/episode/:episodeId', LibraryItemController.middleware.bind(this), LibraryItemController.removeEpisode.bind(this))
|
||||
this.router.get('/items/:id/scan', LibraryItemController.middleware.bind(this), LibraryItemController.scan.bind(this)) // Root only
|
||||
this.router.get('/items/:id/audio-metadata', LibraryItemController.middleware.bind(this), LibraryItemController.updateAudioFileMetadata.bind(this)) // Root only
|
||||
|
||||
this.router.post('/items/batch/delete', LibraryItemController.batchDelete.bind(this))
|
||||
this.router.post('/items/batch/update', LibraryItemController.batchUpdate.bind(this))
|
||||
|
@ -39,7 +39,7 @@ async function runFfmpeg() {
|
||||
ffmpegCommand.on('stderr', (stdErrline) => {
|
||||
parentPort.postMessage({
|
||||
type: 'FFMPEG',
|
||||
level: 'error',
|
||||
level: 'debug',
|
||||
log: '[DownloadWorker] Ffmpeg Stderr: ' + stdErrline
|
||||
})
|
||||
})
|
||||
|
@ -48,10 +48,33 @@ async function writeMetadataFile(libraryItem, outputPath) {
|
||||
`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(';')}`
|
||||
`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 = [
|
||||
|
@ -204,12 +204,12 @@ function parseTags(format, verbose) {
|
||||
}
|
||||
}
|
||||
|
||||
var keysToLookOutFor = ['file_tag_genre1', 'file_tag_genre2', 'file_tag_series', 'file_tag_seriespart', 'file_tag_movement', 'file_tag_movementname', 'file_tag_wwwaudiofile', 'file_tag_contentgroup', 'file_tag_releasetime', 'file_tag_isbn']
|
||||
keysToLookOutFor.forEach((key) => {
|
||||
if (tags[key]) {
|
||||
Logger.debug(`Notable! ${key} => ${tags[key]}`)
|
||||
}
|
||||
})
|
||||
// var keysToLookOutFor = ['file_tag_genre1', 'file_tag_genre2', 'file_tag_series', 'file_tag_seriespart', 'file_tag_movement', 'file_tag_movementname', 'file_tag_wwwaudiofile', 'file_tag_contentgroup', 'file_tag_releasetime', 'file_tag_isbn']
|
||||
// keysToLookOutFor.forEach((key) => {
|
||||
// if (tags[key]) {
|
||||
// Logger.debug(`Notable! ${key} => ${tags[key]}`)
|
||||
// }
|
||||
// })
|
||||
return tags
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user