mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-10-27 11:18:14 +01:00 
			
		
		
		
	Merge pull request #2486 from FlyinPancake/dewyer/add-custom-metadata-provider
[Feature] Add support for custom metadata providers through a REST API
This commit is contained in:
		
						commit
						ce7f81d676
					
				| @ -1,6 +1,7 @@ | ||||
| <template> | ||||
|   <div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8"> | ||||
|     <div class="flex items-center mb-2"> | ||||
|       <slot name="header-prefix"></slot> | ||||
|       <h1 class="text-xl">{{ headerText }}</h1> | ||||
| 
 | ||||
|       <slot name="header-items"></slot> | ||||
|  | ||||
							
								
								
									
										105
									
								
								client/components/modals/AddCustomMetadataProviderModal.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										105
									
								
								client/components/modals/AddCustomMetadataProviderModal.vue
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,105 @@ | ||||
| <template> | ||||
|   <modals-modal ref="modal" v-model="show" name="custom-metadata-provider" :width="600" :height="'unset'" :processing="processing"> | ||||
|     <template #outer> | ||||
|       <div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden"> | ||||
|         <p class="text-3xl text-white truncate">Add custom metadata provider</p> | ||||
|       </div> | ||||
|     </template> | ||||
|     <form @submit.prevent="submitForm"> | ||||
|       <div class="px-4 w-full flex items-center text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 overflow-y-auto overflow-x-hidden" style="min-height: 400px; max-height: 80vh"> | ||||
|         <div class="w-full p-8"> | ||||
|           <div class="flex mb-2"> | ||||
|             <div class="w-3/4 p-1"> | ||||
|               <ui-text-input-with-label v-model="newName" :label="$strings.LabelName" /> | ||||
|             </div> | ||||
|             <div class="w-1/4 p-1"> | ||||
|               <ui-text-input-with-label value="Book" readonly :label="$strings.LabelMediaType" /> | ||||
|             </div> | ||||
|           </div> | ||||
|           <div class="w-full mb-2 p-1"> | ||||
|             <ui-text-input-with-label v-model="newUrl" label="URL" /> | ||||
|           </div> | ||||
|           <div class="w-full mb-2 p-1"> | ||||
|             <ui-text-input-with-label v-model="newAuthHeaderValue" :label="'Authorization Header Value'" type="password" /> | ||||
|           </div> | ||||
|           <div class="flex px-1 pt-4"> | ||||
|             <div class="flex-grow" /> | ||||
|             <ui-btn color="success" type="submit">{{ $strings.ButtonAdd }}</ui-btn> | ||||
|           </div> | ||||
|         </div> | ||||
|       </div> | ||||
|     </form> | ||||
|   </modals-modal> | ||||
| </template> | ||||
| 
 | ||||
