mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-10-27 11:18:14 +01:00 
			
		
		
		
	Update:Remove image path input from author modal, add API endpoints for uploading and removing author image
This commit is contained in:
		
							parent
							
								
									290a377ef9
								
							
						
					
					
						commit
						656c81a1fa
					
				| @ -5,18 +5,23 @@ | ||||
|         <p class="text-3xl text-white truncate">{{ title }}</p> | ||||
|       </div> | ||||
|     </template> | ||||
|     <div class="p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh"> | ||||
|       <form v-if="author" @submit.prevent="submitForm"> | ||||
|     <div v-if="author" class="p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh"> | ||||
|       <div class="flex"> | ||||
|         <div class="w-40 p-2"> | ||||
|           <div class="w-full h-45 relative"> | ||||
|             <covers-author-image :author="author" /> | ||||
|               <div v-show="!processing && author.imagePath" class="absolute top-0 left-0 w-full h-full opacity-0 hover:opacity-100"> | ||||
|             <div v-if="userCanDelete && !processing && author.imagePath" class="absolute top-0 left-0 w-full h-full opacity-0 hover:opacity-100"> | ||||
|               <span class="absolute top-2 right-2 material-icons text-error transform hover:scale-125 transition-transform cursor-pointer text-lg" @click="removeCover">delete</span> | ||||
|             </div> | ||||
|           </div> | ||||
|         </div> | ||||
|         <div class="flex-grow"> | ||||
|           <form @submit.prevent="submitUploadCover" class="flex flex-grow mb-2 p-2"> | ||||
|             <ui-text-input v-model="imageUrl" :placeholder="$strings.LabelImageURLFromTheWeb" class="h-9 w-full" /> | ||||
|             <ui-btn color="success" type="submit" :padding-x="4" :disabled="!imageUrl" class="ml-2 sm:ml-3 w-24 h-9">{{ $strings.ButtonSubmit }}</ui-btn> | ||||
|           </form> | ||||
| 
 | ||||
|           <form v-if="author" @submit.prevent="submitForm"> | ||||
|             <div class="flex"> | ||||
|               <div class="w-3/4 p-2"> | ||||
|                 <ui-text-input-with-label v-model="authorCopy.name" :disabled="processing" :label="$strings.LabelName" /> | ||||
| @ -25,9 +30,9 @@ | ||||
|                 <ui-text-input-with-label v-model="authorCopy.asin" :disabled="processing" label="ASIN" /> | ||||
|               </div> | ||||
|             </div> | ||||
|             <div class="p-2"> | ||||
|             <!-- <div class="p-2"> | ||||
|               <ui-text-input-with-label v-model="authorCopy.imagePath" :disabled="processing" :label="$strings.LabelPhotoPathURL" /> | ||||
|             </div> | ||||
|             </div> --> | ||||
|             <div class="p-2"> | ||||
|               <ui-textarea-with-label v-model="authorCopy.description" :disabled="processing" :label="$strings.LabelDescription" :rows="8" /> | ||||
|             </div> | ||||
| @ -39,10 +44,10 @@ | ||||
| 
 | ||||
|               <ui-btn type="submit">{{ $strings.ButtonSave }}</ui-btn> | ||||
|             </div> | ||||
|           </div> | ||||
|         </div> | ||||
|           </form> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|   </modals-modal> | ||||
| </template> | ||||
| 
 | ||||
