mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-10-27 11:18:14 +01:00 
			
		
		
		
	New model update details, author and series inputs with create new, compare & copy utils
This commit is contained in:
		
							parent
							
								
									f2be3bc95e
								
							
						
					
					
						commit
						5f4e5cd3d8
					
				@ -87,7 +87,6 @@ export default {
 | 
			
		||||
      var categories = await this.$axios
 | 
			
		||||
        .$get(`/api/libraries/${this.currentLibraryId}/personalized?minified=1`)
 | 
			
		||||
        .then((data) => {
 | 
			
		||||
          console.log('Personalized data', data)
 | 
			
		||||
          return data
 | 
			
		||||
        })
 | 
			
		||||
        .catch((error) => {
 | 
			
		||||
 | 
			
		||||
@ -168,9 +168,16 @@ export default {
 | 
			
		||||
    },
 | 
			
		||||
    sublistItems() {
 | 
			
		||||
      return (this[this.sublist] || []).map((item) => {
 | 
			
		||||
        return {
 | 
			
		||||
          text: item,
 | 
			
		||||
          value: this.$encode(item)
 | 
			
		||||
        if (typeof item === 'string') {
 | 
			
		||||
          return {
 | 
			
		||||
            text: item,
 | 
			
		||||
            value: this.$encode(item)
 | 
			
		||||
          }
 | 
			
		||||
        } else {
 | 
			
		||||
          return {
 | 
			
		||||
            text: item.name,
 | 
			
		||||
            value: item.id
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      })
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
@ -86,6 +86,7 @@ export default {
 | 
			
		||||
    },
 | 
			
		||||
    selectedText() {
 | 
			
		||||
      var _selected = this.selected
 | 
			
		||||
      if (!_selected) return ''
 | 
			
		||||
      if (this.selected.startsWith('book.')) _selected = _selected.replace('book.', 'media.metadata.')
 | 
			
		||||
      var _sel = this.items.find((i) => i.value === _selected)
 | 
			
		||||
      if (!_sel) return ''
 | 
			
		||||
 | 
			
		||||
@ -18,7 +18,7 @@
 | 
			
		||||
      <div class="material-icons text-5xl text-white text-opacity-50 hover:text-opacity-90 cursor-pointer pointer-events-auto" @click.stop.prevent="goNextBook" @mousedown.prevent>arrow_forward_ios</div>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <div class="w-full h-full text-sm rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300">
 | 
			
		||||
    <div class="w-full h-full text-sm rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300 relative">
 | 
			
		||||
      <component v-if="libraryItem && show" :is="tabName" :library-item="libraryItem" :processing.sync="processing" @close="show = false" @selectTab="selectTab" />
 | 
			
		||||
    </div>
 | 
			
		||||
  </modals-modal>
 | 
			
		||||
@ -30,7 +30,7 @@ export default {
 | 
			
		||||
    return {
 | 
			
		||||
      processing: false,
 | 
			
		||||
      libraryItem: null,
 | 
			
		||||
      fetchOnShow: false,
 | 
			
		||||
 | 
			
		||||
      tabs: [
 | 
			
		||||
        {
 | 
			
		||||
          id: 'details',
 | 
			
		||||
@ -62,11 +62,6 @@ export default {
 | 
			
		||||
          title: 'Match',
 | 
			
		||||
          component: 'modals-edit-tabs-match'
 | 
			
		||||
        }
 | 
			
		||||
        // {
 | 
			
		||||
        //   id: 'authors',
 | 
			
		||||
        //   title: 'Authors',
 | 
			
		||||
        //   component: 'modals-edit-tabs-authors'
 | 
			
		||||
        // }
 | 
			
		||||
      ]
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
@ -84,11 +79,6 @@ export default {
 | 
			
		||||
            this.selectedTab = availableTabIds[0]
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          if (this.libraryItem && this.libraryItem.id === this.selectedLibraryItemId) {
 | 
			
		||||
            if (this.fetchOnShow) this.fetchFull()
 | 
			
		||||
            return
 | 
			
		||||
          }
 | 
			
		||||
          this.fetchOnShow = false
 | 
			
		||||
          this.libraryItem = null
 | 
			
		||||
          this.init()
 | 
			
		||||
          this.registerListeners()
 | 
			
		||||
@ -214,14 +204,10 @@ export default {
 | 
			
		||||
        this.selectedTab = tab
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    audiobookUpdated() {
 | 
			
		||||
      if (!this.show) this.fetchOnShow = true
 | 
			
		||||
      else {
 | 
			
		||||
        this.fetchFull()
 | 
			
		||||
      }
 | 
			
		||||
    libraryItemUpdated(expandedLibraryItem) {
 | 
			
		||||
      this.libraryItem = expandedLibraryItem
 | 
			
		||||
    },
 | 
			
		||||
    init() {
 | 
			
		||||
      this.$store.commit('audiobooks/addListener', { meth: this.audiobookUpdated, id: 'edit-modal', audiobookId: this.selectedLibraryItemId })
 | 
			
		||||
      this.fetchFull()
 | 
			
		||||
    },
 | 
			
		||||
    async fetchFull() {
 | 
			
		||||
@ -244,9 +230,11 @@ export default {
 | 
			
		||||
    },
 | 
			
		||||
    registerListeners() {
 | 
			
		||||
      this.$eventBus.$on('modal-hotkey', this.hotkey)
 | 
			
		||||
      this.$eventBus.$on(`${this.selectedLibraryItemId}_updated`, this.libraryItemUpdated)
 | 
			
		||||
    },
 | 
			
		||||
    unregisterListeners() {
 | 
			
		||||
      this.$eventBus.$off('modal-hotkey', this.hotkey)
 | 
			
		||||
      this.$eventBus.$off(`${this.selectedLibraryItemId}_updated`, this.libraryItemUpdated)
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  mounted() {},
 | 
			
		||||
 | 
			
		||||
@ -13,9 +13,7 @@
 | 
			
		||||
 | 
			
		||||
        <div class="flex mt-2 -mx-1">
 | 
			
		||||
          <div class="w-3/4 px-1">
 | 
			
		||||
            <!-- <ui-text-input-with-label v-model="details.authors" label="Author" /> -->
 | 
			
		||||
            <!-- <p>Authors placeholder</p> -->
 | 
			
		||||
            <ui-multi-select-query-input ref="authorsSelect" v-model="authorNames" label="Authors" endpoint="authors/search" />
 | 
			
		||||
            <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" />
 | 
			
		||||
@ -23,12 +21,8 @@
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <div class="flex mt-2 -mx-1">
 | 
			
		||||
          <div class="w-3/4 px-1">
 | 
			
		||||
            <p>Series placeholder</p>
 | 
			
		||||
            <!-- <ui-input-dropdown ref="seriesDropdown" v-model="details.series" label="Series" :items="series" /> -->
 | 
			
		||||
          </div>
 | 
			
		||||
          <div class="flex-grow px-1">
 | 
			
		||||
            <!-- <ui-text-input-with-label v-model="details.volumeNumber" label="Volume #" /> -->
 | 
			
		||||
            <ui-multi-select-query-input ref="seriesSelect" v-model="seriesItems" text-key="displayName" label="Series" readonly show-edit @edit="editSeriesItem" @add="addNewSeries" />
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
@ -87,6 +81,27 @@
 | 
			
		||||
        </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>
 | 
			
		||||
 | 
			
		||||
@ -101,6 +116,8 @@ export default {
 | 
			
		||||
  },
 | 
			
		||||
  data() {
 | 
			
		||||
    return {
 | 
			
		||||
      selectedSeries: {},
 | 
			
		||||
      showSeriesForm: false,
 | 
			
		||||
      details: {
 | 
			
		||||
        title: null,
 | 
			
		||||
        subtitle: null,
 | 
			
		||||
@ -116,7 +133,6 @@ export default {
 | 
			
		||||
        genres: []
 | 
			
		||||
      },
 | 
			
		||||
      newTags: [],
 | 
			
		||||
      authorNames: [],
 | 
			
		||||
      resettingProgress: false,
 | 
			
		||||
      isScrollable: false,
 | 
			
		||||
      savingMetadata: false,
 | 
			
		||||
@ -180,9 +196,71 @@ export default {
 | 
			
		||||
    libraryScan() {
 | 
			
		||||
      if (!this.libraryId) return null
 | 
			
		||||
      return this.$store.getters['scanners/getLibraryScan'](this.libraryId)
 | 
			
		||||
    },
 | 
			
		||||
    existingSeriesNames() {
 | 
			
		||||
      // Only show series names not already selected
 | 
			
		||||
      var alreadySelectedSeriesIds = this.details.series.map((se) => se.id)
 | 
			
		||||
      return this.series.filter((se) => !alreadySelectedSeriesIds.includes(se.id)).map((se) => se.name)
 | 
			
		||||
    },
 | 
			
		||||
    seriesItems: {
 | 
			
		||||
      get() {
 | 
			
		||||
        return this.details.series.map((se) => {
 | 
			
		||||
          return {
 | 
			
		||||
            displayName: se.sequence ? `${se.name} #${se.sequence}` : se.name,
 | 
			
		||||
            ...se
 | 
			
		||||
          }
 | 
			
		||||
        })
 | 
			
		||||
      },
 | 
			
		||||
      set(val) {
 | 
			
		||||
        this.details.series = val
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  methods: {
 | 
			
		||||
    cancelSeriesForm() {
 | 
			
		||||
      this.showSeriesForm = false
 | 
			
		||||
    },
 | 
			
		||||
    editSeriesItem(series) {
 | 
			
		||||
      var _series = this.details.series.find((se) => se.id === series.id)
 | 
			
		||||
      if (!_series) return
 | 
			
		||||
      this.selectedSeries = {
 | 
			
		||||
        ..._series
 | 
			
		||||
      }
 | 
			
		||||
      this.showSeriesForm = true
 | 
			
		||||
    },
 | 
			
		||||
    submitSeriesForm() {
 | 
			
		||||
      if (!this.selectedSeries.name) {
 | 
			
		||||
        this.$toast.error('Must enter a series')
 | 
			
		||||
        return
 | 
			
		||||
      }
 | 
			
		||||
      if (this.$refs.newSeriesSelect) {
 | 
			
		||||
        this.$refs.newSeriesSelect.blur()
 | 
			
		||||
      }
 | 
			
		||||
      var existingSeriesIndex = this.details.series.findIndex((se) => se.id === this.selectedSeries.id)
 | 
			
		||||
 | 
			
		||||
      var seriesSameName = this.series.find((se) => se.name.toLowerCase() === this.selectedSeries.name.toLowerCase())
 | 
			
		||||
      if (existingSeriesIndex < 0 && seriesSameName) {
 | 
			
		||||
        this.selectedSeries.id = seriesSameName.id
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (existingSeriesIndex >= 0) {
 | 
			
		||||
        this.details.series.splice(existingSeriesIndex, 1, { ...this.selectedSeries })
 | 
			
		||||
      } else {
 | 
			
		||||
        this.details.series.push({
 | 
			
		||||
          ...this.selectedSeries
 | 
			
		||||
        })
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      this.showSeriesForm = false
 | 
			
		||||
    },
 | 
			
		||||
    addNewSeries() {
 | 
			
		||||
      this.selectedSeries = {
 | 
			
		||||
        id: `new-${Date.now()}`,
 | 
			
		||||
        name: '',
 | 
			
		||||
        sequence: ''
 | 
			
		||||
      }
 | 
			
		||||
      this.showSeriesForm = true
 | 
			
		||||
    },
 | 
			
		||||
    quickMatch() {
 | 
			
		||||
      this.quickMatching = true
 | 
			
		||||
      var matchOptions = {
 | 
			
		||||
@ -249,8 +327,8 @@ export default {
 | 
			
		||||
        return
 | 
			
		||||
      }
 | 
			
		||||
      this.isProcessing = true
 | 
			
		||||
      if (this.$refs.seriesDropdown && this.$refs.seriesDropdown.isFocused) {
 | 
			
		||||
        this.$refs.seriesDropdown.blur()
 | 
			
		||||
      if (this.$refs.authorsSelect && this.$refs.authorsSelect.isFocused) {
 | 
			
		||||
        this.$refs.authorsSelect.forceBlur()
 | 
			
		||||
      }
 | 
			
		||||
      if (this.$refs.genresSelect && this.$refs.genresSelect.isFocused) {
 | 
			
		||||
        this.$refs.genresSelect.forceBlur()
 | 
			
		||||
@ -262,11 +340,11 @@ export default {
 | 
			
		||||
    },
 | 
			
		||||
    async handleForm() {
 | 
			
		||||
      const updatePayload = {
 | 
			
		||||
        book: this.details,
 | 
			
		||||
        metadata: this.details,
 | 
			
		||||
        tags: this.newTags
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      var updatedAudiobook = await this.$axios.$patch(`/api/books/${this.libraryItemId}`, 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)
 | 
			
		||||
        return false
 | 
			
		||||
      })
 | 
			
		||||
@ -280,18 +358,24 @@ export default {
 | 
			
		||||
      this.details.title = this.mediaMetadata.title
 | 
			
		||||
      this.details.subtitle = this.mediaMetadata.subtitle
 | 
			
		||||
      this.details.description = this.mediaMetadata.description
 | 
			
		||||
      this.details.authors = this.mediaMetadata.authors || []
 | 
			
		||||
      this.$set(
 | 
			
		||||
        this.details,
 | 
			
		||||
        'authors',
 | 
			
		||||
        (this.mediaMetadata.authors || []).map((se) => ({ ...se }))
 | 
			
		||||
      )
 | 
			
		||||
      this.details.narrator = this.mediaMetadata.narrator
 | 
			
		||||
      this.details.genres = this.mediaMetadata.genres || []
 | 
			
		||||
      this.details.series = this.mediaMetadata.series
 | 
			
		||||
      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 || []
 | 
			
		||||
      this.authorNames = this.details.authors.map((au) => au.name)
 | 
			
		||||
      this.newTags = [...(this.media.tags || [])]
 | 
			
		||||
    },
 | 
			
		||||
    removeItem() {
 | 
			
		||||
      if (confirm(`Are you sure you want to remove this item?\n\n*Does not delete your files, only removes the item from audiobookshelf`)) {
 | 
			
		||||
 | 
			
		||||
@ -3,14 +3,15 @@
 | 
			
		||||
    <p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">{{ label }}</p>
 | 
			
		||||
    <div ref="wrapper" class="relative">
 | 
			
		||||
      <form @submit.prevent="submitForm">
 | 
			
		||||
        <div ref="inputWrapper" style="min-height: 40px" class="flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded-md px-2 py-1 cursor-text" :class="disabled ? 'bg-black-300' : 'bg-primary'" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent>
 | 
			
		||||
          <div v-for="item in selected" :key="item" class="rounded-full px-2 py-1 ma-0.5 text-xs bg-bg flex flex-nowrap whitespace-nowrap items-center relative">
 | 
			
		||||
        <div ref="inputWrapper" style="min-height: 36px" class="flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded px-2 py-1" :class="wrapperClass" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent>
 | 
			
		||||
          <div v-for="item in selected" :key="item" class="rounded-full px-2 py-1 mx-0.5 text-xs bg-bg flex flex-nowrap whitespace-nowrap items-center relative">
 | 
			
		||||
            <div class="w-full h-full rounded-full absolute top-0 left-0 opacity-0 hover:opacity-100 px-1 bg-bg bg-opacity-75 flex items-center justify-end cursor-pointer">
 | 
			
		||||
              <span v-if="showEdit" class="material-icons text-white hover:text-warning" style="font-size: 1.1rem" @click.stop="editItem(item)">edit</span>
 | 
			
		||||
              <span class="material-icons text-white hover:text-error" style="font-size: 1.1rem" @click.stop="removeItem(item)">close</span>
 | 
			
		||||
            </div>
 | 
			
		||||
            {{ item }}
 | 
			
		||||
          </div>
 | 
			
		||||
          <input ref="input" v-model="textInput" :disabled="disabled" style="min-width: 40px; width: 40px" class="h-full bg-primary focus:outline-none px-1" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" />
 | 
			
		||||
          <input v-show="!readonly" ref="input" v-model="textInput" :disabled="disabled" style="min-width: 40px; width: 40px" class="h-full bg-primary focus:outline-none px-1" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" />
 | 
			
		||||
        </div>
 | 
			
		||||
      </form>
 | 
			
		||||
 | 
			
		||||
@ -47,7 +48,9 @@ export default {
 | 
			
		||||
      default: () => []
 | 
			
		||||
    },
 | 
			
		||||
    label: String,
 | 
			
		||||
    disabled: Boolean
 | 
			
		||||
    disabled: Boolean,
 | 
			
		||||
    readonly: Boolean,
 | 
			
		||||
    showEdit: Boolean
 | 
			
		||||
  },
 | 
			
		||||
  data() {
 | 
			
		||||
    return {
 | 
			
		||||
@ -76,6 +79,13 @@ export default {
 | 
			
		||||
    showMenu() {
 | 
			
		||||
      return this.isFocused
 | 
			
		||||
    },
 | 
			
		||||
    wrapperClass() {
 | 
			
		||||
      var classes = []
 | 
			
		||||
      if (this.disabled) classes.push('bg-black-300')
 | 
			
		||||
      else classes.push('bg-primary')
 | 
			
		||||
      if (!this.readonly) classes.push('cursor-text')
 | 
			
		||||
      return classes.join(' ')
 | 
			
		||||
    },
 | 
			
		||||
    itemsToShow() {
 | 
			
		||||
      if (!this.currentSearch || !this.textInput) {
 | 
			
		||||
        return this.items
 | 
			
		||||
@ -88,6 +98,9 @@ export default {
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  methods: {
 | 
			
		||||
    editItem(item) {
 | 
			
		||||
      this.$emit('edit', item)
 | 
			
		||||
    },
 | 
			
		||||
    keydownInput() {
 | 
			
		||||
      clearTimeout(this.typingTimeout)
 | 
			
		||||
      this.typingTimeout = setTimeout(() => {
 | 
			
		||||
 | 
			
		||||
@ -3,26 +3,30 @@
 | 
			
		||||
    <p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">{{ label }}</p>
 | 
			
		||||
    <div ref="wrapper" class="relative">
 | 
			
		||||
      <form @submit.prevent="submitForm">
 | 
			
		||||
        <div ref="inputWrapper" style="min-height: 40px" class="flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded-md px-2 py-1 cursor-text" :class="disabled ? 'bg-black-300' : 'bg-primary'" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent>
 | 
			
		||||
          <div v-for="item in selected" :key="item" class="rounded-full px-2 py-1 ma-0.5 text-xs bg-bg flex flex-nowrap whitespace-nowrap items-center relative">
 | 
			
		||||
        <div ref="inputWrapper" style="min-height: 36px" class="flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded px-2 py-0.5" :class="wrapperClass" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent>
 | 
			
		||||
          <div v-for="item in selected" :key="item.id" class="rounded-full px-2 py-0.5 m-0.5 text-xs bg-bg flex flex-nowrap whitespace-nowrap items-center justify-center relative min-w-12">
 | 
			
		||||
            <div class="w-full h-full rounded-full absolute top-0 left-0 opacity-0 hover:opacity-100 px-1 bg-bg bg-opacity-75 flex items-center justify-end cursor-pointer">
 | 
			
		||||
              <span class="material-icons text-white hover:text-error" style="font-size: 1.1rem" @click.stop="removeItem(item)">close</span>
 | 
			
		||||
              <span v-if="showEdit" class="material-icons text-white hover:text-warning mr-1" style="font-size: 1rem" @click.stop="editItem(item)">edit</span>
 | 
			
		||||
              <span class="material-icons text-white hover:text-error" style="font-size: 1.1rem" @click.stop="removeItem(item.id)">close</span>
 | 
			
		||||
            </div>
 | 
			
		||||
            {{ item }}
 | 
			
		||||
            {{ item[textKey] }}
 | 
			
		||||
          </div>
 | 
			
		||||
          <input ref="input" v-model="textInput" :disabled="disabled" style="min-width: 40px; width: 40px" class="h-full bg-primary focus:outline-none px-1" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" />
 | 
			
		||||
          <div v-if="showEdit" class="rounded-full cursor-pointer w-6 h-6 mx-0.5 bg-bg flex items-center justify-center">
 | 
			
		||||
            <span class="material-icons text-white hover:text-success pt-px pr-px" style="font-size: 1.1rem" @click.stop="addItem">add</span>
 | 
			
		||||
          </div>
 | 
			
		||||
          <input v-show="!readonly" ref="input" v-model="textInput" :disabled="disabled" style="min-width: 40px; width: 40px" class="h-full bg-primary focus:outline-none px-1" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" />
 | 
			
		||||
        </div>
 | 
			
		||||
      </form>
 | 
			
		||||
 | 
			
		||||
      <ul ref="menu" v-show="showMenu" class="absolute z-50 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
 | 
			
		||||
        <template v-for="item in itemsToShow">
 | 
			
		||||
          <li :key="item.id" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item.name)" @mouseup.stop.prevent @mousedown.prevent>
 | 
			
		||||
          <li :key="item.id" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent>
 | 
			
		||||
            <div class="flex items-center">
 | 
			
		||||
              <span class="font-normal ml-3 block truncate">{{ item.name }}</span>
 | 
			
		||||
            </div>
 | 
			
		||||
            <!-- <span v-if="selected.includes(item)" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
 | 
			
		||||
            <span v-if="getIsSelected(item.id)" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
 | 
			
		||||
              <span class="material-icons text-xl">checkmark</span>
 | 
			
		||||
            </span> -->
 | 
			
		||||
            </span>
 | 
			
		||||
          </li>
 | 
			
		||||
        </template>
 | 
			
		||||
        <li v-if="!itemsToShow.length" class="text-gray-50 select-none relative py-2 pr-9" role="option">
 | 
			
		||||
@ -44,7 +48,13 @@ export default {
 | 
			
		||||
    },
 | 
			
		||||
    endpoint: String,
 | 
			
		||||
    label: String,
 | 
			
		||||
    disabled: Boolean
 | 
			
		||||
    disabled: Boolean,
 | 
			
		||||
    readonly: Boolean,
 | 
			
		||||
    showEdit: Boolean,
 | 
			
		||||
    textKey: {
 | 
			
		||||
      type: String,
 | 
			
		||||
      default: 'name'
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  data() {
 | 
			
		||||
    return {
 | 
			
		||||
@ -66,20 +76,36 @@ export default {
 | 
			
		||||
  computed: {
 | 
			
		||||
    selected: {
 | 
			
		||||
      get() {
 | 
			
		||||
        return this.value
 | 
			
		||||
        return this.value || []
 | 
			
		||||
      },
 | 
			
		||||
      set(val) {
 | 
			
		||||
        this.$emit('input', val)
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    wrapperClass() {
 | 
			
		||||
      var classes = []
 | 
			
		||||
      if (this.disabled) classes.push('bg-black-300')
 | 
			
		||||
      else classes.push('bg-primary')
 | 
			
		||||
      if (!this.readonly) classes.push('cursor-text')
 | 
			
		||||
      return classes.join(' ')
 | 
			
		||||
    },
 | 
			
		||||
    showMenu() {
 | 
			
		||||
      return this.isFocused
 | 
			
		||||
      return this.isFocused && this.currentSearch
 | 
			
		||||
    },
 | 
			
		||||
    itemsToShow() {
 | 
			
		||||
      return this.items
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  methods: {
 | 
			
		||||
    addItem() {
 | 
			
		||||
      this.$emit('add')
 | 
			
		||||
    },
 | 
			
		||||
    editItem(item) {
 | 
			
		||||
      this.$emit('edit', item)
 | 
			
		||||
    },
 | 
			
		||||
    getIsSelected(itemValue) {
 | 
			
		||||
      return !!this.selected.find((i) => i.id === itemValue)
 | 
			
		||||
    },
 | 
			
		||||
    async search() {
 | 
			
		||||
      if (this.searching) return
 | 
			
		||||
      this.currentSearch = this.textInput
 | 
			
		||||
@ -96,7 +122,7 @@ export default {
 | 
			
		||||
      clearTimeout(this.typingTimeout)
 | 
			
		||||
      this.typingTimeout = setTimeout(() => {
 | 
			
		||||
        this.search()
 | 
			
		||||
      }, 500)
 | 
			
		||||
      }, 250)
 | 
			
		||||
      this.setInputWidth()
 | 
			
		||||
    },
 | 
			
		||||
    setInputWidth() {
 | 
			
		||||
@ -165,7 +191,7 @@ export default {
 | 
			
		||||
      if (this.textInput) this.submitForm()
 | 
			
		||||
      if (this.$refs.input) this.$refs.input.blur()
 | 
			
		||||
    },
 | 
			
		||||
    clickedOption(e, itemValue) {
 | 
			
		||||
    clickedOption(e, item) {
 | 
			
		||||
      if (e) {
 | 
			
		||||
        e.stopPropagation()
 | 
			
		||||
        e.preventDefault()
 | 
			
		||||
@ -173,11 +199,11 @@ export default {
 | 
			
		||||
      if (this.$refs.input) this.$refs.input.focus()
 | 
			
		||||
 | 
			
		||||
      var newSelected = null
 | 
			
		||||
      if (this.selected.includes(itemValue)) {
 | 
			
		||||
        newSelected = this.selected.filter((s) => s !== itemValue)
 | 
			
		||||
        this.$emit('removedItem', itemValue)
 | 
			
		||||
      if (this.getIsSelected(item.id)) {
 | 
			
		||||
        newSelected = this.selected.filter((s) => s.id !== item.id)
 | 
			
		||||
        this.$emit('removedItem', item.id)
 | 
			
		||||
      } else {
 | 
			
		||||
        newSelected = this.selected.concat([itemValue])
 | 
			
		||||
        newSelected = this.selected.concat([item])
 | 
			
		||||
      }
 | 
			
		||||
      this.textInput = null
 | 
			
		||||
      this.currentSearch = null
 | 
			
		||||
@ -193,10 +219,10 @@ export default {
 | 
			
		||||
      }
 | 
			
		||||
      this.focus()
 | 
			
		||||
    },
 | 
			
		||||
    removeItem(item) {
 | 
			
		||||
      var remaining = this.selected.filter((i) => i !== item)
 | 
			
		||||
    removeItem(itemId) {
 | 
			
		||||
      var remaining = this.selected.filter((i) => i.id !== itemId)
 | 
			
		||||
      this.$emit('input', remaining)
 | 
			
		||||
      this.$emit('removedItem', item)
 | 
			
		||||
      this.$emit('removedItem', itemId)
 | 
			
		||||
      this.$nextTick(() => {
 | 
			
		||||
        this.recalcMenuPos()
 | 
			
		||||
      })
 | 
			
		||||
@ -221,7 +247,10 @@ export default {
 | 
			
		||||
      if (matchesItem) {
 | 
			
		||||
        this.clickedOption(null, matchesItem)
 | 
			
		||||
      } else {
 | 
			
		||||
        this.insertNewItem(this.textInput)
 | 
			
		||||
        this.insertNewItem({
 | 
			
		||||
          id: `new-${Date.now()}`,
 | 
			
		||||
          name: this.textInput
 | 
			
		||||
        })
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    scroll() {
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										157
									
								
								client/components/ui/QueryInput.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										157
									
								
								client/components/ui/QueryInput.vue
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,157 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <div class="w-full">
 | 
			
		||||
    <p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">{{ label }}</p>
 | 
			
		||||
    <div ref="wrapper" class="relative">
 | 
			
		||||
      <form @submit.prevent="submitForm">
 | 
			
		||||
        <div ref="inputWrapper" class="input-wrapper flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded px-2 py-2" :class="disabled ? 'pointer-events-none bg-black-300 text-gray-400' : 'bg-primary'">
 | 
			
		||||
          <input ref="input" v-model="textInput" :disabled="disabled" class="h-full w-full bg-transparent focus:outline-none px-1" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" />
 | 
			
		||||
        </div>
 | 
			
		||||
      </form>
 | 
			
		||||
 | 
			
		||||
      <ul ref="menu" v-show="isFocused && 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">
 | 
			
		||||
        <template v-for="item in items">
 | 
			
		||||
          <li :key="item.id" 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">
 | 
			
		||||
              <span class="font-normal ml-3 block truncate">{{ item.name }}</span>
 | 
			
		||||
            </div>
 | 
			
		||||
            <span v-if="isItemSelected(item)" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
 | 
			
		||||
              <span class="material-icons text-xl">checkmark</span>
 | 
			
		||||
            </span>
 | 
			
		||||
          </li>
 | 
			
		||||
        </template>
 | 
			
		||||
        <li v-if="!items.length" class="text-gray-50 select-none relative py-2 pr-9" role="option">
 | 
			
		||||
          <div class="flex items-center justify-center">
 | 
			
		||||
            <span class="font-normal">No items</span>
 | 
			
		||||
          </div>
 | 
			
		||||
        </li>
 | 
			
		||||
      </ul>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script>
 | 
			
		||||
export default {
 | 
			
		||||
  props: {
 | 
			
		||||
    value: String,
 | 
			
		||||
    disabled: Boolean,
 | 
			
		||||
    label: String,
 | 
			
		||||
    endpoint: String
 | 
			
		||||
  },
 | 
			
		||||
  data() {
 | 
			
		||||
    return {
 | 
			
		||||
      isFocused: false,
 | 
			
		||||
      currentSearch: null,
 | 
			
		||||
      typingTimeout: null,
 | 
			
		||||
      textInput: null,
 | 
			
		||||
      searching: false,
 | 
			
		||||
      items: [],
 | 
			
		||||
      selectedItemObject: null
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  watch: {
 | 
			
		||||
    value: {
 | 
			
		||||
      immediate: true,
 | 
			
		||||
      handler(newVal) {
 | 
			
		||||
        this.textInput = newVal
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  computed: {
 | 
			
		||||
    input: {
 | 
			
		||||
      get() {
 | 
			
		||||
        return this.value || ''
 | 
			
		||||
      },
 | 
			
		||||
      set(val) {
 | 
			
		||||
        this.$emit('input', val)
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  methods: {
 | 
			
		||||
    isItemSelected(item) {
 | 
			
		||||
      return !!this.input.toLowerCase() === item.name
 | 
			
		||||
    },
 | 
			
		||||
    async search() {
 | 
			
		||||
      if (this.searching) return
 | 
			
		||||
      this.currentSearch = this.textInput
 | 
			
		||||
      this.searching = true
 | 
			
		||||
      var results = await this.$axios.$get(`/api/${this.endpoint}?q=${this.currentSearch}&limit=15`).catch((error) => {
 | 
			
		||||
        console.error('Failed to get search results', error)
 | 
			
		||||
        return []
 | 
			
		||||
      })
 | 
			
		||||
      console.log('Search results', results)
 | 
			
		||||
      this.items = results || []
 | 
			
		||||
      this.searching = false
 | 
			
		||||
    },
 | 
			
		||||
    keydownInput() {
 | 
			
		||||
      clearTimeout(this.typingTimeout)
 | 
			
		||||
      this.typingTimeout = setTimeout(() => {
 | 
			
		||||
        this.search()
 | 
			
		||||
      }, 250)
 | 
			
		||||
    },
 | 
			
		||||
    inputFocus() {
 | 
			
		||||
      this.isFocused = true
 | 
			
		||||
    },
 | 
			
		||||
    blur() {
 | 
			
		||||
      // Handle blur immediately
 | 
			
		||||
      this.isFocused = false
 | 
			
		||||
      if (this.inputName.toLowerCase() !== this.textInput.toLowerCase()) {
 | 
			
		||||
        var val = this.textInput ? this.textInput.trim() : null
 | 
			
		||||
        if (val) {
 | 
			
		||||
          this.submitForm()
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (this.$refs.input) {
 | 
			
		||||
        this.$refs.input.blur()
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    inputBlur() {
 | 
			
		||||
      if (!this.isFocused) return
 | 
			
		||||
 | 
			
		||||
      setTimeout(() => {
 | 
			
		||||
        if (document.activeElement === this.$refs.input) {
 | 
			
		||||
          return
 | 
			
		||||
        }
 | 
			
		||||
        this.isFocused = false
 | 
			
		||||
        if (this.input !== this.textInput) {
 | 
			
		||||
          var val = this.textInput ? this.textInput.trim() : null
 | 
			
		||||
          if (val) {
 | 
			
		||||
            this.setItem(val)
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      }, 50)
 | 
			
		||||
    },
 | 
			
		||||
    submitForm() {
 | 
			
		||||
      var val = this.textInput ? this.textInput.trim() : null
 | 
			
		||||
      if (val) {
 | 
			
		||||
        this.setItem(val)
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    setItem(itemText) {
 | 
			
		||||
      if (!this.items.find((i) => i.name.toLowerCase() !== val.toLowerCase())) {
 | 
			
		||||
        var newItem = {
 | 
			
		||||
          id: `new-${Date.now()}`,
 | 
			
		||||
          name: val
 | 
			
		||||
        }
 | 
			
		||||
        this.$emit('selected', newItem)
 | 
			
		||||
        this.input = val
 | 
			
		||||
      } else {
 | 
			
		||||
        var item = this.items.find((i) => i.name.toLowerCase() !== val.toLowerCase())
 | 
			
		||||
        this.$emit('selected', item)
 | 
			
		||||
        this.input = item.name
 | 
			
		||||
      }
 | 
			
		||||
      this.currentSearch = null
 | 
			
		||||
    },
 | 
			
		||||
    clickedOption(e, item) {
 | 
			
		||||
      this.textInput = item.name
 | 
			
		||||
      this.currentSearch = null
 | 
			
		||||
      this.input = item.name
 | 
			
		||||
      this.selectedItemObject = item
 | 
			
		||||
      this.$emit('selected', item)
 | 
			
		||||
 | 
			
		||||
      if (this.$refs.input) this.$refs.input.blur()
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  mounted() {}
 | 
			
		||||
}
 | 
			
		||||
</script>
 | 
			
		||||
@ -209,6 +209,12 @@ export default {
 | 
			
		||||
    libraryRemoved(library) {
 | 
			
		||||
      this.$store.commit('libraries/remove', library)
 | 
			
		||||
    },
 | 
			
		||||
    libraryItemUpdated(libraryItem) {
 | 
			
		||||
      if (this.$store.state.selectedLibraryItem && this.$store.state.selectedLibraryItem.id === libraryItem.id) {
 | 
			
		||||
        this.$store.commit('setSelectedLibraryItem', libraryItem)
 | 
			
		||||
      }
 | 
			
		||||
      this.$eventBus.$emit(`${libraryItem.id}_updated`, libraryItem)
 | 
			
		||||
    },
 | 
			
		||||
    scanComplete(data) {
 | 
			
		||||
      console.log('Scan complete received', data)
 | 
			
		||||
 | 
			
		||||
@ -395,6 +401,9 @@ export default {
 | 
			
		||||
      this.socket.on('library_added', this.libraryAdded)
 | 
			
		||||
      this.socket.on('library_removed', this.libraryRemoved)
 | 
			
		||||
 | 
			
		||||
      // Library Item Listeners
 | 
			
		||||
      this.socket.on('item_updated', this.libraryItemUpdated)
 | 
			
		||||
 | 
			
		||||
      // User Listeners
 | 
			
		||||
      this.socket.on('user_updated', this.userUpdated)
 | 
			
		||||
      this.socket.on('user_online', this.userOnline)
 | 
			
		||||
 | 
			
		||||
@ -15,6 +15,7 @@ const CollectionController = require('./controllers/CollectionController')
 | 
			
		||||
const MeController = require('./controllers/MeController')
 | 
			
		||||
const BackupController = require('./controllers/BackupController')
 | 
			
		||||
const LibraryItemController = require('./controllers/LibraryItemController')
 | 
			
		||||
const SeriesController = require('./controllers/SeriesController')
 | 
			
		||||
 | 
			
		||||
const BookFinder = require('./finders/BookFinder')
 | 
			
		||||
const AuthorFinder = require('./finders/AuthorFinder')
 | 
			
		||||
@ -74,6 +75,8 @@ class ApiController {
 | 
			
		||||
    // Item Routes
 | 
			
		||||
    //
 | 
			
		||||
    this.router.get('/items/:id', LibraryItemController.middleware.bind(this), LibraryItemController.findOne.bind(this))
 | 
			
		||||
    this.router.patch('/items/:id', LibraryItemController.middleware.bind(this), LibraryItemController.update.bind(this))
 | 
			
		||||
    this.router.patch('/items/:id/media', LibraryItemController.middleware.bind(this), LibraryItemController.updateMedia.bind(this))
 | 
			
		||||
    this.router.get('/items/:id/cover', LibraryItemController.middleware.bind(this), LibraryItemController.getCover.bind(this))
 | 
			
		||||
 | 
			
		||||
    //
 | 
			
		||||
@ -152,7 +155,7 @@ class ApiController {
 | 
			
		||||
    this.router.get('/filesystem', FileSystemController.getPaths.bind(this))
 | 
			
		||||
 | 
			
		||||
    //
 | 
			
		||||
    // Others
 | 
			
		||||
    // Author Routes
 | 
			
		||||
    //
 | 
			
		||||
    this.router.get('/authors', this.getAuthors.bind(this))
 | 
			
		||||
    this.router.get('/authors/search', this.searchAuthors.bind(this))
 | 
			
		||||
@ -161,6 +164,15 @@ class ApiController {
 | 
			
		||||
    this.router.patch('/authors/:id', this.updateAuthor.bind(this))
 | 
			
		||||
    this.router.delete('/authors/:id', this.deleteAuthor.bind(this))
 | 
			
		||||
 | 
			
		||||
    //
 | 
			
		||||
    // Series Routes
 | 
			
		||||
    //
 | 
			
		||||
    this.router.get('/series/search', SeriesController.search.bind(this))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    //
 | 
			
		||||
    // Misc Routes
 | 
			
		||||
    //
 | 
			
		||||
    this.router.patch('/serverSettings', this.updateServerSettings.bind(this))
 | 
			
		||||
 | 
			
		||||
    this.router.post('/authorize', this.authorize.bind(this))
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										14
									
								
								server/Db.js
									
									
									
									
									
								
							
							
						
						
									
										14
									
								
								server/Db.js
									
									
									
									
									
								
							@ -184,6 +184,20 @@ class Db {
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async updateLibraryItem(libraryItem) {
 | 
			
		||||
    if (libraryItem && libraryItem.saveMetadata) {
 | 
			
		||||
      await libraryItem.saveMetadata()
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return this.libraryItemsDb.update((record) => record.id === libraryItem.id, () => libraryItem).then((results) => {
 | 
			
		||||
      Logger.debug(`[DB] Library Item updated ${results.updated}`)
 | 
			
		||||
      return true
 | 
			
		||||
    }).catch((error) => {
 | 
			
		||||
      Logger.error(`[DB] Library Item update failed ${error}`)
 | 
			
		||||
      return false
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async updateAudiobook(audiobook) {
 | 
			
		||||
    if (audiobook && audiobook.saveAbMetadata) {
 | 
			
		||||
      // TODO: Book may have updates where this save is not necessary
 | 
			
		||||
 | 
			
		||||
@ -1,4 +1,6 @@
 | 
			
		||||
const Logger = require('../Logger')
 | 
			
		||||
const Author = require('../objects/entities/Author')
 | 
			
		||||
const Series = require('../objects/entities/Series')
 | 
			
		||||
const { reqSupportsWebp } = require('../utils/index')
 | 
			
		||||
 | 
			
		||||
class LibraryItemController {
 | 
			
		||||
@ -9,6 +11,95 @@ class LibraryItemController {
 | 
			
		||||
    res.json(req.libraryItem)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async update(req, res) {
 | 
			
		||||
    if (!req.user.canUpdate) {
 | 
			
		||||
      Logger.warn('User attempted to update without permission', req.user)
 | 
			
		||||
      return res.sendStatus(403)
 | 
			
		||||
    }
 | 
			
		||||
    var libraryItem = req.libraryItem
 | 
			
		||||
    // Item has cover and update is removing cover so purge it from cache
 | 
			
		||||
    if (libraryItem.media.coverPath && req.body.media && (req.body.media.coverPath === '' || req.body.media.coverPath === null)) {
 | 
			
		||||
      await this.cacheManager.purgeCoverCache(libraryItem.id)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    var hasUpdates = libraryItem.update(req.body)
 | 
			
		||||
    if (hasUpdates) {
 | 
			
		||||
      Logger.debug(`[LibraryItemController] Updated now saving`)
 | 
			
		||||
      await this.db.updateLibraryItem(libraryItem)
 | 
			
		||||
      this.emitter('item_updated', libraryItem.toJSONExpanded())
 | 
			
		||||
    }
 | 
			
		||||
    res.json(libraryItem.toJSON())
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  //
 | 
			
		||||
  // PATCH: will create new authors & series if in payload
 | 
			
		||||
  //
 | 
			
		||||
  async updateMedia(req, res) {
 | 
			
		||||
    if (!req.user.canUpdate) {
 | 
			
		||||
      Logger.warn('User attempted to update without permission', req.user)
 | 
			
		||||
      return res.sendStatus(403)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    var libraryItem = req.libraryItem
 | 
			
		||||
    var mediaPayload = req.body
 | 
			
		||||
    // Item has cover and update is removing cover so purge it from cache
 | 
			
		||||
    if (libraryItem.media.coverPath && (mediaPayload.coverPath === '' || mediaPayload.coverPath === null)) {
 | 
			
		||||
      await this.cacheManager.purgeCoverCache(libraryItem.id)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (mediaPayload.metadata) {
 | 
			
		||||
      var mediaMetadata = mediaPayload.metadata
 | 
			
		||||
 | 
			
		||||
      // Create new authors if in payload
 | 
			
		||||
      if (mediaMetadata.authors && mediaMetadata.authors.length) {
 | 
			
		||||
        // TODO: validate authors
 | 
			
		||||
        var newAuthors = []
 | 
			
		||||
        for (let i = 0; i < mediaMetadata.authors.length; i++) {
 | 
			
		||||
          if (mediaMetadata.authors[i].id.startsWith('new')) {
 | 
			
		||||
            var newAuthor = new Author()
 | 
			
		||||
            newAuthor.setData(mediaMetadata.authors[i])
 | 
			
		||||
            Logger.debug(`[LibraryItemController] Created new author "${newAuthor.name}"`)
 | 
			
		||||
            newAuthors.push(newAuthor)
 | 
			
		||||
            // Update ID in original payload
 | 
			
		||||
            mediaMetadata.authors[i].id = newAuthor.id
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
        if (newAuthors.length) {
 | 
			
		||||
          await this.db.insertEntities('author', newAuthors)
 | 
			
		||||
          this.emitter('authors_added', newAuthors)
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // Create new series if in payload
 | 
			
		||||
      if (mediaMetadata.series && mediaMetadata.series.length) {
 | 
			
		||||
        // TODO: validate series
 | 
			
		||||
        var newSeries = []
 | 
			
		||||
        for (let i = 0; i < mediaMetadata.series.length; i++) {
 | 
			
		||||
          if (mediaMetadata.series[i].id.startsWith('new')) {
 | 
			
		||||
            var newSeriesItem = new Series()
 | 
			
		||||
            newSeriesItem.setData(mediaMetadata.series[i])
 | 
			
		||||
            Logger.debug(`[LibraryItemController] Created new series "${newSeriesItem.name}"`)
 | 
			
		||||
            newSeries.push(newSeriesItem)
 | 
			
		||||
            // Update ID in original payload
 | 
			
		||||
            mediaMetadata.series[i].id = newSeriesItem.id
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
        if (newSeries.length) {
 | 
			
		||||
          await this.db.insertEntities('series', newSeries)
 | 
			
		||||
          this.emitter('authors_added', newSeries)
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    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())
 | 
			
		||||
    }
 | 
			
		||||
    res.json(libraryItem)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // GET api/items/:id/cover
 | 
			
		||||
  async getCover(req, res) {
 | 
			
		||||
    let { query: { width, height, format }, libraryItem } = req
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										15
									
								
								server/controllers/SeriesController.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								server/controllers/SeriesController.js
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,15 @@
 | 
			
		||||
const Logger = require('../Logger')
 | 
			
		||||
 | 
			
		||||
class SeriesController {
 | 
			
		||||
  constructor() { }
 | 
			
		||||
 | 
			
		||||
  async search(req, res) {
 | 
			
		||||
    var q = (req.query.q || '').toLowerCase()
 | 
			
		||||
    if (!q) return res.json([])
 | 
			
		||||
    var limit = (req.query.limit && !isNaN(req.query.limit)) ? Number(req.query.limit) : 25
 | 
			
		||||
    var series = this.db.series.filter(se => se.name.toLowerCase().includes(q))
 | 
			
		||||
    series = series.slice(0, limit)
 | 
			
		||||
    res.json(series)
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
module.exports = new SeriesController()
 | 
			
		||||
@ -2,6 +2,7 @@ const Logger = require('../Logger')
 | 
			
		||||
const LibraryFile = require('./files/LibraryFile')
 | 
			
		||||
const Book = require('./entities/Book')
 | 
			
		||||
const Podcast = require('./entities/Podcast')
 | 
			
		||||
const { areEquivalent, copyValue } = require('../utils/index')
 | 
			
		||||
 | 
			
		||||
class LibraryItem {
 | 
			
		||||
  constructor(libraryItem = null) {
 | 
			
		||||
@ -132,5 +133,23 @@ class LibraryItem {
 | 
			
		||||
    this.libraryFiles.forEach((lf) => total += lf.metadata.size)
 | 
			
		||||
    return total
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  update(payload) {
 | 
			
		||||
    var json = this.toJSON()
 | 
			
		||||
    var hasUpdates = false
 | 
			
		||||
    for (const key in json) {
 | 
			
		||||
      if (payload[key] !== undefined) {
 | 
			
		||||
        if (key === 'media') {
 | 
			
		||||
          if (this.media.update(payload[key])) {
 | 
			
		||||
            hasUpdates = true
 | 
			
		||||
          }
 | 
			
		||||
        } else if (!areEquivalent(payload[key], json[key])) {
 | 
			
		||||
          this[key] = copyValue(payload[key])
 | 
			
		||||
          hasUpdates = true
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    return hasUpdates
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
module.exports = LibraryItem
 | 
			
		||||
@ -1,6 +1,8 @@
 | 
			
		||||
const Logger = require('../../Logger')
 | 
			
		||||
const BookMetadata = require('../metadata/BookMetadata')
 | 
			
		||||
const AudioFile = require('../files/AudioFile')
 | 
			
		||||
const EBookFile = require('../files/EBookFile')
 | 
			
		||||
const { areEquivalent, copyValue } = require('../../utils/index')
 | 
			
		||||
 | 
			
		||||
class Book {
 | 
			
		||||
  constructor(book) {
 | 
			
		||||
@ -78,5 +80,24 @@ class Book {
 | 
			
		||||
    this.audioFiles.forEach((af) => total += af.metadata.size)
 | 
			
		||||
    return total
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  update(payload) {
 | 
			
		||||
    var json = this.toJSON()
 | 
			
		||||
    var hasUpdates = false
 | 
			
		||||
    for (const key in json) {
 | 
			
		||||
      if (payload[key] !== undefined) {
 | 
			
		||||
        if (key === 'metadata') {
 | 
			
		||||
          if (this.metadata.update(payload.metadata)) {
 | 
			
		||||
            hasUpdates = true
 | 
			
		||||
          }
 | 
			
		||||
        } else if (!areEquivalent(payload[key], json[key])) {
 | 
			
		||||
          this[key] = copyValue(payload[key])
 | 
			
		||||
          Logger.debug('[Book] Key updated', key, this[key])
 | 
			
		||||
          hasUpdates = true
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    return hasUpdates
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
module.exports = Book
 | 
			
		||||
@ -1,3 +1,6 @@
 | 
			
		||||
const Logger = require('../../Logger')
 | 
			
		||||
const { areEquivalent, copyValue } = require('../../utils/index')
 | 
			
		||||
 | 
			
		||||
class BookMetadata {
 | 
			
		||||
  constructor(metadata) {
 | 
			
		||||
    this.title = null
 | 
			
		||||
@ -100,5 +103,20 @@ class BookMetadata {
 | 
			
		||||
  hasNarrator(narratorName) {
 | 
			
		||||
    return this.narrators.includes(narratorName)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  update(payload) {
 | 
			
		||||
    var json = this.toJSON()
 | 
			
		||||
    var hasUpdates = false
 | 
			
		||||
    for (const key in json) {
 | 
			
		||||
      if (payload[key] !== undefined) {
 | 
			
		||||
        if (!areEquivalent(payload[key], json[key])) {
 | 
			
		||||
          this[key] = copyValue(payload[key])
 | 
			
		||||
          Logger.debug('[BookMetadata] Key updated', key, this[key])
 | 
			
		||||
          hasUpdates = true
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    return hasUpdates
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
module.exports = BookMetadata
 | 
			
		||||
							
								
								
									
										142
									
								
								server/utils/areEquivalent.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										142
									
								
								server/utils/areEquivalent.js
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,142 @@
 | 
			
		||||
/**
 | 
			
		||||
 * https://gist.github.com/DLiblik/96801665f9b6c935f12c1071d37eae95
 | 
			
		||||
 Compares two items (values or references) for nested equivalency, meaning that
 | 
			
		||||
 at root and at each key or index they are equivalent as follows:
 | 
			
		||||
 - If a value type, values are either hard equal (===) or are both NaN
 | 
			
		||||
     (different than JS where NaN !== NaN)
 | 
			
		||||
 - If functions, they are the same function instance or have the same value
 | 
			
		||||
     when converted to string via `toString()`
 | 
			
		||||
 - If Date objects, both have the same getTime() or are both NaN (invalid)
 | 
			
		||||
 - If arrays, both are same length, and all contained values areEquivalent
 | 
			
		||||
     recursively - only contents by numeric key are checked
 | 
			
		||||
 - If other object types, enumerable keys are the same (the keys themselves)
 | 
			
		||||
     and values at every key areEquivalent recursively
 | 
			
		||||
 Author: Dathan Liblik
 | 
			
		||||
 License: Free to use anywhere by anyone, as-is, no guarantees of any kind.
 | 
			
		||||
 @param value1 First item to compare
 | 
			
		||||
 @param value2 Other item to compare
 | 
			
		||||
 @param stack Used internally to track circular refs - don't set it
 | 
			
		||||
 */
 | 
			
		||||
module.exports = function areEquivalent(value1, value2, stack = []) {
 | 
			
		||||
  // Numbers, strings, null, undefined, symbols, functions, booleans.
 | 
			
		||||
  // Also: objects (incl. arrays) that are actually the same instance
 | 
			
		||||
  if (value1 === value2) {
 | 
			
		||||
    // Fast and done
 | 
			
		||||
    return true;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const type1 = typeof value1;
 | 
			
		||||
 | 
			
		||||
  // Ensure types match
 | 
			
		||||
  if (type1 !== typeof value2) {
 | 
			
		||||
    return false;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Special case for number: check for NaN on both sides
 | 
			
		||||
  // (only way they can still be equivalent but not equal)
 | 
			
		||||
  if (type1 === 'number') {
 | 
			
		||||
    // Failed initial equals test, but could still both be NaN
 | 
			
		||||
    return (isNaN(value1) && isNaN(value2));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Special case for function: check for toString() equivalence
 | 
			
		||||
  if (type1 === 'function') {
 | 
			
		||||
    // Failed initial equals test, but could still have equivalent
 | 
			
		||||
    // implementations - note, will match on functions that have same name
 | 
			
		||||
    // and are native code: `function abc() { [native code] }`
 | 
			
		||||
    return value1.toString() === value2.toString();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // For these types, cannot still be equal at this point, so fast-fail
 | 
			
		||||
  if (type1 === 'bigint' || type1 === 'boolean' ||
 | 
			
		||||
    type1 === 'function' || type1 === 'string' ||
 | 
			
		||||
    type1 === 'symbol') {
 | 
			
		||||
    return false;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // For dates, cast to number and ensure equal or both NaN (note, if same
 | 
			
		||||
  // exact instance then we're not here - that was checked above)
 | 
			
		||||
  if (value1 instanceof Date) {
 | 
			
		||||
    if (!(value2 instanceof Date)) {
 | 
			
		||||
      return false;
 | 
			
		||||
    }
 | 
			
		||||
    // Convert to number to compare
 | 
			
		||||
    const asNum1 = +value1, asNum2 = +value2;
 | 
			
		||||
    // Check if both invalid (NaN) or are same value
 | 
			
		||||
    return asNum1 === asNum2 || (isNaN(asNum1) && isNaN(asNum2));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // At this point, it's a reference type and could be circular, so
 | 
			
		||||
  // make sure we haven't been here before... note we only need to track value1
 | 
			
		||||
  // since value1 being un-circular means value2 will either be equal (and not
 | 
			
		||||
  // circular too) or unequal whether circular or not.
 | 
			
		||||
  if (stack.includes(value1)) {
 | 
			
		||||
    throw new Error(`areEquivalent value1 is circular`);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // breadcrumb
 | 
			
		||||
  stack.push(value1);
 | 
			
		||||
 | 
			
		||||
  // Handle arrays
 | 
			
		||||
  if (Array.isArray(value1)) {
 | 
			
		||||
    if (!Array.isArray(value2)) {
 | 
			
		||||
      return false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const length = value1.length;
 | 
			
		||||
 | 
			
		||||
    if (length !== value2.length) {
 | 
			
		||||
      return false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    for (let i = 0; i < length; i++) {
 | 
			
		||||
      if (!areEquivalent(value1[i], value2[i], stack)) {
 | 
			
		||||
        return false;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    return true;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Final case: object
 | 
			
		||||
 | 
			
		||||
  // get both key lists and check length
 | 
			
		||||
  const keys1 = Object.keys(value1);
 | 
			
		||||
  const keys2 = Object.keys(value2);
 | 
			
		||||
  const numKeys = keys1.length;
 | 
			
		||||
 | 
			
		||||
  if (keys2.length !== numKeys) {
 | 
			
		||||
    return false;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Empty object on both sides?
 | 
			
		||||
  if (numKeys === 0) {
 | 
			
		||||
    return true;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // sort is a native call so it's very fast - much faster than comparing the
 | 
			
		||||
  // values at each key if it can be avoided, so do the sort and then
 | 
			
		||||
  // ensure every key matches at every index
 | 
			
		||||
  keys1.sort();
 | 
			
		||||
  keys2.sort();
 | 
			
		||||
 | 
			
		||||
  // Ensure perfect match across all keys
 | 
			
		||||
  for (let i = 0; i < numKeys; i++) {
 | 
			
		||||
    if (keys1[i] !== keys2[i]) {
 | 
			
		||||
      return false;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Ensure perfect match across all values
 | 
			
		||||
  for (let i = 0; i < numKeys; i++) {
 | 
			
		||||
    if (!areEquivalent(value1[keys1[i]], value2[keys1[i]], stack)) {
 | 
			
		||||
      return false;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // back up
 | 
			
		||||
  stack.pop();
 | 
			
		||||
 | 
			
		||||
  // Walk the same, talk the same - matching ducks. Quack.
 | 
			
		||||
  // 🦆🦆
 | 
			
		||||
  return true;
 | 
			
		||||
}
 | 
			
		||||
@ -2,6 +2,7 @@ const Path = require('path')
 | 
			
		||||
const fs = require('fs')
 | 
			
		||||
const Logger = require('../Logger')
 | 
			
		||||
const { parseString } = require("xml2js")
 | 
			
		||||
const areEquivalent = require('./areEquivalent')
 | 
			
		||||
 | 
			
		||||
const levenshteinDistance = (str1, str2, caseSensitive = false) => {
 | 
			
		||||
  if (!caseSensitive) {
 | 
			
		||||
@ -99,4 +100,21 @@ module.exports.msToTimestamp = (ms, includeMs) => secondsToTimestamp(ms / 1000,
 | 
			
		||||
module.exports.reqSupportsWebp = (req) => {
 | 
			
		||||
  if (!req || !req.headers || !req.headers.accept) return false
 | 
			
		||||
  return req.headers.accept.includes('image/webp') || req.headers.accept === '*/*'
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
module.exports.areEquivalent = areEquivalent
 | 
			
		||||
 | 
			
		||||
module.exports.copyValue = (val) => {
 | 
			
		||||
  if (!val) return null
 | 
			
		||||
  if (!this.isObject(val)) return val
 | 
			
		||||
 | 
			
		||||
  if (Array.isArray(val)) {
 | 
			
		||||
    return val.map(this.copyValue)
 | 
			
		||||
  } else {
 | 
			
		||||
    var final = {}
 | 
			
		||||
    for (const key in val) {
 | 
			
		||||
      final[key] = this.copyValue(val[key])
 | 
			
		||||
    }
 | 
			
		||||
    return final
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@ -97,12 +97,12 @@ module.exports = {
 | 
			
		||||
      var mediaMetadata = li.media.metadata
 | 
			
		||||
      if (mediaMetadata.authors.length) {
 | 
			
		||||
        mediaMetadata.authors.forEach((author) => {
 | 
			
		||||
          if (author && !data.authors.includes(author.name)) data.authors.push(author.name)
 | 
			
		||||
          if (author && !data.authors.find(au => au.id === author.id)) data.authors.push({ id: author.id, name: author.name })
 | 
			
		||||
        })
 | 
			
		||||
      }
 | 
			
		||||
      if (mediaMetadata.series.length) {
 | 
			
		||||
        mediaMetadata.series.forEach((series) => {
 | 
			
		||||
          if (series && !data.series.includes(series.name)) data.series.push(series.name)
 | 
			
		||||
          if (series && !data.series.find(se => se.id === series.id)) data.series.push({ id: series.id, name: series.name })
 | 
			
		||||
        })
 | 
			
		||||
      }
 | 
			
		||||
      if (mediaMetadata.genres.length) {
 | 
			
		||||
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user