| <script> | ||||
| export default { | ||||
|   props: { | ||||
|     value: Boolean | ||||
|   }, | ||||
|   data() { | ||||
|     return { | ||||
|       processing: false, | ||||
|       newName: '', | ||||
|       newUrl: '', | ||||
|       newAuthHeaderValue: '' | ||||
|     } | ||||
|   }, | ||||
|   watch: { | ||||
|     show: { | ||||
|       handler(newVal) { | ||||
|         if (newVal) { | ||||
|           this.init() | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   }, | ||||
|   computed: { | ||||
|     show: { | ||||
|       get() { | ||||
|         return this.value | ||||
|       }, | ||||
|       set(val) { | ||||
|         this.$emit('input', val) | ||||
|       } | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
|     submitForm() { | ||||
|       if (!this.newName || !this.newUrl) { | ||||
|         this.$toast.error('Must add name and url') | ||||
|         return | ||||
|       } | ||||
| 
 | ||||
|       this.processing = true | ||||
|       this.$axios | ||||
|         .$post('/api/custom-metadata-providers', { | ||||
|           name: this.newName, | ||||
|           url: this.newUrl, | ||||
|           mediaType: 'book', // Currently only supporting book mediaType | ||||
|           authHeaderValue: this.newAuthHeaderValue | ||||
|         }) | ||||
|         .then((data) => { | ||||
|           this.$emit('added', data.provider) | ||||
|           this.$toast.success('New provider added') | ||||
|           this.show = false | ||||
|         }) | ||||
|         .catch((error) => { | ||||
|           const errorMsg = error.response?.data || 'Unknown error' | ||||
|           console.error('Failed to add provider', error) | ||||
|           this.$toast.error('Failed to add provider: ' + errorMsg) | ||||
|         }) | ||||
|         .finally(() => { | ||||
|           this.processing = false | ||||
|         }) | ||||
|     }, | ||||
|     init() { | ||||
|       this.processing = false | ||||
|       this.newName = '' | ||||
|       this.newUrl = '' | ||||
|       this.newAuthHeaderValue = '' | ||||
|     } | ||||
|   }, | ||||
|   mounted() {} | ||||
| } | ||||
| </script> | ||||
| @ -328,6 +328,17 @@ export default { | ||||
|         console.error('PersistProvider', error) | ||||
|       } | ||||
|     }, | ||||
|     getDefaultBookProvider() { | ||||
|       let provider = localStorage.getItem('book-provider') | ||||
|       if (!provider) return 'google' | ||||
|       // Validate book provider | ||||
|       if (!this.$store.getters['scanners/checkBookProviderExists'](provider)) { | ||||
|         console.error('Stored book provider does not exist', provider) | ||||
|         localStorage.removeItem('book-provider') | ||||
|         return 'google' | ||||
|       } | ||||
|       return provider | ||||
|     }, | ||||
|     getSearchQuery() { | ||||
|       if (this.isPodcast) return `term=${encodeURIComponent(this.searchTitle)}` | ||||
|       var searchQuery = `provider=${this.provider}&fallbackTitleOnly=1&title=${encodeURIComponent(this.searchTitle)}` | ||||
| @ -434,7 +445,9 @@ export default { | ||||
|       this.searchTitle = this.libraryItem.media.metadata.title | ||||
|       this.searchAuthor = this.libraryItem.media.metadata.authorName || '' | ||||
|       if (this.isPodcast) this.provider = 'itunes' | ||||
|       else this.provider = localStorage.getItem('book-provider') || 'google' | ||||
|       else { | ||||
|         this.provider = this.getDefaultBookProvider() | ||||
|       } | ||||
| 
 | ||||
|       // Prefer using ASIN if set and using audible provider | ||||
|       if (this.provider.startsWith('audible') && this.libraryItem.media.metadata.asin) { | ||||
|  | ||||
							
								
								
									
										127
									
								
								client/components/tables/CustomMetadataProviderTable.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										127
									
								
								client/components/tables/CustomMetadataProviderTable.vue
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,127 @@ | ||||
| <template> | ||||
|   <div class="min-h-40"> | ||||
|     <table v-if="providers.length" id="providers"> | ||||
|       <tr> | ||||
|         <th>{{ $strings.LabelName }}</th> | ||||
|         <th>URL</th> | ||||
|         <th>Authorization Header Value</th> | ||||
|         <th class="w-12"></th> | ||||
|       </tr> | ||||
|       <tr v-for="provider in providers" :key="provider.id"> | ||||
|         <td class="text-sm">{{ provider.name }}</td> | ||||
|         <td class="text-sm">{{ provider.url }}</td> | ||||
|         <td class="text-sm"> | ||||
|           <span v-if="provider.authHeaderValue" class="custom-provider-api-key">{{ provider.authHeaderValue }}</span> | ||||
|         </td> | ||||
|         <td class="py-0"> | ||||
|           <div class="h-8 w-8 flex items-center justify-center text-white text-opacity-50 hover:text-error cursor-pointer" @click.stop="removeProvider(provider)"> | ||||
|             <button type="button" :aria-label="$strings.ButtonDelete" class="material-icons text-base">delete</button> | ||||
|           </div> | ||||
|         </td> | ||||
|       </tr> | ||||
|     </table> | ||||
|     <div v-else-if="!processing" class="text-center py-8"> | ||||
|       <p class="text-lg">No custom metadata providers</p> | ||||
|     </div> | ||||
| 
 | ||||
|     <div v-if="processing" class="absolute inset-0 h-full flex items-center justify-center bg-black/40 rounded-md"> | ||||
|       <ui-loading-indicator /> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
| 
 | ||||
| <script> | ||||
| export default { | ||||
|   props: { | ||||
|     providers: { | ||||
|       type: Array, | ||||
|       default: () => [] | ||||
|     }, | ||||
|     processing: Boolean | ||||
|   }, | ||||
|   data() { | ||||
|     return {} | ||||
|   }, | ||||
|   methods: { | ||||
|     removeProvider(provider) { | ||||
|       const payload = { | ||||
|         message: `Are you sure you want remove custom metadata provider "${provider.name}"?`, | ||||
|         callback: (confirmed) => { | ||||
|           if (confirmed) { | ||||
|             this.$emit('update:processing', true) | ||||
| 
 | ||||
|             this.$axios | ||||
|               .$delete(`/api/custom-metadata-providers/${provider.id}`) | ||||
|               .then(() => { | ||||
|                 this.$toast.success('Provider removed') | ||||
|                 this.$emit('removed', provider.id) | ||||
|               }) | ||||
|               .catch((error) => { | ||||
|                 console.error('Failed to remove provider', error) | ||||
|                 this.$toast.error('Failed to remove provider') | ||||
|               }) | ||||
|               .finally(() => { | ||||
|                 this.$emit('update:processing', false) | ||||
|               }) | ||||
|           } | ||||
|         }, | ||||
|         type: 'yesNo' | ||||
|       } | ||||
|       this.$store.commit('globals/setConfirmPrompt', payload) | ||||
|     } | ||||
|   } | ||||
| } | ||||
| </script> | ||||
| 
 | ||||
| <style> | ||||
| #providers { | ||||
|   table-layout: fixed; | ||||
|   border-collapse: collapse; | ||||
|   border: 1px solid #474747; | ||||
|   width: 100%; | ||||
| } | ||||
| 
 | ||||
| #providers td, | ||||
| #providers th { | ||||
|   /* border: 1px solid #2e2e2e; */ | ||||
|   padding: 8px 8px; | ||||
|   text-align: left; | ||||
| } | ||||
| 
 | ||||
| #providers td.py-0 { | ||||
|   padding: 0px 8px; | ||||
| } | ||||
| 
 | ||||
| #providers tr:nth-child(even) { | ||||
|   background-color: #373838; | ||||
| } | ||||
| 
 | ||||
| #providers tr:nth-child(odd) { | ||||
|   background-color: #2f2f2f; | ||||
| } | ||||
| 
 | ||||
| #providers tr:hover { | ||||
|   background-color: #444; | ||||
| } | ||||
| 
 | ||||
| #providers th { | ||||
|   font-size: 0.8rem; | ||||
|   font-weight: 600; | ||||
|   padding-top: 5px; | ||||
|   padding-bottom: 5px; | ||||
|   background-color: #272727; | ||||
| } | ||||
| 
 | ||||