| @ -53,9 +58,9 @@ export default { | ||||
|       authorCopy: { | ||||
|         name: '', | ||||
|         asin: '', | ||||
|         description: '', | ||||
|         imagePath: '' | ||||
|         description: '' | ||||
|       }, | ||||
|       imageUrl: '', | ||||
|       processing: false | ||||
|     } | ||||
|   }, | ||||
| @ -100,10 +105,10 @@ export default { | ||||
|   }, | ||||
|   methods: { | ||||
|     init() { | ||||
|       this.imageUrl = '' | ||||
|       this.authorCopy.name = this.author.name | ||||
|       this.authorCopy.asin = this.author.asin | ||||
|       this.authorCopy.description = this.author.description | ||||
|       this.authorCopy.imagePath = this.author.imagePath | ||||
|     }, | ||||
|     removeClick() { | ||||
|       const payload = { | ||||
| @ -131,7 +136,7 @@ export default { | ||||
|       this.$store.commit('globals/setConfirmPrompt', payload) | ||||
|     }, | ||||
|     async submitForm() { | ||||
|       var keysToCheck = ['name', 'asin', 'description', 'imagePath'] | ||||
|       var keysToCheck = ['name', 'asin', 'description'] | ||||
|       var updatePayload = {} | ||||
|       keysToCheck.forEach((key) => { | ||||
|         if (this.authorCopy[key] !== this.author[key]) { | ||||
| @ -160,21 +165,46 @@ export default { | ||||
|       } | ||||
|       this.processing = false | ||||
|     }, | ||||
|     async removeCover() { | ||||
|       var updatePayload = { | ||||
|         imagePath: null | ||||
|       } | ||||
|     removeCover() { | ||||
|       this.processing = true | ||||
|       var result = await this.$axios.$patch(`/api/authors/${this.authorId}`, updatePayload).catch((error) => { | ||||
|       this.$axios | ||||
|         .$delete(`/api/authors/${this.authorId}/image`) | ||||
|         .then((data) => { | ||||
|           this.$toast.success(this.$strings.ToastAuthorImageRemoveSuccess) | ||||
|           this.$store.commit('globals/showEditAuthorModal', data.author) | ||||
|         }) | ||||
|         .catch((error) => { | ||||
|           console.error('Failed', error) | ||||
|           this.$toast.error(this.$strings.ToastAuthorImageRemoveFailed) | ||||
|         return null | ||||
|         }) | ||||
|       if (result && result.updated) { | ||||
|         this.$toast.success(this.$strings.ToastAuthorImageRemoveSuccess) | ||||
|         this.$store.commit('globals/showEditAuthorModal', result.author) | ||||
|       } | ||||
|         .finally(() => { | ||||
|           this.processing = false | ||||
|         }) | ||||
|     }, | ||||
|     submitUploadCover() { | ||||
|       if (!this.imageUrl?.startsWith('http:') && !this.imageUrl?.startsWith('https:')) { | ||||
|         this.$toast.error('Invalid image url') | ||||
|         return | ||||
|       } | ||||
| 
 | ||||
|       this.processing = true | ||||
|       const updatePayload = { | ||||
|         url: this.imageUrl | ||||
|       } | ||||
|       this.$axios | ||||
|         .$post(`/api/authors/${this.authorId}/image`, updatePayload) | ||||
|         .then((data) => { | ||||
|           this.imageUrl = '' | ||||
|           this.$toast.success('Author image updated') | ||||
|           this.$store.commit('globals/showEditAuthorModal', data.author) | ||||
|         }) | ||||
|         .catch((error) => { | ||||
|           console.error('Failed', error) | ||||
|           this.$toast.error(error.response.data || 'Failed to remove author image') | ||||
|         }) | ||||
|         .finally(() => { | ||||
|           this.processing = false | ||||
|         }) | ||||
|     }, | ||||
|     async searchAuthor() { | ||||
|       if (!this.authorCopy.name && !this.authorCopy.asin) { | ||||
|  | ||||
| @ -67,30 +67,10 @@ class AuthorController { | ||||
|     const payload = req.body | ||||
|     let hasUpdated = false | ||||
| 
 | ||||
|     // Updating/removing cover image
 | ||||
|     if (payload.imagePath !== undefined && payload.imagePath !== req.author.imagePath) { | ||||
|       if (!payload.imagePath && req.author.imagePath) { // If removing image then remove file
 | ||||
|         await CacheManager.purgeImageCache(req.author.id) // Purge cache
 | ||||
|         await CoverManager.removeFile(req.author.imagePath) | ||||
|       } else if (payload.imagePath.startsWith('http')) { // Check if image path is a url
 | ||||
|         const imageData = await AuthorFinder.saveAuthorImage(req.author.id, payload.imagePath) | ||||
|         if (imageData) { | ||||
|           if (req.author.imagePath) { | ||||
|             await CacheManager.purgeImageCache(req.author.id) // Purge cache
 | ||||
|           } | ||||
|           payload.imagePath = imageData.path | ||||
|           hasUpdated = true | ||||
|         } | ||||
|       } else if (payload.imagePath && payload.imagePath !== req.author.imagePath) { // Changing image path locally
 | ||||
|         if (!await fs.pathExists(payload.imagePath)) { // Make sure image path exists
 | ||||
|           Logger.error(`[AuthorController] Image path does not exist: "${payload.imagePath}"`) | ||||
|           return res.status(400).send('Author image path does not exist') | ||||
|         } | ||||
| 
 | ||||
|         if (req.author.imagePath) { | ||||
|           await CacheManager.purgeImageCache(req.author.id) // Purge cache
 | ||||
|         } | ||||
|       } | ||||
|     // author imagePath must be set through other endpoints as of v2.4.5
 | ||||
|     if (payload.imagePath !== undefined) { | ||||
|       Logger.warn(`[AuthorController] Updating local author imagePath is not supported`) | ||||
|       delete payload.imagePath | ||||
|     } | ||||
| 
 | ||||
|     const authorNameUpdate = payload.name !== undefined && payload.name !== req.author.name | ||||
| @ -131,7 +111,7 @@ class AuthorController { | ||||
|       Database.removeAuthorFromFilterData(req.author.libraryId, req.author.id) | ||||
| 
 | ||||
|       // Send updated num books for merged author
 | ||||
|       const numBooks = await Database.libraryItemModel.getForAuthor(existingAuthor).length | ||||
|       const numBooks = (await Database.libraryItemModel.getForAuthor(existingAuthor)).length | ||||
|       SocketAuthority.emitter('author_updated', existingAuthor.toJSONExpanded(numBooks)) | ||||
| 
 | ||||
|       res.json({ | ||||
| @ -191,6 +171,75 @@ class AuthorController { | ||||
|     res.sendStatus(200) | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * POST: /api/authors/:id/image | ||||
|    * Upload author image from web URL | ||||
|    *  | ||||
|    * @param {import('express').Request} req  | ||||
|    * @param {import('express').Response} res  | ||||
|    */ | ||||
|   async uploadImage(req, res) { | ||||
|     if (!req.user.canUpload) { | ||||
|       Logger.warn('User attempted to upload an image without permission', req.user) | ||||
|       return res.sendStatus(403) | ||||
|     } | ||||
|     if (!req.body.url) { | ||||
|       Logger.error(`[AuthorController] Invalid request payload. 'url' not in request body`) | ||||
|       return res.status(400).send(`Invalid request payload. 'url' not in request body`) | ||||
|     } | ||||
|     if (!req.body.url.startsWith?.('http:') && !req.body.url.startsWith?.('https:')) { | ||||
|       Logger.error(`[AuthorController] Invalid request payload. Invalid url "${req.body.url}"`) | ||||
|       return res.status(400).send(`Invalid request payload. Invalid url "${req.body.url}"`) | ||||
|     } | ||||
| 
 | ||||
|     Logger.debug(`[AuthorController] Requesting download author image from url "${req.body.url}"`) | ||||
|     const result = await AuthorFinder.saveAuthorImage(req.author.id, req.body.url) | ||||
| 
 | ||||
|     if (result?.error) { | ||||
|       return res.status(400).send(result.error) | ||||
|     } else if (!result?.path) { | ||||
|       return res.status(500).send('Unknown error occurred') | ||||
|     } | ||||
| 
 | ||||
|     if (req.author.imagePath) { | ||||
|       await CacheManager.purgeImageCache(req.author.id) // Purge cache
 | ||||
|     } | ||||
| 
 | ||||
|     req.author.imagePath = result.path | ||||
|     await Database.authorModel.updateFromOld(req.author) | ||||
| 
 | ||||
|     const numBooks = (await Database.libraryItemModel.getForAuthor(req.author)).length | ||||
|     SocketAuthority.emitter('author_updated', req.author.toJSONExpanded(numBooks)) | ||||
|     res.json({ | ||||
|       author: req.author.toJSON() | ||||
|     }) | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * DELETE: /api/authors/:id/image | ||||
|    * Remove author image & delete image file | ||||
|    *  | ||||
|    * @param {import('express').Request} req  | ||||
|    * @param {import('express').Response} res  | ||||
|    */ | ||||
|   async deleteImage(req, res) { | ||||
|     if (!req.author.imagePath) { | ||||
|       Logger.error(`[AuthorController] Author "${req.author.imagePath}" has no imagePath set`) | ||||
|       return res.status(400).send('Author has no image path set') | ||||
|     } | ||||
|     Logger.info(`[AuthorController] Removing image for author "${req.author.name}" at "${req.author.imagePath}"`) | ||||
|     await CacheManager.purgeImageCache(req.author.id) // Purge cache
 | ||||
|     await CoverManager.removeFile(req.author.imagePath) | ||||
|     req.author.imagePath = null | ||||
|     await Database.authorModel.updateFromOld(req.author) | ||||
| 
 | ||||
|     const numBooks = (await Database.libraryItemModel.getForAuthor(req.author)).length | ||||
|     SocketAuthority.emitter('author_updated', req.author.toJSONExpanded(numBooks)) | ||||
|     res.json({ | ||||
|       author: req.author.toJSON() | ||||
|     }) | ||||
|   } | ||||
| 
 | ||||
|   async match(req, res) { | ||||
|     let authorData = null | ||||
|     const region = req.body.region || 'us' | ||||
| @ -215,7 +264,7 @@ class AuthorController { | ||||
|       await CacheManager.purgeImageCache(req.author.id) | ||||
| 
 | ||||
|       const imageData = await AuthorFinder.saveAuthorImage(req.author.id, authorData.image) | ||||
|       if (imageData) { | ||||
|       if (imageData?.path) { | ||||
|         req.author.imagePath = imageData.path | ||||
|         hasUpdates = true | ||||
|       } | ||||
| @ -231,7 +280,7 @@ class AuthorController { | ||||
| 
 | ||||
|       await Database.updateAuthor(req.author) | ||||
| 
 | ||||
|       const numBooks = await Database.libraryItemModel.getForAuthor(req.author).length | ||||
|       const numBooks = (await Database.libraryItemModel.getForAuthor(req.author)).length | ||||
|       SocketAuthority.emitter('author_updated', req.author.toJSONExpanded(numBooks)) | ||||
|     } | ||||
| 
 | ||||
|  | ||||
| @ -10,13 +10,6 @@ class AuthorFinder { | ||||
|     this.audnexus = new Audnexus() | ||||
|   } | ||||
| 
 | ||||
|   async downloadImage(url, outputPath) { | ||||
|     return downloadFile(url, outputPath).then(() => true).catch((error) => { | ||||
|       Logger.error('[AuthorFinder] Failed to download author image', error) | ||||
|       return null | ||||
|     }) | ||||
|   } | ||||
| 
 | ||||
|   findAuthorByASIN(asin, region) { | ||||
|     if (!asin) return null | ||||
|     return this.audnexus.findAuthorByASIN(asin, region) | ||||
| @ -33,28 +26,36 @@ class AuthorFinder { | ||||
|     return author | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Download author image from url and save in authors folder | ||||
|    *  | ||||
|    * @param {string} authorId  | ||||
|    * @param {string} url  | ||||
|    * @returns {Promise<{path:string, error:string}>} | ||||
|    */ | ||||
|   async saveAuthorImage(authorId, url) { | ||||
|     var authorDir = Path.join(global.MetadataPath, 'authors') | ||||
|     var relAuthorDir = Path.posix.join('/metadata', 'authors') | ||||
|     const authorDir = Path.join(global.MetadataPath, 'authors') | ||||
| 
 | ||||
|     if (!await fs.pathExists(authorDir)) { | ||||
|       await fs.ensureDir(authorDir) | ||||
|     } | ||||
| 
 | ||||
|     var imageExtension = url.toLowerCase().split('.').pop() | ||||
|     var ext = imageExtension === 'png' ? 'png' : 'jpg' | ||||
|     var filename = authorId + '.' + ext | ||||
|     var outputPath = Path.posix.join(authorDir, filename) | ||||
|     var relPath = Path.posix.join(relAuthorDir, filename) | ||||
|     const imageExtension = url.toLowerCase().split('.').pop() | ||||
|     const ext = imageExtension === 'png' ? 'png' : 'jpg' | ||||
|     const filename = authorId + '.' + ext | ||||
|     const outputPath = Path.posix.join(authorDir, filename) | ||||
| 
 | ||||
|     var success = await this.downloadImage(url, outputPath) | ||||
|     if (!success) { | ||||
|       return null | ||||
|     } | ||||
|     return downloadFile(url, outputPath).then(() => { | ||||
|       return { | ||||
|       path: outputPath, | ||||
|       relPath | ||||
|         path: outputPath | ||||
|       } | ||||
|     }).catch((err) => { | ||||
|       let errorMsg = err.message || 'Unknown error' | ||||
|       Logger.error(`[AuthorFinder] Download image file failed for "${url}"`, errorMsg) | ||||
|       return { | ||||
|         error: errorMsg | ||||
|       } | ||||
|     }) | ||||
|   } | ||||
| } | ||||
| module.exports = new AuthorFinder() | ||||
| @ -202,6 +202,8 @@ class ApiRouter { | ||||
|     this.router.delete('/authors/:id', AuthorController.middleware.bind(this), AuthorController.delete.bind(this)) | ||||
|     this.router.post('/authors/:id/match', AuthorController.middleware.bind(this), AuthorController.match.bind(this)) | ||||
|     this.router.get('/authors/:id/image', AuthorController.middleware.bind(this), AuthorController.getImage.bind(this)) | ||||
|     this.router.post('/authors/:id/image', AuthorController.middleware.bind(this), AuthorController.uploadImage.bind(this)) | ||||
|     this.router.delete('/authors/:id/image', AuthorController.middleware.bind(this), AuthorController.deleteImage.bind(this)) | ||||
| 
 | ||||
|     //
 | ||||
|     // Series Routes
 | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user