mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-03-29 09:07:54 +01:00
New data model batch routes and batch editor
This commit is contained in:
parent
6597fca576
commit
4bdef893af
@ -44,8 +44,8 @@
|
||||
</nuxt-link>
|
||||
</div>
|
||||
|
||||
<div v-show="numAudiobooksSelected" 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>
|
||||
<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">{{ numLibraryItemsSelected }} Selected</h1>
|
||||
<div class="flex-grow" />
|
||||
<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" />
|
||||
@ -53,7 +53,7 @@
|
||||
<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-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" />
|
||||
</template>
|
||||
<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() {
|
||||
return this.user ? this.user.username : 'err'
|
||||
},
|
||||
numAudiobooksSelected() {
|
||||
return this.selectedAudiobooks.length
|
||||
numLibraryItemsSelected() {
|
||||
return this.selectedLibraryItems.length
|
||||
},
|
||||
selectedAudiobooks() {
|
||||
return this.$store.state.selectedAudiobooks
|
||||
selectedLibraryItems() {
|
||||
return this.$store.state.selectedLibraryItems
|
||||
},
|
||||
userAudiobooks() {
|
||||
return this.$store.state.user.user.audiobooks || {}
|
||||
@ -117,8 +117,8 @@ export default {
|
||||
},
|
||||
selectedIsRead() {
|
||||
// Find an audiobook that is not read, if none then all audiobooks read
|
||||
return !this.selectedAudiobooks.find((ab) => {
|
||||
var userAb = this.userAudiobooks[ab]
|
||||
return !this.selectedLibraryItems.find((li) => {
|
||||
var userAb = this.userAudiobooks[li]
|
||||
return !userAb || !userAb.isRead
|
||||
})
|
||||
},
|
||||
@ -150,16 +150,16 @@ export default {
|
||||
},
|
||||
cancelSelectionMode() {
|
||||
if (this.processingBatchDelete) return
|
||||
this.$store.commit('setSelectedAudiobooks', [])
|
||||
this.$store.commit('setSelectedLibraryItems', [])
|
||||
this.$eventBus.$emit('bookshelf-clear-selection')
|
||||
this.isAllSelected = false
|
||||
},
|
||||
toggleBatchRead() {
|
||||
this.$store.commit('setProcessingBatch', true)
|
||||
var newIsRead = !this.selectedIsRead
|
||||
var updateProgressPayloads = this.selectedAudiobooks.map((ab) => {
|
||||
var updateProgressPayloads = this.selectedLibraryItems.map((lid) => {
|
||||
return {
|
||||
audiobookId: ab,
|
||||
audiobookId: lid,
|
||||
isRead: newIsRead
|
||||
}
|
||||
})
|
||||
@ -168,7 +168,7 @@ export default {
|
||||
.then(() => {
|
||||
this.$toast.success('Batch update success!')
|
||||
this.$store.commit('setProcessingBatch', false)
|
||||
this.$store.commit('setSelectedAudiobooks', [])
|
||||
this.$store.commit('setSelectedLibraryItems', [])
|
||||
this.$eventBus.$emit('bookshelf-clear-selection')
|
||||
})
|
||||
.catch((error) => {
|
||||
@ -178,20 +178,20 @@ export default {
|
||||
})
|
||||
},
|
||||
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`
|
||||
if (confirm(confirmMsg)) {
|
||||
this.processingBatchDelete = true
|
||||
this.$store.commit('setProcessingBatch', true)
|
||||
this.$axios
|
||||
.$post(`/api/books/batch/delete`, {
|
||||
audiobookIds: this.selectedAudiobooks
|
||||
.$post(`/api/items/batch/delete`, {
|
||||
libraryItemIds: this.selectedLibraryItems
|
||||
})
|
||||
.then(() => {
|
||||
this.$toast.success('Batch delete success!')
|
||||
this.processingBatchDelete = false
|
||||
this.$store.commit('setProcessingBatch', false)
|
||||
this.$store.commit('setSelectedAudiobooks', [])
|
||||
this.$store.commit('setSelectedLibraryItems', [])
|
||||
this.$eventBus.$emit('bookshelf-clear-selection')
|
||||
})
|
||||
.catch((error) => {
|
||||
|
@ -69,16 +69,16 @@ export default {
|
||||
showExperimentalFeatures() {
|
||||
return this.$store.state.showExperimentalFeatures
|
||||
},
|
||||
audiobookId() {
|
||||
libraryItemId() {
|
||||
return this.book.id
|
||||
},
|
||||
selected: {
|
||||
get() {
|
||||
return this.$store.getters['getIsAudiobookSelected'](this.audiobookId)
|
||||
return this.$store.getters['getIsLibraryItemSelected'](this.libraryItemId)
|
||||
},
|
||||
set(val) {
|
||||
if (this.processingBatch) return
|
||||
this.$store.commit('setAudiobookSelected', { audiobookId: this.audiobookId, selected: val })
|
||||
this.$store.commit('setLibraryItemSelected', { libraryItemId: this.libraryItemId, selected: val })
|
||||
}
|
||||
},
|
||||
processingBatch() {
|
||||
@ -118,7 +118,7 @@ export default {
|
||||
return this.book.numTracks
|
||||
},
|
||||
isStreaming() {
|
||||
return this.$store.getters['getLibraryItemIdStreaming'] === this.audiobookId
|
||||
return this.$store.getters['getLibraryItemIdStreaming'] === this.libraryItemId
|
||||
},
|
||||
showReadButton() {
|
||||
return this.showExperimentalFeatures && this.numEbooks
|
||||
@ -142,7 +142,7 @@ export default {
|
||||
methods: {
|
||||
selectBtnClick() {
|
||||
if (this.processingBatch) return
|
||||
this.$store.commit('toggleAudiobookSelected', this.audiobookId)
|
||||
this.$store.commit('toggleLibraryItemSelected', this.libraryItemId)
|
||||
},
|
||||
openEbook() {
|
||||
this.$store.commit('showEReader', this.book)
|
||||
@ -156,7 +156,7 @@ export default {
|
||||
}
|
||||
this.isProcessingReadUpdate = true
|
||||
this.$axios
|
||||
.$patch(`/api/me/audiobook/${this.audiobookId}`, updatePayload)
|
||||
.$patch(`/api/me/audiobook/${this.libraryItemId}`, updatePayload)
|
||||
.then(() => {
|
||||
this.isProcessingReadUpdate = false
|
||||
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 v-if="shelf.type === 'books'" class="flex items-center">
|
||||
<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>
|
||||
</div>
|
||||
<div v-if="shelf.type === 'series'" class="flex items-center">
|
||||
@ -90,7 +90,7 @@ export default {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
isSelectionMode() {
|
||||
return this.$store.getters['getNumAudiobooksSelected'] > 0
|
||||
return this.$store.getters['getNumLibraryItemsSelected'] > 0
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@ -100,19 +100,19 @@ export default {
|
||||
this.$store.commit('showEditModal', audiobook)
|
||||
},
|
||||
updateSelectionMode(val) {
|
||||
var selectedAudiobooks = this.$store.state.selectedAudiobooks
|
||||
var selectedLibraryItems = this.$store.state.selectedLibraryItems
|
||||
if (this.shelf.type === 'books') {
|
||||
this.shelf.entities.forEach((ent) => {
|
||||
var component = this.$refs[`shelf-book-${ent.id}`]
|
||||
if (!component || !component.length) return
|
||||
component = component[0]
|
||||
component.setSelectionMode(val)
|
||||
component.selected = selectedAudiobooks.includes(ent.id)
|
||||
component.selected = selectedLibraryItems.includes(ent.id)
|
||||
})
|
||||
}
|
||||
},
|
||||
selectBook(audiobook) {
|
||||
this.$store.commit('toggleAudiobookSelected', audiobook.id)
|
||||
selectItem(libraryItem) {
|
||||
this.$store.commit('toggleLibraryItemSelected', libraryItem.id)
|
||||
},
|
||||
scrolled() {
|
||||
clearTimeout(this.scrollTimer)
|
||||
|
@ -183,8 +183,8 @@ export default {
|
||||
// Includes margin
|
||||
return this.entityWidth + 24
|
||||
},
|
||||
selectedAudiobooks() {
|
||||
return this.$store.state.selectedAudiobooks || []
|
||||
selectedLibraryItems() {
|
||||
return this.$store.state.selectedLibraryItems || []
|
||||
},
|
||||
sizeMultiplier() {
|
||||
var baseSize = this.isCoverSquareAspectRatio ? 192 : 120
|
||||
@ -214,9 +214,9 @@ export default {
|
||||
},
|
||||
selectEntity(entity) {
|
||||
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) {
|
||||
this.isSelectionMode = newIsSelectionMode
|
||||
this.updateBookSelectionMode(newIsSelectionMode)
|
||||
|
@ -101,7 +101,7 @@ export default {
|
||||
return this.$store.state.globals.showBatchUserCollectionModal
|
||||
},
|
||||
selectedBookIds() {
|
||||
return this.$store.state.selectedAudiobooks || []
|
||||
return this.$store.state.selectedLibraryItems || []
|
||||
},
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
|
@ -1,106 +1,27 @@
|
||||
<template>
|
||||
<div class="w-full h-full relative">
|
||||
<form class="w-full h-full" @submit.prevent="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>
|
||||
<widgets-item-details-edit ref="itemDetailsEdit" :library-item="libraryItem" @submit="submitForm" />
|
||||
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-3/4 px-1">
|
||||
<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="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 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>
|
||||
<div class="flex-grow" />
|
||||
|
||||
<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">
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-1/3 px-1">
|
||||
<ui-text-input-with-label v-model="details.narrator" label="Narrator" />
|
||||
</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>
|
||||
<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>
|
||||
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<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>
|
||||
<ui-btn @click="submitForm">Submit</ui-btn>
|
||||
</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>
|
||||
</template>
|
||||
@ -116,23 +37,6 @@ export default {
|
||||
},
|
||||
data() {
|
||||
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,
|
||||
isScrollable: false,
|
||||
savingMetadata: false,
|
||||
@ -140,14 +44,6 @@ export default {
|
||||
quickMatching: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
libraryItem: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
if (newVal) this.init()
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isProcessing: {
|
||||
get() {
|
||||
@ -175,18 +71,6 @@ export default {
|
||||
userCanDelete() {
|
||||
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() {
|
||||
return this.libraryItem ? this.libraryItem.libraryId : null
|
||||
},
|
||||
@ -196,71 +80,9 @@ export default {
|
||||
libraryScan() {
|
||||
if (!this.libraryId) return null
|
||||
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: {
|
||||
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() {
|
||||
this.quickMatching = true
|
||||
var matchOptions = {
|
||||
@ -326,25 +148,20 @@ export default {
|
||||
if (this.isProcessing) {
|
||||
return
|
||||
}
|
||||
this.isProcessing = true
|
||||
if (this.$refs.authorsSelect && this.$refs.authorsSelect.isFocused) {
|
||||
this.$refs.authorsSelect.forceBlur()
|
||||
if (!this.$refs.itemDetailsEdit) {
|
||||
return
|
||||
}
|
||||
if (this.$refs.genresSelect && this.$refs.genresSelect.isFocused) {
|
||||
this.$refs.genresSelect.forceBlur()
|
||||
var updatedDetails = this.$refs.itemDetailsEdit.getDetails()
|
||||
if (!updatedDetails.hasChanges) {
|
||||
this.$toast.info('No changes were made')
|
||||
return
|
||||
}
|
||||
if (this.$refs.tagsSelect && this.$refs.tagsSelect.isFocused) {
|
||||
this.$refs.tagsSelect.forceBlur()
|
||||
}
|
||||
this.$nextTick(this.handleForm)
|
||||
this.updateDetails(updatedDetails)
|
||||
},
|
||||
async handleForm() {
|
||||
const updatePayload = {
|
||||
metadata: this.details,
|
||||
tags: this.newTags
|
||||
}
|
||||
console.log('Sending update', updatePayload)
|
||||
var updatedAudiobook = await this.$axios.$patch(`/api/items/${this.libraryItemId}/media`, updatePayload).catch((error) => {
|
||||
async updateDetails(updatedDetails) {
|
||||
this.isProcessing = true
|
||||
console.log('Sending update', updatedDetails.updatePayload)
|
||||
var updatedAudiobook = await this.$axios.$patch(`/api/items/${this.libraryItemId}/media`, updatedDetails.updatePayload).catch((error) => {
|
||||
console.error('Failed to update', error)
|
||||
return false
|
||||
})
|
||||
@ -354,29 +171,6 @@ export default {
|
||||
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() {
|
||||
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
|
||||
@ -396,8 +190,9 @@ export default {
|
||||
},
|
||||
checkIsScrollable() {
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.formWrapper) {
|
||||
if (this.$refs.formWrapper.scrollHeight > this.$refs.formWrapper.clientHeight) {
|
||||
var formWrapper = document.getElementById('formWrapper')
|
||||
if (formWrapper) {
|
||||
if (formWrapper.scrollHeight > formWrapper.clientHeight) {
|
||||
this.isScrollable = true
|
||||
} else {
|
||||
this.isScrollable = false
|
||||
@ -407,12 +202,15 @@ export default {
|
||||
},
|
||||
setResizeObserver() {
|
||||
try {
|
||||
this.$nextTick(() => {
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
this.checkIsScrollable()
|
||||
var formWrapper = document.getElementById('formWrapper')
|
||||
if (formWrapper) {
|
||||
this.$nextTick(() => {
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
this.checkIsScrollable()
|
||||
})
|
||||
resizeObserver.observe(formWrapper)
|
||||
})
|
||||
resizeObserver.observe(this.$refs.formWrapper)
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to set resize observer')
|
||||
}
|
||||
|
@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<label class="flex justify-start items-center cursor-pointer">
|
||||
<div class="border-2 rounded border-gray-400 flex flex-shrink-0 justify-center items-center" :class="wrapperClass">
|
||||
<input v-model="selected" type="checkbox" class="opacity-0 absolute cursor-pointer" />
|
||||
<label class="flex justify-start items-center" :class="!disabled ? 'cursor-pointer' : ''">
|
||||
<div class="border-2 rounded flex flex-shrink-0 justify-center items-center" :class="wrapperClass">
|
||||
<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>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@ -18,10 +18,19 @@ export default {
|
||||
type: String,
|
||||
default: 'white'
|
||||
},
|
||||
borderColor: {
|
||||
type: String,
|
||||
default: 'gray-400'
|
||||
},
|
||||
checkColor: {
|
||||
type: String,
|
||||
default: 'green-500'
|
||||
}
|
||||
},
|
||||
labelClass: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
disabled: Boolean
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
@ -36,15 +45,17 @@ export default {
|
||||
}
|
||||
},
|
||||
wrapperClass() {
|
||||
var classes = [`bg-${this.checkboxBg}`]
|
||||
var classes = [`bg-${this.checkboxBg} border-${this.borderColor}`]
|
||||
if (this.small) classes.push('w-4 h-4')
|
||||
else classes.push('w-6 h-6')
|
||||
|
||||
return classes.join(' ')
|
||||
},
|
||||
labelClass() {
|
||||
if (this.small) return 'text-xs md:text-sm'
|
||||
return ''
|
||||
labelClassname() {
|
||||
if (this.labelClass) return this.labelClass
|
||||
var classes = ['pl-1']
|
||||
if (this.small) classes.push('text-xs md:text-sm')
|
||||
return classes.join(' ')
|
||||
},
|
||||
svgClass() {
|
||||
var classes = [`text-${this.checkColor}`]
|
||||
|
@ -8,7 +8,7 @@
|
||||
</div>
|
||||
</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">
|
||||
<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">
|
||||
@ -47,7 +47,7 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
isFocused: false,
|
||||
currentSearch: null,
|
||||
// currentSearch: null,
|
||||
typingTimeout: null,
|
||||
textInput: null
|
||||
}
|
||||
@ -70,12 +70,13 @@ export default {
|
||||
}
|
||||
},
|
||||
itemsToShow() {
|
||||
if (!this.currentSearch || !this.textInput || this.textInput === this.input) {
|
||||
return this.items
|
||||
if (!this.editable) return this.items
|
||||
if (!this.textInput || this.textInput === this.input) {
|
||||
return []
|
||||
}
|
||||
return this.items.filter((i) => {
|
||||
var iValue = String(i).toLowerCase()
|
||||
return iValue.includes(this.currentSearch.toLowerCase())
|
||||
return iValue.includes(this.textInput.toLowerCase())
|
||||
})
|
||||
}
|
||||
},
|
||||
@ -83,7 +84,7 @@ export default {
|
||||
keydownInput() {
|
||||
clearTimeout(this.typingTimeout)
|
||||
this.typingTimeout = setTimeout(() => {
|
||||
this.currentSearch = this.textInput
|
||||
// this.currentSearch = this.textInput
|
||||
}, 100)
|
||||
},
|
||||
inputFocus() {
|
||||
@ -127,11 +128,11 @@ export default {
|
||||
if (val && !this.items.includes(val)) {
|
||||
this.$emit('newItem', val)
|
||||
}
|
||||
this.currentSearch = null
|
||||
// this.currentSearch = null
|
||||
},
|
||||
clickedOption(e, item) {
|
||||
this.textInput = null
|
||||
this.currentSearch = null
|
||||
// this.currentSearch = null
|
||||
this.input = item
|
||||
if (this.$refs.input) this.$refs.input.blur()
|
||||
}
|
||||
|
@ -70,7 +70,7 @@ export default {
|
||||
computed: {
|
||||
selected: {
|
||||
get() {
|
||||
return this.value
|
||||
return this.value || []
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
|
@ -82,6 +82,9 @@ export default {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
wrapperClass() {
|
||||
var classes = []
|
||||
if (this.disabled) classes.push('bg-black-300')
|
||||
@ -110,7 +113,7 @@ export default {
|
||||
if (this.searching) return
|
||||
this.currentSearch = this.textInput
|
||||
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)
|
||||
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) {
|
||||
this.$store.commit('setShowEditModal', false)
|
||||
}
|
||||
if (this.$store.state.selectedAudiobooks) {
|
||||
this.$store.commit('setSelectedAudiobooks', [])
|
||||
if (this.$store.state.selectedLibraryItems) {
|
||||
this.$store.commit('setSelectedLibraryItems', [])
|
||||
}
|
||||
if (this.$store.state.audiobooks.keywordFilter) {
|
||||
this.$store.commit('audiobooks/setKeywordFilter', '')
|
||||
@ -486,9 +486,9 @@ export default {
|
||||
}
|
||||
|
||||
// Batch selecting
|
||||
if (this.$store.getters['getNumAudiobooksSelected'] && name === 'Escape') {
|
||||
if (this.$store.getters['getNumLibraryItemsSelected'] && name === 'Escape') {
|
||||
// ESCAPE key cancels batch selection
|
||||
this.$store.commit('setSelectedAudiobooks', [])
|
||||
this.$store.commit('setSelectedLibraryItems', [])
|
||||
this.$eventBus.$emit('bookshelf-clear-selection')
|
||||
e.preventDefault()
|
||||
return
|
||||
|
@ -30,7 +30,7 @@ export default {
|
||||
shelfEl.appendChild(bookComponent.$el)
|
||||
if (this.isSelectionMode) {
|
||||
bookComponent.setSelectionMode(true)
|
||||
if (this.selectedAudiobooks.includes(bookComponent.audiobookId) || this.isSelectAll) {
|
||||
if (this.selectedLibraryItems.includes(bookComponent.libraryItemId) || this.isSelectAll) {
|
||||
bookComponent.selected = true
|
||||
} else {
|
||||
bookComponent.selected = false
|
||||
@ -85,7 +85,7 @@ export default {
|
||||
}
|
||||
if (this.isSelectionMode) {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
<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="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>
|
||||
@ -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" />
|
||||
</div>
|
||||
<div class="flex items-center px-4 w-1/2">
|
||||
<ui-checkbox v-model="selectedBatchUsage.author" />
|
||||
<ui-text-input-with-label ref="authorInput" v-model="batchDetails.author" :disabled="!selectedBatchUsage.author" label="Author" class="mb-4 ml-4" />
|
||||
<ui-checkbox v-model="selectedBatchUsage.authors" />
|
||||
<!-- 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 class="flex items-center px-4 w-1/2">
|
||||
<ui-checkbox v-model="selectedBatchUsage.publishYear" />
|
||||
@ -23,7 +24,7 @@
|
||||
</div>
|
||||
<div class="flex items-center px-4 w-1/2">
|
||||
<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 class="flex items-center px-4 w-1/2">
|
||||
<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" />
|
||||
</div>
|
||||
<div class="flex items-center px-4 w-1/2">
|
||||
<ui-checkbox v-model="selectedBatchUsage.narrator" />
|
||||
<ui-text-input-with-label ref="narratorInput" v-model="batchDetails.narrator" :disabled="!selectedBatchUsage.narrator" label="Narrator" class="mb-4 ml-4" />
|
||||
<ui-checkbox v-model="selectedBatchUsage.narrators" />
|
||||
<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 class="flex items-center px-4 w-1/2">
|
||||
<ui-checkbox v-model="selectedBatchUsage.publisher" />
|
||||
@ -45,6 +46,20 @@
|
||||
<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" />
|
||||
</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">
|
||||
<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 class="flex justify-center flex-wrap">
|
||||
<template v-for="audiobook in audiobookCopies">
|
||||
<div :key="audiobook.id" class="w-full max-w-3xl border border-black-300 p-6 -ml-px -mt-px flex">
|
||||
<div class="w-32">
|
||||
<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>
|
||||
<template v-for="libraryItem in libraryItemCopies">
|
||||
<div :key="libraryItem.id" class="w-full max-w-3xl border border-black-300 p-6 -ml-px -mt-px">
|
||||
<widgets-item-details-edit :ref="`itemForm-${libraryItem.id}`" :library-item="libraryItem" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@ -127,7 +80,7 @@
|
||||
<ui-loading-indicator />
|
||||
</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" />
|
||||
<ui-btn color="success" :padding-x="8" class="text-lg" :loading="isProcessing" @click.prevent="saveClick">Save</ui-btn>
|
||||
</div>
|
||||
@ -137,47 +90,50 @@
|
||||
<script>
|
||||
export default {
|
||||
async asyncData({ store, redirect, app }) {
|
||||
if (!store.state.selectedAudiobooks.length) {
|
||||
if (!store.state.selectedLibraryItems.length) {
|
||||
return redirect('/')
|
||||
}
|
||||
var audiobooks = await app.$axios.$post(`/api/books/batch/get`, { books: store.state.selectedAudiobooks }).catch((error) => {
|
||||
var errorMsg = error.response.data || 'Failed to get audiobooks'
|
||||
var libraryItems = await app.$axios.$post(`/api/items/batch/get`, { libraryItemIds: store.state.selectedLibraryItems }).catch((error) => {
|
||||
var errorMsg = error.response.data || 'Failed to get items'
|
||||
console.error(errorMsg, error)
|
||||
return []
|
||||
})
|
||||
return {
|
||||
audiobooks
|
||||
libraryItems
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isProcessing: false,
|
||||
audiobookCopies: [],
|
||||
libraryItemCopies: [],
|
||||
isScrollable: false,
|
||||
newSeriesItems: [],
|
||||
newSeriesNames: [],
|
||||
newTagItems: [],
|
||||
newGenreItems: [],
|
||||
newNarratorItems: [],
|
||||
batchDetails: {
|
||||
subtitle: null,
|
||||
author: null,
|
||||
authors: null,
|
||||
publishYear: null,
|
||||
series: null,
|
||||
series: [],
|
||||
genres: [],
|
||||
tags: [],
|
||||
narrator: null,
|
||||
narrators: [],
|
||||
publisher: null,
|
||||
language: null
|
||||
language: null,
|
||||
explicit: false
|
||||
},
|
||||
selectedBatchUsage: {
|
||||
subtitle: false,
|
||||
author: false,
|
||||
authors: false,
|
||||
publishYear: false,
|
||||
series: false,
|
||||
genres: false,
|
||||
tags: false,
|
||||
narrator: false,
|
||||
narrators: false,
|
||||
publisher: false,
|
||||
language: false
|
||||
language: false,
|
||||
explicit: false
|
||||
},
|
||||
openMapOptions: false
|
||||
}
|
||||
@ -189,8 +145,8 @@ export default {
|
||||
bookCoverAspectRatio() {
|
||||
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE ? 1 : 1.6
|
||||
},
|
||||
streamAudiobook() {
|
||||
return this.$store.state.streamAudiobook
|
||||
streamLibraryItem() {
|
||||
return this.$store.state.streamLibraryItem
|
||||
},
|
||||
genreItems() {
|
||||
return this.genres.concat(this.newGenreItems)
|
||||
@ -199,7 +155,10 @@ export default {
|
||||
return this.tags.concat(this.newTagItems)
|
||||
},
|
||||
seriesItems() {
|
||||
return [...this.series, ...this.newSeriesItems]
|
||||
return [...this.existingSeriesNames, ...this.newSeriesNames]
|
||||
},
|
||||
narratorItems() {
|
||||
return [...this.narrators, ...this.newNarratorItems]
|
||||
},
|
||||
genres() {
|
||||
return this.filterData.genres || []
|
||||
@ -210,6 +169,15 @@ export default {
|
||||
series() {
|
||||
return this.filterData.series || []
|
||||
},
|
||||
narrators() {
|
||||
return this.filterData.narrators || []
|
||||
},
|
||||
authors() {
|
||||
return this.filterData.authors || []
|
||||
},
|
||||
existingSeriesNames() {
|
||||
return this.series.map((se) => se.name)
|
||||
},
|
||||
filterData() {
|
||||
return this.$store.state.libraries.filterData || {}
|
||||
},
|
||||
@ -222,8 +190,14 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
blurBatchForm() {
|
||||
if (this.$refs.seriesDropdown && this.$refs.seriesDropdown.isFocused) {
|
||||
this.$refs.seriesDropdown.blur()
|
||||
if (this.$refs.seriesSelect && this.$refs.seriesSelect.isFocused) {
|
||||
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) {
|
||||
this.$refs.genresSelect.forceBlur()
|
||||
@ -241,69 +215,92 @@ export default {
|
||||
mapBatchDetails() {
|
||||
this.blurBatchForm()
|
||||
|
||||
this.audiobookCopies = this.audiobookCopies.map((ab) => {
|
||||
for (const key in this.selectedBatchUsage) {
|
||||
if (this.selectedBatchUsage[key]) {
|
||||
if (key === 'tags') {
|
||||
ab.tags = this.batchDetails.tags
|
||||
} else {
|
||||
ab.book[key] = this.batchDetails[key]
|
||||
}
|
||||
var batchMapPayload = {}
|
||||
for (const key in this.selectedBatchUsage) {
|
||||
if (this.selectedBatchUsage[key]) {
|
||||
if (key === 'series') {
|
||||
// Map string of series to series objects
|
||||
batchMapPayload[key] = this.batchDetails[key].map((seItem) => {
|
||||
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')
|
||||
},
|
||||
newSeriesItem(item) {},
|
||||
removedSeriesItem(item) {},
|
||||
newNarratorItem(item) {},
|
||||
removedNarratorItem(item) {},
|
||||
newTagItem(item) {
|
||||
if (item && !this.newTagItems.includes(item)) {
|
||||
this.newTagItems.push(item)
|
||||
}
|
||||
// if (item && !this.newTagItems.includes(item)) {
|
||||
// this.newTagItems.push(item)
|
||||
// }
|
||||
},
|
||||
removedTagItem(item) {
|
||||
// If newly added, remove if not used on any other audiobooks
|
||||
if (item && this.newTagItems.includes(item)) {
|
||||
var usedByOtherAb = this.audiobookCopies.find((ab) => {
|
||||
return ab.tags && ab.tags.includes(item)
|
||||
})
|
||||
if (!usedByOtherAb) {
|
||||
this.newTagItems = this.newTagItems.filter((t) => t !== item)
|
||||
}
|
||||
}
|
||||
// If newly added, remove if not used on any other items
|
||||
// if (item && this.newTagItems.includes(item)) {
|
||||
// var usedByOtherAb = this.libraryItemCopies.find((ab) => {
|
||||
// return ab.tags && ab.tags.includes(item)
|
||||
// })
|
||||
// if (!usedByOtherAb) {
|
||||
// this.newTagItems = this.newTagItems.filter((t) => t !== item)
|
||||
// }
|
||||
// }
|
||||
},
|
||||
newGenreItem(item) {
|
||||
if (item && !this.newGenreItems.includes(item)) {
|
||||
this.newGenreItems.push(item)
|
||||
}
|
||||
// if (item && !this.newGenreItems.includes(item)) {
|
||||
// this.newGenreItems.push(item)
|
||||
// }
|
||||
},
|
||||
removedGenreItem(item) {
|
||||
// If newly added, remove if not used on any other audiobooks
|
||||
if (item && this.newGenreItems.includes(item)) {
|
||||
var usedByOtherAb = this.audiobookCopies.find((ab) => {
|
||||
return ab.book.genres && ab.book.genres.includes(item)
|
||||
})
|
||||
if (!usedByOtherAb) {
|
||||
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)
|
||||
})
|
||||
// If newly added, remove if not used on any other items
|
||||
// if (item && this.newGenreItems.includes(item)) {
|
||||
// var usedByOtherAb = this.libraryItemCopies.find((ab) => {
|
||||
// return ab.book.genres && ab.book.genres.includes(item)
|
||||
// })
|
||||
// if (!usedByOtherAb) {
|
||||
// this.newGenreItems = this.newGenreItems.filter((t) => t !== item)
|
||||
// }
|
||||
// }
|
||||
},
|
||||
init() {
|
||||
this.audiobookCopies = this.audiobooks.map((ab) => {
|
||||
var copy = { ...ab }
|
||||
copy.tags = [...ab.tags]
|
||||
copy.book = { ...ab.book }
|
||||
copy.book.genres = [...ab.book.genres]
|
||||
copy.originalAudiobook = ab
|
||||
// TODO: Better deep cloning of library items
|
||||
this.libraryItemCopies = this.libraryItems.map((li) => {
|
||||
var copy = {
|
||||
...li
|
||||
}
|
||||
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
|
||||
})
|
||||
this.$nextTick(() => {
|
||||
@ -312,46 +309,23 @@ export default {
|
||||
}
|
||||
})
|
||||
},
|
||||
compareStringArrays(arr1, arr2) {
|
||||
if (!arr1 || !arr2) return false
|
||||
return arr1.join(',') !== arr2.join(',')
|
||||
},
|
||||
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
|
||||
getEditFormRef(itemId) {
|
||||
var refs = this.$refs[`itemForm-${itemId}`]
|
||||
if (refs && refs.length) return refs[0]
|
||||
return null
|
||||
},
|
||||
saveClick() {
|
||||
var updates = []
|
||||
for (let i = 0; i < this.audiobookCopies.length; i++) {
|
||||
var ab = { ...this.audiobookCopies[i] }
|
||||
var origAb = ab.originalAudiobook
|
||||
delete ab.originalAudiobook
|
||||
|
||||
var res = this.compareAudiobooks(ab, origAb)
|
||||
if (res && Object.keys(res).length) {
|
||||
for (let i = 0; i < this.libraryItemCopies.length; i++) {
|
||||
var editForm = this.getEditFormRef(this.libraryItemCopies[i].id)
|
||||
if (!editForm) {
|
||||
throw new Error('Invalid edit form ref not found')
|
||||
}
|
||||
var details = editForm.getDetails()
|
||||
if (details.hasChanges) {
|
||||
updates.push({
|
||||
id: ab.id,
|
||||
updates: res
|
||||
id: this.libraryItemCopies[i].id,
|
||||
mediaPayload: details.updatePayload
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -362,11 +336,11 @@ export default {
|
||||
console.log('Pushing updates', updates)
|
||||
this.isProcessing = true
|
||||
this.$axios
|
||||
.$post('/api/books/batch/update', updates)
|
||||
.$post('/api/items/batch/update', updates)
|
||||
.then((data) => {
|
||||
this.isProcessing = false
|
||||
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`)
|
||||
} else {
|
||||
this.$toast.warning('No updates were necessary')
|
||||
@ -377,11 +351,6 @@ export default {
|
||||
this.$toast.error('Failed to batch update')
|
||||
this.isProcessing = false
|
||||
})
|
||||
},
|
||||
applyBatchUpdates() {
|
||||
this.audiobookCopies = this.audiobookCopies.map((ab) => {
|
||||
if (this.batchDetails.series) ab.book.series = this.batchDetails.series
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
|
@ -12,7 +12,7 @@ export const state = () => ({
|
||||
selectedLibraryItem: null,
|
||||
selectedAudiobookFile: null,
|
||||
developerMode: false,
|
||||
selectedAudiobooks: [],
|
||||
selectedLibraryItems: [],
|
||||
processingBatch: false,
|
||||
previousPath: '/',
|
||||
routeHistory: [],
|
||||
@ -25,8 +25,8 @@ export const state = () => ({
|
||||
})
|
||||
|
||||
export const getters = {
|
||||
getIsAudiobookSelected: state => audiobookId => {
|
||||
return !!state.selectedAudiobooks.includes(audiobookId)
|
||||
getIsLibraryItemSelected: state => libraryItemId => {
|
||||
return !!state.selectedLibraryItems.includes(libraryItemId)
|
||||
},
|
||||
getServerSetting: state => key => {
|
||||
if (!state.serverSettings) return null
|
||||
@ -36,7 +36,7 @@ export const getters = {
|
||||
if (!state.serverSettings || !state.serverSettings.coverAspectRatio) return 1.6
|
||||
return state.serverSettings.coverAspectRatio === 0 ? 1.6 : 1
|
||||
},
|
||||
getNumAudiobooksSelected: state => state.selectedAudiobooks.length,
|
||||
getNumLibraryItemsSelected: state => state.selectedLibraryItems.length,
|
||||
getLibraryItemIdStreaming: state => {
|
||||
return state.streamLibraryItem ? state.streamLibraryItem.id : null
|
||||
}
|
||||
@ -146,24 +146,24 @@ export const mutations = {
|
||||
setSelectedLibraryItem(state, val) {
|
||||
Vue.set(state, 'selectedLibraryItem', val)
|
||||
},
|
||||
setSelectedAudiobooks(state, audiobooks) {
|
||||
Vue.set(state, 'selectedAudiobooks', audiobooks)
|
||||
setSelectedLibraryItems(state, items) {
|
||||
Vue.set(state, 'selectedLibraryItems', items)
|
||||
},
|
||||
toggleAudiobookSelected(state, audiobookId) {
|
||||
if (state.selectedAudiobooks.includes(audiobookId)) {
|
||||
state.selectedAudiobooks = state.selectedAudiobooks.filter(a => a !== audiobookId)
|
||||
toggleLibraryItemSelected(state, itemId) {
|
||||
if (state.selectedLibraryItems.includes(itemId)) {
|
||||
state.selectedLibraryItems = state.selectedLibraryItems.filter(a => a !== itemId)
|
||||
} else {
|
||||
var newSel = state.selectedAudiobooks.concat([audiobookId])
|
||||
Vue.set(state, 'selectedAudiobooks', newSel)
|
||||
var newSel = state.selectedLibraryItems.concat([itemId])
|
||||
Vue.set(state, 'selectedLibraryItems', newSel)
|
||||
}
|
||||
},
|
||||
setAudiobookSelected(state, { audiobookId, selected }) {
|
||||
var isThere = state.selectedAudiobooks.includes(audiobookId)
|
||||
setLibraryItemSelected(state, { libraryItemId, selected }) {
|
||||
var isThere = state.selectedLibraryItems.includes(libraryItemId)
|
||||
if (isThere && !selected) {
|
||||
state.selectedAudiobooks = state.selectedAudiobooks.filter(a => a !== audiobookId)
|
||||
state.selectedLibraryItems = state.selectedLibraryItems.filter(a => a !== libraryItemId)
|
||||
} else if (selected && !isThere) {
|
||||
var newSel = state.selectedAudiobooks.concat([audiobookId])
|
||||
Vue.set(state, 'selectedAudiobooks', newSel)
|
||||
var newSel = state.selectedLibraryItems.concat([libraryItemId])
|
||||
Vue.set(state, 'selectedLibraryItems', newSel)
|
||||
}
|
||||
},
|
||||
setProcessingBatch(state, val) {
|
||||
|
@ -21,6 +21,9 @@ const AuthorController = require('./controllers/AuthorController')
|
||||
const BookFinder = require('./finders/BookFinder')
|
||||
const AuthorFinder = require('./finders/AuthorFinder')
|
||||
const PodcastFinder = require('./finders/PodcastFinder')
|
||||
|
||||
const Author = require('./objects/entities/Author')
|
||||
const Series = require('./objects/entities/Series')
|
||||
const FileSystemController = require('./controllers/FileSystemController')
|
||||
|
||||
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/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/order', LibraryController.reorder.bind(this))
|
||||
|
||||
// Legacy
|
||||
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))
|
||||
this.router.post('/libraries/order', LibraryController.reorder.bind(this))
|
||||
|
||||
//
|
||||
// Item Routes
|
||||
@ -84,6 +84,10 @@ class ApiController {
|
||||
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.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
|
||||
//
|
||||
@ -93,9 +97,6 @@ class ApiController {
|
||||
this.router.delete('/books/:id', BookController.delete.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.get('/books/:id/stream', BookController.openStream.bind(this))
|
||||
this.router.post('/books/:id/cover', BookController.uploadCover.bind(this))
|
||||
@ -162,17 +163,16 @@ class ApiController {
|
||||
//
|
||||
// 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.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/search', AuthorController.search.bind(this))
|
||||
|
||||
//
|
||||
// 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/:id', SeriesController.middleware.bind(this), SeriesController.findOne.bind(this))
|
||||
|
||||
//
|
||||
// Misc Routes
|
||||
@ -488,6 +488,60 @@ class ApiController {
|
||||
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) {
|
||||
var url = req.body.rssFeed
|
||||
if (!url) {
|
||||
|
@ -226,14 +226,6 @@ class LibraryController {
|
||||
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) {
|
||||
res.json(libraryHelpers.getDistinctFilterDataNew(req.libraryItems))
|
||||
}
|
||||
|
@ -1,6 +1,4 @@
|
||||
const Logger = require('../Logger')
|
||||
const Author = require('../objects/entities/Author')
|
||||
const Series = require('../objects/entities/Series')
|
||||
const { reqSupportsWebp } = require('../utils/index')
|
||||
|
||||
class LibraryItemController {
|
||||
@ -43,49 +41,7 @@ class LibraryItemController {
|
||||
await this.cacheManager.purgeCoverCache(libraryItem.id)
|
||||
}
|
||||
|
||||
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 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
await this.createAuthorsAndSeriesForItemUpdate(mediaPayload)
|
||||
|
||||
var hasUpdates = libraryItem.media.update(mediaPayload)
|
||||
if (hasUpdates) {
|
||||
@ -182,11 +138,76 @@ class LibraryItemController {
|
||||
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) {
|
||||
var item = this.db.libraryItems.find(li => li.id === req.params.id)
|
||||
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)) {
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
@ -133,50 +133,6 @@ module.exports = {
|
||||
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) {
|
||||
var _series = {}
|
||||
books.forEach((libraryItem) => {
|
||||
|
Loading…
Reference in New Issue
Block a user