mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-10-27 11:18:14 +01:00 
			
		
		
		
	Add: author object, author search api, author images #187
This commit is contained in:
		
							parent
							
								
									979fb70c31
								
							
						
					
					
						commit
						5308801540
					
				
							
								
								
									
										56
									
								
								client/components/cards/PersonCard.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										56
									
								
								client/components/cards/PersonCard.vue
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,56 @@ | ||||
| <template> | ||||
|   <div class="h-24 flex"> | ||||
|     <div class="w-32"> | ||||
|       <img :src="imgSrc" class="w-full object-cover" /> | ||||
|     </div> | ||||
|     <div class="flex-grow"> | ||||
|       <p>{{ name }}</p> | ||||
|       <p class="text-sm text-gray-300">{{ description }}</p> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
| 
 | ||||
| <script> | ||||
| export default { | ||||
|   props: { | ||||
|     person: { | ||||
|       type: Object, | ||||
|       default: () => {} | ||||
|     } | ||||
|   }, | ||||
|   data() { | ||||
|     return { | ||||
|       placeholder: '/Logo.png' | ||||
|     } | ||||
|   }, | ||||
|   computed: { | ||||
|     userToken() { | ||||
|       return this.$store.getters['user/getToken'] | ||||
|     }, | ||||
|     _person() { | ||||
|       return this.person || {} | ||||
|     }, | ||||
|     name() { | ||||
|       return this._person.name || '' | ||||
|     }, | ||||
|     image() { | ||||
|       return this._person.image || null | ||||
|     }, | ||||
|     description() { | ||||
|       return this._person.description | ||||
|     }, | ||||
|     lastUpdate() { | ||||
|       return this._person.lastUpdate | ||||
|     }, | ||||
|     imgSrc() { | ||||
|       if (!this.image) return this.placeholder | ||||
|       var encodedImg = this.image.replace(/%/g, '%25').replace(/#/g, '%23') | ||||
| 
 | ||||
|       var url = new URL(encodedImg, document.baseURI) | ||||
|       return url.href + `?token=${this.userToken}&ts=${this.lastUpdate}` | ||||
|     } | ||||
|   }, | ||||
|   methods: {}, | ||||
|   mounted() {} | ||||
| } | ||||
| </script> | ||||
							
								
								
									
										73
									
								
								client/components/cards/SearchAuthorCard.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										73
									
								
								client/components/cards/SearchAuthorCard.vue
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,73 @@ | ||||
| <template> | ||||
|   <div> | ||||
|     <form @submit.prevent="submitSearch"> | ||||
|       <div class="flex items-center justify-start -mx-1 h-20"> | ||||
|         <!-- <div class="w-40 px-1"> | ||||
|           <ui-dropdown v-model="provider" :items="providers" label="Provider" small /> | ||||
|         </div> --> | ||||
|         <div class="flex-grow px-1"> | ||||
|           <ui-text-input-with-label v-model="searchAuthor" label="Author" /> | ||||
|         </div> | ||||
|         <ui-btn class="mt-5 ml-1" type="submit">Search</ui-btn> | ||||
|       </div> | ||||
|     </form> | ||||
|   </div> | ||||
| </template> | ||||
| 
 | ||||
| <script> | ||||
| export default { | ||||
|   props: { | ||||
|     authorName: String | ||||
|   }, | ||||
|   data() { | ||||
|     return { | ||||
|       searchAuthor: null, | ||||
|       lastSearch: null, | ||||
|       isProcessing: false, | ||||
|       provider: 'audnexus', | ||||
|       providers: [ | ||||
|         { | ||||
|           text: 'Audnexus', | ||||
|           value: 'audnexus' | ||||
|         } | ||||
|       ] | ||||
|     } | ||||
|   }, | ||||
|   watch: { | ||||
|     authorName: { | ||||
|       immediate: true, | ||||
|       handler(newVal) { | ||||
|         this.searchAuthor = newVal | ||||
|       } | ||||
|     } | ||||
|   }, | ||||
|   computed: {}, | ||||
|   methods: { | ||||
|     getSearchQuery() { | ||||
|       return `q=${this.searchAuthor}` | ||||
|     }, | ||||
|     submitSearch() { | ||||
|       if (!this.searchAuthor) { | ||||
|         this.$toast.warning('Author name is required') | ||||
|         return | ||||
|       } | ||||
|       this.runSearch() | ||||
|     }, | ||||
|     async runSearch() { | ||||
|       var searchQuery = this.getSearchQuery() | ||||
|       if (this.lastSearch === searchQuery) return | ||||
|       this.isProcessing = true | ||||
|       this.lastSearch = searchQuery | ||||
|       var result = await this.$axios.$get(`/api/authors/search?${searchQuery}`).catch((error) => { | ||||
|         console.error('Failed', error) | ||||
|         return [] | ||||
|       }) | ||||
|       this.isProcessing = false | ||||
|       if (result) { | ||||
|         this.$emit('match', result) | ||||
|       } | ||||
|     } | ||||
|   }, | ||||
|   mounted() {} | ||||
| } | ||||
| </script> | ||||
| @ -44,11 +44,11 @@ export default { | ||||
|           title: 'Cover', | ||||
|           component: 'modals-edit-tabs-cover' | ||||
|         }, | ||||
|         { | ||||
|           id: 'tracks', | ||||
|           title: 'Tracks', | ||||
|           component: 'modals-edit-tabs-tracks' | ||||
|         }, | ||||
|         // { | ||||
|         //   id: 'tracks', | ||||
|         //   title: 'Tracks', | ||||
|         //   component: 'modals-edit-tabs-tracks' | ||||
|         // }, | ||||
|         { | ||||
|           id: 'chapters', | ||||
|           title: 'Chapters', | ||||
| @ -69,6 +69,11 @@ export default { | ||||
|           title: 'Match', | ||||
|           component: 'modals-edit-tabs-match' | ||||
|         } | ||||
|         // { | ||||
|         //   id: 'authors', | ||||
|         //   title: 'Authors', | ||||
|         //   component: 'modals-edit-tabs-authors' | ||||
|         // } | ||||
|       ] | ||||
|     } | ||||
|   }, | ||||
| @ -130,8 +135,8 @@ export default { | ||||
|       if (!this.userCanUpdate && !this.userCanDownload) return [] | ||||
|       return this.tabs.filter((tab) => { | ||||
|         if (tab.id === 'download' && this.isMissing) return false | ||||
|         if ((tab.id === 'download' || tab.id === 'tracks') && this.userCanDownload) return true | ||||
|         if (tab.id !== 'download' && tab.id !== 'tracks' && this.userCanUpdate) return true | ||||
|         if ((tab.id === 'download' || tab.id === 'files' || tab.id === 'authors') && this.userCanDownload) return true | ||||
|         if (tab.id !== 'download' && tab.id !== 'files' && tab.id !== 'authors' && this.userCanUpdate) return true | ||||
|         if (tab.id === 'match' && this.userCanUpdate && this.showExperimentalFeatures) return true | ||||
|         return false | ||||
|       }) | ||||
|  | ||||
							
								
								
									
										201
									
								
								client/components/modals/edit-tabs/Authors.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										201
									
								
								client/components/modals/edit-tabs/Authors.vue
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,201 @@ | ||||
| <template> | ||||
|   <div class="w-full h-full overflow-hidden px-4 py-6 relative"> | ||||
|     <template v-for="(authorName, index) in searchAuthors"> | ||||
|       <cards-search-author-card :key="index" :author-name="authorName" @match="setSelectedMatch" /> | ||||
|     </template> | ||||
| 
 | ||||
|     <div v-show="processing" class="flex h-full items-center justify-center"> | ||||
|       <p>Loading...</p> | ||||
|     </div> | ||||
|     <div v-if="selectedMatch" class="absolute top-0 left-0 w-full bg-bg h-full p-8 max-h-full overflow-y-auto overflow-x-hidden"> | ||||
|       <div class="flex mb-2"> | ||||
|         <div class="w-8 h-8 rounded-full hover:bg-white hover:bg-opacity-10 flex items-center justify-center cursor-pointer" @click="selectedMatch = null"> | ||||
|           <span class="material-icons text-3xl">arrow_back</span> | ||||
|         </div> | ||||
|         <p class="text-xl pl-3">Update Author Details</p> | ||||
|       </div> | ||||
|       <form @submit.prevent="submitMatchUpdate"> | ||||
|         <div v-if="selectedMatch.image" class="flex items-center py-2"> | ||||
|           <ui-checkbox v-model="selectedMatchUsage.image" /> | ||||
|           <img :src="selectedMatch.image" class="w-24 object-contain ml-4" /> | ||||
|           <ui-text-input-with-label v-model="selectedMatch.image" :disabled="!selectedMatchUsage.image" label="Image" class="flex-grow ml-4" /> | ||||
|         </div> | ||||
|         <div v-if="selectedMatch.name" class="flex items-center py-2"> | ||||
|           <ui-checkbox v-model="selectedMatchUsage.name" /> | ||||
|           <ui-text-input-with-label v-model="selectedMatch.name" :disabled="!selectedMatchUsage.name" label="Name" class="flex-grow ml-4" /> | ||||
|         </div> | ||||
|         <div v-if="selectedMatch.description" class="flex items-center py-2"> | ||||
|           <ui-checkbox v-model="selectedMatchUsage.description" /> | ||||
|           <ui-textarea-with-label v-model="selectedMatch.description" :rows="3" :disabled="!selectedMatchUsage.description" label="Description" class="flex-grow ml-4" /> | ||||
|         </div> | ||||
|         <div class="flex items-center justify-end py-2"> | ||||
|           <ui-btn color="success" type="submit">Update</ui-btn> | ||||
|         </div> | ||||
|       </form> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
| 
 | ||||
| <script> | ||||
| export default { | ||||
|   props: { | ||||
|     processing: Boolean, | ||||
|     audiobook: { | ||||
|       type: Object, | ||||
|       default: () => {} | ||||
|     } | ||||
|   }, | ||||
|   data() { | ||||
|     return { | ||||
|       searchAuthors: [], | ||||
|       audiobookId: null, | ||||
|       searchAuthor: null, | ||||
|       lastSearch: null, | ||||
|       hasSearched: false, | ||||
|       selectedMatch: null, | ||||
| 
 | ||||
|       selectedMatchUsage: { | ||||
|         image: true, | ||||
|         name: true, | ||||
|         description: true | ||||
|       } | ||||
|     } | ||||
|   }, | ||||
|   watch: { | ||||
|     audiobook: { | ||||
|       immediate: true, | ||||
|       handler(newVal) { | ||||
|         if (newVal) this.init() | ||||
|       } | ||||
|     } | ||||
|   }, | ||||
|   computed: { | ||||
|     isProcessing: { | ||||
|       get() { | ||||
|         return this.processing | ||||
|       }, | ||||
|       set(val) { | ||||
|         this.$emit('update:processing', val) | ||||
|       } | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
|     // getSearchQuery() { | ||||
|     //   return `q=${this.searchAuthor}` | ||||
|     // }, | ||||
|     // submitSearch() { | ||||
|     //   if (!this.searchTitle) { | ||||
|     //     this.$toast.warning('Search title is required') | ||||
|     //     return | ||||
|     //   } | ||||
|     //   this.runSearch() | ||||
|     // }, | ||||
|     // async runSearch() { | ||||
|     //   var searchQuery = this.getSearchQuery() | ||||
|     //   if (this.lastSearch === searchQuery) return | ||||
|     //   this.selectedMatch = null | ||||
|     //   this.isProcessing = true | ||||
|     //   this.lastSearch = searchQuery | ||||
|     //   var result = await this.$axios.$get(`/api/authors/search?${searchQuery}`).catch((error) => { | ||||
|     //     console.error('Failed', error) | ||||
|     //     return [] | ||||
|     //   }) | ||||
|     //   if (result) { | ||||
|     //     this.selectedMatch = result | ||||
|     //   } | ||||
|     //   this.isProcessing = false | ||||
|     //   this.hasSearched = true | ||||
|     // }, | ||||
|     init() { | ||||
|       this.selectedMatch = null | ||||
|       // this.selectedMatchUsage = { | ||||
|       //   title: true, | ||||
|       //   subtitle: true, | ||||
|       //   cover: true, | ||||
|       //   author: true, | ||||
|       //   description: true, | ||||
|       //   isbn: true, | ||||
|       //   publisher: true, | ||||
|       //   publishYear: true | ||||
|       // } | ||||
| 
 | ||||
|       if (this.audiobook.id !== this.audiobookId) { | ||||
|         this.selectedMatch = null | ||||
|         this.hasSearched = false | ||||
|         this.audiobookId = this.audiobook.id | ||||
|       } | ||||
| 
 | ||||
|       if (!this.audiobook.book || !this.audiobook.book.authorFL) { | ||||
|         this.searchAuthors = [] | ||||
|         return | ||||
|       } | ||||
|       this.searchAuthors = (this.audiobook.book.authorFL || '').split(', ') | ||||
|     }, | ||||
|     selectMatch(match) { | ||||
|       this.selectedMatch = match | ||||
|     }, | ||||
|     buildMatchUpdatePayload() { | ||||
|       var updatePayload = {} | ||||
|       for (const key in this.selectedMatchUsage) { | ||||
|         if (this.selectedMatchUsage[key] && this.selectedMatch[key]) { | ||||
|           updatePayload[key] = this.selectedMatch[key] | ||||
|         } | ||||
|       } | ||||
|       return updatePayload | ||||
|     }, | ||||
|     async submitMatchUpdate() { | ||||
|       var updatePayload = this.buildMatchUpdatePayload() | ||||
|       if (!Object.keys(updatePayload).length) { | ||||
|         return | ||||
|       } | ||||
|       this.isProcessing = true | ||||
| 
 | ||||
|       if (updatePayload.cover) { | ||||
|         var coverPayload = { | ||||
|           url: updatePayload.cover | ||||
|         } | ||||
|         var success = await this.$axios.$post(`/api/audiobook/${this.audiobook.id}/cover`, coverPayload).catch((error) => { | ||||
|           console.error('Failed to update', error) | ||||
|           return false | ||||
|         }) | ||||
|         if (success) { | ||||
|           this.$toast.success('Book Cover Updated') | ||||
|         } else { | ||||
|           this.$toast.error('Book Cover Failed to Update') | ||||
|         } | ||||
|         console.log('Updated cover') | ||||
|         delete updatePayload.cover | ||||
|       } | ||||
| 
 | ||||
|       if (Object.keys(updatePayload).length) { | ||||
|         var bookUpdatePayload = { | ||||
|           book: updatePayload | ||||
|         } | ||||
|         var success = await this.$axios.$patch(`/api/audiobook/${this.audiobook.id}`, bookUpdatePayload).catch((error) => { | ||||
|           console.error('Failed to update', error) | ||||
|           return false | ||||
|         }) | ||||
|         if (success) { | ||||
|           this.$toast.success('Book Details Updated') | ||||
|           this.selectedMatch = null | ||||
|           this.$emit('selectTab', 'details') | ||||
|         } else { | ||||
|           this.$toast.error('Book Details Failed to Update') | ||||
|         } | ||||
|       } else { | ||||
|         this.selectedMatch = null | ||||
|       } | ||||
|       this.isProcessing = false | ||||
|     }, | ||||
|     setSelectedMatch(authorMatchObj) { | ||||
|       this.selectedMatch = authorMatchObj | ||||
|     } | ||||
|   } | ||||
| } | ||||
| </script> | ||||
| 
 | ||||
| <style> | ||||
| .matchListWrapper { | ||||
|   height: calc(100% - 80px); | ||||
| } | ||||
| </style> | ||||
| @ -1,5 +1,48 @@ | ||||
| <template> | ||||
|   <div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6"> | ||||
|     <div class="mb-4"> | ||||
|       <template v-if="hasTracks"> | ||||
|         <div class="w-full bg-primary px-4 py-2 flex items-center"> | ||||
|           <p class="pr-4">Audio Tracks</p> | ||||
|           <div class="h-7 w-7 rounded-full bg-white bg-opacity-10 flex items-center justify-center"> | ||||
|             <span class="text-sm font-mono">{{ tracks.length }}</span> | ||||
|           </div> | ||||
|           <div class="flex-grow" /> | ||||
|           <ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">Full Path</ui-btn> | ||||
|           <nuxt-link v-if="userCanUpdate" :to="`/audiobook/${audiobook.id}/edit`"> | ||||
|             <ui-btn small color="primary">Manage Tracks</ui-btn> | ||||
|           </nuxt-link> | ||||
|         </div> | ||||
|         <table class="text-sm tracksTable"> | ||||
|           <tr class="font-book"> | ||||
|             <th>#</th> | ||||
|             <th class="text-left">Filename</th> | ||||
|             <th class="text-left">Size</th> | ||||
|             <th class="text-left">Duration</th> | ||||
|             <th v-if="showDownload" class="text-center">Download</th> | ||||
|           </tr> | ||||
|           <template v-for="track in tracksCleaned"> | ||||
|             <tr :key="track.index"> | ||||
|               <td class="text-center"> | ||||
|                 <p>{{ track.index }}</p> | ||||
|               </td> | ||||
|               <td class="font-sans">{{ showFullPath ? track.fullPath : track.filename }}</td> | ||||
|               <td class="font-mono"> | ||||
|                 {{ $bytesPretty(track.size) }} | ||||
|               </td> | ||||
|               <td class="font-mono"> | ||||
|                 {{ $secondsToTimestamp(track.duration) }} | ||||
|               </td> | ||||
|               <td v-if="showDownload" class="font-mono text-center"> | ||||
|                 <a :href="`/s/book/${audiobook.id}/${track.relativePath}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a> | ||||
|               </td> | ||||
|             </tr> | ||||
|           </template> | ||||
|         </table> | ||||
|       </template> | ||||
|       <div v-else class="flex my-4 text-center justify-center text-xl">No Audio Tracks</div> | ||||
|     </div> | ||||
| 
 | ||||
|     <tables-all-files-table :audiobook="audiobook" /> | ||||
|   </div> | ||||
| </template> | ||||
| @ -13,9 +56,60 @@ export default { | ||||
|     } | ||||
|   }, | ||||
|   data() { | ||||
|     return {} | ||||
|     return { | ||||
|       tracks: null, | ||||
|       showFullPath: false | ||||
|     } | ||||
|   }, | ||||
|   computed: {}, | ||||
|   methods: {} | ||||
|   watch: { | ||||
|     audiobook: { | ||||
|       immediate: true, | ||||
|       handler(newVal) { | ||||
|         if (newVal) this.init() | ||||
|       } | ||||
|     } | ||||
|   }, | ||||
|   computed: { | ||||
|     audiobookPath() { | ||||
|       return this.audiobook.path | ||||
|     }, | ||||
|     tracksCleaned() { | ||||
|       return this.tracks.map((track) => { | ||||
|         var trackPath = track.path.replace(/\\/g, '/') | ||||
|         var audiobookPath = this.audiobookPath.replace(/\\/g, '/') | ||||
| 
 | ||||
|         return { | ||||
|           ...track, | ||||
|           relativePath: trackPath | ||||
|             .replace(audiobookPath + '/', '') | ||||
|             .replace(/%/g, '%25') | ||||
|             .replace(/#/g, '%23') | ||||
|         } | ||||
|       }) | ||||
|     }, | ||||
|     userToken() { | ||||
|       return this.$store.getters['user/getToken'] | ||||
|     }, | ||||
|     userCanUpdate() { | ||||
|       return this.$store.getters['user/getUserCanUpdate'] | ||||
|     }, | ||||
|     userCanDownload() { | ||||
|       return this.$store.getters['user/getUserCanDownload'] | ||||
|     }, | ||||
|     isMissing() { | ||||
|       return this.audiobook.isMissing | ||||
|     }, | ||||
|     showDownload() { | ||||
|       return this.userCanDownload && !this.isMissing | ||||
|     }, | ||||
|     hasTracks() { | ||||
|       return this.audiobook.tracks.length | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
|     init() { | ||||
|       this.tracks = this.audiobook.tracks | ||||
|     } | ||||
|   } | ||||
| } | ||||
| </script> | ||||
| @ -1,7 +1,7 @@ | ||||
| <template> | ||||
|   <div class="w-full my-2"> | ||||
|     <div class="w-full bg-primary px-6 py-2 flex items-center cursor-pointer"> | ||||
|       <p class="pr-4">Files</p> | ||||
|     <div class="w-full bg-primary px-4 py-2 flex items-center cursor-pointer"> | ||||
|       <p class="pr-4">All Files</p> | ||||
|       <span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ allFiles.length }}</span> | ||||
|       <div class="flex-grow" /> | ||||
| 
 | ||||
