Update:M4b Merge tool moved to manage page

This commit is contained in:
advplyr 2022-10-02 11:53:53 -05:00
parent d456ec2786
commit 441b8c5bb7
6 changed files with 213 additions and 287 deletions

View File

@ -66,9 +66,9 @@ export default {
component: 'modals-item-tabs-match'
},
{
id: 'manage',
title: 'Manage',
component: 'modals-item-tabs-manage',
id: 'tools',
title: 'Tools',
component: 'modals-item-tabs-tools',
mediaType: 'book',
admin: true
},
@ -141,10 +141,10 @@ export default {
if (tab.mediaType && this.mediaType !== tab.mediaType) return false
if (tab.admin && !this.userIsAdminOrUp) return false
if (tab.id === 'manage' && this.isMissing) return false
if (tab.id === 'tools' && this.isMissing) return false
if ((tab.id === 'manage' || tab.id === 'files') && this.userCanDownload) return true
if (tab.id !== 'manage' && tab.id !== 'files' && this.userCanUpdate) return true
if ((tab.id === 'tools' || tab.id === 'files') && this.userCanDownload) return true
if (tab.id !== 'tools' && tab.id !== 'files' && this.userCanUpdate) return true
if (tab.id === 'match' && this.userCanUpdate) return true
return false
})

View File

