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, 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, res,
}) => {
const response = await singleRequest<GetCurrentUsersPlaylists>(
authHeaders, authHeaders,
res, path: `/me/playlists`,
allowedMethods.Get, config: {
`/me/playlists`,
{
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, res,
nextURL,
}) => {
const response = await singleRequest<GetCurrentUsersPlaylists>(
authHeaders, authHeaders,
res, path: nextURL,
allowedMethods.Get, });
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, res,
nextURL,
}) => {
const response = await singleRequest<GetPlaylistItems>(
authHeaders, authHeaders,
res, path: nextURL,
allowedMethods.Get, });
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 }) => {
return await singleRequest<AddItemsToPlaylistData>({
authHeaders, authHeaders,
res, method: allowedMethods.Post,
nextBatch, path: `/playlists/${playlistID}/tracks`,
playlistID, data: { uris: nextBatch },
}) => { inlineData: false,
const response = await singleRequest<AddItemsToPlaylist>( });
authHeaders,
res,
allowedMethods.Post,
`/playlists/${playlistID}/tracks`,
{},
{ 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 ({
let checkFields = ["collaborative", "owner(id)"];
const checkFromData = await getPlaylistDetailsFirstPage({
authHeaders,
res, res,
authHeaders,
playlistID,
userID,
}) => {
let checkFields = ["collaborative", "owner(id)"];
const { resp, error, message } = await getPlaylistDetailsFirstPage({
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: "" };
} }
}; };

View File

@ -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

View File

@ -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) {

View File

@ -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({ !(
await checkPlaylistEditable({
authHeaders, authHeaders,
res, res,
playlistID: fromPl.id, playlistID: fromPl.id,
userID: uID, 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({ !(
await checkPlaylistEditable({
authHeaders, authHeaders,
res, res,
playlistID: toPl.id, playlistID: toPl.id,
userID: uID, 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) {

View File

@ -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;

View File

@ -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);
}); });

View File

@ -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 };

View File

@ -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

View File

@ -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;
} }

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)); 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 };

View File

@ -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 };

View File

@ -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;

View File

@ -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 };

View File

@ -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;

View File

@ -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
*/ */

View File

@ -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 };