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.
- package/.nvmrc +1 -0
- package/.prettierignore +4 -0
- package/.prettierrc +17 -0
- package/CHANGELOG.md +34 -0
- package/README.md +84 -0
- package/ambient.config.js.template +13 -0
- package/env.template +2 -0
- package/eslint.config.js +24 -0
- package/jsconfig.json +19 -0
- package/package.json +67 -0
- package/screenshot.png +0 -0
- package/server/api/index.js +213 -0
- package/server/api/interact.js +234 -0
- package/server/api/utils.js +284 -0
- package/server/comms.js +13 -0
- package/server/config.js +27 -0
- package/server/constants.js +52 -0
- package/server/events.js +41 -0
- package/server/history.js +47 -0
- package/server/index.js +155 -0
- package/server/logs.js +15 -0
- package/server/memo.js +63 -0
- package/server/run.js +5 -0
- package/server/spotify/auth.js +98 -0
- package/server/spotify/index.js +105 -0
- package/server/spotify/sdk.js +217 -0
- package/server/types/comms.js +7 -0
- package/server/types/data.js +94 -0
- package/server/types/index.js +16 -0
- package/server/types/options.js +72 -0
- package/server/utils.js +101 -0
- package/src/app.d.ts +13 -0
- package/src/app.html +12 -0
- package/src/lib/actions/qr.svelte.js +23 -0
- package/src/lib/comms.js +25 -0
- package/src/lib/components/AuthenticateTrigger.svelte +74 -0
- package/src/lib/components/Controls.svelte +91 -0
- package/src/lib/components/ImageLoad.svelte +79 -0
- package/src/lib/components/LoadingIndicator.svelte +75 -0
- package/src/lib/components/MediaItem.svelte +75 -0
- package/src/lib/components/PlayingTracker.svelte +94 -0
- package/src/lib/components/ResultsList.svelte +80 -0
- package/src/lib/components/Toast/Item.svelte +71 -0
- package/src/lib/components/Toast/Manager.svelte +34 -0
- package/src/lib/icons/disc.svg +1 -0
- package/src/lib/index.js +1 -0
- package/src/lib/store.js +146 -0
- package/src/lib/styles.scss +166 -0
- package/src/lib/toast.js +57 -0
- package/src/lib/utils.js +723 -0
- package/src/routes/+layout.server.js +25 -0
- package/src/routes/+layout.svelte +72 -0
- package/src/routes/+page.svelte +381 -0
- package/src/routes/player/+page.svelte +294 -0
- package/static/favicon.ico +0 -0
- package/static/favicon.png +0 -0
- package/static/icons/144.favicon.png +0 -0
- package/static/icons/168.favicon.png +0 -0
- package/static/icons/192.favicon.png +0 -0
- package/static/icons/48.favicon.png +0 -0
- package/static/icons/72.favicon.png +0 -0
- package/static/icons/96.favicon.png +0 -0
- package/static/manifest.json +40 -0
- package/svelte.config.js +19 -0
- package/tools/BuildManifest.js +87 -0
- package/vite.config.js +46 -0
package/server/index.js
ADDED
|
@@ -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,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
|
+
}
|