@ -1,256 +0,0 @@
<template>
<div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6">
<!-- Merge to m4b -->
<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">Make M4B Audiobook File <span class="text-error">*</span></p>
<p class="max-w-sm text-sm pt-2 text-gray-300">Generate a .M4B audiobook file with embedded metadata, cover image, and chapters. <br /><span class="text-warning">*</span> Does not delete existing audio files.</p>
</div>
<div class="flex-grow" />
<div class="mt-2 md:mt-0">
<p v-if="abmergeStatus === $constants.DownloadStatus.FAILED" class="text-error mb-2">Download Failed</p>
<p v-if="abmergeStatus === $constants.DownloadStatus.READY" class="text-success mb-2">Download Ready!</p>
<p v-if="abmergeStatus === $constants.DownloadStatus.EXPIRED" class="text-error mb-2">Download Expired</p>
<ui-btn v-if="abmergeStatus !== $constants.DownloadStatus.READY" :loading="abmergeStatus === $constants.DownloadStatus.PENDING" :disabled="tempDisable" @click="startAudiobookMerge">Start Merge</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>
<!-- Split to mp3 -->
<div v-if="showMp3Split && showExperimentalFeatures" class="w-full border border-black-200 p-4 my-8">
<div class="flex items-center">
<div>
<p class="text-lg">Split M4B to MP3's</p>
<p class="max-w-sm text-sm pt-2 text-gray-300">Generate multiple MP3's split by chapters with embedded metadata, cover image, and chapters. <br /><span class="text-warning">*</span> Does not delete existing audio files.</p>
</div>
<div class="flex-grow" />
<div>
<ui-btn :loading="abmergeStatus === $constants.DownloadStatus.PENDING" :disabled="true" @click="startAudiobookMerge">Not yet implemented</ui-btn>
</div>
</div>
</div>
<!-- Embed Metadata -->
<div v-if="mediaTracks.length && showExperimentalFeatures" class="w-full border border-black-200 p-4 my-8">
<div class="flex items-center">
<div>
<p class="text-lg">Embed Metadata</p>
<p class="max-w-sm text-sm pt-2 text-gray-300">Embed metadata into audio files including cover image and chapters. <br /><span class="text-warning">*</span> Modifies audio files.</p>
</div>
<div class="flex-grow" />
<div>
<ui-btn :to="`/item/${libraryItemId}/manage`" class="flex items-center"
>Open Manager
<span class="material-icons text-lg ml-2">launch</span>
</ui-btn>
</div>
</div>
</div>
<p v-if="showM4bDownload" class="text-left text-base mb-4 py-4">
<span class="text-error">* <strong>Experimental</strong></span
>&nbsp;-&nbsp;M4b merge can take several minutes and will be stored in <span class="bg-primary bg-opacity-75 font-mono p-1 text-base">/metadata/downloads</span>. After the download is ready, it will remain available for 60 minutes, then be deleted. Download will timeout after 30 minutes.
</p>
<!-- <p v-if="isSingleM4b" class="text-lg text-center my-8">Audiobook is already a single m4b!</p> -->
<p v-if="!mediaTracks.length" class="text-lg text-center my-8">No audio tracks to merge</p>
<div v-if="isDownloading" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 z-50 flex items-center justify-center">
<div class="w-80 border border-black-400 bg-bg rounded-xl h-20">
<div class="w-full h-full flex items-center justify-center">
<p class="text-lg">Download.... {{ downloadPercent }}%</p>
<p class="w-24 font-mono pl-8 text-right">
{{ downloadAmount }}
</p>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
processing: Boolean,
libraryItem: {
type: Object,
default: () => {}
}
},
data() {
return {
tempDisable: false,
isDownloading: false,
downloadPercent: '0',
downloadAmount: '0 KB'
}
},
watch: {
abmergeStatus(newVal) {
if (newVal) {
this.tempDisable = false
}
}
},
computed: {
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
},
libraryItemId() {
return this.libraryItem ? this.libraryItem.id : null
},
media() {
return this.libraryItem ? this.libraryItem.media || {} : {}
},
downloads() {
return this.$store.getters['downloads/getDownloads'](this.libraryItemId)
},
abmergeDownload() {
return this.downloads.find((d) => d.type === 'abmerge')
},
abmergeStatus() {
return this.abmergeDownload ? this.abmergeDownload.status : false
},
libraryFiles() {
return this.libraryItem.libraryFiles
},
totalFiles() {
return this.libraryFiles.length
},
mediaTracks() {
return this.media.tracks || []
},
isSingleM4b() {
return this.mediaTracks.length === 1 && this.mediaTracks[0].metadata.ext.toLowerCase() === '.m4b'
},
chapters() {
return this.media.chapters || []
},
showM4bDownload() {
if (!this.mediaTracks.length) return false
return !this.isSingleM4b
},
showMp3Split() {
if (!this.mediaTracks.length) return false
return this.isSingleM4b && this.chapters.length
}
},
methods: {
removeDownload() {
if (!this.abmergeDownload) return
if (!confirm(`Are you sure you want to remove this merge download?`)) return
var downloadId = this.abmergeDownload.id
this.tempDisable = true
this.$axios
.$delete(`/api/ab-manager-tasks/${downloadId}`)
.then(() => {
this.tempDisable = false
this.$toast.success('Merge download deleted')
this.$store.commit('downloads/removeDownload', { id: downloadId })
})
.catch((error) => {
var errorMsg = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
this.$toast.error(errorMsg)
this.tempDisable = false
})
},
startAudiobookMerge() {
this.tempDisable = true
this.$axios
.$get(`/api/audiobook-merge/${this.libraryItemId}`)
.then(() => {
this.tempDisable = false
})
.catch((error) => {
var errorMsg = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
this.$toast.error(errorMsg)
this.tempDisable = false
})
},
downloadWithProgress(download) {
var downloadId = download.id
var downloadUrl = `${process.env.serverUrl}/api/ab-manager-tasks/${downloadId}`
var filename = download.filename
this.isDownloading = true
var request = new XMLHttpRequest()
request.responseType = 'blob'
request.open('get', downloadUrl, true)
request.setRequestHeader('Authorization', `Bearer ${this.$store.getters['user/getToken']}`)
request.send()
request.onreadystatechange = () => {
if (request.readyState === 4) {
this.isDownloading = false
}
if (request.readyState == 4 && request.status == 200) {
const url = window.URL.createObjectURL(request.response)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = filename
document.body.appendChild(anchor)
anchor.click()
setTimeout(() => {
if (anchor) anchor.remove()
}, 1000)
}
}
request.onerror = (err) => {
console.error('Download error', err)
this.isDownloading = false
}
request.onprogress = (e) => {
const percent_complete = Math.floor((e.loaded / e.total) * 100)
this.downloadAmount = this.$bytesPretty(e.loaded)
this.downloadPercent = percent_complete
// const duration = (new Date().getTime() - startTime) / 1000
// const bps = e.loaded / duration
// const kbps = Math.floor(bps / 1024)
// const time = (e.total - e.loaded) / bps
// const seconds = Math.floor(time % 60)
// const minutes = Math.floor(time / 60)
// console.log(`${percent_complete}% - ${kbps} Kbps - ${minutes} min ${seconds} sec remaining`)
}
},
loadDownloads() {
this.$axios
.$get(`/api/ab-manager-tasks`)
.then((data) => {
var pendingTasks = data.pendingTasks.map((pd) => {
pd.download.status = this.$constants.DownloadStatus.PENDING
return pd.download
})
var tasks = data.tasks.map((d) => {
d.status = this.$constants.DownloadStatus.READY
return d
})
var allTasks = tasks.concat(pendingTasks)
this.$store.commit('downloads/setDownloads', allTasks)
})
.catch((error) => {
console.error('Failed to load downloads', error)
})
}
},
mounted() {
this.loadDownloads()
}
}
</script>

