mirror of
https://github.com/20kaushik02/spotify-manager.git
synced 2025-12-06 10:34:07 +00:00
improved API request wrapper, partial completion of operations
This commit is contained in:
parent
7eec2adc7a
commit
17e0480f83
276
api/spotify.ts
276
api/spotify.ts
@ -6,13 +6,14 @@ import {
|
|||||||
type RawAxiosRequestHeaders,
|
type RawAxiosRequestHeaders,
|
||||||
} from "axios";
|
} from "axios";
|
||||||
import type {
|
import type {
|
||||||
AddItemsToPlaylist,
|
AddItemsToPlaylistData,
|
||||||
EndpointHandlerBaseArgs,
|
EndpointHandlerBaseArgs,
|
||||||
GetCurrentUsersPlaylists,
|
EndpointHandlerWithResArgs,
|
||||||
GetCurrentUsersProfile,
|
GetCurrentUsersPlaylistsData,
|
||||||
GetPlaylist,
|
GetCurrentUsersProfileData,
|
||||||
GetPlaylistItems,
|
GetPlaylistData,
|
||||||
RemovePlaylistItems,
|
GetPlaylistItemsData,
|
||||||
|
RemovePlaylistItemsData,
|
||||||
Res,
|
Res,
|
||||||
} from "spotify_manager/index.d.ts";
|
} from "spotify_manager/index.d.ts";
|
||||||
|
|
||||||
@ -26,25 +27,41 @@ enum allowedMethods {
|
|||||||
Delete = "delete",
|
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
|
* Spotify API (v1) - 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)
|
|
||||||
*/
|
*/
|
||||||
const singleRequest = async <RespDataType>(
|
const singleRequest = async <RespDataType>({
|
||||||
authHeaders: RawAxiosRequestHeaders,
|
res,
|
||||||
res: Res,
|
authHeaders,
|
||||||
method: allowedMethods,
|
method = allowedMethods.Get,
|
||||||
path: string,
|
path,
|
||||||
config: AxiosRequestConfig = {},
|
config = {},
|
||||||
data: any = null,
|
data = null,
|
||||||
inlineData: boolean = false
|
inlineData = false,
|
||||||
): Promise<AxiosResponse<RespDataType, any> | null> => {
|
}: SingleRequestArgs): SingleRequestResult<RespDataType> => {
|
||||||
let resp: AxiosResponse<RespDataType, any>;
|
let resp: AxiosResponse<RespDataType, any>;
|
||||||
config.headers = { ...config.headers, ...authHeaders };
|
config.headers = { ...config.headers, ...authHeaders };
|
||||||
try {
|
try {
|
||||||
@ -55,160 +72,138 @@ const singleRequest = async <RespDataType>(
|
|||||||
resp = await axiosInstance[method](path, data, config);
|
resp = await axiosInstance[method](path, data, config);
|
||||||
}
|
}
|
||||||
logger.debug(logPrefix + "Successful response received.");
|
logger.debug(logPrefix + "Successful response received.");
|
||||||
return resp;
|
return { resp, message: "" };
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
let message = logPrefix;
|
||||||
if (error.response) {
|
if (error.response) {
|
||||||
// Non 2XX response received
|
// Non 2XX response received
|
||||||
let logMsg;
|
message = message.concat(
|
||||||
if (error.response.status >= 400 && error.response.status < 600) {
|
`${error.response.status} - ${error.response.data?.message}`
|
||||||
res.status(error.response.status).send(error.response.data);
|
);
|
||||||
logMsg = "" + error.response.status;
|
res?.status(error.response.status).send(error.response.data);
|
||||||
} else {
|
logger.warn(message, {
|
||||||
res.sendStatus(error.response.status);
|
|
||||||
logMsg = "???";
|
|
||||||
}
|
|
||||||
logger.warn(logPrefix + logMsg, {
|
|
||||||
response: {
|
response: {
|
||||||
data: error.response.data,
|
data: error.response.data,
|
||||||
status: error.response.status,
|
status: error.response.status,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
return { error, message };
|
||||||
} else if (error.request) {
|
} else if (error.request) {
|
||||||
// No response received
|
// Request sent, but no response received
|
||||||
res.status(504).send({ message: "No response from Spotify" });
|
message = message.concat("No response");
|
||||||
logger.error(logPrefix + "No response", { error });
|
res?.status(504).send({ message });
|
||||||
|
logger.error(message, { error });
|
||||||
|
return { error, message };
|
||||||
} else {
|
} else {
|
||||||
// Something happened in setting up the request that triggered an Error
|
// Something happened in setting up the request that triggered an Error
|
||||||
res.status(500).send({ message: "Internal Server Error" });
|
message = message.concat("Request failed");
|
||||||
logger.error(logPrefix + "Request failed?", { error });
|
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: (
|
const getCurrentUsersProfile: (
|
||||||
opts: GetCurrentUsersProfileArgs
|
opts: GetCurrentUsersProfileArgs
|
||||||
) => Promise<GetCurrentUsersProfile | null> = async ({ authHeaders, res }) => {
|
) => GetCurrentUsersProfile = async ({ res, authHeaders }) => {
|
||||||
const response = await singleRequest<GetCurrentUsersProfile>(
|
return await singleRequest<GetCurrentUsersProfileData>({
|
||||||
authHeaders,
|
|
||||||
res,
|
res,
|
||||||
allowedMethods.Get,
|
authHeaders,
|
||||||
"/me"
|
path: "/me",
|
||||||
);
|
});
|
||||||
return response ? response.data : null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
interface GetCurrentUsersPlaylistsFirstPageArgs
|
interface GetCurrentUsersPlaylistsFirstPageArgs
|
||||||
extends EndpointHandlerBaseArgs {}
|
extends EndpointHandlerWithResArgs {}
|
||||||
|
type GetCurrentUsersPlaylists =
|
||||||
|
SingleRequestResult<GetCurrentUsersPlaylistsData>;
|
||||||
const getCurrentUsersPlaylistsFirstPage: (
|
const getCurrentUsersPlaylistsFirstPage: (
|
||||||
opts: GetCurrentUsersPlaylistsFirstPageArgs
|
opts: GetCurrentUsersPlaylistsFirstPageArgs
|
||||||
) => Promise<GetCurrentUsersPlaylists | null> = async ({
|
) => GetCurrentUsersPlaylists = async ({ res, authHeaders }) => {
|
||||||
authHeaders,
|
return await singleRequest<GetCurrentUsersPlaylistsData>({
|
||||||
res,
|
|
||||||
}) => {
|
|
||||||
const response = await singleRequest<GetCurrentUsersPlaylists>(
|
|
||||||
authHeaders,
|
|
||||||
res,
|
res,
|
||||||
allowedMethods.Get,
|
authHeaders,
|
||||||
`/me/playlists`,
|
path: `/me/playlists`,
|
||||||
{
|
config: {
|
||||||
params: {
|
params: {
|
||||||
offset: 0,
|
offset: 0,
|
||||||
limit: 50,
|
limit: 50,
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
);
|
});
|
||||||
return response?.data ?? null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
interface GetCurrentUsersPlaylistsNextPageArgs extends EndpointHandlerBaseArgs {
|
interface GetCurrentUsersPlaylistsNextPageArgs
|
||||||
|
extends EndpointHandlerWithResArgs {
|
||||||
nextURL: string;
|
nextURL: string;
|
||||||
}
|
}
|
||||||
const getCurrentUsersPlaylistsNextPage: (
|
const getCurrentUsersPlaylistsNextPage: (
|
||||||
opts: GetCurrentUsersPlaylistsNextPageArgs
|
opts: GetCurrentUsersPlaylistsNextPageArgs
|
||||||
) => Promise<GetCurrentUsersPlaylists | null> = async ({
|
) => GetCurrentUsersPlaylists = async ({ res, authHeaders, nextURL }) => {
|
||||||
authHeaders,
|
return await singleRequest<GetCurrentUsersPlaylistsData>({
|
||||||
res,
|
|
||||||
nextURL,
|
|
||||||
}) => {
|
|
||||||
const response = await singleRequest<GetCurrentUsersPlaylists>(
|
|
||||||
authHeaders,
|
|
||||||
res,
|
res,
|
||||||
allowedMethods.Get,
|
authHeaders,
|
||||||
nextURL
|
path: nextURL,
|
||||||
);
|
});
|
||||||
return response?.data ?? null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
interface GetPlaylistDetailsFirstPageArgs extends EndpointHandlerBaseArgs {
|
interface GetPlaylistDetailsFirstPageArgs extends EndpointHandlerWithResArgs {
|
||||||
initialFields: string;
|
initialFields: string;
|
||||||
playlistID: string;
|
playlistID: string;
|
||||||
}
|
}
|
||||||
|
type GetPlaylistDetailsFirstPage = SingleRequestResult<GetPlaylistData>;
|
||||||
const getPlaylistDetailsFirstPage: (
|
const getPlaylistDetailsFirstPage: (
|
||||||
opts: GetPlaylistDetailsFirstPageArgs
|
opts: GetPlaylistDetailsFirstPageArgs
|
||||||
) => Promise<GetPlaylist | null> = async ({
|
) => GetPlaylistDetailsFirstPage = async ({
|
||||||
authHeaders,
|
|
||||||
res,
|
res,
|
||||||
|
authHeaders,
|
||||||
initialFields,
|
initialFields,
|
||||||
playlistID,
|
playlistID,
|
||||||
}) => {
|
}) => {
|
||||||
const response = await singleRequest<GetPlaylist>(
|
return await singleRequest<GetPlaylistData>({
|
||||||
authHeaders,
|
authHeaders,
|
||||||
res,
|
res,
|
||||||
allowedMethods.Get,
|
path: `/playlists/${playlistID}/`,
|
||||||
`/playlists/${playlistID}/`,
|
config: {
|
||||||
{
|
|
||||||
params: {
|
params: {
|
||||||
fields: initialFields,
|
fields: initialFields,
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
);
|
});
|
||||||
return response?.data ?? null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
interface GetPlaylistDetailsNextPageArgs extends EndpointHandlerBaseArgs {
|
interface GetPlaylistDetailsNextPageArgs extends EndpointHandlerWithResArgs {
|
||||||
nextURL: string;
|
nextURL: string;
|
||||||
}
|
}
|
||||||
|
type GetPlaylistItems = SingleRequestResult<GetPlaylistItemsData>;
|
||||||
const getPlaylistDetailsNextPage: (
|
const getPlaylistDetailsNextPage: (
|
||||||
opts: GetPlaylistDetailsNextPageArgs
|
opts: GetPlaylistDetailsNextPageArgs
|
||||||
) => Promise<GetPlaylistItems | null> = async ({
|
) => GetPlaylistItems = async ({ res, authHeaders, nextURL }) => {
|
||||||
authHeaders,
|
return await singleRequest<GetPlaylistItemsData>({
|
||||||
res,
|
|
||||||
nextURL,
|
|
||||||
}) => {
|
|
||||||
const response = await singleRequest<GetPlaylistItems>(
|
|
||||||
authHeaders,
|
|
||||||
res,
|
res,
|
||||||
allowedMethods.Get,
|
authHeaders,
|
||||||
nextURL
|
path: nextURL,
|
||||||
);
|
});
|
||||||
return response?.data ?? null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
interface AddItemsToPlaylistArgs extends EndpointHandlerBaseArgs {
|
interface AddItemsToPlaylistArgs extends EndpointHandlerBaseArgs {
|
||||||
nextBatch: string[];
|
nextBatch: string[];
|
||||||
playlistID: string;
|
playlistID: string;
|
||||||
}
|
}
|
||||||
|
type AddItemsToPlaylist = SingleRequestResult<AddItemsToPlaylistData>;
|
||||||
const addItemsToPlaylist: (
|
const addItemsToPlaylist: (
|
||||||
opts: AddItemsToPlaylistArgs
|
opts: AddItemsToPlaylistArgs
|
||||||
) => Promise<AddItemsToPlaylist | null> = async ({
|
) => AddItemsToPlaylist = async ({ authHeaders, nextBatch, playlistID }) => {
|
||||||
authHeaders,
|
return await singleRequest<AddItemsToPlaylistData>({
|
||||||
res,
|
|
||||||
nextBatch,
|
|
||||||
playlistID,
|
|
||||||
}) => {
|
|
||||||
const response = await singleRequest<AddItemsToPlaylist>(
|
|
||||||
authHeaders,
|
authHeaders,
|
||||||
res,
|
method: allowedMethods.Post,
|
||||||
allowedMethods.Post,
|
path: `/playlists/${playlistID}/tracks`,
|
||||||
`/playlists/${playlistID}/tracks`,
|
data: { uris: nextBatch },
|
||||||
{},
|
inlineData: false,
|
||||||
{ uris: nextBatch },
|
});
|
||||||
false
|
|
||||||
);
|
|
||||||
return response?.data ?? null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
interface RemovePlaylistItemsArgs extends EndpointHandlerBaseArgs {
|
interface RemovePlaylistItemsArgs extends EndpointHandlerBaseArgs {
|
||||||
@ -216,66 +211,67 @@ interface RemovePlaylistItemsArgs extends EndpointHandlerBaseArgs {
|
|||||||
playlistID: string;
|
playlistID: string;
|
||||||
snapshotID: string;
|
snapshotID: string;
|
||||||
}
|
}
|
||||||
|
type RemovePlaylistItems = SingleRequestResult<RemovePlaylistItemsData>;
|
||||||
const removePlaylistItems: (
|
const removePlaylistItems: (
|
||||||
opts: RemovePlaylistItemsArgs
|
opts: RemovePlaylistItemsArgs
|
||||||
) => Promise<RemovePlaylistItems | null> = async ({
|
) => RemovePlaylistItems = async ({
|
||||||
authHeaders,
|
authHeaders,
|
||||||
res,
|
|
||||||
nextBatch,
|
nextBatch,
|
||||||
playlistID,
|
playlistID,
|
||||||
snapshotID,
|
snapshotID,
|
||||||
}) => {
|
}) => {
|
||||||
// API doesn't document this kind of deletion via the 'positions' field
|
// 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
|
// but see here: https://web.archive.org/web/20250313173723/https://github.com/spotipy-dev/spotipy/issues/95#issuecomment-2263634801
|
||||||
const response = await singleRequest<RemovePlaylistItems>(
|
return await singleRequest<RemovePlaylistItemsData>({
|
||||||
authHeaders,
|
authHeaders,
|
||||||
res,
|
method: allowedMethods.Delete,
|
||||||
allowedMethods.Delete,
|
path: `/playlists/${playlistID}/tracks`,
|
||||||
`/playlists/${playlistID}/tracks`,
|
|
||||||
{},
|
|
||||||
// axios delete method doesn't have separate arg for body so hv to put it in config
|
// axios delete method doesn't have separate arg for body so hv to put it in config
|
||||||
{ positions: nextBatch, snapshot_id: snapshotID },
|
data: { positions: nextBatch, snapshot_id: snapshotID },
|
||||||
true
|
inlineData: true,
|
||||||
);
|
});
|
||||||
return response?.data ?? null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------
|
// ---------
|
||||||
// non-endpoints, i.e. convenience wrappers
|
// non-endpoints, i.e. convenience wrappers
|
||||||
// ---------
|
// ---------
|
||||||
|
|
||||||
interface CheckPlaylistEditableArgs extends EndpointHandlerBaseArgs {
|
interface CheckPlaylistEditableArgs extends EndpointHandlerWithResArgs {
|
||||||
playlistID: string;
|
playlistID: string;
|
||||||
userID: string;
|
userID: string;
|
||||||
}
|
}
|
||||||
|
type CheckPlaylistEditable = Promise<{
|
||||||
|
status: boolean;
|
||||||
|
error?: any;
|
||||||
|
message: string;
|
||||||
|
}>;
|
||||||
const checkPlaylistEditable: (
|
const checkPlaylistEditable: (
|
||||||
opts: CheckPlaylistEditableArgs
|
opts: CheckPlaylistEditableArgs
|
||||||
) => Promise<boolean> = async ({ authHeaders, res, playlistID, userID }) => {
|
) => CheckPlaylistEditable = async ({
|
||||||
|
res,
|
||||||
|
authHeaders,
|
||||||
|
playlistID,
|
||||||
|
userID,
|
||||||
|
}) => {
|
||||||
let checkFields = ["collaborative", "owner(id)"];
|
let checkFields = ["collaborative", "owner(id)"];
|
||||||
|
const { resp, error, message } = await getPlaylistDetailsFirstPage({
|
||||||
const checkFromData = await getPlaylistDetailsFirstPage({
|
|
||||||
authHeaders,
|
|
||||||
res,
|
res,
|
||||||
|
authHeaders,
|
||||||
initialFields: checkFields.join(),
|
initialFields: checkFields.join(),
|
||||||
playlistID,
|
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
|
// 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
|
// playlist is editable if it's collaborative (and thus private) or owned by the user
|
||||||
if (
|
if (resp.data.collaborative !== true && resp.data.owner.id !== userID) {
|
||||||
checkFromData.collaborative !== true &&
|
return {
|
||||||
checkFromData.owner?.id !== userID
|
status: false,
|
||||||
) {
|
error: { playlistID, playlistName: resp.data.name },
|
||||||
res.status(403).send({
|
message: "Cannot edit playlist: " + resp.data.name,
|
||||||
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;
|
|
||||||
} else {
|
} else {
|
||||||
return true;
|
return { status: true, message: "" };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
// https://github.com/motdotla/dotenv/issues/133#issuecomment-255298822
|
// https://github.com/motdotla/dotenv/issues/133#issuecomment-255298822
|
||||||
// explanation: ESM import statements execute first
|
// explanation: in ESM, import statements execute first, unlike CJS where it's line order
|
||||||
// so the .config gets called after all other imports in index.ts
|
// 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
|
// 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
|
// soln: raise the priority of dotenv to match by placing it in a separate module like this
|
||||||
|
|
||||||
|
|||||||
@ -21,14 +21,12 @@ const login: RequestHandler = async (_req, res) => {
|
|||||||
const state = generateRandString(16);
|
const state = generateRandString(16);
|
||||||
res.cookie(stateKey, state);
|
res.cookie(stateKey, state);
|
||||||
|
|
||||||
const scope = Object.values(requiredScopes).join(" ");
|
|
||||||
|
|
||||||
res.redirect(
|
res.redirect(
|
||||||
`${accountsAPIURL}/authorize?` +
|
`${accountsAPIURL}/authorize?` +
|
||||||
new URLSearchParams({
|
new URLSearchParams({
|
||||||
response_type: "code",
|
response_type: "code",
|
||||||
client_id: process.env["CLIENT_ID"],
|
client_id: process.env["CLIENT_ID"],
|
||||||
scope: scope,
|
scope: Object.values(requiredScopes).join(" "),
|
||||||
redirect_uri: process.env["REDIRECT_URI"],
|
redirect_uri: process.env["REDIRECT_URI"],
|
||||||
state: state,
|
state: state,
|
||||||
} as Record<string, string>).toString()
|
} as Record<string, string>).toString()
|
||||||
@ -88,17 +86,20 @@ const callback: RequestHandler = async (req, res) => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const userData = await getCurrentUsersProfile({ authHeaders, res });
|
const { resp } = await getCurrentUsersProfile({
|
||||||
if (!userData) return null;
|
res,
|
||||||
|
authHeaders,
|
||||||
|
});
|
||||||
|
if (!resp) return null;
|
||||||
|
|
||||||
req.session.user = {
|
req.session.user = {
|
||||||
username: userData.display_name ?? "",
|
username: resp.data.display_name ?? "",
|
||||||
id: userData.id,
|
id: resp.data.id,
|
||||||
};
|
};
|
||||||
|
|
||||||
// res.status(200).send({ message: "OK" });
|
// res.status(200).send({ message: "OK" });
|
||||||
res.redirect(process.env["APP_URI"] + "?login=success");
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -141,7 +142,10 @@ const refresh: RequestHandler = async (req, res) => {
|
|||||||
res
|
res
|
||||||
.status(response.status)
|
.status(response.status)
|
||||||
.send({ message: "Error: Refresh token flow failed." });
|
.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;
|
return null;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@ -12,7 +12,7 @@ import {
|
|||||||
|
|
||||||
import type { RequestHandler } from "express";
|
import type { RequestHandler } from "express";
|
||||||
import type {
|
import type {
|
||||||
EndpointHandlerBaseArgs,
|
EndpointHandlerWithResArgs,
|
||||||
LinkModel_Edge,
|
LinkModel_Edge,
|
||||||
PlaylistModel_Pl,
|
PlaylistModel_Pl,
|
||||||
URIObject,
|
URIObject,
|
||||||
@ -47,11 +47,12 @@ const updateUser: RequestHandler = async (req, res) => {
|
|||||||
let currentPlaylists: PlaylistModel_Pl[] = [];
|
let currentPlaylists: PlaylistModel_Pl[] = [];
|
||||||
|
|
||||||
// get first 50
|
// get first 50
|
||||||
const respData = await getCurrentUsersPlaylistsFirstPage({
|
const { resp } = await getCurrentUsersPlaylistsFirstPage({
|
||||||
authHeaders,
|
authHeaders,
|
||||||
res,
|
res,
|
||||||
});
|
});
|
||||||
if (!respData) return null;
|
if (!resp) return null;
|
||||||
|
const respData = resp.data;
|
||||||
|
|
||||||
currentPlaylists = respData.items.map((playlist) => {
|
currentPlaylists = respData.items.map((playlist) => {
|
||||||
return {
|
return {
|
||||||
@ -63,12 +64,13 @@ const updateUser: RequestHandler = async (req, res) => {
|
|||||||
|
|
||||||
// keep getting batches of 50 till exhausted
|
// keep getting batches of 50 till exhausted
|
||||||
while (nextURL) {
|
while (nextURL) {
|
||||||
const nextData = await getCurrentUsersPlaylistsNextPage({
|
const { resp } = await getCurrentUsersPlaylistsNextPage({
|
||||||
authHeaders,
|
authHeaders,
|
||||||
res,
|
res,
|
||||||
nextURL,
|
nextURL,
|
||||||
});
|
});
|
||||||
if (!nextData) return null;
|
if (!resp) return null;
|
||||||
|
const nextData = resp.data;
|
||||||
|
|
||||||
currentPlaylists.push(
|
currentPlaylists.push(
|
||||||
...nextData.items.map((playlist) => {
|
...nextData.items.map((playlist) => {
|
||||||
@ -411,7 +413,7 @@ const removeLink: RequestHandler = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
interface _GetPlaylistTracksArgs extends EndpointHandlerBaseArgs {
|
interface _GetPlaylistTracksArgs extends EndpointHandlerWithResArgs {
|
||||||
playlistID: string;
|
playlistID: string;
|
||||||
}
|
}
|
||||||
interface _GetPlaylistTracks {
|
interface _GetPlaylistTracks {
|
||||||
@ -431,13 +433,14 @@ const _getPlaylistTracks: (
|
|||||||
let initialFields = ["snapshot_id,tracks(next,items(is_local,track(uri)))"];
|
let initialFields = ["snapshot_id,tracks(next,items(is_local,track(uri)))"];
|
||||||
let mainFields = ["next", "items(is_local,track(uri))"];
|
let mainFields = ["next", "items(is_local,track(uri))"];
|
||||||
|
|
||||||
const respData = await getPlaylistDetailsFirstPage({
|
const { resp } = await getPlaylistDetailsFirstPage({
|
||||||
authHeaders,
|
authHeaders,
|
||||||
res,
|
res,
|
||||||
initialFields: initialFields.join(),
|
initialFields: initialFields.join(),
|
||||||
playlistID,
|
playlistID,
|
||||||
});
|
});
|
||||||
if (!respData) return null;
|
if (!resp) return null;
|
||||||
|
const respData = resp.data;
|
||||||
|
|
||||||
// check cache
|
// check cache
|
||||||
const cachedSnapshotID = await redisClient.get(
|
const cachedSnapshotID = await redisClient.get(
|
||||||
@ -470,12 +473,13 @@ const _getPlaylistTracks: (
|
|||||||
|
|
||||||
// keep getting batches of 50 till exhausted
|
// keep getting batches of 50 till exhausted
|
||||||
while (nextURL) {
|
while (nextURL) {
|
||||||
const nextData = await getPlaylistDetailsNextPage({
|
const { resp } = await getPlaylistDetailsNextPage({
|
||||||
authHeaders,
|
authHeaders,
|
||||||
res,
|
res,
|
||||||
nextURL,
|
nextURL,
|
||||||
});
|
});
|
||||||
if (!nextData) return null;
|
if (!resp) return null;
|
||||||
|
const nextData = resp.data;
|
||||||
|
|
||||||
pl.tracks.push(
|
pl.tracks.push(
|
||||||
...nextData.items.map((playlist_item) => {
|
...nextData.items.map((playlist_item) => {
|
||||||
@ -499,7 +503,7 @@ const _getPlaylistTracks: (
|
|||||||
return pl;
|
return pl;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface _PopulateSingleLinkCoreArgs extends EndpointHandlerBaseArgs {
|
interface _PopulateSingleLinkCoreArgs extends EndpointHandlerWithResArgs {
|
||||||
link: {
|
link: {
|
||||||
from: URIObject;
|
from: URIObject;
|
||||||
to: URIObject;
|
to: URIObject;
|
||||||
@ -522,29 +526,29 @@ interface _PopulateSingleLinkCoreArgs extends EndpointHandlerBaseArgs {
|
|||||||
*
|
*
|
||||||
* CANNOT populate local files; Spotify API does not support it yet.
|
* CANNOT populate local files; Spotify API does not support it yet.
|
||||||
*/
|
*/
|
||||||
const _populateSingleLinkCore: (
|
const _populateSingleLinkCore: (opts: _PopulateSingleLinkCoreArgs) => Promise<{
|
||||||
opts: _PopulateSingleLinkCoreArgs
|
toAddNum: number;
|
||||||
) => Promise<{ toAddNum: number; localNum: number } | null> = async ({
|
addedNum: number;
|
||||||
authHeaders,
|
localNum: number;
|
||||||
res,
|
} | null> = async ({ res, authHeaders, link }) => {
|
||||||
link,
|
|
||||||
}) => {
|
|
||||||
try {
|
try {
|
||||||
const fromPl = link.from,
|
const fromPl = link.from,
|
||||||
toPl = link.to;
|
toPl = link.to;
|
||||||
|
|
||||||
const fromPlaylist = await _getPlaylistTracks({
|
const fromPlaylist = await _getPlaylistTracks({
|
||||||
authHeaders,
|
|
||||||
res,
|
res,
|
||||||
|
authHeaders,
|
||||||
playlistID: fromPl.id,
|
playlistID: fromPl.id,
|
||||||
});
|
});
|
||||||
|
if (!fromPlaylist) return null;
|
||||||
|
|
||||||
const toPlaylist = await _getPlaylistTracks({
|
const toPlaylist = await _getPlaylistTracks({
|
||||||
authHeaders,
|
|
||||||
res,
|
res,
|
||||||
|
authHeaders,
|
||||||
playlistID: toPl.id,
|
playlistID: toPl.id,
|
||||||
});
|
});
|
||||||
|
if (!toPlaylist) return null;
|
||||||
|
|
||||||
if (!fromPlaylist || !toPlaylist) return null;
|
|
||||||
const fromTrackURIs = fromPlaylist.tracks.map((track) => track.uri);
|
const fromTrackURIs = fromPlaylist.tracks.map((track) => track.uri);
|
||||||
let toTrackURIs = toPlaylist.tracks
|
let toTrackURIs = toPlaylist.tracks
|
||||||
.filter((track) => !track.is_local) // API doesn't support adding local files to playlists yet
|
.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 toAddNum = toTrackURIs.length;
|
||||||
const localNum = toPlaylist.tracks.filter((track) => track.is_local).length;
|
const localNum = toPlaylist.tracks.filter((track) => track.is_local).length;
|
||||||
|
let addedNum = 0;
|
||||||
|
|
||||||
// append to end in batches of 100
|
// append to end in batches of 100
|
||||||
while (toTrackURIs.length > 0) {
|
while (toTrackURIs.length > 0) {
|
||||||
const nextBatch = toTrackURIs.splice(0, 100);
|
const nextBatch = toTrackURIs.splice(0, 100);
|
||||||
const addData = await addItemsToPlaylist({
|
const { resp } = await addItemsToPlaylist({
|
||||||
authHeaders,
|
authHeaders,
|
||||||
res,
|
|
||||||
nextBatch,
|
nextBatch,
|
||||||
playlistID: fromPl.id,
|
playlistID: fromPl.id,
|
||||||
});
|
});
|
||||||
if (!addData) return null;
|
if (!resp) break;
|
||||||
|
addedNum += nextBatch.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { toAddNum, localNum };
|
return { toAddNum, addedNum, localNum };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).send({ message: "Internal Server Error" });
|
res.status(500).send({ message: "Internal Server Error" });
|
||||||
logger.error("_populateSingleLinkCore", { error });
|
logger.error("_populateSingleLinkCore", { error });
|
||||||
@ -612,12 +617,14 @@ const populateSingleLink: RequestHandler = async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!(await checkPlaylistEditable({
|
!(
|
||||||
authHeaders,
|
await checkPlaylistEditable({
|
||||||
res,
|
authHeaders,
|
||||||
playlistID: fromPl.id,
|
res,
|
||||||
userID: uID,
|
playlistID: fromPl.id,
|
||||||
}))
|
userID: uID,
|
||||||
|
})
|
||||||
|
).status
|
||||||
)
|
)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
@ -627,15 +634,18 @@ const populateSingleLink: RequestHandler = async (req, res) => {
|
|||||||
link: { from: fromPl, to: toPl },
|
link: { from: fromPl, to: toPl },
|
||||||
});
|
});
|
||||||
if (result) {
|
if (result) {
|
||||||
const { toAddNum, localNum } = result;
|
const { toAddNum, addedNum, localNum } = result;
|
||||||
let logMsg;
|
let message;
|
||||||
logMsg =
|
message =
|
||||||
toAddNum > 0 ? "Added " + toAddNum + " tracks" : "No tracks to add";
|
toAddNum > 0 ? "Added " + addedNum + " tracks" : "No tracks to add";
|
||||||
logMsg +=
|
message +=
|
||||||
localNum > 0 ? "; could not process " + localNum + " local files" : ".";
|
addedNum < toAddNum
|
||||||
|
? ", failed to add " + (toAddNum - addedNum) + " tracks"
|
||||||
|
: "";
|
||||||
|
message += localNum > 0 ? ", skipped " + localNum + " local files" : ".";
|
||||||
|
|
||||||
res.status(200).send({ message: logMsg });
|
res.status(200).send({ message, toAddNum, addedNum, localNum });
|
||||||
logger.debug(logMsg, { toAddNum, localNum });
|
logger.debug(message, { toAddNum, localNum });
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
} catch (error) {
|
} 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 };
|
link: { from: URIObject; to: URIObject };
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
@ -665,7 +675,7 @@ interface _PruneSingleLinkCoreArgs extends EndpointHandlerBaseArgs {
|
|||||||
*/
|
*/
|
||||||
const _pruneSingleLinkCore: (
|
const _pruneSingleLinkCore: (
|
||||||
opts: _PruneSingleLinkCoreArgs
|
opts: _PruneSingleLinkCoreArgs
|
||||||
) => Promise<{ toDelNum: number } | null> = async ({
|
) => Promise<{ toDelNum: number; deletedNum: number } | null> = async ({
|
||||||
authHeaders,
|
authHeaders,
|
||||||
res,
|
res,
|
||||||
link,
|
link,
|
||||||
@ -679,13 +689,15 @@ const _pruneSingleLinkCore: (
|
|||||||
res,
|
res,
|
||||||
playlistID: fromPl.id,
|
playlistID: fromPl.id,
|
||||||
});
|
});
|
||||||
|
if (!fromPlaylist) return null;
|
||||||
|
|
||||||
const toPlaylist = await _getPlaylistTracks({
|
const toPlaylist = await _getPlaylistTracks({
|
||||||
authHeaders,
|
authHeaders,
|
||||||
res,
|
res,
|
||||||
playlistID: toPl.id,
|
playlistID: toPl.id,
|
||||||
});
|
});
|
||||||
|
if (!toPlaylist) return null;
|
||||||
|
|
||||||
if (!fromPlaylist || !toPlaylist) return null;
|
|
||||||
const fromTrackURIs = fromPlaylist.tracks.map((track) => track.uri);
|
const fromTrackURIs = fromPlaylist.tracks.map((track) => track.uri);
|
||||||
const indexedToTrackURIs = toPlaylist.tracks.map((track, index) => {
|
const indexedToTrackURIs = toPlaylist.tracks.map((track, index) => {
|
||||||
return { ...track, position: index };
|
return { ...track, position: index };
|
||||||
@ -696,23 +708,24 @@ const _pruneSingleLinkCore: (
|
|||||||
.map((track) => track.position); // get track positions
|
.map((track) => track.position); // get track positions
|
||||||
|
|
||||||
const toDelNum = indexes.length;
|
const toDelNum = indexes.length;
|
||||||
|
let deletedNum = 0;
|
||||||
|
|
||||||
// remove in batches of 100 (from reverse, to preserve positions while modifying)
|
// remove in batches of 100 (from reverse, to preserve positions while modifying)
|
||||||
let currentSnapshot = toPlaylist.snapshotID;
|
let currentSnapshot = toPlaylist.snapshotID;
|
||||||
while (indexes.length > 0) {
|
while (indexes.length > 0) {
|
||||||
const nextBatch = indexes.splice(Math.max(indexes.length - 100, 0), 100);
|
const nextBatch = indexes.splice(Math.max(indexes.length - 100, 0), 100);
|
||||||
const delResponse = await removePlaylistItems({
|
const { resp } = await removePlaylistItems({
|
||||||
authHeaders,
|
authHeaders,
|
||||||
res,
|
|
||||||
nextBatch,
|
nextBatch,
|
||||||
playlistID: toPl.id,
|
playlistID: toPl.id,
|
||||||
snapshotID: currentSnapshot,
|
snapshotID: currentSnapshot,
|
||||||
});
|
});
|
||||||
if (!delResponse) return null;
|
if (!resp) break;
|
||||||
currentSnapshot = delResponse.snapshot_id;
|
deletedNum += nextBatch.length;
|
||||||
|
currentSnapshot = resp.data.snapshot_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { toDelNum };
|
return { toDelNum, deletedNum };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).send({ message: "Internal Server Error" });
|
res.status(500).send({ message: "Internal Server Error" });
|
||||||
logger.error("_pruneSingleLinkCore", { error });
|
logger.error("_pruneSingleLinkCore", { error });
|
||||||
@ -758,12 +771,14 @@ const pruneSingleLink: RequestHandler = async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!(await checkPlaylistEditable({
|
!(
|
||||||
authHeaders,
|
await checkPlaylistEditable({
|
||||||
res,
|
authHeaders,
|
||||||
playlistID: toPl.id,
|
res,
|
||||||
userID: uID,
|
playlistID: toPl.id,
|
||||||
}))
|
userID: uID,
|
||||||
|
})
|
||||||
|
).status
|
||||||
)
|
)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
@ -776,9 +791,19 @@ const pruneSingleLink: RequestHandler = async (req, res) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (result) {
|
if (result) {
|
||||||
const { toDelNum } = result;
|
const { toDelNum, deletedNum } = result;
|
||||||
res.status(200).send({ message: `Removed ${toDelNum} tracks.` });
|
let message;
|
||||||
logger.debug(`Pruned ${toDelNum} tracks`, { toDelNum });
|
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;
|
return null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@ -20,29 +20,29 @@ const fetchUserPlaylists: RequestHandler = async (req, res) => {
|
|||||||
if (!authHeaders)
|
if (!authHeaders)
|
||||||
throw new ReferenceError("session does not have auth headers");
|
throw new ReferenceError("session does not have auth headers");
|
||||||
// get first 50
|
// get first 50
|
||||||
const respData = await getCurrentUsersPlaylistsFirstPage({
|
const { resp } = await getCurrentUsersPlaylistsFirstPage({
|
||||||
authHeaders,
|
|
||||||
res,
|
res,
|
||||||
|
authHeaders,
|
||||||
});
|
});
|
||||||
if (!respData) return null;
|
if (!resp) return null;
|
||||||
|
|
||||||
let tmpData = structuredClone(respData);
|
|
||||||
const userPlaylists: Pick<
|
const userPlaylists: Pick<
|
||||||
Pagination<SimplifiedPlaylistObject>,
|
Pagination<SimplifiedPlaylistObject>,
|
||||||
"items" | "total"
|
"items" | "total"
|
||||||
> = {
|
> = {
|
||||||
items: [...tmpData.items],
|
items: [...resp.data.items],
|
||||||
total: tmpData.total,
|
total: resp.data.total,
|
||||||
};
|
};
|
||||||
let nextURL = respData.next;
|
let nextURL = resp.data.next;
|
||||||
// keep getting batches of 50 till exhausted
|
// keep getting batches of 50 till exhausted
|
||||||
while (nextURL) {
|
while (nextURL) {
|
||||||
const nextData = await getCurrentUsersPlaylistsNextPage({
|
const { resp } = await getCurrentUsersPlaylistsNextPage({
|
||||||
authHeaders,
|
authHeaders,
|
||||||
res,
|
res,
|
||||||
nextURL,
|
nextURL,
|
||||||
});
|
});
|
||||||
if (!nextData) return null;
|
if (!resp) return null;
|
||||||
|
const nextData = resp.data;
|
||||||
|
|
||||||
userPlaylists.items.push(...nextData.items);
|
userPlaylists.items.push(...nextData.items);
|
||||||
nextURL = nextData.next;
|
nextURL = nextData.next;
|
||||||
|
|||||||
8
index.ts
8
index.ts
@ -94,8 +94,8 @@ app.use("/auth-health", isAuthenticated, async (req, res) => {
|
|||||||
const { authHeaders } = req.session;
|
const { authHeaders } = req.session;
|
||||||
if (!authHeaders)
|
if (!authHeaders)
|
||||||
throw new ReferenceError("session does not have auth headers");
|
throw new ReferenceError("session does not have auth headers");
|
||||||
const respData = await getCurrentUsersProfile({ authHeaders, res });
|
const { resp } = await getCurrentUsersProfile({ authHeaders, res });
|
||||||
if (!respData) return null;
|
if (!resp) return null;
|
||||||
res.status(200).send({ message: "OK" });
|
res.status(200).send({ message: "OK" });
|
||||||
return null;
|
return null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -130,9 +130,7 @@ const server = app.listen(port, () => {
|
|||||||
const cleanupFunc = (signal?: string) => {
|
const cleanupFunc = (signal?: string) => {
|
||||||
if (signal) logger.debug(`${signal} signal received, shutting down now...`);
|
if (signal) logger.debug(`${signal} signal received, shutting down now...`);
|
||||||
|
|
||||||
Promise.allSettled([
|
Promise.allSettled([promisify(server.close)]).then(() => {
|
||||||
promisify(server.close),
|
|
||||||
]).then(() => {
|
|
||||||
logger.info("Cleaned up, exiting.");
|
logger.info("Cleaned up, exiting.");
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { sessionName } from "../constants.ts";
|
|||||||
|
|
||||||
import logger from "../utils/logger.ts";
|
import logger from "../utils/logger.ts";
|
||||||
|
|
||||||
export const isAuthenticated: RequestHandler = (req, res, next) => {
|
const isAuthenticated: RequestHandler = (req, res, next) => {
|
||||||
if (req.session.accessToken) {
|
if (req.session.accessToken) {
|
||||||
req.session.authHeaders = {
|
req.session.authHeaders = {
|
||||||
Authorization: `Bearer ${req.session.accessToken}`,
|
Authorization: `Bearer ${req.session.accessToken}`,
|
||||||
@ -27,3 +27,5 @@ export const isAuthenticated: RequestHandler = (req, res, next) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export { isAuthenticated };
|
||||||
|
|||||||
@ -32,52 +32,54 @@ import type {
|
|||||||
|
|
||||||
// GET method
|
// GET method
|
||||||
// Albums
|
// Albums
|
||||||
export type GetAlbum = AlbumObject;
|
export type GetAlbumData = AlbumObject;
|
||||||
export type GetSeveralAlbums = { albums: AlbumObject[] };
|
export type GetSeveralAlbumsData = { albums: AlbumObject[] };
|
||||||
export type GetAlbumTracks = Pagination<SimplifiedTrackObject>;
|
export type GetAlbumTracksData = Pagination<SimplifiedTrackObject>;
|
||||||
export type GetUsersSavedAlbums = Pagination<SavedAlbumObject>;
|
export type GetUsersSavedAlbumsData = Pagination<SavedAlbumObject>;
|
||||||
export type CheckUsersSavedAlbums = boolean[];
|
export type CheckUsersSavedAlbumsData = boolean[];
|
||||||
export type GetNewReleases = { albums: Pagination<SimplifiedAlbumObject> };
|
export type GetNewReleasesData = { albums: Pagination<SimplifiedAlbumObject> };
|
||||||
|
|
||||||
// Artists
|
// Artists
|
||||||
export type GetArtist = ArtistObject;
|
export type GetArtistData = ArtistObject;
|
||||||
export type GetSeveralArtists = { artists: ArtistObject[] };
|
export type GetSeveralArtistsData = { artists: ArtistObject[] };
|
||||||
export type GetArtistsAlbums = Pagination<ArtistsAlbumObject>;
|
export type GetArtistsAlbumsData = Pagination<ArtistsAlbumObject>;
|
||||||
export type GetArtistsTopTracks = { tracks: TrackObject[] };
|
export type GetArtistsTopTracksData = { tracks: TrackObject[] };
|
||||||
|
|
||||||
// Episodes
|
// Episodes
|
||||||
export type GetEpisode = EpisodeObject;
|
export type GetEpisodeData = EpisodeObject;
|
||||||
export type GetSeveralEpisodes = { episodes: EpisodeObject[] };
|
export type GetSeveralEpisodesData = { episodes: EpisodeObject[] };
|
||||||
export type GetUsersSavedEpisodes = Pagination<SavedEpisodeObject>;
|
export type GetUsersSavedEpisodesData = Pagination<SavedEpisodeObject>;
|
||||||
|
|
||||||
// Shows
|
// Shows
|
||||||
export type GetShow = ShowObject;
|
export type GetShowData = ShowObject;
|
||||||
export type GetSeveralShows = { shows: SimplifiedShowObject[] };
|
export type GetSeveralShowsData = { shows: SimplifiedShowObject[] };
|
||||||
export type GetShowEpisodes = Pagination<SimplifiedEpisodeObject>;
|
export type GetShowEpisodesData = Pagination<SimplifiedEpisodeObject>;
|
||||||
export type GetUsersSavedShows = Pagination<SavedShowObject>;
|
export type GetUsersSavedShowsData = Pagination<SavedShowObject>;
|
||||||
|
|
||||||
// Playlists
|
// Playlists
|
||||||
export type GetPlaylist = PlaylistObject;
|
export type GetPlaylistData = PlaylistObject;
|
||||||
export type GetPlaylistItems = Pagination<PlaylistTrackObject>;
|
export type GetPlaylistItemsData = Pagination<PlaylistTrackObject>;
|
||||||
export type GetCurrentUsersPlaylists = Pagination<SimplifiedPlaylistObject>;
|
export type GetCurrentUsersPlaylistsData = Pagination<SimplifiedPlaylistObject>;
|
||||||
export type GetUsersPlaylists = GetCurrentUsersPlaylists;
|
export type GetUsersPlaylistsData = Pagination<SimplifiedPlaylistObject>;
|
||||||
export type GetPlaylistCoverImage = ImageObject[];
|
export type GetPlaylistCoverImageData = ImageObject[];
|
||||||
|
|
||||||
// Tracks
|
// Tracks
|
||||||
export type GetTrack = TrackObject;
|
export type GetTrackData = TrackObject;
|
||||||
export type GetSeveralTracks = { tracks: TrackObject[] };
|
export type GetSeveralTracksData = { tracks: TrackObject[] };
|
||||||
export type GetUsersSavedTracks = Pagination<SavedTrackObject>;
|
export type GetUsersSavedTracksData = Pagination<SavedTrackObject>;
|
||||||
export type CheckUsersSavedTracks = boolean[];
|
export type CheckUsersSavedTracksData = boolean[];
|
||||||
|
|
||||||
// Users
|
// Users
|
||||||
export type GetCurrentUsersProfile = UserObject;
|
export type GetCurrentUsersProfileData = UserObject;
|
||||||
export type GetUsersTopItems =
|
export type GetUsersTopItemsData =
|
||||||
| Pagination<ArtistObject>
|
| Pagination<ArtistObject>
|
||||||
| Pagination<TrackObject>;
|
| Pagination<TrackObject>;
|
||||||
export type GetUsersProfile = SimplifiedUserObject;
|
export type GetUsersProfileData = SimplifiedUserObject;
|
||||||
export type GetFollowedArtists = { artists: PaginationByCursor<ArtistObject> };
|
export type GetFollowedArtistsData = {
|
||||||
export type CheckIfUserFollowsArtistsOrNot = boolean[];
|
artists: PaginationByCursor<ArtistObject>;
|
||||||
export type CheckIfCurrentUserFollowsPlaylist = boolean[];
|
};
|
||||||
|
export type CheckIfUserFollowsArtistsOrNotData = boolean[];
|
||||||
|
export type CheckIfCurrentUserFollowsPlaylistData = boolean[];
|
||||||
|
|
||||||
// POST method
|
// POST method
|
||||||
// Albums
|
// Albums
|
||||||
@ -86,48 +88,48 @@ export type CheckIfCurrentUserFollowsPlaylist = boolean[];
|
|||||||
// Shows
|
// Shows
|
||||||
|
|
||||||
// Playlists
|
// Playlists
|
||||||
export type AddItemsToPlaylist = { snapshot_id: string };
|
export type AddItemsToPlaylistData = { snapshot_id: string };
|
||||||
export type CreatePlaylist = PlaylistObject;
|
export type CreatePlaylistData = PlaylistObject;
|
||||||
|
|
||||||
// Tracks
|
// Tracks
|
||||||
// Users
|
// Users
|
||||||
|
|
||||||
// PUT method
|
// PUT method
|
||||||
// Albums
|
// Albums
|
||||||
export type SaveAlbumsForCurrentUser = {};
|
export type SaveAlbumsForCurrentUserData = {};
|
||||||
// Artists
|
// Artists
|
||||||
// Episodes
|
// Episodes
|
||||||
// Shows
|
// Shows
|
||||||
|
|
||||||
// Playlists
|
// Playlists
|
||||||
export type ChangePlaylistDetails = {};
|
export type ChangePlaylistDetailsData = {};
|
||||||
export type UpdatePlaylistItems = { snapshot_id: string };
|
export type UpdatePlaylistItemsData = { snapshot_id: string };
|
||||||
export type AddCustomPlaylistCoverImage = {};
|
export type AddCustomPlaylistCoverImageData = {};
|
||||||
|
|
||||||
// Tracks
|
// Tracks
|
||||||
export type SaveTracksForCurrentUser = {};
|
export type SaveTracksForCurrentUserData = {};
|
||||||
|
|
||||||
// Users
|
// Users
|
||||||
export type FollowPlaylist = {};
|
export type FollowPlaylistData = {};
|
||||||
export type FollowArtistsOrUsers = {};
|
export type FollowArtistsOrUsersData = {};
|
||||||
|
|
||||||
// DELETE method
|
// DELETE method
|
||||||
// Albums
|
// Albums
|
||||||
export type RemoveUsersSavedAlbums = {};
|
export type RemoveUsersSavedAlbumsData = {};
|
||||||
|
|
||||||
// Artists
|
// Artists
|
||||||
// Episodes
|
// Episodes
|
||||||
// Shows
|
// Shows
|
||||||
|
|
||||||
// Playlists
|
// Playlists
|
||||||
export type RemovePlaylistItems = { snapshot_id: string };
|
export type RemovePlaylistItemsData = { snapshot_id: string };
|
||||||
|
|
||||||
// Tracks
|
// Tracks
|
||||||
export type RemoveUsersSavedTracks = {};
|
export type RemoveUsersSavedTracksData = {};
|
||||||
|
|
||||||
// Users
|
// Users
|
||||||
export type UnfollowPlaylist = {};
|
export type UnfollowPlaylistData = {};
|
||||||
export type UnfollowArtistsOrUsers = {};
|
export type UnfollowArtistsOrUsersData = {};
|
||||||
|
|
||||||
// <insert other method> method
|
// <insert other method> method
|
||||||
// Albums
|
// Albums
|
||||||
|
|||||||
@ -7,5 +7,7 @@ export type Next = NextFunction;
|
|||||||
|
|
||||||
export interface EndpointHandlerBaseArgs {
|
export interface EndpointHandlerBaseArgs {
|
||||||
authHeaders: RawAxiosRequestHeaders;
|
authHeaders: RawAxiosRequestHeaders;
|
||||||
|
}
|
||||||
|
export interface EndpointHandlerWithResArgs extends EndpointHandlerBaseArgs {
|
||||||
res: Res;
|
res: Res;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
export const sleep = (ms: number): Promise<unknown> =>
|
const sleep = (ms: number): Promise<unknown> =>
|
||||||
new Promise((resolve) => setTimeout(resolve, ms));
|
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;
|
Math.random() < chance_of_failure;
|
||||||
|
|
||||||
|
export { sleep, randomBool };
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Generates a random string containing numbers and letters
|
* Generates a random string containing numbers and letters
|
||||||
*/
|
*/
|
||||||
export const generateRandString = (length: number): string => {
|
const generateRandString = (length: number): string => {
|
||||||
const possible =
|
const possible =
|
||||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||||
let text = "";
|
let text = "";
|
||||||
@ -11,3 +11,5 @@ export const generateRandString = (length: number): string => {
|
|||||||
}
|
}
|
||||||
return text;
|
return text;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export { generateRandString };
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
export type GNode = string;
|
type GNode = string;
|
||||||
export type GEdge = { from: string; to: string };
|
type GEdge = { from: string; to: string };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Directed graph, may or may not be connected.
|
* Directed graph, may or may not be connected.
|
||||||
@ -20,7 +20,7 @@ export type GEdge = { from: string; to: string };
|
|||||||
* console.log(g.detectCycle()); // true
|
* console.log(g.detectCycle()); // true
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export class myGraph {
|
class myGraph {
|
||||||
nodes: GNode[];
|
nodes: GNode[];
|
||||||
edges: GEdge[];
|
edges: GEdge[];
|
||||||
/**
|
/**
|
||||||
@ -135,4 +135,5 @@ export class myGraph {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { type GNode, type GEdge, myGraph };
|
||||||
export default myGraph;
|
export default myGraph;
|
||||||
|
|||||||
@ -1,8 +1,5 @@
|
|||||||
/** Stringifies only values of a JSON object, including nested ones */
|
/** Stringifies only values of a JSON object, including nested ones */
|
||||||
export const getNestedValuesString = (
|
const getNestedValuesString = (obj: any, delimiter: string = ", "): string => {
|
||||||
obj: any,
|
|
||||||
delimiter: string = ", "
|
|
||||||
): string => {
|
|
||||||
let values: string[] = [];
|
let values: string[] = [];
|
||||||
for (const key in obj) {
|
for (const key in obj) {
|
||||||
if (typeof obj[key] !== "object") {
|
if (typeof obj[key] !== "object") {
|
||||||
@ -14,3 +11,5 @@ export const getNestedValuesString = (
|
|||||||
|
|
||||||
return values.join(delimiter);
|
return values.join(delimiter);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export { getNestedValuesString };
|
||||||
|
|||||||
@ -54,12 +54,16 @@ const winstonLogger: Logger = createLogger({
|
|||||||
new transports.File({
|
new transports.File({
|
||||||
filename: path.join(import.meta.dirname, "..", "logs", "error.log"),
|
filename: path.join(import.meta.dirname, "..", "logs", "error.log"),
|
||||||
level: "error",
|
level: "error",
|
||||||
maxsize: 1048576,
|
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
winstonLogger.on("error", (error) =>
|
winstonLogger.on("error", (error) =>
|
||||||
winstonLogger.error("Error inside logger", { 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;
|
export default winstonLogger;
|
||||||
|
|||||||
@ -4,7 +4,7 @@ const base62Pattern: RegExp = /^[A-Za-z0-9]+$/;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns type and ID from a Spotify URI
|
* 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.
|
* @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
|
* @throws {TypeError} If the input is not a valid Spotify URI
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -6,8 +6,7 @@ import { getNestedValuesString } from "../utils/jsonTransformer.ts";
|
|||||||
|
|
||||||
import logger from "../utils/logger.ts";
|
import logger from "../utils/logger.ts";
|
||||||
|
|
||||||
/** Refer: https://stackoverflow.com/questions/58848625/access-messages-in-express-validator */
|
const validate: RequestHandler = (req, res, next) => {
|
||||||
export const validate: RequestHandler = (req, res, next) => {
|
|
||||||
const errors = validationResult(req);
|
const errors = validationResult(req);
|
||||||
if (errors.isEmpty()) {
|
if (errors.isEmpty()) {
|
||||||
return next();
|
return next();
|
||||||
@ -35,3 +34,5 @@ export const validate: RequestHandler = (req, res, next) => {
|
|||||||
logger.warn("invalid request", { extractedErrors });
|
logger.warn("invalid request", { extractedErrors });
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export { validate };
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user