diff --git a/src/controllers/api/card.controller.ts b/src/controllers/api/card.controller.ts index cb7b095..6b20f6e 100644 --- a/src/controllers/api/card.controller.ts +++ b/src/controllers/api/card.controller.ts @@ -5,6 +5,7 @@ import User from '../../models/user.model'; import Image from '../../models/image.model'; import CardGetRequestQueryParams from '../../interfaces/card-get-request-query-params.interface'; import CardDeleteRequestQueryParams from '../../interfaces/card-delete-request-query-params.interface'; +import UserProfileResponseBody from '../../interfaces/user-profile-response-body.interface'; import Track from '../../interfaces/track.interface'; import Artist from '../../interfaces/artist.interface'; import DataCardProps from '../../interfaces/data-card-props.interface'; @@ -24,7 +25,7 @@ export const card_get: RequestHandler = async (req, res) => { const browserIsBuggy = detectBuggyBrowser(req); // validate user id query param - const cardReqBody = req.query as unknown as CardGetRequestQueryParams; + const cardReqBody = req.query as CardGetRequestQueryParams; const { user_id: userId, show_border } = cardReqBody; const showBorder = boolFromString(show_border); if (!userId) { @@ -51,21 +52,6 @@ export const card_get: RequestHandler = async (req, res) => { return; } - // fetch user display name - let userDisplayName; - try { - const { display_name } = await User.getUserProfile(accessToken); - userDisplayName = display_name; - } catch (error) { - renderErrorCard( - res, - getGenericErrorMessage(userId), - showBorder, - browserIsBuggy - ); - return; - } - // get options from query params const { show_date, @@ -95,6 +81,40 @@ export const card_get: RequestHandler = async (req, res) => { limit ); + // create data-fetching tasks + const tasks: (Promise | (Track | Artist)[] | null)[] = [ + User.getUserProfile(accessToken), + showNowPlaying ? User.getNowPlaying(accessToken, hideExplicit) : null, + showRecentlyPlayed + ? User.getRecentlyPlayed(accessToken, hideExplicit, itemLimit) + : [], + showTopTracks + ? User.getTopTracks(userId, accessToken, hideExplicit, itemLimit) + : [], + showTopArtists ? User.getTopArtists(userId, accessToken, itemLimit) : [] + ]; + + // run all tasks concurrently + let userProfile: UserProfileResponseBody | null = null; + let userDisplayName: string | null = null; + let nowPlaying: Track | null = null; + let recentlyPlayed: Track[] = []; + let topTracks: Track[] = []; + let topArtists: Artist[] = []; + try { + [userProfile, nowPlaying, recentlyPlayed, topTracks, topArtists] = + await Promise.all(tasks); + userDisplayName = userProfile!.display_name; + } catch (error) { + renderErrorCard( + res, + getGenericErrorMessage(userId), + showBorder, + browserIsBuggy + ); + return; + } + // render error card if no data is visible if ( !showNowPlaying && @@ -111,79 +131,6 @@ export const card_get: RequestHandler = async (req, res) => { return; } - // fetch currently playing track - let nowPlaying = null; - if (showNowPlaying) { - try { - nowPlaying = await User.getNowPlaying(accessToken, hideExplicit); - } catch (error) { - renderErrorCard( - res, - getGenericErrorMessage(userId, userDisplayName), - showBorder, - browserIsBuggy - ); - return; - } - } - - // fetch recently played tracks - let recentlyPlayed: Track[] = []; - if (showRecentlyPlayed) { - try { - recentlyPlayed = await User.getRecentlyPlayed( - accessToken, - hideExplicit, - itemLimit - ); - } catch (error) { - renderErrorCard( - res, - getGenericErrorMessage(userId, userDisplayName), - showBorder, - browserIsBuggy - ); - return; - } - } - - // fetch top tracks - let topTracks: Track[] = []; - if (showTopTracks) { - try { - topTracks = await User.getTopTracks( - userId, - accessToken, - hideExplicit, - itemLimit - ); - } catch (error) { - renderErrorCard( - res, - getGenericErrorMessage(userId, userDisplayName), - showBorder, - browserIsBuggy - ); - return; - } - } - - // fetch top artists - let topArtists: Artist[] = []; - if (showTopArtists) { - try { - topArtists = await User.getTopArtists(userId, accessToken, itemLimit); - } catch (error) { - renderErrorCard( - res, - getGenericErrorMessage(userId, userDisplayName), - showBorder, - browserIsBuggy - ); - return; - } - } - // disable http caching if real-time data is requested if (showNowPlaying || showRecentlyPlayed) disableHttpCaching(res); @@ -217,21 +164,10 @@ export const card_get: RequestHandler = async (req, res) => { res.render(CARD_API_VIEW_PATH, dataCardProps); }; -export const CARD_API_ERROR_MESSAGE = { - NO_USER_ID: 'Missing required parameter: user_id', - NO_TOKEN: 'No token provided.', - INVALID_AUTH: 'Only valid bearer authentication supported.', - CARD_NOT_FOUND: 'Data card not found.', - INVALID_TOKEN: 'Invalid token.' -}; -export const CARD_API_DELETION_SUCCESS_MESSAGE = - 'Data card deleted successfully.'; - // deletes a data card export const card_delete: RequestHandler = async (req, res) => { // validate user id query param - const { user_id: userId } = - req.query as unknown as CardDeleteRequestQueryParams; + const { user_id: userId } = req.query as CardDeleteRequestQueryParams; if (!userId) { res.status(400).send(CARD_API_ERROR_MESSAGE.NO_USER_ID); return; @@ -286,6 +222,17 @@ export const card_delete: RequestHandler = async (req, res) => { res.send(CARD_API_DELETION_SUCCESS_MESSAGE); }; +export const CARD_API_ERROR_MESSAGE = { + NO_USER_ID: 'Missing required parameter: user_id', + NO_TOKEN: 'No token provided.', + INVALID_AUTH: 'Only valid bearer authentication supported.', + CARD_NOT_FOUND: 'Data card not found.', + INVALID_TOKEN: 'Invalid token.' +}; + +export const CARD_API_DELETION_SUCCESS_MESSAGE = + 'Data card deleted successfully.'; + // helpers const detectBuggyBrowser = (req: Request) => { @@ -312,8 +259,6 @@ const renderErrorCard = ( }); }; -const getGenericErrorMessage = (userId: string, userDisplayName?: string) => { - return `Card not found! ${ - userDisplayName || `The user with ID ${userId}` - } may need to generate/re-generate a data card at ${SHORT_URL}.`; +const getGenericErrorMessage = (userId: string) => { + return `Card not found! The user with ID ${userId} may need to generate/re-generate a data card at ${SHORT_URL}.`; }; diff --git a/src/controllers/auth/index.controller.ts b/src/controllers/auth/index.controller.ts index b6ae51f..7a95d38 100644 --- a/src/controllers/auth/index.controller.ts +++ b/src/controllers/auth/index.controller.ts @@ -76,8 +76,7 @@ export const auth_callback: RequestHandler = async (req, res) => { const refreshToken = refresh_token!; let userId; try { - const { id } = await User.getUserProfile(access_token); - userId = id; + ({ id: userId } = await User.getUserProfile(access_token)); } catch (error) { redirectToHomePageWithError(res, error as string); return; diff --git a/src/models/image.model.ts b/src/models/image.model.ts index 12a7993..4d8a701 100644 --- a/src/models/image.model.ts +++ b/src/models/image.model.ts @@ -6,15 +6,19 @@ import { getBase64DataFromImageUrl } from '../utils/image.util'; export default class Image { static async getImageDataMap(items: Item[]) { const map: StringMap = {}; + const tasks: Promise[] = []; for (const item of items) { if (!item) continue; const imageUrl = isTrack(item) ? item.albumImageUrl : item.imageUrl; const imageUrlArray = imageUrl.split('/'); const imageId = imageUrlArray[imageUrlArray.length - 1]; - map[imageUrl] = await Redis.getImageDataFromOrSaveToCache(imageId, () => { - return getBase64DataFromImageUrl(imageUrl); - }); + tasks.push( + Redis.getImageDataFromCacheOrGetAndSaveToCache(imageId, () => + getBase64DataFromImageUrl(imageUrl) + ).then((imageData) => (map[imageUrl] = imageData)) + ); } + await Promise.allSettled(tasks); return map; } } diff --git a/src/models/redis.model.ts b/src/models/redis.model.ts index 9bd3621..5f0b725 100644 --- a/src/models/redis.model.ts +++ b/src/models/redis.model.ts @@ -19,7 +19,7 @@ export default class Redis { static #client = createClient({ url: REDIS_URI }); - static async #getFromOrSaveToCache( + static async #getFromCacheOrGetAndSaveToCache( key: string, fallbackFunction: Function, expiration?: number @@ -32,9 +32,7 @@ export default class Redis { // don't throw console.log(error); } - if (data) { - return JSON.parse(data); - } + if (data) return JSON.parse(data); // run fallback function const freshData = await fallbackFunction(); @@ -42,9 +40,10 @@ export default class Redis { // save to cache try { typeof expiration === 'number' - ? await this.#client.setEx(key, expiration, JSON.stringify(freshData)) - : await this.#client.set(key, JSON.stringify(freshData)); + ? this.#client.setEx(key, expiration, JSON.stringify(freshData)) + : this.#client.set(key, JSON.stringify(freshData)); } catch (error) { + // don't throw console.log(error); } @@ -95,25 +94,25 @@ export default class Redis { return JSON.parse(profile || 'null'); } - static getTopTracksFromOrSaveToCache( + static getTopTracksFromCacheOrGetAndSaveToCache( userId: string, hideExplicit: boolean, limit: number, fallbackFunction: Function ): Promise { - return this.#getFromOrSaveToCache( + return this.#getFromCacheOrGetAndSaveToCache( getTopTracksCacheKey(userId, hideExplicit, limit), fallbackFunction, DEFAULT_EXPIRATION ); } - static getTopArtistsFromOrSaveToCache( + static getTopArtistsFromCacheOrGetAndSaveToCache( userId: string, limit: number, fallbackFunction: Function ): Promise { - return this.#getFromOrSaveToCache( + return this.#getFromCacheOrGetAndSaveToCache( getTopArtistsCacheKey(userId, limit), fallbackFunction, DEFAULT_EXPIRATION @@ -122,11 +121,11 @@ export default class Redis { // images - static getImageDataFromOrSaveToCache( + static getImageDataFromCacheOrGetAndSaveToCache( imageId: string, fallbackFunction: Function ): Promise { - return this.#getFromOrSaveToCache( + return this.#getFromCacheOrGetAndSaveToCache( getImageCacheKey(imageId), fallbackFunction ); @@ -136,11 +135,13 @@ export default class Redis { static async deleteTokenMapAndUserDataFromCache( userId: string - ): Promise { - await this.#client.del(getTokenMapCacheKey(userId)); - await this.#client.del(getUserProfileCacheKey(userId)); - await this.#client.eval(getTopItemCacheDeletionScript(userId, 'Track')); - await this.#client.eval(getTopItemCacheDeletionScript(userId, 'Artist')); + ): Promise { + return Promise.all([ + this.#client.del(getTokenMapCacheKey(userId)), + this.#client.del(getUserProfileCacheKey(userId)), + this.#client.eval(getTopItemCacheDeletionScript(userId, 'Track')), + this.#client.eval(getTopItemCacheDeletionScript(userId, 'Artist')) + ]); } } diff --git a/src/models/token-map.model.ts b/src/models/token-map.model.ts index 6cca4ce..da1f5e8 100644 --- a/src/models/token-map.model.ts +++ b/src/models/token-map.model.ts @@ -62,7 +62,7 @@ export default class TokenMap extends MongoTokenMap { // save to cache try { - await Redis.saveTokenMapToCache(userId, tokenMap); + Redis.saveTokenMapToCache(userId, tokenMap); } catch (error) { console.log(error); } @@ -103,7 +103,7 @@ export default class TokenMap extends MongoTokenMap { // save to cache try { - await Redis.saveTokenMapToCache(userId, tokenMap); + Redis.saveTokenMapToCache(userId, tokenMap); } catch (error) { console.log(error); } @@ -118,15 +118,13 @@ export default class TokenMap extends MongoTokenMap { } catch (error) { throw (error as Error).message; } - if (!tokenMap) { - throw `Token map with user ID '${userId}' didn't exist.`; - } + if (!tokenMap) throw `Token map with user ID '${userId}' didn't exist.`; - // delete from cache + // delete from cache (await) try { await Redis.deleteTokenMapAndUserDataFromCache(userId); } catch (error) { - console.log(error); + throw 'Failed to delete token map from cache server.'; } return tokenMap; @@ -162,17 +160,17 @@ TokenMap.getLatestAccessToken = async function ( // get tokens from token map and update access token if it's expired let { accessToken } = tokenMap; const { refreshToken, accessTokenExpiresAt } = tokenMap; - if (accessTokenExpiresAt <= Date.now()) { + if ((accessTokenExpiresAt as number) <= Date.now()) { const { access_token, expires_in } = await Auth.getAccessTokenWithRefreshToken(refreshToken); - await this.updateAccessTokenInTokenMap(userId, access_token, expires_in); + this.updateAccessTokenInTokenMap(userId, access_token, expires_in); accessToken = access_token; } // save token map to cache if necessary if (!cacheHit) { try { - await Redis.saveTokenMapToCache(userId, tokenMap); + Redis.saveTokenMapToCache(userId, tokenMap); } catch (error) { console.log(error); } diff --git a/src/models/user.model.ts b/src/models/user.model.ts index fe1ddb4..6439a8e 100644 --- a/src/models/user.model.ts +++ b/src/models/user.model.ts @@ -55,7 +55,7 @@ export default class User { // save profile to cache if necessary if (fetchedUserId !== null) { try { - await Redis.saveUserProfileToCache(fetchedUserId, profile); + Redis.saveUserProfileToCache(fetchedUserId, profile); } catch (error) { console.log(error); } @@ -130,9 +130,7 @@ export default class User { } // hide explicit tracks if necessary - let trackDataArray = response.data.items.map( - (item) => item.track - ) as TrackResponseBody[]; + let trackDataArray = response.data.items.map((item) => item.track); if (hideExplicit) { trackDataArray = trackDataArray.filter( (trackData) => !trackData.explicit @@ -156,7 +154,7 @@ export default class User { hideExplicit: boolean, limit: number ): Promise { - return Redis.getTopTracksFromOrSaveToCache( + return Redis.getTopTracksFromCacheOrGetAndSaveToCache( userId, hideExplicit, limit, @@ -204,29 +202,33 @@ export default class User { accessToken: string, limit: number ): Promise { - return Redis.getTopArtistsFromOrSaveToCache(userId, limit, async () => { - // fetch top artists - let response; - try { - response = await axios.get( - `${TOP_ARTISTS_ENDPOINT}?limit=${limit}`, - { - headers: { - Authorization: `Bearer ${accessToken}` + return Redis.getTopArtistsFromCacheOrGetAndSaveToCache( + userId, + limit, + async () => { + // fetch top artists + let response; + try { + response = await axios.get( + `${TOP_ARTISTS_ENDPOINT}?limit=${limit}`, + { + headers: { + Authorization: `Bearer ${accessToken}` + } } - } - ); - } catch (error) { - throw (error as AxiosError).message; - } + ); + } catch (error) { + throw (error as AxiosError).message; + } - // resolve with artists - const artistDataArray = response.data.items as ArtistResponseBody[]; - return artistDataArray.map((artistData) => ({ - name: artistData.name, - imageUrl: artistData.images[2].url, - url: artistData.external_urls.spotify - })); - }); + // resolve with artists + const artistDataArray = response.data.items as ArtistResponseBody[]; + return artistDataArray.map((artistData) => ({ + name: artistData.name, + imageUrl: artistData.images[2].url, + url: artistData.external_urls.spotify + })); + } + ); } }