improved API request wrapper, partial completion of operations

This commit is contained in:
Kaushik Narayan R 2025-03-13 15:15:43 -07:00
parent 7eec2adc7a
commit 17e0480f83
16 changed files with 320 additions and 282 deletions

View File

@ -6,13 +6,14 @@ import {
type RawAxiosRequestHeaders,
} from "axios";
import type {
AddItemsToPlaylist,
AddItemsToPlaylistData,
EndpointHandlerBaseArgs,
GetCurrentUsersPlaylists,
GetCurrentUsersProfile,
GetPlaylist,
GetPlaylistItems,
RemovePlaylistItems,
EndpointHandlerWithResArgs,
GetCurrentUsersPlaylistsData,
GetCurrentUsersProfileData,
GetPlaylistData,
GetPlaylistItemsData,
RemovePlaylistItemsData,
Res,
} from "spotify_manager/index.d.ts";
@ -26,25 +27,41 @@ enum allowedMethods {
Delete = "delete",
}
type SingleRequestArgs = {
/** Express response object. If set, send error responses from handler itself */
res?: Res;
/** mainly the `Authorization` header, could be extended later to account for custom headers, maybe rate-limiting stuff? */
authHeaders: RawAxiosRequestHeaders;
/** HTTP method */
method?: allowedMethods;
/** relative request path (from `/api/v1`) */
path: string;
/** request params, headers, etc. */
config?: AxiosRequestConfig;
/** request body */
data?: any;
/** true if `data` is to be placed inside config (say, axios' delete method) */
inlineData?: boolean;
};
type SingleRequestResult<RespDataType> = Promise<{
resp?: AxiosResponse<RespDataType, any>;
error?: any;
message: string;
}>;
/**
* Spotify API - one-off request handler
* @param req convenient auto-placing headers from middleware (not a good approach?)
* @param res handle failure responses here itself (not a good approach?)
* @param method HTTP method
* @param path request path
* @param config request params, headers, etc.
* @param data request body
* @param inlineData true if `data` is to be placed inside config (say, axios' delete method)
* Spotify API (v1) - one-off request handler
*/
const singleRequest = async <RespDataType>(
authHeaders: RawAxiosRequestHeaders,
res: Res,
method: allowedMethods,
path: string,
config: AxiosRequestConfig = {},
data: any = null,
inlineData: boolean = false
): Promise<AxiosResponse<RespDataType, any> | null> => {
const singleRequest = async <RespDataType>({
res,
authHeaders,
method = allowedMethods.Get,
path,
config = {},
data = null,
inlineData = false,
}: SingleRequestArgs): SingleRequestResult<RespDataType> => {
let resp: AxiosResponse<RespDataType, any>;
config.headers = { ...config.headers, ...authHeaders };
try {
@ -55,160 +72,138 @@ const singleRequest = async <RespDataType>(
resp = await axiosInstance[method](path, data, config);
}
logger.debug(logPrefix + "Successful response received.");
return resp;
return { resp, message: "" };
} catch (error: any) {
let message = logPrefix;
if (error.response) {
// Non 2XX response received
let logMsg;
if (error.response.status >= 400 && error.response.status < 600) {
res.status(error.response.status).send(error.response.data);
logMsg = "" + error.response.status;
} else {
res.sendStatus(error.response.status);
logMsg = "???";
}
logger.warn(logPrefix + logMsg, {
message = message.concat(
`${error.response.status} - ${error.response.data?.message}`
);
res?.status(error.response.status).send(error.response.data);
logger.warn(message, {
response: {
data: error.response.data,
status: error.response.status,
},
});
return { error, message };
} else if (error.request) {
// No response received
res.status(504).send({ message: "No response from Spotify" });
logger.error(logPrefix + "No response", { error });
// Request sent, but no response received
message = message.concat("No response");
res?.status(504).send({ message });
logger.error(message, { error });
return { error, message };
} else {
// Something happened in setting up the request that triggered an Error
res.status(500).send({ message: "Internal Server Error" });
logger.error(logPrefix + "Request failed?", { error });
message = message.concat("Request failed");
res?.status(500).send({ message: "Internal Server Error" });
logger.error(message, { error });
return { error, message };
}
return null;
}
};
interface GetCurrentUsersProfileArgs extends EndpointHandlerBaseArgs {}
interface GetCurrentUsersProfileArgs extends EndpointHandlerWithResArgs {}
type GetCurrentUsersProfile = SingleRequestResult<GetCurrentUsersProfileData>;
const getCurrentUsersProfile: (
opts: GetCurrentUsersProfileArgs
) => Promise<GetCurrentUsersProfile | null> = async ({ authHeaders, res }) => {
const response = await singleRequest<GetCurrentUsersProfile>(
authHeaders,
) => GetCurrentUsersProfile = async ({ res, authHeaders }) => {
return await singleRequest<GetCurrentUsersProfileData>({
res,
allowedMethods.Get,
"/me"
);
return response ? response.data : null;
authHeaders,
path: "/me",
});
};
interface GetCurrentUsersPlaylistsFirstPageArgs
extends EndpointHandlerBaseArgs {}
extends EndpointHandlerWithResArgs {}
type GetCurrentUsersPlaylists =
SingleRequestResult<GetCurrentUsersPlaylistsData>;
const getCurrentUsersPlaylistsFirstPage: (
opts: GetCurrentUsersPlaylistsFirstPageArgs
) => Promise<GetCurrentUsersPlaylists | null> = async ({
authHeaders,
) => GetCurrentUsersPlaylists = async ({ res, authHeaders }) => {
return await singleRequest<GetCurrentUsersPlaylistsData>({
res,
}) => {
const response = await singleRequest<GetCurrentUsersPlaylists>(
authHeaders,
res,
allowedMethods.Get,
`/me/playlists`,
{
path: `/me/playlists`,
config: {
params: {
offset: 0,
limit: 50,
},
}
);
return response?.data ?? null;
},
});
};
interface GetCurrentUsersPlaylistsNextPageArgs extends EndpointHandlerBaseArgs {
interface GetCurrentUsersPlaylistsNextPageArgs
extends EndpointHandlerWithResArgs {
nextURL: string;
}
const getCurrentUsersPlaylistsNextPage: (
opts: GetCurrentUsersPlaylistsNextPageArgs
) => Promise<GetCurrentUsersPlaylists | null> = async ({
authHeaders,
) => GetCurrentUsersPlaylists = async ({ res, authHeaders, nextURL }) => {
return await singleRequest<GetCurrentUsersPlaylistsData>({
res,
nextURL,
}) => {
const response = await singleRequest<GetCurrentUsersPlaylists>(
authHeaders,
res,
allowedMethods.Get,
nextURL
);
return response?.data ?? null;
path: nextURL,
});
};
interface GetPlaylistDetailsFirstPageArgs extends EndpointHandlerBaseArgs {
interface GetPlaylistDetailsFirstPageArgs extends EndpointHandlerWithResArgs {
initialFields: string;
playlistID: string;
}
type GetPlaylistDetailsFirstPage = SingleRequestResult<GetPlaylistData>;
const getPlaylistDetailsFirstPage: (
opts: GetPlaylistDetailsFirstPageArgs
) => Promise<GetPlaylist | null> = async ({
authHeaders,
) => GetPlaylistDetailsFirstPage = async ({
res,
authHeaders,
initialFields,
playlistID,
}) => {
const response = await singleRequest<GetPlaylist>(
return await singleRequest<GetPlaylistData>({
authHeaders,
res,
allowedMethods.Get,
`/playlists/${playlistID}/`,
{
path: `/playlists/${playlistID}/`,
config: {
params: {
fields: initialFields,
},
}
);
return response?.data ?? null;
},
});
};
interface GetPlaylistDetailsNextPageArgs extends EndpointHandlerBaseArgs {
interface GetPlaylistDetailsNextPageArgs extends EndpointHandlerWithResArgs {
nextURL: string;
}
type GetPlaylistItems = SingleRequestResult<GetPlaylistItemsData>;
const getPlaylistDetailsNextPage: (
opts: GetPlaylistDetailsNextPageArgs
) => Promise<GetPlaylistItems | null> = async ({
authHeaders,
) => GetPlaylistItems = async ({ res, authHeaders, nextURL }) => {
return await singleRequest<GetPlaylistItemsData>({
res,
nextURL,
}) => {
const response = await singleRequest<GetPlaylistItems>(
authHeaders,
res,
allowedMethods.Get,
nextURL
);
return response?.data ?? null;
path: nextURL,
});
};
interface AddItemsToPlaylistArgs extends EndpointHandlerBaseArgs {
nextBatch: string[];
playlistID: string;
}
type AddItemsToPlaylist = SingleRequestResult<AddItemsToPlaylistData>;
const addItemsToPlaylist: (
opts: AddItemsToPlaylistArgs
) => Promise<AddItemsToPlaylist | null> = async ({
) => AddItemsToPlaylist = async ({ authHeaders, nextBatch, playlistID }) => {
return await singleRequest<AddItemsToPlaylistData>({
authHeaders,
res,
nextBatch,
playlistID,
}) => {
const response = await singleRequest<AddItemsToPlaylist>(
authHeaders,
res,
allowedMethods.Post,
`/playlists/${playlistID}/tracks`,
{},
{ uris: nextBatch },
false
);
return response?.data ?? null;
method: allowedMethods.Post,
path: `/playlists/${playlistID}/tracks`,
data: { uris: nextBatch },
inlineData: false,
});
};
interface RemovePlaylistItemsArgs extends EndpointHandlerBaseArgs {
@ -216,66 +211,67 @@ interface RemovePlaylistItemsArgs extends EndpointHandlerBaseArgs {
playlistID: string;
snapshotID: string;
}
type RemovePlaylistItems = SingleRequestResult<RemovePlaylistItemsData>;
const removePlaylistItems: (
opts: RemovePlaylistItemsArgs
) => Promise<RemovePlaylistItems | null> = async ({
) => RemovePlaylistItems = async ({
authHeaders,
res,
nextBatch,
playlistID,
snapshotID,
}) => {
// API doesn't document this kind of deletion via the 'positions' field
// but see here: https://github.com/spotipy-dev/spotipy/issues/95#issuecomment-2263634801
const response = await singleRequest<RemovePlaylistItems>(
// but see here: https://web.archive.org/web/20250313173723/https://github.com/spotipy-dev/spotipy/issues/95#issuecomment-2263634801
return await singleRequest<RemovePlaylistItemsData>({
authHeaders,
res,
allowedMethods.Delete,
`/playlists/${playlistID}/tracks`,
{},
method: allowedMethods.Delete,
path: `/playlists/${playlistID}/tracks`,
// axios delete method doesn't have separate arg for body so hv to put it in config
{ positions: nextBatch, snapshot_id: snapshotID },
true
);
return response?.data ?? null;
data: { positions: nextBatch, snapshot_id: snapshotID },
inlineData: true,
});
};
// ---------
// non-endpoints, i.e. convenience wrappers
// ---------
interface CheckPlaylistEditableArgs extends EndpointHandlerBaseArgs {
interface CheckPlaylistEditableArgs extends EndpointHandlerWithResArgs {
playlistID: string;
userID: string;
}
type CheckPlaylistEditable = Promise<{
status: boolean;
error?: any;
message: string;
}>;
const checkPlaylistEditable: (
opts: CheckPlaylistEditableArgs
) => Promise<boolean> = async ({ authHeaders, res, playlistID, userID }) => {
let checkFields = ["collaborative", "owner(id)"];
const checkFromData = await getPlaylistDetailsFirstPage({
authHeaders,
) => CheckPlaylistEditable = async ({
res,
authHeaders,
playlistID,
userID,
}) => {
let checkFields = ["collaborative", "owner(id)"];
const { resp, error, message } = await getPlaylistDetailsFirstPage({
res,
authHeaders,
initialFields: checkFields.join(),
playlistID,
});
if (!checkFromData) return false;
if (!resp) return { status: false, error, message };
// https://web.archive.org/web/20241226081630/https://developer.spotify.com/documentation/web-api/concepts/playlists#:~:text=A%20playlist%20can%20also%20be%20made%20collaborative
// playlist is editable if it's collaborative (and thus private) or owned by the user
if (
checkFromData.collaborative !== true &&
checkFromData.owner?.id !== userID
) {
res.status(403).send({
message:
"You cannot edit this playlist, you must be the owner/the playlist must be collaborative",
playlistID,
});
logger.info("user cannot edit target playlist", { playlistID });
return false;
if (resp.data.collaborative !== true && resp.data.owner.id !== userID) {
return {
status: false,
error: { playlistID, playlistName: resp.data.name },
message: "Cannot edit playlist: " + resp.data.name,
};
} else {
return true;
return { status: true, message: "" };
}
};

View File

@ -1,6 +1,6 @@
// https://github.com/motdotla/dotenv/issues/133#issuecomment-255298822
// explanation: ESM import statements execute first
// so the .config gets called after all other imports in index.ts
// explanation: in ESM, import statements execute first, unlike CJS where it's line order
// so if placed directly in index.ts, the .config gets called after all other imports in index.ts
// and one of those imports is the sequelize loader, which depends on env being loaded
// soln: raise the priority of dotenv to match by placing it in a separate module like this

View File

@ -21,14 +21,12 @@ const login: RequestHandler = async (_req, res) => {
const state = generateRandString(16);
res.cookie(stateKey, state);
const scope = Object.values(requiredScopes).join(" ");
res.redirect(
`${accountsAPIURL}/authorize?` +
new URLSearchParams({
response_type: "code",
client_id: process.env["CLIENT_ID"],
scope: scope,
scope: Object.values(requiredScopes).join(" "),
redirect_uri: process.env["REDIRECT_URI"],
state: state,
} as Record<string, string>).toString()
@ -88,17 +86,20 @@ const callback: RequestHandler = async (req, res) => {
return null;
}
const userData = await getCurrentUsersProfile({ authHeaders, res });
if (!userData) return null;
const { resp } = await getCurrentUsersProfile({
res,
authHeaders,
});
if (!resp) return null;
req.session.user = {
username: userData.display_name ?? "",
id: userData.id,
username: resp.data.display_name ?? "",
id: resp.data.id,
};
// res.status(200).send({ message: "OK" });
res.redirect(process.env["APP_URI"] + "?login=success");
logger.debug("New login.", { username: userData.display_name });
logger.debug("New login.", { username: resp.data.display_name });
return null;
}
} catch (error) {
@ -141,7 +142,10 @@ const refresh: RequestHandler = async (req, res) => {
res
.status(response.status)
.send({ message: "Error: Refresh token flow failed." });
logger.error("refresh failed", { statusCode: response.status });
logger.error("refresh failed", {
statusCode: response.status,
data: response.data,
});
return null;
}
} catch (error) {

View File

@ -12,7 +12,7 @@ import {
import type { RequestHandler } from "express";
import type {
EndpointHandlerBaseArgs,
EndpointHandlerWithResArgs,
LinkModel_Edge,
PlaylistModel_Pl,
URIObject,
@ -47,11 +47,12 @@ const updateUser: RequestHandler = async (req, res) => {
let currentPlaylists: PlaylistModel_Pl[] = [];
// get first 50
const respData = await getCurrentUsersPlaylistsFirstPage({
const { resp } = await getCurrentUsersPlaylistsFirstPage({
authHeaders,
res,
});
if (!respData) return null;
if (!resp) return null;
const respData = resp.data;
currentPlaylists = respData.items.map((playlist) => {
return {
@ -63,12 +64,13 @@ const updateUser: RequestHandler = async (req, res) => {
// keep getting batches of 50 till exhausted
while (nextURL) {
const nextData = await getCurrentUsersPlaylistsNextPage({
const { resp } = await getCurrentUsersPlaylistsNextPage({
authHeaders,
res,
nextURL,
});
if (!nextData) return null;
if (!resp) return null;
const nextData = resp.data;
currentPlaylists.push(
...nextData.items.map((playlist) => {
@ -411,7 +413,7 @@ const removeLink: RequestHandler = async (req, res) => {
}
};
interface _GetPlaylistTracksArgs extends EndpointHandlerBaseArgs {
interface _GetPlaylistTracksArgs extends EndpointHandlerWithResArgs {
playlistID: string;
}
interface _GetPlaylistTracks {
@ -431,13 +433,14 @@ const _getPlaylistTracks: (
let initialFields = ["snapshot_id,tracks(next,items(is_local,track(uri)))"];
let mainFields = ["next", "items(is_local,track(uri))"];
const respData = await getPlaylistDetailsFirstPage({
const { resp } = await getPlaylistDetailsFirstPage({
authHeaders,
res,
initialFields: initialFields.join(),
playlistID,
});
if (!respData) return null;
if (!resp) return null;
const respData = resp.data;
// check cache
const cachedSnapshotID = await redisClient.get(
@ -470,12 +473,13 @@ const _getPlaylistTracks: (
// keep getting batches of 50 till exhausted
while (nextURL) {
const nextData = await getPlaylistDetailsNextPage({
const { resp } = await getPlaylistDetailsNextPage({
authHeaders,
res,
nextURL,
});
if (!nextData) return null;
if (!resp) return null;
const nextData = resp.data;
pl.tracks.push(
...nextData.items.map((playlist_item) => {
@ -499,7 +503,7 @@ const _getPlaylistTracks: (
return pl;
};
interface _PopulateSingleLinkCoreArgs extends EndpointHandlerBaseArgs {
interface _PopulateSingleLinkCoreArgs extends EndpointHandlerWithResArgs {
link: {
from: URIObject;
to: URIObject;
@ -522,29 +526,29 @@ interface _PopulateSingleLinkCoreArgs extends EndpointHandlerBaseArgs {
*
* CANNOT populate local files; Spotify API does not support it yet.
*/
const _populateSingleLinkCore: (
opts: _PopulateSingleLinkCoreArgs
) => Promise<{ toAddNum: number; localNum: number } | null> = async ({
authHeaders,
res,
link,
}) => {
const _populateSingleLinkCore: (opts: _PopulateSingleLinkCoreArgs) => Promise<{
toAddNum: number;
addedNum: number;
localNum: number;
} | null> = async ({ res, authHeaders, link }) => {
try {
const fromPl = link.from,
toPl = link.to;
const fromPlaylist = await _getPlaylistTracks({
authHeaders,
res,
authHeaders,
playlistID: fromPl.id,
});
if (!fromPlaylist) return null;
const toPlaylist = await _getPlaylistTracks({
authHeaders,
res,
authHeaders,
playlistID: toPl.id,
});
if (!toPlaylist) return null;
if (!fromPlaylist || !toPlaylist) return null;
const fromTrackURIs = fromPlaylist.tracks.map((track) => track.uri);
let toTrackURIs = toPlaylist.tracks
.filter((track) => !track.is_local) // API doesn't support adding local files to playlists yet
@ -553,20 +557,21 @@ const _populateSingleLinkCore: (
const toAddNum = toTrackURIs.length;
const localNum = toPlaylist.tracks.filter((track) => track.is_local).length;
let addedNum = 0;
// append to end in batches of 100
while (toTrackURIs.length > 0) {
const nextBatch = toTrackURIs.splice(0, 100);
const addData = await addItemsToPlaylist({
const { resp } = await addItemsToPlaylist({
authHeaders,
res,
nextBatch,
playlistID: fromPl.id,
});
if (!addData) return null;
if (!resp) break;
addedNum += nextBatch.length;
}
return { toAddNum, localNum };
return { toAddNum, addedNum, localNum };
} catch (error) {
res.status(500).send({ message: "Internal Server Error" });
logger.error("_populateSingleLinkCore", { error });
@ -612,12 +617,14 @@ const populateSingleLink: RequestHandler = async (req, res) => {
}
if (
!(await checkPlaylistEditable({
!(
await checkPlaylistEditable({
authHeaders,
res,
playlistID: fromPl.id,
userID: uID,
}))
})
).status
)
return null;
@ -627,15 +634,18 @@ const populateSingleLink: RequestHandler = async (req, res) => {
link: { from: fromPl, to: toPl },
});
if (result) {
const { toAddNum, localNum } = result;
let logMsg;
logMsg =
toAddNum > 0 ? "Added " + toAddNum + " tracks" : "No tracks to add";
logMsg +=
localNum > 0 ? "; could not process " + localNum + " local files" : ".";
const { toAddNum, addedNum, localNum } = result;
let message;
message =
toAddNum > 0 ? "Added " + addedNum + " tracks" : "No tracks to add";
message +=
addedNum < toAddNum
? ", failed to add " + (toAddNum - addedNum) + " tracks"
: "";
message += localNum > 0 ? ", skipped " + localNum + " local files" : ".";
res.status(200).send({ message: logMsg });
logger.debug(logMsg, { toAddNum, localNum });
res.status(200).send({ message, toAddNum, addedNum, localNum });
logger.debug(message, { toAddNum, localNum });
}
return null;
} catch (error) {
@ -645,7 +655,7 @@ const populateSingleLink: RequestHandler = async (req, res) => {
}
};
interface _PruneSingleLinkCoreArgs extends EndpointHandlerBaseArgs {
interface _PruneSingleLinkCoreArgs extends EndpointHandlerWithResArgs {
link: { from: URIObject; to: URIObject };
}
/**
@ -665,7 +675,7 @@ interface _PruneSingleLinkCoreArgs extends EndpointHandlerBaseArgs {
*/
const _pruneSingleLinkCore: (
opts: _PruneSingleLinkCoreArgs
) => Promise<{ toDelNum: number } | null> = async ({
) => Promise<{ toDelNum: number; deletedNum: number } | null> = async ({
authHeaders,
res,
link,
@ -679,13 +689,15 @@ const _pruneSingleLinkCore: (
res,
playlistID: fromPl.id,
});
if (!fromPlaylist) return null;
const toPlaylist = await _getPlaylistTracks({
authHeaders,
res,
playlistID: toPl.id,
});
if (!toPlaylist) return null;
if (!fromPlaylist || !toPlaylist) return null;
const fromTrackURIs = fromPlaylist.tracks.map((track) => track.uri);
const indexedToTrackURIs = toPlaylist.tracks.map((track, index) => {
return { ...track, position: index };
@ -696,23 +708,24 @@ const _pruneSingleLinkCore: (
.map((track) => track.position); // get track positions
const toDelNum = indexes.length;
let deletedNum = 0;
// remove in batches of 100 (from reverse, to preserve positions while modifying)
let currentSnapshot = toPlaylist.snapshotID;
while (indexes.length > 0) {
const nextBatch = indexes.splice(Math.max(indexes.length - 100, 0), 100);
const delResponse = await removePlaylistItems({
const { resp } = await removePlaylistItems({
authHeaders,
res,
nextBatch,
playlistID: toPl.id,
snapshotID: currentSnapshot,
});
if (!delResponse) return null;
currentSnapshot = delResponse.snapshot_id;
if (!resp) break;
deletedNum += nextBatch.length;
currentSnapshot = resp.data.snapshot_id;
}
return { toDelNum };
return { toDelNum, deletedNum };
} catch (error) {
res.status(500).send({ message: "Internal Server Error" });
logger.error("_pruneSingleLinkCore", { error });
@ -758,12 +771,14 @@ const pruneSingleLink: RequestHandler = async (req, res) => {
}
if (
!(await checkPlaylistEditable({
!(
await checkPlaylistEditable({
authHeaders,
res,
playlistID: toPl.id,
userID: uID,
}))
})
).status
)
return null;
@ -776,9 +791,19 @@ const pruneSingleLink: RequestHandler = async (req, res) => {
},
});
if (result) {
const { toDelNum } = result;
res.status(200).send({ message: `Removed ${toDelNum} tracks.` });
logger.debug(`Pruned ${toDelNum} tracks`, { toDelNum });
const { toDelNum, deletedNum } = result;
let message;
message =
toDelNum > 0
? "Removed " + deletedNum + " tracks"
: "No tracks to remove";
message +=
deletedNum < toDelNum
? ", failed to remove " + (toDelNum - deletedNum) + " tracks"
: ".";
res.status(200).send({ message, toDelNum, deletedNum });
logger.debug(message, { toDelNum, deletedNum });
}
return null;
} catch (error) {

View File

@ -20,29 +20,29 @@ const fetchUserPlaylists: RequestHandler = async (req, res) => {
if (!authHeaders)
throw new ReferenceError("session does not have auth headers");
// get first 50
const respData = await getCurrentUsersPlaylistsFirstPage({
authHeaders,
const { resp } = await getCurrentUsersPlaylistsFirstPage({
res,
authHeaders,
});
if (!respData) return null;
if (!resp) return null;
let tmpData = structuredClone(respData);
const userPlaylists: Pick<
Pagination<SimplifiedPlaylistObject>,
"items" | "total"
> = {
items: [...tmpData.items],
total: tmpData.total,
items: [...resp.data.items],
total: resp.data.total,
};
let nextURL = respData.next;
let nextURL = resp.data.next;
// keep getting batches of 50 till exhausted
while (nextURL) {
const nextData = await getCurrentUsersPlaylistsNextPage({
const { resp } = await getCurrentUsersPlaylistsNextPage({
authHeaders,
res,
nextURL,
});
if (!nextData) return null;
if (!resp) return null;
const nextData = resp.data;
userPlaylists.items.push(...nextData.items);
nextURL = nextData.next;

View File

@ -94,8 +94,8 @@ app.use("/auth-health", isAuthenticated, async (req, res) => {
const { authHeaders } = req.session;
if (!authHeaders)
throw new ReferenceError("session does not have auth headers");
const respData = await getCurrentUsersProfile({ authHeaders, res });
if (!respData) return null;
const { resp } = await getCurrentUsersProfile({ authHeaders, res });
if (!resp) return null;
res.status(200).send({ message: "OK" });
return null;
} catch (error) {
@ -130,9 +130,7 @@ const server = app.listen(port, () => {
const cleanupFunc = (signal?: string) => {
if (signal) logger.debug(`${signal} signal received, shutting down now...`);
Promise.allSettled([
promisify(server.close),
]).then(() => {
Promise.allSettled([promisify(server.close)]).then(() => {
logger.info("Cleaned up, exiting.");
process.exit(0);
});

View File

@ -4,7 +4,7 @@ import { sessionName } from "../constants.ts";
import logger from "../utils/logger.ts";
export const isAuthenticated: RequestHandler = (req, res, next) => {
const isAuthenticated: RequestHandler = (req, res, next) => {
if (req.session.accessToken) {
req.session.authHeaders = {
Authorization: `Bearer ${req.session.accessToken}`,
@ -27,3 +27,5 @@ export const isAuthenticated: RequestHandler = (req, res, next) => {
});
}
};
export { isAuthenticated };

View File

@ -32,52 +32,54 @@ import type {
// GET method
// Albums
export type GetAlbum = AlbumObject;
export type GetSeveralAlbums = { albums: AlbumObject[] };
export type GetAlbumTracks = Pagination<SimplifiedTrackObject>;
export type GetUsersSavedAlbums = Pagination<SavedAlbumObject>;
export type CheckUsersSavedAlbums = boolean[];
export type GetNewReleases = { albums: Pagination<SimplifiedAlbumObject> };
export type GetAlbumData = AlbumObject;
export type GetSeveralAlbumsData = { albums: AlbumObject[] };
export type GetAlbumTracksData = Pagination<SimplifiedTrackObject>;
export type GetUsersSavedAlbumsData = Pagination<SavedAlbumObject>;
export type CheckUsersSavedAlbumsData = boolean[];
export type GetNewReleasesData = { albums: Pagination<SimplifiedAlbumObject> };
// Artists
export type GetArtist = ArtistObject;
export type GetSeveralArtists = { artists: ArtistObject[] };
export type GetArtistsAlbums = Pagination<ArtistsAlbumObject>;
export type GetArtistsTopTracks = { tracks: TrackObject[] };
export type GetArtistData = ArtistObject;
export type GetSeveralArtistsData = { artists: ArtistObject[] };
export type GetArtistsAlbumsData = Pagination<ArtistsAlbumObject>;
export type GetArtistsTopTracksData = { tracks: TrackObject[] };
// Episodes
export type GetEpisode = EpisodeObject;
export type GetSeveralEpisodes = { episodes: EpisodeObject[] };
export type GetUsersSavedEpisodes = Pagination<SavedEpisodeObject>;
export type GetEpisodeData = EpisodeObject;
export type GetSeveralEpisodesData = { episodes: EpisodeObject[] };
export type GetUsersSavedEpisodesData = Pagination<SavedEpisodeObject>;
// Shows
export type GetShow = ShowObject;
export type GetSeveralShows = { shows: SimplifiedShowObject[] };
export type GetShowEpisodes = Pagination<SimplifiedEpisodeObject>;
export type GetUsersSavedShows = Pagination<SavedShowObject>;
export type GetShowData = ShowObject;
export type GetSeveralShowsData = { shows: SimplifiedShowObject[] };
export type GetShowEpisodesData = Pagination<SimplifiedEpisodeObject>;
export type GetUsersSavedShowsData = Pagination<SavedShowObject>;
// Playlists
export type GetPlaylist = PlaylistObject;
export type GetPlaylistItems = Pagination<PlaylistTrackObject>;
export type GetCurrentUsersPlaylists = Pagination<SimplifiedPlaylistObject>;
export type GetUsersPlaylists = GetCurrentUsersPlaylists;
export type GetPlaylistCoverImage = ImageObject[];
export type GetPlaylistData = PlaylistObject;
export type GetPlaylistItemsData = Pagination<PlaylistTrackObject>;
export type GetCurrentUsersPlaylistsData = Pagination<SimplifiedPlaylistObject>;
export type GetUsersPlaylistsData = Pagination<SimplifiedPlaylistObject>;
export type GetPlaylistCoverImageData = ImageObject[];
// Tracks
export type GetTrack = TrackObject;
export type GetSeveralTracks = { tracks: TrackObject[] };
export type GetUsersSavedTracks = Pagination<SavedTrackObject>;
export type CheckUsersSavedTracks = boolean[];
export type GetTrackData = TrackObject;
export type GetSeveralTracksData = { tracks: TrackObject[] };
export type GetUsersSavedTracksData = Pagination<SavedTrackObject>;
export type CheckUsersSavedTracksData = boolean[];
// Users
export type GetCurrentUsersProfile = UserObject;
export type GetUsersTopItems =
export type GetCurrentUsersProfileData = UserObject;
export type GetUsersTopItemsData =
| Pagination<ArtistObject>
| Pagination<TrackObject>;
export type GetUsersProfile = SimplifiedUserObject;
export type GetFollowedArtists = { artists: PaginationByCursor<ArtistObject> };
export type CheckIfUserFollowsArtistsOrNot = boolean[];
export type CheckIfCurrentUserFollowsPlaylist = boolean[];
export type GetUsersProfileData = SimplifiedUserObject;
export type GetFollowedArtistsData = {
artists: PaginationByCursor<ArtistObject>;
};
export type CheckIfUserFollowsArtistsOrNotData = boolean[];
export type CheckIfCurrentUserFollowsPlaylistData = boolean[];
// POST method
// Albums
@ -86,48 +88,48 @@ export type CheckIfCurrentUserFollowsPlaylist = boolean[];
// Shows
// Playlists
export type AddItemsToPlaylist = { snapshot_id: string };
export type CreatePlaylist = PlaylistObject;
export type AddItemsToPlaylistData = { snapshot_id: string };
export type CreatePlaylistData = PlaylistObject;
// Tracks
// Users
// PUT method
// Albums
export type SaveAlbumsForCurrentUser = {};
export type SaveAlbumsForCurrentUserData = {};
// Artists
// Episodes
// Shows
// Playlists
export type ChangePlaylistDetails = {};
export type UpdatePlaylistItems = { snapshot_id: string };
export type AddCustomPlaylistCoverImage = {};
export type ChangePlaylistDetailsData = {};
export type UpdatePlaylistItemsData = { snapshot_id: string };
export type AddCustomPlaylistCoverImageData = {};
// Tracks
export type SaveTracksForCurrentUser = {};
export type SaveTracksForCurrentUserData = {};
// Users
export type FollowPlaylist = {};
export type FollowArtistsOrUsers = {};
export type FollowPlaylistData = {};
export type FollowArtistsOrUsersData = {};
// DELETE method
// Albums
export type RemoveUsersSavedAlbums = {};
export type RemoveUsersSavedAlbumsData = {};
// Artists
// Episodes
// Shows
// Playlists
export type RemovePlaylistItems = { snapshot_id: string };
export type RemovePlaylistItemsData = { snapshot_id: string };
// Tracks
export type RemoveUsersSavedTracks = {};
export type RemoveUsersSavedTracksData = {};
// Users
export type UnfollowPlaylist = {};
export type UnfollowArtistsOrUsers = {};
export type UnfollowPlaylistData = {};
export type UnfollowArtistsOrUsersData = {};
// <insert other method> method
// Albums

View File

@ -7,5 +7,7 @@ export type Next = NextFunction;
export interface EndpointHandlerBaseArgs {
authHeaders: RawAxiosRequestHeaders;
}
export interface EndpointHandlerWithResArgs extends EndpointHandlerBaseArgs {
res: Res;
}

View File

@ -1,5 +1,7 @@
export const sleep = (ms: number): Promise<unknown> =>
const sleep = (ms: number): Promise<unknown> =>
new Promise((resolve) => setTimeout(resolve, ms));
export const randomBool = (chance_of_failure = 0.25): boolean =>
const randomBool = (chance_of_failure = 0.25): boolean =>
Math.random() < chance_of_failure;
export { sleep, randomBool };

View File

@ -1,7 +1,7 @@
/**
* Generates a random string containing numbers and letters
*/
export const generateRandString = (length: number): string => {
const generateRandString = (length: number): string => {
const possible =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
let text = "";
@ -11,3 +11,5 @@ export const generateRandString = (length: number): string => {
}
return text;
};
export { generateRandString };

View File

@ -1,5 +1,5 @@
export type GNode = string;
export type GEdge = { from: string; to: string };
type GNode = string;
type GEdge = { from: string; to: string };
/**
* Directed graph, may or may not be connected.
@ -20,7 +20,7 @@ export type GEdge = { from: string; to: string };
* console.log(g.detectCycle()); // true
* ```
*/
export class myGraph {
class myGraph {
nodes: GNode[];
edges: GEdge[];
/**
@ -135,4 +135,5 @@ export class myGraph {
}
}
export { type GNode, type GEdge, myGraph };
export default myGraph;

View File

@ -1,8 +1,5 @@
/** Stringifies only values of a JSON object, including nested ones */
export const getNestedValuesString = (
obj: any,
delimiter: string = ", "
): string => {
const getNestedValuesString = (obj: any, delimiter: string = ", "): string => {
let values: string[] = [];
for (const key in obj) {
if (typeof obj[key] !== "object") {
@ -14,3 +11,5 @@ export const getNestedValuesString = (
return values.join(delimiter);
};
export { getNestedValuesString };

View File

@ -54,12 +54,16 @@ const winstonLogger: Logger = createLogger({
new transports.File({
filename: path.join(import.meta.dirname, "..", "logs", "error.log"),
level: "error",
maxsize: 1048576,
}),
],
});
winstonLogger.on("error", (error) =>
winstonLogger.error("Error inside logger", { error })
);
winstonLogger.exceptions.handle(
new transports.File({
filename: path.join(import.meta.dirname, "..", "logs", "exceptions.log"),
})
);
export default winstonLogger;

View File

@ -4,7 +4,7 @@ const base62Pattern: RegExp = /^[A-Za-z0-9]+$/;
/**
* Returns type and ID from a Spotify URI
* @see {@link https://developer.spotify.com/documentation/web-api/concepts/spotify-uris-ids|Spotify URIs and IDs}
* @see {@link https://web.archive.org/web/20250313174409/https://developer.spotify.com/documentation/web-api/concepts/spotify-uris-ids|Spotify URIs and IDs}
* @param uri Spotify URI - can be of an album, track, playlist, user, episode, etc.
* @throws {TypeError} If the input is not a valid Spotify URI
*/

View File

@ -6,8 +6,7 @@ import { getNestedValuesString } from "../utils/jsonTransformer.ts";
import logger from "../utils/logger.ts";
/** Refer: https://stackoverflow.com/questions/58848625/access-messages-in-express-validator */
export const validate: RequestHandler = (req, res, next) => {
const validate: RequestHandler = (req, res, next) => {
const errors = validationResult(req);
if (errors.isEmpty()) {
return next();
@ -35,3 +34,5 @@ export const validate: RequestHandler = (req, res, next) => {
logger.warn("invalid request", { extractedErrors });
return null;
};
export { validate };