mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-07-10 03:11:34 +00:00
feat: unified package
This commit is contained in:
parent
f850db23fe
commit
0f42a4d580
335 changed files with 18102 additions and 19926 deletions
46
components/modals/item/tabs/Chapters.vue
Normal file
46
components/modals/item/tabs/Chapters.vue
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<template>
|
||||
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
|
||||
<div class="w-full mb-4">
|
||||
<tables-chapters-table v-if="chapters.length" :library-item="libraryItem" keep-open @close="closeModal" />
|
||||
<div v-if="!chapters.length" class="py-4 text-center">
|
||||
<p class="mb-8 text-xl">{{ $strings.MessageNoChapters }}</p>
|
||||
<ui-btn v-if="userCanUpdate" :to="`/audiobook/${libraryItem.id}/chapters`" @click="clickAddChapters">{{ $strings.ButtonAddChapters }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
libraryItem: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
media() {
|
||||
return this.libraryItem?.media || {}
|
||||
},
|
||||
chapters() {
|
||||
return this.media.chapters || []
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
closeModal() {
|
||||
this.$emit('close')
|
||||
},
|
||||
clickAddChapters() {
|
||||
if (this.$route.name === 'audiobook-id-chapters' && this.$route.params?.id === this.libraryItem?.id) {
|
||||
this.closeModal()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
319
components/modals/item/tabs/Cover.vue
Normal file
319
components/modals/item/tabs/Cover.vue
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
<template>
|
||||
<div class="w-full h-full overflow-hidden overflow-y-auto px-2 sm:px-4 py-6 relative">
|
||||
<div class="flex flex-col sm:flex-row mb-4">
|
||||
<div class="relative self-center">
|
||||
<covers-preview-cover :src="$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId, libraryItemUpdatedAt, true)" :width="120" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
|
||||
<!-- book cover overlay -->
|
||||
<div v-if="media.coverPath" class="absolute top-0 left-0 w-full h-full z-10 opacity-0 hover:opacity-100 transition-opacity duration-100">
|
||||
<div class="absolute top-0 left-0 w-full h-16 bg-gradient-to-b from-black-600 to-transparent" />
|
||||
<div v-if="userCanDelete" class="p-1 absolute top-1 right-1 text-red-500 rounded-full w-8 h-8 cursor-pointer hover:text-red-400 shadow-sm" @click="removeCover">
|
||||
<ui-tooltip direction="top" :text="$strings.LabelRemoveCover">
|
||||
<span class="material-symbols text-2xl">delete</span>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow sm:pl-2 md:pl-6 sm:pr-2 mt-6 md:mt-0">
|
||||
<div class="flex items-center">
|
||||
<div v-if="userCanUpload" class="w-10 md:w-40 pr-2 md:min-w-32">
|
||||
<ui-file-input ref="fileInput" @change="fileUploadSelected">
|
||||
<span class="hidden md:inline-block">{{ $strings.ButtonUploadCover }}</span>
|
||||
<span class="material-symbols text-2xl inline-block md:!hidden">upload</span>
|
||||
</ui-file-input>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="submitForm" class="flex flex-grow">
|
||||
<ui-text-input v-model="imageUrl" :placeholder="$strings.LabelImageURLFromTheWeb" class="h-9 w-full" />
|
||||
<ui-btn color="success" type="submit" :padding-x="4" :disabled="!imageUrl" class="ml-2 sm:ml-3 w-24 h-9">{{ $strings.ButtonSubmit }}</ui-btn>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div v-if="localCovers.length" class="mb-4 mt-6 border-t border-b border-white border-opacity-10">
|
||||
<div class="flex items-center justify-center py-2">
|
||||
<p>{{ localCovers.length }} local image{{ localCovers.length !== 1 ? 's' : '' }}</p>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn small @click="showLocalCovers = !showLocalCovers">{{ showLocalCovers ? $strings.ButtonHide : $strings.ButtonShow }}</ui-btn>
|
||||
</div>
|
||||
|
||||
<div v-if="showLocalCovers" class="flex items-center justify-center pb-2">
|
||||
<template v-for="localCoverFile in localCovers">
|
||||
<div :key="localCoverFile.ino" class="m-0.5 mb-5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="localCoverFile.metadata.path === coverPath ? 'border-yellow-300' : ''" @click="setCover(localCoverFile)">
|
||||
<div class="h-24 bg-primary" :style="{ width: 96 / bookCoverAspectRatio + 'px' }">
|
||||
<covers-preview-cover :src="localCoverFile.localPath" :width="96 / bookCoverAspectRatio" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form @submit.prevent="submitSearchForm">
|
||||
<div class="flex flex-wrap sm:flex-nowrap items-center justify-start -mx-1">
|
||||
<div class="w-48 flex-grow p-1">
|
||||
<ui-dropdown v-model="provider" :items="providers" :label="$strings.LabelProvider" small />
|
||||
</div>
|
||||
<div class="w-72 flex-grow p-1">
|
||||
<ui-text-input-with-label v-model="searchTitle" :label="searchTitleLabel" :placeholder="$strings.PlaceholderSearch" />
|
||||
</div>
|
||||
<div v-show="provider != 'itunes' && provider != 'audiobookcovers'" class="w-72 flex-grow p-1">
|
||||
<ui-text-input-with-label v-model="searchAuthor" :label="$strings.LabelAuthor" />
|
||||
</div>
|
||||
<ui-btn class="mt-5 ml-1 md:min-w-24" :padding-x="4" type="submit">{{ $strings.ButtonSearch }}</ui-btn>
|
||||
</div>
|
||||
</form>
|
||||
<div v-if="hasSearched" class="flex items-center flex-wrap justify-center sm:max-h-80 sm:overflow-y-scroll mt-2 max-w-full">
|
||||
<p v-if="!coversFound.length">{{ $strings.MessageNoCoversFound }}</p>
|
||||
<template v-for="cover in coversFound">
|
||||
<div :key="cover" class="m-0.5 mb-5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover === coverPath ? 'border-yellow-300' : ''" @click="updateCover(cover)">
|
||||
<covers-preview-cover :src="cover" :width="80" show-open-new-tab :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="previewUpload" class="absolute top-0 left-0 w-full h-full z-10 bg-bg p-8">
|
||||
<p class="text-lg">{{ $strings.HeaderPreviewCover }}</p>
|
||||
<span class="absolute top-4 right-4 material-symbols text-2xl cursor-pointer" @click="resetCoverPreview">close</span>
|
||||
<div class="flex justify-center py-4">
|
||||
<covers-preview-cover :src="previewUpload" :width="240" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
</div>
|
||||
<div class="absolute bottom-0 right-0 flex py-4 px-5">
|
||||
<ui-btn :disabled="processingUpload" class="mx-2" @click="resetCoverPreview">{{ $strings.ButtonReset }}</ui-btn>
|
||||
<ui-btn :loading="processingUpload" color="success" @click="submitCoverUpload">{{ $strings.ButtonUpload }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
processing: Boolean,
|
||||
libraryItem: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
processingUpload: false,
|
||||
searchTitle: null,
|
||||
searchAuthor: null,
|
||||
imageUrl: null,
|
||||
coversFound: [],
|
||||
hasSearched: false,
|
||||
showLocalCovers: false,
|
||||
previewUpload: null,
|
||||
selectedFile: null,
|
||||
provider: 'google'
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
libraryItem: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
if (newVal) {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isProcessing: {
|
||||
get() {
|
||||
return this.processing
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('update:processing', val)
|
||||
}
|
||||
},
|
||||
providers() {
|
||||
if (this.isPodcast) return this.$store.state.scanners.podcastProviders
|
||||
return [{ text: 'All', value: 'all' }, ...this.$store.state.scanners.providers, ...this.$store.state.scanners.coverOnlyProviders]
|
||||
},
|
||||
searchTitleLabel() {
|
||||
if (this.provider.startsWith('audible')) return this.$strings.LabelSearchTitleOrASIN
|
||||
else if (this.provider == 'itunes') return this.$strings.LabelSearchTerm
|
||||
return this.$strings.LabelSearchTitle
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
libraryItemId() {
|
||||
return this.libraryItem?.id || null
|
||||
},
|
||||
libraryItemUpdatedAt() {
|
||||
return this.libraryItem?.updatedAt || null
|
||||
},
|
||||
mediaType() {
|
||||
return this.libraryItem?.mediaType || null
|
||||
},
|
||||
isPodcast() {
|
||||
return this.mediaType == 'podcast'
|
||||
},
|
||||
media() {
|
||||
return this.libraryItem?.media || {}
|
||||
},
|
||||
coverPath() {
|
||||
return this.media.coverPath
|
||||
},
|
||||
mediaMetadata() {
|
||||
return this.media.metadata || {}
|
||||
},
|
||||
libraryFiles() {
|
||||
return this.libraryItem?.libraryFiles || []
|
||||
},
|
||||
userCanUpload() {
|
||||
return this.$store.getters['user/getUserCanUpload']
|
||||
},
|
||||
userCanDelete() {
|
||||
return this.$store.getters['user/getUserCanDelete']
|
||||
},
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
localCovers() {
|
||||
return this.libraryFiles
|
||||
.filter((f) => f.fileType === 'image')
|
||||
.map((file) => {
|
||||
const _file = { ...file }
|
||||
_file.localPath = `${process.env.serverUrl}/api/items/${this.libraryItemId}/file/${file.ino}?token=${this.userToken}`
|
||||
return _file
|
||||
})
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
submitCoverUpload() {
|
||||
this.processingUpload = true
|
||||
var form = new FormData()
|
||||
form.set('cover', this.selectedFile)
|
||||
|
||||
this.$axios
|
||||
.$post(`/api/items/${this.libraryItemId}/cover`, form)
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
this.$toast.error(data.error)
|
||||
} else {
|
||||
this.resetCoverPreview()
|
||||
}
|
||||
this.processingUpload = false
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
if (error.response && error.response.data) {
|
||||
this.$toast.error(error.response.data)
|
||||
} else {
|
||||
this.$toast.error(this.$strings.ToastUnknownError)
|
||||
}
|
||||
this.processingUpload = false
|
||||
})
|
||||
},
|
||||
resetCoverPreview() {
|
||||
if (this.$refs.fileInput) {
|
||||
this.$refs.fileInput.reset()
|
||||
}
|
||||
this.previewUpload = null
|
||||
this.selectedFile = null
|
||||
},
|
||||
fileUploadSelected(file) {
|
||||
this.previewUpload = URL.createObjectURL(file)
|
||||
this.selectedFile = file
|
||||
},
|
||||
init() {
|
||||
this.showLocalCovers = false
|
||||
if (this.coversFound.length && (this.searchTitle !== this.mediaMetadata.title || this.searchAuthor !== this.mediaMetadata.authorName)) {
|
||||
this.coversFound = []
|
||||
this.hasSearched = false
|
||||
}
|
||||
this.imageUrl = ''
|
||||
this.searchTitle = this.mediaMetadata.title || ''
|
||||
this.searchAuthor = this.mediaMetadata.authorName || ''
|
||||
if (this.isPodcast) this.provider = 'itunes'
|
||||
else this.provider = localStorage.getItem('book-cover-provider') || localStorage.getItem('book-provider') || 'google'
|
||||
},
|
||||
removeCover() {
|
||||
if (!this.coverPath) {
|
||||
return
|
||||
}
|
||||
this.isProcessing = true
|
||||
this.$axios
|
||||
.$delete(`/api/items/${this.libraryItemId}/cover`)
|
||||
.then(() => {})
|
||||
.catch((error) => {
|
||||
console.error('Failed to remove cover', error)
|
||||
if (error.response?.data) {
|
||||
this.$toast.error(error.response.data)
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
this.isProcessing = false
|
||||
})
|
||||
},
|
||||
submitForm() {
|
||||
this.updateCover(this.imageUrl)
|
||||
},
|
||||
async updateCover(cover) {
|
||||
if (!cover.startsWith('http:') && !cover.startsWith('https:')) {
|
||||
this.$toast.error(this.$strings.ToastInvalidUrl)
|
||||
return
|
||||
}
|
||||
|
||||
this.isProcessing = true
|
||||
this.$axios
|
||||
.$post(`/api/items/${this.libraryItemId}/cover`, { url: cover })
|
||||
.then(() => {
|
||||
this.imageUrl = ''
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to update cover', error)
|
||||
this.$toast.error(error.response?.data || this.$strings.ToastCoverUpdateFailed)
|
||||
})
|
||||
.finally(() => {
|
||||
this.isProcessing = false
|
||||
})
|
||||
},
|
||||
getSearchQuery() {
|
||||
var searchQuery = `provider=${this.provider}&title=${this.searchTitle}`
|
||||
if (this.searchAuthor) searchQuery += `&author=${this.searchAuthor || ''}`
|
||||
if (this.isPodcast) searchQuery += '&podcast=1'
|
||||
return searchQuery
|
||||
},
|
||||
persistProvider() {
|
||||
try {
|
||||
localStorage.setItem('book-cover-provider', this.provider)
|
||||
} catch (error) {
|
||||
console.error('PersistProvider', error)
|
||||
}
|
||||
},
|
||||
async submitSearchForm() {
|
||||
// Store provider in local storage
|
||||
this.persistProvider()
|
||||
|
||||
this.isProcessing = true
|
||||
const searchQuery = this.getSearchQuery()
|
||||
const results = await this.$axios
|
||||
.$get(`/api/search/covers?${searchQuery}`)
|
||||
.then((res) => res.results)
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
return []
|
||||
})
|
||||
this.coversFound = results
|
||||
this.isProcessing = false
|
||||
this.hasSearched = true
|
||||
},
|
||||
setCover(coverFile) {
|
||||
this.isProcessing = true
|
||||
this.$axios
|
||||
.$patch(`/api/items/${this.libraryItemId}/cover`, { cover: coverFile.metadata.path })
|
||||
.catch((error) => {
|
||||
console.error('Failed to set local cover', error)
|
||||
this.$toast.error(error.response?.data || this.$strings.ToastCoverUpdateFailed)
|
||||
})
|
||||
.finally(() => {
|
||||
this.isProcessing = false
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
220
components/modals/item/tabs/Details.vue
Normal file
220
components/modals/item/tabs/Details.vue
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
<template>
|
||||
<div class="w-full h-full relative">
|
||||
<div id="formWrapper" class="w-full overflow-y-auto">
|
||||
<widgets-book-details-edit v-if="mediaType == 'book'" ref="itemDetailsEdit" :library-item="libraryItem" @submit="saveAndClose" />
|
||||
<widgets-podcast-details-edit v-else ref="itemDetailsEdit" :library-item="libraryItem" @submit="saveAndClose" />
|
||||
</div>
|
||||
|
||||
<div class="absolute bottom-0 left-0 w-full py-2 md:py-4 bg-bg" :class="isScrollable ? 'box-shadow-md-up' : 'border-t border-white border-opacity-5'">
|
||||
<div class="flex items-center px-4">
|
||||
<ui-tooltip :disabled="!!quickMatching" :text="$getString('MessageQuickMatchDescription', [libraryProvider])" direction="bottom" class="mr-2 md:mr-4">
|
||||
<ui-btn v-if="userIsAdminOrUp" :loading="quickMatching" color="bg" type="button" class="h-full" small @click.stop.prevent="quickMatch">{{ $strings.ButtonQuickMatch }}</ui-btn>
|
||||
</ui-tooltip>
|
||||
|
||||
<ui-tooltip :disabled="isLibraryScanning" text="Rescan library item including metadata" direction="bottom" class="mr-2 md:mr-4">
|
||||
<ui-btn v-if="userIsAdminOrUp && !isFile" :loading="rescanning" :disabled="isLibraryScanning" color="bg" type="button" class="h-full" small @click.stop.prevent="rescan">{{ $strings.ButtonReScan }}</ui-btn>
|
||||
</ui-tooltip>
|
||||
|
||||
<div class="flex-grow" />
|
||||
|
||||
<!-- desktop -->
|
||||
<ui-btn @click="save" class="mx-2 hidden md:block">{{ $strings.ButtonSave }}</ui-btn>
|
||||
<ui-btn @click="saveAndClose" class="mx-2 hidden md:block">{{ $strings.ButtonSaveAndClose }}</ui-btn>
|
||||
<!-- mobile -->
|
||||
<ui-btn @click="saveAndClose" class="mx-2 md:hidden">{{ $strings.ButtonSave }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
processing: Boolean,
|
||||
libraryItem: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
resettingProgress: false,
|
||||
isScrollable: false,
|
||||
rescanning: false,
|
||||
quickMatching: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isProcessing: {
|
||||
get() {
|
||||
return this.processing
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('update:processing', val)
|
||||
}
|
||||
},
|
||||
isFile() {
|
||||
return !!this.libraryItem && this.libraryItem.isFile
|
||||
},
|
||||
userIsAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
isMissing() {
|
||||
return !!this.libraryItem && !!this.libraryItem.isMissing
|
||||
},
|
||||
libraryItemId() {
|
||||
return this.libraryItem ? this.libraryItem.id : null
|
||||
},
|
||||
media() {
|
||||
return this.libraryItem ? this.libraryItem.media || {} : {}
|
||||
},
|
||||
mediaType() {
|
||||
return this.libraryItem ? this.libraryItem.mediaType : null
|
||||
},
|
||||
mediaMetadata() {
|
||||
return this.media.metadata || {}
|
||||
},
|
||||
libraryId() {
|
||||
return this.libraryItem ? this.libraryItem.libraryId : null
|
||||
},
|
||||
libraryProvider() {
|
||||
return this.$store.getters['libraries/getLibraryProvider'](this.libraryId) || 'google'
|
||||
},
|
||||
isLibraryScanning() {
|
||||
if (!this.libraryId) return null
|
||||
return !!this.$store.getters['tasks/getRunningLibraryScanTask'](this.libraryId)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
quickMatch() {
|
||||
if (this.quickMatching) return
|
||||
if (!this.$refs.itemDetailsEdit) return
|
||||
|
||||
var { title, author } = this.$refs.itemDetailsEdit.getTitleAndAuthorName()
|
||||
if (!title) {
|
||||
this.$toast.error(this.$strings.ToastTitleRequired)
|
||||
return
|
||||
}
|
||||
this.quickMatching = true
|
||||
var matchOptions = {
|
||||
provider: this.libraryProvider,
|
||||
title: title || null,
|
||||
author: author || null
|
||||
}
|
||||
this.$axios
|
||||
.$post(`/api/items/${this.libraryItemId}/match`, matchOptions)
|
||||
.then((res) => {
|
||||
this.quickMatching = false
|
||||
if (res.warning) {
|
||||
this.$toast.warning(res.warning)
|
||||
} else if (res.updated) {
|
||||
this.$toast.success(this.$strings.ToastItemDetailsUpdateSuccess)
|
||||
} else {
|
||||
this.$toast.info(this.$strings.ToastNoUpdatesNecessary)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
var errMsg = error.response ? error.response.data || '' : ''
|
||||
console.error('Failed to match', error)
|
||||
this.$toast.error(errMsg || 'Failed to match')
|
||||
this.quickMatching = false
|
||||
})
|
||||
},
|
||||
rescan() {
|
||||
this.rescanning = true
|
||||
this.$axios
|
||||
.$post(`/api/items/${this.libraryItemId}/scan`)
|
||||
.then((data) => {
|
||||
this.rescanning = false
|
||||
var result = data.result
|
||||
if (!result) {
|
||||
this.$toast.error(this.$getString('ToastRescanFailed', [this.title]))
|
||||
} else if (result === 'UPDATED') {
|
||||
this.$toast.success(this.$strings.ToastRescanUpdated)
|
||||
} else if (result === 'UPTODATE') {
|
||||
this.$toast.success(this.$strings.ToastRescanUpToDate)
|
||||
} else if (result === 'REMOVED') {
|
||||
this.$toast.error(this.$strings.ToastRescanRemoved)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to scan library item', error)
|
||||
this.$toast.error(this.$strings.ToastScanFailed)
|
||||
this.rescanning = false
|
||||
})
|
||||
},
|
||||
async saveAndClose() {
|
||||
const wasUpdated = await this.save()
|
||||
if (wasUpdated !== null) this.$emit('close')
|
||||
},
|
||||
async save() {
|
||||
if (this.isProcessing) {
|
||||
return null
|
||||
}
|
||||
if (!this.$refs.itemDetailsEdit) {
|
||||
return null
|
||||
}
|
||||
var updatedDetails = this.$refs.itemDetailsEdit.getDetails()
|
||||
if (!updatedDetails.hasChanges) {
|
||||
this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary)
|
||||
return false
|
||||
}
|
||||
return this.updateDetails(updatedDetails)
|
||||
},
|
||||
async updateDetails(updatedDetails) {
|
||||
this.isProcessing = true
|
||||
var updateResult = await this.$axios.$patch(`/api/items/${this.libraryItemId}/media`, updatedDetails.updatePayload).catch((error) => {
|
||||
console.error('Failed to update', error)
|
||||
return false
|
||||
})
|
||||
this.isProcessing = false
|
||||
if (updateResult) {
|
||||
if (updateResult.updated) {
|
||||
this.$toast.success(this.$strings.ToastItemDetailsUpdateSuccess)
|
||||
return true
|
||||
} else {
|
||||
this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary)
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
checkIsScrollable() {
|
||||
this.$nextTick(() => {
|
||||
var formWrapper = document.getElementById('formWrapper')
|
||||
if (formWrapper) {
|
||||
if (formWrapper.scrollHeight > formWrapper.clientHeight) {
|
||||
this.isScrollable = true
|
||||
} else {
|
||||
this.isScrollable = false
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
setResizeObserver() {
|
||||
try {
|
||||
var formWrapper = document.getElementById('formWrapper')
|
||||
if (formWrapper) {
|
||||
this.$nextTick(() => {
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
this.checkIsScrollable()
|
||||
})
|
||||
resizeObserver.observe(formWrapper)
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to set resize observer')
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.setResizeObserver()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
#formWrapper {
|
||||
height: calc(100% - 80px);
|
||||
max-height: calc(100% - 80px);
|
||||
}
|
||||
</style>
|
||||
144
components/modals/item/tabs/Episodes.vue
Normal file
144
components/modals/item/tabs/Episodes.vue
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
<template>
|
||||
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
|
||||
<div class="w-full mb-4">
|
||||
<div v-if="userIsAdminOrUp" class="flex items-end justify-end mb-4">
|
||||
<ui-text-input-with-label ref="lastCheckInput" v-model="lastEpisodeCheckInput" :disabled="checkingNewEpisodes" type="datetime-local" :label="$strings.LabelLookForNewEpisodesAfterDate" class="max-w-xs mr-2" />
|
||||
<ui-text-input-with-label ref="maxEpisodesInput" v-model="maxEpisodesToDownload" :disabled="checkingNewEpisodes" type="number" :label="$strings.LabelLimit" class="w-16 mr-2" input-class="h-10">
|
||||
<div class="flex -mb-0.5">
|
||||
<p class="px-1 text-sm font-semibold" :class="{ 'text-gray-400': checkingNewEpisodes }">{{ $strings.LabelLimit }}</p>
|
||||
<ui-tooltip direction="top" :text="$strings.LabelMaxEpisodesToDownload">
|
||||
<span class="material-symbols text-base">info</span>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
</ui-text-input-with-label>
|
||||
<ui-btn :loading="checkingNewEpisodes" @click="checkForNewEpisodes">{{ $strings.ButtonCheckAndDownloadNewEpisodes }}</ui-btn>
|
||||
</div>
|
||||
|
||||
<div v-if="episodes.length" class="w-full p-4 bg-primary">
|
||||
<p>{{ $strings.HeaderEpisodes }}</p>
|
||||
</div>
|
||||
<div v-if="!episodes.length" class="flex my-4 text-center justify-center text-xl">{{ $strings.MessageNoEpisodes }}</div>
|
||||
<table v-else class="text-sm tracksTable">
|
||||
<tr>
|
||||
<th class="text-center w-20 min-w-20">{{ $strings.LabelEpisode }}</th>
|
||||
<th class="text-left">{{ $strings.LabelEpisodeTitle }}</th>
|
||||
<th class="text-center w-28">{{ $strings.LabelEpisodeDuration }}</th>
|
||||
<th class="text-center w-28">{{ $strings.LabelEpisodeSize }}</th>
|
||||
</tr>
|
||||
<tr v-for="episode in episodes" :key="episode.id">
|
||||
<td class="text-center w-20 min-w-20">
|
||||
<p>{{ episode.episode }}</p>
|
||||
</td>
|
||||
<td dir="auto">
|
||||
{{ episode.title }}
|
||||
</td>
|
||||
<td class="font-mono text-center">
|
||||
{{ $secondsToTimestamp(episode.duration) }}
|
||||
</td>
|
||||
<td class="font-mono text-center">
|
||||
{{ $bytesPretty(episode.size) }}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
libraryItem: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
checkingNewEpisodes: false,
|
||||
lastEpisodeCheckInput: null,
|
||||
maxEpisodesToDownload: 3
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
lastEpisodeCheck: {
|
||||
handler(newVal) {
|
||||
if (newVal) {
|
||||
this.setLastEpisodeCheckInput()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
userIsAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
autoDownloadEpisodes() {
|
||||
return !!this.media.autoDownloadEpisodes
|
||||
},
|
||||
lastEpisodeCheck() {
|
||||
return this.media.lastEpisodeCheck
|
||||
},
|
||||
media() {
|
||||
return this.libraryItem ? this.libraryItem.media || {} : {}
|
||||
},
|
||||
libraryItemId() {
|
||||
return this.libraryItem ? this.libraryItem.id : null
|
||||
},
|
||||
episodes() {
|
||||
return this.media.episodes || []
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async checkForNewEpisodes() {
|
||||
if (this.$refs.lastCheckInput) {
|
||||
this.$refs.lastCheckInput.blur()
|
||||
}
|
||||
if (this.$refs.maxEpisodesInput) {
|
||||
this.$refs.maxEpisodesInput.blur()
|
||||
}
|
||||
|
||||
if (this.maxEpisodesToDownload < 0) {
|
||||
this.maxEpisodesToDownload = 3
|
||||
this.$toast.error(this.$strings.ToastInvalidMaxEpisodesToDownload)
|
||||
return
|
||||
}
|
||||
|
||||
this.checkingNewEpisodes = true
|
||||
const lastEpisodeCheck = new Date(this.lastEpisodeCheckInput).valueOf()
|
||||
|
||||
// If last episode check changed then update it first
|
||||
if (lastEpisodeCheck && lastEpisodeCheck !== this.lastEpisodeCheck) {
|
||||
var updateResult = await this.$axios.$patch(`/api/items/${this.libraryItemId}/media`, { lastEpisodeCheck }).catch((error) => {
|
||||
console.error('Failed to update', error)
|
||||
return false
|
||||
})
|
||||
console.log('updateResult', updateResult)
|
||||
}
|
||||
|
||||
this.$axios
|
||||
.$get(`/api/podcasts/${this.libraryItemId}/checknew?limit=${this.maxEpisodesToDownload}`)
|
||||
.then((response) => {
|
||||
if (response.episodes && response.episodes.length) {
|
||||
console.log('New episodes', response.episodes.length)
|
||||
this.$toast.success(this.$getString('ToastNewEpisodesFound', [response.episodes.length]))
|
||||
} else {
|
||||
this.$toast.info(this.$strings.ToastNoNewEpisodesFound)
|
||||
}
|
||||
this.checkingNewEpisodes = false
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
var errorMsg = error.response && error.response.data ? error.response.data : 'Unknown Error'
|
||||
this.$toast.error(errorMsg)
|
||||
this.checkingNewEpisodes = false
|
||||
})
|
||||
},
|
||||
setLastEpisodeCheckInput() {
|
||||
this.lastEpisodeCheckInput = this.lastEpisodeCheck ? this.$formatDate(this.lastEpisodeCheck, "yyyy-MM-dd'T'HH:mm") : null
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.setLastEpisodeCheckInput()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
54
components/modals/item/tabs/Files.vue
Normal file
54
components/modals/item/tabs/Files.vue
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
<template>
|
||||
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
|
||||
<tables-library-files-table expanded :library-item="libraryItem" :is-missing="isMissing" in-modal />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
libraryItem: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
tracks: []
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
libraryItem: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
if (newVal) this.init()
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
media() {
|
||||
return this.libraryItem.media || {}
|
||||
},
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
},
|
||||
userCanDownload() {
|
||||
return this.$store.getters['user/getUserCanDownload']
|
||||
},
|
||||
isMissing() {
|
||||
return this.libraryItem.isMissing
|
||||
},
|
||||
showDownload() {
|
||||
return this.userCanDownload && !this.isMissing
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
init() {
|
||||
this.tracks = this.media.tracks || []
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
652
components/modals/item/tabs/Match.vue
Normal file
652
components/modals/item/tabs/Match.vue
Normal file
|
|
@ -0,0 +1,652 @@
|
|||
<template>
|
||||
<div id="match-wrapper" class="w-full h-full overflow-hidden px-2 md:px-4 py-4 md:py-6 relative">
|
||||
<form @submit.prevent="submitSearch">
|
||||
<div class="flex flex-wrap md:flex-nowrap items-center justify-start -mx-1">
|
||||
<div class="w-36 px-1">
|
||||
<ui-dropdown v-model="provider" :items="providers" :label="$strings.LabelProvider" small />
|
||||
</div>
|
||||
<div class="flex-grow md:w-72 px-1">
|
||||
<ui-text-input-with-label v-model="searchTitle" :label="searchTitleLabel" :placeholder="$strings.PlaceholderSearch" />
|
||||
</div>
|
||||
<div v-show="provider != 'itunes'" class="w-60 md:w-72 px-1">
|
||||
<ui-text-input-with-label v-model="searchAuthor" :label="$strings.LabelAuthor" />
|
||||
</div>
|
||||
<ui-btn class="mt-5 ml-1" type="submit">{{ $strings.ButtonSearch }}</ui-btn>
|
||||
</div>
|
||||
</form>
|
||||
<div v-show="processing" class="flex h-full items-center justify-center">
|
||||
<p>{{ $strings.MessageLoading }}</p>
|
||||
</div>
|
||||
<div v-show="!processing && !searchResults.length && hasSearched" class="flex h-full items-center justify-center">
|
||||
<p>{{ $strings.MessageNoResults }}</p>
|
||||
</div>
|
||||
<div v-show="!processing" class="w-full max-h-full overflow-y-auto overflow-x-hidden matchListWrapper mt-4">
|
||||
<template v-for="(res, index) in searchResults">
|
||||
<cards-book-match-card :key="index" :book="res" :current-book-duration="currentBookDuration" :is-podcast="isPodcast" :book-cover-aspect-ratio="bookCoverAspectRatio" @select="selectMatch" />
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="selectedMatchOrig" class="absolute top-0 left-0 w-full bg-bg h-full px-2 py-6 md:p-8 max-h-full overflow-y-auto overflow-x-hidden">
|
||||
<div class="flex mb-4">
|
||||
<div class="w-8 h-8 rounded-full hover:bg-white hover:bg-opacity-10 flex items-center justify-center cursor-pointer" @click="clearSelectedMatch">
|
||||
<span class="material-symbols text-3xl">arrow_back</span>
|
||||
</div>
|
||||
<p class="text-xl pl-3">{{ $strings.HeaderUpdateDetails }}</p>
|
||||
</div>
|
||||
<ui-checkbox v-model="selectAll" :label="$strings.LabelSelectAll" checkbox-bg="bg" @input="selectAllToggled" />
|
||||
<form @submit.prevent="submitMatchUpdate">
|
||||
<div v-if="selectedMatchOrig.cover" class="flex flex-wrap md:flex-nowrap items-center justify-center">
|
||||
<div class="flex flex-grow items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.cover" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<ui-text-input-with-label v-model="selectedMatch.cover" :disabled="!selectedMatchUsage.cover" readonly :label="$strings.LabelCover" class="flex-grow mx-4" />
|
||||
</div>
|
||||
|
||||
<div class="flex py-2">
|
||||
<div>
|
||||
<p class="text-center text-gray-200">{{ $strings.LabelNew }}</p>
|
||||
<a :href="selectedMatch.cover" target="_blank" class="bg-primary">
|
||||
<covers-preview-cover :src="selectedMatch.cover" :width="100" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
</a>
|
||||
</div>
|
||||
<div v-if="media.coverPath" class="ml-0.5">
|
||||
<p class="text-center text-gray-200">{{ $strings.LabelCurrent }}</p>
|
||||
<a :href="$store.getters['globals/getLibraryItemCoverSrc'](libraryItem, null, true)" target="_blank" class="bg-primary">
|
||||
<covers-preview-cover :src="$store.getters['globals/getLibraryItemCoverSrc'](libraryItem, null, true)" :width="100" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatchOrig.title" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.title" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.title" :disabled="!selectedMatchUsage.title" :label="$strings.LabelTitle" />
|
||||
<p v-if="mediaMetadata.title" class="text-xs ml-1 text-white text-opacity-60">
|
||||
{{ $strings.LabelCurrently }} <a :title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('title', mediaMetadata.title)">{{ mediaMetadata.title || '' }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatchOrig.subtitle" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.subtitle" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.subtitle" :disabled="!selectedMatchUsage.subtitle" :label="$strings.LabelSubtitle" />
|
||||
<p v-if="mediaMetadata.subtitle" class="text-xs ml-1 text-white text-opacity-60">
|
||||
{{ $strings.LabelCurrently }} <a :title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('subtitle', mediaMetadata.subtitle)">{{ mediaMetadata.subtitle }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatchOrig.author" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.author" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.author" :disabled="!selectedMatchUsage.author" :label="$strings.LabelAuthor" />
|
||||
<p v-if="mediaMetadata.authorName" class="text-xs ml-1 text-white text-opacity-60">
|
||||
{{ $strings.LabelCurrently }} <a title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('author', mediaMetadata.authorName)">{{ mediaMetadata.authorName }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatchOrig.narrator" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.narrator" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-multi-select v-model="selectedMatch.narrator" :items="narrators" :disabled="!selectedMatchUsage.narrator" :label="$strings.LabelNarrators" />
|
||||
<p v-if="mediaMetadata.narratorName" class="text-xs ml-1 text-white text-opacity-60">
|
||||
{{ $strings.LabelCurrently }} <a title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('narrator', mediaMetadata.narrators)">{{ mediaMetadata.narratorName }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatchOrig.description" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.description" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-textarea-with-label v-model="selectedMatch.description" :rows="3" :disabled="!selectedMatchUsage.description" :label="$strings.LabelDescription" />
|
||||
<p v-if="mediaMetadata.description" class="text-xs ml-1 text-white text-opacity-60">
|
||||
{{ $strings.LabelCurrently }} <a title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('description', mediaMetadata.description)">{{ mediaMetadata.description.substr(0, 100) + (mediaMetadata.description.length > 100 ? '...' : '') }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatchOrig.publisher" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.publisher" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.publisher" :disabled="!selectedMatchUsage.publisher" :label="$strings.LabelPublisher" />
|
||||
<p v-if="mediaMetadata.publisher" class="text-xs ml-1 text-white text-opacity-60">
|
||||
{{ $strings.LabelCurrently }} <a title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('publisher', mediaMetadata.publisher)">{{ mediaMetadata.publisher }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatchOrig.publishedYear" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.publishedYear" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.publishedYear" :disabled="!selectedMatchUsage.publishedYear" :label="$strings.LabelPublishYear" />
|
||||
<p v-if="mediaMetadata.publishedYear" class="text-xs ml-1 text-white text-opacity-60">
|
||||
{{ $strings.LabelCurrently }} <a title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('publishedYear', mediaMetadata.publishedYear)">{{ mediaMetadata.publishedYear }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedMatchOrig.series" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.series" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4">
|
||||
<widgets-series-input-widget v-model="selectedMatch.series" :disabled="!selectedMatchUsage.series" />
|
||||
<p v-if="mediaMetadata.seriesName" class="text-xs ml-1 text-white text-opacity-60">
|
||||
{{ $strings.LabelCurrently }} <a :title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('series', mediaMetadata.series)">{{ mediaMetadata.seriesName }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatchOrig.genres?.length" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.genres" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-multi-select v-model="selectedMatch.genres" :items="genres" :disabled="!selectedMatchUsage.genres" :label="$strings.LabelGenres" />
|
||||
<p v-if="mediaMetadata.genres?.length" class="text-xs ml-1 text-white text-opacity-60">
|
||||
{{ $strings.LabelCurrently }} <a :title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('genres', mediaMetadata.genres)">{{ mediaMetadata.genres.join(', ') }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatchOrig.tags" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.tags" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-multi-select v-model="selectedMatch.tags" :items="tags" :disabled="!selectedMatchUsage.tags" :label="$strings.LabelTags" />
|
||||
<p v-if="media.tags?.length" class="text-xs ml-1 text-white text-opacity-60">
|
||||
{{ $strings.LabelCurrently }} <a :title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('tags', media.tags)">{{ media.tags.join(', ') }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatchOrig.language" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.language" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.language" :disabled="!selectedMatchUsage.language" :label="$strings.LabelLanguage" />
|
||||
<p v-if="mediaMetadata.language" class="text-xs ml-1 text-white text-opacity-60">
|
||||
{{ $strings.LabelCurrently }} <a :title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('language', mediaMetadata.language)">{{ mediaMetadata.language }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatchOrig.isbn" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.isbn" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.isbn" :disabled="!selectedMatchUsage.isbn" label="ISBN" />
|
||||
<p v-if="mediaMetadata.isbn" class="text-xs ml-1 text-white text-opacity-60">
|
||||
{{ $strings.LabelCurrently }} <a :title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('isbn', mediaMetadata.isbn)">{{ mediaMetadata.isbn }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatchOrig.asin" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.asin" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.asin" :disabled="!selectedMatchUsage.asin" label="ASIN" />
|
||||
<p v-if="mediaMetadata.asin" class="text-xs ml-1 text-white text-opacity-60">
|
||||
{{ $strings.LabelCurrently }} <a :title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('asin', mediaMetadata.asin)">{{ mediaMetadata.asin }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedMatchOrig.itunesId" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.itunesId" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.itunesId" type="number" :disabled="!selectedMatchUsage.itunesId" label="iTunes ID" />
|
||||
<p v-if="mediaMetadata.itunesId" class="text-xs ml-1 text-white text-opacity-60">
|
||||
{{ $strings.LabelCurrently }} <a :title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('itunesId', mediaMetadata.itunesId)">{{ mediaMetadata.itunesId }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatchOrig.feedUrl" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.feedUrl" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.feedUrl" :disabled="!selectedMatchUsage.feedUrl" label="RSS Feed URL" />
|
||||
<p v-if="mediaMetadata.feedUrl" class="text-xs ml-1 text-white text-opacity-60">
|
||||
{{ $strings.LabelCurrently }} <a :title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('feedUrl', mediaMetadata.feedUrl)">{{ mediaMetadata.feedUrl }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatchOrig.itunesPageUrl" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.itunesPageUrl" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.itunesPageUrl" :disabled="!selectedMatchUsage.itunesPageUrl" label="iTunes Page URL" />
|
||||
<p v-if="mediaMetadata.itunesPageUrl" class="text-xs ml-1 text-white text-opacity-60">
|
||||
{{ $strings.LabelCurrently }} <a :title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('itunesPageUrl', mediaMetadata.itunesPageUrl)">{{ mediaMetadata.itunesPageUrl }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatchOrig.releaseDate" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.releaseDate" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.releaseDate" :disabled="!selectedMatchUsage.releaseDate" :label="$strings.LabelReleaseDate" />
|
||||
<p v-if="mediaMetadata.releaseDate" class="text-xs ml-1 text-white text-opacity-60">
|
||||
{{ $strings.LabelCurrently }} <a :title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('releaseDate', mediaMetadata.releaseDate)">{{ mediaMetadata.releaseDate }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatchOrig.explicit != null" class="flex items-center pb-2" :class="{ 'pt-2': mediaMetadata.explicit == null }">
|
||||
<ui-checkbox v-model="selectedMatchUsage.explicit" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4" :class="{ 'pt-4': mediaMetadata.explicit != null }">
|
||||
<ui-checkbox v-model="selectedMatch.explicit" :label="$strings.LabelExplicit" :disabled="!selectedMatchUsage.explicit" :checkbox-bg="!selectedMatchUsage.explicit ? 'bg' : 'primary'" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
|
||||
<p v-if="mediaMetadata.explicit != null" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.explicit ? $strings.LabelExplicitChecked : $strings.LabelExplicitUnchecked }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatchOrig.abridged != null" class="flex items-center pb-2" :class="{ 'pt-2': mediaMetadata.abridged == null }">
|
||||
<ui-checkbox v-model="selectedMatchUsage.abridged" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4" :class="{ 'pt-4': mediaMetadata.abridged != null }">
|
||||
<ui-checkbox v-model="selectedMatch.abridged" :label="$strings.LabelAbridged" :disabled="!selectedMatchUsage.abridged" :checkbox-bg="!selectedMatchUsage.abridged ? 'bg' : 'primary'" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
|
||||
<p v-if="mediaMetadata.abridged != null" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.abridged ? $strings.LabelAbridgedChecked : $strings.LabelAbridgedUnchecked }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end py-2">
|
||||
<ui-btn color="success" type="submit">{{ $strings.ButtonSubmit }}</ui-btn>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
processing: Boolean,
|
||||
libraryItem: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
libraryItemId: null,
|
||||
searchTitle: null,
|
||||
searchAuthor: null,
|
||||
lastSearch: null,
|
||||
provider: 'google',
|
||||
searchResults: [],
|
||||
hasSearched: false,
|
||||
selectedMatch: null,
|
||||
selectedMatchOrig: null,
|
||||
selectedMatchUsage: {
|
||||
title: true,
|
||||
subtitle: true,
|
||||
cover: true,
|
||||
author: true,
|
||||
narrator: true,
|
||||
description: true,
|
||||
publisher: true,
|
||||
publishedYear: true,
|
||||
series: true,
|
||||
genres: true,
|
||||
tags: true,
|
||||
language: true,
|
||||
explicit: true,
|
||||
asin: true,
|
||||
isbn: true,
|
||||
abridged: true,
|
||||
// Podcast specific
|
||||
itunesPageUrl: true,
|
||||
itunesId: true,
|
||||
feedUrl: true,
|
||||
releaseDate: true
|
||||
},
|
||||
selectAll: true
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
libraryItem: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
if (newVal) this.init()
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isProcessing: {
|
||||
get() {
|
||||
return this.processing
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('update:processing', val)
|
||||
}
|
||||
},
|
||||
seriesItems: {
|
||||
get() {
|
||||
return this.selectedMatch.series.map((se) => {
|
||||
return {
|
||||
id: `new-${Math.floor(Math.random() * 10000)}`,
|
||||
displayName: se.sequence ? `${se.series} #${se.sequence}` : se.series,
|
||||
name: se.series,
|
||||
sequence: se.sequence || ''
|
||||
}
|
||||
})
|
||||
},
|
||||
set(val) {
|
||||
this.selectedMatch.series = val
|
||||
}
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
filterData() {
|
||||
return this.$store.state.libraries.filterData || {}
|
||||
},
|
||||
providers() {
|
||||
if (this.isPodcast) return this.$store.state.scanners.podcastProviders
|
||||
return this.$store.state.scanners.providers
|
||||
},
|
||||
searchTitleLabel() {
|
||||
if (this.provider.startsWith('audible')) return this.$strings.LabelSearchTitleOrASIN
|
||||
else if (this.provider == 'itunes') return this.$strings.LabelSearchTerm
|
||||
return this.$strings.LabelSearchTitle
|
||||
},
|
||||
media() {
|
||||
return this.libraryItem?.media || {}
|
||||
},
|
||||
mediaMetadata() {
|
||||
return this.media.metadata || {}
|
||||
},
|
||||
currentBookDuration() {
|
||||
if (this.isPodcast) return 0
|
||||
return this.media.duration || 0
|
||||
},
|
||||
mediaType() {
|
||||
return this.libraryItem?.mediaType || null
|
||||
},
|
||||
isPodcast() {
|
||||
return this.mediaType == 'podcast'
|
||||
},
|
||||
narrators() {
|
||||
return this.filterData.narrators || []
|
||||
},
|
||||
genres() {
|
||||
const currentGenres = this.filterData.genres || []
|
||||
const selectedMatchGenres = this.selectedMatch.genres || []
|
||||
return [...new Set([...currentGenres, ...selectedMatchGenres])]
|
||||
},
|
||||
tags() {
|
||||
return this.filterData.tags || []
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
setMatchFieldValue(field, value) {
|
||||
if (Array.isArray(value)) {
|
||||
this.selectedMatch[field] = [...value]
|
||||
} else {
|
||||
this.selectedMatch[field] = value
|
||||
}
|
||||
},
|
||||
selectAllToggled(val) {
|
||||
for (const key in this.selectedMatchUsage) {
|
||||
this.selectedMatchUsage[key] = val
|
||||
}
|
||||
},
|
||||
checkboxToggled() {
|
||||
this.selectAll = Object.values(this.selectedMatchUsage).findIndex((v) => v == false) < 0
|
||||
},
|
||||
persistProvider() {
|
||||
try {
|
||||
localStorage.setItem('book-provider', this.provider)
|
||||
} catch (error) {
|
||||
console.error('PersistProvider', error)
|
||||
}
|
||||
},
|
||||
getDefaultBookProvider() {
|
||||
let provider = localStorage.getItem('book-provider')
|
||||
if (!provider) return 'google'
|
||||
// Validate book provider
|
||||
if (!this.$store.getters['scanners/checkBookProviderExists'](provider)) {
|
||||
console.error('Stored book provider does not exist', provider)
|
||||
localStorage.removeItem('book-provider')
|
||||
return 'google'
|
||||
}
|
||||
return provider
|
||||
},
|
||||
getSearchQuery() {
|
||||
if (this.isPodcast) return `term=${encodeURIComponent(this.searchTitle)}`
|
||||
var searchQuery = `provider=${this.provider}&fallbackTitleOnly=1&title=${encodeURIComponent(this.searchTitle)}`
|
||||
if (this.searchAuthor) searchQuery += `&author=${encodeURIComponent(this.searchAuthor)}`
|
||||
if (this.libraryItemId) searchQuery += `&id=${this.libraryItemId}`
|
||||
return searchQuery
|
||||
},
|
||||
submitSearch() {
|
||||
if (!this.searchTitle) {
|
||||
this.$toast.warning(this.$strings.ToastTitleRequired)
|
||||
return
|
||||
}
|
||||
this.persistProvider()
|
||||
this.runSearch()
|
||||
},
|
||||
async runSearch() {
|
||||
const searchQuery = this.getSearchQuery()
|
||||
if (this.lastSearch === searchQuery) return
|
||||
this.searchResults = []
|
||||
this.isProcessing = true
|
||||
this.lastSearch = searchQuery
|
||||
const searchEntity = this.isPodcast ? 'podcast' : 'books'
|
||||
let results = await this.$axios.$get(`/api/search/${searchEntity}?${searchQuery}`, { timeout: 20000 }).catch((error) => {
|
||||
console.error('Failed', error)
|
||||
return []
|
||||
})
|
||||
// console.log('Got search results', results)
|
||||
results = (results || []).filter((res) => {
|
||||
return !!res.title
|
||||
})
|
||||
|
||||
if (this.isPodcast) {
|
||||
// Map to match PodcastMetadata keys
|
||||
results = results.map((res) => {
|
||||
res.itunesPageUrl = res.pageUrl || null
|
||||
res.itunesId = res.id || null
|
||||
res.author = res.artistName || null
|
||||
res.explicit = res.explicit || false
|
||||
return res
|
||||
})
|
||||
}
|
||||
|
||||
this.searchResults = results || []
|
||||
this.isProcessing = false
|
||||
this.hasSearched = true
|
||||
},
|
||||
initSelectedMatchUsage() {
|
||||
this.selectedMatchUsage = {
|
||||
title: true,
|
||||
subtitle: true,
|
||||
cover: true,
|
||||
author: true,
|
||||
narrator: true,
|
||||
description: true,
|
||||
publisher: true,
|
||||
publishedYear: true,
|
||||
series: true,
|
||||
genres: true,
|
||||
tags: true,
|
||||
language: true,
|
||||
explicit: true,
|
||||
asin: true,
|
||||
isbn: true,
|
||||
abridged: true,
|
||||
// Podcast specific
|
||||
itunesPageUrl: true,
|
||||
itunesId: true,
|
||||
feedUrl: true,
|
||||
releaseDate: true
|
||||
}
|
||||
|
||||
// Load saved selected match from local storage
|
||||
try {
|
||||
let savedSelectedMatchUsage = localStorage.getItem('selectedMatchUsage')
|
||||
if (!savedSelectedMatchUsage) return
|
||||
savedSelectedMatchUsage = JSON.parse(savedSelectedMatchUsage)
|
||||
|
||||
for (const key in savedSelectedMatchUsage) {
|
||||
if (this.selectedMatchUsage[key] !== undefined) {
|
||||
this.selectedMatchUsage[key] = !!savedSelectedMatchUsage[key]
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load saved selectedMatchUsage', error)
|
||||
}
|
||||
|
||||
this.checkboxToggled()
|
||||
},
|
||||
init() {
|
||||
this.clearSelectedMatch()
|
||||
this.initSelectedMatchUsage()
|
||||
|
||||
if (this.libraryItem.id !== this.libraryItemId) {
|
||||
this.searchResults = []
|
||||
this.hasSearched = false
|
||||
this.libraryItemId = this.libraryItem.id
|
||||
}
|
||||
|
||||
if (!this.libraryItem.media || !this.libraryItem.media.metadata.title) {
|
||||
this.searchTitle = null
|
||||
this.searchAuthor = null
|
||||
return
|
||||
}
|
||||
this.searchTitle = this.libraryItem.media.metadata.title
|
||||
this.searchAuthor = this.libraryItem.media.metadata.authorName || ''
|
||||
if (this.isPodcast) this.provider = 'itunes'
|
||||
else {
|
||||
this.provider = this.getDefaultBookProvider()
|
||||
}
|
||||
|
||||
// Prefer using ASIN if set and using audible provider
|
||||
if (this.provider.startsWith('audible') && this.libraryItem.media.metadata.asin) {
|
||||
this.searchTitle = this.libraryItem.media.metadata.asin
|
||||
this.searchAuthor = ''
|
||||
}
|
||||
|
||||
if (this.searchTitle) {
|
||||
this.submitSearch()
|
||||
}
|
||||
},
|
||||
selectMatch(match) {
|
||||
if (match) {
|
||||
if (match.series) {
|
||||
if (!match.series.length) {
|
||||
delete match.series
|
||||
} else {
|
||||
match.series = match.series.map((se) => {
|
||||
return {
|
||||
id: `new-${Math.floor(Math.random() * 10000)}`,
|
||||
displayName: se.sequence ? `${se.series} #${se.sequence}` : se.series,
|
||||
name: se.series,
|
||||
sequence: se.sequence || ''
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
if (match.genres && !Array.isArray(match.genres)) {
|
||||
// match.genres = match.genres.join(',')
|
||||
match.genres = match.genres.split(',').map((g) => g.trim())
|
||||
}
|
||||
if (match.tags && !Array.isArray(match.tags)) {
|
||||
match.tags = match.tags.split(',').map((g) => g.trim())
|
||||
}
|
||||
if (match.narrator && !Array.isArray(match.narrator)) {
|
||||
match.narrator = match.narrator.split(',').map((g) => g.trim())
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Select Match', match)
|
||||
this.selectedMatch = match
|
||||
this.selectedMatchOrig = JSON.parse(JSON.stringify(match))
|
||||
},
|
||||
buildMatchUpdatePayload() {
|
||||
var updatePayload = {}
|
||||
updatePayload.metadata = {}
|
||||
|
||||
for (const key in this.selectedMatchUsage) {
|
||||
if (this.selectedMatchUsage[key] && this.selectedMatch[key]) {
|
||||
if (key === 'series') {
|
||||
if (!Array.isArray(this.selectedMatch[key])) {
|
||||
console.error('Invalid series in selectedMatch', this.selectedMatch[key])
|
||||
} else {
|
||||
var seriesPayload = []
|
||||
this.selectedMatch[key].forEach((seriesItem) =>
|
||||
seriesPayload.push({
|
||||
id: seriesItem.id,
|
||||
name: seriesItem.name,
|
||||
sequence: seriesItem.sequence
|
||||
})
|
||||
)
|
||||
updatePayload.metadata.series = seriesPayload
|
||||
}
|
||||
} else if (key === 'author' && !this.isPodcast) {
|
||||
var authors = this.selectedMatch[key]
|
||||
if (!Array.isArray(authors)) {
|
||||
authors = authors
|
||||
.split(',')
|
||||
.map((au) => au.trim())
|
||||
.filter((au) => !!au)
|
||||
}
|
||||
var authorPayload = []
|
||||
authors.forEach((authorName) =>
|
||||
authorPayload.push({
|
||||
id: `new-${Math.floor(Math.random() * 10000)}`,
|
||||
name: authorName
|
||||
})
|
||||
)
|
||||
updatePayload.metadata.authors = authorPayload
|
||||
} else if (key === 'narrator') {
|
||||
updatePayload.metadata.narrators = this.selectedMatch[key]
|
||||
} else if (key === 'genres') {
|
||||
updatePayload.metadata.genres = [...this.selectedMatch[key]]
|
||||
} else if (key === 'tags') {
|
||||
updatePayload.tags = this.selectedMatch[key]
|
||||
} else if (key === 'itunesId') {
|
||||
updatePayload.metadata.itunesId = Number(this.selectedMatch[key])
|
||||
} else {
|
||||
updatePayload.metadata[key] = this.selectedMatch[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return updatePayload
|
||||
},
|
||||
async submitMatchUpdate() {
|
||||
var updatePayload = this.buildMatchUpdatePayload()
|
||||
if (!Object.keys(updatePayload).length) {
|
||||
return
|
||||
}
|
||||
|
||||
console.log('Match payload', updatePayload)
|
||||
this.isProcessing = true
|
||||
|
||||
// Persist in local storage
|
||||
localStorage.setItem('selectedMatchUsage', JSON.stringify(this.selectedMatchUsage))
|
||||
|
||||
if (Object.keys(updatePayload).length) {
|
||||
if (updatePayload.metadata.cover) {
|
||||
updatePayload.url = updatePayload.metadata.cover
|
||||
delete updatePayload.metadata.cover
|
||||
}
|
||||
const mediaUpdatePayload = updatePayload
|
||||
const updateResult = await this.$axios.$patch(`/api/items/${this.libraryItemId}/media`, mediaUpdatePayload).catch((error) => {
|
||||
console.error('Failed to update', error)
|
||||
return false
|
||||
})
|
||||
if (updateResult) {
|
||||
if (updateResult.updated) {
|
||||
this.$toast.success(this.$strings.ToastItemDetailsUpdateSuccess)
|
||||
} else {
|
||||
this.$toast.info(this.$strings.ToastNoUpdatesNecessary)
|
||||
}
|
||||
this.clearSelectedMatch()
|
||||
this.$emit('selectTab', 'details')
|
||||
} else {
|
||||
this.$toast.error(this.$strings.ToastFailedToUpdate)
|
||||
}
|
||||
} else {
|
||||
this.clearSelectedMatch()
|
||||
}
|
||||
|
||||
this.isProcessing = false
|
||||
},
|
||||
clearSelectedMatch() {
|
||||
this.selectedMatch = null
|
||||
this.selectedMatchOrig = null
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.matchListWrapper {
|
||||
height: calc(100% - 124px);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.matchListWrapper {
|
||||
height: calc(100% - 80px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
192
components/modals/item/tabs/Schedule.vue
Normal file
192
components/modals/item/tabs/Schedule.vue
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
<template>
|
||||
<div class="w-full h-full relative">
|
||||
<div id="scheduleWrapper" class="w-full overflow-y-auto px-2 py-4 md:px-6 md:py-6">
|
||||
<template v-if="!feedUrl">
|
||||
<widgets-alert type="warning" class="text-base mb-4">{{ $strings.ToastPodcastNoRssFeed }}</widgets-alert>
|
||||
</template>
|
||||
<template v-if="feedUrl || autoDownloadEpisodes">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<p class="text-base md:text-xl font-semibold">{{ $strings.HeaderScheduleEpisodeDownloads }}</p>
|
||||
<ui-checkbox v-model="enableAutoDownloadEpisodes" :label="$strings.LabelEnable" medium checkbox-bg="bg" label-class="pl-2 text-base md:text-lg" />
|
||||
</div>
|
||||
|
||||
<div v-if="enableAutoDownloadEpisodes" class="flex items-center py-2">
|
||||
<ui-text-input ref="maxEpisodesInput" type="number" v-model="newMaxEpisodesToKeep" no-spinner :padding-x="1" text-center class="w-10 text-base" @change="updatedMaxEpisodesToKeep" />
|
||||
<ui-tooltip :text="$strings.LabelMaxEpisodesToKeepHelp">
|
||||
<p class="pl-4 text-base">
|
||||
{{ $strings.LabelMaxEpisodesToKeep }}
|
||||
<span class="material-symbols icon-text">info</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
<div v-if="enableAutoDownloadEpisodes" class="flex items-center py-2">
|
||||
<ui-text-input ref="maxEpisodesToDownloadInput" type="number" v-model="newMaxNewEpisodesToDownload" no-spinner :padding-x="1" text-center class="w-10 text-base" @change="updateMaxNewEpisodesToDownload" />
|
||||
<ui-tooltip :text="$strings.LabelUseZeroForUnlimited">
|
||||
<p class="pl-4 text-base">
|
||||
{{ $strings.LabelMaxEpisodesToDownloadPerCheck }}
|
||||
<span class="material-symbols icon-text">info</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<widgets-cron-expression-builder ref="cronExpressionBuilder" v-if="enableAutoDownloadEpisodes" v-model="cronExpression" />
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="feedUrl || autoDownloadEpisodes" class="absolute bottom-0 left-0 w-full py-2 md:py-4 bg-bg border-t border-white border-opacity-5">
|
||||
<div class="flex items-center px-2 md:px-4">
|
||||
<div class="flex-grow" />
|
||||
<ui-btn @click="save" :disabled="!isUpdated" :color="isUpdated ? 'success' : 'primary'" class="mx-2">{{ isUpdated ? $strings.ButtonSave : $strings.MessageNoUpdatesWereNecessary }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
processing: Boolean,
|
||||
libraryItem: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
enableAutoDownloadEpisodes: false,
|
||||
cronExpression: null,
|
||||
newMaxEpisodesToKeep: 0,
|
||||
newMaxNewEpisodesToDownload: 0
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
libraryItem: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
if (newVal) this.init()
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isProcessing: {
|
||||
get() {
|
||||
return this.processing
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('update:processing', val)
|
||||
}
|
||||
},
|
||||
userIsAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
media() {
|
||||
return this.libraryItem ? this.libraryItem.media || {} : {}
|
||||
},
|
||||
mediaMetadata() {
|
||||
return this.media.metadata || {}
|
||||
},
|
||||
libraryItemId() {
|
||||
return this.libraryItem ? this.libraryItem.id : null
|
||||
},
|
||||
feedUrl() {
|
||||
return this.mediaMetadata.feedUrl
|
||||
},
|
||||
autoDownloadEpisodes() {
|
||||
return !!this.media.autoDownloadEpisodes
|
||||
},
|
||||
autoDownloadSchedule() {
|
||||
return this.media.autoDownloadSchedule
|
||||
},
|
||||
maxEpisodesToKeep() {
|
||||
return this.media.maxEpisodesToKeep
|
||||
},
|
||||
maxNewEpisodesToDownload() {
|
||||
return this.media.maxNewEpisodesToDownload
|
||||
},
|
||||
isUpdated() {
|
||||
return this.autoDownloadSchedule !== this.cronExpression || this.autoDownloadEpisodes !== this.enableAutoDownloadEpisodes || this.maxEpisodesToKeep !== Number(this.newMaxEpisodesToKeep) || this.maxNewEpisodesToDownload !== Number(this.newMaxNewEpisodesToDownload)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
updatedMaxEpisodesToKeep() {
|
||||
if (isNaN(this.newMaxEpisodesToKeep) || this.newMaxEpisodesToKeep < 0) {
|
||||
this.newMaxEpisodesToKeep = 0
|
||||
} else {
|
||||
this.newMaxEpisodesToKeep = Number(this.newMaxEpisodesToKeep)
|
||||
}
|
||||
},
|
||||
updateMaxNewEpisodesToDownload() {
|
||||
if (isNaN(this.newMaxNewEpisodesToDownload) || this.newMaxNewEpisodesToDownload < 0) {
|
||||
this.newMaxNewEpisodesToDownload = 0
|
||||
} else {
|
||||
this.newMaxNewEpisodesToDownload = Number(this.newMaxNewEpisodesToDownload)
|
||||
}
|
||||
},
|
||||
save() {
|
||||
// If custom expression input is focused then unfocus it instead of submitting
|
||||
if (this.$refs.cronExpressionBuilder && this.$refs.cronExpressionBuilder.checkBlurExpressionInput) {
|
||||
if (this.$refs.cronExpressionBuilder.checkBlurExpressionInput()) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (this.$refs.maxEpisodesInput?.isFocused) {
|
||||
this.$refs.maxEpisodesInput.blur()
|
||||
}
|
||||
if (this.$refs.maxEpisodesToDownloadInput?.isFocused) {
|
||||
this.$refs.maxEpisodesToDownloadInput.blur()
|
||||
}
|
||||
|
||||
const updatePayload = {
|
||||
autoDownloadEpisodes: this.enableAutoDownloadEpisodes
|
||||
}
|
||||
if (this.enableAutoDownloadEpisodes) {
|
||||
updatePayload.autoDownloadSchedule = this.cronExpression
|
||||
}
|
||||
this.newMaxEpisodesToKeep = Number(this.newMaxEpisodesToKeep)
|
||||
if (this.newMaxEpisodesToKeep !== this.maxEpisodesToKeep) {
|
||||
updatePayload.maxEpisodesToKeep = this.newMaxEpisodesToKeep
|
||||
}
|
||||
this.newMaxNewEpisodesToDownload = Number(this.newMaxNewEpisodesToDownload)
|
||||
if (this.newMaxNewEpisodesToDownload !== this.maxNewEpisodesToDownload) {
|
||||
updatePayload.maxNewEpisodesToDownload = this.newMaxNewEpisodesToDownload
|
||||
}
|
||||
|
||||
this.updateDetails(updatePayload)
|
||||
},
|
||||
async updateDetails(updatePayload) {
|
||||
this.isProcessing = true
|
||||
var updateResult = await this.$axios.$patch(`/api/items/${this.libraryItemId}/media`, updatePayload).catch((error) => {
|
||||
console.error('Failed to update', error)
|
||||
return false
|
||||
})
|
||||
this.isProcessing = false
|
||||
if (updateResult) {
|
||||
if (updateResult.updated) {
|
||||
this.$toast.success(this.$strings.ToastItemDetailsUpdateSuccess)
|
||||
return true
|
||||
} else {
|
||||
this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary)
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
init() {
|
||||
this.enableAutoDownloadEpisodes = this.autoDownloadEpisodes
|
||||
this.cronExpression = this.autoDownloadSchedule
|
||||
this.newMaxEpisodesToKeep = this.maxEpisodesToKeep
|
||||
this.newMaxNewEpisodesToDownload = this.maxNewEpisodesToDownload
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
#scheduleWrapper {
|
||||
height: calc(100% - 80px);
|
||||
max-height: calc(100% - 80px);
|
||||
}
|
||||
</style>
|
||||
135
components/modals/item/tabs/Tools.vue
Normal file
135
components/modals/item/tabs/Tools.vue
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
<template>
|
||||
<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>
|
||||
|
||||
<!-- 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">{{ $strings.LabelToolsMakeM4b }}</p>
|
||||
<p class="max-w-sm text-sm pt-2 text-gray-300">{{ $strings.LabelToolsMakeM4bDescription }}</p>
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<div>
|
||||
<ui-btn :to="`/audiobook/${libraryItemId}/manage?tool=m4b`" class="flex items-center"
|
||||
>{{ $strings.ButtonOpenManager }}
|
||||
<span class="material-symbols text-lg ml-2">launch</span>
|
||||
</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">{{ $strings.LabelToolsEmbedMetadata }}</p>
|
||||
<p class="max-w-sm text-sm pt-2 text-gray-300">{{ $strings.LabelToolsEmbedMetadataDescription }}</p>
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<div>
|
||||
<ui-btn :to="`/audiobook/${libraryItemId}/manage?tool=embed`" class="flex items-center"
|
||||
>{{ $strings.ButtonOpenManager }}
|
||||
<span class="material-symbols text-lg ml-2">launch</span>
|
||||
</ui-btn>
|
||||
|
||||
<ui-btn v-if="!isMetadataEmbedQueued && !isEmbedTaskRunning" class="w-full mt-4" small @click.stop="quickEmbed">{{ $strings.ButtonQuickEmbed }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- queued alert -->
|
||||
<widgets-alert v-if="isMetadataEmbedQueued" type="warning" class="mt-4">
|
||||
<p class="text-lg">{{ $getString('MessageQuickEmbedQueue', [queuedEmbedLIds.length]) }}</p>
|
||||
</widgets-alert>
|
||||
|
||||
<!-- processing alert -->
|
||||
<widgets-alert v-if="isEmbedTaskRunning" type="warning" class="mt-4">
|
||||
<p class="text-lg">{{ $strings.MessageQuickEmbedInProgress }}</p>
|
||||
</widgets-alert>
|
||||
</div>
|
||||
|
||||
<p v-if="!mediaTracks.length" class="text-lg text-center my-8">{{ $strings.MessageNoAudioTracks }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
processing: Boolean,
|
||||
libraryItem: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
libraryItemId() {
|
||||
return this.libraryItem?.id || null
|
||||
},
|
||||
media() {
|
||||
return 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
|
||||
},
|
||||
queuedEmbedLIds() {
|
||||
return this.$store.state.tasks.queuedEmbedLIds || []
|
||||
},
|
||||
isMetadataEmbedQueued() {
|
||||
return this.queuedEmbedLIds.some((lid) => lid === this.libraryItemId)
|
||||
},
|
||||
tasks() {
|
||||
return this.$store.getters['tasks/getTasksByLibraryItemId'](this.libraryItemId)
|
||||
},
|
||||
embedTask() {
|
||||
return this.tasks.find((t) => t.action === 'embed-metadata')
|
||||
},
|
||||
encodeTask() {
|
||||
return this.tasks.find((t) => t.action === 'encode-m4b')
|
||||
},
|
||||
isEmbedTaskRunning() {
|
||||
return this.embedTask && !this.embedTask?.isFinished
|
||||
},
|
||||
isEncodeTaskRunning() {
|
||||
return this.encodeTask && !this.encodeTask?.isFinished
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
quickEmbed() {
|
||||
const payload = {
|
||||
message: this.$strings.MessageConfirmQuickEmbed,
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.$axios
|
||||
.$post(`/api/tools/item/${this.libraryItemId}/embed-metadata`)
|
||||
.then(() => {
|
||||
console.log('Audio metadata encode started')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Audio metadata encode failed', error)
|
||||
})
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
Loading…
Add table
Add a link
Reference in a new issue