mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-06-01 04:34:13 -04:00
New data model batch routes and batch editor
This commit is contained in:
parent
6597fca576
commit
4bdef893af
@ -44,8 +44,8 @@
|
|||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-show="numAudiobooksSelected" class="absolute top-0 left-0 w-full h-full px-4 bg-primary flex items-center">
|
<div v-show="numLibraryItemsSelected" class="absolute top-0 left-0 w-full h-full px-4 bg-primary flex items-center">
|
||||||
<h1 class="text-2xl px-4">{{ numAudiobooksSelected }} Selected</h1>
|
<h1 class="text-2xl px-4">{{ numLibraryItemsSelected }} Selected</h1>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<ui-tooltip :text="`Mark as ${selectedIsRead ? 'Not Read' : 'Read'}`" direction="bottom">
|
<ui-tooltip :text="`Mark as ${selectedIsRead ? 'Not Read' : 'Read'}`" direction="bottom">
|
||||||
<ui-read-icon-btn :disabled="processingBatch" :is-read="selectedIsRead" @click="toggleBatchRead" class="mx-1.5" />
|
<ui-read-icon-btn :disabled="processingBatch" :is-read="selectedIsRead" @click="toggleBatchRead" class="mx-1.5" />
|
||||||
@ -53,7 +53,7 @@
|
|||||||
<ui-tooltip v-if="userCanUpdate" text="Add to Collection" direction="bottom">
|
<ui-tooltip v-if="userCanUpdate" text="Add to Collection" direction="bottom">
|
||||||
<ui-icon-btn :disabled="processingBatch" icon="collections_bookmark" @click="batchAddToCollectionClick" class="mx-1.5" />
|
<ui-icon-btn :disabled="processingBatch" icon="collections_bookmark" @click="batchAddToCollectionClick" class="mx-1.5" />
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
<template v-if="userCanUpdate && numAudiobooksSelected < 50">
|
<template v-if="userCanUpdate && numLibraryItemsSelected < 50">
|
||||||
<ui-icon-btn v-show="!processingBatchDelete" icon="edit" bg-color="warning" class="mx-1.5" @click="batchEditClick" />
|
<ui-icon-btn v-show="!processingBatchDelete" icon="edit" bg-color="warning" class="mx-1.5" @click="batchEditClick" />
|
||||||
</template>
|
</template>
|
||||||
<ui-icon-btn v-show="userCanDelete" :disabled="processingBatchDelete" icon="delete" bg-color="error" class="mx-1.5" @click="batchDeleteClick" />
|
<ui-icon-btn v-show="userCanDelete" :disabled="processingBatchDelete" icon="delete" bg-color="error" class="mx-1.5" @click="batchDeleteClick" />
|
||||||
@ -94,11 +94,11 @@ export default {
|
|||||||
username() {
|
username() {
|
||||||
return this.user ? this.user.username : 'err'
|
return this.user ? this.user.username : 'err'
|
||||||
},
|
},
|
||||||
numAudiobooksSelected() {
|
numLibraryItemsSelected() {
|
||||||
return this.selectedAudiobooks.length
|
return this.selectedLibraryItems.length
|
||||||
},
|
},
|
||||||
selectedAudiobooks() {
|
selectedLibraryItems() {
|
||||||
return this.$store.state.selectedAudiobooks
|
return this.$store.state.selectedLibraryItems
|
||||||
},
|
},
|
||||||
userAudiobooks() {
|
userAudiobooks() {
|
||||||
return this.$store.state.user.user.audiobooks || {}
|
return this.$store.state.user.user.audiobooks || {}
|
||||||
@ -117,8 +117,8 @@ export default {
|
|||||||
},
|
},
|
||||||
selectedIsRead() {
|
selectedIsRead() {
|
||||||
// Find an audiobook that is not read, if none then all audiobooks read
|
// Find an audiobook that is not read, if none then all audiobooks read
|
||||||
return !this.selectedAudiobooks.find((ab) => {
|
return !this.selectedLibraryItems.find((li) => {
|
||||||
var userAb = this.userAudiobooks[ab]
|
var userAb = this.userAudiobooks[li]
|
||||||
return !userAb || !userAb.isRead
|
return !userAb || !userAb.isRead
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@ -150,16 +150,16 @@ export default {
|
|||||||
},
|
},
|
||||||
cancelSelectionMode() {
|
cancelSelectionMode() {
|
||||||
if (this.processingBatchDelete) return
|
if (this.processingBatchDelete) return
|
||||||
this.$store.commit('setSelectedAudiobooks', [])
|
this.$store.commit('setSelectedLibraryItems', [])
|
||||||
this.$eventBus.$emit('bookshelf-clear-selection')
|
this.$eventBus.$emit('bookshelf-clear-selection')
|
||||||
this.isAllSelected = false
|
this.isAllSelected = false
|
||||||
},
|
},
|
||||||
toggleBatchRead() {
|
toggleBatchRead() {
|
||||||
this.$store.commit('setProcessingBatch', true)
|
this.$store.commit('setProcessingBatch', true)
|
||||||
var newIsRead = !this.selectedIsRead
|
var newIsRead = !this.selectedIsRead
|
||||||
var updateProgressPayloads = this.selectedAudiobooks.map((ab) => {
|
var updateProgressPayloads = this.selectedLibraryItems.map((lid) => {
|
||||||
return {
|
return {
|
||||||
audiobookId: ab,
|
audiobookId: lid,
|
||||||
isRead: newIsRead
|
isRead: newIsRead
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -168,7 +168,7 @@ export default {
|
|||||||
.then(() => {
|
.then(() => {
|
||||||
this.$toast.success('Batch update success!')
|
this.$toast.success('Batch update success!')
|
||||||
this.$store.commit('setProcessingBatch', false)
|
this.$store.commit('setProcessingBatch', false)
|
||||||
this.$store.commit('setSelectedAudiobooks', [])
|
this.$store.commit('setSelectedLibraryItems', [])
|
||||||
this.$eventBus.$emit('bookshelf-clear-selection')
|
this.$eventBus.$emit('bookshelf-clear-selection')
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
@ -178,20 +178,20 @@ export default {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
batchDeleteClick() {
|
batchDeleteClick() {
|
||||||
var audiobookText = this.numAudiobooksSelected > 1 ? `these ${this.numAudiobooksSelected} audiobooks` : 'this audiobook'
|
var audiobookText = this.numLibraryItemsSelected > 1 ? `these ${this.numLibraryItemsSelected} audiobooks` : 'this audiobook'
|
||||||
var confirmMsg = `Are you sure you want to remove ${audiobookText}?\n\n*Does not delete your files, only removes the audiobooks from AudioBookshelf`
|
var confirmMsg = `Are you sure you want to remove ${audiobookText}?\n\n*Does not delete your files, only removes the audiobooks from AudioBookshelf`
|
||||||
if (confirm(confirmMsg)) {
|
if (confirm(confirmMsg)) {
|
||||||
this.processingBatchDelete = true
|
this.processingBatchDelete = true
|
||||||
this.$store.commit('setProcessingBatch', true)
|
this.$store.commit('setProcessingBatch', true)
|
||||||
this.$axios
|
this.$axios
|
||||||
.$post(`/api/books/batch/delete`, {
|
.$post(`/api/items/batch/delete`, {
|
||||||
audiobookIds: this.selectedAudiobooks
|
libraryItemIds: this.selectedLibraryItems
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.$toast.success('Batch delete success!')
|
this.$toast.success('Batch delete success!')
|
||||||
this.processingBatchDelete = false
|
this.processingBatchDelete = false
|
||||||
this.$store.commit('setProcessingBatch', false)
|
this.$store.commit('setProcessingBatch', false)
|
||||||
this.$store.commit('setSelectedAudiobooks', [])
|
this.$store.commit('setSelectedLibraryItems', [])
|
||||||
this.$eventBus.$emit('bookshelf-clear-selection')
|
this.$eventBus.$emit('bookshelf-clear-selection')
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
@ -69,16 +69,16 @@ export default {
|
|||||||
showExperimentalFeatures() {
|
showExperimentalFeatures() {
|
||||||
return this.$store.state.showExperimentalFeatures
|
return this.$store.state.showExperimentalFeatures
|
||||||
},
|
},
|
||||||
audiobookId() {
|
libraryItemId() {
|
||||||
return this.book.id
|
return this.book.id
|
||||||
},
|
},
|
||||||
selected: {
|
selected: {
|
||||||
get() {
|
get() {
|
||||||
return this.$store.getters['getIsAudiobookSelected'](this.audiobookId)
|
return this.$store.getters['getIsLibraryItemSelected'](this.libraryItemId)
|
||||||
},
|
},
|
||||||
set(val) {
|
set(val) {
|
||||||
if (this.processingBatch) return
|
if (this.processingBatch) return
|
||||||
this.$store.commit('setAudiobookSelected', { audiobookId: this.audiobookId, selected: val })
|
this.$store.commit('setLibraryItemSelected', { libraryItemId: this.libraryItemId, selected: val })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
processingBatch() {
|
processingBatch() {
|
||||||
@ -118,7 +118,7 @@ export default {
|
|||||||
return this.book.numTracks
|
return this.book.numTracks
|
||||||
},
|
},
|
||||||
isStreaming() {
|
isStreaming() {
|
||||||
return this.$store.getters['getLibraryItemIdStreaming'] === this.audiobookId
|
return this.$store.getters['getLibraryItemIdStreaming'] === this.libraryItemId
|
||||||
},
|
},
|
||||||
showReadButton() {
|
showReadButton() {
|
||||||
return this.showExperimentalFeatures && this.numEbooks
|
return this.showExperimentalFeatures && this.numEbooks
|
||||||
@ -142,7 +142,7 @@ export default {
|
|||||||
methods: {
|
methods: {
|
||||||
selectBtnClick() {
|
selectBtnClick() {
|
||||||
if (this.processingBatch) return
|
if (this.processingBatch) return
|
||||||
this.$store.commit('toggleAudiobookSelected', this.audiobookId)
|
this.$store.commit('toggleLibraryItemSelected', this.libraryItemId)
|
||||||
},
|
},
|
||||||
openEbook() {
|
openEbook() {
|
||||||
this.$store.commit('showEReader', this.book)
|
this.$store.commit('showEReader', this.book)
|
||||||
@ -156,7 +156,7 @@ export default {
|
|||||||
}
|
}
|
||||||
this.isProcessingReadUpdate = true
|
this.isProcessingReadUpdate = true
|
||||||
this.$axios
|
this.$axios
|
||||||
.$patch(`/api/me/audiobook/${this.audiobookId}`, updatePayload)
|
.$patch(`/api/me/audiobook/${this.libraryItemId}`, updatePayload)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.isProcessingReadUpdate = false
|
this.isProcessingReadUpdate = false
|
||||||
this.$toast.success(`"${this.title}" Marked as ${updatePayload.isRead ? 'Read' : 'Not Read'}`)
|
this.$toast.success(`"${this.title}" Marked as ${updatePayload.isRead ? 'Read' : 'Not Read'}`)
|
||||||
|
@ -4,7 +4,7 @@
|
|||||||
<div class="w-full h-full pt-6">
|
<div class="w-full h-full pt-6">
|
||||||
<div v-if="shelf.type === 'books'" class="flex items-center">
|
<div v-if="shelf.type === 'books'" class="flex items-center">
|
||||||
<template v-for="(entity, index) in shelf.entities">
|
<template v-for="(entity, index) in shelf.entities">
|
||||||
<cards-lazy-book-card :key="entity.id" :ref="`shelf-book-${entity.id}`" :index="index" :width="bookCoverWidth" :height="bookCoverHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" :book-mount="entity" class="relative mx-2" @hook:updated="updatedBookCard" @select="selectBook" @edit="editBook" />
|
<cards-lazy-book-card :key="entity.id" :ref="`shelf-book-${entity.id}`" :index="index" :width="bookCoverWidth" :height="bookCoverHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" :book-mount="entity" class="relative mx-2" @hook:updated="updatedBookCard" @select="selectItem" @edit="editBook" />
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="shelf.type === 'series'" class="flex items-center">
|
<div v-if="shelf.type === 'series'" class="flex items-center">
|
||||||
@ -90,7 +90,7 @@ export default {
|
|||||||
return this.$store.state.libraries.currentLibraryId
|
return this.$store.state.libraries.currentLibraryId
|
||||||
},
|
},
|
||||||
isSelectionMode() {
|
isSelectionMode() {
|
||||||
return this.$store.getters['getNumAudiobooksSelected'] > 0
|
return this.$store.getters['getNumLibraryItemsSelected'] > 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@ -100,19 +100,19 @@ export default {
|
|||||||
this.$store.commit('showEditModal', audiobook)
|
this.$store.commit('showEditModal', audiobook)
|
||||||
},
|
},
|
||||||
updateSelectionMode(val) {
|
updateSelectionMode(val) {
|
||||||
var selectedAudiobooks = this.$store.state.selectedAudiobooks
|
var selectedLibraryItems = this.$store.state.selectedLibraryItems
|
||||||
if (this.shelf.type === 'books') {
|
if (this.shelf.type === 'books') {
|
||||||
this.shelf.entities.forEach((ent) => {
|
this.shelf.entities.forEach((ent) => {
|
||||||
var component = this.$refs[`shelf-book-${ent.id}`]
|
var component = this.$refs[`shelf-book-${ent.id}`]
|
||||||
if (!component || !component.length) return
|
if (!component || !component.length) return
|
||||||
component = component[0]
|
component = component[0]
|
||||||
component.setSelectionMode(val)
|
component.setSelectionMode(val)
|
||||||
component.selected = selectedAudiobooks.includes(ent.id)
|
component.selected = selectedLibraryItems.includes(ent.id)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
selectBook(audiobook) {
|
selectItem(libraryItem) {
|
||||||
this.$store.commit('toggleAudiobookSelected', audiobook.id)
|
this.$store.commit('toggleLibraryItemSelected', libraryItem.id)
|
||||||
},
|
},
|
||||||
scrolled() {
|
scrolled() {
|
||||||
clearTimeout(this.scrollTimer)
|
clearTimeout(this.scrollTimer)
|
||||||
|
@ -183,8 +183,8 @@ export default {
|
|||||||
// Includes margin
|
// Includes margin
|
||||||
return this.entityWidth + 24
|
return this.entityWidth + 24
|
||||||
},
|
},
|
||||||
selectedAudiobooks() {
|
selectedLibraryItems() {
|
||||||
return this.$store.state.selectedAudiobooks || []
|
return this.$store.state.selectedLibraryItems || []
|
||||||
},
|
},
|
||||||
sizeMultiplier() {
|
sizeMultiplier() {
|
||||||
var baseSize = this.isCoverSquareAspectRatio ? 192 : 120
|
var baseSize = this.isCoverSquareAspectRatio ? 192 : 120
|
||||||
@ -214,9 +214,9 @@ export default {
|
|||||||
},
|
},
|
||||||
selectEntity(entity) {
|
selectEntity(entity) {
|
||||||
if (this.entityName === 'books' || this.entityName === 'series-books') {
|
if (this.entityName === 'books' || this.entityName === 'series-books') {
|
||||||
this.$store.commit('toggleAudiobookSelected', entity.id)
|
this.$store.commit('toggleLibraryItemSelected', entity.id)
|
||||||
|
|
||||||
var newIsSelectionMode = !!this.selectedAudiobooks.length
|
var newIsSelectionMode = !!this.selectedLibraryItems.length
|
||||||
if (this.isSelectionMode !== newIsSelectionMode) {
|
if (this.isSelectionMode !== newIsSelectionMode) {
|
||||||
this.isSelectionMode = newIsSelectionMode
|
this.isSelectionMode = newIsSelectionMode
|
||||||
this.updateBookSelectionMode(newIsSelectionMode)
|
this.updateBookSelectionMode(newIsSelectionMode)
|
||||||
|
@ -101,7 +101,7 @@ export default {
|
|||||||
return this.$store.state.globals.showBatchUserCollectionModal
|
return this.$store.state.globals.showBatchUserCollectionModal
|
||||||
},
|
},
|
||||||
selectedBookIds() {
|
selectedBookIds() {
|
||||||
return this.$store.state.selectedAudiobooks || []
|
return this.$store.state.selectedLibraryItems || []
|
||||||
},
|
},
|
||||||
currentLibraryId() {
|
currentLibraryId() {
|
||||||
return this.$store.state.libraries.currentLibraryId
|
return this.$store.state.libraries.currentLibraryId
|
||||||
|
@ -1,106 +1,27 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full h-full relative">
|
<div class="w-full h-full relative">
|
||||||
<form class="w-full h-full" @submit.prevent="submitForm">
|
<widgets-item-details-edit ref="itemDetailsEdit" :library-item="libraryItem" @submit="submitForm" />
|
||||||
<div ref="formWrapper" class="px-4 py-6 details-form-wrapper w-full overflow-hidden overflow-y-auto">
|
|
||||||
<div class="flex -mx-1">
|
|
||||||
<div class="w-1/2 px-1">
|
|
||||||
<ui-text-input-with-label v-model="details.title" label="Title" />
|
|
||||||
</div>
|
|
||||||
<div class="flex-grow px-1">
|
|
||||||
<ui-text-input-with-label v-model="details.subtitle" label="Subtitle" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex mt-2 -mx-1">
|
<div class="absolute bottom-0 left-0 w-full py-4 bg-bg" :class="isScrollable ? 'box-shadow-md-up' : 'box-shadow-sm-up border-t border-primary border-opacity-50'">
|
||||||
<div class="w-3/4 px-1">
|
<div class="flex items-center px-4">
|
||||||
<ui-multi-select-query-input ref="authorsSelect" v-model="details.authors" label="Authors" endpoint="authors/search" />
|
<ui-btn v-if="userCanDelete" color="error" type="button" class="h-8" :padding-x="3" small @click.stop.prevent="removeItem">Remove</ui-btn>
|
||||||
</div>
|
|
||||||
<div class="flex-grow px-1">
|
|
||||||
<ui-text-input-with-label v-model="details.publishYear" type="number" label="Publish Year" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex mt-2 -mx-1">
|
<div class="flex-grow" />
|
||||||
<div class="flex-grow px-1">
|
|
||||||
<ui-multi-select-query-input ref="seriesSelect" v-model="seriesItems" text-key="displayName" label="Series" readonly show-edit @edit="editSeriesItem" @add="addNewSeries" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ui-textarea-with-label v-model="details.description" :rows="3" label="Description" class="mt-2" />
|
<ui-tooltip v-if="!isMissing" text="(Root User Only) Save a NFO metadata file in your audiobooks directory" direction="bottom" class="mr-4 hidden sm:block">
|
||||||
|
<ui-btn v-if="isRootUser" :loading="savingMetadata" color="bg" type="button" class="h-full" small @click.stop.prevent="saveMetadata">Save Metadata</ui-btn>
|
||||||
|
</ui-tooltip>
|
||||||
|
|
||||||
<div class="flex mt-2 -mx-1">
|
<ui-tooltip :disabled="!!quickMatching" :text="`(Root User Only) Populate empty book details & cover with first book result from '${libraryProvider}'. Does not overwrite details.`" direction="bottom" class="mr-4">
|
||||||
<div class="w-1/2 px-1">
|
<ui-btn v-if="isRootUser" :loading="quickMatching" color="bg" type="button" class="h-full" small @click.stop.prevent="quickMatch">Quick Match</ui-btn>
|
||||||
<ui-multi-select ref="genresSelect" v-model="details.genres" label="Genres" :items="genres" />
|
</ui-tooltip>
|
||||||
</div>
|
|
||||||
<div class="flex-grow px-1">
|
|
||||||
<ui-multi-select ref="tagsSelect" v-model="newTags" label="Tags" :items="tags" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex mt-2 -mx-1">
|
<ui-tooltip :disabled="!!libraryScan" text="(Root User Only) Rescan audiobook including metadata" direction="bottom" class="mr-4">
|
||||||
<div class="w-1/3 px-1">
|
<ui-btn v-if="isRootUser" :loading="rescanning" :disabled="!!libraryScan" color="bg" type="button" class="h-full" small @click.stop.prevent="rescan">Re-Scan</ui-btn>
|
||||||
<ui-text-input-with-label v-model="details.narrator" label="Narrator" />
|
</ui-tooltip>
|
||||||
</div>
|
|
||||||
<div class="w-1/3 px-1">
|
|
||||||
<ui-text-input-with-label v-model="details.publisher" label="Publisher" />
|
|
||||||
</div>
|
|
||||||
<div class="flex-grow px-1">
|
|
||||||
<ui-text-input-with-label v-model="details.language" label="Language" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex mt-2 -mx-1">
|
<ui-btn @click="submitForm">Submit</ui-btn>
|
||||||
<div class="w-1/3 px-1">
|
|
||||||
<ui-text-input-with-label v-model="details.isbn" label="ISBN" />
|
|
||||||
</div>
|
|
||||||
<div class="w-1/3 px-1">
|
|
||||||
<ui-text-input-with-label v-model="details.asin" label="ASIN" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="absolute bottom-0 left-0 w-full py-4 bg-bg" :class="isScrollable ? 'box-shadow-md-up' : 'box-shadow-sm-up border-t border-primary border-opacity-50'">
|
|
||||||
<div class="flex items-center px-4">
|
|
||||||
<ui-btn v-if="userCanDelete" color="error" type="button" class="h-8" :padding-x="3" small @click.stop.prevent="removeItem">Remove</ui-btn>
|
|
||||||
|
|
||||||
<div class="flex-grow" />
|
|
||||||
|
|
||||||
<ui-tooltip v-if="!isMissing" text="(Root User Only) Save a NFO metadata file in your audiobooks directory" direction="bottom" class="mr-4 hidden sm:block">
|
|
||||||
<ui-btn v-if="isRootUser" :loading="savingMetadata" color="bg" type="button" class="h-full" small @click.stop.prevent="saveMetadata">Save Metadata</ui-btn>
|
|
||||||
</ui-tooltip>
|
|
||||||
|
|
||||||
<ui-tooltip :disabled="!!quickMatching" :text="`(Root User Only) Populate empty book details & cover with first book result from '${libraryProvider}'. Does not overwrite details.`" direction="bottom" class="mr-4">
|
|
||||||
<ui-btn v-if="isRootUser" :loading="quickMatching" color="bg" type="button" class="h-full" small @click.stop.prevent="quickMatch">Quick Match</ui-btn>
|
|
||||||
</ui-tooltip>
|
|
||||||
|
|
||||||
<ui-tooltip :disabled="!!libraryScan" text="(Root User Only) Rescan audiobook including metadata" direction="bottom" class="mr-4">
|
|
||||||
<ui-btn v-if="isRootUser" :loading="rescanning" :disabled="!!libraryScan" color="bg" type="button" class="h-full" small @click.stop.prevent="rescan">Re-Scan</ui-btn>
|
|
||||||
</ui-tooltip>
|
|
||||||
|
|
||||||
<ui-btn type="submit">Submit</ui-btn>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div v-if="showSeriesForm" class="absolute top-0 left-0 z-20 w-full h-full bg-black bg-opacity-50 rounded-lg flex items-center justify-center" @click="cancelSeriesForm">
|
|
||||||
<div class="absolute top-0 right-0 p-4">
|
|
||||||
<span class="material-icons text-gray-200 hover:text-white text-4xl cursor-pointer">close</span>
|
|
||||||
</div>
|
|
||||||
<form @submit.prevent="submitSeriesForm">
|
|
||||||
<div class="bg-bg rounded-lg p-8" @click.stop>
|
|
||||||
<div class="flex">
|
|
||||||
<div class="flex-grow p-1 min-w-80">
|
|
||||||
<ui-input-dropdown ref="newSeriesSelect" v-model="selectedSeries.name" :items="existingSeriesNames" :disabled="!selectedSeries.id.startsWith('new')" label="Series Name" />
|
|
||||||
</div>
|
|
||||||
<div class="w-40 p-1">
|
|
||||||
<ui-text-input-with-label v-model="selectedSeries.sequence" label="Sequence" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-end mt-2 p-1">
|
|
||||||
<ui-btn type="submit">Save</ui-btn>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -116,23 +37,6 @@ export default {
|
|||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
selectedSeries: {},
|
|
||||||
showSeriesForm: false,
|
|
||||||
details: {
|
|
||||||
title: null,
|
|
||||||
subtitle: null,
|
|
||||||
description: null,
|
|
||||||
author: null,
|
|
||||||
narrator: null,
|
|
||||||
series: null,
|
|
||||||
publishYear: null,
|
|
||||||
publisher: null,
|
|
||||||
language: null,
|
|
||||||
isbn: null,
|
|
||||||
asin: null,
|
|
||||||
genres: []
|
|
||||||
},
|
|
||||||
newTags: [],
|
|
||||||
resettingProgress: false,
|
resettingProgress: false,
|
||||||
isScrollable: false,
|
isScrollable: false,
|
||||||
savingMetadata: false,
|
savingMetadata: false,
|
||||||
@ -140,14 +44,6 @@ export default {
|
|||||||
quickMatching: false
|
quickMatching: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
|
||||||
libraryItem: {
|
|
||||||
immediate: true,
|
|
||||||
handler(newVal) {
|
|
||||||
if (newVal) this.init()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
computed: {
|
||||||
isProcessing: {
|
isProcessing: {
|
||||||
get() {
|
get() {
|
||||||
@ -175,18 +71,6 @@ export default {
|
|||||||
userCanDelete() {
|
userCanDelete() {
|
||||||
return this.$store.getters['user/getUserCanDelete']
|
return this.$store.getters['user/getUserCanDelete']
|
||||||
},
|
},
|
||||||
genres() {
|
|
||||||
return this.filterData.genres || []
|
|
||||||
},
|
|
||||||
tags() {
|
|
||||||
return this.filterData.tags || []
|
|
||||||
},
|
|
||||||
series() {
|
|
||||||
return this.filterData.series || []
|
|
||||||
},
|
|
||||||
filterData() {
|
|
||||||
return this.$store.state.libraries.filterData || {}
|
|
||||||
},
|
|
||||||
libraryId() {
|
libraryId() {
|
||||||
return this.libraryItem ? this.libraryItem.libraryId : null
|
return this.libraryItem ? this.libraryItem.libraryId : null
|
||||||
},
|
},
|
||||||
@ -196,71 +80,9 @@ export default {
|
|||||||
libraryScan() {
|
libraryScan() {
|
||||||
if (!this.libraryId) return null
|
if (!this.libraryId) return null
|
||||||
return this.$store.getters['scanners/getLibraryScan'](this.libraryId)
|
return this.$store.getters['scanners/getLibraryScan'](this.libraryId)
|
||||||
},
|
|
||||||
existingSeriesNames() {
|
|
||||||
// Only show series names not already selected
|
|
||||||
var alreadySelectedSeriesIds = this.details.series.map((se) => se.id)
|
|
||||||
return this.series.filter((se) => !alreadySelectedSeriesIds.includes(se.id)).map((se) => se.name)
|
|
||||||
},
|
|
||||||
seriesItems: {
|
|
||||||
get() {
|
|
||||||
return this.details.series.map((se) => {
|
|
||||||
return {
|
|
||||||
displayName: se.sequence ? `${se.name} #${se.sequence}` : se.name,
|
|
||||||
...se
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
set(val) {
|
|
||||||
this.details.series = val
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
cancelSeriesForm() {
|
|
||||||
this.showSeriesForm = false
|
|
||||||
},
|
|
||||||
editSeriesItem(series) {
|
|
||||||
var _series = this.details.series.find((se) => se.id === series.id)
|
|
||||||
if (!_series) return
|
|
||||||
this.selectedSeries = {
|
|
||||||
..._series
|
|
||||||
}
|
|
||||||
this.showSeriesForm = true
|
|
||||||
},
|
|
||||||
submitSeriesForm() {
|
|
||||||
if (!this.selectedSeries.name) {
|
|
||||||
this.$toast.error('Must enter a series')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (this.$refs.newSeriesSelect) {
|
|
||||||
this.$refs.newSeriesSelect.blur()
|
|
||||||
}
|
|
||||||
var existingSeriesIndex = this.details.series.findIndex((se) => se.id === this.selectedSeries.id)
|
|
||||||
|
|
||||||
var seriesSameName = this.series.find((se) => se.name.toLowerCase() === this.selectedSeries.name.toLowerCase())
|
|
||||||
if (existingSeriesIndex < 0 && seriesSameName) {
|
|
||||||
this.selectedSeries.id = seriesSameName.id
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existingSeriesIndex >= 0) {
|
|
||||||
this.details.series.splice(existingSeriesIndex, 1, { ...this.selectedSeries })
|
|
||||||
} else {
|
|
||||||
this.details.series.push({
|
|
||||||
...this.selectedSeries
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
this.showSeriesForm = false
|
|
||||||
},
|
|
||||||
addNewSeries() {
|
|
||||||
this.selectedSeries = {
|
|
||||||
id: `new-${Date.now()}`,
|
|
||||||
name: '',
|
|
||||||
sequence: ''
|
|
||||||
}
|
|
||||||
this.showSeriesForm = true
|
|
||||||
},
|
|
||||||
quickMatch() {
|
quickMatch() {
|
||||||
this.quickMatching = true
|
this.quickMatching = true
|
||||||
var matchOptions = {
|
var matchOptions = {
|
||||||
@ -326,25 +148,20 @@ export default {
|
|||||||
if (this.isProcessing) {
|
if (this.isProcessing) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.isProcessing = true
|
if (!this.$refs.itemDetailsEdit) {
|
||||||
if (this.$refs.authorsSelect && this.$refs.authorsSelect.isFocused) {
|
return
|
||||||
this.$refs.authorsSelect.forceBlur()
|
|
||||||
}
|
}
|
||||||
if (this.$refs.genresSelect && this.$refs.genresSelect.isFocused) {
|
var updatedDetails = this.$refs.itemDetailsEdit.getDetails()
|
||||||
this.$refs.genresSelect.forceBlur()
|
if (!updatedDetails.hasChanges) {
|
||||||
|
this.$toast.info('No changes were made')
|
||||||
|
return
|
||||||
}
|
}
|
||||||
if (this.$refs.tagsSelect && this.$refs.tagsSelect.isFocused) {
|
this.updateDetails(updatedDetails)
|
||||||
this.$refs.tagsSelect.forceBlur()
|
|
||||||
}
|
|
||||||
this.$nextTick(this.handleForm)
|
|
||||||
},
|
},
|
||||||
async handleForm() {
|
async updateDetails(updatedDetails) {
|
||||||
const updatePayload = {
|
this.isProcessing = true
|
||||||
metadata: this.details,
|
console.log('Sending update', updatedDetails.updatePayload)
|
||||||
tags: this.newTags
|
var updatedAudiobook = await this.$axios.$patch(`/api/items/${this.libraryItemId}/media`, updatedDetails.updatePayload).catch((error) => {
|
||||||
}
|
|
||||||
console.log('Sending update', updatePayload)
|
|
||||||
var updatedAudiobook = await this.$axios.$patch(`/api/items/${this.libraryItemId}/media`, updatePayload).catch((error) => {
|
|
||||||
console.error('Failed to update', error)
|
console.error('Failed to update', error)
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
@ -354,29 +171,6 @@ export default {
|
|||||||
this.$emit('close')
|
this.$emit('close')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
init() {
|
|
||||||
this.details.title = this.mediaMetadata.title
|
|
||||||
this.details.subtitle = this.mediaMetadata.subtitle
|
|
||||||
this.details.description = this.mediaMetadata.description
|
|
||||||
this.$set(
|
|
||||||
this.details,
|
|
||||||
'authors',
|
|
||||||
(this.mediaMetadata.authors || []).map((se) => ({ ...se }))
|
|
||||||
)
|
|
||||||
this.details.narrator = this.mediaMetadata.narrator
|
|
||||||
this.details.genres = [...(this.mediaMetadata.genres || [])]
|
|
||||||
this.$set(
|
|
||||||
this.details,
|
|
||||||
'series',
|
|
||||||
(this.mediaMetadata.series || []).map((se) => ({ ...se }))
|
|
||||||
)
|
|
||||||
this.details.publishYear = this.mediaMetadata.publishYear
|
|
||||||
this.details.publisher = this.mediaMetadata.publisher || null
|
|
||||||
this.details.language = this.mediaMetadata.language || null
|
|
||||||
this.details.isbn = this.mediaMetadata.isbn || null
|
|
||||||
this.details.asin = this.mediaMetadata.asin || null
|
|
||||||
this.newTags = [...(this.media.tags || [])]
|
|
||||||
},
|
|
||||||
removeItem() {
|
removeItem() {
|
||||||
if (confirm(`Are you sure you want to remove this item?\n\n*Does not delete your files, only removes the item from audiobookshelf`)) {
|
if (confirm(`Are you sure you want to remove this item?\n\n*Does not delete your files, only removes the item from audiobookshelf`)) {
|
||||||
this.isProcessing = true
|
this.isProcessing = true
|
||||||
@ -396,8 +190,9 @@ export default {
|
|||||||
},
|
},
|
||||||
checkIsScrollable() {
|
checkIsScrollable() {
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
if (this.$refs.formWrapper) {
|
var formWrapper = document.getElementById('formWrapper')
|
||||||
if (this.$refs.formWrapper.scrollHeight > this.$refs.formWrapper.clientHeight) {
|
if (formWrapper) {
|
||||||
|
if (formWrapper.scrollHeight > formWrapper.clientHeight) {
|
||||||
this.isScrollable = true
|
this.isScrollable = true
|
||||||
} else {
|
} else {
|
||||||
this.isScrollable = false
|
this.isScrollable = false
|
||||||
@ -407,12 +202,15 @@ export default {
|
|||||||
},
|
},
|
||||||
setResizeObserver() {
|
setResizeObserver() {
|
||||||
try {
|
try {
|
||||||
this.$nextTick(() => {
|
var formWrapper = document.getElementById('formWrapper')
|
||||||
const resizeObserver = new ResizeObserver(() => {
|
if (formWrapper) {
|
||||||
this.checkIsScrollable()
|
this.$nextTick(() => {
|
||||||
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
|
this.checkIsScrollable()
|
||||||
|
})
|
||||||
|
resizeObserver.observe(formWrapper)
|
||||||
})
|
})
|
||||||
resizeObserver.observe(this.$refs.formWrapper)
|
}
|
||||||
})
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to set resize observer')
|
console.error('Failed to set resize observer')
|
||||||
}
|
}
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<label class="flex justify-start items-center cursor-pointer">
|
<label class="flex justify-start items-center" :class="!disabled ? 'cursor-pointer' : ''">
|
||||||
<div class="border-2 rounded border-gray-400 flex flex-shrink-0 justify-center items-center" :class="wrapperClass">
|
<div class="border-2 rounded flex flex-shrink-0 justify-center items-center" :class="wrapperClass">
|
||||||
<input v-model="selected" type="checkbox" class="opacity-0 absolute cursor-pointer" />
|
<input v-model="selected" :disabled="disabled" type="checkbox" class="opacity-0 absolute" :class="!disabled ? 'cursor-pointer' : ''" />
|
||||||
<svg v-if="selected" class="fill-current pointer-events-none" :class="svgClass" viewBox="0 0 20 20"><path d="M0 11l2-2 5 5L18 3l2 2L7 18z" /></svg>
|
<svg v-if="selected" class="fill-current pointer-events-none" :class="svgClass" viewBox="0 0 20 20"><path d="M0 11l2-2 5 5L18 3l2 2L7 18z" /></svg>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="label" class="select-none pl-1 text-gray-100" :class="labelClass">{{ label }}</div>
|
<div v-if="label" class="select-none text-gray-100" :class="labelClassname">{{ label }}</div>
|
||||||
</label>
|
</label>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -18,10 +18,19 @@ export default {
|
|||||||
type: String,
|
type: String,
|
||||||
default: 'white'
|
default: 'white'
|
||||||
},
|
},
|
||||||
|
borderColor: {
|
||||||
|
type: String,
|
||||||
|
default: 'gray-400'
|
||||||
|
},
|
||||||
checkColor: {
|
checkColor: {
|
||||||
type: String,
|
type: String,
|
||||||
default: 'green-500'
|
default: 'green-500'
|
||||||
}
|
},
|
||||||
|
labelClass: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
disabled: Boolean
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {}
|
return {}
|
||||||
@ -36,15 +45,17 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
wrapperClass() {
|
wrapperClass() {
|
||||||
var classes = [`bg-${this.checkboxBg}`]
|
var classes = [`bg-${this.checkboxBg} border-${this.borderColor}`]
|
||||||
if (this.small) classes.push('w-4 h-4')
|
if (this.small) classes.push('w-4 h-4')
|
||||||
else classes.push('w-6 h-6')
|
else classes.push('w-6 h-6')
|
||||||
|
|
||||||
return classes.join(' ')
|
return classes.join(' ')
|
||||||
},
|
},
|
||||||
labelClass() {
|
labelClassname() {
|
||||||
if (this.small) return 'text-xs md:text-sm'
|
if (this.labelClass) return this.labelClass
|
||||||
return ''
|
var classes = ['pl-1']
|
||||||
|
if (this.small) classes.push('text-xs md:text-sm')
|
||||||
|
return classes.join(' ')
|
||||||
},
|
},
|
||||||
svgClass() {
|
svgClass() {
|
||||||
var classes = [`text-${this.checkColor}`]
|
var classes = [`text-${this.checkColor}`]
|
||||||
|
@ -8,7 +8,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<ul ref="menu" v-show="isFocused && items.length && (itemsToShow.length || currentSearch)" class="absolute z-50 mt-0 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
|
<ul ref="menu" v-show="isFocused && itemsToShow.length" class="absolute z-50 mt-0 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
|
||||||
<template v-for="item in itemsToShow">
|
<template v-for="item in itemsToShow">
|
||||||
<li :key="item" class="text-gray-50 select-none relative py-2 pr-3 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent>
|
<li :key="item" class="text-gray-50 select-none relative py-2 pr-3 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
@ -47,7 +47,7 @@ export default {
|
|||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
isFocused: false,
|
isFocused: false,
|
||||||
currentSearch: null,
|
// currentSearch: null,
|
||||||
typingTimeout: null,
|
typingTimeout: null,
|
||||||
textInput: null
|
textInput: null
|
||||||
}
|
}
|
||||||
@ -70,12 +70,13 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
itemsToShow() {
|
itemsToShow() {
|
||||||
if (!this.currentSearch || !this.textInput || this.textInput === this.input) {
|
if (!this.editable) return this.items
|
||||||
return this.items
|
if (!this.textInput || this.textInput === this.input) {
|
||||||
|
return []
|
||||||
}
|
}
|
||||||
return this.items.filter((i) => {
|
return this.items.filter((i) => {
|
||||||
var iValue = String(i).toLowerCase()
|
var iValue = String(i).toLowerCase()
|
||||||
return iValue.includes(this.currentSearch.toLowerCase())
|
return iValue.includes(this.textInput.toLowerCase())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -83,7 +84,7 @@ export default {
|
|||||||
keydownInput() {
|
keydownInput() {
|
||||||
clearTimeout(this.typingTimeout)
|
clearTimeout(this.typingTimeout)
|
||||||
this.typingTimeout = setTimeout(() => {
|
this.typingTimeout = setTimeout(() => {
|
||||||
this.currentSearch = this.textInput
|
// this.currentSearch = this.textInput
|
||||||
}, 100)
|
}, 100)
|
||||||
},
|
},
|
||||||
inputFocus() {
|
inputFocus() {
|
||||||
@ -127,11 +128,11 @@ export default {
|
|||||||
if (val && !this.items.includes(val)) {
|
if (val && !this.items.includes(val)) {
|
||||||
this.$emit('newItem', val)
|
this.$emit('newItem', val)
|
||||||
}
|
}
|
||||||
this.currentSearch = null
|
// this.currentSearch = null
|
||||||
},
|
},
|
||||||
clickedOption(e, item) {
|
clickedOption(e, item) {
|
||||||
this.textInput = null
|
this.textInput = null
|
||||||
this.currentSearch = null
|
// this.currentSearch = null
|
||||||
this.input = item
|
this.input = item
|
||||||
if (this.$refs.input) this.$refs.input.blur()
|
if (this.$refs.input) this.$refs.input.blur()
|
||||||
}
|
}
|
||||||
|
@ -70,7 +70,7 @@ export default {
|
|||||||
computed: {
|
computed: {
|
||||||
selected: {
|
selected: {
|
||||||
get() {
|
get() {
|
||||||
return this.value
|
return this.value || []
|
||||||
},
|
},
|
||||||
set(val) {
|
set(val) {
|
||||||
this.$emit('input', val)
|
this.$emit('input', val)
|
||||||
|
@ -82,6 +82,9 @@ export default {
|
|||||||
this.$emit('input', val)
|
this.$emit('input', val)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
userToken() {
|
||||||
|
return this.$store.getters['user/getToken']
|
||||||
|
},
|
||||||
wrapperClass() {
|
wrapperClass() {
|
||||||
var classes = []
|
var classes = []
|
||||||
if (this.disabled) classes.push('bg-black-300')
|
if (this.disabled) classes.push('bg-black-300')
|
||||||
@ -110,7 +113,7 @@ export default {
|
|||||||
if (this.searching) return
|
if (this.searching) return
|
||||||
this.currentSearch = this.textInput
|
this.currentSearch = this.textInput
|
||||||
this.searching = true
|
this.searching = true
|
||||||
var results = await this.$axios.$get(`/api/${this.endpoint}?q=${this.currentSearch}&limit=15`).catch((error) => {
|
var results = await this.$axios.$get(`/api/${this.endpoint}?q=${this.currentSearch}&limit=15&token=${this.userToken}`).catch((error) => {
|
||||||
console.error('Failed to get search results', error)
|
console.error('Failed to get search results', error)
|
||||||
return []
|
return []
|
||||||
})
|
})
|
||||||
|
334
client/components/widgets/ItemDetailsEdit.vue
Normal file
334
client/components/widgets/ItemDetailsEdit.vue
Normal file
@ -0,0 +1,334 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full h-full relative">
|
||||||
|
<form class="w-full h-full" @submit.prevent="submitForm">
|
||||||
|
<div id="formWrapper" class="px-4 py-6 details-form-wrapper w-full overflow-hidden overflow-y-auto">
|
||||||
|
<div class="flex -mx-1">
|
||||||
|
<div class="w-1/2 px-1">
|
||||||
|
<ui-text-input-with-label v-model="details.title" label="Title" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow px-1">
|
||||||
|
<ui-text-input-with-label v-model="details.subtitle" label="Subtitle" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex mt-2 -mx-1">
|
||||||
|
<div class="w-3/4 px-1">
|
||||||
|
<!-- Authors filter only contains authors in this library, use query input to query all authors -->
|
||||||
|
<ui-multi-select-query-input ref="authorsSelect" v-model="details.authors" label="Authors" endpoint="authors/search" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow px-1">
|
||||||
|
<ui-text-input-with-label v-model="details.publishYear" type="number" label="Publish Year" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex mt-2 -mx-1">
|
||||||
|
<div class="flex-grow px-1">
|
||||||
|
<ui-multi-select-query-input ref="seriesSelect" v-model="seriesItems" text-key="displayName" label="Series" readonly show-edit @edit="editSeriesItem" @add="addNewSeries" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ui-textarea-with-label v-model="details.description" :rows="3" label="Description" class="mt-2" />
|
||||||
|
|
||||||
|
<div class="flex mt-2 -mx-1">
|
||||||
|
<div class="w-1/2 px-1">
|
||||||
|
<ui-multi-select ref="genresSelect" v-model="details.genres" label="Genres" :items="genres" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow px-1">
|
||||||
|
<ui-multi-select ref="tagsSelect" v-model="newTags" label="Tags" :items="tags" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex mt-2 -mx-1">
|
||||||
|
<div class="w-1/2 px-1">
|
||||||
|
<ui-multi-select ref="narratorsSelect" v-model="details.narrators" label="Narrators" :items="narrators" />
|
||||||
|
</div>
|
||||||
|
<div class="w-1/4 px-1">
|
||||||
|
<ui-text-input-with-label v-model="details.isbn" label="ISBN" />
|
||||||
|
</div>
|
||||||
|
<div class="w-1/4 px-1">
|
||||||
|
<ui-text-input-with-label v-model="details.asin" label="ASIN" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex mt-2 -mx-1">
|
||||||
|
<div class="w-1/2 px-1">
|
||||||
|
<ui-text-input-with-label v-model="details.publisher" label="Publisher" />
|
||||||
|
</div>
|
||||||
|
<div class="w-1/4 px-1">
|
||||||
|
<ui-text-input-with-label v-model="details.language" label="Language" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow px-1 pt-6">
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<ui-checkbox v-model="details.explicit" label="Explicit" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div v-if="showSeriesForm" class="absolute top-0 left-0 z-20 w-full h-full bg-black bg-opacity-50 rounded-lg flex items-center justify-center" @click="cancelSeriesForm">
|
||||||
|
<div class="absolute top-0 right-0 p-4">
|
||||||
|
<span class="material-icons text-gray-200 hover:text-white text-4xl cursor-pointer">close</span>
|
||||||
|
</div>
|
||||||
|
<form @submit.prevent="submitSeriesForm">
|
||||||
|
<div class="bg-bg rounded-lg p-8" @click.stop>
|
||||||
|
<div class="flex">
|
||||||
|
<div class="flex-grow p-1 min-w-80">
|
||||||
|
<ui-input-dropdown ref="newSeriesSelect" v-model="selectedSeries.name" :items="existingSeriesNames" :disabled="!selectedSeries.id.startsWith('new')" label="Series Name" />
|
||||||
|
</div>
|
||||||
|
<div class="w-40 p-1">
|
||||||
|
<ui-text-input-with-label v-model="selectedSeries.sequence" label="Sequence" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end mt-2 p-1">
|
||||||
|
<ui-btn type="submit">Save</ui-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
libraryItem: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
selectedSeries: {},
|
||||||
|
showSeriesForm: false,
|
||||||
|
details: {
|
||||||
|
title: null,
|
||||||
|
subtitle: null,
|
||||||
|
description: null,
|
||||||
|
authors: [],
|
||||||
|
narrators: [],
|
||||||
|
series: [],
|
||||||
|
publishYear: null,
|
||||||
|
publisher: null,
|
||||||
|
language: null,
|
||||||
|
isbn: null,
|
||||||
|
asin: null,
|
||||||
|
genres: [],
|
||||||
|
explicit: false
|
||||||
|
},
|
||||||
|
newTags: []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
libraryItem: {
|
||||||
|
immediate: true,
|
||||||
|
handler(newVal) {
|
||||||
|
if (newVal) this.init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
media() {
|
||||||
|
return this.libraryItem ? this.libraryItem.media || {} : {}
|
||||||
|
},
|
||||||
|
mediaMetadata() {
|
||||||
|
return this.media.metadata || {}
|
||||||
|
},
|
||||||
|
genres() {
|
||||||
|
return this.filterData.genres || []
|
||||||
|
},
|
||||||
|
tags() {
|
||||||
|
return this.filterData.tags || []
|
||||||
|
},
|
||||||
|
series() {
|
||||||
|
return this.filterData.series || []
|
||||||
|
},
|
||||||
|
narrators() {
|
||||||
|
return this.filterData.narrators || []
|
||||||
|
},
|
||||||
|
filterData() {
|
||||||
|
return this.$store.state.libraries.filterData || {}
|
||||||
|
},
|
||||||
|
existingSeriesNames() {
|
||||||
|
// Only show series names not already selected
|
||||||
|
var alreadySelectedSeriesIds = this.details.series.map((se) => se.id)
|
||||||
|
return this.series.filter((se) => !alreadySelectedSeriesIds.includes(se.id)).map((se) => se.name)
|
||||||
|
},
|
||||||
|
seriesItems: {
|
||||||
|
get() {
|
||||||
|
return this.details.series.map((se) => {
|
||||||
|
return {
|
||||||
|
displayName: se.sequence ? `${se.name} #${se.sequence}` : se.name,
|
||||||
|
...se
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.details.series = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
getDetails() {
|
||||||
|
this.forceBlur()
|
||||||
|
return this.checkForChanges()
|
||||||
|
},
|
||||||
|
mapBatchDetails(batchDetails) {
|
||||||
|
for (const key in batchDetails) {
|
||||||
|
if (key === 'tags') {
|
||||||
|
this.newTags = [...batchDetails.tags]
|
||||||
|
} else if (key === 'genres' || key === 'narrators') {
|
||||||
|
this.details[key] = [...batchDetails[key]]
|
||||||
|
} else if (key === 'authors' || key === 'series') {
|
||||||
|
this.details[key] = batchDetails[key].map((i) => ({ ...i }))
|
||||||
|
} else {
|
||||||
|
this.details[key] = batchDetails[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
forceBlur() {
|
||||||
|
if (this.$refs.authorsSelect && this.$refs.authorsSelect.isFocused) {
|
||||||
|
this.$refs.authorsSelect.forceBlur()
|
||||||
|
}
|
||||||
|
if (this.$refs.narratorsSelect && this.$refs.narratorsSelect.isFocused) {
|
||||||
|
this.$refs.narratorsSelect.forceBlur()
|
||||||
|
}
|
||||||
|
if (this.$refs.genresSelect && this.$refs.genresSelect.isFocused) {
|
||||||
|
this.$refs.genresSelect.forceBlur()
|
||||||
|
}
|
||||||
|
if (this.$refs.tagsSelect && this.$refs.tagsSelect.isFocused) {
|
||||||
|
this.$refs.tagsSelect.forceBlur()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
cancelSeriesForm() {
|
||||||
|
this.showSeriesForm = false
|
||||||
|
},
|
||||||
|
editSeriesItem(series) {
|
||||||
|
var _series = this.details.series.find((se) => se.id === series.id)
|
||||||
|
if (!_series) return
|
||||||
|
this.selectedSeries = {
|
||||||
|
..._series
|
||||||
|
}
|
||||||
|
this.showSeriesForm = true
|
||||||
|
},
|
||||||
|
addNewSeries() {
|
||||||
|
this.selectedSeries = {
|
||||||
|
id: `new-${Date.now()}`,
|
||||||
|
name: '',
|
||||||
|
sequence: ''
|
||||||
|
}
|
||||||
|
this.showSeriesForm = true
|
||||||
|
},
|
||||||
|
submitSeriesForm() {
|
||||||
|
if (!this.selectedSeries.name) {
|
||||||
|
this.$toast.error('Must enter a series')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (this.$refs.newSeriesSelect) {
|
||||||
|
this.$refs.newSeriesSelect.blur()
|
||||||
|
}
|
||||||
|
var existingSeriesIndex = this.details.series.findIndex((se) => se.id === this.selectedSeries.id)
|
||||||
|
|
||||||
|
var seriesSameName = this.series.find((se) => se.name.toLowerCase() === this.selectedSeries.name.toLowerCase())
|
||||||
|
if (existingSeriesIndex < 0 && seriesSameName) {
|
||||||
|
this.selectedSeries.id = seriesSameName.id
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingSeriesIndex >= 0) {
|
||||||
|
this.details.series.splice(existingSeriesIndex, 1, { ...this.selectedSeries })
|
||||||
|
} else {
|
||||||
|
this.details.series.push({
|
||||||
|
...this.selectedSeries
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
this.showSeriesForm = false
|
||||||
|
},
|
||||||
|
stringArrayEqual(array1, array2) {
|
||||||
|
// return false if different
|
||||||
|
if (array1.length !== array2.length) return false
|
||||||
|
for (var item of array1) {
|
||||||
|
if (!array2.includes(item)) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
objectArrayEqual(array1, array2) {
|
||||||
|
const isIterable = (value) => {
|
||||||
|
return Symbol.iterator in Object(value)
|
||||||
|
}
|
||||||
|
if (!isIterable(array1) || !isIterable(array2)) {
|
||||||
|
console.error(array1, array2)
|
||||||
|
throw new Error('Invalid arrays passed in')
|
||||||
|
}
|
||||||
|
|
||||||
|
// array of objects with id key
|
||||||
|
if (array1.length !== array2.length) return false
|
||||||
|
|
||||||
|
for (var item of array1) {
|
||||||
|
var matchingItem = array2.find((a) => a.id === item.id)
|
||||||
|
if (!matchingItem) return false
|
||||||
|
for (var key in item) {
|
||||||
|
if (item[key] !== matchingItem[key]) {
|
||||||
|
console.log('Object array item keys changed', key, item[key], matchingItem[key])
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
checkForChanges() {
|
||||||
|
var metadata = {}
|
||||||
|
for (const key in this.details) {
|
||||||
|
var newValue = this.details[key]
|
||||||
|
var oldValue = this.mediaMetadata[key]
|
||||||
|
// Key cleared out or key first populated
|
||||||
|
if ((!newValue && oldValue) || (newValue && !oldValue)) {
|
||||||
|
metadata[key] = newValue
|
||||||
|
} else if (key === 'narrators' || key === 'genres') {
|
||||||
|
// Check array of strings
|
||||||
|
if (!this.stringArrayEqual(newValue, oldValue)) {
|
||||||
|
metadata[key] = [...newValue]
|
||||||
|
}
|
||||||
|
} else if (key === 'authors' || key === 'series') {
|
||||||
|
if (!this.objectArrayEqual(newValue, oldValue)) {
|
||||||
|
metadata[key] = newValue.map((v) => ({ ...v }))
|
||||||
|
}
|
||||||
|
} else if (newValue && newValue != oldValue) {
|
||||||
|
// Intentional !=
|
||||||
|
metadata[key] = newValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var updatePayload = {}
|
||||||
|
if (!!Object.keys(metadata).length) updatePayload.metadata = metadata
|
||||||
|
|
||||||
|
if (!this.stringArrayEqual(this.newTags, this.media.tags || [])) {
|
||||||
|
updatePayload.tags = [...this.newTags]
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
updatePayload,
|
||||||
|
hasChanges: !!Object.keys(updatePayload).length
|
||||||
|
}
|
||||||
|
},
|
||||||
|
init() {
|
||||||
|
this.details.title = this.mediaMetadata.title
|
||||||
|
this.details.subtitle = this.mediaMetadata.subtitle
|
||||||
|
this.details.description = this.mediaMetadata.description
|
||||||
|
this.details.authors = (this.mediaMetadata.authors || []).map((se) => ({ ...se }))
|
||||||
|
this.details.narrators = [...(this.mediaMetadata.narrators || [])]
|
||||||
|
this.details.genres = [...(this.mediaMetadata.genres || [])]
|
||||||
|
this.details.series = (this.mediaMetadata.series || []).map((se) => ({ ...se }))
|
||||||
|
this.details.publishYear = this.mediaMetadata.publishYear
|
||||||
|
this.details.publisher = this.mediaMetadata.publisher || null
|
||||||
|
this.details.language = this.mediaMetadata.language || null
|
||||||
|
this.details.isbn = this.mediaMetadata.isbn || null
|
||||||
|
this.details.asin = this.mediaMetadata.asin || null
|
||||||
|
this.details.explicit = !!this.mediaMetadata.explicit
|
||||||
|
this.newTags = [...(this.media.tags || [])]
|
||||||
|
},
|
||||||
|
submitForm() {
|
||||||
|
this.$emit('submit')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
@ -32,8 +32,8 @@ export default {
|
|||||||
if (this.$store.state.showEditModal) {
|
if (this.$store.state.showEditModal) {
|
||||||
this.$store.commit('setShowEditModal', false)
|
this.$store.commit('setShowEditModal', false)
|
||||||
}
|
}
|
||||||
if (this.$store.state.selectedAudiobooks) {
|
if (this.$store.state.selectedLibraryItems) {
|
||||||
this.$store.commit('setSelectedAudiobooks', [])
|
this.$store.commit('setSelectedLibraryItems', [])
|
||||||
}
|
}
|
||||||
if (this.$store.state.audiobooks.keywordFilter) {
|
if (this.$store.state.audiobooks.keywordFilter) {
|
||||||
this.$store.commit('audiobooks/setKeywordFilter', '')
|
this.$store.commit('audiobooks/setKeywordFilter', '')
|
||||||
@ -486,9 +486,9 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Batch selecting
|
// Batch selecting
|
||||||
if (this.$store.getters['getNumAudiobooksSelected'] && name === 'Escape') {
|
if (this.$store.getters['getNumLibraryItemsSelected'] && name === 'Escape') {
|
||||||
// ESCAPE key cancels batch selection
|
// ESCAPE key cancels batch selection
|
||||||
this.$store.commit('setSelectedAudiobooks', [])
|
this.$store.commit('setSelectedLibraryItems', [])
|
||||||
this.$eventBus.$emit('bookshelf-clear-selection')
|
this.$eventBus.$emit('bookshelf-clear-selection')
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
return
|
return
|
||||||
|
@ -30,7 +30,7 @@ export default {
|
|||||||
shelfEl.appendChild(bookComponent.$el)
|
shelfEl.appendChild(bookComponent.$el)
|
||||||
if (this.isSelectionMode) {
|
if (this.isSelectionMode) {
|
||||||
bookComponent.setSelectionMode(true)
|
bookComponent.setSelectionMode(true)
|
||||||
if (this.selectedAudiobooks.includes(bookComponent.audiobookId) || this.isSelectAll) {
|
if (this.selectedLibraryItems.includes(bookComponent.libraryItemId) || this.isSelectAll) {
|
||||||
bookComponent.selected = true
|
bookComponent.selected = true
|
||||||
} else {
|
} else {
|
||||||
bookComponent.selected = false
|
bookComponent.selected = false
|
||||||
@ -85,7 +85,7 @@ export default {
|
|||||||
}
|
}
|
||||||
if (this.isSelectionMode) {
|
if (this.isSelectionMode) {
|
||||||
instance.setSelectionMode(true)
|
instance.setSelectionMode(true)
|
||||||
if (instance.audiobookId && this.selectedAudiobooks.includes(instance.audiobookId) || this.isSelectAll) {
|
if (instance.libraryItemId && this.selectedLibraryItems.includes(instance.libraryItemId) || this.isSelectAll) {
|
||||||
instance.selected = true
|
instance.selected = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div ref="page" id="page-wrapper" class="page px-6 pt-6 pb-52 overflow-y-auto" :class="streamAudiobook ? 'streaming' : ''">
|
<div ref="page" id="page-wrapper" class="page px-6 pt-6 pb-52 overflow-y-auto" :class="streamLibraryItem ? 'streaming' : ''">
|
||||||
<div class="border border-white border-opacity-10 max-w-7xl mx-auto mb-10 mt-5">
|
<div class="border border-white border-opacity-10 max-w-7xl mx-auto mb-10 mt-5">
|
||||||
<div class="flex items-center px-4 py-4 cursor-pointer" @click="openMapOptions = !openMapOptions" @mousedown.prevent @mouseup.prevent>
|
<div class="flex items-center px-4 py-4 cursor-pointer" @click="openMapOptions = !openMapOptions" @mousedown.prevent @mouseup.prevent>
|
||||||
<span class="material-icons">{{ openMapOptions ? 'expand_less' : 'expand_more' }}</span>
|
<span class="material-icons">{{ openMapOptions ? 'expand_less' : 'expand_more' }}</span>
|
||||||
@ -14,8 +14,9 @@
|
|||||||
<ui-text-input-with-label ref="subtitleInput" v-model="batchDetails.subtitle" :disabled="!selectedBatchUsage.subtitle" label="Subtitle" class="mb-4 ml-4" />
|
<ui-text-input-with-label ref="subtitleInput" v-model="batchDetails.subtitle" :disabled="!selectedBatchUsage.subtitle" label="Subtitle" class="mb-4 ml-4" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center px-4 w-1/2">
|
<div class="flex items-center px-4 w-1/2">
|
||||||
<ui-checkbox v-model="selectedBatchUsage.author" />
|
<ui-checkbox v-model="selectedBatchUsage.authors" />
|
||||||
<ui-text-input-with-label ref="authorInput" v-model="batchDetails.author" :disabled="!selectedBatchUsage.author" label="Author" class="mb-4 ml-4" />
|
<!-- Authors filter only contains authors in this library, use query input to query all authors -->
|
||||||
|
<ui-multi-select-query-input ref="authorsSelect" v-model="batchDetails.authors" :disabled="!selectedBatchUsage.authors" label="Authors" endpoint="authors/search" class="mb-4 ml-4" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center px-4 w-1/2">
|
<div class="flex items-center px-4 w-1/2">
|
||||||
<ui-checkbox v-model="selectedBatchUsage.publishYear" />
|
<ui-checkbox v-model="selectedBatchUsage.publishYear" />
|
||||||
@ -23,7 +24,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex items-center px-4 w-1/2">
|
<div class="flex items-center px-4 w-1/2">
|
||||||
<ui-checkbox v-model="selectedBatchUsage.series" />
|
<ui-checkbox v-model="selectedBatchUsage.series" />
|
||||||
<ui-input-dropdown ref="seriesDropdown" v-model="batchDetails.series" :disabled="!selectedBatchUsage.series" label="Series" :items="seriesItems" @input="seriesChanged" @newItem="newSeriesItem" class="mb-4 ml-4" />
|
<ui-multi-select ref="seriesSelect" v-model="batchDetails.series" :disabled="!selectedBatchUsage.series" label="Series" :items="seriesItems" @newItem="newSeriesItem" @removedItem="removedSeriesItem" class="mb-4 ml-4" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center px-4 w-1/2">
|
<div class="flex items-center px-4 w-1/2">
|
||||||
<ui-checkbox v-model="selectedBatchUsage.genres" />
|
<ui-checkbox v-model="selectedBatchUsage.genres" />
|
||||||
@ -34,8 +35,8 @@
|
|||||||
<ui-multi-select ref="tagsSelect" v-model="batchDetails.tags" label="Tags" :disabled="!selectedBatchUsage.tags" :items="tagItems" @newItem="newTagItem" @removedItem="removedTagItem" class="mb-4 ml-4" />
|
<ui-multi-select ref="tagsSelect" v-model="batchDetails.tags" label="Tags" :disabled="!selectedBatchUsage.tags" :items="tagItems" @newItem="newTagItem" @removedItem="removedTagItem" class="mb-4 ml-4" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center px-4 w-1/2">
|
<div class="flex items-center px-4 w-1/2">
|
||||||
<ui-checkbox v-model="selectedBatchUsage.narrator" />
|
<ui-checkbox v-model="selectedBatchUsage.narrators" />
|
||||||
<ui-text-input-with-label ref="narratorInput" v-model="batchDetails.narrator" :disabled="!selectedBatchUsage.narrator" label="Narrator" class="mb-4 ml-4" />
|
<ui-multi-select ref="narratorsSelect" v-model="batchDetails.narrators" :disabled="!selectedBatchUsage.narrators" label="Narrators" :items="narratorItems" @newItem="newNarratorItem" @removedItem="removedNarratorItem" class="mb-4 ml-4" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center px-4 w-1/2">
|
<div class="flex items-center px-4 w-1/2">
|
||||||
<ui-checkbox v-model="selectedBatchUsage.publisher" />
|
<ui-checkbox v-model="selectedBatchUsage.publisher" />
|
||||||
@ -45,6 +46,20 @@
|
|||||||
<ui-checkbox v-model="selectedBatchUsage.language" />
|
<ui-checkbox v-model="selectedBatchUsage.language" />
|
||||||
<ui-text-input-with-label ref="languageInput" v-model="batchDetails.language" :disabled="!selectedBatchUsage.language" label="Language" class="mb-4 ml-4" />
|
<ui-text-input-with-label ref="languageInput" v-model="batchDetails.language" :disabled="!selectedBatchUsage.language" label="Language" class="mb-4 ml-4" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-center px-4 w-1/2">
|
||||||
|
<ui-checkbox v-model="selectedBatchUsage.explicit" />
|
||||||
|
<div class="ml-4">
|
||||||
|
<ui-checkbox
|
||||||
|
v-model="batchDetails.explicit"
|
||||||
|
label="Explicit"
|
||||||
|
:disabled="!selectedBatchUsage.explicit"
|
||||||
|
:checkbox-bg="!selectedBatchUsage.explicit ? 'bg' : 'primary'"
|
||||||
|
:check-color="!selectedBatchUsage.explicit ? 'gray-600' : 'green-500'"
|
||||||
|
border-color="gray-600"
|
||||||
|
:label-class="!selectedBatchUsage.explicit ? 'pl-2 text-base text-gray-400 font-semibold' : 'pl-2 text-base font-semibold'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="w-full flex items-center justify-end p-4">
|
<div class="w-full flex items-center justify-end p-4">
|
||||||
<ui-btn color="success" :disabled="!hasSelectedBatchUsage" :padding-x="8" small class="text-base" :loading="isProcessing" @click="mapBatchDetails">Apply</ui-btn>
|
<ui-btn color="success" :disabled="!hasSelectedBatchUsage" :padding-x="8" small class="text-base" :loading="isProcessing" @click="mapBatchDetails">Apply</ui-btn>
|
||||||
@ -55,71 +70,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-center flex-wrap">
|
<div class="flex justify-center flex-wrap">
|
||||||
<template v-for="audiobook in audiobookCopies">
|
<template v-for="libraryItem in libraryItemCopies">
|
||||||
<div :key="audiobook.id" class="w-full max-w-3xl border border-black-300 p-6 -ml-px -mt-px flex">
|
<div :key="libraryItem.id" class="w-full max-w-3xl border border-black-300 p-6 -ml-px -mt-px">
|
||||||
<div class="w-32">
|
<widgets-item-details-edit :ref="`itemForm-${libraryItem.id}`" :library-item="libraryItem" />
|
||||||
<covers-book-cover :audiobook="audiobook.originalAudiobook" :width="120" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
|
||||||
</div>
|
|
||||||
<div class="flex-grow pl-4">
|
|
||||||
<ui-text-input-with-label v-model="audiobook.book.title" label="Title" />
|
|
||||||
|
|
||||||
<ui-text-input-with-label v-model="audiobook.book.subtitle" label="Subtitle" class="mt-2" />
|
|
||||||
|
|
||||||
<div class="flex mt-2 -mx-1">
|
|
||||||
<div class="w-3/4 px-1">
|
|
||||||
<ui-text-input-with-label v-model="audiobook.book.author" label="Author" />
|
|
||||||
</div>
|
|
||||||
<div class="flex-grow px-1">
|
|
||||||
<ui-text-input-with-label v-model="audiobook.book.publishYear" type="number" label="Publish Year" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex mt-2 -mx-1">
|
|
||||||
<div class="w-3/4 px-1">
|
|
||||||
<ui-input-dropdown v-model="audiobook.book.series" label="Series" :items="seriesItems" @input="seriesChanged" @newItem="newSeriesItem" />
|
|
||||||
</div>
|
|
||||||
<div class="flex-grow px-1">
|
|
||||||
<ui-text-input-with-label v-model="audiobook.book.volumeNumber" label="Volume #" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ui-textarea-with-label v-model="audiobook.book.description" :rows="3" label="Description" class="mt-2" />
|
|
||||||
|
|
||||||
<div class="flex mt-2 -mx-1">
|
|
||||||
<div class="w-1/2 px-1">
|
|
||||||
<ui-multi-select v-model="audiobook.book.genres" label="Genres" :items="genreItems" @newItem="newGenreItem" @removedItem="removedGenreItem" />
|
|
||||||
</div>
|
|
||||||
<div class="flex-grow px-1">
|
|
||||||
<ui-multi-select v-model="audiobook.tags" label="Tags" :items="tagItems" @newItem="newTagItem" @removedItem="removedTagItem" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- <div class="flex mt-2 -mx-1">
|
|
||||||
<div class="w-1/2 px-1">
|
|
||||||
<ui-text-input-with-label v-model="audiobook.book.narrator" label="Narrator" />
|
|
||||||
</div>
|
|
||||||
</div> -->
|
|
||||||
<div class="flex mt-2 -mx-1">
|
|
||||||
<div class="w-1/3 px-1">
|
|
||||||
<ui-text-input-with-label v-model="audiobook.book.narrator" label="Narrator" />
|
|
||||||
</div>
|
|
||||||
<div class="w-1/3 px-1">
|
|
||||||
<ui-text-input-with-label v-model="audiobook.book.publisher" label="Publisher" />
|
|
||||||
</div>
|
|
||||||
<div class="flex-grow px-1">
|
|
||||||
<ui-text-input-with-label v-model="audiobook.book.language" label="Language" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex mt-2 -mx-1">
|
|
||||||
<div class="w-1/3 px-1">
|
|
||||||
<ui-text-input-with-label v-model="audiobook.book.isbn" label="ISBN" />
|
|
||||||
</div>
|
|
||||||
<div class="w-1/3 px-1">
|
|
||||||
<ui-text-input-with-label v-model="audiobook.book.asin" label="ASIN" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@ -127,7 +80,7 @@
|
|||||||
<ui-loading-indicator />
|
<ui-loading-indicator />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div :class="isScrollable ? 'fixed left-0 box-shadow-lg-up bg-primary' : ''" class="w-full h-20 px-4 flex items-center border-t border-bg z-40" :style="{ bottom: streamAudiobook ? '165px' : '0px' }">
|
<div :class="isScrollable ? 'fixed left-0 box-shadow-lg-up bg-primary' : ''" class="w-full h-20 px-4 flex items-center border-t border-bg z-40" :style="{ bottom: streamLibraryItem ? '165px' : '0px' }">
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<ui-btn color="success" :padding-x="8" class="text-lg" :loading="isProcessing" @click.prevent="saveClick">Save</ui-btn>
|
<ui-btn color="success" :padding-x="8" class="text-lg" :loading="isProcessing" @click.prevent="saveClick">Save</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
@ -137,47 +90,50 @@
|
|||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
async asyncData({ store, redirect, app }) {
|
async asyncData({ store, redirect, app }) {
|
||||||
if (!store.state.selectedAudiobooks.length) {
|
if (!store.state.selectedLibraryItems.length) {
|
||||||
return redirect('/')
|
return redirect('/')
|
||||||
}
|
}
|
||||||
var audiobooks = await app.$axios.$post(`/api/books/batch/get`, { books: store.state.selectedAudiobooks }).catch((error) => {
|
var libraryItems = await app.$axios.$post(`/api/items/batch/get`, { libraryItemIds: store.state.selectedLibraryItems }).catch((error) => {
|
||||||
var errorMsg = error.response.data || 'Failed to get audiobooks'
|
var errorMsg = error.response.data || 'Failed to get items'
|
||||||
console.error(errorMsg, error)
|
console.error(errorMsg, error)
|
||||||
return []
|
return []
|
||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
audiobooks
|
libraryItems
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
isProcessing: false,
|
isProcessing: false,
|
||||||
audiobookCopies: [],
|
libraryItemCopies: [],
|
||||||
isScrollable: false,
|
isScrollable: false,
|
||||||
newSeriesItems: [],
|
newSeriesNames: [],
|
||||||
newTagItems: [],
|
newTagItems: [],
|
||||||
newGenreItems: [],
|
newGenreItems: [],
|
||||||
|
newNarratorItems: [],
|
||||||
batchDetails: {
|
batchDetails: {
|
||||||
subtitle: null,
|
subtitle: null,
|
||||||
author: null,
|
authors: null,
|
||||||
publishYear: null,
|
publishYear: null,
|
||||||
series: null,
|
series: [],
|
||||||
genres: [],
|
genres: [],
|
||||||
tags: [],
|
tags: [],
|
||||||
narrator: null,
|
narrators: [],
|
||||||
publisher: null,
|
publisher: null,
|
||||||
language: null
|
language: null,
|
||||||
|
explicit: false
|
||||||
},
|
},
|
||||||
selectedBatchUsage: {
|
selectedBatchUsage: {
|
||||||
subtitle: false,
|
subtitle: false,
|
||||||
author: false,
|
authors: false,
|
||||||
publishYear: false,
|
publishYear: false,
|
||||||
series: false,
|
series: false,
|
||||||
genres: false,
|
genres: false,
|
||||||
tags: false,
|
tags: false,
|
||||||
narrator: false,
|
narrators: false,
|
||||||
publisher: false,
|
publisher: false,
|
||||||
language: false
|
language: false,
|
||||||
|
explicit: false
|
||||||
},
|
},
|
||||||
openMapOptions: false
|
openMapOptions: false
|
||||||
}
|
}
|
||||||
@ -189,8 +145,8 @@ export default {
|
|||||||
bookCoverAspectRatio() {
|
bookCoverAspectRatio() {
|
||||||
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE ? 1 : 1.6
|
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE ? 1 : 1.6
|
||||||
},
|
},
|
||||||
streamAudiobook() {
|
streamLibraryItem() {
|
||||||
return this.$store.state.streamAudiobook
|
return this.$store.state.streamLibraryItem
|
||||||
},
|
},
|
||||||
genreItems() {
|
genreItems() {
|
||||||
return this.genres.concat(this.newGenreItems)
|
return this.genres.concat(this.newGenreItems)
|
||||||
@ -199,7 +155,10 @@ export default {
|
|||||||
return this.tags.concat(this.newTagItems)
|
return this.tags.concat(this.newTagItems)
|
||||||
},
|
},
|
||||||
seriesItems() {
|
seriesItems() {
|
||||||
return [...this.series, ...this.newSeriesItems]
|
return [...this.existingSeriesNames, ...this.newSeriesNames]
|
||||||
|
},
|
||||||
|
narratorItems() {
|
||||||
|
return [...this.narrators, ...this.newNarratorItems]
|
||||||
},
|
},
|
||||||
genres() {
|
genres() {
|
||||||
return this.filterData.genres || []
|
return this.filterData.genres || []
|
||||||
@ -210,6 +169,15 @@ export default {
|
|||||||
series() {
|
series() {
|
||||||
return this.filterData.series || []
|
return this.filterData.series || []
|
||||||
},
|
},
|
||||||
|
narrators() {
|
||||||
|
return this.filterData.narrators || []
|
||||||
|
},
|
||||||
|
authors() {
|
||||||
|
return this.filterData.authors || []
|
||||||
|
},
|
||||||
|
existingSeriesNames() {
|
||||||
|
return this.series.map((se) => se.name)
|
||||||
|
},
|
||||||
filterData() {
|
filterData() {
|
||||||
return this.$store.state.libraries.filterData || {}
|
return this.$store.state.libraries.filterData || {}
|
||||||
},
|
},
|
||||||
@ -222,8 +190,14 @@ export default {
|
|||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
blurBatchForm() {
|
blurBatchForm() {
|
||||||
if (this.$refs.seriesDropdown && this.$refs.seriesDropdown.isFocused) {
|
if (this.$refs.seriesSelect && this.$refs.seriesSelect.isFocused) {
|
||||||
this.$refs.seriesDropdown.blur()
|
this.$refs.seriesSelect.forceBlur()
|
||||||
|
}
|
||||||
|
if (this.$refs.authorsSelect && this.$refs.authorsSelect.isFocused) {
|
||||||
|
this.$refs.authorsSelect.forceBlur()
|
||||||
|
}
|
||||||
|
if (this.$refs.narratorsSelect && this.$refs.narratorsSelect.isFocused) {
|
||||||
|
this.$refs.narratorsSelect.forceBlur()
|
||||||
}
|
}
|
||||||
if (this.$refs.genresSelect && this.$refs.genresSelect.isFocused) {
|
if (this.$refs.genresSelect && this.$refs.genresSelect.isFocused) {
|
||||||
this.$refs.genresSelect.forceBlur()
|
this.$refs.genresSelect.forceBlur()
|
||||||
@ -241,69 +215,92 @@ export default {
|
|||||||
mapBatchDetails() {
|
mapBatchDetails() {
|
||||||
this.blurBatchForm()
|
this.blurBatchForm()
|
||||||
|
|
||||||
this.audiobookCopies = this.audiobookCopies.map((ab) => {
|
var batchMapPayload = {}
|
||||||
for (const key in this.selectedBatchUsage) {
|
for (const key in this.selectedBatchUsage) {
|
||||||
if (this.selectedBatchUsage[key]) {
|
if (this.selectedBatchUsage[key]) {
|
||||||
if (key === 'tags') {
|
if (key === 'series') {
|
||||||
ab.tags = this.batchDetails.tags
|
// Map string of series to series objects
|
||||||
} else {
|
batchMapPayload[key] = this.batchDetails[key].map((seItem) => {
|
||||||
ab.book[key] = this.batchDetails[key]
|
var existingSeries = this.series.find((se) => se.name.toLowerCase() === seItem.toLowerCase().trim())
|
||||||
}
|
if (existingSeries) {
|
||||||
|
return existingSeries
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
id: `new-${Math.floor(Math.random() * 10000)}`,
|
||||||
|
name: seItem
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
batchMapPayload[key] = this.batchDetails[key]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ab
|
}
|
||||||
|
|
||||||
|
this.libraryItemCopies.forEach((li) => {
|
||||||
|
var ref = this.getEditFormRef(li.id)
|
||||||
|
ref.mapBatchDetails(batchMapPayload)
|
||||||
})
|
})
|
||||||
this.$toast.success('Details mapped')
|
this.$toast.success('Details mapped')
|
||||||
},
|
},
|
||||||
|
newSeriesItem(item) {},
|
||||||
|
removedSeriesItem(item) {},
|
||||||
|
newNarratorItem(item) {},
|
||||||
|
removedNarratorItem(item) {},
|
||||||
newTagItem(item) {
|
newTagItem(item) {
|
||||||
if (item && !this.newTagItems.includes(item)) {
|
// if (item && !this.newTagItems.includes(item)) {
|
||||||
this.newTagItems.push(item)
|
// this.newTagItems.push(item)
|
||||||
}
|
// }
|
||||||
},
|
},
|
||||||
removedTagItem(item) {
|
removedTagItem(item) {
|
||||||
// If newly added, remove if not used on any other audiobooks
|
// If newly added, remove if not used on any other items
|
||||||
if (item && this.newTagItems.includes(item)) {
|
// if (item && this.newTagItems.includes(item)) {
|
||||||
var usedByOtherAb = this.audiobookCopies.find((ab) => {
|
// var usedByOtherAb = this.libraryItemCopies.find((ab) => {
|
||||||
return ab.tags && ab.tags.includes(item)
|
// return ab.tags && ab.tags.includes(item)
|
||||||
})
|
// })
|
||||||
if (!usedByOtherAb) {
|
// if (!usedByOtherAb) {
|
||||||
this.newTagItems = this.newTagItems.filter((t) => t !== item)
|
// this.newTagItems = this.newTagItems.filter((t) => t !== item)
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
},
|
},
|
||||||
newGenreItem(item) {
|
newGenreItem(item) {
|
||||||
if (item && !this.newGenreItems.includes(item)) {
|
// if (item && !this.newGenreItems.includes(item)) {
|
||||||
this.newGenreItems.push(item)
|
// this.newGenreItems.push(item)
|
||||||
}
|
// }
|
||||||
},
|
},
|
||||||
removedGenreItem(item) {
|
removedGenreItem(item) {
|
||||||
// If newly added, remove if not used on any other audiobooks
|
// If newly added, remove if not used on any other items
|
||||||
if (item && this.newGenreItems.includes(item)) {
|
// if (item && this.newGenreItems.includes(item)) {
|
||||||
var usedByOtherAb = this.audiobookCopies.find((ab) => {
|
// var usedByOtherAb = this.libraryItemCopies.find((ab) => {
|
||||||
return ab.book.genres && ab.book.genres.includes(item)
|
// return ab.book.genres && ab.book.genres.includes(item)
|
||||||
})
|
// })
|
||||||
if (!usedByOtherAb) {
|
// if (!usedByOtherAb) {
|
||||||
this.newGenreItems = this.newGenreItems.filter((t) => t !== item)
|
// this.newGenreItems = this.newGenreItems.filter((t) => t !== item)
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
},
|
|
||||||
newSeriesItem(item) {
|
|
||||||
if (item && !this.newSeriesItems.includes(item)) {
|
|
||||||
this.newSeriesItems.push(item)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
seriesChanged() {
|
|
||||||
this.newSeriesItems = this.newSeriesItems.filter((item) => {
|
|
||||||
return this.audiobookCopies.find((ab) => ab.book.series === item)
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
init() {
|
init() {
|
||||||
this.audiobookCopies = this.audiobooks.map((ab) => {
|
// TODO: Better deep cloning of library items
|
||||||
var copy = { ...ab }
|
this.libraryItemCopies = this.libraryItems.map((li) => {
|
||||||
copy.tags = [...ab.tags]
|
var copy = {
|
||||||
copy.book = { ...ab.book }
|
...li
|
||||||
copy.book.genres = [...ab.book.genres]
|
}
|
||||||
copy.originalAudiobook = ab
|
copy.media = { ...li.media }
|
||||||
|
if (copy.media.tags) copy.media.tags = [...copy.media.tags]
|
||||||
|
copy.media.metadata = { ...copy.media.metadata }
|
||||||
|
if (copy.media.metadata.authors) {
|
||||||
|
copy.media.metadata.authors = copy.media.metadata.authors.map((au) => ({ ...au }))
|
||||||
|
}
|
||||||
|
if (copy.media.metadata.series) {
|
||||||
|
copy.media.metadata.series = copy.media.metadata.series.map((se) => ({ ...se }))
|
||||||
|
}
|
||||||
|
if (copy.media.metadata.narrators) {
|
||||||
|
copy.media.metadata.narrators = [...copy.media.metadata.narrators]
|
||||||
|
}
|
||||||
|
if (copy.media.metadata.genres) {
|
||||||
|
copy.media.metadata.genres = [...copy.media.metadata.genres]
|
||||||
|
}
|
||||||
|
copy.originalLibraryItem = li
|
||||||
return copy
|
return copy
|
||||||
})
|
})
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
@ -312,46 +309,23 @@ export default {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
compareStringArrays(arr1, arr2) {
|
getEditFormRef(itemId) {
|
||||||
if (!arr1 || !arr2) return false
|
var refs = this.$refs[`itemForm-${itemId}`]
|
||||||
return arr1.join(',') !== arr2.join(',')
|
if (refs && refs.length) return refs[0]
|
||||||
},
|
return null
|
||||||
compareAudiobooks(newAb, origAb) {
|
|
||||||
const bookKeysToCheck = ['title', 'subtitle', 'narrator', 'author', 'publishYear', 'series', 'volumeNumber', 'description', 'language', 'publisher', 'isbn', 'asin']
|
|
||||||
var newBook = newAb.book
|
|
||||||
var origBook = origAb.book
|
|
||||||
var diffObj = {}
|
|
||||||
for (const key in newBook) {
|
|
||||||
if (bookKeysToCheck.includes(key)) {
|
|
||||||
if (newBook[key] !== origBook[key]) {
|
|
||||||
if (!diffObj.book) diffObj.book = {}
|
|
||||||
diffObj.book[key] = newBook[key]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (key === 'genres') {
|
|
||||||
if (this.compareStringArrays(newBook[key], origBook[key])) {
|
|
||||||
if (!diffObj.book) diffObj.book = {}
|
|
||||||
diffObj.book[key] = newBook[key]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (newAb.tags && origAb.tags && newAb.tags.join(',') !== origAb.tags.join(',')) {
|
|
||||||
diffObj.tags = newAb.tags
|
|
||||||
}
|
|
||||||
return diffObj
|
|
||||||
},
|
},
|
||||||
saveClick() {
|
saveClick() {
|
||||||
var updates = []
|
var updates = []
|
||||||
for (let i = 0; i < this.audiobookCopies.length; i++) {
|
for (let i = 0; i < this.libraryItemCopies.length; i++) {
|
||||||
var ab = { ...this.audiobookCopies[i] }
|
var editForm = this.getEditFormRef(this.libraryItemCopies[i].id)
|
||||||
var origAb = ab.originalAudiobook
|
if (!editForm) {
|
||||||
delete ab.originalAudiobook
|
throw new Error('Invalid edit form ref not found')
|
||||||
|
}
|
||||||
var res = this.compareAudiobooks(ab, origAb)
|
var details = editForm.getDetails()
|
||||||
if (res && Object.keys(res).length) {
|
if (details.hasChanges) {
|
||||||
updates.push({
|
updates.push({
|
||||||
id: ab.id,
|
id: this.libraryItemCopies[i].id,
|
||||||
updates: res
|
mediaPayload: details.updatePayload
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -362,11 +336,11 @@ export default {
|
|||||||
console.log('Pushing updates', updates)
|
console.log('Pushing updates', updates)
|
||||||
this.isProcessing = true
|
this.isProcessing = true
|
||||||
this.$axios
|
this.$axios
|
||||||
.$post('/api/books/batch/update', updates)
|
.$post('/api/items/batch/update', updates)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
this.isProcessing = false
|
this.isProcessing = false
|
||||||
if (data.updates) {
|
if (data.updates) {
|
||||||
this.$toast.success(`Successfully updated ${data.updates} audiobooks`)
|
this.$toast.success(`Successfully updated ${data.updates} items`)
|
||||||
this.$router.replace(`/library/${this.currentLibraryId}/bookshelf`)
|
this.$router.replace(`/library/${this.currentLibraryId}/bookshelf`)
|
||||||
} else {
|
} else {
|
||||||
this.$toast.warning('No updates were necessary')
|
this.$toast.warning('No updates were necessary')
|
||||||
@ -377,11 +351,6 @@ export default {
|
|||||||
this.$toast.error('Failed to batch update')
|
this.$toast.error('Failed to batch update')
|
||||||
this.isProcessing = false
|
this.isProcessing = false
|
||||||
})
|
})
|
||||||
},
|
|
||||||
applyBatchUpdates() {
|
|
||||||
this.audiobookCopies = this.audiobookCopies.map((ab) => {
|
|
||||||
if (this.batchDetails.series) ab.book.series = this.batchDetails.series
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
@ -12,7 +12,7 @@ export const state = () => ({
|
|||||||
selectedLibraryItem: null,
|
selectedLibraryItem: null,
|
||||||
selectedAudiobookFile: null,
|
selectedAudiobookFile: null,
|
||||||
developerMode: false,
|
developerMode: false,
|
||||||
selectedAudiobooks: [],
|
selectedLibraryItems: [],
|
||||||
processingBatch: false,
|
processingBatch: false,
|
||||||
previousPath: '/',
|
previousPath: '/',
|
||||||
routeHistory: [],
|
routeHistory: [],
|
||||||
@ -25,8 +25,8 @@ export const state = () => ({
|
|||||||
})
|
})
|
||||||
|
|
||||||
export const getters = {
|
export const getters = {
|
||||||
getIsAudiobookSelected: state => audiobookId => {
|
getIsLibraryItemSelected: state => libraryItemId => {
|
||||||
return !!state.selectedAudiobooks.includes(audiobookId)
|
return !!state.selectedLibraryItems.includes(libraryItemId)
|
||||||
},
|
},
|
||||||
getServerSetting: state => key => {
|
getServerSetting: state => key => {
|
||||||
if (!state.serverSettings) return null
|
if (!state.serverSettings) return null
|
||||||
@ -36,7 +36,7 @@ export const getters = {
|
|||||||
if (!state.serverSettings || !state.serverSettings.coverAspectRatio) return 1.6
|
if (!state.serverSettings || !state.serverSettings.coverAspectRatio) return 1.6
|
||||||
return state.serverSettings.coverAspectRatio === 0 ? 1.6 : 1
|
return state.serverSettings.coverAspectRatio === 0 ? 1.6 : 1
|
||||||
},
|
},
|
||||||
getNumAudiobooksSelected: state => state.selectedAudiobooks.length,
|
getNumLibraryItemsSelected: state => state.selectedLibraryItems.length,
|
||||||
getLibraryItemIdStreaming: state => {
|
getLibraryItemIdStreaming: state => {
|
||||||
return state.streamLibraryItem ? state.streamLibraryItem.id : null
|
return state.streamLibraryItem ? state.streamLibraryItem.id : null
|
||||||
}
|
}
|
||||||
@ -146,24 +146,24 @@ export const mutations = {
|
|||||||
setSelectedLibraryItem(state, val) {
|
setSelectedLibraryItem(state, val) {
|
||||||
Vue.set(state, 'selectedLibraryItem', val)
|
Vue.set(state, 'selectedLibraryItem', val)
|
||||||
},
|
},
|
||||||
setSelectedAudiobooks(state, audiobooks) {
|
setSelectedLibraryItems(state, items) {
|
||||||
Vue.set(state, 'selectedAudiobooks', audiobooks)
|
Vue.set(state, 'selectedLibraryItems', items)
|
||||||
},
|
},
|
||||||
toggleAudiobookSelected(state, audiobookId) {
|
toggleLibraryItemSelected(state, itemId) {
|
||||||
if (state.selectedAudiobooks.includes(audiobookId)) {
|
if (state.selectedLibraryItems.includes(itemId)) {
|
||||||
state.selectedAudiobooks = state.selectedAudiobooks.filter(a => a !== audiobookId)
|
state.selectedLibraryItems = state.selectedLibraryItems.filter(a => a !== itemId)
|
||||||
} else {
|
} else {
|
||||||
var newSel = state.selectedAudiobooks.concat([audiobookId])
|
var newSel = state.selectedLibraryItems.concat([itemId])
|
||||||
Vue.set(state, 'selectedAudiobooks', newSel)
|
Vue.set(state, 'selectedLibraryItems', newSel)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
setAudiobookSelected(state, { audiobookId, selected }) {
|
setLibraryItemSelected(state, { libraryItemId, selected }) {
|
||||||
var isThere = state.selectedAudiobooks.includes(audiobookId)
|
var isThere = state.selectedLibraryItems.includes(libraryItemId)
|
||||||
if (isThere && !selected) {
|
if (isThere && !selected) {
|
||||||
state.selectedAudiobooks = state.selectedAudiobooks.filter(a => a !== audiobookId)
|
state.selectedLibraryItems = state.selectedLibraryItems.filter(a => a !== libraryItemId)
|
||||||
} else if (selected && !isThere) {
|
} else if (selected && !isThere) {
|
||||||
var newSel = state.selectedAudiobooks.concat([audiobookId])
|
var newSel = state.selectedLibraryItems.concat([libraryItemId])
|
||||||
Vue.set(state, 'selectedAudiobooks', newSel)
|
Vue.set(state, 'selectedLibraryItems', newSel)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
setProcessingBatch(state, val) {
|
setProcessingBatch(state, val) {
|
||||||
|
@ -21,6 +21,9 @@ const AuthorController = require('./controllers/AuthorController')
|
|||||||
const BookFinder = require('./finders/BookFinder')
|
const BookFinder = require('./finders/BookFinder')
|
||||||
const AuthorFinder = require('./finders/AuthorFinder')
|
const AuthorFinder = require('./finders/AuthorFinder')
|
||||||
const PodcastFinder = require('./finders/PodcastFinder')
|
const PodcastFinder = require('./finders/PodcastFinder')
|
||||||
|
|
||||||
|
const Author = require('./objects/entities/Author')
|
||||||
|
const Series = require('./objects/entities/Series')
|
||||||
const FileSystemController = require('./controllers/FileSystemController')
|
const FileSystemController = require('./controllers/FileSystemController')
|
||||||
|
|
||||||
class ApiController {
|
class ApiController {
|
||||||
@ -65,11 +68,8 @@ class ApiController {
|
|||||||
this.router.get('/libraries/:id/stats', LibraryController.middleware.bind(this), LibraryController.stats.bind(this))
|
this.router.get('/libraries/:id/stats', LibraryController.middleware.bind(this), LibraryController.stats.bind(this))
|
||||||
this.router.get('/libraries/:id/authors', LibraryController.middleware.bind(this), LibraryController.getAuthors.bind(this))
|
this.router.get('/libraries/:id/authors', LibraryController.middleware.bind(this), LibraryController.getAuthors.bind(this))
|
||||||
this.router.post('/libraries/:id/matchbooks', LibraryController.middleware.bind(this), LibraryController.matchBooks.bind(this))
|
this.router.post('/libraries/:id/matchbooks', LibraryController.middleware.bind(this), LibraryController.matchBooks.bind(this))
|
||||||
this.router.post('/libraries/order', LibraryController.reorder.bind(this))
|
|
||||||
|
|
||||||
// Legacy
|
this.router.post('/libraries/order', LibraryController.reorder.bind(this))
|
||||||
this.router.get('/libraries/:id/categories', LibraryController.middleware.bind(this), LibraryController.getLibraryCategories.bind(this))
|
|
||||||
this.router.get('/libraries/:id/filters', LibraryController.middleware.bind(this), LibraryController.getLibraryFilters.bind(this))
|
|
||||||
|
|
||||||
//
|
//
|
||||||
// Item Routes
|
// Item Routes
|
||||||
@ -84,6 +84,10 @@ class ApiController {
|
|||||||
this.router.delete('/items/:id/cover', LibraryItemController.middleware.bind(this), LibraryItemController.removeCover.bind(this))
|
this.router.delete('/items/:id/cover', LibraryItemController.middleware.bind(this), LibraryItemController.removeCover.bind(this))
|
||||||
this.router.get('/items/:id/stream', LibraryItemController.middleware.bind(this), LibraryItemController.openStream.bind(this))
|
this.router.get('/items/:id/stream', LibraryItemController.middleware.bind(this), LibraryItemController.openStream.bind(this))
|
||||||
|
|
||||||
|
this.router.post('/items/batch/delete', LibraryItemController.batchDelete.bind(this))
|
||||||
|
this.router.post('/items/batch/update', LibraryItemController.batchUpdate.bind(this))
|
||||||
|
this.router.post('/items/batch/get', LibraryItemController.batchGet.bind(this))
|
||||||
|
|
||||||
//
|
//
|
||||||
// Book Routes
|
// Book Routes
|
||||||
//
|
//
|
||||||
@ -93,9 +97,6 @@ class ApiController {
|
|||||||
this.router.delete('/books/:id', BookController.delete.bind(this))
|
this.router.delete('/books/:id', BookController.delete.bind(this))
|
||||||
|
|
||||||
this.router.delete('/books/all', BookController.deleteAll.bind(this))
|
this.router.delete('/books/all', BookController.deleteAll.bind(this))
|
||||||
this.router.post('/books/batch/delete', BookController.batchDelete.bind(this))
|
|
||||||
this.router.post('/books/batch/update', BookController.batchUpdate.bind(this))
|
|
||||||
this.router.post('/books/batch/get', BookController.batchGet.bind(this))
|
|
||||||
this.router.patch('/books/:id/tracks', BookController.updateTracks.bind(this))
|
this.router.patch('/books/:id/tracks', BookController.updateTracks.bind(this))
|
||||||
this.router.get('/books/:id/stream', BookController.openStream.bind(this))
|
this.router.get('/books/:id/stream', BookController.openStream.bind(this))
|
||||||
this.router.post('/books/:id/cover', BookController.uploadCover.bind(this))
|
this.router.post('/books/:id/cover', BookController.uploadCover.bind(this))
|
||||||
@ -162,17 +163,16 @@ class ApiController {
|
|||||||
//
|
//
|
||||||
// Author Routes
|
// Author Routes
|
||||||
//
|
//
|
||||||
|
this.router.get('/authors/search', AuthorController.search.bind(this))
|
||||||
this.router.get('/authors/:id', AuthorController.middleware.bind(this), AuthorController.findOne.bind(this))
|
this.router.get('/authors/:id', AuthorController.middleware.bind(this), AuthorController.findOne.bind(this))
|
||||||
this.router.post('/authors/:id/match', AuthorController.middleware.bind(this), AuthorController.match.bind(this))
|
this.router.post('/authors/:id/match', AuthorController.middleware.bind(this), AuthorController.match.bind(this))
|
||||||
this.router.get('/authors/:id/image', AuthorController.middleware.bind(this), AuthorController.getImage.bind(this))
|
this.router.get('/authors/:id/image', AuthorController.middleware.bind(this), AuthorController.getImage.bind(this))
|
||||||
this.router.get('/authors/search', AuthorController.search.bind(this))
|
|
||||||
|
|
||||||
//
|
//
|
||||||
// Series Routes
|
// Series Routes
|
||||||
//
|
//
|
||||||
this.router.get('/series/:id', SeriesController.middleware.bind(this), SeriesController.findOne.bind(this))
|
|
||||||
this.router.get('/series/search', SeriesController.search.bind(this))
|
this.router.get('/series/search', SeriesController.search.bind(this))
|
||||||
|
this.router.get('/series/:id', SeriesController.middleware.bind(this), SeriesController.findOne.bind(this))
|
||||||
|
|
||||||
//
|
//
|
||||||
// Misc Routes
|
// Misc Routes
|
||||||
@ -488,6 +488,60 @@ class ApiController {
|
|||||||
res.sendStatus(200)
|
res.sendStatus(200)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async createAuthorsAndSeriesForItemUpdate(mediaPayload) {
|
||||||
|
if (mediaPayload.metadata) {
|
||||||
|
var mediaMetadata = mediaPayload.metadata
|
||||||
|
|
||||||
|
// Create new authors if in payload
|
||||||
|
if (mediaMetadata.authors && mediaMetadata.authors.length) {
|
||||||
|
// TODO: validate authors
|
||||||
|
var newAuthors = []
|
||||||
|
for (let i = 0; i < mediaMetadata.authors.length; i++) {
|
||||||
|
if (mediaMetadata.authors[i].id.startsWith('new')) {
|
||||||
|
var author = this.db.authors.find(au => au.checkNameEquals(mediaMetadata.authors[i].name))
|
||||||
|
if (!author) {
|
||||||
|
author = new Author()
|
||||||
|
author.setData(mediaMetadata.authors[i])
|
||||||
|
Logger.debug(`[ApiController] Created new author "${author.name}"`)
|
||||||
|
newAuthors.push(author)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update ID in original payload
|
||||||
|
mediaMetadata.authors[i].id = author.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (newAuthors.length) {
|
||||||
|
await this.db.insertEntities('author', newAuthors)
|
||||||
|
this.emitter('authors_added', newAuthors)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new series if in payload
|
||||||
|
if (mediaMetadata.series && mediaMetadata.series.length) {
|
||||||
|
// TODO: validate series
|
||||||
|
var newSeries = []
|
||||||
|
for (let i = 0; i < mediaMetadata.series.length; i++) {
|
||||||
|
if (mediaMetadata.series[i].id.startsWith('new')) {
|
||||||
|
var seriesItem = this.db.series.find(se => se.checkNameEquals(mediaMetadata.series[i].name))
|
||||||
|
if (!seriesItem) {
|
||||||
|
seriesItem = new Series()
|
||||||
|
seriesItem.setData(mediaMetadata.series[i])
|
||||||
|
Logger.debug(`[ApiController] Created new series "${seriesItem.name}"`)
|
||||||
|
newSeries.push(seriesItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update ID in original payload
|
||||||
|
mediaMetadata.series[i].id = seriesItem.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (newSeries.length) {
|
||||||
|
await this.db.insertEntities('series', newSeries)
|
||||||
|
this.emitter('authors_added', newSeries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
getPodcastFeed(req, res) {
|
getPodcastFeed(req, res) {
|
||||||
var url = req.body.rssFeed
|
var url = req.body.rssFeed
|
||||||
if (!url) {
|
if (!url) {
|
||||||
|
@ -226,14 +226,6 @@ class LibraryController {
|
|||||||
res.json(payload)
|
res.json(payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
// LEGACY
|
|
||||||
// api/libraries/:id/books/filters
|
|
||||||
async getLibraryFilters(req, res) {
|
|
||||||
var library = req.library
|
|
||||||
var books = this.db.audiobooks.filter(ab => ab.libraryId === library.id)
|
|
||||||
res.json(libraryHelpers.getDistinctFilterData(books))
|
|
||||||
}
|
|
||||||
|
|
||||||
async getLibraryFilterData(req, res) {
|
async getLibraryFilterData(req, res) {
|
||||||
res.json(libraryHelpers.getDistinctFilterDataNew(req.libraryItems))
|
res.json(libraryHelpers.getDistinctFilterDataNew(req.libraryItems))
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,4 @@
|
|||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
const Author = require('../objects/entities/Author')
|
|
||||||
const Series = require('../objects/entities/Series')
|
|
||||||
const { reqSupportsWebp } = require('../utils/index')
|
const { reqSupportsWebp } = require('../utils/index')
|
||||||
|
|
||||||
class LibraryItemController {
|
class LibraryItemController {
|
||||||
@ -43,49 +41,7 @@ class LibraryItemController {
|
|||||||
await this.cacheManager.purgeCoverCache(libraryItem.id)
|
await this.cacheManager.purgeCoverCache(libraryItem.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mediaPayload.metadata) {
|
await this.createAuthorsAndSeriesForItemUpdate(mediaPayload)
|
||||||
var mediaMetadata = mediaPayload.metadata
|
|
||||||
|
|
||||||
// Create new authors if in payload
|
|
||||||
if (mediaMetadata.authors && mediaMetadata.authors.length) {
|
|
||||||
// TODO: validate authors
|
|
||||||
var newAuthors = []
|
|
||||||
for (let i = 0; i < mediaMetadata.authors.length; i++) {
|
|
||||||
if (mediaMetadata.authors[i].id.startsWith('new')) {
|
|
||||||
var newAuthor = new Author()
|
|
||||||
newAuthor.setData(mediaMetadata.authors[i])
|
|
||||||
Logger.debug(`[LibraryItemController] Created new author "${newAuthor.name}"`)
|
|
||||||
newAuthors.push(newAuthor)
|
|
||||||
// Update ID in original payload
|
|
||||||
mediaMetadata.authors[i].id = newAuthor.id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (newAuthors.length) {
|
|
||||||
await this.db.insertEntities('author', newAuthors)
|
|
||||||
this.emitter('authors_added', newAuthors)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create new series if in payload
|
|
||||||
if (mediaMetadata.series && mediaMetadata.series.length) {
|
|
||||||
// TODO: validate series
|
|
||||||
var newSeries = []
|
|
||||||
for (let i = 0; i < mediaMetadata.series.length; i++) {
|
|
||||||
if (mediaMetadata.series[i].id.startsWith('new')) {
|
|
||||||
var newSeriesItem = new Series()
|
|
||||||
newSeriesItem.setData(mediaMetadata.series[i])
|
|
||||||
Logger.debug(`[LibraryItemController] Created new series "${newSeriesItem.name}"`)
|
|
||||||
newSeries.push(newSeriesItem)
|
|
||||||
// Update ID in original payload
|
|
||||||
mediaMetadata.series[i].id = newSeriesItem.id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (newSeries.length) {
|
|
||||||
await this.db.insertEntities('series', newSeries)
|
|
||||||
this.emitter('authors_added', newSeries)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var hasUpdates = libraryItem.media.update(mediaPayload)
|
var hasUpdates = libraryItem.media.update(mediaPayload)
|
||||||
if (hasUpdates) {
|
if (hasUpdates) {
|
||||||
@ -182,11 +138,76 @@ class LibraryItemController {
|
|||||||
this.streamManager.openStreamApiRequest(res, req.user, req.libraryItem)
|
this.streamManager.openStreamApiRequest(res, req.user, req.libraryItem)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// POST: api/items/batch/delete
|
||||||
|
async batchDelete(req, res) {
|
||||||
|
if (!req.user.canDelete) {
|
||||||
|
Logger.warn(`[LibraryItemController] User attempted to delete without permission`, req.user)
|
||||||
|
return res.sendStatus(403)
|
||||||
|
}
|
||||||
|
|
||||||
|
var { libraryItemIds } = req.body
|
||||||
|
if (!libraryItemIds || !libraryItemIds.length) {
|
||||||
|
return res.sendStatus(500)
|
||||||
|
}
|
||||||
|
|
||||||
|
var itemsToDelete = this.db.libraryItems.filter(li => libraryItemIds.includes(li.id))
|
||||||
|
if (!itemsToDelete.length) {
|
||||||
|
return res.sendStatus(404)
|
||||||
|
}
|
||||||
|
for (let i = 0; i < itemsToDelete.length; i++) {
|
||||||
|
Logger.info(`[LibraryItemController] Deleting Library Item "${itemsToDelete[i].media.metadata.title}"`)
|
||||||
|
await this.handleDeleteLibraryItem(itemsToDelete[i])
|
||||||
|
}
|
||||||
|
res.sendStatus(200)
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST: api/items/batch/update
|
||||||
|
async batchUpdate(req, res) {
|
||||||
|
var updatePayloads = req.body
|
||||||
|
if (!updatePayloads || !updatePayloads.length) {
|
||||||
|
return res.sendStatus(500)
|
||||||
|
}
|
||||||
|
|
||||||
|
var itemsUpdated = 0
|
||||||
|
|
||||||
|
for (let i = 0; i < updatePayloads.length; i++) {
|
||||||
|
var mediaPayload = updatePayloads[i].mediaPayload
|
||||||
|
var libraryItem = this.db.libraryItems.find(_li => _li.id === updatePayloads[i].id)
|
||||||
|
if (!libraryItem) return null
|
||||||
|
|
||||||
|
await this.createAuthorsAndSeriesForItemUpdate(mediaPayload)
|
||||||
|
|
||||||
|
var hasUpdates = libraryItem.media.update(mediaPayload)
|
||||||
|
if (hasUpdates) {
|
||||||
|
Logger.debug(`[LibraryItemController] Updated library item media ${libraryItem.media.metadata.title}`)
|
||||||
|
await this.db.updateLibraryItem(libraryItem)
|
||||||
|
this.emitter('item_updated', libraryItem.toJSONExpanded())
|
||||||
|
itemsUpdated++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
updates: itemsUpdated
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST: api/items/batch/get
|
||||||
|
async batchGet(req, res) {
|
||||||
|
var libraryItemIds = req.body.libraryItemIds || []
|
||||||
|
if (!libraryItemIds.length) {
|
||||||
|
return res.status(403).send('Invalid payload')
|
||||||
|
}
|
||||||
|
var libraryItems = this.db.libraryItems.filter(li => libraryItemIds.includes(li.id)).map((li) => li.toJSONExpanded())
|
||||||
|
res.json(libraryItems)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
middleware(req, res, next) {
|
middleware(req, res, next) {
|
||||||
var item = this.db.libraryItems.find(li => li.id === req.params.id)
|
var item = this.db.libraryItems.find(li => li.id === req.params.id)
|
||||||
if (!item || !item.media) return res.sendStatus(404)
|
if (!item || !item.media) return res.sendStatus(404)
|
||||||
|
|
||||||
// Check user can access this audiobooks library
|
// Check user can access this library
|
||||||
if (!req.user.checkCanAccessLibrary(item.libraryId)) {
|
if (!req.user.checkCanAccessLibrary(item.libraryId)) {
|
||||||
return res.sendStatus(403)
|
return res.sendStatus(403)
|
||||||
}
|
}
|
||||||
|
@ -133,50 +133,6 @@ module.exports = {
|
|||||||
return data
|
return data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
// TODO: Remove legacy
|
|
||||||
getDistinctFilterData(audiobooks) {
|
|
||||||
var data = {
|
|
||||||
authors: [],
|
|
||||||
genres: [],
|
|
||||||
tags: [],
|
|
||||||
series: [],
|
|
||||||
narrators: [],
|
|
||||||
languages: []
|
|
||||||
}
|
|
||||||
audiobooks.forEach((ab) => {
|
|
||||||
if (ab.book._authorsList.length) {
|
|
||||||
ab.book._authorsList.forEach((author) => {
|
|
||||||
if (author && !data.authors.includes(author)) data.authors.push(author)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (ab.book._genres.length) {
|
|
||||||
ab.book._genres.forEach((genre) => {
|
|
||||||
if (genre && !data.genres.includes(genre)) data.genres.push(genre)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (ab.tags.length) {
|
|
||||||
ab.tags.forEach((tag) => {
|
|
||||||
if (tag && !data.tags.includes(tag)) data.tags.push(tag)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (ab.book._series && !data.series.includes(ab.book._series)) data.series.push(ab.book._series)
|
|
||||||
if (ab.book._narratorsList.length) {
|
|
||||||
ab.book._narratorsList.forEach((narrator) => {
|
|
||||||
if (narrator && !data.narrators.includes(narrator)) data.narrators.push(narrator)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (ab.book._language && !data.languages.includes(ab.book._language)) data.languages.push(ab.book._language)
|
|
||||||
})
|
|
||||||
data.authors = naturalSort(data.authors).asc()
|
|
||||||
data.genres = naturalSort(data.genres).asc()
|
|
||||||
data.tags = naturalSort(data.tags).asc()
|
|
||||||
data.series = naturalSort(data.series).asc()
|
|
||||||
data.narrators = naturalSort(data.narrators).asc()
|
|
||||||
data.languages = naturalSort(data.languages).asc()
|
|
||||||
return data
|
|
||||||
},
|
|
||||||
|
|
||||||
getSeriesFromBooks(books, minified = false) {
|
getSeriesFromBooks(books, minified = false) {
|
||||||
var _series = {}
|
var _series = {}
|
||||||
books.forEach((libraryItem) => {
|
books.forEach((libraryItem) => {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user