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 @@ 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 ccdc1791..2d3d465c 100644 --- a/client/store/scanners.js +++ b/client/store/scanners.js @@ -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 = {} \ No newline at end of file +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 + } + } +} \ No newline at end of file diff --git a/client/strings/en-us.json b/client/strings/en-us.json index e3349d1f..2a68424e 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -104,6 +104,7 @@ "HeaderCollectionItems": "Collection Items", "HeaderCover": "Cover", "HeaderCurrentDownloads": "Current Downloads", + "HeaderCustomMetadataProviders": "Custom Metadata Providers", "HeaderDetails": "Details", "HeaderDownloadQueue": "Download Queue", "HeaderEbookFiles": "Ebook Files", diff --git a/custom-metadata-provider-specification.yaml b/custom-metadata-provider-specification.yaml new file mode 100644 index 00000000..90df875b --- /dev/null +++ b/custom-metadata-provider-specification.yaml @@ -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 + + diff --git a/server/Database.js b/server/Database.js index 0ddef620..dd9a0550 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 }) } 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 70baff85..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 => { @@ -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) { 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 466c8701..7ba97ed1 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,20 @@ 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}`) + + 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 diff --git a/server/models/CustomMetadataProvider.js b/server/models/CustomMetadataProvider.js new file mode 100644 index 00000000..8218e419 --- /dev/null +++ b/server/models/CustomMetadataProvider.js @@ -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} + */ + 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 + * @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 \ No newline at end of file diff --git a/server/providers/CustomProviderAdapter.js b/server/providers/CustomProviderAdapter.js new file mode 100644 index 00000000..36f4c930 --- /dev/null +++ b/server/providers/CustomProviderAdapter.js @@ -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} + */ + 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 \ No newline at end of file diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 2956cd52..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 //