diff --git a/dev/u-wave-dev-server b/dev/u-wave-dev-server index 7757229e..cd9794b6 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 ytSource = require('u-wave-source-youtube'); -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'); @@ -64,15 +64,10 @@ 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, - }); - } - uw.source(scSource, { - key: process.env.SOUNDCLOUD_API_KEY, - }); + uw.useSource(YouTubeSource); + + uw.useSource(scSource, { + key: process.env.SOUNDCLOUD_API_KEY ?? null, }); await uw.listen(); diff --git a/package.json b/package.json index ba4b5ec1..3ce26b15 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", @@ -44,6 +48,7 @@ "http-errors": "^2.0.0", "i18next": "^21.0.2", "ioredis": "^5.0.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/HttpApi.js b/src/HttpApi.js index ac3c722c..fdc4c57f 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'); @@ -120,6 +121,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 deleted file mode 100644 index e04f51cc..00000000 --- a/src/Source.js +++ /dev/null @@ -1,202 +0,0 @@ -'use strict'; - -const { SourceNoImportError } = require('./errors'); - -/** - * @typedef {import('./models').User} User - * @typedef {import('./models').Playlist} Playlist - * @typedef {import('./plugins/playlists').PlaylistItemDesc} PlaylistItemDesc - */ - -/** - * @typedef {object} SourcePluginV1 - * @prop {undefined|1} api - * @prop {(ids: string[]) => Promise} get - * @prop {(query: string, page: unknown, ...args: unknown[]) => Promise} search - * @prop {(context: ImportContext, ...args: unknown[]) => Promise} [import] - * - * @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] - * @prop {(context: SourceContext, entry: PlaylistItemDesc) => - * Promise} [play] - * - * @typedef {SourcePluginV1 | SourcePluginV2} 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 { - /** - * @param {import('./Uwave')} uw - * @param {string} sourceType - * @param {SourcePlugin} sourcePlugin - */ - constructor(uw, sourceType, sourcePlugin) { - this.uw = uw; - this.type = sourceType; - this.plugin = sourcePlugin; - - this.addSourceType = this.addSourceType.bind(this); - } - - get apiVersion() { - return this.plugin.api ?? 1; - } - - /** - * 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[]} - */ - addSourceType(items) { - return items.map((item) => ({ - sourceType: this.type, - ...item, - })); - } - - /** - * Find a single media item by ID. - * - * @param {User} user - * @param {string} id - * @returns {Promise} - */ - getOne(user, id) { - return this.get(user, [id]) - .then((items) => items[0]); - } - - /** - * Find several media items by ID. - * - * @param {User} user - * @param {string[]} ids - * @returns {Promise} - */ - async get(user, ids) { - let items; - if (this.plugin.api === 2) { - const context = new SourceContext(this.uw, this, user); - items = await this.plugin.get(context, ids); - } else { - items = await this.plugin.get(ids); - } - return this.addSourceType(items); - } - - /** - * 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 - * @returns {Promise} - */ - async search(user, query, page, ...args) { - 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); - } - return this.addSourceType(results); - } - - /** - * Playback hook. Media sources can use this to pass the necessary data for - * media playback to clients, for example a temporary signed URL. - * - * @param {User} user - * @param {PlaylistItemDesc} entry - */ - play(user, entry) { - if (this.plugin.api === 2 && this.plugin.play != null) { - const context = new SourceContext(this.uw, this, user); - return this.plugin.play(context, entry); - } - return undefined; - } - - /** - * Import *something* from this media source. Because media sources can - * provide wildly different imports, üWave trusts clients to know what they're - * doing. - * - * @param {User} user - * @param {unknown[]} args - */ - 'import'(user, ...args) { - const importContext = new ImportContext(this.uw, this, user); - if (this.plugin.import != null) { - return this.plugin.import(importContext, ...args); - } - throw new SourceNoImportError({ name: this.type }); - } -} - -exports.SourceContext = SourceContext; -exports.ImportContext = ImportContext; -exports.Source = Source; diff --git a/src/Uwave.js b/src/Uwave.js index 54c08e38..7a0db4b9 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'); @@ -9,7 +10,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 +31,8 @@ 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/types').StaticSourcePlugin} StaticSourcePlugin */ +/** @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(); @@ -199,10 +200,30 @@ class UwaveServer extends EventEmitter { boot.use(httpApi.errorHandling); } + /** + * 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) { + /** @type {import('avvio').Avvio} */ + // @ts-ignore + const boot = this; + + boot.use(pluginifySource, { + source: sourcePlugin, + baseOptions: opts, + }); + } + /** * An array of registered sources. * - * @type {Source[]} + * @type {SourceWrapper[]} */ get sources() { return [...this.#sources.values()]; @@ -213,8 +234,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) => SourcePlugin)} SourcePluginFactory - * @typedef {SourcePlugin | SourcePluginFactory} ToSourcePlugin + * @typedef {(uw: UwaveServer, opts: object) => StaticSourcePlugin} SourcePluginFactory + * @typedef {StaticSourcePlugin | SourcePluginFactory} ToSourcePlugin * * @param {string | Omit | { default: ToSourcePlugin }} sourcePlugin * Source name or definition. @@ -234,6 +255,13 @@ class UwaveServer extends EventEmitter { throw new TypeError(`Source plugin should be a function, got ${typeof sourceFactory}`); } + 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.'); + } + const sourceDefinition = typeof sourceFactory === 'function' ? sourceFactory(this, opts) : sourceFactory; @@ -241,16 +269,38 @@ 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.#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 {SourceWrapper} 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/import.js b/src/controllers/import.js index 02a4caf8..5fed6ce5 100644 --- a/src/controllers/import.js +++ b/src/controllers/import.js @@ -17,7 +17,7 @@ const getImportableSource = (req) => { if (!source) { throw new SourceNotFoundError({ name: sourceName }); } - if (!source.import) { + if (source.apiVersion >= 3) { throw new SourceNoImportError({ name: sourceName }); } diff --git a/src/controllers/search.js b/src/controllers/search.js index 00eb0cd2..4a4cf1e5 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'); /** @typedef {import('../models').Playlist} Playlist */ /** @typedef {import('../plugins/playlists').PlaylistItemDesc} PlaylistItemDesc */ @@ -21,14 +21,13 @@ async function searchAll(req) { source.search(user, query).catch((error) => { debug(error); // Default to empty search on failure, for now. - return []; }) )); const searchResults = await Promise.all(searches); const combinedResults = Object.fromEntries( - sourceNames.map((name, index) => [name, searchResults[index]]), + sourceNames.map((name, index) => [name, searchResults[index]?.data ?? []]), ); return combinedResults; @@ -79,11 +78,11 @@ async function search(req) { throw new SourceNotFoundError({ name: sourceName }); } - /** @type {(PlaylistItemDesc & { inPlaylists?: Playlist[] })[]} */ + /** @type {import('../Page')} */ const searchResults = await source.search(user, query); const searchResultsByID = new Map(); - searchResults.forEach((result) => { + searchResults.data.forEach((result) => { searchResultsByID.set(result.sourceID, result); }); @@ -125,23 +124,23 @@ async function search(req) { return new Map(); }); - searchResults.forEach((result) => { + searchResults.data.forEach((result) => { const media = mediaBySourceID.get(String(result.sourceID)); if (media) { result.inPlaylists = playlistsByMediaID.get(media._id.toString()); } }); - return toListResponse(searchResults, { - url: req.fullUrl, + return toPaginatedResponse(searchResults, { + baseUrl: req.fullUrl, included: { playlists: ['inPlaylists'], }, }); } - return toListResponse(searchResults, { - url: req.fullUrl, + return toPaginatedResponse(searchResults, { + baseUrl: req.fullUrl, }); } diff --git a/src/controllers/sources.js b/src/controllers/sources.js new file mode 100644 index 00000000..b52b3175 --- /dev/null +++ b/src/controllers/sources.js @@ -0,0 +1,66 @@ +'use strict'; + +const { BadRequest } = require('http-errors'); +const { + SourceNotFoundError, + SourceNoImportError, +} = require('../errors'); +const searchController = require('./search'); +const toPaginatedResponse = require('../utils/toPaginatedResponse'); + +/** + * @param {import('../types').Request} req + */ +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.apiVersion < 3) { + throw new SourceNoImportError({ name: sourceName }); + } + + return source; +} + +/** + * @type {import('../types').AuthenticatedController} + */ +async function getPlaylists(req) { + const source = getImportableSource(req); + const { + userID, + } = req.query; + + let items; + + if (userID) { + items = await source.getUserPlaylists(req.user, userID); + } else { + throw new BadRequest('No playlist filter provided'); + } + + return toPaginatedResponse(items, { + baseUrl: req.fullUrl, + }); +} + +/** + * @type {import('../types').AuthenticatedController} + */ +async function getPlaylistItems(req) { + const source = getImportableSource(req); + const { playlistID } = req.params; + + const items = await source.getPlaylistItems(req.user, playlistID); + return toPaginatedResponse(items, { + baseUrl: req.fullUrl, + }); +} + +exports.search = searchController.search; +exports.getPlaylists = getPlaylists; +exports.getPlaylistItems = getPlaylistItems; 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/routes/sources.js b/src/routes/sources.js new file mode 100644 index 00000000..fb305277 --- /dev/null +++ b/src/routes/sources.js @@ -0,0 +1,32 @@ +'use strict'; + +const { Router } = require('express'); +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/playlists - List playlists from the media source. + .get( + '/:source/playlists', + route(controller.getPlaylists), + ) + // 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; diff --git a/src/source/ImportContext.js b/src/source/ImportContext.js new file mode 100644 index 00000000..f90d88dd --- /dev/null +++ b/src/source/ImportContext.js @@ -0,0 +1,40 @@ +'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 = rawItems.map((item) => ({ + ...item, + sourceType: this.source.type, + })); + + if (items.length > 0) { + await this.uw.playlists.addPlaylistItems(playlist, items); + } + + return playlist; + } +} + +module.exports = ImportContext; diff --git a/src/source/Source.js b/src/source/Source.js new file mode 100644 index 00000000..5e18e243 --- /dev/null +++ b/src/source/Source.js @@ -0,0 +1,266 @@ +'use strict'; + +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 {import('./types').SourceWrapper} SourceWrapper */ + +/** + * 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 { + /** + * @param {Uwave} uw + * @param {string} sourceType + * @param {import('./types').StaticSourcePlugin} sourcePlugin + */ + constructor(uw, sourceType, sourcePlugin) { + this.uw = uw; + this.type = sourceType; + this.plugin = sourcePlugin; + } + + get apiVersion() { + return this.plugin.api || 1; + } + + /** + * 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[]} + */ + addSourceType(items) { + return items.map((item) => ({ + sourceType: this.type, + ...item, + })); + } + + /** + * 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; + } + + /** + * Find several media items by ID. + * + * @param {User} user + * @param {string[]} ids + * @returns {Promise} + */ + async get(user, ids) { + let items; + if (this.plugin.api === 2) { + const context = new SourceContext(this.uw, this, user); + items = await this.plugin.get(context, ids); + } else { + items = await this.plugin.get(ids); + } + return this.addSourceType(items); + } + + /** + * 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 {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 new Page(this.addSourceType(results), { + current: page ?? null, + }); + } + + /** + * Unsupported for legacy sources. + * @returns {Promise} + */ + async getUserPlaylists() { + throw new SourceNoImportError({ name: this.type }); + } + + /** + * Unsupported for legacy sources. + * @returns {Promise} + */ + async getPlaylistItems() { + throw new SourceNoImportError({ name: this.type }); + } + + /** + * Import *something* from this media source. Because media sources can + * provide wildly different imports, üWave trusts clients to know what they're + * doing. + * + * @param {User} user + * @param {{}} values + * @returns {Promise} + */ + '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, values); + } + throw new SourceNoImportError({ name: this.type }); + } +} + +/** + * @implements {SourceWrapper} + */ +class ModernSourceWrapper { + /** + * @param {Uwave} uw + * @param {string} sourceType + * @param {import('./types').SourcePluginV3Instance} sourcePlugin + */ + constructor(uw, sourceType, sourcePlugin) { + this.uw = uw; + this.type = sourceType; + this.plugin = sourcePlugin; + } + + // eslint-disable-next-line class-methods-use-this + get apiVersion() { + // Can pass this number in through the constructor in the future. + return 3; + } + + /** + * 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, + })); + } + + /** + * 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; + } + + /** + * 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); + } + + /** + * 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 {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); + results.data = this.addSourceType(results.data); + return results; + } + + /** + * Get playlists for a specific user from this media source. + * + * @param {User} user + * @param {string} userID + * @returns {Promise>} + */ + async getUserPlaylists(user, userID) { + if (!has(this.plugin, 'getUserPlaylists') || this.plugin.getUserPlaylists == null) { + 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. + * + * @param {User} user + * @param {string} playlistID + * @returns {Promise>} + */ + async getPlaylistItems(user, playlistID) { + if (!has(this.plugin, 'getPlaylistItems') || this.plugin.getPlaylistItems == null) { + throw new SourceNoImportError({ name: this.type }); + } + + const context = new SourceContext(this.uw, this, user); + return this.plugin.getPlaylistItems(context, playlistID); + } + + /** + * Unsupported for modern media sources. + * + * @returns {Promise} + */ + async 'import'() { + throw new SourceNoImportError({ name: this.type }); + } +} + +exports.LegacySourceWrapper = LegacySourceWrapper; +exports.ModernSourceWrapper = ModernSourceWrapper; diff --git a/src/source/SourceContext.js b/src/source/SourceContext.js new file mode 100644 index 00000000..e2b410b9 --- /dev/null +++ b/src/source/SourceContext.js @@ -0,0 +1,23 @@ +'use strict'; + +/** @typedef {import('../Uwave')} Uwave */ +/** @typedef {import('../models').User} User */ +/** @typedef {import('./types').SourceWrapper} SourceWrapper */ + +/** + * Data holder for things that source plugins may require. + */ +class SourceContext { + /** + * @param {Uwave} uw + * @param {SourceWrapper} 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..d14f4c81 --- /dev/null +++ b/src/source/index.js @@ -0,0 +1,4 @@ +'use strict'; + +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..a6925374 --- /dev/null +++ b/src/source/plugin.js @@ -0,0 +1,116 @@ +'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} */ +/** + * @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: HotSwappableSourcePlugin, baseOptions?: TOptions }} + * 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`'); + } + + /** + * 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') { + 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 })); + + // 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) => { + if (uw.options.onError) { + uw.options.onError(error); + } else { + debug(error); + } + }); + } + }); + + // 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({}); + } +} + +module.exports = plugin; diff --git a/src/source/types.d.ts b/src/source/types.d.ts new file mode 100644 index 00000000..1039effb --- /dev/null +++ b/src/source/types.d.ts @@ -0,0 +1,46 @@ +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>; + import(user: User, params: {}): 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/test/sources.mjs b/test/sources.mjs index c2f178b8..d7ebf132 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', () => { @@ -47,12 +47,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); }); @@ -60,7 +60,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 }, ]); });