diff options
author | Leonardo Bishop <me@leonardobishop.com> | 2023-08-06 12:47:15 +0100 |
---|---|---|
committer | Leonardo Bishop <me@leonardobishop.com> | 2023-08-06 12:57:19 +0100 |
commit | 5091b1bf0501d08bb5af90eb75a0833d7d9aba3e (patch) | |
tree | 844dd4740092b38a0168e56944d33a9e3301d32d /app |
Initial commit
Diffstat (limited to 'app')
-rw-r--r-- | app/config/redis.ts | 13 | ||||
-rw-r--r-- | app/index.ts | 43 | ||||
-rw-r--r-- | app/logger.ts | 23 | ||||
-rw-r--r-- | app/routes/spotify.ts | 68 | ||||
-rw-r--r-- | app/spotify/client.ts | 141 | ||||
-rw-r--r-- | app/websocket/spotify.ts | 20 |
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; |