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)) } //