From 8027c4a06f01f3a13d43cf041af012a4f64d62f5 Mon Sep 17 00:00:00 2001 From: Barnabas Ratki Date: Wed, 3 Jan 2024 01:36:56 +0100 Subject: [PATCH 1/6] Added support for custom metadata providers WiP but already open to feedback --- client/components/app/ConfigSideNav.vue | 5 + .../modals/AddCustomMetadataProviderModal.vue | 104 ++++++++++++ .../tables/CustomMetadataProviderTable.vue | 150 ++++++++++++++++++ client/layouts/default.vue | 8 + .../config/custom-metadata-providers.vue | 45 ++++++ client/store/scanners.js | 31 +++- client/strings/en-us.json | 3 + server/Database.js | 45 ++++++ server/controllers/LibraryController.js | 10 ++ server/controllers/MiscController.js | 90 +++++++++++ server/finders/BookFinder.js | 17 +- server/models/CustomMetadataProvider.js | 58 +++++++ server/providers/CustomProviderAdapter.js | 76 +++++++++ server/routers/ApiRouter.js | 4 + 14 files changed, 642 insertions(+), 4 deletions(-) create mode 100644 client/components/modals/AddCustomMetadataProviderModal.vue create mode 100644 client/components/tables/CustomMetadataProviderTable.vue create mode 100644 client/pages/config/custom-metadata-providers.vue create mode 100644 server/models/CustomMetadataProvider.js create mode 100644 server/providers/CustomProviderAdapter.js diff --git a/client/components/app/ConfigSideNav.vue b/client/components/app/ConfigSideNav.vue index c2db0725..e253d1ae 100644 --- a/client/components/app/ConfigSideNav.vue +++ b/client/components/app/ConfigSideNav.vue @@ -109,6 +109,11 @@ export default { id: 'config-authentication', title: this.$strings.HeaderAuthentication, path: '/config/authentication' + }, + { + id: 'config-custom-metadata-providers', + title: this.$strings.HeaderCustomMetadataProviders, + path: '/config/custom-metadata-providers' } ] diff --git a/client/components/modals/AddCustomMetadataProviderModal.vue b/client/components/modals/AddCustomMetadataProviderModal.vue new file mode 100644 index 00000000..1b9f930c --- /dev/null +++ b/client/components/modals/AddCustomMetadataProviderModal.vue @@ -0,0 +1,104 @@ + + + diff --git a/client/components/tables/CustomMetadataProviderTable.vue b/client/components/tables/CustomMetadataProviderTable.vue new file mode 100644 index 00000000..8104cede --- /dev/null +++ b/client/components/tables/CustomMetadataProviderTable.vue @@ -0,0 +1,150 @@ + + + + + diff --git a/client/layouts/default.vue b/client/layouts/default.vue index c3cc3484..1d33c44c 100644 --- a/client/layouts/default.vue +++ b/client/layouts/default.vue @@ -328,6 +328,9 @@ export default { this.$store.commit('libraries/setEReaderDevices', data.ereaderDevices) }, + customMetadataProvidersChanged() { + this.$store.dispatch('scanners/reFetchCustom') + }, initializeSocket() { this.socket = this.$nuxtSocket({ name: process.env.NODE_ENV === 'development' ? 'dev' : 'prod', @@ -406,6 +409,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.customMetadataProvidersChanged) + this.socket.on('custom_metadata_provider_removed', this.customMetadataProvidersChanged) }, showUpdateToast(versionData) { var ignoreVersion = localStorage.getItem('ignoreVersion') @@ -541,6 +548,7 @@ export default { window.addEventListener('keydown', this.keyDown) this.$store.dispatch('libraries/load') + this.$store.dispatch('scanners/reFetchCustom') this.initLocalStorage() diff --git a/client/pages/config/custom-metadata-providers.vue b/client/pages/config/custom-metadata-providers.vue new file mode 100644 index 00000000..10fdb21b --- /dev/null +++ b/client/pages/config/custom-metadata-providers.vue @@ -0,0 +1,45 @@ + + + + + diff --git a/client/store/scanners.js b/client/store/scanners.js index ccdc1791..32878a6a 100644 --- a/client/store/scanners.js +++ b/client/store/scanners.js @@ -73,6 +73,33 @@ export const state = () => ({ export const getters = {} -export const actions = {} +export const actions = { + reFetchCustom({ dispatch, commit }) { + return this.$axios + .$get(`/api/custom-metadata-providers`) + .then((data) => { + const providers = data.providers -export const mutations = {} \ No newline at end of file + commit('setCustomProviders', providers) + return data + }) + .catch((error) => { + console.error('Failed', error) + return false + }) + }, +} + +export const mutations = { + setCustomProviders(state, providers) { + // 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) => {return { + text: p.name, + value: p.slug, + }}) + ] + }, +} \ No newline at end of file diff --git a/client/strings/en-us.json b/client/strings/en-us.json index f69175fd..9dfde095 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -96,6 +96,7 @@ "HeaderAudiobookTools": "Audiobook File Management Tools", "HeaderAudioTracks": "Audio Tracks", "HeaderAuthentication": "Authentication", + "HeaderCustomMetadataProviders": "Custom metadata providers", "HeaderBackups": "Backups", "HeaderChangePassword": "Change Password", "HeaderChapters": "Chapters", @@ -540,6 +541,8 @@ "LabelYourBookmarks": "Your Bookmarks", "LabelYourPlaylists": "Your Playlists", "LabelYourProgress": "Your Progress", + "LabelUrl": "URL", + "LabelApiKey": "API Key", "MessageAddToPlayerQueue": "Add to player queue", "MessageAppriseDescription": "To use this feature you will need to have an instance of Apprise API running or an api that will handle those same requests.
The Apprise API Url should be the full URL path to send the notification, e.g., if your API instance is served at http://192.168.1.1:8337 then you would put http://192.168.1.1:8337/notify.", "MessageBackupsDescription": "Backups include users, user progress, library item details, server settings, and images stored in /metadata/items & /metadata/authors. Backups do not include any files stored in your library folders.", diff --git a/server/Database.js b/server/Database.js index fd606bac..bbea7352 100644 --- a/server/Database.js +++ b/server/Database.js @@ -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 }) } @@ -694,6 +700,45 @@ class Database { }) } + /** + * Returns true if a custom provider with the given slug exists + * @param {string} providerSlug + * @return {boolean} + */ + async doesCustomProviderExistBySlug(providerSlug) { + const id = providerSlug.split("custom-")[1] + + if (!id) { + return false + } + + return !!await this.customMetadataProviderModel.findByPk(id) + } + + /** + * Removes a custom metadata provider + * @param {string} id + */ + async removeCustomMetadataProviderById(id) { + // destroy metadta provider + await this.customMetadataProviderModel.destroy({ + where: { + id, + } + }) + + const slug = `custom-${id}`; + + // fallback libraries using it to google + await this.libraryModel.update({ + provider: "google", + }, { + where: { + provider: slug, + } + }); + } + /** * Clean invalid records in database * Series should have atleast one Book diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index 70baff85..304ca4f0 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -51,6 +51,11 @@ class LibraryController { } } + // Validate that the custom provider exists if given any + if (newLibraryPayload.provider && newLibraryPayload.provider.startsWith("custom-")) { + await Database.doesCustomProviderExistBySlug(newLibraryPayload.provider) + } + const library = new Library() let currentLargestDisplayOrder = await Database.libraryModel.getMaxDisplayOrder() @@ -175,6 +180,11 @@ class LibraryController { } } + // Validate that the custom provider exists if given any + if (req.body.provider && req.body.provider.startsWith("custom-")) { + await Database.doesCustomProviderExistBySlug(req.body.provider) + } + const hasUpdates = library.update(req.body) // TODO: Should check if this is an update to folder paths or name only if (hasUpdates) { diff --git a/server/controllers/MiscController.js b/server/controllers/MiscController.js index c2272ee6..31f4587b 100644 --- a/server/controllers/MiscController.js +++ b/server/controllers/MiscController.js @@ -717,5 +717,95 @@ class MiscController { const stats = await adminStats.getStatsForYear(year) res.json(stats) } + + /** + * GET: /api/custom-metadata-providers + * + * @param {import('express').Request} req + * @param {import('express').Response} res + */ + async getCustomMetadataProviders(req, res) { + const providers = await Database.customMetadataProviderModel.findAll() + + res.json({ + providers: providers.map((p) => p.toUserJson()), + }) + } + + /** + * GET: /api/custom-metadata-providers/admin + * + * @param {import('express').Request} req + * @param {import('express').Response} res + */ + async getAdminCustomMetadataProviders(req, res) { + if (!req.user.isAdminOrUp) { + Logger.error(`[MiscController] Non-admin user "${req.user.username}" attempted to get admin custom metadata providers`) + return res.sendStatus(403) + } + + const providers = await Database.customMetadataProviderModel.findAll() + + res.json({ + providers, + }) + } + + /** + * PATCH: /api/custom-metadata-providers/admin + * + * @param {import('express').Request} req + * @param {import('express').Response} res + */ + async addCustomMetadataProviders(req, res) { + if (!req.user.isAdminOrUp) { + Logger.error(`[MiscController] Non-admin user "${req.user.username}" attempted to get admin custom metadata providers`) + return res.sendStatus(403) + } + + const { name, url, apiKey } = req.body; + + if (!name || !url || !apiKey) { + return res.status(500).send(`Invalid patch data`) + } + + const provider = await Database.customMetadataProviderModel.create({ + name, + url, + apiKey, + }) + + SocketAuthority.adminEmitter('custom_metadata_provider_added', provider) + + res.json({ + provider, + }) + } + + /** + * DELETE: /api/custom-metadata-providers/admin/:id + * + * @param {import('express').Request} req + * @param {import('express').Response} res + */ + async deleteCustomMetadataProviders(req, res) { + if (!req.user.isAdminOrUp) { + Logger.error(`[MiscController] Non-admin user "${req.user.username}" attempted to get admin custom metadata providers`) + return res.sendStatus(403) + } + + const { id } = req.params; + + if (!id) { + return res.status(500).send(`Invalid delete data`) + } + + const provider = await Database.customMetadataProviderModel.findByPk(id); + await Database.removeCustomMetadataProviderById(id); + + SocketAuthority.adminEmitter('custom_metadata_provider_removed', provider) + + res.json({}) + } } module.exports = new MiscController() diff --git a/server/finders/BookFinder.js b/server/finders/BookFinder.js index 466c8701..6c35a5fb 100644 --- a/server/finders/BookFinder.js +++ b/server/finders/BookFinder.js @@ -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,13 @@ class BookFinder { return books } + 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 +324,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 await this.getCustomProviderResults(title, author, provider) + } + if (!title) return books @@ -397,8 +411,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 diff --git a/server/models/CustomMetadataProvider.js b/server/models/CustomMetadataProvider.js new file mode 100644 index 00000000..4f2488d2 --- /dev/null +++ b/server/models/CustomMetadataProvider.js @@ -0,0 +1,58 @@ +const { DataTypes, Model, Sequelize } = require('sequelize') + +class CustomMetadataProvider extends Model { + constructor(values, options) { + super(values, options) + + /** @type {UUIDV4} */ + this.id + /** @type {string} */ + this.name + /** @type {string} */ + this.url + /** @type {string} */ + this.apiKey + } + + getSlug() { + return `custom-${this.id}` + } + + toUserJson() { + return { + name: this.name, + id: this.id, + slug: this.getSlug() + } + } + + static findByPk(id) { + this.findOne({ + where: { + id, + } + }) + } + + /** + * Initialize model + * @param {import('../Database').sequelize} sequelize + */ + static init(sequelize) { + super.init({ + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + name: DataTypes.STRING, + url: DataTypes.STRING, + apiKey: DataTypes.STRING + }, { + sequelize, + modelName: 'customMetadataProvider' + }) + } +} + +module.exports = CustomMetadataProvider \ No newline at end of file diff --git a/server/providers/CustomProviderAdapter.js b/server/providers/CustomProviderAdapter.js new file mode 100644 index 00000000..1bf5a5ee --- /dev/null +++ b/server/providers/CustomProviderAdapter.js @@ -0,0 +1,76 @@ +const Database = require('../Database') +const axios = require("axios"); +const Logger = require("../Logger"); + +class CustomProviderAdapter { + constructor() { + } + + async search(title, author, providerSlug) { + const providerId = providerSlug.split("custom-")[1] + + console.log(providerId) + const provider = await Database.customMetadataProviderModel.findOne({ + where: { + id: providerId, + } + }); + + if (!provider) { + throw new Error("Custom provider not found for the given id"); + } + + const matches = await axios.get(`${provider.url}/search?query=${encodeURIComponent(title)}${!!author ? `&author=${encodeURIComponent(author)}` : ""}`, { + headers: { + "Authorization": provider.apiKey, + }, + }).then((res) => { + if (!res || !res.data || !Array.isArray(res.data.matches)) return null + return res.data.matches + }).catch(error => { + Logger.error('[CustomMetadataProvider] Search error', error) + return [] + }) + + if (matches === null) { + throw new Error("Custom provider returned malformed response"); + } + + // re-map keys to throw out + return matches.map(({ + title, + subtitle, + author, + narrator, + publisher, + published_year, + description, + cover, + isbn, + asin, + genres, + tags, + language, + duration, + }) => { + return { + title, + subtitle, + author, + narrator, + publisher, + publishedYear: published_year, + description, + cover, + isbn, + asin, + genres, + tags: tags.join(","), + language, + duration, + } + }) + } +} + +module.exports = CustomProviderAdapter \ No newline at end of file diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 3edce256..f78d4539 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -318,6 +318,10 @@ class ApiRouter { this.router.patch('/auth-settings', MiscController.updateAuthSettings.bind(this)) this.router.post('/watcher/update', MiscController.updateWatchedPath.bind(this)) this.router.get('/stats/year/:year', MiscController.getAdminStatsForYear.bind(this)) + this.router.get('/custom-metadata-providers', MiscController.getCustomMetadataProviders.bind(this)) + this.router.get('/custom-metadata-providers/admin', MiscController.getAdminCustomMetadataProviders.bind(this)) + this.router.patch('/custom-metadata-providers/admin', MiscController.addCustomMetadataProviders.bind(this)) + this.router.delete('/custom-metadata-providers/admin/:id', MiscController.deleteCustomMetadataProviders.bind(this)) } async getDirectories(dir, relpath, excludedDirs, level = 0) { From 08a41e37b4d412ef04cb45e839acda07b1e77cd9 Mon Sep 17 00:00:00 2001 From: Barnabas Ratki Date: Wed, 3 Jan 2024 20:27:42 +0100 Subject: [PATCH 2/6] Add specification --- custom-metadata-provider-specification.yaml | 124 ++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 custom-metadata-provider-specification.yaml diff --git a/custom-metadata-provider-specification.yaml b/custom-metadata-provider-specification.yaml new file mode 100644 index 00000000..3201fbb8 --- /dev/null +++ b/custom-metadata-provider-specification.yaml @@ -0,0 +1,124 @@ +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 + published_year: + 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 + language: + type: string + duration: + type: number + format: int64 + description: Duration in seconds + required: + - title + securitySchemes: + api_key: + type: apiKey + name: AUTHORIZATION + in: header + + From 5ea423072be02beb9489e62c51d8aeb023acbeb1 Mon Sep 17 00:00:00 2001 From: Barnabas Ratki Date: Wed, 3 Jan 2024 20:40:36 +0100 Subject: [PATCH 3/6] Small fixes --- server/Database.js | 2 +- server/controllers/LibraryController.js | 4 ++-- server/models/CustomMetadataProvider.js | 2 +- server/providers/CustomProviderAdapter.js | 8 +------- 4 files changed, 5 insertions(+), 11 deletions(-) diff --git a/server/Database.js b/server/Database.js index bbea7352..302170ac 100644 --- a/server/Database.js +++ b/server/Database.js @@ -705,7 +705,7 @@ class Database { * @param {string} providerSlug * @return {boolean} */ - async doesCustomProviderExistBySlug(providerSlug) { + async doesCustomProviderExistWithSlug(providerSlug) { const id = providerSlug.split("custom-")[1] if (!id) { diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index 304ca4f0..b1ab572f 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -53,7 +53,7 @@ class LibraryController { // Validate that the custom provider exists if given any if (newLibraryPayload.provider && newLibraryPayload.provider.startsWith("custom-")) { - await Database.doesCustomProviderExistBySlug(newLibraryPayload.provider) + await Database.doesCustomProviderExistWithSlug(newLibraryPayload.provider) } const library = new Library() @@ -182,7 +182,7 @@ class LibraryController { // Validate that the custom provider exists if given any if (req.body.provider && req.body.provider.startsWith("custom-")) { - await Database.doesCustomProviderExistBySlug(req.body.provider) + await Database.doesCustomProviderExistWithSlug(req.body.provider) } const hasUpdates = library.update(req.body) diff --git a/server/models/CustomMetadataProvider.js b/server/models/CustomMetadataProvider.js index 4f2488d2..9bc175c4 100644 --- a/server/models/CustomMetadataProvider.js +++ b/server/models/CustomMetadataProvider.js @@ -27,7 +27,7 @@ class CustomMetadataProvider extends Model { } static findByPk(id) { - this.findOne({ + return this.findOne({ where: { id, } diff --git a/server/providers/CustomProviderAdapter.js b/server/providers/CustomProviderAdapter.js index 1bf5a5ee..d5f64291 100644 --- a/server/providers/CustomProviderAdapter.js +++ b/server/providers/CustomProviderAdapter.js @@ -8,13 +8,7 @@ class CustomProviderAdapter { async search(title, author, providerSlug) { const providerId = providerSlug.split("custom-")[1] - - console.log(providerId) - const provider = await Database.customMetadataProviderModel.findOne({ - where: { - id: providerId, - } - }); + const provider = await Database.customMetadataProviderModel.findByPk(providerId); if (!provider) { throw new Error("Custom provider not found for the given id"); From 12c6a1baa02b2514b48565a4e030281856b2906d Mon Sep 17 00:00:00 2001 From: Barnabas Ratki Date: Wed, 3 Jan 2024 20:42:35 +0100 Subject: [PATCH 4/6] Fix log messages --- server/controllers/MiscController.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/controllers/MiscController.js b/server/controllers/MiscController.js index 31f4587b..76140dcc 100644 --- a/server/controllers/MiscController.js +++ b/server/controllers/MiscController.js @@ -759,7 +759,7 @@ class MiscController { */ async addCustomMetadataProviders(req, res) { if (!req.user.isAdminOrUp) { - Logger.error(`[MiscController] Non-admin user "${req.user.username}" attempted to get admin custom metadata providers`) + Logger.error(`[MiscController] Non-admin user "${req.user.username}" attempted to add admin custom metadata providers`) return res.sendStatus(403) } @@ -790,7 +790,7 @@ class MiscController { */ async deleteCustomMetadataProviders(req, res) { if (!req.user.isAdminOrUp) { - Logger.error(`[MiscController] Non-admin user "${req.user.username}" attempted to get admin custom metadata providers`) + Logger.error(`[MiscController] Non-admin user "${req.user.username}" attempted to delete admin custom metadata providers`) return res.sendStatus(403) } From 3b531144cfdbce2379b90bb4f2ee36ca949db79c Mon Sep 17 00:00:00 2001 From: FlyinPancake Date: Fri, 12 Jan 2024 21:45:03 +0100 Subject: [PATCH 5/6] implemented suggestions, extended CMPs with series --- .../pages/config/custom-metadata-providers.vue | 6 ++---- client/strings/en-us.json | 6 +++--- custom-metadata-provider-specification.yaml | 13 ++++++++++++- server/controllers/MiscController.js | 10 +++++----- server/models/CustomMetadataProvider.js | 10 ++-------- server/providers/CustomProviderAdapter.js | 16 +++++++++------- 6 files changed, 33 insertions(+), 28 deletions(-) diff --git a/client/pages/config/custom-metadata-providers.vue b/client/pages/config/custom-metadata-providers.vue index 10fdb21b..9f394eae 100644 --- a/client/pages/config/custom-metadata-providers.vue +++ b/client/pages/config/custom-metadata-providers.vue @@ -9,7 +9,7 @@
- {{ $strings.ButtonAdd }} + {{ $strings.ButtonAdd }} @@ -40,6 +40,4 @@ export default { } - + diff --git a/client/strings/en-us.json b/client/strings/en-us.json index 9dfde095..e937ed72 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -96,7 +96,6 @@ "HeaderAudiobookTools": "Audiobook File Management Tools", "HeaderAudioTracks": "Audio Tracks", "HeaderAuthentication": "Authentication", - "HeaderCustomMetadataProviders": "Custom metadata providers", "HeaderBackups": "Backups", "HeaderChangePassword": "Change Password", "HeaderChapters": "Chapters", @@ -105,6 +104,7 @@ "HeaderCollectionItems": "Collection Items", "HeaderCover": "Cover", "HeaderCurrentDownloads": "Current Downloads", + "HeaderCustomMetadataProviders": "Custom metadata providers", "HeaderDetails": "Details", "HeaderDownloadQueue": "Download Queue", "HeaderEbookFiles": "Ebook Files", @@ -194,6 +194,7 @@ "LabelAllUsersExcludingGuests": "All users excluding guests", "LabelAllUsersIncludingGuests": "All users including guests", "LabelAlreadyInYourLibrary": "Already in your library", + "LabelApiKey": "API Key", "LabelAppend": "Append", "LabelAuthor": "Author", "LabelAuthorFirstLast": "Author (First Last)", @@ -526,6 +527,7 @@ "LabelUploaderDragAndDrop": "Drag & drop files or folders", "LabelUploaderDropFiles": "Drop files", "LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series", + "LabelUrl": "URL", "LabelUseChapterTrack": "Use chapter track", "LabelUseFullTrack": "Use full track", "LabelUser": "User", @@ -541,8 +543,6 @@ "LabelYourBookmarks": "Your Bookmarks", "LabelYourPlaylists": "Your Playlists", "LabelYourProgress": "Your Progress", - "LabelUrl": "URL", - "LabelApiKey": "API Key", "MessageAddToPlayerQueue": "Add to player queue", "MessageAppriseDescription": "To use this feature you will need to have an instance of Apprise API running or an api that will handle those same requests.
The Apprise API Url should be the full URL path to send the notification, e.g., if your API instance is served at http://192.168.1.1:8337 then you would put http://192.168.1.1:8337/notify.", "MessageBackupsDescription": "Backups include users, user progress, library item details, server settings, and images stored in /metadata/items & /metadata/authors. Backups do not include any files stored in your library folders.", diff --git a/custom-metadata-provider-specification.yaml b/custom-metadata-provider-specification.yaml index 3201fbb8..90df875b 100644 --- a/custom-metadata-provider-specification.yaml +++ b/custom-metadata-provider-specification.yaml @@ -86,7 +86,7 @@ components: type: string publisher: type: string - published_year: + publishedYear: type: string description: type: string @@ -107,6 +107,17 @@ components: 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: diff --git a/server/controllers/MiscController.js b/server/controllers/MiscController.js index 76140dcc..1d2fff04 100644 --- a/server/controllers/MiscController.js +++ b/server/controllers/MiscController.js @@ -763,7 +763,7 @@ class MiscController { return res.sendStatus(403) } - const { name, url, apiKey } = req.body; + const { name, url, apiKey } = req.body if (!name || !url || !apiKey) { return res.status(500).send(`Invalid patch data`) @@ -794,18 +794,18 @@ class MiscController { return res.sendStatus(403) } - const { id } = req.params; + const { id } = req.params if (!id) { return res.status(500).send(`Invalid delete data`) } - const provider = await Database.customMetadataProviderModel.findByPk(id); - await Database.removeCustomMetadataProviderById(id); + const provider = await Database.customMetadataProviderModel.findByPk(id) + await Database.removeCustomMetadataProviderById(id) SocketAuthority.adminEmitter('custom_metadata_provider_removed', provider) - res.json({}) + res.sendStatus(200) } } module.exports = new MiscController() diff --git a/server/models/CustomMetadataProvider.js b/server/models/CustomMetadataProvider.js index 9bc175c4..d6047bb8 100644 --- a/server/models/CustomMetadataProvider.js +++ b/server/models/CustomMetadataProvider.js @@ -26,13 +26,7 @@ class CustomMetadataProvider extends Model { } } - static findByPk(id) { - return this.findOne({ - where: { - id, - } - }) - } + /** * Initialize model @@ -47,7 +41,7 @@ class CustomMetadataProvider extends Model { }, name: DataTypes.STRING, url: DataTypes.STRING, - apiKey: DataTypes.STRING + apiKey: DataTypes.STRING, }, { sequelize, modelName: 'customMetadataProvider' diff --git a/server/providers/CustomProviderAdapter.js b/server/providers/CustomProviderAdapter.js index d5f64291..1919ecc9 100644 --- a/server/providers/CustomProviderAdapter.js +++ b/server/providers/CustomProviderAdapter.js @@ -1,6 +1,6 @@ const Database = require('../Database') -const axios = require("axios"); -const Logger = require("../Logger"); +const axios = require("axios") +const Logger = require("../Logger") class CustomProviderAdapter { constructor() { @@ -8,10 +8,10 @@ class CustomProviderAdapter { async search(title, author, providerSlug) { const providerId = providerSlug.split("custom-")[1] - const provider = await Database.customMetadataProviderModel.findByPk(providerId); + const provider = await Database.customMetadataProviderModel.findByPk(providerId) if (!provider) { - throw new Error("Custom provider not found for the given id"); + throw new Error("Custom provider not found for the given id") } const matches = await axios.get(`${provider.url}/search?query=${encodeURIComponent(title)}${!!author ? `&author=${encodeURIComponent(author)}` : ""}`, { @@ -27,7 +27,7 @@ class CustomProviderAdapter { }) if (matches === null) { - throw new Error("Custom provider returned malformed response"); + throw new Error("Custom provider returned malformed response") } // re-map keys to throw out @@ -37,13 +37,14 @@ class CustomProviderAdapter { author, narrator, publisher, - published_year, + publishedYear, description, cover, isbn, asin, genres, tags, + series, language, duration, }) => { @@ -53,13 +54,14 @@ class CustomProviderAdapter { author, narrator, publisher, - publishedYear: published_year, + publishedYear, description, cover, isbn, asin, genres, tags: tags.join(","), + series: series.length ? series : null, language, duration, } From 0cf2f8885ec34f85433f254c0e1ae23007be4a6c Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 11 Feb 2024 16:48:16 -0600 Subject: [PATCH 6/6] Add custom metadata provider controller, update model, move to item metadata utils --- client/components/app/ConfigSideNav.vue | 5 - client/components/app/SettingsContent.vue | 1 + .../modals/AddCustomMetadataProviderModal.vue | 83 +++++------ client/components/modals/item/tabs/Match.vue | 15 +- .../tables/CustomMetadataProviderTable.vue | 139 ++++++++---------- client/layouts/default.vue | 14 +- .../config/custom-metadata-providers.vue | 43 ------ .../custom-metadata-providers.vue | 74 ++++++++++ .../config/item-metadata-utils/index.vue | 6 + client/store/libraries.js | 3 + client/store/scanners.js | 73 +++++---- client/strings/en-us.json | 4 +- server/Database.js | 39 ----- .../CustomMetadataProviderController.js | 117 +++++++++++++++ server/controllers/LibraryController.js | 36 +++-- server/controllers/MiscController.js | 90 ------------ server/controllers/SessionController.js | 2 +- server/finders/BookFinder.js | 11 +- server/models/CustomMetadataProvider.js | 61 +++++++- server/providers/CustomProviderAdapter.js | 40 +++-- server/routers/ApiRouter.js | 13 +- 21 files changed, 496 insertions(+), 373 deletions(-) delete mode 100644 client/pages/config/custom-metadata-providers.vue create mode 100644 client/pages/config/item-metadata-utils/custom-metadata-providers.vue create mode 100644 server/controllers/CustomMetadataProviderController.js diff --git a/client/components/app/ConfigSideNav.vue b/client/components/app/ConfigSideNav.vue index e253d1ae..c2db0725 100644 --- a/client/components/app/ConfigSideNav.vue +++ b/client/components/app/ConfigSideNav.vue @@ -109,11 +109,6 @@ export default { id: 'config-authentication', title: this.$strings.HeaderAuthentication, path: '/config/authentication' - }, - { - id: 'config-custom-metadata-providers', - title: this.$strings.HeaderCustomMetadataProviders, - path: '/config/custom-metadata-providers' } ] diff --git a/client/components/app/SettingsContent.vue b/client/components/app/SettingsContent.vue index c78873e3..ec129ebc 100644 --- a/client/components/app/SettingsContent.vue +++ b/client/components/app/SettingsContent.vue @@ -1,6 +1,7 @@
-
+
-
- +
+
+ +
+
+ +
-
- +
+
-
- +
+
-
+
{{ $strings.ButtonAdd }}
@@ -30,14 +35,14 @@ - - diff --git a/client/pages/config/item-metadata-utils/custom-metadata-providers.vue b/client/pages/config/item-metadata-utils/custom-metadata-providers.vue new file mode 100644 index 00000000..66581dae --- /dev/null +++ b/client/pages/config/item-metadata-utils/custom-metadata-providers.vue @@ -0,0 +1,74 @@ + + + + + diff --git a/client/pages/config/item-metadata-utils/index.vue b/client/pages/config/item-metadata-utils/index.vue index 3a12261b..7d0ba068 100644 --- a/client/pages/config/item-metadata-utils/index.vue +++ b/client/pages/config/item-metadata-utils/index.vue @@ -13,6 +13,12 @@ arrow_forward
+ +
+

{{ $strings.HeaderCustomMetadataProviders }}

+ arrow_forward +
+
diff --git a/client/store/libraries.js b/client/store/libraries.js index 8771ebcf..1d13d632 100644 --- a/client/store/libraries.js +++ b/client/store/libraries.js @@ -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 }) diff --git a/client/store/scanners.js b/client/store/scanners.js index 32878a6a..2d3d465c 100644 --- a/client/store/scanners.js +++ b/client/store/scanners.js @@ -71,35 +71,56 @@ export const state = () => ({ ] }) -export const getters = {} - -export const actions = { - reFetchCustom({ dispatch, commit }) { - return this.$axios - .$get(`/api/custom-metadata-providers`) - .then((data) => { - const providers = data.providers - - commit('setCustomProviders', providers) - return data - }) - .catch((error) => { - console.error('Failed', error) - return false - }) +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 = { - setCustomProviders(state, providers) { - // 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) => {return { - text: p.name, - value: p.slug, - }}) - ] + 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 + } + } } \ No newline at end of file diff --git a/client/strings/en-us.json b/client/strings/en-us.json index c9b2d687..2a68424e 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -104,7 +104,7 @@ "HeaderCollectionItems": "Collection Items", "HeaderCover": "Cover", "HeaderCurrentDownloads": "Current Downloads", - "HeaderCustomMetadataProviders": "Custom metadata providers", + "HeaderCustomMetadataProviders": "Custom Metadata Providers", "HeaderDetails": "Details", "HeaderDownloadQueue": "Download Queue", "HeaderEbookFiles": "Ebook Files", @@ -194,7 +194,6 @@ "LabelAllUsersExcludingGuests": "All users excluding guests", "LabelAllUsersIncludingGuests": "All users including guests", "LabelAlreadyInYourLibrary": "Already in your library", - "LabelApiKey": "API Key", "LabelAppend": "Append", "LabelAuthor": "Author", "LabelAuthorFirstLast": "Author (First Last)", @@ -536,7 +535,6 @@ "LabelUploaderDragAndDrop": "Drag & drop files or folders", "LabelUploaderDropFiles": "Drop files", "LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series", - "LabelUrl": "URL", "LabelUseChapterTrack": "Use chapter track", "LabelUseFullTrack": "Use full track", "LabelUser": "User", diff --git a/server/Database.js b/server/Database.js index d2bb6fa4..dd9a0550 100644 --- a/server/Database.js +++ b/server/Database.js @@ -700,45 +700,6 @@ class Database { }) } - /** - * Returns true if a custom provider with the given slug exists - * @param {string} providerSlug - * @return {boolean} - */ - async doesCustomProviderExistWithSlug(providerSlug) { - const id = providerSlug.split("custom-")[1] - - if (!id) { - return false - } - - return !!await this.customMetadataProviderModel.findByPk(id) - } - - /** - * Removes a custom metadata provider - * @param {string} id - */ - async removeCustomMetadataProviderById(id) { - // destroy metadta provider - await this.customMetadataProviderModel.destroy({ - where: { - id, - } - }) - - const slug = `custom-${id}`; - - // fallback libraries using it to google - await this.libraryModel.update({ - provider: "google", - }, { - where: { - provider: slug, - } - }); - } - /** * Clean invalid records in database * Series should have atleast one Book diff --git a/server/controllers/CustomMetadataProviderController.js b/server/controllers/CustomMetadataProviderController.js new file mode 100644 index 00000000..fdb4df2d --- /dev/null +++ b/server/controllers/CustomMetadataProviderController.js @@ -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() diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index b1ab572f..ecea310c 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -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 => { @@ -51,11 +59,6 @@ class LibraryController { } } - // Validate that the custom provider exists if given any - if (newLibraryPayload.provider && newLibraryPayload.provider.startsWith("custom-")) { - await Database.doesCustomProviderExistWithSlug(newLibraryPayload.provider) - } - const library = new Library() let currentLargestDisplayOrder = await Database.libraryModel.getMaxDisplayOrder() @@ -91,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) } /** @@ -120,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) { @@ -180,11 +199,6 @@ class LibraryController { } } - // Validate that the custom provider exists if given any - if (req.body.provider && req.body.provider.startsWith("custom-")) { - await Database.doesCustomProviderExistWithSlug(req.body.provider) - } - const hasUpdates = library.update(req.body) // TODO: Should check if this is an update to folder paths or name only if (hasUpdates) { diff --git a/server/controllers/MiscController.js b/server/controllers/MiscController.js index 1d2fff04..c2272ee6 100644 --- a/server/controllers/MiscController.js +++ b/server/controllers/MiscController.js @@ -717,95 +717,5 @@ class MiscController { const stats = await adminStats.getStatsForYear(year) res.json(stats) } - - /** - * GET: /api/custom-metadata-providers - * - * @param {import('express').Request} req - * @param {import('express').Response} res - */ - async getCustomMetadataProviders(req, res) { - const providers = await Database.customMetadataProviderModel.findAll() - - res.json({ - providers: providers.map((p) => p.toUserJson()), - }) - } - - /** - * GET: /api/custom-metadata-providers/admin - * - * @param {import('express').Request} req - * @param {import('express').Response} res - */ - async getAdminCustomMetadataProviders(req, res) { - if (!req.user.isAdminOrUp) { - Logger.error(`[MiscController] Non-admin user "${req.user.username}" attempted to get admin custom metadata providers`) - return res.sendStatus(403) - } - - const providers = await Database.customMetadataProviderModel.findAll() - - res.json({ - providers, - }) - } - - /** - * PATCH: /api/custom-metadata-providers/admin - * - * @param {import('express').Request} req - * @param {import('express').Response} res - */ - async addCustomMetadataProviders(req, res) { - if (!req.user.isAdminOrUp) { - Logger.error(`[MiscController] Non-admin user "${req.user.username}" attempted to add admin custom metadata providers`) - return res.sendStatus(403) - } - - const { name, url, apiKey } = req.body - - if (!name || !url || !apiKey) { - return res.status(500).send(`Invalid patch data`) - } - - const provider = await Database.customMetadataProviderModel.create({ - name, - url, - apiKey, - }) - - SocketAuthority.adminEmitter('custom_metadata_provider_added', provider) - - res.json({ - provider, - }) - } - - /** - * DELETE: /api/custom-metadata-providers/admin/:id - * - * @param {import('express').Request} req - * @param {import('express').Response} res - */ - async deleteCustomMetadataProviders(req, res) { - if (!req.user.isAdminOrUp) { - Logger.error(`[MiscController] Non-admin user "${req.user.username}" attempted to delete admin custom metadata providers`) - return res.sendStatus(403) - } - - const { id } = req.params - - if (!id) { - return res.status(500).send(`Invalid delete data`) - } - - const provider = await Database.customMetadataProviderModel.findByPk(id) - await Database.removeCustomMetadataProviderById(id) - - SocketAuthority.adminEmitter('custom_metadata_provider_removed', provider) - - res.sendStatus(200) - } } module.exports = new MiscController() diff --git a/server/controllers/SessionController.js b/server/controllers/SessionController.js index 22fcaa1c..7626bd12 100644 --- a/server/controllers/SessionController.js +++ b/server/controllers/SessionController.js @@ -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) { diff --git a/server/finders/BookFinder.js b/server/finders/BookFinder.js index 6c35a5fb..7ba97ed1 100644 --- a/server/finders/BookFinder.js +++ b/server/finders/BookFinder.js @@ -149,6 +149,13 @@ class BookFinder { return books } + /** + * + * @param {string} title + * @param {string} author + * @param {string} providerSlug + * @returns {Promise} + */ 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}`) @@ -325,8 +332,8 @@ class BookFinder { let numFuzzySearches = 0 // Custom providers are assumed to be correct - if (provider.startsWith("custom-")) { - return await this.getCustomProviderResults(title, author, provider) + if (provider.startsWith('custom-')) { + return this.getCustomProviderResults(title, author, provider) } if (!title) diff --git a/server/models/CustomMetadataProvider.js b/server/models/CustomMetadataProvider.js index d6047bb8..8218e419 100644 --- a/server/models/CustomMetadataProvider.js +++ b/server/models/CustomMetadataProvider.js @@ -1,4 +1,12 @@ -const { DataTypes, Model, Sequelize } = require('sequelize') +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) { @@ -7,26 +15,67 @@ class CustomMetadataProvider extends Model { /** @type {UUIDV4} */ this.id /** @type {string} */ + this.mediaType + /** @type {string} */ this.name /** @type {string} */ this.url /** @type {string} */ - this.apiKey + this.authHeaderValue + /** @type {Object} */ + this.extraData + /** @type {Date} */ + this.createdAt + /** @type {Date} */ + this.updatedAt } getSlug() { return `custom-${this.id}` } - toUserJson() { + /** + * Safe for clients + * @returns {ClientCustomMetadataProvider} + */ + toClientJson() { return { - name: this.name, 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} + */ + 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} + */ + static async checkExistsBySlug(providerSlug) { + const providerId = providerSlug?.split?.('custom-')[1] + if (!providerId) return false + + return (await this.count({ where: { id: providerId } })) > 0 + } /** * Initialize model @@ -40,8 +89,10 @@ class CustomMetadataProvider extends Model { primaryKey: true }, name: DataTypes.STRING, + mediaType: DataTypes.STRING, url: DataTypes.STRING, - apiKey: DataTypes.STRING, + authHeaderValue: DataTypes.STRING, + extraData: DataTypes.JSON }, { sequelize, modelName: 'customMetadataProvider' diff --git a/server/providers/CustomProviderAdapter.js b/server/providers/CustomProviderAdapter.js index 1919ecc9..36f4c930 100644 --- a/server/providers/CustomProviderAdapter.js +++ b/server/providers/CustomProviderAdapter.js @@ -1,32 +1,40 @@ const Database = require('../Database') -const axios = require("axios") -const Logger = require("../Logger") +const axios = require('axios') +const Logger = require('../Logger') class CustomProviderAdapter { - constructor() { - } + constructor() { } + /** + * + * @param {string} title + * @param {string} author + * @param {string} providerSlug + * @returns {Promise} + */ async search(title, author, providerSlug) { - const providerId = providerSlug.split("custom-")[1] + 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 matches = await axios.get(`${provider.url}/search?query=${encodeURIComponent(title)}${!!author ? `&author=${encodeURIComponent(author)}` : ""}`, { - headers: { - "Authorization": provider.apiKey, - }, - }).then((res) => { - if (!res || !res.data || !Array.isArray(res.data.matches)) return null + 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 === null) { + if (!matches) { throw new Error("Custom provider returned malformed response") } @@ -46,7 +54,7 @@ class CustomProviderAdapter { tags, series, language, - duration, + duration }) => { return { title, @@ -60,10 +68,10 @@ class CustomProviderAdapter { isbn, asin, genres, - tags: tags.join(","), - series: series.length ? series : null, + tags: tags?.join(',') || null, + series: series?.length ? series : null, language, - duration, + duration } }) } diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 99769648..a2688b88 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -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 // @@ -318,10 +327,6 @@ class ApiRouter { this.router.patch('/auth-settings', MiscController.updateAuthSettings.bind(this)) this.router.post('/watcher/update', MiscController.updateWatchedPath.bind(this)) this.router.get('/stats/year/:year', MiscController.getAdminStatsForYear.bind(this)) - this.router.get('/custom-metadata-providers', MiscController.getCustomMetadataProviders.bind(this)) - this.router.get('/custom-metadata-providers/admin', MiscController.getAdminCustomMetadataProviders.bind(this)) - this.router.patch('/custom-metadata-providers/admin', MiscController.addCustomMetadataProviders.bind(this)) - this.router.delete('/custom-metadata-providers/admin/:id', MiscController.deleteCustomMetadataProviders.bind(this)) } //