View File

@ -0,0 +1,100 @@
<template>
<div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6">
<p class="text-xl font-semibold mb-2">Audiobook File Management Tools</p>
<!-- Merge to m4b -->
<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">Make M4B Audiobook File</p>
<p class="max-w-sm text-sm pt-2 text-gray-300">Generate a .M4B audiobook file with embedded metadata, cover image, and chapters.</p>
</div>
<div class="flex-grow" />
<div>
<ui-btn :to="`/audiobook/${libraryItemId}/manage?tool=m4b`" class="flex items-center"
>Open Manager
<span class="material-icons text-lg ml-2">launch</span>
</ui-btn>
</div>
</div>
</div>
<!-- Split to mp3 -->
<div v-if="showMp3Split && showExperimentalFeatures" class="w-full border border-black-200 p-4 my-8">
<div class="flex items-center">
<div>
<p class="text-lg">Split M4B to MP3's</p>
<p class="max-w-sm text-sm pt-2 text-gray-300">Generate multiple MP3's split by chapters with embedded metadata, cover image, and chapters.</p>
</div>
<div class="flex-grow" />
<div>
<ui-btn :disabled="true">Not yet implemented</ui-btn>
</div>
</div>
</div>
<!-- Embed Metadata -->
<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">Embed Metadata</p>
<p class="max-w-sm text-sm pt-2 text-gray-300">Embed metadata into audio files including cover image and chapters.</p>
</div>
<div class="flex-grow" />
<div>
<ui-btn :to="`/audiobook/${libraryItemId}/manage`" class="flex items-center"
>Open Manager
<span class="material-icons text-lg ml-2">launch</span>
</ui-btn>
</div>
</div>
</div>
<p v-if="!mediaTracks.length" class="text-lg text-center my-8">No audio tracks</p>
</div>
</template>
<script>
export default {
props: {
processing: Boolean,
libraryItem: {
type: Object,
default: () => {}
}
},
data() {
return {}
},
computed: {
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
},
libraryItemId() {
return this.libraryItem ? this.libraryItem.id : null
},
media() {
return this.libraryItem ? this.libraryItem.media || {} : {}
},
mediaTracks() {
return this.media.tracks || []
},
isSingleM4b() {
return this.mediaTracks.length === 1 && this.mediaTracks[0].metadata.ext.toLowerCase() === '.m4b'
},
chapters() {
return this.media.chapters || []
},
showM4bDownload() {
if (!this.mediaTracks.length) return false
return !this.isSingleM4b
},
showMp3Split() {
if (!this.mediaTracks.length) return false
return this.isSingleM4b && this.chapters.length
}
},
methods: {},
mounted() {}
}
</script>

View File

