From eaf31c9efaeae16b9629d7f9e661bef1c86eb820 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9e=20Kooi?= Date: Thu, 4 Mar 2021 17:19:04 +0100 Subject: [PATCH 01/13] sketch out new source API implementation --- dev/u-wave-dev-server | 21 ++++------- package.json | 1 + src/Source.js | 75 ++++++++++++++++++++++++++++++++++++++ src/Uwave.js | 40 +++++++++++++++++++- src/controllers/sources.js | 52 ++++++++++++++++++++++++++ src/routes/sources.js | 31 ++++++++++++++++ 6 files changed, 205 insertions(+), 15 deletions(-) create mode 100644 src/controllers/sources.js create mode 100644 src/routes/sources.js diff --git a/dev/u-wave-dev-server b/dev/u-wave-dev-server index c56c512b..8f3e5578 100755 --- a/dev/u-wave-dev-server +++ b/dev/u-wave-dev-server @@ -6,7 +6,7 @@ const argv = require('minimist')(process.argv.slice(2)); const concat = require('concat-stream'); const explain = require('explain-error'); const announce = require('u-wave-announce'); -const ytSource = require('u-wave-source-youtube'); +const YouTubeSource = require('u-wave-source-youtube').default; const scSource = require('u-wave-source-soundcloud'); const recaptchaTestKeys = require('recaptcha-test-keys'); const debug = require('debug')('uwave:dev-server'); @@ -64,18 +64,13 @@ async function start() { seed: Buffer.from('8286a5e55c62d93a042b8c56c8face52c05354c288807d941751f0e9060c2ded', 'hex'), }); - uw.use(async function configureSources(uw) { - if (process.env.YOUTUBE_API_KEY) { - uw.source(ytSource, { - key: process.env.YOUTUBE_API_KEY, - }); - } - if (process.env.SOUNDCLOUD_API_KEY) { - uw.source(scSource, { - key: process.env.SOUNDCLOUD_API_KEY, - }); - } - }); + uw.useSource(YouTubeSource); + + if (process.env.SOUNDCLOUD_API_KEY) { + uw.useSource(scSource, { + key: process.env.SOUNDCLOUD_API_KEY, + }); + } await uw.listen(port); console.log(`Now listening on ${port}`); diff --git a/package.json b/package.json index 0b81ca44..8638f13d 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "http-errors": "^1.7.3", "i18next": "^21.0.2", "ioredis": "^4.14.1", + "json-schema-merge-allof": "^0.8.1", "jsonwebtoken": "^8.5.1", "lodash": "^4.17.15", "make-promises-safe": "^5.1.0", diff --git a/src/Source.js b/src/Source.js index 4ebd21e7..f33550b5 100644 --- a/src/Source.js +++ b/src/Source.js @@ -1,5 +1,7 @@ 'use strict'; +const debug = require('debug')('uwave:source'); +const mergeAllOf = require('json-schema-merge-allof'); const { SourceNoImportError } = require('./errors'); /** @@ -177,6 +179,79 @@ class Source { } throw new SourceNoImportError({ name: this.type }); } + + static async plugin(uw, { source: SourcePlugin, baseOptions = {} }) { + debug('registering plugin', SourcePlugin); + if (SourcePlugin.api == null || SourcePlugin.api < 3) { + uw.source(SourcePlugin, baseOptions); + return; + } + + if (!SourcePlugin.sourceName) { + throw new TypeError('Source plugin does not provide a `sourceName`'); + } + + async function readdSource(options) { + debug('adding plugin', options); + const { enabled, ...sourceOptions } = options; + + const oldSource = uw.removeSourceInternal(SourcePlugin.sourceName); + if (oldSource && typeof oldSource.close === 'function') { + await oldSource.close(); + } + + if (enabled) { + const instance = new SourcePlugin({ + ...baseOptions, + ...sourceOptions + }); + + const source = new Source(uw, SourcePlugin.sourceName, instance); + uw.insertSourceInternal(SourcePlugin.sourceName, source); + } + } + + if (SourcePlugin.schema) { + if (!SourcePlugin.schema['uw:key']) { + throw new TypeError(`Option schema for media source does not specify an "uw:key" value`); + } + + uw.config.register(SourcePlugin.schema['uw:key'], mergeAllOf({ + allOf: [ + { + type: 'object', + properties: { + enabled: { + type: 'boolean', + title: 'Enabled', + default: false, + }, + }, + required: ['enabled'], + }, + SourcePlugin.schema + ], + }, { deep: false })); + + const initialOptions = await uw.config.get(SourcePlugin.schema['uw:key']); + uw.config.on('set', (key, newOptions) => { + if (key === SourcePlugin.schema['uw:key']) { + readdSource(newOptions).catch((error) => { + if (uw.options.onError) { + uw.options.onError(error); + } else { + debug(error); + } + }); + } + }); + + await readdSource(initialOptions); + } else { + // The source does not support options + await readdSource({}); + } + } } exports.SourceContext = SourceContext; diff --git a/src/Uwave.js b/src/Uwave.js index 2032eb84..819d3804 100644 --- a/src/Uwave.js +++ b/src/Uwave.js @@ -208,6 +208,16 @@ class UwaveServer extends EventEmitter { return [...this.#sources.values()]; } + /** + * Register a source plugin. + */ + async useSource(sourcePlugin, opts = {}) { + await this.use(Source.plugin, { + source: sourcePlugin, + baseOptions: opts, + }); + } + /** * Get or register a source plugin. * If the first parameter is a string, returns an existing source plugin. @@ -234,6 +244,10 @@ class UwaveServer extends EventEmitter { throw new TypeError(`Source plugin should be a function, got ${typeof sourceFactory}`); } + if (typeof sourceFactory === 'function' && has(sourceFactory, 'api') && sourceFactory.api >= 3) { + throw new TypeError('uw.source() only supports old-style source plugins.'); + } + const sourceDefinition = typeof sourceFactory === 'function' ? sourceFactory(this, opts) : sourceFactory; @@ -243,14 +257,36 @@ class UwaveServer extends EventEmitter { } const newSource = new Source(this, sourceType, sourceDefinition); - this.#sources.set(sourceType, newSource); + this.insertSourceInternal(sourceType, newSource); return newSource; } /** - * @private + * Adds a fully wrapped source plugin. Not for external use. + * + * @param {string} sourceType + * @param {Source} source */ + insertSourceInternal(sourceType, source) { + this.#sources.set(sourceType, source); + } + + /** + * Removes a source plugin. Not for external use. + * + * Only source plugins using Media Source API 3 or higher can be removed. + * + * @param {string} sourceType + */ + removeSourceInternal(sourceType) { + const source = this.#sources.get(sourceType); + if (this.#sources.delete(sourceType)) { + return source; + } + return null; + } + configureRedis() { this.redis.on('error', (e) => { this.emit('redisError', e); diff --git a/src/controllers/sources.js b/src/controllers/sources.js new file mode 100644 index 00000000..593d659b --- /dev/null +++ b/src/controllers/sources.js @@ -0,0 +1,52 @@ +'use strict'; + +const { + SourceNotFoundError, + SourceNoImportError, +} = require('../errors'); +const searchController = require('./search'); +const toListResponse = require('../utils/toListResponse'); + +function getImportableSource(req) { + const uw = req.uwave; + const { source: sourceName } = req.params; + + const source = uw.source(sourceName); + if (!source) { + throw new SourceNotFoundError({ name: sourceName }); + } + if (!source.import) { + throw new SourceNoImportError({ name: sourceName }); + } + if (source.apiVersion < 3) { + throw new SourceNoImportError({ name: sourceName }); + } + + return source; +} + +async function getChannelPlaylists(req) { + const uw = req.uwave; + const source = getImportableSource(req); + const { userID } = req.params; + + const items = await source.getUserPlaylists(userID); + return toListResponse(items, { + url: req.fullUrl, + }); +} + +async function getPlaylistItems(req) { + const uw = req.uwave; + const source = getImportableSource(req); + const { playlistID } = req.params; + + const items = await source.getPlaylistItems(playlistID); + return toListResponse(items, { + url: req.fullUrl, + }); +} + +exports.search = searchController.search; +exports.getChannelPlaylists = getChannelPlaylists; +exports.getPlaylistItems = getPlaylistItems; diff --git a/src/routes/sources.js b/src/routes/sources.js new file mode 100644 index 00000000..bd382d2b --- /dev/null +++ b/src/routes/sources.js @@ -0,0 +1,31 @@ +'use strict'; + +const router = require('router'); +const route = require('../route'); +const protect = require('../middleware/protect'); +const schema = require('../middleware/schema'); +const controller = require('../controllers/sources'); +const validations = require('../validations'); + +function sourceRoutes() { + return router() + .use(protect()) + // GET /sources/:source/search - Search for media in a single source. + .get( + '/:source/search', + schema(validations.search), + route(controller.search), + ) + // GET /sources/:source/channels/:userID/playlists - Get playlists for a user on the media source. + .get( + '/:source/channels/:userID/playlists', + route(controller.getChannelPlaylists), + ) + // GET /sources/:source/channels/:userID/playlists - Get items for a playlist on the media source. + .get( + '/:source/playlists/:playlistID/media', + route(controller.getPlaylistItems), + ); +} + +module.exports = sourceRoutes; From e10db7e6a54fa571e0c96d50bf35485ca918feae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9e=20Kooi?= Date: Fri, 5 Mar 2021 17:46:09 +0100 Subject: [PATCH 02/13] implement /sources/:source/playlists?userID=x --- src/HttpApi.js | 6 ++++-- src/Source.js | 29 ++++++++++++++++++++++++++--- src/Uwave.js | 24 ++++++++++++++---------- src/controllers/sources.js | 18 ++++++++++++++---- src/routes/sources.js | 6 +++--- 5 files changed, 61 insertions(+), 22 deletions(-) diff --git a/src/HttpApi.js b/src/HttpApi.js index 50bbe553..f8fab333 100644 --- a/src/HttpApi.js +++ b/src/HttpApi.js @@ -12,11 +12,12 @@ const debug = require('debug')('uwave:http-api'); // routes const authenticate = require('./routes/authenticate'); const bans = require('./routes/bans'); +const imports = require('./routes/import'); +const now = require('./routes/now'); const search = require('./routes/search'); const server = require('./routes/server'); +const sources = require('./routes/sources'); const users = require('./routes/users'); -const now = require('./routes/now'); -const imports = require('./routes/import'); // middleware const addFullUrl = require('./middleware/addFullUrl'); @@ -127,6 +128,7 @@ async function httpApi(uw, options) { .use('/now', now()) .use('/search', search()) .use('/server', server()) + .use('/sources', sources()) .use('/users', users()); uw.express = express(); diff --git a/src/Source.js b/src/Source.js index f33550b5..e6f65dbe 100644 --- a/src/Source.js +++ b/src/Source.js @@ -88,12 +88,10 @@ class Source { this.uw = uw; this.type = sourceType; this.plugin = sourcePlugin; - - this.addSourceType = this.addSourceType.bind(this); } get apiVersion() { - return this.plugin.api || 1; + return this.plugin.api || this.plugin.constructor.api || 1; } /** @@ -164,6 +162,31 @@ class Source { return this.addSourceType(results); } + /** + * Get playlists for a specific user from this media source. + */ + async getUserPlaylists(user, userID) { + if (this.apiVersion < 3 || !this.plugin.getUserPlaylists) { + throw new SourceNoImportError({ name: this.type }); + } + + const context = new SourceContext(this.uw, this, user); + + return this.plugin.getUserPlaylists(context, userID); + } + + /** + * Get playlists for a specific user from this media source. + */ + async getPlaylistItems(user, playlistID) { + if (this.apiVersion < 3 || !this.plugin.getPlaylistItems) { + throw new SourceNoImportError({ name: this.type }); + } + + const context = new SourceContext(this.uw, this, user); + return this.plugin.getPlaylistItems(context, playlistID); + } + /** * Import *something* from this media source. Because media sources can * provide wildly different imports, üWave trusts clients to know what they're diff --git a/src/Uwave.js b/src/Uwave.js index 819d3804..d2c746ce 100644 --- a/src/Uwave.js +++ b/src/Uwave.js @@ -199,25 +199,29 @@ class UwaveServer extends EventEmitter { boot.use(httpApi.errorHandling); } - /** - * An array of registered sources. - * - * @type {Source[]} - */ - get sources() { - return [...this.#sources.values()]; - } - /** * Register a source plugin. */ async useSource(sourcePlugin, opts = {}) { - await this.use(Source.plugin, { + /** @type {import('avvio').Avvio} */ + // @ts-ignore + const boot = this; + + boot.use(Source.plugin, { source: sourcePlugin, baseOptions: opts, }); } + /** + * An array of registered sources. + * + * @type {Source[]} + */ + get sources() { + return [...this.#sources.values()]; + } + /** * Get or register a source plugin. * If the first parameter is a string, returns an existing source plugin. diff --git a/src/controllers/sources.js b/src/controllers/sources.js index 593d659b..73f905be 100644 --- a/src/controllers/sources.js +++ b/src/controllers/sources.js @@ -1,5 +1,6 @@ 'use strict'; +const { BadRequest } = require('http-errors'); const { SourceNotFoundError, SourceNoImportError, @@ -25,12 +26,21 @@ function getImportableSource(req) { return source; } -async function getChannelPlaylists(req) { +async function getPlaylists(req) { const uw = req.uwave; const source = getImportableSource(req); - const { userID } = req.params; + const { + userID, + } = req.query; + + let items; + + if (userID) { + items = await source.getUserPlaylists(req.user, userID); + } else { + throw new BadRequest('No playlist filter provided'); + } - const items = await source.getUserPlaylists(userID); return toListResponse(items, { url: req.fullUrl, }); @@ -48,5 +58,5 @@ async function getPlaylistItems(req) { } exports.search = searchController.search; -exports.getChannelPlaylists = getChannelPlaylists; +exports.getPlaylists = getPlaylists; exports.getPlaylistItems = getPlaylistItems; diff --git a/src/routes/sources.js b/src/routes/sources.js index bd382d2b..bc7e2ea7 100644 --- a/src/routes/sources.js +++ b/src/routes/sources.js @@ -16,10 +16,10 @@ function sourceRoutes() { schema(validations.search), route(controller.search), ) - // GET /sources/:source/channels/:userID/playlists - Get playlists for a user on the media source. + // GET /sources/:source/playlists - List playlists from the media source. .get( - '/:source/channels/:userID/playlists', - route(controller.getChannelPlaylists), + '/:source/playlists', + route(controller.getPlaylists), ) // GET /sources/:source/channels/:userID/playlists - Get items for a playlist on the media source. .get( From 5b51685b3f2a18fbefbe3c79d8205963d8c2ea1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9e=20Kooi?= Date: Wed, 10 Mar 2021 11:46:03 +0100 Subject: [PATCH 03/13] lint fixes --- src/Source.js | 6 +++--- src/controllers/sources.js | 2 -- src/routes/sources.js | 3 ++- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/Source.js b/src/Source.js index e6f65dbe..e7642d5b 100644 --- a/src/Source.js +++ b/src/Source.js @@ -226,7 +226,7 @@ class Source { if (enabled) { const instance = new SourcePlugin({ ...baseOptions, - ...sourceOptions + ...sourceOptions, }); const source = new Source(uw, SourcePlugin.sourceName, instance); @@ -236,7 +236,7 @@ class Source { if (SourcePlugin.schema) { if (!SourcePlugin.schema['uw:key']) { - throw new TypeError(`Option schema for media source does not specify an "uw:key" value`); + throw new TypeError('Option schema for media source does not specify an "uw:key" value'); } uw.config.register(SourcePlugin.schema['uw:key'], mergeAllOf({ @@ -252,7 +252,7 @@ class Source { }, required: ['enabled'], }, - SourcePlugin.schema + SourcePlugin.schema, ], }, { deep: false })); diff --git a/src/controllers/sources.js b/src/controllers/sources.js index 73f905be..7ffd73f2 100644 --- a/src/controllers/sources.js +++ b/src/controllers/sources.js @@ -27,7 +27,6 @@ function getImportableSource(req) { } async function getPlaylists(req) { - const uw = req.uwave; const source = getImportableSource(req); const { userID, @@ -47,7 +46,6 @@ async function getPlaylists(req) { } async function getPlaylistItems(req) { - const uw = req.uwave; const source = getImportableSource(req); const { playlistID } = req.params; diff --git a/src/routes/sources.js b/src/routes/sources.js index bc7e2ea7..6f91ed46 100644 --- a/src/routes/sources.js +++ b/src/routes/sources.js @@ -21,7 +21,8 @@ function sourceRoutes() { '/:source/playlists', route(controller.getPlaylists), ) - // GET /sources/:source/channels/:userID/playlists - Get items for a playlist on the media source. + // GET /sources/:source/channels/:userID/playlists - Get items for a playlist on the media + // source. .get( '/:source/playlists/:playlistID/media', route(controller.getPlaylistItems), From 7e148fef900f1f5d0fd94a133b61c49bc24ee9f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9e=20Kooi?= Date: Wed, 1 Sep 2021 09:58:17 +0200 Subject: [PATCH 04/13] upd --- src/Source.js | 21 ++++++++++++++++----- src/routes/sources.js | 4 ++-- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/Source.js b/src/Source.js index e7642d5b..00762c86 100644 --- a/src/Source.js +++ b/src/Source.js @@ -91,7 +91,7 @@ class Source { } get apiVersion() { - return this.plugin.api || this.plugin.constructor.api || 1; + return this.plugin.api || 1; } /** @@ -115,11 +115,11 @@ class Source { * * @param {User} user * @param {string} id - * @returns {Promise} + * @returns {Promise} */ - getOne(user, id) { - return this.get(user, [id]) - .then((items) => items[0]); + async getOne(user, id) { + const [item] = await this.get(user, [id]); + return item; } /** @@ -164,6 +164,10 @@ class Source { /** * Get playlists for a specific user from this media source. + * + * @param {User} user + * @param {string} userID + * @returns {Promise} */ async getUserPlaylists(user, userID) { if (this.apiVersion < 3 || !this.plugin.getUserPlaylists) { @@ -177,6 +181,10 @@ class Source { /** * Get playlists for a specific user from this media source. + * + * @param {User} user + * @param {string} playlistID + * @returns {Promise} */ async getPlaylistItems(user, playlistID) { if (this.apiVersion < 3 || !this.plugin.getPlaylistItems) { @@ -203,6 +211,9 @@ class Source { throw new SourceNoImportError({ name: this.type }); } + /** + * @param {import('./Uwave')} uw + */ static async plugin(uw, { source: SourcePlugin, baseOptions = {} }) { debug('registering plugin', SourcePlugin); if (SourcePlugin.api == null || SourcePlugin.api < 3) { diff --git a/src/routes/sources.js b/src/routes/sources.js index 6f91ed46..fb305277 100644 --- a/src/routes/sources.js +++ b/src/routes/sources.js @@ -1,6 +1,6 @@ 'use strict'; -const router = require('router'); +const { Router } = require('express'); const route = require('../route'); const protect = require('../middleware/protect'); const schema = require('../middleware/schema'); @@ -8,7 +8,7 @@ const controller = require('../controllers/sources'); const validations = require('../validations'); function sourceRoutes() { - return router() + return Router() .use(protect()) // GET /sources/:source/search - Search for media in a single source. .get( From f8be1e9781d0d5fe8e7ca1068b4c89a5b10dbd09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9e=20Kooi?= Date: Tue, 12 Oct 2021 13:45:27 +0200 Subject: [PATCH 05/13] work on source typings --- src/Source.js | 146 ++++++++++++++++++++----------------- src/controllers/sources.js | 11 ++- 2 files changed, 91 insertions(+), 66 deletions(-) diff --git a/src/Source.js b/src/Source.js index 00762c86..f3f18a68 100644 --- a/src/Source.js +++ b/src/Source.js @@ -1,5 +1,6 @@ 'use strict'; +const has = require('has'); const debug = require('debug')('uwave:source'); const mergeAllOf = require('json-schema-merge-allof'); const { SourceNoImportError } = require('./errors'); @@ -27,7 +28,19 @@ const { SourceNoImportError } = require('./errors'); * ) => Promise} search * @prop {(context: ImportContext, ...args: unknown[]) => Promise} [import] * - * @typedef {SourcePluginV1 | SourcePluginV2} SourcePlugin + * @typedef {object} SourcePluginV3Statics + * @prop {3} api + * @prop {string} sourceName + * @prop {import('ajv').JSONSchemaType & { 'uw:key': string }} schema + * @typedef {object} SourcePluginV3Instance + * @prop {(context: SourceContext, ids: string[]) => Promise} get + * @prop {(context: SourceContext, query: string, page: unknown) => Promise} search + * @prop {(context: SourceContext, sourceID: string) => Promise} [getPlaylistItems] + * @prop {() => void} [close] + * @typedef {new(options: unknown) => SourcePluginV3Instance} SourcePluginV3Constructor + * @typedef {SourcePluginV3Constructor & SourcePluginV3Statics} SourcePluginV3 + * + * @typedef {SourcePluginV1 | SourcePluginV2 | SourcePluginV3Instance} SourcePlugin */ /** @@ -152,9 +165,10 @@ class Source { * @returns {Promise} */ async search(user, query, page, ...args) { + const context = new SourceContext(this.uw, this, user); + let results; if (this.plugin.api === 2) { - const context = new SourceContext(this.uw, this, user); results = await this.plugin.search(context, query, page, ...args); } else { results = await this.plugin.search(query, page, ...args); @@ -210,84 +224,86 @@ class Source { } throw new SourceNoImportError({ name: this.type }); } +} - /** - * @param {import('./Uwave')} uw - */ - static async plugin(uw, { source: SourcePlugin, baseOptions = {} }) { - debug('registering plugin', SourcePlugin); - if (SourcePlugin.api == null || SourcePlugin.api < 3) { - uw.source(SourcePlugin, baseOptions); - return; - } +/** + * @param {import('./Uwave')} uw + * @param {{ source: SourcePluginV3, baseOptions?: object }} options + */ +async function plugin(uw, { source: SourcePlugin, baseOptions = {} }) { + debug('registering plugin', SourcePlugin); + if (SourcePlugin.api !== 3) { + uw.source(SourcePlugin, baseOptions); + return; + } - if (!SourcePlugin.sourceName) { - throw new TypeError('Source plugin does not provide a `sourceName`'); - } + if (!SourcePlugin.sourceName) { + throw new TypeError('Source plugin does not provide a `sourceName`'); + } - async function readdSource(options) { - debug('adding plugin', options); - const { enabled, ...sourceOptions } = options; + async function readdSource(options) { + debug('adding plugin', options); + const { enabled, ...sourceOptions } = options; - const oldSource = uw.removeSourceInternal(SourcePlugin.sourceName); - if (oldSource && typeof oldSource.close === 'function') { - await oldSource.close(); - } + const oldSource = uw.removeSourceInternal(SourcePlugin.sourceName); + if (oldSource && has(oldSource, 'close') && typeof oldSource.close === 'function') { + await oldSource.close(); + } - if (enabled) { - const instance = new SourcePlugin({ - ...baseOptions, - ...sourceOptions, - }); + if (enabled) { + const instance = new SourcePlugin({ + ...baseOptions, + ...sourceOptions, + }); - const source = new Source(uw, SourcePlugin.sourceName, instance); - uw.insertSourceInternal(SourcePlugin.sourceName, source); - } + const source = new Source(uw, SourcePlugin.sourceName, instance); + uw.insertSourceInternal(SourcePlugin.sourceName, source); } + } - if (SourcePlugin.schema) { - if (!SourcePlugin.schema['uw:key']) { - throw new TypeError('Option schema for media source does not specify an "uw:key" value'); - } + if (SourcePlugin.schema) { + if (!SourcePlugin.schema['uw:key']) { + throw new TypeError('Option schema for media source does not specify an "uw:key" value'); + } - uw.config.register(SourcePlugin.schema['uw:key'], mergeAllOf({ - allOf: [ - { - type: 'object', - properties: { - enabled: { - type: 'boolean', - title: 'Enabled', - default: false, - }, + uw.config.register(SourcePlugin.schema['uw:key'], mergeAllOf({ + allOf: [ + { + type: 'object', + properties: { + enabled: { + type: 'boolean', + title: 'Enabled', + default: false, }, - required: ['enabled'], }, - SourcePlugin.schema, - ], - }, { deep: false })); - - const initialOptions = await uw.config.get(SourcePlugin.schema['uw:key']); - uw.config.on('set', (key, newOptions) => { - if (key === SourcePlugin.schema['uw:key']) { - readdSource(newOptions).catch((error) => { - if (uw.options.onError) { - uw.options.onError(error); - } else { - debug(error); - } - }); - } - }); + required: ['enabled'], + }, + SourcePlugin.schema, + ], + }, { deep: false })); - await readdSource(initialOptions); - } else { - // The source does not support options - await readdSource({}); - } + const initialOptions = await uw.config.get(SourcePlugin.schema['uw:key']); + uw.config.on('set', (key, newOptions) => { + if (key === SourcePlugin.schema['uw:key']) { + readdSource(newOptions).catch((error) => { + if (uw.options.onError) { + uw.options.onError(error); + } else { + debug(error); + } + }); + } + }); + + await readdSource(initialOptions); + } else { + // The source does not support options + await readdSource({}); } } exports.SourceContext = SourceContext; exports.ImportContext = ImportContext; exports.Source = Source; +exports.plugin = plugin; diff --git a/src/controllers/sources.js b/src/controllers/sources.js index 7ffd73f2..74d3f423 100644 --- a/src/controllers/sources.js +++ b/src/controllers/sources.js @@ -8,6 +8,9 @@ const { const searchController = require('./search'); const toListResponse = require('../utils/toListResponse'); +/** + * @param {import('../types').Request} req + */ function getImportableSource(req) { const uw = req.uwave; const { source: sourceName } = req.params; @@ -26,6 +29,9 @@ function getImportableSource(req) { return source; } +/** + * @type {import('../types').AuthenticatedController} + */ async function getPlaylists(req) { const source = getImportableSource(req); const { @@ -45,11 +51,14 @@ async function getPlaylists(req) { }); } +/** + * @type {import('../types').AuthenticatedController} + */ async function getPlaylistItems(req) { const source = getImportableSource(req); const { playlistID } = req.params; - const items = await source.getPlaylistItems(playlistID); + const items = await source.getPlaylistItems(req.user, playlistID); return toListResponse(items, { url: req.fullUrl, }); From 0fc24de01e5030d20c978253d7e677406845daa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9e=20Kooi?= Date: Tue, 12 Oct 2021 14:20:43 +0200 Subject: [PATCH 06/13] mh --- package.json | 4 + src/Uwave.js | 26 +-- src/source/ImportContext.js | 37 +++++ src/{ => source}/Source.js | 305 +++++++++++++++++------------------- src/source/SourceContext.js | 22 +++ src/source/index.js | 6 + src/source/index.mjs | 6 + src/source/plugin.js | 88 +++++++++++ 8 files changed, 320 insertions(+), 174 deletions(-) create mode 100644 src/source/ImportContext.js rename src/{ => source}/Source.js (50%) create mode 100644 src/source/SourceContext.js create mode 100644 src/source/index.js create mode 100644 src/source/index.mjs create mode 100644 src/source/plugin.js diff --git a/package.json b/package.json index 8638f13d..997b5704 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,10 @@ "import": "./src/index.mjs", "default": "./src/index.js" }, + "./source": { + "import": "./src/source/index.mjs", + "default": "./src/source/index.js" + }, "./package.json": "./package.json" }, "type": "commonjs", diff --git a/src/Uwave.js b/src/Uwave.js index d2c746ce..ff9949f2 100644 --- a/src/Uwave.js +++ b/src/Uwave.js @@ -9,7 +9,8 @@ const avvio = require('avvio'); const httpApi = require('./HttpApi'); const SocketServer = require('./SocketServer'); -const { Source } = require('./Source'); +const { LegacySourceWrapper } = require('./source/Source'); +const pluginifySource = require('./source/plugin'); const { i18n } = require('./locale'); const models = require('./models'); @@ -29,9 +30,9 @@ const migrations = require('./plugins/migrations'); const DEFAULT_MONGO_URL = 'mongodb://localhost:27017/uwave'; const DEFAULT_REDIS_URL = 'redis://localhost:6379'; -/** - * @typedef {import('./Source').SourcePlugin} SourcePlugin - */ +/** @typedef {import('./source/Source').SourcePluginV1} SourcePluginV1 */ +/** @typedef {import('./source/Source').SourcePluginV2} SourcePluginV2 */ +/** @typedef {import('./source/Source').SourceWrapper} SourceWrapper */ /** * @typedef {UwaveServer & avvio.Server} Boot @@ -119,7 +120,7 @@ class UwaveServer extends EventEmitter { socketServer; /** - * @type {Map} + * @type {Map} */ #sources = new Map(); @@ -207,7 +208,7 @@ class UwaveServer extends EventEmitter { // @ts-ignore const boot = this; - boot.use(Source.plugin, { + boot.use(pluginifySource, { source: sourcePlugin, baseOptions: opts, }); @@ -216,7 +217,7 @@ class UwaveServer extends EventEmitter { /** * An array of registered sources. * - * @type {Source[]} + * @type {SourceWrapper[]} */ get sources() { return [...this.#sources.values()]; @@ -227,8 +228,9 @@ class UwaveServer extends EventEmitter { * If the first parameter is a string, returns an existing source plugin. * Else, adds a source plugin and returns its wrapped source plugin. * - * @typedef {((uw: UwaveServer, opts: object) => SourcePlugin)} SourcePluginFactory - * @typedef {SourcePlugin | SourcePluginFactory} ToSourcePlugin + * @typedef {((uw: UwaveServer, opts: object) => SourcePluginV1 | SourcePluginV2)} + * SourcePluginFactory + * @typedef {SourcePluginV1 | SourcePluginV2 | SourcePluginFactory} ToSourcePlugin * * @param {string | Omit | { default: ToSourcePlugin }} sourcePlugin * Source name or definition. @@ -248,7 +250,7 @@ class UwaveServer extends EventEmitter { throw new TypeError(`Source plugin should be a function, got ${typeof sourceFactory}`); } - if (typeof sourceFactory === 'function' && has(sourceFactory, 'api') && sourceFactory.api >= 3) { + if (typeof sourceFactory === 'function' && sourceFactory.api >= 3) { throw new TypeError('uw.source() only supports old-style source plugins.'); } @@ -259,7 +261,7 @@ class UwaveServer extends EventEmitter { if (typeof sourceType !== 'string') { throw new TypeError('Source plugin does not specify a name. It may be incompatible with this version of üWave.'); } - const newSource = new Source(this, sourceType, sourceDefinition); + const newSource = new LegacySourceWrapper(this, sourceType, sourceDefinition); this.insertSourceInternal(sourceType, newSource); @@ -270,7 +272,7 @@ class UwaveServer extends EventEmitter { * Adds a fully wrapped source plugin. Not for external use. * * @param {string} sourceType - * @param {Source} source + * @param {SourceWrapper} source */ insertSourceInternal(sourceType, source) { this.#sources.set(sourceType, source); diff --git a/src/source/ImportContext.js b/src/source/ImportContext.js new file mode 100644 index 00000000..d2776315 --- /dev/null +++ b/src/source/ImportContext.js @@ -0,0 +1,37 @@ +'use strict'; + +const SourceContext = require('./SourceContext'); + +/** @typedef {import('../models').Playlist} Playlist */ +/** @typedef {import('../plugins/playlists').PlaylistItemDesc} PlaylistItemDesc */ + +/** + * Wrapper around playlist functions for use with import plugins. Intended to be + * temporary until more data manipulation stuff is moved into core from api-v1. + * + * This is legacy, media sources should use the methods provided by the + * `playlists` plugin instead. + */ +class ImportContext extends SourceContext { + /** + * Create a playlist for the current user. + * + * @param {string} name Playlist name. + * @param {Omit[]} itemOrItems Playlist items. + * @returns {Promise} Playlist model. + */ + async createPlaylist(name, itemOrItems) { + const playlist = await this.uw.playlists.createPlaylist(this.user, { name }); + + const rawItems = Array.isArray(itemOrItems) ? itemOrItems : [itemOrItems]; + const items = this.source.addSourceType(rawItems); + + if (items.length > 0) { + await this.uw.playlists.addPlaylistItems(playlist, items); + } + + return playlist; + } +} + +module.exports = ImportContext; diff --git a/src/Source.js b/src/source/Source.js similarity index 50% rename from src/Source.js rename to src/source/Source.js index f3f18a68..df5821f9 100644 --- a/src/Source.js +++ b/src/source/Source.js @@ -1,14 +1,23 @@ 'use strict'; const has = require('has'); -const debug = require('debug')('uwave:source'); -const mergeAllOf = require('json-schema-merge-allof'); -const { SourceNoImportError } = require('./errors'); +const { SourceNoImportError } = require('../errors'); +const SourceContext = require('./SourceContext'); +const ImportContext = require('./ImportContext'); + +/** @typedef {import('../Uwave')} Uwave */ +/** @typedef {import('../models').User} User */ +/** @typedef {import('../models').Playlist} Playlist */ +/** @typedef {import('../plugins/playlists').PlaylistItemDesc} PlaylistItemDesc */ /** - * @typedef {import('./models').User} User - * @typedef {import('./models').Playlist} Playlist - * @typedef {import('./plugins/playlists').PlaylistItemDesc} PlaylistItemDesc + * @typedef {object} SourceWrapper + * @prop {number} apiVersion + * @prop {(user: User, id: string) => Promise} getOne + * @prop {(user: User, ids: string[]) => Promise} get + * @prop {(user: User, query: string, page?: unknown) => Promise} search + * @prop {(user: User, userID: string) => Promise} getUserPlaylists + * @prop {(user: User, playlistID: string) => Promise} getPlaylistItems */ /** @@ -27,75 +36,16 @@ const { SourceNoImportError } = require('./errors'); * ...args: unknown[] * ) => Promise} search * @prop {(context: ImportContext, ...args: unknown[]) => Promise} [import] - * - * @typedef {object} SourcePluginV3Statics - * @prop {3} api - * @prop {string} sourceName - * @prop {import('ajv').JSONSchemaType & { 'uw:key': string }} schema - * @typedef {object} SourcePluginV3Instance - * @prop {(context: SourceContext, ids: string[]) => Promise} get - * @prop {(context: SourceContext, query: string, page: unknown) => Promise} search - * @prop {(context: SourceContext, sourceID: string) => Promise} [getPlaylistItems] - * @prop {() => void} [close] - * @typedef {new(options: unknown) => SourcePluginV3Instance} SourcePluginV3Constructor - * @typedef {SourcePluginV3Constructor & SourcePluginV3Statics} SourcePluginV3 - * - * @typedef {SourcePluginV1 | SourcePluginV2 | SourcePluginV3Instance} SourcePlugin */ -/** - * Data holder for things that source plugins may require. - */ -class SourceContext { - /** - * @param {import('./Uwave')} uw - * @param {Source} source - * @param {User} user - */ - constructor(uw, source, user) { - this.uw = uw; - this.source = source; - this.user = user; - } -} - -/** - * Wrapper around playlist functions for use with import plugins. Intended to be - * temporary until more data manipulation stuff is moved into core from api-v1. - * - * This is legacy, media sources should use the methods provided by the - * `playlists` plugin instead. - */ -class ImportContext extends SourceContext { - /** - * Create a playlist for the current user. - * - * @param {string} name Playlist name. - * @param {Omit[]} itemOrItems Playlist items. - * @returns {Promise} Playlist model. - */ - async createPlaylist(name, itemOrItems) { - const playlist = await this.uw.playlists.createPlaylist(this.user, { name }); - - const rawItems = Array.isArray(itemOrItems) ? itemOrItems : [itemOrItems]; - const items = this.source.addSourceType(rawItems); - - if (items.length > 0) { - await this.uw.playlists.addPlaylistItems(playlist, items); - } - - return playlist; - } -} - /** * Wrapper around source plugins with some more convenient aliases. */ -class Source { +class LegacySourceWrapper { /** - * @param {import('./Uwave')} uw + * @param {Uwave} uw * @param {string} sourceType - * @param {SourcePlugin} sourcePlugin + * @param {SourcePluginV1 | SourcePluginV2} sourcePlugin */ constructor(uw, sourceType, sourcePlugin) { this.uw = uw; @@ -157,56 +107,35 @@ class Source { * Search this media source for items. Parameters can really be anything, but * will usually include a search string `query` and a page identifier `page`. * - * @template {object} TPagination * @param {User} user * @param {string} query - * @param {TPagination} [page] - * @param {unknown[]} args + * @param {unknown} [page] * @returns {Promise} */ - async search(user, query, page, ...args) { + async search(user, query, page) { const context = new SourceContext(this.uw, this, user); let results; if (this.plugin.api === 2) { - results = await this.plugin.search(context, query, page, ...args); + results = await this.plugin.search(context, query, page); } else { - results = await this.plugin.search(query, page, ...args); + results = await this.plugin.search(query, page); } return this.addSourceType(results); } /** - * Get playlists for a specific user from this media source. - * - * @param {User} user - * @param {string} userID - * @returns {Promise} + * Unsupported for legacy sources. */ - async getUserPlaylists(user, userID) { - if (this.apiVersion < 3 || !this.plugin.getUserPlaylists) { - throw new SourceNoImportError({ name: this.type }); - } - - const context = new SourceContext(this.uw, this, user); - - return this.plugin.getUserPlaylists(context, userID); + async getUserPlaylists() { + throw new SourceNoImportError({ name: this.type }); } /** - * Get playlists for a specific user from this media source. - * - * @param {User} user - * @param {string} playlistID - * @returns {Promise} + * Unsupported for legacy sources. */ - async getPlaylistItems(user, playlistID) { - if (this.apiVersion < 3 || !this.plugin.getPlaylistItems) { - throw new SourceNoImportError({ name: this.type }); - } - - const context = new SourceContext(this.uw, this, user); - return this.plugin.getPlaylistItems(context, playlistID); + async getPlaylistItems() { + throw new SourceNoImportError({ name: this.type }); } /** @@ -227,83 +156,135 @@ class Source { } /** - * @param {import('./Uwave')} uw - * @param {{ source: SourcePluginV3, baseOptions?: object }} options + * @typedef {object} SourcePluginV3Statics + * @prop {3} api + * @prop {string} sourceName + * @prop {import('ajv').JSONSchemaType & { 'uw:key': string }} schema + * @typedef {object} SourcePluginV3Instance + * @prop {(context: SourceContext, ids: string[]) => Promise} get + * @prop {(context: SourceContext, query: string, page: unknown) => Promise} + * search + * @prop {(context: SourceContext, userID: string) => Promise} [getUserPlaylists] + * @prop {(context: SourceContext, sourceID: string) => Promise} + * [getPlaylistItems] + * @prop {() => void} [close] + * @typedef {new(options: unknown) => SourcePluginV3Instance} SourcePluginV3Constructor + * @typedef {SourcePluginV3Constructor & SourcePluginV3Statics} SourcePluginV3 */ -async function plugin(uw, { source: SourcePlugin, baseOptions = {} }) { - debug('registering plugin', SourcePlugin); - if (SourcePlugin.api !== 3) { - uw.source(SourcePlugin, baseOptions); - return; + +class ModernSourceWrapper { + /** + * @param {Uwave} uw + * @param {string} sourceType + * @param {SourcePluginV3Instance} sourcePlugin + */ + constructor(uw, sourceType, sourcePlugin) { + this.uw = uw; + this.type = sourceType; + this.plugin = sourcePlugin; } - if (!SourcePlugin.sourceName) { - throw new TypeError('Source plugin does not provide a `sourceName`'); + // eslint-disable-next-line class-methods-use-this + get apiVersion() { + // Can pass this number in through the constructor in the future. + return 3; } - async function readdSource(options) { - debug('adding plugin', options); - const { enabled, ...sourceOptions } = options; + /** + * Add a default sourceType property to a list of media items. + * + * Media items can provide their own sourceType, too, so media sources can + * aggregate items from different source types. + * + * @param {Omit[]} items + * @returns {PlaylistItemDesc[]} + * @private + */ + addSourceType(items) { + return items.map((item) => ({ + sourceType: this.type, + ...item, + })); + } - const oldSource = uw.removeSourceInternal(SourcePlugin.sourceName); - if (oldSource && has(oldSource, 'close') && typeof oldSource.close === 'function') { - await oldSource.close(); - } + /** + * Find a single media item by ID. + * + * @param {User} user + * @param {string} id + * @returns {Promise} + */ + async getOne(user, id) { + const [item] = await this.get(user, [id]); + return item; + } - if (enabled) { - const instance = new SourcePlugin({ - ...baseOptions, - ...sourceOptions, - }); + /** + * Find several media items by ID. + * + * @param {User} user + * @param {string[]} ids + * @returns {Promise} + */ + async get(user, ids) { + const context = new SourceContext(this.uw, this, user); + const items = await this.plugin.get(context, ids); + return this.addSourceType(items); + } - const source = new Source(uw, SourcePlugin.sourceName, instance); - uw.insertSourceInternal(SourcePlugin.sourceName, source); - } + /** + * Search this media source for items. Parameters can really be anything, but + * will usually include a search string `query` and a page identifier `page`. + * + * @param {User} user + * @param {string} query + * @param {unknown} [page] + * @returns {Promise} + */ + async search(user, query, page) { + const context = new SourceContext(this.uw, this, user); + + const results = await this.plugin.search(context, query, page); + return this.addSourceType(results); } - if (SourcePlugin.schema) { - if (!SourcePlugin.schema['uw:key']) { - throw new TypeError('Option schema for media source does not specify an "uw:key" value'); + /** + * Get playlists for a specific user from this media source. + * + * @param {User} user + * @param {string} userID + */ + async getUserPlaylists(user, userID) { + if (!has(this.plugin, 'getUserPlaylists')) { + throw new SourceNoImportError({ name: this.type }); } - uw.config.register(SourcePlugin.schema['uw:key'], mergeAllOf({ - allOf: [ - { - type: 'object', - properties: { - enabled: { - type: 'boolean', - title: 'Enabled', - default: false, - }, - }, - required: ['enabled'], - }, - SourcePlugin.schema, - ], - }, { deep: false })); + const context = new SourceContext(this.uw, this, user); + return this.plugin.getUserPlaylists(context, userID); + } - const initialOptions = await uw.config.get(SourcePlugin.schema['uw:key']); - uw.config.on('set', (key, newOptions) => { - if (key === SourcePlugin.schema['uw:key']) { - readdSource(newOptions).catch((error) => { - if (uw.options.onError) { - uw.options.onError(error); - } else { - debug(error); - } - }); - } - }); + /** + * Get playlists for a specific user from this media source. + * + * @param {User} user + * @param {string} playlistID + */ + async getPlaylistItems(user, playlistID) { + if (!has(this.plugin, 'getPlaylistItems')) { + throw new SourceNoImportError({ name: this.type }); + } - await readdSource(initialOptions); - } else { - // The source does not support options - await readdSource({}); + const context = new SourceContext(this.uw, this, user); + return this.plugin.getPlaylistItems(context, playlistID); + } + + /** + * Unsupported for modern media sources. + */ + 'import'() { + throw new SourceNoImportError({ name: this.type }); } } -exports.SourceContext = SourceContext; -exports.ImportContext = ImportContext; -exports.Source = Source; -exports.plugin = plugin; +exports.LegacySourceWrapper = LegacySourceWrapper; +exports.ModernSourceWrapper = ModernSourceWrapper; diff --git a/src/source/SourceContext.js b/src/source/SourceContext.js new file mode 100644 index 00000000..c7daefd5 --- /dev/null +++ b/src/source/SourceContext.js @@ -0,0 +1,22 @@ +'use strict'; + +/** @typedef {import('../Uwave')} Uwave */ +/** @typedef {import('../models').User} User */ + +/** + * Data holder for things that source plugins may require. + */ +class SourceContext { + /** + * @param {Uwave} uw + * @param {Source} source + * @param {User} user + */ + constructor(uw, source, user) { + this.uw = uw; + this.source = source; + this.user = user; + } +} + +module.exports = SourceContext; diff --git a/src/source/index.js b/src/source/index.js new file mode 100644 index 00000000..5d8d4f22 --- /dev/null +++ b/src/source/index.js @@ -0,0 +1,6 @@ +'use strict'; + +/** @typedef {import('./Source').SourcePluginV3} SourcePluginV3} */ + +exports.SourceContext = require('./SourceContext'); +exports.plugin = require('./plugin'); diff --git a/src/source/index.mjs b/src/source/index.mjs new file mode 100644 index 00000000..e25c7a70 --- /dev/null +++ b/src/source/index.mjs @@ -0,0 +1,6 @@ +import SourceContext from './SourceContext.js'; +import plugin from './plugin.js'; + +/** @typedef {import('./Source').SourcePluginV3} SourcePluginV3} */ + +export { SourceContext, plugin }; diff --git a/src/source/plugin.js b/src/source/plugin.js new file mode 100644 index 00000000..a167cd7b --- /dev/null +++ b/src/source/plugin.js @@ -0,0 +1,88 @@ +'use strict'; + +const has = require('has'); +const debug = require('debug')('uwave:source'); +const mergeAllOf = require('json-schema-merge-allof'); +const { ModernSourceWrapper } = require('./Source'); + +/** @typedef {import('../Uwave')} Uwave} */ +/** @typedef {import('./Source').SourcePluginV3} SourcePluginV3} */ + +/** + * @param {Uwave} uw + * @param {{ source: SourcePluginV3, baseOptions?: object }} options + */ +async function plugin(uw, { source: SourcePlugin, baseOptions = {} }) { + debug('registering plugin', SourcePlugin); + if (SourcePlugin.api !== 3) { + uw.source(SourcePlugin, baseOptions); + return; + } + + if (!SourcePlugin.sourceName) { + throw new TypeError('Source plugin does not provide a `sourceName`'); + } + + async function readdSource(options) { + debug('adding plugin', options); + const { enabled, ...sourceOptions } = options; + + const oldSource = uw.removeSourceInternal(SourcePlugin.sourceName); + if (oldSource && has(oldSource, 'close') && typeof oldSource.close === 'function') { + await oldSource.close(); + } + + if (enabled) { + const instance = new SourcePlugin({ + ...baseOptions, + ...sourceOptions, + }); + + const source = new ModernSourceWrapper(uw, SourcePlugin.sourceName, instance); + uw.insertSourceInternal(SourcePlugin.sourceName, source); + } + } + + if (SourcePlugin.schema) { + if (!SourcePlugin.schema['uw:key']) { + throw new TypeError('Option schema for media source does not specify an "uw:key" value'); + } + + uw.config.register(SourcePlugin.schema['uw:key'], mergeAllOf({ + allOf: [ + { + type: 'object', + properties: { + enabled: { + type: 'boolean', + title: 'Enabled', + default: false, + }, + }, + required: ['enabled'], + }, + SourcePlugin.schema, + ], + }, { deep: false })); + + const initialOptions = await uw.config.get(SourcePlugin.schema['uw:key']); + uw.config.on('set', (key, newOptions) => { + if (key === SourcePlugin.schema['uw:key']) { + readdSource(newOptions).catch((error) => { + if (uw.options.onError) { + uw.options.onError(error); + } else { + debug(error); + } + }); + } + }); + + await readdSource(initialOptions); + } else { + // The source does not support options + await readdSource({}); + } +} + +module.exports = plugin; From b214f2f3def34643360de0f15ac412e523200563 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9e=20Kooi?= Date: Tue, 2 Nov 2021 11:27:15 +0100 Subject: [PATCH 07/13] fix sources test --- test/sources.mjs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/sources.mjs b/test/sources.mjs index 68743985..a87a1571 100644 --- a/test/sources.mjs +++ b/test/sources.mjs @@ -1,7 +1,7 @@ import assert from 'assert'; import supertest from 'supertest'; import sinon from 'sinon'; -import { Source } from '../src/Source.js'; +import { LegacySourceWrapper } from '../src/source/Source.js'; import createUwave from './utils/createUwave.mjs'; describe('Media Sources', () => { @@ -35,12 +35,12 @@ describe('Media Sources', () => { it('should register sources from objects', () => { uw.source(testSourceObject); - assert(uw.source('test-source') instanceof Source); + assert(uw.source('test-source') instanceof LegacySourceWrapper); assert.strictEqual(uw.source('test-source').apiVersion, 1); }); it('should register sources from a factory function', () => { uw.source(testSource); - assert(uw.source('test-source') instanceof Source); + assert(uw.source('test-source') instanceof LegacySourceWrapper); assert.strictEqual(uw.source('test-source').apiVersion, 1); }); From 80dd76cc5e978a43f322d6eae31820ea9b4ccb83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9e=20Kooi?= Date: Tue, 2 Nov 2021 12:28:25 +0100 Subject: [PATCH 08/13] some progress on the types --- src/Uwave.js | 8 ++- src/controllers/import.js | 3 +- src/controllers/search.js | 14 +++--- src/controllers/sources.js | 13 ++--- src/json-schema-merge-allof.d.ts | 7 +++ src/source/ImportContext.js | 5 +- src/source/Source.js | 86 ++++++++++++-------------------- src/source/SourceContext.js | 3 +- src/source/index.js | 2 - src/source/plugin.js | 38 ++++++++++++-- src/source/types.d.ts | 45 +++++++++++++++++ src/utils/toPaginatedResponse.js | 24 ++++++--- tsconfig.json | 2 + 13 files changed, 160 insertions(+), 90 deletions(-) create mode 100644 src/json-schema-merge-allof.d.ts create mode 100644 src/source/types.d.ts diff --git a/src/Uwave.js b/src/Uwave.js index ff9949f2..86426769 100644 --- a/src/Uwave.js +++ b/src/Uwave.js @@ -30,8 +30,7 @@ const migrations = require('./plugins/migrations'); const DEFAULT_MONGO_URL = 'mongodb://localhost:27017/uwave'; const DEFAULT_REDIS_URL = 'redis://localhost:6379'; -/** @typedef {import('./source/Source').SourcePluginV1} SourcePluginV1 */ -/** @typedef {import('./source/Source').SourcePluginV2} SourcePluginV2 */ +/** @typedef {import('./source/types').StaticSourcePlugin} StaticSourcePlugin */ /** @typedef {import('./source/Source').SourceWrapper} SourceWrapper */ /** @@ -228,9 +227,8 @@ class UwaveServer extends EventEmitter { * If the first parameter is a string, returns an existing source plugin. * Else, adds a source plugin and returns its wrapped source plugin. * - * @typedef {((uw: UwaveServer, opts: object) => SourcePluginV1 | SourcePluginV2)} - * SourcePluginFactory - * @typedef {SourcePluginV1 | SourcePluginV2 | SourcePluginFactory} ToSourcePlugin + * @typedef {(uw: UwaveServer, opts: object) => StaticSourcePlugin} SourcePluginFactory + * @typedef {StaticSourcePlugin | SourcePluginFactory} ToSourcePlugin * * @param {string | Omit | { default: ToSourcePlugin }} sourcePlugin * Source name or definition. diff --git a/src/controllers/import.js b/src/controllers/import.js index 1fb087f0..790b54d8 100644 --- a/src/controllers/import.js +++ b/src/controllers/import.js @@ -1,5 +1,6 @@ 'use strict'; +const has = require('has'); const { SourceNotFoundError, SourceNoImportError, @@ -17,7 +18,7 @@ const getImportableSource = (req) => { if (!source) { throw new SourceNotFoundError({ name: sourceName }); } - if (!source.import) { + if (!has(source, 'import')) { throw new SourceNoImportError({ name: sourceName }); } diff --git a/src/controllers/search.js b/src/controllers/search.js index 7e15d40f..6e6046f4 100644 --- a/src/controllers/search.js +++ b/src/controllers/search.js @@ -3,7 +3,7 @@ const debug = require('debug')('uwave:http:search'); const { isEqual } = require('lodash'); const { SourceNotFoundError } = require('../errors'); -const toListResponse = require('../utils/toListResponse'); +const toPaginatedResponse = require('../utils/toPaginatedResponse'); // TODO should be deprecated once the Web client uses the better single-source route. /** @@ -18,7 +18,6 @@ async function searchAll(req) { source.search(user, query).catch((error) => { debug(error); // Default to empty search on failure, for now. - return []; }) )); @@ -27,7 +26,8 @@ async function searchAll(req) { /** @type {Record} */ const combinedResults = {}; sourceNames.forEach((name, index) => { - combinedResults[name] = searchResults[index]; + const searchResultsForSource = searchResults[index]; + combinedResults[name] = searchResultsForSource ? searchResultsForSource.data : []; }); return combinedResults; @@ -72,7 +72,7 @@ async function search(req) { const searchResults = await source.search(user, query); const searchResultsByID = new Map(); - searchResults.forEach((result) => { + searchResults.data.forEach((result) => { searchResultsByID.set(result.sourceID, result); }); @@ -103,7 +103,7 @@ async function search(req) { { author: user._id }, ); - searchResults.forEach((result) => { + searchResults.data.forEach((result) => { const media = mediaBySourceID.get(String(result.sourceID)); if (media) { // @ts-ignore @@ -116,8 +116,8 @@ async function search(req) { debug('sourceData update failed', error); }); - return toListResponse(searchResults, { - url: req.fullUrl, + return toPaginatedResponse(searchResults, { + baseUrl: req.fullUrl, included: { playlists: ['inPlaylists'], }, diff --git a/src/controllers/sources.js b/src/controllers/sources.js index 74d3f423..e8b6d435 100644 --- a/src/controllers/sources.js +++ b/src/controllers/sources.js @@ -1,12 +1,13 @@ 'use strict'; +const has = require('has'); const { BadRequest } = require('http-errors'); const { SourceNotFoundError, SourceNoImportError, } = require('../errors'); const searchController = require('./search'); -const toListResponse = require('../utils/toListResponse'); +const toPaginatedResponse = require('../utils/toPaginatedResponse'); /** * @param {import('../types').Request} req @@ -19,7 +20,7 @@ function getImportableSource(req) { if (!source) { throw new SourceNotFoundError({ name: sourceName }); } - if (!source.import) { + if (has(source, 'import')) { throw new SourceNoImportError({ name: sourceName }); } if (source.apiVersion < 3) { @@ -46,8 +47,8 @@ async function getPlaylists(req) { throw new BadRequest('No playlist filter provided'); } - return toListResponse(items, { - url: req.fullUrl, + return toPaginatedResponse(items, { + baseUrl: req.fullUrl, }); } @@ -59,8 +60,8 @@ async function getPlaylistItems(req) { const { playlistID } = req.params; const items = await source.getPlaylistItems(req.user, playlistID); - return toListResponse(items, { - url: req.fullUrl, + return toPaginatedResponse(items, { + baseUrl: req.fullUrl, }); } diff --git a/src/json-schema-merge-allof.d.ts b/src/json-schema-merge-allof.d.ts new file mode 100644 index 00000000..daf3815b --- /dev/null +++ b/src/json-schema-merge-allof.d.ts @@ -0,0 +1,7 @@ +declare module 'json-schema-merge-allof' { + import { JsonSchemaType } from 'ajv'; + + type Options = { deep: boolean }; + declare function jsonSchemaMergeAllOf(schema: JsonSchemaType, options?: Partial); + export = jsonSchemaMergeAllOf; +} diff --git a/src/source/ImportContext.js b/src/source/ImportContext.js index d2776315..f90d88dd 100644 --- a/src/source/ImportContext.js +++ b/src/source/ImportContext.js @@ -24,7 +24,10 @@ class ImportContext extends SourceContext { const playlist = await this.uw.playlists.createPlaylist(this.user, { name }); const rawItems = Array.isArray(itemOrItems) ? itemOrItems : [itemOrItems]; - const items = this.source.addSourceType(rawItems); + const items = rawItems.map((item) => ({ + ...item, + sourceType: this.source.type, + })); if (items.length > 0) { await this.uw.playlists.addPlaylistItems(playlist, items); diff --git a/src/source/Source.js b/src/source/Source.js index df5821f9..3fe3ea3a 100644 --- a/src/source/Source.js +++ b/src/source/Source.js @@ -4,48 +4,23 @@ const has = require('has'); const { SourceNoImportError } = require('../errors'); const SourceContext = require('./SourceContext'); const ImportContext = require('./ImportContext'); +const Page = require('../Page'); /** @typedef {import('../Uwave')} Uwave */ /** @typedef {import('../models').User} User */ /** @typedef {import('../models').Playlist} Playlist */ /** @typedef {import('../plugins/playlists').PlaylistItemDesc} PlaylistItemDesc */ - -/** - * @typedef {object} SourceWrapper - * @prop {number} apiVersion - * @prop {(user: User, id: string) => Promise} getOne - * @prop {(user: User, ids: string[]) => Promise} get - * @prop {(user: User, query: string, page?: unknown) => Promise} search - * @prop {(user: User, userID: string) => Promise} getUserPlaylists - * @prop {(user: User, playlistID: string) => Promise} getPlaylistItems - */ - -/** - * @typedef {object} SourcePluginV1 - * @prop {undefined|1} api - * @prop {(ids: string[]) => Promise} get - * @prop {(query: string, page: unknown, ...args: unknown[]) => Promise} search - * - * @typedef {object} SourcePluginV2 - * @prop {2} api - * @prop {(context: SourceContext, ids: string[]) => Promise} get - * @prop {( - * context: SourceContext, - * query: string, - * page: unknown, - * ...args: unknown[] - * ) => Promise} search - * @prop {(context: ImportContext, ...args: unknown[]) => Promise} [import] - */ +/** @typedef {import('./types').SourceWrapper} SourceWrapper */ /** * Wrapper around source plugins with some more convenient aliases. + * @implements {SourceWrapper} */ class LegacySourceWrapper { /** * @param {Uwave} uw * @param {string} sourceType - * @param {SourcePluginV1 | SourcePluginV2} sourcePlugin + * @param {import('./types').StaticSourcePlugin} sourcePlugin */ constructor(uw, sourceType, sourcePlugin) { this.uw = uw; @@ -109,32 +84,44 @@ class LegacySourceWrapper { * * @param {User} user * @param {string} query - * @param {unknown} [page] - * @returns {Promise} + * @param {import('type-fest').JsonValue} [page] + * @returns {Promise>} */ async search(user, query, page) { const context = new SourceContext(this.uw, this, user); + /** @type {PlaylistItemDesc[] | undefined} */ let results; if (this.plugin.api === 2) { results = await this.plugin.search(context, query, page); } else { results = await this.plugin.search(query, page); } - return this.addSourceType(results); + + return new Page(this.addSourceType(results), { + current: page ?? null, + }); } /** * Unsupported for legacy sources. + * @param {User} user + * @param {string} userID + * @returns {Promise>} */ - async getUserPlaylists() { + // eslint-disable-next-line no-unused-vars + async getUserPlaylists(user, userID) { throw new SourceNoImportError({ name: this.type }); } /** * Unsupported for legacy sources. + * @param {User} user + * @param {string} playlistID + * @returns {Promise>} */ - async getPlaylistItems() { + // eslint-disable-next-line no-unused-vars + async getPlaylistItems(user, playlistID) { throw new SourceNoImportError({ name: this.type }); } @@ -156,27 +143,13 @@ class LegacySourceWrapper { } /** - * @typedef {object} SourcePluginV3Statics - * @prop {3} api - * @prop {string} sourceName - * @prop {import('ajv').JSONSchemaType & { 'uw:key': string }} schema - * @typedef {object} SourcePluginV3Instance - * @prop {(context: SourceContext, ids: string[]) => Promise} get - * @prop {(context: SourceContext, query: string, page: unknown) => Promise} - * search - * @prop {(context: SourceContext, userID: string) => Promise} [getUserPlaylists] - * @prop {(context: SourceContext, sourceID: string) => Promise} - * [getPlaylistItems] - * @prop {() => void} [close] - * @typedef {new(options: unknown) => SourcePluginV3Instance} SourcePluginV3Constructor - * @typedef {SourcePluginV3Constructor & SourcePluginV3Statics} SourcePluginV3 + * @implements {SourceWrapper} */ - class ModernSourceWrapper { /** * @param {Uwave} uw * @param {string} sourceType - * @param {SourcePluginV3Instance} sourcePlugin + * @param {import('./types').SourcePluginV3Instance} sourcePlugin */ constructor(uw, sourceType, sourcePlugin) { this.uw = uw; @@ -238,14 +211,15 @@ class ModernSourceWrapper { * * @param {User} user * @param {string} query - * @param {unknown} [page] - * @returns {Promise} + * @param {import('type-fest').JsonValue} [page] + * @returns {Promise>} */ async search(user, query, page) { const context = new SourceContext(this.uw, this, user); const results = await this.plugin.search(context, query, page); - return this.addSourceType(results); + results.data = this.addSourceType(results.data); + return results; } /** @@ -253,9 +227,10 @@ class ModernSourceWrapper { * * @param {User} user * @param {string} userID + * @returns {Promise>} */ async getUserPlaylists(user, userID) { - if (!has(this.plugin, 'getUserPlaylists')) { + if (!has(this.plugin, 'getUserPlaylists') || this.plugin.getUserPlaylists == null) { throw new SourceNoImportError({ name: this.type }); } @@ -268,9 +243,10 @@ class ModernSourceWrapper { * * @param {User} user * @param {string} playlistID + * @returns {Promise>} */ async getPlaylistItems(user, playlistID) { - if (!has(this.plugin, 'getPlaylistItems')) { + if (!has(this.plugin, 'getPlaylistItems') || this.plugin.getPlaylistItems == null) { throw new SourceNoImportError({ name: this.type }); } diff --git a/src/source/SourceContext.js b/src/source/SourceContext.js index c7daefd5..e2b410b9 100644 --- a/src/source/SourceContext.js +++ b/src/source/SourceContext.js @@ -2,6 +2,7 @@ /** @typedef {import('../Uwave')} Uwave */ /** @typedef {import('../models').User} User */ +/** @typedef {import('./types').SourceWrapper} SourceWrapper */ /** * Data holder for things that source plugins may require. @@ -9,7 +10,7 @@ class SourceContext { /** * @param {Uwave} uw - * @param {Source} source + * @param {SourceWrapper} source * @param {User} user */ constructor(uw, source, user) { diff --git a/src/source/index.js b/src/source/index.js index 5d8d4f22..d14f4c81 100644 --- a/src/source/index.js +++ b/src/source/index.js @@ -1,6 +1,4 @@ 'use strict'; -/** @typedef {import('./Source').SourcePluginV3} SourcePluginV3} */ - exports.SourceContext = require('./SourceContext'); exports.plugin = require('./plugin'); diff --git a/src/source/plugin.js b/src/source/plugin.js index a167cd7b..a6925374 100644 --- a/src/source/plugin.js +++ b/src/source/plugin.js @@ -6,16 +6,23 @@ const mergeAllOf = require('json-schema-merge-allof'); const { ModernSourceWrapper } = require('./Source'); /** @typedef {import('../Uwave')} Uwave} */ -/** @typedef {import('./Source').SourcePluginV3} SourcePluginV3} */ +/** + * @template TOptions + * @template {import('type-fest').JsonValue} TPagination + * @typedef {import('./types').HotSwappableSourcePlugin} HotSwappableSourcePlugin + */ /** + * @template TOptions + * @template {import('type-fest').JsonValue} TPagination * @param {Uwave} uw - * @param {{ source: SourcePluginV3, baseOptions?: object }} options + * @param {{ source: HotSwappableSourcePlugin, baseOptions?: TOptions }} + * options */ -async function plugin(uw, { source: SourcePlugin, baseOptions = {} }) { +async function plugin(uw, { source: SourcePlugin, baseOptions }) { debug('registering plugin', SourcePlugin); if (SourcePlugin.api !== 3) { - uw.source(SourcePlugin, baseOptions); + uw.source(SourcePlugin, baseOptions ?? {}); return; } @@ -23,9 +30,23 @@ async function plugin(uw, { source: SourcePlugin, baseOptions = {} }) { throw new TypeError('Source plugin does not provide a `sourceName`'); } + /** + * This function is used to tell the compiler that something is of the TOptions shape. + * This will be safe if we ensure that source options never contain an `enabled` property… + * + * @param {unknown} options + * @returns {asserts options is TOptions} + */ + // eslint-disable-next-line no-unused-vars + function forceTOptions(options) {} + + /** + * @param {TOptions & { enabled: boolean }} options + */ async function readdSource(options) { debug('adding plugin', options); const { enabled, ...sourceOptions } = options; + forceTOptions(sourceOptions); const oldSource = uw.removeSourceInternal(SourcePlugin.sourceName); if (oldSource && has(oldSource, 'close') && typeof oldSource.close === 'function') { @@ -65,7 +86,9 @@ async function plugin(uw, { source: SourcePlugin, baseOptions = {} }) { ], }, { deep: false })); - const initialOptions = await uw.config.get(SourcePlugin.schema['uw:key']); + // NOTE this is wrong if the schema changes between versions :/ + /** @type {TOptions | undefined} */ + const initialOptions = (/** @type {unknown} */ await uw.config.get(SourcePlugin.schema['uw:key'])); uw.config.on('set', (key, newOptions) => { if (key === SourcePlugin.schema['uw:key']) { readdSource(newOptions).catch((error) => { @@ -78,9 +101,14 @@ async function plugin(uw, { source: SourcePlugin, baseOptions = {} }) { } }); + // TODO(goto-bus-stop) correctly type the `undefined` case + // @ts-ignore await readdSource(initialOptions); } else { // The source does not support options + // TODO(goto-bus-stop) we still need to support enabling/disabling the source here, so this + // probably can just use the same code path as above. + // @ts-ignore await readdSource({}); } } diff --git a/src/source/types.d.ts b/src/source/types.d.ts new file mode 100644 index 00000000..c9350b96 --- /dev/null +++ b/src/source/types.d.ts @@ -0,0 +1,45 @@ +import { JsonValue } from 'type-fest'; +import { PlaylistItemDesc } from '../plugins/playlists'; +import Page from '../Page'; +import SourceContext from './SourceContext'; +import ImportContext from './ImportContext'; + +export interface SourcePluginV1 { + api?: 1; + get(ids: string[]): Promise; + search(query: string, page: unknown, ...args: unknown[]): Promise; +} + +export interface SourcePluginV2 { + api: 2; + get(context: SourceContext, ids: string[]): Promise; + search(context: SourceContext, query: string, page: unknown, ...args: unknown[]): Promise; + import?(context: ImportContext, ...args: unknown[]): Promise; +} + +export interface SourcePluginV3Instance { + get(context: SourceContext, ids: string[]): Promise; + search(context: SourceContext, query: string, page?: JsonValue): Promise>; + getUserPlaylists?(context: SourceContext, userID: string, page?: JsonValue): Promise>; + getPlaylistItems?(context: SourceContext, sourceID: string, page?: JsonValue): Promise> +} + +export interface SourcePluginV3Statics { + api: 3; + sourceName: string; + schema: JSONSchemaType & { 'uw:key': string }; + new(options: TOptions): SourcePluginV3Instance; +} + +export type StaticSourcePlugin = SourcePluginV1 | SourcePluginV2; +export type HotSwappableSourcePlugin = SourcePluginV3Statics; + +export interface SourceWrapper { + readonly apiVersion: number; + readonly type: string; + getOne(user: User, id: string): Promise; + get(user: User, ids: string[]): Promise; + search(user: User, query: string, page?: JsonValue): Promise>; + getUserPlaylists(user: User, userID: string): Promise>; + getPlaylistItems(user: User, playlistID: string): Promise>; +} diff --git a/src/utils/toPaginatedResponse.js b/src/utils/toPaginatedResponse.js index f36938a5..14fcce5e 100644 --- a/src/utils/toPaginatedResponse.js +++ b/src/utils/toPaginatedResponse.js @@ -1,6 +1,7 @@ 'use strict'; const url = require('url'); +const has = require('has'); const qs = require('qs'); const toListResponse = require('./toListResponse'); @@ -22,7 +23,7 @@ function appendQuery(base, query) { /** * @template {any} TItem - * @template {{ offset: number }} TPagination + * @template {import('type-fest').JsonValue} TPagination * @param {import('../Page')} page * @param {{ baseUrl?: string, included?: toListResponse.IncludedOptions }} options */ @@ -30,14 +31,23 @@ function toPaginatedResponse( page, { baseUrl = '', included } = {}, ) { + /** @type {import('type-fest').JsonObject} */ + const meta = { + pageSize: page.pageSize, + results: page.filteredSize, + total: page.totalSize, + }; + + if (page.currentPage + && typeof page.currentPage === 'object' + && has(page.currentPage, 'offset') + && typeof page.currentPage.offset === 'number') { + meta.offset = page.currentPage.offset; + } + return Object.assign(toListResponse(page.data, { included, - meta: { - offset: page.currentPage.offset, - pageSize: page.pageSize, - results: page.filteredSize, - total: page.totalSize, - }, + meta, }), { links: { self: appendQuery(baseUrl, { page: page.currentPage }), diff --git a/tsconfig.json b/tsconfig.json index c347c8df..343d13df 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,8 +11,10 @@ "emitDeclarationOnly": true }, "include": [ + "src/json-schema-merge-allof.d.ts", "src/types.d.ts", "src/redisMessages.d.ts", + "src/source/types.d.ts", "src/**/*.js", "src/schemas/*.json" ] From 6133ce1bc3b603cc9b076466247dec6a8f4e9614 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9e=20Kooi?= Date: Tue, 2 Nov 2021 13:04:25 +0100 Subject: [PATCH 09/13] fix source version detection in legacy/modern imports --- src/controllers/import.js | 3 +-- src/controllers/sources.js | 4 ---- src/source/Source.js | 25 +++++++++++-------------- src/source/types.d.ts | 1 + 4 files changed, 13 insertions(+), 20 deletions(-) diff --git a/src/controllers/import.js b/src/controllers/import.js index 790b54d8..e7f59081 100644 --- a/src/controllers/import.js +++ b/src/controllers/import.js @@ -1,6 +1,5 @@ 'use strict'; -const has = require('has'); const { SourceNotFoundError, SourceNoImportError, @@ -18,7 +17,7 @@ const getImportableSource = (req) => { if (!source) { throw new SourceNotFoundError({ name: sourceName }); } - if (!has(source, 'import')) { + if (source.apiVersion >= 3) { throw new SourceNoImportError({ name: sourceName }); } diff --git a/src/controllers/sources.js b/src/controllers/sources.js index e8b6d435..b52b3175 100644 --- a/src/controllers/sources.js +++ b/src/controllers/sources.js @@ -1,6 +1,5 @@ 'use strict'; -const has = require('has'); const { BadRequest } = require('http-errors'); const { SourceNotFoundError, @@ -20,9 +19,6 @@ function getImportableSource(req) { if (!source) { throw new SourceNotFoundError({ name: sourceName }); } - if (has(source, 'import')) { - throw new SourceNoImportError({ name: sourceName }); - } if (source.apiVersion < 3) { throw new SourceNoImportError({ name: sourceName }); } diff --git a/src/source/Source.js b/src/source/Source.js index 3fe3ea3a..f6e342fd 100644 --- a/src/source/Source.js +++ b/src/source/Source.js @@ -105,23 +105,17 @@ class LegacySourceWrapper { /** * Unsupported for legacy sources. - * @param {User} user - * @param {string} userID - * @returns {Promise>} + * @returns {Promise} */ - // eslint-disable-next-line no-unused-vars - async getUserPlaylists(user, userID) { + async getUserPlaylists() { throw new SourceNoImportError({ name: this.type }); } /** * Unsupported for legacy sources. - * @param {User} user - * @param {string} playlistID - * @returns {Promise>} + * @returns {Promise} */ - // eslint-disable-next-line no-unused-vars - async getPlaylistItems(user, playlistID) { + async getPlaylistItems() { throw new SourceNoImportError({ name: this.type }); } @@ -131,12 +125,13 @@ class LegacySourceWrapper { * doing. * * @param {User} user - * @param {unknown[]} args + * @param {{}} values + * @returns {Promise} */ - 'import'(user, ...args) { + 'import'(user, values) { const importContext = new ImportContext(this.uw, this, user); if (this.plugin.api === 2 && this.plugin.import != null) { - return this.plugin.import(importContext, ...args); + return this.plugin.import(importContext, values); } throw new SourceNoImportError({ name: this.type }); } @@ -256,8 +251,10 @@ class ModernSourceWrapper { /** * Unsupported for modern media sources. + * + * @returns {Promise} */ - 'import'() { + async 'import'() { throw new SourceNoImportError({ name: this.type }); } } diff --git a/src/source/types.d.ts b/src/source/types.d.ts index c9350b96..1039effb 100644 --- a/src/source/types.d.ts +++ b/src/source/types.d.ts @@ -42,4 +42,5 @@ export interface SourceWrapper { search(user: User, query: string, page?: JsonValue): Promise>; getUserPlaylists(user: User, userID: string): Promise>; getPlaylistItems(user: User, playlistID: string): Promise>; + import(user: User, params: {}): Promise; } From 94d4065b3babed4a96c013c0f6b3f06a5e74a0bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9e=20Kooi?= Date: Tue, 2 Nov 2021 13:07:12 +0100 Subject: [PATCH 10/13] make the api>=3 check typecheck --- src/Uwave.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Uwave.js b/src/Uwave.js index 86426769..95e58b3b 100644 --- a/src/Uwave.js +++ b/src/Uwave.js @@ -1,5 +1,6 @@ 'use strict'; +const has = require('has'); const EventEmitter = require('events'); const { promisify } = require('util'); const mongoose = require('mongoose'); @@ -248,7 +249,10 @@ class UwaveServer extends EventEmitter { throw new TypeError(`Source plugin should be a function, got ${typeof sourceFactory}`); } - if (typeof sourceFactory === 'function' && sourceFactory.api >= 3) { + if (typeof sourceFactory === 'function' + && has(sourceFactory, 'api') + && typeof sourceFactory.api === 'number' + && sourceFactory.api >= 3) { throw new TypeError('uw.source() only supports old-style source plugins.'); } From 76cf0162b0bc2c6972209cd3926c8272f99d099f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9e=20Kooi?= Date: Tue, 2 Nov 2021 13:14:50 +0100 Subject: [PATCH 11/13] typecheck useSource() --- src/Uwave.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/Uwave.js b/src/Uwave.js index 95e58b3b..e87373dd 100644 --- a/src/Uwave.js +++ b/src/Uwave.js @@ -202,8 +202,14 @@ class UwaveServer extends EventEmitter { /** * Register a source plugin. + * + * @template TOptions + * @template {import('./source/types').HotSwappableSourcePlugin< + * TOptions, import('type-fest').JsonValue>} TPlugin + * @param {TPlugin} sourcePlugin + * @param {TOptions} [opts] */ - async useSource(sourcePlugin, opts = {}) { + async useSource(sourcePlugin, opts) { /** @type {import('avvio').Avvio} */ // @ts-ignore const boot = this; From 94faee79e130521c3ac99c28654c2b512ef70029 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9e=20Kooi?= Date: Tue, 2 Nov 2021 13:29:37 +0100 Subject: [PATCH 12/13] search() returns a Page now --- src/source/Source.js | 5 ++++- test/sources.mjs | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/source/Source.js b/src/source/Source.js index f6e342fd..5e18e243 100644 --- a/src/source/Source.js +++ b/src/source/Source.js @@ -13,7 +13,10 @@ const Page = require('../Page'); /** @typedef {import('./types').SourceWrapper} SourceWrapper */ /** - * Wrapper around source plugins with some more convenient aliases. + * Wrapper around V1/V2 source plugins with some more convenient aliases. + * + * Ideally we get rid of this in like a year and only support hot-swappable sources… + * * @implements {SourceWrapper} */ class LegacySourceWrapper { diff --git a/test/sources.mjs b/test/sources.mjs index a87a1571..cf5e315a 100644 --- a/test/sources.mjs +++ b/test/sources.mjs @@ -48,7 +48,7 @@ describe('Media Sources', () => { uw.source(testSource); const query = 'search-query'; const results = await uw.source('test-source').search(null, query); - assert.deepStrictEqual(results, [ + assert.deepStrictEqual(results.data, [ { sourceType: 'test-source', sourceID: query }, ]); }); From a117f06de9bd74fc750deadf76876fac7d1358f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9e=20Kooi?= Date: Tue, 2 Nov 2021 14:00:32 +0100 Subject: [PATCH 13/13] stash --- dev/u-wave-dev-server | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/dev/u-wave-dev-server b/dev/u-wave-dev-server index 8f3e5578..90ac83dd 100755 --- a/dev/u-wave-dev-server +++ b/dev/u-wave-dev-server @@ -6,8 +6,8 @@ const argv = require('minimist')(process.argv.slice(2)); const concat = require('concat-stream'); const explain = require('explain-error'); const announce = require('u-wave-announce'); -const YouTubeSource = require('u-wave-source-youtube').default; -const scSource = require('u-wave-source-soundcloud'); +const YouTubeSource = require('../../u-wave-source-youtube').default; +const scSource = require('../../u-wave-source-soundcloud'); const recaptchaTestKeys = require('recaptcha-test-keys'); const debug = require('debug')('uwave:dev-server'); const dotenv = require('dotenv'); @@ -66,11 +66,9 @@ async function start() { uw.useSource(YouTubeSource); - if (process.env.SOUNDCLOUD_API_KEY) { - uw.useSource(scSource, { - key: process.env.SOUNDCLOUD_API_KEY, - }); - } + uw.useSource(scSource, { + key: process.env.SOUNDCLOUD_API_KEY ?? null, + }); await uw.listen(port); console.log(`Now listening on ${port}`);