mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-02-16 02:09:33 +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