mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-10-27 11:18:14 +01:00 
			
		
		
		
	Add:Manage genres #1163
This commit is contained in:
		
							parent
							
								
									106ddc9541
								
							
						
					
					
						commit
						4971787482
					
				@ -405,8 +405,6 @@ export default {
 | 
				
			|||||||
      }
 | 
					      }
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    removeListeners() {
 | 
					    removeListeners() {
 | 
				
			||||||
      this.$store.commit('user/removeSettingsListener', 'bookshelf')
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      if (this.$root.socket) {
 | 
					      if (this.$root.socket) {
 | 
				
			||||||
        this.$root.socket.off('user_updated', this.userUpdated)
 | 
					        this.$root.socket.off('user_updated', this.userUpdated)
 | 
				
			||||||
        this.$root.socket.off('author_updated', this.authorUpdated)
 | 
					        this.$root.socket.off('author_updated', this.authorUpdated)
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										169
									
								
								client/pages/config/item-metadata-utils/genres.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										169
									
								
								client/pages/config/item-metadata-utils/genres.vue
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,169 @@
 | 
				
			|||||||
 | 
					<template>
 | 
				
			||||||
 | 
					  <div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8 relative" style="min-height: 200px">
 | 
				
			||||||
 | 
					    <div class="flex items-center mb-4">
 | 
				
			||||||
 | 
					      <nuxt-link to="/config/item-metadata-utils" class="w-8 h-8 flex items-center justify-center rounded-full cursor-pointer hover:bg-white hover:bg-opacity-10 text-center">
 | 
				
			||||||
 | 
					        <span class="material-icons text-2xl">arrow_back</span>
 | 
				
			||||||
 | 
					      </nuxt-link>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      <h1 class="text-xl mx-2">Manage Genres</h1>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <p v-if="!genres.length && !loading" class="text-center py-8 text-lg">No Genres</p>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <div class="border border-white/10">
 | 
				
			||||||
 | 
					      <template v-for="(genre, index) in genres">
 | 
				
			||||||
 | 
					        <div :key="genre" class="w-full p-2 flex items-center text-gray-400 hover:text-white" :class="{ 'bg-primary/20': index % 2 === 0 }">
 | 
				
			||||||
 | 
					          <p v-if="editingGenre !== genre" class="text-sm md:text-base text-gray-100">{{ genre }}</p>
 | 
				
			||||||
 | 
					          <ui-text-input v-else v-model="newGenreName" />
 | 
				
			||||||
 | 
					          <div class="flex-grow" />
 | 
				
			||||||
 | 
					          <template v-if="editingGenre !== genre">
 | 
				
			||||||
 | 
					            <ui-icon-btn v-if="editingGenre !== genre" icon="edit" borderless :size="8" icon-font-size="1.1rem" class="mx-1" @click="editClick(genre)" />
 | 
				
			||||||
 | 
					            <ui-icon-btn v-if="editingGenre !== genre" icon="delete" borderless :size="8" icon-font-size="1.1rem" @click="removeClick(genre)" />
 | 
				
			||||||
 | 
					          </template>
 | 
				
			||||||
 | 
					          <template v-else>
 | 
				
			||||||
 | 
					            <ui-btn color="success" small class="mx-2" @click.stop="saveClick">{{ $strings.ButtonSave }}</ui-btn>
 | 
				
			||||||
 | 
					            <ui-btn small @click.stop="cancelEditClick">{{ $strings.ButtonCancel }}</ui-btn>
 | 
				
			||||||
 | 
					          </template>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </template>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <div v-if="loading" class="absolute top-0 left-0 w-full h-full bg-black/25 flex items-center justify-center">
 | 
				
			||||||
 | 
					      <ui-loading-indicator />
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<script>
 | 
				
			||||||
 | 
					export default {
 | 
				
			||||||
 | 
					  data() {
 | 
				
			||||||
 | 
					    return {
 | 
				
			||||||
 | 
					      loading: false,
 | 
				
			||||||
 | 
					      genres: [],
 | 
				
			||||||
 | 
					      editingGenre: null,
 | 
				
			||||||
 | 
					      newGenreName: ''
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  watch: {},
 | 
				
			||||||
 | 
					  computed: {},
 | 
				
			||||||
 | 
					  methods: {
 | 
				
			||||||
 | 
					    cancelEditClick() {
 | 
				
			||||||
 | 
					      this.newGenreName = ''
 | 
				
			||||||
 | 
					      this.editingGenre = null
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    removeClick(genre) {
 | 
				
			||||||
 | 
					      const payload = {
 | 
				
			||||||
 | 
					        message: `Are you sure you want to remove genre "${genre}" from all items?`,
 | 
				
			||||||
 | 
					        callback: (confirmed) => {
 | 
				
			||||||
 | 
					          if (confirmed) {
 | 
				
			||||||
 | 
					            this.removeGenre(genre)
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        type: 'yesNo'
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      this.$store.commit('globals/setConfirmPrompt', payload)
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    editClick(genre) {
 | 
				
			||||||
 | 
					      this.newGenreName = genre
 | 
				
			||||||
 | 
					      this.editingGenre = genre
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    saveClick() {
 | 
				
			||||||
 | 
					      this.newGenreName = this.newGenreName.trim()
 | 
				
			||||||
 | 
					      if (!this.newGenreName) {
 | 
				
			||||||
 | 
					        return
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (this.editingGenre === this.newGenreName) {
 | 
				
			||||||
 | 
					        this.cancelEditClick()
 | 
				
			||||||
 | 
					        return
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const genreNameExists = this.genres.find((g) => g !== this.editingGenre && g === this.newGenreName)
 | 
				
			||||||
 | 
					      const genreNameExistsOfDifferentCase = !genreNameExists ? this.genres.find((g) => g !== this.editingGenre && g.toLowerCase() === this.newGenreName.toLowerCase()) : null
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      let message = `Are you sure you want to rename genre "${this.editingGenre}" to "${this.newGenreName}" for all items?`
 | 
				
			||||||
 | 
					      if (genreNameExists) {
 | 
				
			||||||
 | 
					        message += '<br><span class="text-sm">Note: This genre already exists so the two genres will be merged.</span>'
 | 
				
			||||||
 | 
					      } else if (genreNameExistsOfDifferentCase) {
 | 
				
			||||||
 | 
					        message += `<br><span class="text-warning text-sm">Warning! A similar genre with a different casing already exists "${genreNameExistsOfDifferentCase}".</span>`
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const payload = {
 | 
				
			||||||
 | 
					        message,
 | 
				
			||||||
 | 
					        callback: (confirmed) => {
 | 
				
			||||||
 | 
					          if (confirmed) {
 | 
				
			||||||
 | 
					            this.renameGenre()
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        type: 'yesNo'
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      this.$store.commit('globals/setConfirmPrompt', payload)
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    renameGenre() {
 | 
				
			||||||
 | 
					      this.loading = true
 | 
				
			||||||
 | 
					      let _newGenreName = this.newGenreName
 | 
				
			||||||
 | 
					      let _editingGenre = this.editingGenre
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const payload = {
 | 
				
			||||||
 | 
					        genre: _editingGenre,
 | 
				
			||||||
 | 
					        newGenre: _newGenreName
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      this.$axios
 | 
				
			||||||
 | 
					        .$post('/api/genres/rename', payload)
 | 
				
			||||||
 | 
					        .then((res) => {
 | 
				
			||||||
 | 
					          this.$toast.success(`${res.numItemsUpdated} Items Updated`)
 | 
				
			||||||
 | 
					          if (res.genreMerged) {
 | 
				
			||||||
 | 
					            this.genres = this.genres.filter((g) => g !== _newGenreName)
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					          this.genres = this.genres.map((g) => {
 | 
				
			||||||
 | 
					            if (g === _editingGenre) return _newGenreName
 | 
				
			||||||
 | 
					            return g
 | 
				
			||||||
 | 
					          })
 | 
				
			||||||
 | 
					          this.cancelEditClick()
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
 | 
					        .catch((error) => {
 | 
				
			||||||
 | 
					          console.error('Failed to rename genre', error)
 | 
				
			||||||
 | 
					          this.$toast.error('Failed to rename genre')
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
 | 
					        .finally(() => {
 | 
				
			||||||
 | 
					          this.loading = false
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    removeGenre(genre) {
 | 
				
			||||||
 | 
					      this.loading = true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      this.$axios
 | 
				
			||||||
 | 
					        .$delete(`/api/genres/${this.$encode(genre)}`)
 | 
				
			||||||
 | 
					        .then((res) => {
 | 
				
			||||||
 | 
					          this.$toast.success(`${res.numItemsUpdated} Items Updated`)
 | 
				
			||||||
 | 
					          this.genres = this.genres.filter((g) => g !== genre)
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
 | 
					        .catch((error) => {
 | 
				
			||||||
 | 
					          console.error('Failed to remove genre', error)
 | 
				
			||||||
 | 
					          this.$toast.error('Failed to remove genre')
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
 | 
					        .finally(() => {
 | 
				
			||||||
 | 
					          this.loading = false
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    init() {
 | 
				
			||||||
 | 
					      this.loading = true
 | 
				
			||||||
 | 
					      this.$axios
 | 
				
			||||||
 | 
					        .$get('/api/genres')
 | 
				
			||||||
 | 
					        .then((data) => {
 | 
				
			||||||
 | 
					          this.genres = data.genres || []
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
 | 
					        .catch((error) => {
 | 
				
			||||||
 | 
					          console.error('Failed to load genres', error)
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
 | 
					        .finally(() => {
 | 
				
			||||||
 | 
					          this.loading = false
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  mounted() {
 | 
				
			||||||
 | 
					    this.init()
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  beforeDestroy() {}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					</script>
 | 
				
			||||||
@ -7,12 +7,12 @@
 | 
				
			|||||||
          <span class="material-icons">arrow_forward</span>
 | 
					          <span class="material-icons">arrow_forward</span>
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
      </nuxt-link>
 | 
					      </nuxt-link>
 | 
				
			||||||
      <!-- <nuxt-link to="/config/item-metadata-utils/tags" class="block w-full rounded bg-primary/40 hover:bg-primary/60 text-gray-300 hover:text-white p-4 my-2">
 | 
					      <nuxt-link to="/config/item-metadata-utils/genres" class="block w-full rounded bg-primary/40 hover:bg-primary/60 text-gray-300 hover:text-white p-4 my-2">
 | 
				
			||||||
        <div class="flex justify-between">
 | 
					        <div class="flex justify-between">
 | 
				
			||||||
          <p>Manage Genres</p>
 | 
					          <p>Manage Genres</p>
 | 
				
			||||||
          <span class="material-icons">arrow_forward</span>
 | 
					          <span class="material-icons">arrow_forward</span>
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
      </nuxt-link> -->
 | 
					      </nuxt-link>
 | 
				
			||||||
    </app-settings-content>
 | 
					    </app-settings-content>
 | 
				
			||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
 | 
				
			|||||||
@ -76,8 +76,6 @@ export default {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
      const tagNameExists = this.tags.find((t) => t !== this.editingTag && t === this.newTagName)
 | 
					      const tagNameExists = this.tags.find((t) => t !== this.editingTag && t === this.newTagName)
 | 
				
			||||||
      const tagNameExistsOfDifferentCase = !tagNameExists ? this.tags.find((t) => t !== this.editingTag && t.toLowerCase() === this.newTagName.toLowerCase()) : null
 | 
					      const tagNameExistsOfDifferentCase = !tagNameExists ? this.tags.find((t) => t !== this.editingTag && t.toLowerCase() === this.newTagName.toLowerCase()) : null
 | 
				
			||||||
      console.log('Tag name', this.newTagName, 'ExistS?', tagNameExists, tagNameExistsOfDifferentCase)
 | 
					 | 
				
			||||||
      console.log('Saving tag', this.editingTag, 'with name', this.newTagName)
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
      let message = `Are you sure you want to rename tag "${this.editingTag}" to "${this.newTagName}" for all items?`
 | 
					      let message = `Are you sure you want to rename tag "${this.editingTag}" to "${this.newTagName}" for all items?`
 | 
				
			||||||
      if (tagNameExists) {
 | 
					      if (tagNameExists) {
 | 
				
			||||||
 | 
				
			|||||||
@ -212,6 +212,92 @@ class MiscController {
 | 
				
			|||||||
    })
 | 
					    })
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // GET: api/genres
 | 
				
			||||||
 | 
					  getAllGenres(req, res) {
 | 
				
			||||||
 | 
					    if (!req.user.isAdminOrUp) {
 | 
				
			||||||
 | 
					      Logger.error(`[MiscController] Non-admin user attempted to getAllGenres`)
 | 
				
			||||||
 | 
					      return res.sendStatus(404)
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    const genres = []
 | 
				
			||||||
 | 
					    this.db.libraryItems.forEach((li) => {
 | 
				
			||||||
 | 
					      if (li.media.metadata.genres && li.media.metadata.genres.length) {
 | 
				
			||||||
 | 
					        li.media.metadata.genres.forEach((genre) => {
 | 
				
			||||||
 | 
					          if (!genres.includes(genre)) genres.push(genre)
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					    res.json({
 | 
				
			||||||
 | 
					      genres
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // POST: api/genres/rename
 | 
				
			||||||
 | 
					  async renameGenre(req, res) {
 | 
				
			||||||
 | 
					    if (!req.user.isAdminOrUp) {
 | 
				
			||||||
 | 
					      Logger.error(`[MiscController] Non-admin user attempted to renameGenre`)
 | 
				
			||||||
 | 
					      return res.sendStatus(404)
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const genre = req.body.genre
 | 
				
			||||||
 | 
					    const newGenre = req.body.newGenre
 | 
				
			||||||
 | 
					    if (!genre || !newGenre) {
 | 
				
			||||||
 | 
					      Logger.error(`[MiscController] Invalid request body for renameGenre`)
 | 
				
			||||||
 | 
					      return res.sendStatus(400)
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let genreMerged = false
 | 
				
			||||||
 | 
					    let numItemsUpdated = 0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    for (const li of this.db.libraryItems) {
 | 
				
			||||||
 | 
					      if (!li.media.metadata.genres || !li.media.metadata.genres.length) continue
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (li.media.metadata.genres.includes(newGenre)) genreMerged = true // new genre is an existing genre so this is a merge
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (li.media.metadata.genres.includes(genre)) {
 | 
				
			||||||
 | 
					        li.media.metadata.genres = li.media.metadata.genres.filter(g => g !== genre) // Remove old genre
 | 
				
			||||||
 | 
					        if (!li.media.metadata.genres.includes(newGenre)) {
 | 
				
			||||||
 | 
					          li.media.metadata.genres.push(newGenre) // Add new genre
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        Logger.debug(`[MiscController] Rename genre "${genre}" to "${newGenre}" for item "${li.media.metadata.title}"`)
 | 
				
			||||||
 | 
					        await this.db.updateLibraryItem(li)
 | 
				
			||||||
 | 
					        SocketAuthority.emitter('item_updated', li.toJSONExpanded())
 | 
				
			||||||
 | 
					        numItemsUpdated++
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    res.json({
 | 
				
			||||||
 | 
					      genreMerged,
 | 
				
			||||||
 | 
					      numItemsUpdated
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // DELETE: api/genres/:genre
 | 
				
			||||||
 | 
					  async deleteGenre(req, res) {
 | 
				
			||||||
 | 
					    if (!req.user.isAdminOrUp) {
 | 
				
			||||||
 | 
					      Logger.error(`[MiscController] Non-admin user attempted to deleteGenre`)
 | 
				
			||||||
 | 
					      return res.sendStatus(404)
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const genre = Buffer.from(decodeURIComponent(req.params.genre), 'base64').toString()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let numItemsUpdated = 0
 | 
				
			||||||
 | 
					    for (const li of this.db.libraryItems) {
 | 
				
			||||||
 | 
					      if (!li.media.metadata.genres || !li.media.metadata.genres.length) continue
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (li.media.metadata.genres.includes(genre)) {
 | 
				
			||||||
 | 
					        li.media.metadata.genres = li.media.metadata.genres.filter(t => t !== genre)
 | 
				
			||||||
 | 
					        Logger.debug(`[MiscController] Remove genre "${genre}" from item "${li.media.metadata.title}"`)
 | 
				
			||||||
 | 
					        await this.db.updateLibraryItem(li)
 | 
				
			||||||
 | 
					        SocketAuthority.emitter('item_updated', li.toJSONExpanded())
 | 
				
			||||||
 | 
					        numItemsUpdated++
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    res.json({
 | 
				
			||||||
 | 
					      numItemsUpdated
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  validateCronExpression(req, res) {
 | 
					  validateCronExpression(req, res) {
 | 
				
			||||||
    const expression = req.body.expression
 | 
					    const expression = req.body.expression
 | 
				
			||||||
    if (!expression) {
 | 
					    if (!expression) {
 | 
				
			||||||
 | 
				
			|||||||
@ -274,6 +274,9 @@ class ApiRouter {
 | 
				
			|||||||
    this.router.get('/tags', MiscController.getAllTags.bind(this))
 | 
					    this.router.get('/tags', MiscController.getAllTags.bind(this))
 | 
				
			||||||
    this.router.post('/tags/rename', MiscController.renameTag.bind(this))
 | 
					    this.router.post('/tags/rename', MiscController.renameTag.bind(this))
 | 
				
			||||||
    this.router.delete('/tags/:tag', MiscController.deleteTag.bind(this))
 | 
					    this.router.delete('/tags/:tag', MiscController.deleteTag.bind(this))
 | 
				
			||||||
 | 
					    this.router.get('/genres', MiscController.getAllGenres.bind(this))
 | 
				
			||||||
 | 
					    this.router.post('/genres/rename', MiscController.renameGenre.bind(this))
 | 
				
			||||||
 | 
					    this.router.delete('/genres/:genre', MiscController.deleteGenre.bind(this))
 | 
				
			||||||
    this.router.post('/validate-cron', MiscController.validateCronExpression.bind(this))
 | 
					    this.router.post('/validate-cron', MiscController.validateCronExpression.bind(this))
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
		Loading…
	
		Reference in New Issue
	
	Block a user