ambient-display 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/.nvmrc +1 -0
  2. package/.prettierignore +4 -0
  3. package/.prettierrc +17 -0
  4. package/CHANGELOG.md +34 -0
  5. package/README.md +84 -0
  6. package/ambient.config.js.template +13 -0
  7. package/env.template +2 -0
  8. package/eslint.config.js +24 -0
  9. package/jsconfig.json +19 -0
  10. package/package.json +67 -0
  11. package/screenshot.png +0 -0
  12. package/server/api/index.js +213 -0
  13. package/server/api/interact.js +234 -0
  14. package/server/api/utils.js +284 -0
  15. package/server/comms.js +13 -0
  16. package/server/config.js +27 -0
  17. package/server/constants.js +52 -0
  18. package/server/events.js +41 -0
  19. package/server/history.js +47 -0
  20. package/server/index.js +155 -0
  21. package/server/logs.js +15 -0
  22. package/server/memo.js +63 -0
  23. package/server/run.js +5 -0
  24. package/server/spotify/auth.js +98 -0
  25. package/server/spotify/index.js +105 -0
  26. package/server/spotify/sdk.js +217 -0
  27. package/server/types/comms.js +7 -0
  28. package/server/types/data.js +94 -0
  29. package/server/types/index.js +16 -0
  30. package/server/types/options.js +72 -0
  31. package/server/utils.js +101 -0
  32. package/src/app.d.ts +13 -0
  33. package/src/app.html +12 -0
  34. package/src/lib/actions/qr.svelte.js +23 -0
  35. package/src/lib/comms.js +25 -0
  36. package/src/lib/components/AuthenticateTrigger.svelte +74 -0
  37. package/src/lib/components/Controls.svelte +91 -0
  38. package/src/lib/components/ImageLoad.svelte +79 -0
  39. package/src/lib/components/LoadingIndicator.svelte +75 -0
  40. package/src/lib/components/MediaItem.svelte +75 -0
  41. package/src/lib/components/PlayingTracker.svelte +94 -0
  42. package/src/lib/components/ResultsList.svelte +80 -0
  43. package/src/lib/components/Toast/Item.svelte +71 -0
  44. package/src/lib/components/Toast/Manager.svelte +34 -0
  45. package/src/lib/icons/disc.svg +1 -0
  46. package/src/lib/index.js +1 -0
  47. package/src/lib/store.js +146 -0
  48. package/src/lib/styles.scss +166 -0
  49. package/src/lib/toast.js +57 -0
  50. package/src/lib/utils.js +723 -0
  51. package/src/routes/+layout.server.js +25 -0
  52. package/src/routes/+layout.svelte +72 -0
  53. package/src/routes/+page.svelte +381 -0
  54. package/src/routes/player/+page.svelte +294 -0
  55. package/static/favicon.ico +0 -0
  56. package/static/favicon.png +0 -0
  57. package/static/icons/144.favicon.png +0 -0
  58. package/static/icons/168.favicon.png +0 -0
  59. package/static/icons/192.favicon.png +0 -0
  60. package/static/icons/48.favicon.png +0 -0
  61. package/static/icons/72.favicon.png +0 -0
  62. package/static/icons/96.favicon.png +0 -0
  63. package/static/manifest.json +40 -0
  64. package/svelte.config.js +19 -0
  65. package/tools/BuildManifest.js +87 -0
  66. package/vite.config.js +46 -0