|  | ||||
| @ -8,6 +8,7 @@ const { isObject, getId } = require('./utils/index') | ||||
| const audioFileScanner = require('./utils/audioFileScanner') | ||||
| 
 | ||||
| const BookFinder = require('./BookFinder') | ||||
| const AuthorController = require('./AuthorController') | ||||
| 
 | ||||
| const Library = require('./objects/Library') | ||||
| const User = require('./objects/User') | ||||
| @ -29,6 +30,7 @@ class ApiController { | ||||
|     this.MetadataPath = MetadataPath | ||||
| 
 | ||||
|     this.bookFinder = new BookFinder() | ||||
|     this.authorController = new AuthorController(this.MetadataPath) | ||||
| 
 | ||||
|     this.router = express() | ||||
|     this.init() | ||||
| @ -88,6 +90,13 @@ class ApiController { | ||||
|     this.router.patch('/collection/:id', this.updateUserCollection.bind(this)) | ||||
|     this.router.delete('/collection/:id', this.deleteUserCollection.bind(this)) | ||||
| 
 | ||||
|     this.router.get('/authors', this.getAuthors.bind(this)) | ||||
|     this.router.get('/authors/search', this.searchAuthor.bind(this)) | ||||
|     this.router.get('/authors/:id', this.getAuthor.bind(this)) | ||||
|     this.router.post('/authors', this.createAuthor.bind(this)) | ||||
|     this.router.patch('/authors/:id', this.updateAuthor.bind(this)) | ||||
|     this.router.delete('/authors/:id', this.deleteAuthor.bind(this)) | ||||
| 
 | ||||
|     this.router.patch('/serverSettings', this.updateServerSettings.bind(this)) | ||||
| 
 | ||||
|     this.router.delete('/backup/:id', this.deleteBackup.bind(this)) | ||||
| @ -897,6 +906,63 @@ class ApiController { | ||||
|     res.sendStatus(200) | ||||
|   } | ||||
| 
 | ||||
|   async getAuthors(req, res) { | ||||
|     var authors = this.db.authors.filter(p => p.isAuthor) | ||||
|     res.json(authors) | ||||
|   } | ||||
| 
 | ||||
|   async getAuthor(req, res) { | ||||
|     var author = this.db.authors.find(p => p.id === req.params.id) | ||||
|     if (!author) { | ||||
|       return res.status(404).send('Author not found') | ||||
|     } | ||||
|     res.json(author.toJSON()) | ||||
|   } | ||||
| 
 | ||||
|   async searchAuthor(req, res) { | ||||
|     var query = req.query.q | ||||
|     var author = await this.authorController.findAuthorByName(query) | ||||
|     res.json(author) | ||||
|   } | ||||
| 
 | ||||
|   async createAuthor(req, res) { | ||||
|     var author = await this.authorController.createAuthor(req.body) | ||||
|     if (!author) { | ||||
|       return res.status(500).send('Failed to create author') | ||||
|     } | ||||
| 
 | ||||
|     await this.db.insertEntity('author', author) | ||||
|     this.emitter('author_added', author.toJSON()) | ||||
|     res.json(author) | ||||
|   } | ||||
| 
 | ||||
|   async updateAuthor(req, res) { | ||||
|     var author = this.db.authors.find(p => p.id === req.params.id) | ||||
|     if (!author) { | ||||
|       return res.status(404).send('Author not found') | ||||
|     } | ||||
| 
 | ||||
|     var wasUpdated = author.update(req.body) | ||||
|     if (wasUpdated) { | ||||
|       await this.db.updateEntity('author', author) | ||||
|       this.emitter('author_updated', author.toJSON()) | ||||
|     } | ||||
|     res.json(author) | ||||
|   } | ||||
| 
 | ||||
|   async deleteAuthor(req, res) { | ||||
|     var author = this.db.authors.find(p => p.id === req.params.id) | ||||
|     if (!author) { | ||||
|       return res.status(404).send('Author not found') | ||||
|     } | ||||
| 
 | ||||
|     var authorJson = author.toJSON() | ||||
| 
 | ||||
|     await this.db.removeEntity('author', author.id) | ||||
|     this.emitter('author_removed', authorJson) | ||||
|     res.sendStatus(200) | ||||
|   } | ||||
| 
 | ||||
|   async updateServerSettings(req, res) { | ||||
|     if (!req.user.isRoot) { | ||||
|       Logger.error('User other than root attempting to update server settings', req.user) | ||||
|  | ||||
							
								
								
									
										110
									
								
								server/AuthorController.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										110
									
								
								server/AuthorController.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,110 @@ | ||||
| const fs = require('fs-extra') | ||||
| const Logger = require('./Logger') | ||||
| const Path = require('path') | ||||
| const Author = require('./objects/Author') | ||||
| const Audnexus = require('./providers/Audnexus') | ||||
| 
 | ||||
| const { downloadFile } = require('./utils/fileUtils') | ||||
| 
 | ||||
| class AuthorController { | ||||
|   constructor(MetadataPath) { | ||||
|     this.MetadataPath = MetadataPath | ||||
|     this.AuthorPath = Path.join(MetadataPath, 'authors') | ||||
| 
 | ||||
|     this.audnexus = new Audnexus() | ||||
|   } | ||||
| 
 | ||||
|   async downloadImage(url, outputPath) { | ||||
|     return downloadFile(url, outputPath).then(() => true).catch((error) => { | ||||
|       Logger.error('[AuthorController] Failed to download author image', error) | ||||
|       return null | ||||
|     }) | ||||
|   } | ||||
| 
 | ||||
|   async findAuthorByName(name, options = {}) { | ||||
|     if (!name) return null | ||||
|     const maxLevenshtein = !isNaN(options.maxLevenshtein) ? Number(options.maxLevenshtein) : 2 | ||||
| 
 | ||||
|     var author = await this.audnexus.findAuthorByName(name, maxLevenshtein) | ||||
|     if (!author || !author.name) { | ||||
|       return null | ||||
|     } | ||||
|     return author | ||||
|   } | ||||
| 
 | ||||
|   async createAuthor(payload) { | ||||
|     if (!payload || !payload.name) return null | ||||
| 
 | ||||
|     var authorDir = Path.posix.join(this.AuthorPath, payload.name) | ||||
|     var relAuthorDir = Path.posix.join('/metadata', 'authors', payload.name) | ||||
| 
 | ||||
|     if (payload.image && payload.image.startsWith('http')) { | ||||
|       await fs.ensureDir(authorDir) | ||||
| 
 | ||||
|       var imageExtension = payload.image.toLowerCase().split('.').pop() | ||||
|       var ext = imageExtension === 'png' ? 'png' : 'jpg' | ||||
|       var filename = 'photo.' + ext | ||||
|       var outputPath = Path.posix.join(authorDir, filename) | ||||
|       var relPath = Path.posix.join(relAuthorDir, filename) | ||||
| 
 | ||||
|       var success = await this.downloadImage(payload.image, outputPath) | ||||
|       if (!success) { | ||||
|         await fs.rmdir(authorDir).catch((error) => { | ||||
|           Logger.error(`[AuthorController] Failed to remove author dir`, authorDir, error) | ||||
|         }) | ||||
|         payload.image = null | ||||
|         payload.imageFullPath = null | ||||
|       } else { | ||||
|         payload.image = relPath | ||||
|         payload.imageFullPath = outputPath | ||||
|       } | ||||
|     } else { | ||||
|       payload.image = null | ||||
|       payload.imageFullPath = null | ||||
|     } | ||||
| 
 | ||||
|     var author = new Author() | ||||
|     author.setData(payload) | ||||
| 
 | ||||
|     return author | ||||
|   } | ||||
| 
 | ||||
|   async getAuthorByName(name, options = {}) { | ||||
|     var authorData = await this.findAuthorByName(name, options) | ||||
|     if (!authorData) return null | ||||
| 
 | ||||
|     var authorDir = Path.posix.join(this.AuthorPath, authorData.name) | ||||
|     var relAuthorDir = Path.posix.join('/metadata', 'authors', authorData.name) | ||||
| 
 | ||||
|     if (authorData.image) { | ||||
|       await fs.ensureDir(authorDir) | ||||
| 
 | ||||
|       var imageExtension = authorData.image.toLowerCase().split('.').pop() | ||||
|       var ext = imageExtension === 'png' ? 'png' : 'jpg' | ||||
|       var filename = 'photo.' + ext | ||||
|       var outputPath = Path.posix.join(authorDir, filename) | ||||
|       var relPath = Path.posix.join(relAuthorDir, filename) | ||||
| 
 | ||||
|       var success = await this.downloadImage(authorData.image, outputPath) | ||||
|       if (!success) { | ||||
|         await fs.rmdir(authorDir).catch((error) => { | ||||
|           Logger.error(`[AuthorController] Failed to remove author dir`, authorDir, error) | ||||
|         }) | ||||
|         authorData.image = null | ||||
|         authorData.imageFullPath = null | ||||
|       } else { | ||||
|         authorData.image = relPath | ||||
|         authorData.imageFullPath = outputPath | ||||
|       } | ||||
|     } else { | ||||
|       authorData.image = null | ||||
|       authorData.imageFullPath = null | ||||
|     } | ||||
| 
 | ||||
|     var author = new Author() | ||||
|     author.setData(authorData) | ||||
| 
 | ||||
|     return author | ||||
|   } | ||||
| } | ||||
| module.exports = AuthorController | ||||
| @ -7,6 +7,7 @@ const imageType = require('image-type') | ||||
| 
 | ||||
| const globals = require('./utils/globals') | ||||
| const { CoverDestination } = require('./utils/constants') | ||||
| const { downloadFile } = require('./utils/fileUtils') | ||||
| 
 | ||||
| class CoverController { | ||||
|   constructor(db, MetadataPath, AudiobookPath) { | ||||
| @ -123,28 +124,13 @@ class CoverController { | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   async downloadFile(url, filepath) { | ||||
|     Logger.debug(`[CoverController] Starting file download to ${filepath}`) | ||||
|     const writer = fs.createWriteStream(filepath) | ||||
|     const response = await axios({ | ||||
|       url, | ||||
|       method: 'GET', | ||||
|       responseType: 'stream' | ||||
|     }) | ||||
|     response.data.pipe(writer) | ||||
|     return new Promise((resolve, reject) => { | ||||
|       writer.on('finish', resolve) | ||||
|       writer.on('error', reject) | ||||
|     }) | ||||
|   } | ||||
| 
 | ||||
|   async downloadCoverFromUrl(audiobook, url) { | ||||
|     try { | ||||
|       var { fullPath, relPath } = this.getCoverDirectory(audiobook) | ||||
|       await fs.ensureDir(fullPath) | ||||
| 
 | ||||
|       var temppath = Path.posix.join(fullPath, 'cover') | ||||
|       var success = await this.downloadFile(url, temppath).then(() => true).catch((err) => { | ||||
|       var success = await downloadFile(url, temppath).then(() => true).catch((err) => { | ||||
|         Logger.error(`[CoverController] Download image file failed for "${url}"`, err) | ||||
|         return false | ||||
|       }) | ||||
|  | ||||
							
								
								
									
										13
									
								
								server/Db.js
									
									
									
									
									
								
							
							
						
						
									
										13
									
								
								server/Db.js
									
									
									
									
									
								
							| @ -8,6 +8,7 @@ const Audiobook = require('./objects/Audiobook') | ||||
| const User = require('./objects/User') | ||||
| const UserCollection = require('./objects/UserCollection') | ||||
| const Library = require('./objects/Library') | ||||
| const Author = require('./objects/Author') | ||||
| const ServerSettings = require('./objects/ServerSettings') | ||||
| 
 | ||||
| class Db { | ||||
| @ -21,6 +22,7 @@ class Db { | ||||
|     this.LibrariesPath = Path.join(ConfigPath, 'libraries') | ||||
|     this.SettingsPath = Path.join(ConfigPath, 'settings') | ||||
|     this.CollectionsPath = Path.join(ConfigPath, 'collections') | ||||
|     this.AuthorsPath = Path.join(ConfigPath, 'authors') | ||||
| 
 | ||||
|     this.audiobooksDb = new njodb.Database(this.AudiobooksPath) | ||||
|     this.usersDb = new njodb.Database(this.UsersPath) | ||||
| @ -28,6 +30,7 @@ class Db { | ||||
|     this.librariesDb = new njodb.Database(this.LibrariesPath, { datastores: 2 }) | ||||
|     this.settingsDb = new njodb.Database(this.SettingsPath, { datastores: 2 }) | ||||
|     this.collectionsDb = new njodb.Database(this.CollectionsPath, { datastores: 2 }) | ||||
|     this.authorsDb = new njodb.Database(this.AuthorsPath) | ||||
| 
 | ||||
|     this.users = [] | ||||
|     this.sessions = [] | ||||
| @ -35,6 +38,7 @@ class Db { | ||||
|     this.audiobooks = [] | ||||
|     this.settings = [] | ||||
|     this.collections = [] | ||||
|     this.authors = [] | ||||
| 
 | ||||
|     this.serverSettings = null | ||||
| 
 | ||||
| @ -49,6 +53,7 @@ class Db { | ||||
|     else if (entityName === 'library') return this.librariesDb | ||||
|     else if (entityName === 'settings') return this.settingsDb | ||||
|     else if (entityName === 'collection') return this.collectionsDb | ||||
|     else if (entityName === 'author') return this.authorsDb | ||||
|     return null | ||||
|   } | ||||
| 
 | ||||
| @ -59,6 +64,7 @@ class Db { | ||||
|     else if (entityName === 'library') return 'libraries' | ||||
|     else if (entityName === 'settings') return 'settings' | ||||
|     else if (entityName === 'collection') return 'collections' | ||||
|     else if (entityName === 'author') return 'authors' | ||||
|     return null | ||||
|   } | ||||
| 
 | ||||
| @ -96,6 +102,7 @@ class Db { | ||||
|     this.librariesDb = new njodb.Database(this.LibrariesPath, { datastores: 2 }) | ||||
|     this.settingsDb = new njodb.Database(this.SettingsPath, { datastores: 2 }) | ||||
|     this.collectionsDb = new njodb.Database(this.CollectionsPath, { datastores: 2 }) | ||||
|     this.authorsDb = new njodb.Database(this.AuthorsPath) | ||||
|     return this.init() | ||||
|   } | ||||
| 
 | ||||
| @ -154,7 +161,11 @@ class Db { | ||||
|       this.collections = results.data.map(l => new UserCollection(l)) | ||||
|       Logger.info(`[DB] ${this.collections.length} Collections Loaded`) | ||||
|     }) | ||||
|     await Promise.all([p1, p2, p3, p4, p5]) | ||||
|     var p6 = this.authorsDb.select(() => true).then((results) => { | ||||
|       this.authors = results.data.map(l => new Author(l)) | ||||
|       Logger.info(`[DB] ${this.authors.length} Authors Loaded`) | ||||
|     }) | ||||
|     await Promise.all([p1, p2, p3, p4, p5, p6]) | ||||
| 
 | ||||
|     // Update server version in server settings
 | ||||
|     if (this.previousVersion) { | ||||
|  | ||||
							
								
								
									
										72
									
								
								server/objects/Author.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										72
									
								
								server/objects/Author.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,72 @@ | ||||
| const { getId } = require('../utils/index') | ||||
| const Logger = require('../Logger') | ||||
| 
 | ||||
| class Author { | ||||
|   constructor(author = null) { | ||||
|     this.id = null | ||||
|     this.name = null | ||||
|     this.description = null | ||||
|     this.asin = null | ||||
|     this.image = null | ||||
|     this.imageFullPath = null | ||||
| 
 | ||||
|     this.createdAt = null | ||||
|     this.lastUpdate = null | ||||
| 
 | ||||
|     if (author) { | ||||
|       this.construct(author) | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   construct(author) { | ||||
|     this.id = author.id | ||||
|     this.name = author.name | ||||
|     this.description = author.description | ||||
|     this.asin = author.asin | ||||
|     this.image = author.image | ||||
|     this.imageFullPath = author.imageFullPath | ||||
| 
 | ||||
|     this.createdAt = author.createdAt | ||||
|     this.lastUpdate = author.lastUpdate | ||||
|   } | ||||
| 
 | ||||
|   toJSON() { | ||||
|     return { | ||||
|       id: this.id, | ||||
|       name: this.name, | ||||
|       description: this.description, | ||||
|       asin: this.asin, | ||||
|       image: this.image, | ||||
|       imageFullPath: this.imageFullPath, | ||||
|       createdAt: this.createdAt, | ||||
|       lastUpdate: this.lastUpdate | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   setData(data) { | ||||
|     this.id = data.id ? data.id : getId('per') | ||||
|     this.name = data.name | ||||
|     this.description = data.description | ||||
|     this.asin = data.asin || null | ||||
|     this.image = data.image || null | ||||
|     this.imageFullPath = data.imageFullPath || null | ||||
|     this.createdAt = Date.now() | ||||
|     this.lastUpdate = Date.now() | ||||
|   } | ||||
| 
 | ||||
|   update(payload) { | ||||
|     var hasUpdates = false | ||||
|     for (const key in payload) { | ||||
|       if (this[key] === undefined) continue; | ||||
|       if (this[key] !== payload[key]) { | ||||
|         hasUpdates = true | ||||
|         this[key] = payload[key] | ||||
|       } | ||||
|     } | ||||
|     if (hasUpdates) { | ||||
|       this.lastUpdate = Date.now() | ||||
|     } | ||||
|     return hasUpdates | ||||
|   } | ||||
| } | ||||
| module.exports = Author | ||||
| @ -9,6 +9,7 @@ class Book { | ||||
|     this.author = null | ||||
|     this.authorFL = null | ||||
|     this.authorLF = null | ||||
|     this.authors = [] | ||||
|     this.narrator = null | ||||
|     this.series = null | ||||
|     this.volumeNumber = null | ||||
| @ -51,6 +52,7 @@ class Book { | ||||
|     this.title = book.title | ||||
|     this.subtitle = book.subtitle || null | ||||
|     this.author = book.author | ||||
|     this.authors = (book.authors || []).map(a => ({ ...a })) | ||||
|     this.authorFL = book.authorFL || null | ||||
|     this.authorLF = book.authorLF || null | ||||
|     this.narrator = book.narrator || book.narrarator || null // Mispelled initially... need to catch those
 | ||||
| @ -81,6 +83,7 @@ class Book { | ||||
|       title: this.title, | ||||
|       subtitle: this.subtitle, | ||||
|       author: this.author, | ||||
|       authors: this.authors, | ||||
|       authorFL: this.authorFL, | ||||
|       authorLF: this.authorLF, | ||||
|       narrator: this.narrator, | ||||
| @ -142,6 +145,7 @@ class Book { | ||||
|     this.title = data.title || null | ||||
|     this.subtitle = data.subtitle || null | ||||
|     this.author = data.author || null | ||||
|     this.authors = data.authors || [] | ||||
|     this.narrator = data.narrator || data.narrarator || null | ||||
|     this.series = data.series || null | ||||
|     this.volumeNumber = data.volumeNumber || null | ||||
|  | ||||
							
								
								
									
										47
									
								
								server/providers/Audnexus.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								server/providers/Audnexus.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,47 @@ | ||||
| const axios = require('axios') | ||||
| const { levenshteinDistance } = require('../utils/index') | ||||
| const Logger = require('../Logger') | ||||
| 
 | ||||
| class Audnexus { | ||||
|   constructor() { | ||||
|     this.baseUrl = 'https://api.audnex.us' | ||||
|   } | ||||
| 
 | ||||
|   authorASINsRequest(name) { | ||||
|     return axios.get(`${this.baseUrl}/authors?name=${name}`).then((res) => { | ||||
|       return res.data || [] | ||||
|     }).catch((error) => { | ||||
|       Logger.error(`[Audnexus] Author ASIN request failed for ${name}`, error) | ||||
|       return [] | ||||
|     }) | ||||
|   } | ||||
| 
 | ||||
|   authorRequest(asin) { | ||||
|     return axios.get(`${this.baseUrl}/authors/${asin}`).then((res) => { | ||||
|       return res.data | ||||
|     }).catch((error) => { | ||||
|       Logger.error(`[Audnexus] Author request failed for ${asin}`, error) | ||||
|       return null | ||||
|     }) | ||||
|   } | ||||
| 
 | ||||
|   async findAuthorByName(name, maxLevenshtein = 2) { | ||||
|     Logger.debug(`[Audnexus] Looking up author by name ${name}`) | ||||
|     var asins = await this.authorASINsRequest(name) | ||||
|     var matchingAsin = asins.find(obj => levenshteinDistance(obj.name, name) <= maxLevenshtein) | ||||
|     if (!matchingAsin) { | ||||
|       return null | ||||
|     } | ||||
|     var author = await this.authorRequest(matchingAsin.asin) | ||||
|     if (!author) { | ||||
|       return null | ||||
|     } | ||||
|     return { | ||||
|       asin: author.asin, | ||||
|       description: author.description, | ||||
|       image: author.image, | ||||
|       name: author.name | ||||
|     } | ||||
|   } | ||||
| } | ||||
| module.exports = Audnexus | ||||
| @ -142,3 +142,19 @@ async function recurseFiles(path) { | ||||
|   return list | ||||
| } | ||||
| module.exports.recurseFiles = recurseFiles | ||||
| 
 | ||||
| module.exports.downloadFile = async (url, filepath) => { | ||||
|   Logger.debug(`[fileUtils] Downloading file to ${filepath}`) | ||||
| 
 | ||||
|   const writer = fs.createWriteStream(filepath) | ||||
|   const response = await axios({ | ||||
|     url, | ||||
|     method: 'GET', | ||||
|     responseType: 'stream' | ||||
|   }) | ||||
|   response.data.pipe(writer) | ||||
|   return new Promise((resolve, reject) => { | ||||
|     writer.on('finish', resolve) | ||||
|     writer.on('error', reject) | ||||
|   }) | ||||
| } | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user