mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-01-03 00:06:46 +01:00
Add:Experimental embed audio metadata page
This commit is contained in:
parent
2746e61cb3
commit
9b9de84740
@ -50,7 +50,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Split to mp3 -->
|
<!-- Embed Metadata -->
|
||||||
<div v-if="mediaTracks.length" 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 class="flex items-center">
|
||||||
<div>
|
<div>
|
||||||
@ -59,18 +59,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<div>
|
<div>
|
||||||
<p v-if="abmergeStatus === $constants.DownloadStatus.FAILED" class="text-error mb-2">Download Failed</p>
|
<ui-btn :to="`/item/${libraryItemId}/manage`" class="flex items-center"
|
||||||
<p v-if="abmergeStatus === $constants.DownloadStatus.READY" class="text-success mb-2">Download Ready!</p>
|
>Open Manager
|
||||||
<p v-if="abmergeStatus === $constants.DownloadStatus.EXPIRED" class="text-error mb-2">Download Expired</p>
|
<span class="material-icons text-lg ml-2">launch</span>
|
||||||
|
</ui-btn>
|
||||||
<ui-btn v-if="abmergeStatus !== $constants.DownloadStatus.READY" :loading="abmergeStatus === $constants.DownloadStatus.PENDING" :disabled="true" @click="startAudiobookMerge">Not yet implemented</ui-btn>
|
|
||||||
<div v-else>
|
|
||||||
<div class="flex">
|
|
||||||
<ui-btn @click="downloadWithProgress(abmergeDownload)">Download</ui-btn>
|
|
||||||
<ui-icon-btn small icon="delete" bg-color="error" class="ml-2" @click="removeDownload" />
|
|
||||||
</div>
|
|
||||||
<p class="px-0.5 py-1 text-sm font-mono text-center">Size: {{ $bytesPretty(abmergeDownload.size) }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -74,17 +74,11 @@
|
|||||||
{{ audio.error }}
|
{{ audio.error }}
|
||||||
</div>
|
</div>
|
||||||
<div class="font-sans text-xs font-normal w-40 flex items-center justify-center">
|
<div class="font-sans text-xs font-normal w-40 flex items-center justify-center">
|
||||||
<widgets-loading-spinner v-if="audiofilesEncoding[audio.ino]" />
|
<ui-toggle-switch v-model="audio.include" :off-color="'error'" @input="includeToggled(audio)" />
|
||||||
<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>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</transition-group>
|
</transition-group>
|
||||||
</draggable>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -133,9 +127,7 @@ export default {
|
|||||||
ghostClass: 'ghost'
|
ghostClass: 'ghost'
|
||||||
},
|
},
|
||||||
saving: false,
|
saving: false,
|
||||||
currentSort: 'current',
|
currentSort: 'current'
|
||||||
updatingMetadata: false,
|
|
||||||
audiofilesEncoding: {}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@ -181,20 +173,6 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
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) {
|
draggableUpdate(e) {
|
||||||
this.currentSort = ''
|
this.currentSort = ''
|
||||||
},
|
},
|
||||||
@ -269,33 +247,7 @@ export default {
|
|||||||
} else {
|
} else {
|
||||||
return 'check_circle'
|
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>
|
</script>
|
||||||
|
270
client/pages/item/_id/manage.vue
Normal file
270
client/pages/item/_id/manage.vue
Normal file
@ -0,0 +1,270 @@
|
|||||||
|
<template>
|
||||||
|
<div id="page-wrapper" class="bg-bg page p-8 overflow-auto relative" :class="streamLibraryItem ? 'streaming' : ''">
|
||||||
|
<div class="flex justify-center mb-2">
|
||||||
|
<div class="w-full max-w-2xl">
|
||||||
|
<p class="text-xl">Metadata to embed</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-full max-w-2xl"></div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-center flex-wrap">
|
||||||
|
<div class="w-full max-w-2xl border border-opacity-10 bg-bg mx-2">
|
||||||
|
<div class="flex py-2 px-4">
|
||||||
|
<div class="w-1/3 text-xs font-semibold uppercase text-gray-200">Meta Tag</div>
|
||||||
|
<div class="w-2/3 text-xs font-semibold uppercase text-gray-200">Value</div>
|
||||||
|
</div>
|
||||||
|
<div class="w-full max-h-72 overflow-auto">
|
||||||
|
<template v-for="(keyValue, index) in metadataKeyValues">
|
||||||
|
<div :key="keyValue.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">{{ keyValue.key }}</div>
|
||||||
|
<div class="w-2/3">
|
||||||
|
{{ keyValue.value }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="w-full max-w-2xl border border-opacity-10 bg-bg mx-2">
|
||||||
|
<div class="flex py-2 px-4">
|
||||||
|
<div class="flex-grow text-xs font-semibold uppercase text-gray-200">Chapter Title</div>
|
||||||
|
<div class="w-24 text-xs font-semibold uppercase text-gray-200">Start</div>
|
||||||
|
<div class="w-24 text-xs font-semibold uppercase text-gray-200">End</div>
|
||||||
|
</div>
|
||||||
|
<div class="w-full max-h-72 overflow-auto">
|
||||||
|
<template v-for="(chapter, index) in metadataChapters">
|
||||||
|
<div :key="index" class="flex py-1 px-4 text-sm" :class="index % 2 === 0 ? 'bg-primary bg-opacity-25' : ''">
|
||||||
|
<div class="flex-grow font-semibold">{{ chapter.title }}</div>
|
||||||
|
<div class="w-24">
|
||||||
|
{{ chapter.start.toFixed(2) }}
|
||||||
|
</div>
|
||||||
|
<div class="w-24">
|
||||||
|
{{ chapter.end.toFixed(2) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-full h-px bg-white bg-opacity-10 my-8" />
|
||||||
|
|
||||||
|
<div class="w-full max-w-4xl mx-auto">
|
||||||
|
<div class="w-full flex justify-between items-center mb-4">
|
||||||
|
<p class="text-warning text-lg font-semibold">Warning: Modifies your audio files</p>
|
||||||
|
<ui-btn v-if="!embedFinished" color="primary" :loading="updatingMetadata" @click="updateAudioFileMetadata">Embed Metadata</ui-btn>
|
||||||
|
<p v-else class="text-success text-lg font-semibold">Embed Finished!</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-full mx-auto border border-opacity-10 bg-bg">
|
||||||
|
<div class="flex py-2 px-4">
|
||||||
|
<div class="w-10 text-xs font-semibold text-gray-200">#</div>
|
||||||
|
<div class="flex-grow text-xs font-semibold uppercase text-gray-200">Filename</div>
|
||||||
|
<div class="w-16 text-xs font-semibold uppercase text-gray-200">Size</div>
|
||||||
|
<div class="w-24"></div>
|
||||||
|
</div>
|
||||||
|
<template v-for="file in audioFiles">
|
||||||
|
<div :key="file.index" class="flex py-2 px-4 text-sm" :class="file.index % 2 === 0 ? 'bg-primary bg-opacity-25' : ''">
|
||||||
|
<div class="w-10">{{ file.index }}</div>
|
||||||
|
<div class="flex-grow">
|
||||||
|
{{ file.metadata.filename }}
|
||||||
|
</div>
|
||||||
|
<div class="w-16 font-mono text-gray-200">
|
||||||
|
{{ $bytesPretty(file.metadata.size) }}
|
||||||
|
</div>
|
||||||
|
<div class="w-24">
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<span v-if="audiofilesFinished[file.ino]" class="material-icons text-xl text-success leading-none">check_circle</span>
|
||||||
|
<div v-else-if="audiofilesEncoding[file.ino]">
|
||||||
|
<widgets-loading-spinner />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
async asyncData({ store, params, app, redirect, route }) {
|
||||||
|
if (!store.state.user.user) {
|
||||||
|
return redirect(`/login?redirect=${route.path}`)
|
||||||
|
}
|
||||||
|
if (!store.getters['user/getIsAdminOrUp']) {
|
||||||
|
return redirect('/?error=unauthorized')
|
||||||
|
}
|
||||||
|
var libraryItem = await app.$axios.$get(`/api/items/${params.id}?expanded=1`).catch((error) => {
|
||||||
|
console.error('Failed', error)
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
if (!libraryItem) {
|
||||||
|
console.error('Not found...', params.id)
|
||||||
|
return redirect('/?error=not found')
|
||||||
|
}
|
||||||
|
if (libraryItem.mediaType !== 'book') {
|
||||||
|
console.error('Invalid media type')
|
||||||
|
return redirect('/?error=invalid media type')
|
||||||
|
}
|
||||||
|
if (!libraryItem.media.audioFiles.length) {
|
||||||
|
cnosole.error('No audio files')
|
||||||
|
return redirect('/?error=no audio files')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
libraryItem
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
audiofilesEncoding: {},
|
||||||
|
audiofilesFinished: {},
|
||||||
|
updatingMetadata: false,
|
||||||
|
embedFinished: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
libraryItemId() {
|
||||||
|
return this.libraryItem.id
|
||||||
|
},
|
||||||
|
media() {
|
||||||
|
return this.libraryItem.media || {}
|
||||||
|
},
|
||||||
|
mediaMetadata() {
|
||||||
|
return this.media.metadata || {}
|
||||||
|
},
|
||||||
|
audioFiles() {
|
||||||
|
return this.media.audioFiles || []
|
||||||
|
},
|
||||||
|
streamLibraryItem() {
|
||||||
|
return this.$store.state.streamLibraryItem
|
||||||
|
},
|
||||||
|
metadataKeyValues() {
|
||||||
|
const keyValues = [
|
||||||
|
{
|
||||||
|
key: 'title',
|
||||||
|
value: this.mediaMetadata.title
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'artist',
|
||||||
|
value: this.mediaMetadata.authorName
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'album_artist',
|
||||||
|
value: this.mediaMetadata.authorName
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'date',
|
||||||
|
value: this.mediaMetadata.publishedYear
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'description',
|
||||||
|
value: this.mediaMetadata.description
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'genre',
|
||||||
|
value: this.mediaMetadata.genres.join(';')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'performer',
|
||||||
|
value: this.mediaMetadata.narratorName
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
if (this.mediaMetadata.subtitle) {
|
||||||
|
keyValues.push({
|
||||||
|
key: 'subtitle',
|
||||||
|
value: this.mediaMetadata.subtitle
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.mediaMetadata.asin) {
|
||||||
|
keyValues.push({
|
||||||
|
key: 'asin',
|
||||||
|
value: this.mediaMetadata.asin
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (this.mediaMetadata.isbn) {
|
||||||
|
keyValues.push({
|
||||||
|
key: 'isbn',
|
||||||
|
value: this.mediaMetadata.isbn
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (this.mediaMetadata.language) {
|
||||||
|
keyValues.push({
|
||||||
|
key: 'language',
|
||||||
|
value: this.mediaMetadata.language
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (this.mediaMetadata.series.length) {
|
||||||
|
var firstSeries = this.mediaMetadata.series[0]
|
||||||
|
keyValues.push({
|
||||||
|
key: 'series',
|
||||||
|
value: firstSeries.name
|
||||||
|
})
|
||||||
|
if (firstSeries.sequence) {
|
||||||
|
keyValues.push({
|
||||||
|
key: 'series-part',
|
||||||
|
value: firstSeries.sequence
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return keyValues
|
||||||
|
},
|
||||||
|
metadataChapters() {
|
||||||
|
var chapters = this.media.chapters || []
|
||||||
|
return chapters.concat(chapters)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
audioMetadataStarted(data) {
|
||||||
|
console.log('audio metadata started', data)
|
||||||
|
if (data.libraryItemId !== this.libraryItemId) return
|
||||||
|
this.audiofilesFinished = {}
|
||||||
|
this.updatingMetadata = true
|
||||||
|
},
|
||||||
|
audioMetadataFinished(data) {
|
||||||
|
console.log('audio metadata finished', data)
|
||||||
|
if (data.libraryItemId !== this.libraryItemId) return
|
||||||
|
this.updatingMetadata = false
|
||||||
|
this.embedFinished = true
|
||||||
|
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)
|
||||||
|
this.$set(this.audiofilesFinished, data.ino, true)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
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)
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
this.$root.socket.off('audio_metadata_started', this.audioMetadataStarted)
|
||||||
|
this.$root.socket.off('audio_metadata_finished', this.audioMetadataFinished)
|
||||||
|
this.$root.socket.off('audiofile_metadata_started', this.audiofileMetadataStarted)
|
||||||
|
this.$root.socket.off('audiofile_metadata_finished', this.audiofileMetadataFinished)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
@ -32,13 +32,14 @@ class AudioMetadataMangaer {
|
|||||||
var metadataFilePath = Path.join(outputDir, 'metadata.txt')
|
var metadataFilePath = Path.join(outputDir, 'metadata.txt')
|
||||||
await writeMetadataFile(libraryItem, metadataFilePath)
|
await writeMetadataFile(libraryItem, metadataFilePath)
|
||||||
|
|
||||||
|
// TODO: Split into batches
|
||||||
const proms = audioFiles.map(af => {
|
const proms = audioFiles.map(af => {
|
||||||
return this.updateAudioFileMetadata(libraryItem.id, af, outputDir, metadataFilePath)
|
return this.updateAudioFileMetadata(libraryItem.id, af, outputDir, metadataFilePath)
|
||||||
})
|
})
|
||||||
|
|
||||||
const results = await Promise.all(proms)
|
const results = await Promise.all(proms)
|
||||||
|
|
||||||
Logger.debug(`[AudioMetadataManager] Finished`, results)
|
Logger.debug(`[AudioMetadataManager] Finished`)
|
||||||
|
|
||||||
await fs.remove(outputDir)
|
await fs.remove(outputDir)
|
||||||
|
|
||||||
@ -123,7 +124,9 @@ class AudioMetadataMangaer {
|
|||||||
resolve(resultPayload)
|
resolve(resultPayload)
|
||||||
}
|
}
|
||||||
} else if (message.type === 'FFMPEG') {
|
} else if (message.type === 'FFMPEG') {
|
||||||
if (Logger[message.level]) {
|
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)
|
Logger[message.level](message.log)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user