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() { | ||||
|       this.$store.commit('user/removeSettingsListener', 'bookshelf') | ||||
| 
 | ||||
|       if (this.$root.socket) { | ||||
|         this.$root.socket.off('user_updated', this.userUpdated) | ||||
|         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> | ||||
|         </div> | ||||
|       </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"> | ||||
|           <p>Manage Genres</p> | ||||
|           <span class="material-icons">arrow_forward</span> | ||||
|         </div> | ||||
|       </nuxt-link> --> | ||||
|       </nuxt-link> | ||||
|     </app-settings-content> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| @ -76,8 +76,6 @@ export default { | ||||
| 
 | ||||
|       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 | ||||
|       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?` | ||||
|       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) { | ||||
|     const expression = req.body.expression | ||||
|     if (!expression) { | ||||
|  | ||||
| @ -274,6 +274,9 @@ class ApiRouter { | ||||
|     this.router.get('/tags', MiscController.getAllTags.bind(this)) | ||||
|     this.router.post('/tags/rename', MiscController.renameTag.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)) | ||||
|   } | ||||
| 
 | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user