mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-02-19 11:40:54 +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>
|
<template>
|
||||||
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8">
|
<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">
|
<div class="flex items-center mb-2">
|
||||||
|
<slot name="header-prefix"></slot>
|
||||||
<h1 class="text-xl">{{ headerText }}</h1>
|
<h1 class="text-xl">{{ headerText }}</h1>
|
||||||
|
|
||||||
<slot name="header-items"></slot>
|
<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)
|
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() {
|
getSearchQuery() {
|
||||||
if (this.isPodcast) return `term=${encodeURIComponent(this.searchTitle)}`
|
if (this.isPodcast) return `term=${encodeURIComponent(this.searchTitle)}`
|
||||||
var searchQuery = `provider=${this.provider}&fallbackTitleOnly=1&title=${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.searchTitle = this.libraryItem.media.metadata.title
|
||||||
this.searchAuthor = this.libraryItem.media.metadata.authorName || ''
|
this.searchAuthor = this.libraryItem.media.metadata.authorName || ''
|
||||||
if (this.isPodcast) this.provider = 'itunes'
|
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
|
// Prefer using ASIN if set and using audible provider
|
||||||
if (this.provider.startsWith('audible') && this.libraryItem.media.metadata.asin) {
|
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)
|
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() {
|
initializeSocket() {
|
||||||
this.socket = this.$nuxtSocket({
|
this.socket = this.$nuxtSocket({
|
||||||
name: process.env.NODE_ENV === 'development' ? 'dev' : 'prod',
|
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('batch_quickmatch_complete', this.batchQuickMatchComplete)
|
||||||
|
|
||||||
this.socket.on('admin_message', this.adminMessageEvt)
|
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) {
|
showUpdateToast(versionData) {
|
||||||
var ignoreVersion = localStorage.getItem('ignoreVersion')
|
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>
|
<span class="material-icons">arrow_forward</span>
|
||||||
</div>
|
</div>
|
||||||
</nuxt-link>
|
</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>
|
</app-settings-content>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
@ -113,6 +113,7 @@ export const actions = {
|
|||||||
const library = data.library
|
const library = data.library
|
||||||
const filterData = data.filterdata
|
const filterData = data.filterdata
|
||||||
const issues = data.issues || 0
|
const issues = data.issues || 0
|
||||||
|
const customMetadataProviders = data.customMetadataProviders || []
|
||||||
const numUserPlaylists = data.numUserPlaylists
|
const numUserPlaylists = data.numUserPlaylists
|
||||||
|
|
||||||
dispatch('user/checkUpdateLibrarySortFilter', library.mediaType, { root: true })
|
dispatch('user/checkUpdateLibrarySortFilter', library.mediaType, { root: true })
|
||||||
@ -126,6 +127,8 @@ export const actions = {
|
|||||||
commit('setLibraryIssues', issues)
|
commit('setLibraryIssues', issues)
|
||||||
commit('setLibraryFilterData', filterData)
|
commit('setLibraryFilterData', filterData)
|
||||||
commit('setNumUserPlaylists', numUserPlaylists)
|
commit('setNumUserPlaylists', numUserPlaylists)
|
||||||
|
commit('scanners/setCustomMetadataProviders', customMetadataProviders, { root: true })
|
||||||
|
|
||||||
commit('setCurrentLibrary', libraryId)
|
commit('setCurrentLibrary', libraryId)
|
||||||
return data
|
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 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",
|
"HeaderCollectionItems": "Collection Items",
|
||||||
"HeaderCover": "Cover",
|
"HeaderCover": "Cover",
|
||||||
"HeaderCurrentDownloads": "Current Downloads",
|
"HeaderCurrentDownloads": "Current Downloads",
|
||||||
|
"HeaderCustomMetadataProviders": "Custom Metadata Providers",
|
||||||
"HeaderDetails": "Details",
|
"HeaderDetails": "Details",
|
||||||
"HeaderDownloadQueue": "Download Queue",
|
"HeaderDownloadQueue": "Download Queue",
|
||||||
"HeaderEbookFiles": "Ebook Files",
|
"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
|
return this.models.playbackSession
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @type {typeof import('./models/CustomMetadataProvider')} */
|
||||||
|
get customMetadataProviderModel() {
|
||||||
|
return this.models.customMetadataProvider
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if db file exists
|
* Check if db file exists
|
||||||
* @returns {boolean}
|
* @returns {boolean}
|
||||||
@ -245,6 +250,7 @@ class Database {
|
|||||||
require('./models/Feed').init(this.sequelize)
|
require('./models/Feed').init(this.sequelize)
|
||||||
require('./models/FeedEpisode').init(this.sequelize)
|
require('./models/FeedEpisode').init(this.sequelize)
|
||||||
require('./models/Setting').init(this.sequelize)
|
require('./models/Setting').init(this.sequelize)
|
||||||
|
require('./models/CustomMetadataProvider').init(this.sequelize)
|
||||||
|
|
||||||
return this.sequelize.sync({ force, alter: false })
|
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')
|
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
|
// Validate folder paths exist or can be created & resolve rel paths
|
||||||
// returns 400 if a folder fails to access
|
// returns 400 if a folder fails to access
|
||||||
newLibraryPayload.folders = newLibraryPayload.folders.map(f => {
|
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) {
|
async findOne(req, res) {
|
||||||
const includeArray = (req.query.include || '').split(',')
|
const includeArray = (req.query.include || '').split(',')
|
||||||
if (includeArray.includes('filterdata')) {
|
if (includeArray.includes('filterdata')) {
|
||||||
const filterdata = await libraryFilters.getFilterData(req.library.mediaType, req.library.id)
|
const filterdata = await libraryFilters.getFilterData(req.library.mediaType, req.library.id)
|
||||||
|
const customMetadataProviders = await Database.customMetadataProviderModel.getForClientByMediaType(req.library.mediaType)
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
filterdata,
|
filterdata,
|
||||||
issues: filterdata.numIssues,
|
issues: filterdata.numIssues,
|
||||||
numUserPlaylists: await Database.playlistModel.getNumPlaylistsForUserAndLibrary(req.user.id, req.library.id),
|
numUserPlaylists: await Database.playlistModel.getNumPlaylistsForUserAndLibrary(req.user.id, req.library.id),
|
||||||
|
customMetadataProviders,
|
||||||
library: req.library
|
library: req.library
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return res.json(req.library)
|
res.json(req.library)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -115,6 +131,14 @@ class LibraryController {
|
|||||||
async update(req, res) {
|
async update(req, res) {
|
||||||
const library = req.library
|
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
|
// Validate new folder paths exist or can be created & resolve rel paths
|
||||||
// returns 400 if a new folder fails to access
|
// returns 400 if a new folder fails to access
|
||||||
if (req.body.folders) {
|
if (req.body.folders) {
|
||||||
|
@ -161,7 +161,7 @@ class SessionController {
|
|||||||
* @typedef batchDeleteReqBody
|
* @typedef batchDeleteReqBody
|
||||||
* @property {string[]} sessions
|
* @property {string[]} sessions
|
||||||
*
|
*
|
||||||
* @param {import('express').Request<{}, {}, batchDeleteReqBody, {}} req
|
* @param {import('express').Request<{}, {}, batchDeleteReqBody, {}>} req
|
||||||
* @param {import('express').Response} res
|
* @param {import('express').Response} res
|
||||||
*/
|
*/
|
||||||
async batchDelete(req, res) {
|
async batchDelete(req, res) {
|
||||||
|
@ -5,6 +5,7 @@ const iTunes = require('../providers/iTunes')
|
|||||||
const Audnexus = require('../providers/Audnexus')
|
const Audnexus = require('../providers/Audnexus')
|
||||||
const FantLab = require('../providers/FantLab')
|
const FantLab = require('../providers/FantLab')
|
||||||
const AudiobookCovers = require('../providers/AudiobookCovers')
|
const AudiobookCovers = require('../providers/AudiobookCovers')
|
||||||
|
const CustomProviderAdapter = require('../providers/CustomProviderAdapter')
|
||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
const { levenshteinDistance, escapeRegExp } = require('../utils/index')
|
const { levenshteinDistance, escapeRegExp } = require('../utils/index')
|
||||||
|
|
||||||
@ -17,6 +18,7 @@ class BookFinder {
|
|||||||
this.audnexus = new Audnexus()
|
this.audnexus = new Audnexus()
|
||||||
this.fantLab = new FantLab()
|
this.fantLab = new FantLab()
|
||||||
this.audiobookCovers = new AudiobookCovers()
|
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']
|
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
|
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 {
|
static TitleCandidates = class {
|
||||||
|
|
||||||
constructor(cleanAuthor) {
|
constructor(cleanAuthor) {
|
||||||
@ -315,6 +331,11 @@ class BookFinder {
|
|||||||
const maxFuzzySearches = !isNaN(options.maxFuzzySearches) ? Number(options.maxFuzzySearches) : 5
|
const maxFuzzySearches = !isNaN(options.maxFuzzySearches) ? Number(options.maxFuzzySearches) : 5
|
||||||
let numFuzzySearches = 0
|
let numFuzzySearches = 0
|
||||||
|
|
||||||
|
// Custom providers are assumed to be correct
|
||||||
|
if (provider.startsWith('custom-')) {
|
||||||
|
return this.getCustomProviderResults(title, author, provider)
|
||||||
|
}
|
||||||
|
|
||||||
if (!title)
|
if (!title)
|
||||||
return books
|
return books
|
||||||
|
|
||||||
@ -397,8 +418,7 @@ class BookFinder {
|
|||||||
books = await this.getFantLabResults(title, author)
|
books = await this.getFantLabResults(title, author)
|
||||||
} else if (provider === 'audiobookcovers') {
|
} else if (provider === 'audiobookcovers') {
|
||||||
books = await this.getAudiobookCoversResults(title)
|
books = await this.getAudiobookCoversResults(title)
|
||||||
}
|
} else {
|
||||||
else {
|
|
||||||
books = await this.getGoogleBooksResults(title, author)
|
books = await this.getGoogleBooksResults(title, author)
|
||||||
}
|
}
|
||||||
return books
|
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 CacheController = require('../controllers/CacheController')
|
||||||
const ToolsController = require('../controllers/ToolsController')
|
const ToolsController = require('../controllers/ToolsController')
|
||||||
const RSSFeedController = require('../controllers/RSSFeedController')
|
const RSSFeedController = require('../controllers/RSSFeedController')
|
||||||
|
const CustomMetadataProviderController = require('../controllers/CustomMetadataProviderController')
|
||||||
const MiscController = require('../controllers/MiscController')
|
const MiscController = require('../controllers/MiscController')
|
||||||
|
|
||||||
const Author = require('../objects/entities/Author')
|
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/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))
|
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
|
// Misc Routes
|
||||||
//
|
//
|
||||||
|
Loading…
Reference in New Issue
Block a user