| .custom-provider-api-key { | ||||
|   padding: 1px; | ||||
|   background-color: #272727; | ||||
|   border-radius: 4px; | ||||
|   color: transparent; | ||||
|   transition: color, background-color 0.5s ease; | ||||
| } | ||||
| 
 | ||||
| .custom-provider-api-key:hover { | ||||
|   background-color: transparent; | ||||
|   color: white; | ||||
| } | ||||
| </style> | ||||
| @ -328,6 +328,14 @@ export default { | ||||
| 
 | ||||
|       this.$store.commit('libraries/setEReaderDevices', data.ereaderDevices) | ||||
|     }, | ||||
|     customMetadataProviderAdded(provider) { | ||||
|       if (!provider?.id) return | ||||
|       this.$store.commit('scanners/addCustomMetadataProvider', provider) | ||||
|     }, | ||||
|     customMetadataProviderRemoved(provider) { | ||||
|       if (!provider?.id) return | ||||
|       this.$store.commit('scanners/removeCustomMetadataProvider', provider) | ||||
|     }, | ||||
|     initializeSocket() { | ||||
|       this.socket = this.$nuxtSocket({ | ||||
|         name: process.env.NODE_ENV === 'development' ? 'dev' : 'prod', | ||||
| @ -406,6 +414,10 @@ export default { | ||||
|       this.socket.on('batch_quickmatch_complete', this.batchQuickMatchComplete) | ||||
| 
 | ||||
|       this.socket.on('admin_message', this.adminMessageEvt) | ||||
| 
 | ||||
|       // Custom metadata provider Listeners | ||||
|       this.socket.on('custom_metadata_provider_added', this.customMetadataProviderAdded) | ||||
|       this.socket.on('custom_metadata_provider_removed', this.customMetadataProviderRemoved) | ||||
|     }, | ||||
|     showUpdateToast(versionData) { | ||||
|       var ignoreVersion = localStorage.getItem('ignoreVersion') | ||||
|  | ||||
| @ -0,0 +1,74 @@ | ||||
| <template> | ||||
|   <div class="relative"> | ||||
|     <app-settings-content :header-text="$strings.HeaderCustomMetadataProviders"> | ||||
|       <template #header-prefix> | ||||
|         <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 mr-2"> | ||||
|           <span class="material-icons text-2xl">arrow_back</span> | ||||
|         </nuxt-link> | ||||
|       </template> | ||||
|       <template #header-items> | ||||
|         <ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2"> | ||||
|           <a href="https://www.audiobookshelf.org/guides/#" target="_blank" class="inline-flex"> | ||||
|             <span class="material-icons text-xl w-5 text-gray-200">help_outline</span> | ||||
|           </a> | ||||
|         </ui-tooltip> | ||||
|         <div class="flex-grow" /> | ||||
| 
 | ||||
|         <ui-btn color="primary" small @click="setShowAddModal">{{ $strings.ButtonAdd }}</ui-btn> | ||||
|       </template> | ||||
| 
 | ||||
|       <tables-custom-metadata-provider-table :providers="providers" :processing.sync="processing" class="pt-2" @removed="providerRemoved" /> | ||||
|       <modals-add-custom-metadata-provider-modal ref="addModal" v-model="showAddModal" @added="providerAdded" /> | ||||
|     </app-settings-content> | ||||
|   </div> | ||||
| </template> | ||||
| 
 | ||||
| <script> | ||||
| export default { | ||||
|   async asyncData({ store, redirect }) { | ||||
|     if (!store.getters['user/getIsAdminOrUp']) { | ||||
|       redirect('/') | ||||
|       return | ||||
|     } | ||||
|     return {} | ||||
|   }, | ||||
|   data() { | ||||
|     return { | ||||
|       showAddModal: false, | ||||
|       processing: false, | ||||
|       providers: [] | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
|     providerRemoved(providerId) { | ||||
|       this.providers = this.providers.filter((p) => p.id !== providerId) | ||||
|     }, | ||||
|     providerAdded(provider) { | ||||
|       this.providers.push(provider) | ||||
|     }, | ||||
|     setShowAddModal() { | ||||
|       this.showAddModal = true | ||||
|     }, | ||||
|     loadProviders() { | ||||
|       this.processing = true | ||||
|       this.$axios | ||||
|         .$get('/api/custom-metadata-providers') | ||||
|         .then((res) => { | ||||
|           this.providers = res.providers | ||||
|         }) | ||||
|         .catch((error) => { | ||||
|           console.error('Failed', error) | ||||
|           this.$toast.error('Failed to load custom metadata providers') | ||||
|         }) | ||||
|         .finally(() => { | ||||
|           this.processing = false | ||||
|         }) | ||||
|     } | ||||
|   }, | ||||
|   mounted() { | ||||
|     this.loadProviders() | ||||
|   } | ||||
| } | ||||
| </script> | ||||
| 
 | ||||
| <style></style> | ||||
| @ -13,6 +13,12 @@ | ||||
|           <span class="material-icons">arrow_forward</span> | ||||
|         </div> | ||||
|       </nuxt-link> | ||||
|       <nuxt-link to="/config/item-metadata-utils/custom-metadata-providers" 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>{{ $strings.HeaderCustomMetadataProviders }}</p> | ||||
|           <span class="material-icons">arrow_forward</span> | ||||
|         </div> | ||||
|       </nuxt-link> | ||||
|     </app-settings-content> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| @ -113,6 +113,7 @@ export const actions = { | ||||
|         const library = data.library | ||||
|         const filterData = data.filterdata | ||||
|         const issues = data.issues || 0 | ||||
|         const customMetadataProviders = data.customMetadataProviders || [] | ||||
|         const numUserPlaylists = data.numUserPlaylists | ||||
| 
 | ||||
|         dispatch('user/checkUpdateLibrarySortFilter', library.mediaType, { root: true }) | ||||
| @ -126,6 +127,8 @@ export const actions = { | ||||
|         commit('setLibraryIssues', issues) | ||||
|         commit('setLibraryFilterData', filterData) | ||||
|         commit('setNumUserPlaylists', numUserPlaylists) | ||||
|         commit('scanners/setCustomMetadataProviders', customMetadataProviders, { root: true }) | ||||
| 
 | ||||
|         commit('setCurrentLibrary', libraryId) | ||||
|         return data | ||||
|       }) | ||||
|  | ||||
| @ -71,8 +71,56 @@ export const state = () => ({ | ||||
|   ] | ||||
| }) | ||||
| 
 | ||||
| export const getters = {} | ||||
| export const getters = { | ||||
|   checkBookProviderExists: state => (providerValue) => { | ||||
|     return state.providers.some(p => p.value === providerValue) | ||||
|   }, | ||||
|   checkPodcastProviderExists: state => (providerValue) => { | ||||
|     return state.podcastProviders.some(p => p.value === providerValue) | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export const actions = {} | ||||
| 
 | ||||
| export const mutations = {} | ||||
| export const mutations = { | ||||
|   addCustomMetadataProvider(state, provider) { | ||||
|     if (provider.mediaType === 'book') { | ||||
|       if (state.providers.some(p => p.value === provider.slug)) return | ||||
|       state.providers.push({ | ||||
|         text: provider.name, | ||||
|         value: provider.slug | ||||
|       }) | ||||
|     } else { | ||||
|       if (state.podcastProviders.some(p => p.value === provider.slug)) return | ||||
|       state.podcastProviders.push({ | ||||
|         text: provider.name, | ||||
|         value: provider.slug | ||||
|       }) | ||||
|     } | ||||
|   }, | ||||
|   removeCustomMetadataProvider(state, provider) { | ||||
|     if (provider.mediaType === 'book') { | ||||
|       state.providers = state.providers.filter(p => p.value !== provider.slug) | ||||
|     } else { | ||||
|       state.podcastProviders = state.podcastProviders.filter(p => p.value !== provider.slug) | ||||
|     } | ||||
|   }, | ||||
|   setCustomMetadataProviders(state, providers) { | ||||
|     if (!providers?.length) return | ||||
| 
 | ||||
|     const mediaType = providers[0].mediaType | ||||
|     if (mediaType === 'book') { | ||||
|       // clear previous values, and add new values to the end
 | ||||
|       state.providers = state.providers.filter((p) => !p.value.startsWith('custom-')) | ||||
|       state.providers = [ | ||||
|         ...state.providers, | ||||
|         ...providers.map((p) => ({ | ||||
|           text: p.name, | ||||
|           value: p.slug | ||||
|         })) | ||||
|       ] | ||||
|     } else { | ||||
|       // Podcast providers not supported yet
 | ||||
|     } | ||||
|   } | ||||
| } | ||||
| @ -104,6 +104,7 @@ | ||||
|   "HeaderCollectionItems": "Collection Items", | ||||
|   "HeaderCover": "Cover", | ||||
|   "HeaderCurrentDownloads": "Current Downloads", | ||||
|   "HeaderCustomMetadataProviders": "Custom Metadata Providers", | ||||
|   "HeaderDetails": "Details", | ||||
|   "HeaderDownloadQueue": "Download Queue", | ||||
|   "HeaderEbookFiles": "Ebook Files", | ||||
|  | ||||
							
								
								
									
										135
									
								
								custom-metadata-provider-specification.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										135
									
								
								custom-metadata-provider-specification.yaml
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,135 @@ | ||||
| openapi: 3.0.0 | ||||
| servers:  | ||||
|   - url: https://example.com | ||||
|     description: Local server  | ||||
| info: | ||||
|   license:  | ||||
|     name: MIT | ||||
|     url: https://opensource.org/licenses/MIT | ||||
| 
 | ||||
|      | ||||
|   title: Custom Metadata Provider | ||||
|   version: 0.1.0 | ||||
| security: | ||||
|   - api_key: [] | ||||
| 
 | ||||
| paths: | ||||
|   /search: | ||||
|     get: | ||||
|       description: Search for books | ||||
|       operationId: search | ||||
|       summary: Search for books | ||||
|       security:  | ||||
|         -  api_key: [] | ||||
|       parameters: | ||||
|         - name: query | ||||
|           in: query | ||||
|           required: true | ||||
|           schema: | ||||
|             type: string | ||||
|         - name: author | ||||
|           in: query | ||||
|           required: false | ||||
|           schema: | ||||
|             type: string | ||||
|       responses: | ||||
|         "200": | ||||
|           description: OK | ||||
|           content: | ||||
|             application/json: | ||||
|               schema: | ||||
|                 type: object | ||||
|                 properties: | ||||
|                   matches: | ||||
|                     type: array | ||||
|                     items: | ||||
|                       $ref: "#/components/schemas/BookMetadata" | ||||
|         "400": | ||||
|           description: Bad Request | ||||
|           content: | ||||
|             application/json: | ||||
|               schema: | ||||
|                 type: object | ||||
|                 properties: | ||||
|                   error: | ||||
|                     type: string | ||||
|         "401": | ||||
|           description: Unauthorized | ||||
|           content: | ||||
|             application/json: | ||||
|               schema: | ||||
|                 type: object | ||||
|                 properties: | ||||
|                   error: | ||||
|                     type: string | ||||
|         "500": | ||||
|           description: Internal Server Error | ||||
|           content: | ||||
|             application/json: | ||||
|               schema: | ||||
|                 type: object | ||||
|                 properties: | ||||
|                   error: | ||||
|                     type: string | ||||
| components: | ||||
|   schemas: | ||||
|     BookMetadata: | ||||
|       type: object | ||||
|       properties: | ||||
|         title: | ||||
|           type: string | ||||
|         subtitle: | ||||
|           type: string | ||||
|         author: | ||||
|           type: string | ||||
|         narrator: | ||||
|           type: string | ||||
|         publisher: | ||||
|           type: string | ||||
|         publishedYear: | ||||
|           type: string | ||||
|         description: | ||||
|           type: string | ||||
|         cover: | ||||
|           type: string | ||||
|           description: URL to the cover image | ||||
|         isbn: | ||||
|           type: string | ||||
|           format: isbn | ||||
|         asin: | ||||
|           type: string | ||||
|           format: asin | ||||
|         genres: | ||||
|           type: array | ||||
|           items: | ||||
|             type: string | ||||
|         tags: | ||||
|           type: array | ||||
|           items: | ||||
|             type: string | ||||
|         series: | ||||
|           type: array | ||||
|           items: | ||||
|             type: object | ||||
|             properties: | ||||
|               series: | ||||
|                 type: string | ||||
|                 required: true | ||||
|               sequence: | ||||
|                 type: number | ||||
|                 format: int64 | ||||
|         language: | ||||
|           type: string | ||||
|         duration: | ||||
|           type: number | ||||
|           format: int64 | ||||
|           description: Duration in seconds | ||||
|       required:  | ||||
|         -  title | ||||
|   securitySchemes: | ||||
|     api_key: | ||||
|       type: apiKey | ||||
|       name: AUTHORIZATION | ||||
|       in: header | ||||
|          | ||||
|            | ||||
| @ -132,6 +132,11 @@ class Database { | ||||
|     return this.models.playbackSession | ||||
|   } | ||||
| 
 | ||||
|   /** @type {typeof import('./models/CustomMetadataProvider')} */ | ||||
|   get customMetadataProviderModel() { | ||||
|     return this.models.customMetadataProvider | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Check if db file exists | ||||
|    * @returns {boolean} | ||||
| @ -245,6 +250,7 @@ class Database { | ||||
|     require('./models/Feed').init(this.sequelize) | ||||
|     require('./models/FeedEpisode').init(this.sequelize) | ||||
|     require('./models/Setting').init(this.sequelize) | ||||
|     require('./models/CustomMetadataProvider').init(this.sequelize) | ||||
| 
 | ||||
|     return this.sequelize.sync({ force, alter: false }) | ||||
|   } | ||||
|  | ||||
							
								
								
									
										117
									
								
								server/controllers/CustomMetadataProviderController.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										117
									
								
								server/controllers/CustomMetadataProviderController.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,117 @@ | ||||
| const Logger = require('../Logger') | ||||
| const SocketAuthority = require('../SocketAuthority') | ||||
| const Database = require('../Database') | ||||
| 
 | ||||
| const { validateUrl } = require('../utils/index') | ||||
| 
 | ||||
| //
 | ||||
| // This is a controller for routes that don't have a home yet :(
 | ||||
| //
 | ||||
| class CustomMetadataProviderController { | ||||
|   constructor() { } | ||||
| 
 | ||||
|   /** | ||||
|    * GET: /api/custom-metadata-providers | ||||
|    * | ||||
|    * @param {import('express').Request} req | ||||
|    * @param {import('express').Response} res | ||||
|    */ | ||||
|   async getAll(req, res) { | ||||
|     const providers = await Database.customMetadataProviderModel.findAll() | ||||
| 
 | ||||
|     res.json({ | ||||
|       providers | ||||
|     }) | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * POST: /api/custom-metadata-providers | ||||
|    * | ||||
|    * @param {import('express').Request} req | ||||
|    * @param {import('express').Response} res | ||||
|    */ | ||||
|   async create(req, res) { | ||||
|     const { name, url, mediaType, authHeaderValue } = req.body | ||||
| 
 | ||||
|     if (!name || !url || !mediaType) { | ||||
|       return res.status(400).send('Invalid request body') | ||||
|     } | ||||
| 
 | ||||
|     const validUrl = validateUrl(url) | ||||
|     if (!validUrl) { | ||||
|       Logger.error(`[CustomMetadataProviderController] Invalid url "${url}"`) | ||||
|       return res.status(400).send('Invalid url') | ||||
|     } | ||||
| 
 | ||||
|     const provider = await Database.customMetadataProviderModel.create({ | ||||
|       name, | ||||
|       mediaType, | ||||
|       url, | ||||
|       authHeaderValue: !authHeaderValue ? null : authHeaderValue, | ||||
|     }) | ||||
| 
 | ||||
|     // TODO: Necessary to emit to all clients?
 | ||||
|     SocketAuthority.emitter('custom_metadata_provider_added', provider.toClientJson()) | ||||
| 
 | ||||
|     res.json({ | ||||
|       provider | ||||
|     }) | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * DELETE: /api/custom-metadata-providers/:id | ||||
|    *  | ||||
|    * @param {import('express').Request} req | ||||
|    * @param {import('express').Response} res | ||||
|    */ | ||||
|   async delete(req, res) { | ||||
|     const slug = `custom-${req.params.id}` | ||||
| 
 | ||||
|     /** @type {import('../models/CustomMetadataProvider')} */ | ||||
|     const provider = req.customMetadataProvider | ||||
|     const providerClientJson = provider.toClientJson() | ||||
| 
 | ||||
|     const fallbackProvider = provider.mediaType === 'book' ? 'google' : 'itunes' | ||||
| 
 | ||||
|     await provider.destroy() | ||||
| 
 | ||||
|     // Libraries using this provider fallback to default provider
 | ||||
|     await Database.libraryModel.update({ | ||||
|       provider: fallbackProvider | ||||
|     }, { | ||||
|       where: { | ||||
|         provider: slug | ||||
|       } | ||||
|     }) | ||||
| 
 | ||||
|     // TODO: Necessary to emit to all clients?
 | ||||
|     SocketAuthority.emitter('custom_metadata_provider_removed', providerClientJson) | ||||
| 
 | ||||
|     res.sendStatus(200) | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Middleware that requires admin or up | ||||
|    *  | ||||
|    * @param {import('express').Request} req  | ||||
|    * @param {import('express').Response} res  | ||||
|    * @param {import('express').NextFunction} next  | ||||
|    */ | ||||
|   async middleware(req, res, next) { | ||||
|     if (!req.user.isAdminOrUp) { | ||||
|       Logger.warn(`[CustomMetadataProviderController] Non-admin user "${req.user.username}" attempted access route "${req.path}"`) | ||||
|       return res.sendStatus(403) | ||||
|     } | ||||
| 
 | ||||
|     // If id param then add req.customMetadataProvider
 | ||||
|     if (req.params.id) { | ||||
|       req.customMetadataProvider = await Database.customMetadataProviderModel.findByPk(req.params.id) | ||||
|       if (!req.customMetadataProvider) { | ||||
|         return res.sendStatus(404) | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     next() | ||||
|   } | ||||
| } | ||||
| module.exports = new CustomMetadataProviderController() | ||||
| @ -33,6 +33,14 @@ class LibraryController { | ||||
|       return res.status(500).send('Invalid request') | ||||
|     } | ||||
| 
 | ||||
|     // Validate that the custom provider exists if given any
 | ||||
|     if (newLibraryPayload.provider?.startsWith('custom-')) { | ||||
|       if (!await Database.customMetadataProviderModel.checkExistsBySlug(newLibraryPayload.provider)) { | ||||
|         Logger.error(`[LibraryController] Custom metadata provider "${newLibraryPayload.provider}" does not exist`) | ||||
|         return res.status(400).send('Custom metadata provider does not exist') | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     // Validate folder paths exist or can be created & resolve rel paths
 | ||||
|     //   returns 400 if a folder fails to access
 | ||||
|     newLibraryPayload.folders = newLibraryPayload.folders.map(f => { | ||||
| @ -86,19 +94,27 @@ class LibraryController { | ||||
|     }) | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * GET: /api/libraries/:id | ||||
|    *  | ||||
|    * @param {import('express').Request} req  | ||||
|    * @param {import('express').Response} res  | ||||
|    */ | ||||
|   async findOne(req, res) { | ||||
|     const includeArray = (req.query.include || '').split(',') | ||||
|     if (includeArray.includes('filterdata')) { | ||||
|       const filterdata = await libraryFilters.getFilterData(req.library.mediaType, req.library.id) | ||||
|       const customMetadataProviders = await Database.customMetadataProviderModel.getForClientByMediaType(req.library.mediaType) | ||||
| 
 | ||||
|       return res.json({ | ||||
|         filterdata, | ||||
|         issues: filterdata.numIssues, | ||||
|         numUserPlaylists: await Database.playlistModel.getNumPlaylistsForUserAndLibrary(req.user.id, req.library.id), | ||||
|         customMetadataProviders, | ||||
|         library: req.library | ||||
|       }) | ||||
|     } | ||||
|     return res.json(req.library) | ||||
|     res.json(req.library) | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
| @ -115,6 +131,14 @@ class LibraryController { | ||||
|   async update(req, res) { | ||||
|     const library = req.library | ||||
| 
 | ||||
|     // Validate that the custom provider exists if given any
 | ||||
|     if (req.body.provider?.startsWith('custom-')) { | ||||
|       if (!await Database.customMetadataProviderModel.checkExistsBySlug(req.body.provider)) { | ||||
|         Logger.error(`[LibraryController] Custom metadata provider "${req.body.provider}" does not exist`) | ||||
|         return res.status(400).send('Custom metadata provider does not exist') | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     // Validate new folder paths exist or can be created & resolve rel paths
 | ||||
|     //   returns 400 if a new folder fails to access
 | ||||
|     if (req.body.folders) { | ||||
|  | ||||
| @ -161,7 +161,7 @@ class SessionController { | ||||
|    * @typedef batchDeleteReqBody | ||||
|    * @property {string[]} sessions | ||||
|    *  | ||||
|    * @param {import('express').Request<{}, {}, batchDeleteReqBody, {}} req  | ||||
|    * @param {import('express').Request<{}, {}, batchDeleteReqBody, {}>} req  | ||||
|    * @param {import('express').Response} res  | ||||
|    */ | ||||
|   async batchDelete(req, res) { | ||||
|  | ||||
| @ -5,6 +5,7 @@ const iTunes = require('../providers/iTunes') | ||||
| const Audnexus = require('../providers/Audnexus') | ||||
| const FantLab = require('../providers/FantLab') | ||||
| const AudiobookCovers = require('../providers/AudiobookCovers') | ||||
| const CustomProviderAdapter = require('../providers/CustomProviderAdapter') | ||||
| const Logger = require('../Logger') | ||||
| const { levenshteinDistance, escapeRegExp } = require('../utils/index') | ||||
| 
 | ||||
| @ -17,6 +18,7 @@ class BookFinder { | ||||
|     this.audnexus = new Audnexus() | ||||
|     this.fantLab = new FantLab() | ||||
|     this.audiobookCovers = new AudiobookCovers() | ||||
|     this.customProviderAdapter = new CustomProviderAdapter() | ||||
| 
 | ||||
|     this.providers = ['google', 'itunes', 'openlibrary', 'fantlab', 'audiobookcovers', 'audible', 'audible.ca', 'audible.uk', 'audible.au', 'audible.fr', 'audible.de', 'audible.jp', 'audible.it', 'audible.in', 'audible.es'] | ||||
| 
 | ||||
| @ -147,6 +149,20 @@ class BookFinder { | ||||
|     return books | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    *  | ||||
|    * @param {string} title  | ||||
|    * @param {string} author  | ||||
|    * @param {string} providerSlug  | ||||
|    * @returns {Promise<Object[]>} | ||||
|    */ | ||||
|   async getCustomProviderResults(title, author, providerSlug) { | ||||
|     const books = await this.customProviderAdapter.search(title, author, providerSlug) | ||||
|     if (this.verbose) Logger.debug(`Custom provider '${providerSlug}' Search Results: ${books.length || 0}`) | ||||
| 
 | ||||
|     return books | ||||
|   } | ||||
| 
 | ||||
|   static TitleCandidates = class { | ||||
| 
 | ||||
|     constructor(cleanAuthor) { | ||||
| @ -315,6 +331,11 @@ class BookFinder { | ||||
|     const maxFuzzySearches = !isNaN(options.maxFuzzySearches) ? Number(options.maxFuzzySearches) : 5 | ||||
|     let numFuzzySearches = 0 | ||||
| 
 | ||||
|     // Custom providers are assumed to be correct
 | ||||
|     if (provider.startsWith('custom-')) { | ||||
|       return this.getCustomProviderResults(title, author, provider) | ||||
|     } | ||||
| 
 | ||||
|     if (!title) | ||||
|       return books | ||||
| 
 | ||||
| @ -397,8 +418,7 @@ class BookFinder { | ||||
|       books = await this.getFantLabResults(title, author) | ||||
|     } else if (provider === 'audiobookcovers') { | ||||
|       books = await this.getAudiobookCoversResults(title) | ||||
|     } | ||||
|     else { | ||||
|     } else { | ||||
|       books = await this.getGoogleBooksResults(title, author) | ||||
|     } | ||||
|     return books | ||||
|  | ||||
							
								
								
									
										103
									
								
								server/models/CustomMetadataProvider.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										103
									
								
								server/models/CustomMetadataProvider.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,103 @@ | ||||
| const { DataTypes, Model } = require('sequelize') | ||||
| 
 | ||||
| /** | ||||
|  * @typedef ClientCustomMetadataProvider | ||||
|  * @property {UUIDV4} id | ||||
|  * @property {string} name | ||||
|  * @property {string} url | ||||
|  * @property {string} slug | ||||
|  */ | ||||
| 
 | ||||
| class CustomMetadataProvider extends Model { | ||||
|   constructor(values, options) { | ||||
|     super(values, options) | ||||
| 
 | ||||
|     /** @type {UUIDV4} */ | ||||
|     this.id | ||||
|     /** @type {string} */ | ||||
|     this.mediaType | ||||
|     /** @type {string} */ | ||||
|     this.name | ||||
|     /** @type {string} */ | ||||
|     this.url | ||||
|     /** @type {string} */ | ||||
|     this.authHeaderValue | ||||
|     /** @type {Object} */ | ||||
|     this.extraData | ||||
|     /** @type {Date} */ | ||||
|     this.createdAt | ||||
|     /** @type {Date} */ | ||||
|     this.updatedAt | ||||
|   } | ||||
| 
 | ||||
|   getSlug() { | ||||
|     return `custom-${this.id}` | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Safe for clients | ||||
|    * @returns {ClientCustomMetadataProvider} | ||||
|    */ | ||||
|   toClientJson() { | ||||
|     return { | ||||
|       id: this.id, | ||||
|       name: this.name, | ||||
|       mediaType: this.mediaType, | ||||
|       slug: this.getSlug() | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Get providers for client by media type | ||||
|    * Currently only available for "book" media type | ||||
|    *  | ||||
|    * @param {string} mediaType  | ||||
|    * @returns {Promise<ClientCustomMetadataProvider[]>} | ||||
|    */ | ||||
|   static async getForClientByMediaType(mediaType) { | ||||
|     if (mediaType !== 'book') return [] | ||||
|     const customMetadataProviders = await this.findAll({ | ||||
|       where: { | ||||
|         mediaType | ||||
|       } | ||||
|     }) | ||||
|     return customMetadataProviders.map(cmp => cmp.toClientJson()) | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Check if provider exists by slug | ||||
|    *  | ||||
|    * @param {string} providerSlug  | ||||
|    * @returns {Promise<boolean>} | ||||
|    */ | ||||
|   static async checkExistsBySlug(providerSlug) { | ||||
|     const providerId = providerSlug?.split?.('custom-')[1] | ||||
|     if (!providerId) return false | ||||
| 
 | ||||
|     return (await this.count({ where: { id: providerId } })) > 0 | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Initialize model | ||||
|    * @param {import('../Database').sequelize} sequelize  | ||||
|    */ | ||||
|   static init(sequelize) { | ||||
|     super.init({ | ||||
|       id: { | ||||
|         type: DataTypes.UUID, | ||||
|         defaultValue: DataTypes.UUIDV4, | ||||
|         primaryKey: true | ||||
|       }, | ||||
|       name: DataTypes.STRING, | ||||
|       mediaType: DataTypes.STRING, | ||||
|       url: DataTypes.STRING, | ||||
|       authHeaderValue: DataTypes.STRING, | ||||
|       extraData: DataTypes.JSON | ||||
|     }, { | ||||
|       sequelize, | ||||
|       modelName: 'customMetadataProvider' | ||||
|     }) | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| module.exports = CustomMetadataProvider | ||||
							
								
								
									
										80
									
								
								server/providers/CustomProviderAdapter.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										80
									
								
								server/providers/CustomProviderAdapter.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,80 @@ | ||||
| const Database = require('../Database') | ||||
| const axios = require('axios') | ||||
| const Logger = require('../Logger') | ||||
| 
 | ||||
| class CustomProviderAdapter { | ||||
|     constructor() { } | ||||
| 
 | ||||
|     /** | ||||
|      *  | ||||
|      * @param {string} title  | ||||
|      * @param {string} author  | ||||
|      * @param {string} providerSlug  | ||||
|      * @returns {Promise<Object[]>} | ||||
|      */ | ||||
|     async search(title, author, providerSlug) { | ||||
|         const providerId = providerSlug.split('custom-')[1] | ||||
|         const provider = await Database.customMetadataProviderModel.findByPk(providerId) | ||||
| 
 | ||||
|         if (!provider) { | ||||
|             throw new Error("Custom provider not found for the given id") | ||||
|         } | ||||
| 
 | ||||
|         const axiosOptions = {} | ||||
|         if (provider.authHeaderValue) { | ||||
|             axiosOptions.headers = { | ||||
|                 'Authorization': provider.authHeaderValue | ||||
|             } | ||||
|         } | ||||
|         const matches = await axios.get(`${provider.url}/search?query=${encodeURIComponent(title)}${!!author ? `&author=${encodeURIComponent(author)}` : ""}`, axiosOptions).then((res) => { | ||||
|             if (!res?.data || !Array.isArray(res.data.matches)) return null | ||||
|             return res.data.matches | ||||
|         }).catch(error => { | ||||
|             Logger.error('[CustomMetadataProvider] Search error', error) | ||||
|             return [] | ||||
|         }) | ||||
| 
 | ||||
|         if (!matches) { | ||||
|             throw new Error("Custom provider returned malformed response") | ||||
|         } | ||||
| 
 | ||||
|         // re-map keys to throw out
 | ||||
|         return matches.map(({ | ||||
|             title, | ||||
|             subtitle, | ||||
|             author, | ||||
|             narrator, | ||||
|             publisher, | ||||
|             publishedYear, | ||||
|             description, | ||||
|             cover, | ||||
|             isbn, | ||||
|             asin, | ||||
|             genres, | ||||
|             tags, | ||||
|             series, | ||||
|             language, | ||||
|             duration | ||||
|         }) => { | ||||
|             return { | ||||
|                 title, | ||||
|                 subtitle, | ||||
|                 author, | ||||
|                 narrator, | ||||
|                 publisher, | ||||
|                 publishedYear, | ||||
|                 description, | ||||
|                 cover, | ||||
|                 isbn, | ||||
|                 asin, | ||||
|                 genres, | ||||
|                 tags: tags?.join(',') || null, | ||||
|                 series: series?.length ? series : null, | ||||
|                 language, | ||||
|                 duration | ||||
|             } | ||||
|         }) | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| module.exports = CustomProviderAdapter | ||||
| @ -28,6 +28,7 @@ const SearchController = require('../controllers/SearchController') | ||||
| const CacheController = require('../controllers/CacheController') | ||||
| const ToolsController = require('../controllers/ToolsController') | ||||
| const RSSFeedController = require('../controllers/RSSFeedController') | ||||
| const CustomMetadataProviderController = require('../controllers/CustomMetadataProviderController') | ||||
| const MiscController = require('../controllers/MiscController') | ||||
| 
 | ||||
| const Author = require('../objects/entities/Author') | ||||
| @ -299,6 +300,14 @@ class ApiRouter { | ||||
|     this.router.post('/feeds/series/:seriesId/open', RSSFeedController.middleware.bind(this), RSSFeedController.openRSSFeedForSeries.bind(this)) | ||||
|     this.router.post('/feeds/:id/close', RSSFeedController.middleware.bind(this), RSSFeedController.closeRSSFeed.bind(this)) | ||||
| 
 | ||||
|     //
 | ||||
|     // Custom Metadata Provider routes
 | ||||
|     //
 | ||||
|     this.router.get('/custom-metadata-providers', CustomMetadataProviderController.middleware.bind(this), CustomMetadataProviderController.getAll.bind(this)) | ||||
|     this.router.post('/custom-metadata-providers', CustomMetadataProviderController.middleware.bind(this), CustomMetadataProviderController.create.bind(this)) | ||||
|     this.router.delete('/custom-metadata-providers/:id', CustomMetadataProviderController.middleware.bind(this), CustomMetadataProviderController.delete.bind(this)) | ||||
| 
 | ||||
| 
 | ||||
|     //
 | ||||
|     // Misc Routes
 | ||||
|     //
 | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user