aboutsummaryrefslogtreecommitdiffstats
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/config/redis.ts13
-rw-r--r--app/index.ts43
-rw-r--r--app/logger.ts23
-rw-r--r--app/routes/spotify.ts68
-rw-r--r--app/spotify/client.ts141
-rw-r--r--app/websocket/spotify.ts20
6 files changed, 308 insertions, 0 deletions
diff --git a/app/config/redis.ts b/app/config/redis.ts
new file mode 100644
index 0000000..02302da
--- /dev/null
+++ b/app/config/redis.ts
@@ -0,0 +1,13 @@
+import { createClient } from 'redis';
+
+export type RedisClientConnection = ReturnType<typeof createClient>
+
+export const connectRedis = async (): Promise<RedisClientConnection> => {
+ const redisClient = createClient({ url: process.env.REDIS_URI });
+ await redisClient.connect();
+
+ return redisClient;
+}
+
+export default connectRedis;
+
diff --git a/app/index.ts b/app/index.ts
new file mode 100644
index 0000000..6c100b3
--- /dev/null
+++ b/app/index.ts
@@ -0,0 +1,43 @@
+import dotenv from 'dotenv-defaults';
+import { logger } from './logger.js'
+import express from 'express';
+import router from './routes/spotify.js';
+import createWebsocket from './websocket/spotify.js';
+import { WebSocketServer } from 'ws';
+import { SpotifyClient } from './spotify/client.js';
+import connectRedis from './config/redis.js';
+
+dotenv.config()
+
+const app = express();
+app.set('view engine', 'ejs');
+app.set('views', 'views');
+
+app.use(router);
+
+let redis;
+try {
+ redis = await connectRedis();
+} catch (err) {
+ logger.error(`Failed to connect to Redis: ${err.message}`);
+ process.exit(1);
+}
+SpotifyClient.initialise(redis);
+
+const server = app.listen(process.env.PORT, () => {
+ logger.info(`App listening on port ${process.env.PORT}`);
+});
+const websocketServer: WebSocketServer = createWebsocket(server);
+
+const exit = () => {
+ logger.info('Stopping server...');
+ websocketServer.clients.forEach(client => {
+ client.terminate();
+ });
+ websocketServer.close();
+ server.close(() => {
+ process.exit(0);
+ })
+}
+process.on('SIGINT', exit);
+process.on('SIGTERM', exit);
diff --git a/app/logger.ts b/app/logger.ts
new file mode 100644
index 0000000..4ce0150
--- /dev/null
+++ b/app/logger.ts
@@ -0,0 +1,23 @@
+import winston from 'winston';
+
+const enumerateErrorFormat = winston.format((info) => {
+ if (info instanceof Error) {
+ Object.assign(info, { message: info.stack });
+ }
+ return info;
+ });
+
+export const logger = winston.createLogger({
+ level: process.env.LOGGING_LEVEL === 'development' ? 'debug' : 'info',
+ format: winston.format.combine(
+ enumerateErrorFormat(),
+ winston.format.colorize(),
+ winston.format.splat(),
+ winston.format.printf(({ level, message }) => `${level}: ${message}`)
+ ),
+ transports: [
+ new winston.transports.Console({
+ stderrLevels: ['error'],
+ }),
+ ],
+});
diff --git a/app/routes/spotify.ts b/app/routes/spotify.ts
new file mode 100644
index 0000000..f45f54d
--- /dev/null
+++ b/app/routes/spotify.ts
@@ -0,0 +1,68 @@
+import express from 'express';
+import axios from 'axios';
+import { logger } from '../logger.js';
+import { SpotifyClient } from '../spotify/client.js';
+
+export const router = express.Router({ mergeParams: true });
+
+router.get('/auth', (req, res, next) => {
+ let scope = 'user-read-currently-playing user-read-email user-read-private';
+ let params = new URLSearchParams();
+ params.append('response_type', 'code');
+ params.append('client_id', process.env.SPOTIFY_CLIENT_ID);
+ params.append('scope', scope);
+ params.append('redirect_uri', process.env.SPOTIFY_REDIRECT_URI);
+
+ res.redirect('https://accounts.spotify.com/authorize?' + params.toString());
+});
+
+router.get('/auth/callback', async (req, res, next) => {
+ if (req.query.error) {
+ res.send('Error: ' + req.query.error);
+ return;
+ }
+ if (!req.query.code) {
+ res.send('No code');
+ return;
+ }
+
+ let accessToken: string;
+ let refreshToken: string;
+ try {
+ const res = await axios.post('https://accounts.spotify.com/api/token', {
+ grant_type: 'authorization_code',
+ code: req.query.code,
+ redirect_uri: process.env.SPOTIFY_REDIRECT_URI,
+ client_id: process.env.SPOTIFY_CLIENT_ID,
+ client_secret: process.env.SPOTIFY_CLIENT_SECRET,
+ }, { headers: { 'Content-Type': 'application/x-www-form-urlencoded' }});
+ accessToken = res.data.access_token;
+ refreshToken = res.data.refresh_token;
+ } catch (err) {
+ if (err.response?.query?.error) {
+ res.send('Error: ' + err.response.query.error);
+ } else {
+ res.send('Error');
+ }
+ return;
+ }
+
+ try {
+ const data = await axios.get('https://api.spotify.com/v1/me', {
+ headers: { 'Authorization': 'Bearer ' + accessToken }
+ });
+ if (data.data.id !== process.env.SPOTIFY_USER_ID) {
+ res.send("I don't want to authenticate with you :(");
+ return;
+ }
+ } catch (err) {
+ logger.error(`Failed to get user data: ${err.message} (${err.response.status} ${err.response.statusText} ${err.response.data.error})`);
+ res.send('Error');
+ return;
+ }
+
+ SpotifyClient.setTokens(accessToken, refreshToken);
+ res.send('Tokens have been updated. You can close this window now.');
+});
+
+export default router;
diff --git a/app/spotify/client.ts b/app/spotify/client.ts
new file mode 100644
index 0000000..cfb0d67
--- /dev/null
+++ b/app/spotify/client.ts
@@ -0,0 +1,141 @@
+import axios, { AxiosResponse } from 'axios';
+import { logger } from '../logger.js';
+import { WebSocket } from 'ws';
+import { RedisClientConnection } from '../config/redis.js';
+
+export namespace SpotifyClient {
+ let clients = new Set<WebSocket>();
+ let interval: NodeJS.Timeout;
+
+ let authenticationFailed = false;
+
+ let lastUpdate: any;
+ let lastUpdateTimestamp: number;
+
+ let redis: RedisClientConnection;
+
+ export const addClient = (client: WebSocket) => {
+ clients.add(client);
+ if (lastUpdate && lastUpdateTimestamp > Date.now() - 10000) {
+ client.send(JSON.stringify(lastUpdate));
+ }
+ }
+
+ const apiTokenUrl = 'https://accounts.spotify.com/api/token';
+
+ const spotifyClientHeaders = {
+ 'Content-Type': 'application/x-www-form-urlencoded'
+ }
+
+ export const setTokens = async (accessToken: string, refreshToken: string) => {
+ logger.info('Re-identifying with Spotify');
+ await redis.set('spotify_access_token', accessToken);
+ await redis.set('spotify_refresh_token', refreshToken);
+ authenticationFailed = false;
+ }
+
+ const refreshAccessToken = async () => {
+ logger.info('Refreshing access token from Spotify');
+ try {
+ const refreshToken = await redis.get('spotify_refresh_token');
+ const res = await axios.post(apiTokenUrl, {
+ grant_type: 'refresh_token',
+ refresh_token: refreshToken,
+ client_id: process.env.SPOTIFY_CLIENT_ID,
+ client_secret: process.env.SPOTIFY_CLIENT_SECRET
+ }, { headers: spotifyClientHeaders });
+ await redis.set('spotify_access_token', res.data.access_token);
+ if (res.data.refresh_token) {
+ await redis.set('spotify_refresh_token', res.data.refresh_token);
+ }
+ logger.info('Access token refreshed');
+ } catch (err) {
+ if (err.response?.data?.error) {
+ logger.error(`Failed to refresh access token: ${err.message}: ${err.response.data.error}`);
+ } else {
+ logger.error(`Failed to refresh access token: ${err.message} (${err.response.status} ${err.response.statusText} ${err.response.data.error})`);
+ }
+ authenticationFailed = true;
+ }
+ }
+
+ export const initialise = async (client: RedisClientConnection) => {
+ redis = client;
+ await refreshAccessToken();
+ await updateTimeout();
+ }
+
+ const updateTimeout = async () => {
+ const delay = await update();
+ interval = setTimeout(updateTimeout, delay*1000);
+ }
+
+ const update = async (): Promise<number> => {
+ if (authenticationFailed) {
+ return 5;
+ }
+
+ clients.forEach(client => {
+ if (client.readyState !== WebSocket.OPEN) {
+ clients.delete(client);
+ }
+ });
+
+ if (clients.size === 0) {
+ return 1;
+ }
+
+ try {
+ const token = await redis.get('spotify_access_token');
+
+ let res: AxiosResponse;
+ try {
+ res = await axios.get('https://api.spotify.com/v1/me/player/currently-playing', {
+ headers: {
+ 'Authorization': 'Bearer ' + token,
+ }
+ });
+ } catch (err) {
+ if (err.response?.status === 401) {
+ await refreshAccessToken();
+ return 1;
+ } else {
+ throw err;
+ }
+ }
+ try {
+ let update = {
+ title: res.data.item?.name,
+ duration: res.data.item?.duration_ms,
+ artist: res.data.item?.artists[0]?.name,
+ progress: res.data.progress_ms,
+ album: res.data.item?.album.name,
+ albumArt: res.data.item?.album.images[0]?.url,
+ url: res.data.item?.external_urls.spotify,
+ state: res.data.is_playing ? 'playing' : 'paused',
+ }
+ lastUpdate = update;
+ lastUpdateTimestamp = Date.now();
+ clients.forEach(client => {
+ client.send(JSON.stringify(update));
+ });
+ } catch (err) {
+ logger.error(`Failed to parse and send current song: ${err.message}`);
+ }
+ } catch (err) {
+ if (err.response?.data?.error?.message) {
+ logger.error(`Failed to get current song: ${err.message}: ${err.response.data.error.message}`);
+ } else {
+ logger.error(`Failed to get current song: ${err.message} (${err.response.status} ${err.response.statusText} ${err.response.data.error})`);
+ }
+ }
+ return 5;
+ }
+
+ export const stop = () => {
+ clearInterval(interval);
+ clients.forEach(client => client.close());
+ }
+
+}
+
diff --git a/app/websocket/spotify.ts b/app/websocket/spotify.ts
new file mode 100644
index 0000000..703cf08
--- /dev/null
+++ b/app/websocket/spotify.ts
@@ -0,0 +1,20 @@
+import { Server } from 'http';
+import { WebSocketServer } from 'ws';
+import { SpotifyClient } from '../spotify/client.js';
+
+export const createWebsocketServer = (server: Server): WebSocketServer => {
+ const wss = new WebSocketServer({ noServer: true });
+ server.on('upgrade', (req, socket, head) => {
+ wss.handleUpgrade(req, socket, head, (ws) => {
+ wss.emit('connection', ws, req)
+ })
+ })
+
+ wss.on('connection', (ws) => {
+ SpotifyClient.addClient(ws);
+ });
+
+ return wss;
+}
+
+export default createWebsocketServer;