@ -1,9 +1,20 @@
<template>
<div id="page-wrapper" class="bg-bg page p-8 overflow-auto relative" :class="streamLibraryItem ? 'streaming' : ''">
<div class="flex justify-center mb-4">
<div class="flex items-center justify-center mb-6">
<div class="w-full max-w-2xl">
<p class="text-xl mb-2">Metadata to embed</p>
<p class="mb-4 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-2xl mb-2">Audiobook File Management Tools</p>
</div>
<div class="w-full max-w-2xl">
<div class="flex justify-end">
<ui-dropdown v-model="selectedTool" :items="availableTools" :disabled="processing" class="max-w-sm" @input="selectedToolUpdated" />
</div>
</div>
</div>
<div class="flex justify-center">
<div class="w-full max-w-2xl">
<p class="text-xl mb-1">Metadata to embed</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>
</div>
<div class="w-full max-w-2xl"></div>
</div>
@ -26,12 +37,13 @@
</div>
</div>
<div class="w-full max-w-2xl border border-white border-opacity-10 bg-bg mx-2">
<div class="flex py-2 px-4">
<div class="flex py-2 px-4 bg-primary bg-opacity-25">
<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">
<p v-if="!metadataChapters.length" class="py-5 text-center text-gray-200">No chapters</p>
<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>
@ -50,18 +62,44 @@
<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-2">
<p class="text-warning text-lg font-semibold">Warning: Modifies your audio files</p>
<ui-btn v-if="!embedFinished" color="primary" :loading="updatingMetadata" @click.stop="embedClick">Embed Metadata</ui-btn>
<div v-if="selectedTool === 'embed'" class="w-full flex justify-end items-center mb-4">
<ui-btn v-if="!isFinished" color="primary" :loading="processing" @click.stop="embedClick">Start Metadata Embed</ui-btn>
<p v-else class="text-success text-lg font-semibold">Embed Finished!</p>
</div>
<div class="flex mb-4">
<p class="text-gray-200">
A backup of your 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> so you can restore the originals if necessary.
</p>
<div v-else class="w-full flex justify-end items-center mb-4">
<ui-btn v-if="!isFinished" color="primary" :loading="processing" @click.stop="encodeM4bClick">Start M4B Encode</ui-btn>
<p v-else class="text-success text-lg font-semibold">M4B Finished!</p>
</div>
<div class="mb-4">
<div v-if="selectedTool === 'embed'" class="flex items-start mb-2">
<span class="material-icons text-base text-warning pt-1">star</span>
<p class="text-gray-200 ml-2">Metadata will be embedded on the audio tracks inside your audiobook folder.</p>
</div>
<div v-else class="flex items-start mb-2">
<span class="material-icons text-base text-warning pt-1">star</span>
<p class="text-gray-200 ml-2">
Finished M4B will be put into your audiobook folder at <span class="rounded-md bg-neutral-600 text-sm text-white py-0.5 px-1 font-mono">.../{{ libraryItemRelPath }}/</span>.
</p>
</div>
<div class="flex items-start mb-2">
<span class="material-icons text-base text-warning pt-1">star</span>
<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> so you can restore the originals if necessary.
</p>
</div>
<div class="flex items-start mb-2">
<span class="material-icons text-base text-warning pt-1">star</span>
<p class="text-gray-200 ml-2">Once the task is started you can navigate away from this page.</p>
</div>
</div>
</div>
<div class="w-full max-w-4xl mx-auto">
<p class="mb-2 font-semibold">Audio Tracks</p>
<div class="w-full mx-auto border border-white border-opacity-10 bg-bg">
<div class="flex py-2 px-4">
<div class="flex py-2 px-4 bg-primary bg-opacity-25">
<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>
@ -124,15 +162,19 @@ export default {
return {
audiofilesEncoding: {},
audiofilesFinished: {},
updatingMetadata: false,
embedFinished: false,
toneObject: null
processing: false,
isFinished: false,
toneObject: null,
selectedTool: 'embed'
}
},
computed: {
libraryItemId() {
return this.libraryItem.id
},
libraryItemRelPath() {
return this.libraryItem.relPath
},
media() {
return this.libraryItem.media || {}
},
@ -140,16 +182,42 @@ export default {
return this.media.metadata || {}
},
audioFiles() {
return this.media.audioFiles || []
return (this.media.audioFiles || []).filter((af) => !af.exclude && !af.invalid)
},
isSingleM4b() {
return this.audioFiles.length === 1 && this.audioFiles[0].metadata.ext.toLowerCase() === '.m4b'
},
streamLibraryItem() {
return this.$store.state.streamLibraryItem
},
metadataChapters() {
return this.media.chapters || []
},
availableTools() {
if (this.isSingleM4b) {
return [{ value: 'embed', text: 'Embed Metadata' }]
} else {
return [
{ value: 'embed', text: 'Embed Metadata' },
{ value: 'm4b', text: 'M4B Encoder' }
]
}
}
},
methods: {
encodeM4bClick() {
this.processing = true
this.$axios
.$get(`/api/audiobook-merge/${this.libraryItemId}`)
.then(() => {
this.processing = false
})
.catch((error) => {
var errorMsg = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
this.$toast.error(errorMsg)
this.processing = false
})
},
embedClick() {
const payload = {
message: `Are you sure you want to embed metadata in ${this.audioFiles.length} audio files?`,
@ -163,7 +231,7 @@ export default {
this.$store.commit('globals/setConfirmPrompt', payload)
},
updateAudioFileMetadata() {
this.updatingMetadata = true
this.processing = true
this.$axios
.$get(`/api/items/${this.libraryItemId}/audio-metadata?tone=1`)
.then(() => {
@ -171,20 +239,20 @@ export default {
})
.catch((error) => {
console.error('Audio metadata encode failed', error)
this.updatingMetadata = false
this.processing = false
})
},
audioMetadataStarted(data) {
console.log('audio metadata started', data)
if (data.libraryItemId !== this.libraryItemId) return
this.audiofilesFinished = {}
this.updatingMetadata = true
this.processing = true
},
audioMetadataFinished(data) {
console.log('audio metadata finished', data)
if (data.libraryItemId !== this.libraryItemId) return
this.updatingMetadata = false
this.embedFinished = true
this.processing = false
this.isFinished = true
this.audiofilesEncoding = {}
this.$toast.success('Audio file metadata updated')
},
@ -197,6 +265,20 @@ export default {
this.$set(this.audiofilesEncoding, data.ino, false)
this.$set(this.audiofilesFinished, data.ino, true)
},
selectedToolUpdated() {
let newurl = window.location.protocol + '//' + window.location.host + window.location.pathname + `?tool=${this.selectedTool}`
window.history.replaceState({ path: newurl }, '', newurl)
},
init() {
this.fetchToneObject()
if (this.$route.query.tool === 'm4b') {
if (this.availableTools.some((t) => t.value === 'm4b')) {
this.selectedTool = 'm4b'
} else {
this.selectedToolUpdated()
}
}
},
fetchToneObject() {
this.$axios
.$get(`/api/items/${this.libraryItemId}/tone-object`)
@ -210,7 +292,7 @@ export default {
}
},
mounted() {
this.fetchToneObject()
this.init()
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)

View File

@ -223,10 +223,10 @@ class Server {
// Client dynamic routes
const dyanimicRoutes = [
'/item/:id',
'/item/:id/manage',
'/author/:id',
'/audiobook/:id/chapters',
'/audiobook/:id/edit',
'/audiobook/:id/manage',
'/library/:library',
'/library/:library/search',
'/library/:library/bookshelf/:id?',

View File

@ -84,8 +84,8 @@ class MiscController {
// GET: api/audiobook-merge/:id
async mergeAudiobook(req, res) {
if (!req.user.canDownload) {
Logger.error('User attempting to download without permission', req.user)
if (!req.user.isAdminOrUp) {
Logger.error('[MiscController] mergeAudiobook: Non-admin user attempting to make m4b', req.user)
return res.sendStatus(403)
}