mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-10-27 11:18:14 +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>
 | 
					        </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…
	
		Reference in New Issue
	
	Block a user