@@ -0,0 +1,155 @@
1
+ import { SpotifyApi } from '@spotify/web-api-ts-sdk';
2
+
3
+ import express from 'express';
4
+ import cors from 'cors';
5
+ import { createServer } from 'node:http';
6
+ import { Server } from 'socket.io';
7
+
8
+ import { ERROR, EVENT } from './constants.js';
9
+ import * as Types from './types/options.js';
10
+ import { events } from './events.js';
11
+ import { comms } from './comms.js';
12
+
13
+ import ApiRoutes from './api/index.js';
14
+ import SpotifyRoutes from './spotify/index.js';
15
+ import { SpotifyInteract } from './api/interact.js';
16
+ import { OPTIONS } from './config.js';
17
+ import { CommandHistory } from './history.js';
18
+
19
+ const URL = `${OPTIONS.origin}:${OPTIONS.port}`;
20
+
21
+ const app = express();
22
+ const server = createServer(app);
23
+ const io = new Server(server, {
24
+ cors: {
25
+ origin: '*'
26
+ }
27
+ });
28
+ const commsObject = comms(io);
29
+
30
+ app.use(express.json());
31
+ app.use(cors());
32
+
33
+ /**
34
+ * A mutable object, to which we can attach the spotify instance onto
35
+ */
36
+ const sdk = {
37
+ /** @type {SpotifyApi | null} */
38
+ current: null
39
+ };
40
+
41
+ const history = CommandHistory();
42
+
43
+ const { plugins, ...config } = OPTIONS;
44
+ const inject = {
45
+ io,
46
+ comms: commsObject,
47
+ server: app,
48
+ events,
49
+ sdk,
50
+ spotify: SpotifyInteract,
51
+ config,
52
+ history,
53
+ info: {
54
+ url: URL,
55
+ player: [URL, OPTIONS.playerRoute].join('')
56
+ }
57
+ };
58
+
59
+ if (OPTIONS.plugins.length > 0) {
60
+ if (OPTIONS.verbose) {
61
+ console.log('Starting to initialise plugins');
62
+ }
63
+
64
+ await Promise.all(
65
+ OPTIONS.plugins
66
+ .filter((plugin) => {
67
+ if (plugin.skip && OPTIONS.verbose) {
68
+ console.log(`Skipping plugin: ${plugin.name ?? 'Unnamed'}`);
69
+ }
70
+ return !plugin.skip;
71
+ })
72
+ .map((plugin) => Promise.resolve().then(() => plugin.handler(inject)))
73
+ );
74
+ if (OPTIONS.verbose) {
75
+ console.log('Finished initialising plugins');
76
+ }
77
+ } else if (OPTIONS.verbose) {
78
+ console.log('No plugins listed');
79
+ }
80
+
81
+ /**
82
+ * Middleware which attachs the spotify intance and the socket io instance
83
+ */
84
+ app.use((req, res, next) => {
85
+ req.history = history;
86
+ req.sdk = sdk.current;
87
+ req.io = io;
88
+ req.comms = commsObject;
89
+ next();
90
+ });
91
+
92
+ /**
93
+ * A middleware for routes that require the spotify sdk
94
+ *
95
+ * @param {import('express').Request} req
96
+ * @param {import('express').Response} res
97
+ * @param {import('express').NextFunction} next
98
+ * @returns {void}
99
+ */
100
+ const sdkProtect = (req, res, next) => {
101
+ if (!req.sdk) {
102
+ return res.json({
103
+ error: true,
104
+ message: ERROR.UNAUTHENTICATED
105
+ });
106
+ }
107
+
108
+ next();
109
+ };
110
+
111
+ // Mount the spotify sub app
112
+ const spotify = await SpotifyRoutes(sdk, OPTIONS);
113
+ app.use('/spotify', spotify);
114
+
115
+ // Mount the api sub app
116
+ app.use('/api', sdkProtect, ApiRoutes(io, sdk, OPTIONS.api ?? {}));
117
+
118
+ // If the app is running in development mode, catch the player redirect and redirect to the sveltekit route instead
119
+ if (process.env.NODE_ENV === 'development') {
120
+ app.get(OPTIONS.playerRoute, (req, res) => res.redirect('http://localhost:5173/player'));
121
+ } else {
122
+ // If its production mount the built sveltekit app
123
+ const { handler } = await import('../build/handler.js');
124
+ app.use(handler);
125
+ }
126
+
127
+ app.use((err, req, res, next) => {
128
+ // events.error(ERROR.GENERAL, err);
129
+
130
+ return res.json({
131
+ error: true,
132
+ message: ERROR.GENERAL
133
+ });
134
+ });
135
+
136
+ events.on(EVENT.APP_ERROR, ({ message }) => {
137
+ if (!OPTIONS.suppressErrors.includes(message)) {
138
+ commsObject.error(message);
139
+ }
140
+ });
141
+
142
+ events.on(`system:authenticated`, () => {
143
+ io.emit('reload');
144
+ });
145
+
146
+ export default {
147
+ inject,
148
+ server,
149
+ start() {
150
+ server.listen(OPTIONS.port, () => {
151
+ console.log(`App running on port ${URL}`);
152
+ events.system('start');
153
+ });
154
+ }
155
+ };
package/server/logs.js ADDED
@@ -0,0 +1,15 @@
1
+ import bunyan from 'bunyan';
2
+
3
+ export const log = bunyan.createLogger({
4
+ name: 'SpotifyParty',
5
+ streams: [
6
+ // {
7
+ // level: 'debug',
8
+ // stream: process.stdout // log INFO and above to stdout
9
+ // },
10
+ {
11
+ level: 'error',
12
+ path: './error.logs.json' // log ERROR and above to a file
13
+ }
14
+ ]
15
+ });
package/server/memo.js ADDED
@@ -0,0 +1,63 @@
1
+ export const memo = {
2
+ items: {},
3
+
4
+ /**
5
+ *
6
+ * @param {string} key
7
+ * @param {number} cacheTime
8
+ * @returns {boolean}
9
+ */
10
+ exists(key, cacheTime = 1000 * 60 * 60) {
11
+ return key in this.items && Date.now() - this.items[key].cacheTime < cacheTime;
12
+ },
13
+
14
+ /**
15
+ *
16
+ * @param {string} key
17
+ * @returns {any}
18
+ */
19
+ get(key) {
20
+ return this.items[key].data;
21
+ },
22
+
23
+ /**
24
+ *
25
+ * @param {string} key
26
+ * @param {any} data
27
+ */
28
+ save(key, data) {
29
+ this.items[key] = {
30
+ cacheTime: Date.now(),
31
+ data
32
+ };
33
+
34
+ return () => this.delete(key);
35
+ },
36
+
37
+ delete(key) {
38
+ delete this.items[key];
39
+ },
40
+
41
+ /**
42
+ * A utility that first checks the cache and if not runs the function and saved the return
43
+ *
44
+ * @param {string} key
45
+ * @param {() => Promise<any>} memoFunc
46
+ * @param {(data: any) => any} transform
47
+ * @returns
48
+ */
49
+ async use(key, memoFunc, transform = (value) => value) {
50
+ if (this.exists(key)) {
51
+ return this.get(key);
52
+ } else {
53
+ this.delete(key);
54
+ const resp = transform(await Promise.resolve().then(() => memoFunc()));
55
+ this.save(key, resp);
56
+ return resp;
57
+ }
58
+ },
59
+
60
+ key(...args) {
61
+ return args.join(':');
62
+ }
63
+ };
package/server/run.js ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+
3
+ import Server from './index.js';
4
+
5
+ Server.start();
@@ -0,0 +1,98 @@
1
+ import fs from 'node:fs/promises';
2
+
3
+ import * as Types from '../types/index.js';
4
+ import * as OptionsTypes from '../types/options.js';
5
+ import { expandAliases } from '../utils.js';
6
+
7
+ export const SpotifyAuth = {
8
+ token: {
9
+ /**
10
+ * Exchanges a refresh token for a new access token.
11
+ *
12
+ * Potentially doesnt return another refresh token, which documentation says to reuse the old one
13
+ *
14
+ * @param {string} refresh_token
15
+ * @returns {Promise<Types.SpotifyAccessToken | {error: string}>}
16
+ */
17
+ refresh(client_id, client_secret, refresh_token) {
18
+ return fetch('https://accounts.spotify.com/api/token', {
19
+ method: 'POST',
20
+ headers: {
21
+ 'Content-Type': 'application/x-www-form-urlencoded'
22
+ },
23
+ body: new URLSearchParams({
24
+ grant_type: 'refresh_token',
25
+ refresh_token,
26
+ client_id,
27
+ client_secret
28
+ })
29
+ }).then((resp) => resp.json());
30
+ },
31
+
32
+ /**
33
+ * Exchanges a code for an access token
34
+ *
35
+ * @param {string} code
36
+ * @param {string} redirect_uri
37
+ * @returns {Promise<Types.SpotifyAccessToken>}
38
+ */
39
+ get(client_id, client_secret, code, redirect_uri) {
40
+ return fetch('https://accounts.spotify.com/api/token', {
41
+ body: new URLSearchParams({
42
+ code,
43
+ redirect_uri,
44
+ grant_type: 'authorization_code'
45
+ }),
46
+ method: 'POST',
47
+ headers: {
48
+ 'content-type': 'application/x-www-form-urlencoded',
49
+ Authorization:
50
+ 'Basic ' + new Buffer.from(client_id + ':' + client_secret).toString('base64')
51
+ }
52
+ }).then((resp) => resp.json());
53
+ }
54
+ }
55
+ };
56
+
57
+ /**
58
+ *
59
+ * @param {OptionsTypes.SpotifyOptions} options
60
+ * @returns {Promise<Types.SpotifyAccessToken | false>}
61
+ */
62
+ export async function initialisePreviousAuth({
63
+ accessTokenJsonLocation,
64
+ client_id,
65
+ client_secret
66
+ }) {
67
+ try {
68
+ // Attemps to read the json file of auth, will throw an error if it doesn't exist
69
+ const previousAuth = await fs.readFile(expandAliases(accessTokenJsonLocation), 'utf-8');
70
+
71
+ // Parse the object to utilise it
72
+ const previous = JSON.parse(previousAuth);
73
+
74
+ const { error, ...token } = await SpotifyAuth.token.refresh(
75
+ client_id,
76
+ client_secret,
77
+ previous.refresh_token,
78
+ previous.access_token
79
+ );
80
+
81
+ if (error) {
82
+ // This is a shot in the dark, but this was throwing sometimes in quick success of accessing, so wondered if its a rate limit thing
83
+ if (error === 'invalid_request') {
84
+ return previous;
85
+ }
86
+
87
+ throw new Error('Invalid token');
88
+ }
89
+
90
+ // Intialise the return with the refresh token, in case it didn't come in the refreshed call. Will get overwritten if it did
91
+ return {
92
+ refresh_token: previous.refresh_token,
93
+ ...token
94
+ };
95
+ } catch {
96
+ return false;
97
+ }
98
+ }
@@ -0,0 +1,105 @@
1
+ import { Router } from 'express';
2
+
3
+ import { ERROR } from '../constants.js';
4
+ import { events } from '../events.js';
5
+ import { initialisePreviousAuth, SpotifyAuth } from './auth.js';
6
+ import { persistSdk } from './sdk.js';
7
+ import { catchAndRetry } from '../utils.js';
8
+
9
+ // These scoped items are fixed and necessary for the app to run
10
+ const SCOPE = [
11
+ 'user-read-currently-playing',
12
+ 'user-read-playback-state',
13
+ 'user-modify-playback-state'
14
+ ];
15
+
16
+ /**
17
+ *
18
+ * @param {{current: null | import("@spotify/web-api-ts-sdk").SpotifyApi}} sdk
19
+ * @param {import("../types/options.js").SpotifyAmbientDisplayOptions} options
20
+ */
21
+ const run = async (sdk, options) => {
22
+ // First check if there is previous auth that is valid
23
+ const refreshedAuth = await initialisePreviousAuth(options.spotify);
24
+
25
+ // If there is, initialise it while also persisting the auth
26
+ if (refreshedAuth) {
27
+ sdk.current = await persistSdk(refreshedAuth, options);
28
+ }
29
+
30
+ // Initalise a sub router
31
+ const app = Router();
32
+
33
+ // Construct the spotify redirect_uri
34
+ const redirect_uri = [
35
+ [options.protocol, [options.origin, options.port].join(':')].join(''),
36
+ options.spotify.routePrefix,
37
+ options.spotify.routeToken
38
+ ].join('');
39
+
40
+ // Merge scopes, ensuring the necessary ones are applied and duplicates are removed
41
+ const scope = [...new Set([...SCOPE, options.spotify.scope])].join(' ');
42
+
43
+ /**
44
+ * The start route, kicks off the authorisation process, by redirecting to the spotify authorise page
45
+ */
46
+ app.get('/start', (req, res) => {
47
+ // If the SDK is already attached to the request object, assume its authenticated and bypass the start
48
+ if (req.sdk) {
49
+ res.redirect(`${options.spotify.authenticatedRedirect}?authenticated=true`);
50
+ return;
51
+ }
52
+
53
+ const url = new URL('https://accounts.spotify.com/authorize');
54
+ url.search = new URLSearchParams({
55
+ response_type: 'code',
56
+ client_id: options.spotify.client_id,
57
+ scope,
58
+ redirect_uri
59
+ });
60
+
61
+ res.redirect(url.toString());
62
+ });
63
+
64
+ /**
65
+ * This route is the route that is redirected to after spotify has authed the user
66
+ */
67
+ app.get(options.spotify.routeToken, async (req, res) => {
68
+ // If the SDK is already attached to the request object, assume its authenticated and bypass the start
69
+ if (req.sdk) {
70
+ res.redirect(`${options.spotify.authenticatedRedirect}?authenticated=true`);
71
+ return;
72
+ }
73
+
74
+ var code = req.query.code || null;
75
+ var error = req.query.error || null;
76
+
77
+ if (error) {
78
+ events.error(ERROR.SPOTIFY_UNAUTHENTICATED);
79
+
80
+ return res.json({
81
+ error: true,
82
+ message: ERROR.SPOTIFY_UNAUTHENTICATED
83
+ });
84
+ }
85
+
86
+ const accessTokenJson = await catchAndRetry(() => {
87
+ return SpotifyAuth.token.get(
88
+ options.spotify.client_id,
89
+ options.spotify.client_secret,
90
+ code,
91
+ redirect_uri
92
+ );
93
+ });
94
+
95
+ sdk.current = await persistSdk(accessTokenJson, options);
96
+
97
+ events.system('authenticated');
98
+
99
+ res.redirect(`${options.spotify.authenticatedRedirect}?authenticated=true`);
100
+ });
101
+
102
+ return app;
103
+ };
104
+
105
+ export default run;
@@ -0,0 +1,217 @@
1
+ import { SpotifyApi } from '@spotify/web-api-ts-sdk';
2
+ import fs from 'node:fs/promises';
3
+
4
+ import { events } from '../events.js';
5
+ import { log } from '../logs.js';
6
+ import * as Types from '../types/index.js';
7
+ import * as OptionTypes from '../types/options.js';
8
+ import { SpotifyAuth } from './auth.js';
9
+ import { ERROR } from '../constants.js';
10
+ import path from 'node:path';
11
+ import { expandAliases, __dirname } from '../utils.js';
12
+
13
+ class FixedAccessTokenStrategy {
14
+ /**
15
+ *
16
+ * @param {Types.SpotifyAccessToken} item
17
+ * @returns {number}
18
+ */
19
+ static calculateExpiry(item) {
20
+ return Date.now() + item.expires_in * 1000;
21
+ }
22
+
23
+ /**
24
+ * @param {string} clientId
25
+ * @param {Types.SpotifyAccessToken} accessToken
26
+ * @param {(clientId: string, token: Types.SpotifyAccessToken) => Promise<Types.SpotifyAccessToken>} refreshTokenAction
27
+ */
28
+ constructor(clientId, accessToken, refreshTokenAction) {
29
+ this.clientId = clientId;
30
+ this.accessToken = accessToken;
31
+
32
+ this.refreshTokenAction = refreshTokenAction;
33
+
34
+ // If the raw token from the jwt response is provided here
35
+ // Calculate an absolute `expiry` value.
36
+ // Caveat: If this token isn't fresh, this value will be off.
37
+ // It's the responsibility of the calling code to either set a valid
38
+ // expires property, or ensure expires_in accounts for any lag between
39
+ // issuing and passing here.
40
+
41
+ if (!this.accessToken.expires) {
42
+ this.accessToken.expires = FixedAccessTokenStrategy.calculateExpiry(this.accessToken);
43
+ }
44
+ }
45
+
46
+ setConfiguration() {}
47
+
48
+ /**
49
+ *
50
+ * @returns {Types.SpotifyAccessToken}
51
+ */
52
+ async getOrCreateAccessToken() {
53
+ if (this.accessToken.expires && this.accessToken.expires <= Date.now()) {
54
+ const refreshed = await this.refreshTokenAction(this.clientId, this.accessToken);
55
+ this.accessToken = refreshed;
56
+ }
57
+
58
+ return this.accessToken;
59
+ }
60
+
61
+ /**
62
+ *
63
+ * @returns {Types.SpotifyAccessToken | null}
64
+ */
65
+ async getAccessToken() {
66
+ return this.accessToken;
67
+ }
68
+
69
+ removeAccessToken() {
70
+ this.accessToken = {
71
+ access_token: '',
72
+ token_type: '',
73
+ expires_in: 0,
74
+ refresh_token: '',
75
+ expires: 0
76
+ };
77
+ }
78
+ }
79
+
80
+ /**
81
+ *
82
+ * @param {Response} resp
83
+ */
84
+ const safeBody = async (resp) => {
85
+ const text = await resp.text();
86
+
87
+ try {
88
+ return JSON.parse(text);
89
+ } catch {
90
+ return text;
91
+ }
92
+ };
93
+
94
+ /**
95
+ *
96
+ * @param {Types.SpotifyAccessToken} accessTokenData
97
+ * @param {OptionTypes.SpotifyAmbientDisplayOptions} opts
98
+ * @returns
99
+ */
100
+ export async function persistSdk(
101
+ accessTokenData,
102
+ { spotify: { client_id, client_secret, accessTokenJsonLocation }, verbose }
103
+ ) {
104
+ const p = path.resolve(expandAliases(accessTokenJsonLocation));
105
+
106
+ try {
107
+ await fs.access(path.dirname(p));
108
+ } catch (e) {
109
+ if (e.code === 'ENOENT') {
110
+ if (verbose) {
111
+ console.log('Creating the folder to store the credentials');
112
+ }
113
+
114
+ await fs.mkdir(path.dirname(p), { recursive: true });
115
+
116
+ if (verbose) {
117
+ console.log('Saving a config file in the folder too');
118
+ }
119
+ await fs.copyFile(
120
+ path.join(__dirname(import.meta.url), '../../ambient.config.js.template'),
121
+ path.join(path.dirname(p), './ambient.config.js')
122
+ );
123
+ } else {
124
+ console.log('Error accessing credentials folder', e);
125
+ }
126
+ }
127
+
128
+ if (verbose) {
129
+ console.log('Persisting credentials in', p);
130
+ }
131
+
132
+ // Persist the access token data to disk
133
+ await fs.writeFile(p, JSON.stringify(accessTokenData), 'utf-8');
134
+
135
+ return new SpotifyApi(
136
+ new FixedAccessTokenStrategy(
137
+ client_id,
138
+ accessTokenData,
139
+ (_client_id, _client_secret, token) => {
140
+ SpotifyAuth.token.refresh(
141
+ client_id,
142
+ client_secret,
143
+ token.refresh_token,
144
+ token.access_token
145
+ );
146
+ }
147
+ ),
148
+ {
149
+ deserializer: {
150
+ async deserialize(response) {
151
+ const text = await response.text();
152
+
153
+ const contentType = response.headers.get('content-type') ?? '';
154
+
155
+ if (text.length > 0 && contentType.includes('application/json')) {
156
+ const json = JSON.parse(text);
157
+ return json;
158
+ }
159
+
160
+ return null;
161
+ }
162
+ },
163
+ responseValidator: {
164
+ async validateResponse(response) {
165
+ switch (response.status) {
166
+ case 401:
167
+ events.error(ERROR.SPOTIFY_REAUTHENTICATE);
168
+
169
+ throw new Error(
170
+ 'Bad or expired token. This can happen if the user revoked a token or the access token has expired. You should re-authenticate the user.'
171
+ );
172
+ case 403: {
173
+ const body = await safeBody(response);
174
+
175
+ if (typeof body === 'string') {
176
+ events.error(ERROR.SPOTIFY_UNAUTHENTICATED, {
177
+ message: body
178
+ });
179
+ } else {
180
+ if (body.error.message === 'Restricted device') {
181
+ events.error(ERROR.SPOTIFY_RESTRICTED);
182
+ } else {
183
+ events.error(ERROR.SPOTIFY_UNAUTHENTICATED, body);
184
+ }
185
+ }
186
+
187
+ throw new Error(
188
+ `Bad OAuth request (wrong consumer key, bad nonce, expired timestamp...). Unfortunately, re-authenticating the user won't help here. Body: ${typeof body === 'string' ? body : JSON.stringify(body)}`
189
+ );
190
+ }
191
+ case 429:
192
+ events.error(ERROR.SPOTIFY_RATE_LIMIT, {
193
+ retry: parseInt(response.headers.get('Retry-After')),
194
+ retryString: `${Math.round((parseInt(response.headers.get('Retry-After')) / 60) * 10) / 10}s`
195
+ });
196
+
197
+ throw new Error('The app has exceeded its rate limits.');
198
+ default:
199
+ if (!response.status.toString().startsWith('20')) {
200
+ events.error(ERROR.SPOTIFY_ERROR);
201
+
202
+ const body = await response.text();
203
+ throw new Error(
204
+ `Unrecognised response code: ${response.status} - ${response.statusText}. Body: ${body}`
205
+ );
206
+ }
207
+ }
208
+ }
209
+ },
210
+ errorHandler: {
211
+ handleErrors(error) {
212
+ log.error(error);
213
+ }
214
+ }
215
+ }
216
+ );
217
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * @typedef {object} Comms
3
+ * @property {(message: string, type: 'info' | 'error' | 'track') => void} message
4
+ * @property {(message: string) => void} error Emit an error message to sockets
5
+ */
6
+
7
+ export const Types = {};