MASSIVE commit

- moved to typescript

- axios rate limitmodule is busted, removed for now, do something else for that

- sequelize-typescript

- dotenv, not dotenv-flow

- removed playlist details route

types for API

ton of minor fixes and improvements
This commit is contained in:
Kaushik Narayan R 2025-03-11 15:24:45 -07:00
parent bcc39d5f38
commit a74ffc453e
68 changed files with 2795 additions and 1569 deletions

4
.env
View File

@ -1,5 +1,5 @@
CLIENT_ID = your_client_id_here
CLIENT_SECRET = your_client_secret_here
CLIENT_ID = your_spotify_client_id_here
CLIENT_SECRET = your_spotify_client_secret_here
SESSION_SECRET = 'your_session_secret_string_here'
PORT = 9001
TRUST_PROXY = 1

View File

@ -1,10 +1,6 @@
BASE_DOMAIN = 127.0.0.1
REDIRECT_URI = http://127.0.0.1:9001/api/auth/callback
APP_URI = http://127.0.0.1:3000
DB_USER = your_database_username
DB_PASSWD = your_database_password
DB_NAME = your_database_name
DB_HOST = 127.0.0.1
DB_PORT = your_database_port
DB_URI = postgres://your_database_username:your_database_password@127.0.0.1:your_database_port/your_database_name
REDIS_HOST = 127.0.0.1
REDIS_PORT = 6379

4
.gitignore vendored
View File

@ -105,3 +105,7 @@ dist
# SQLite db
*.db
# production
/build
/tsout

View File

@ -3,5 +3,5 @@ dotenvFlow.config();
import { resolve } from "path";
export default {
"config": resolve("config", "sequelize.js")
config: resolve("config", "sequelize.ts"),
};

View File

@ -1,53 +0,0 @@
import axios from "axios";
import rateLimit from "axios-rate-limit";
import { baseAPIURL, accountsAPIURL } from "../constants.js";
import curriedLogger from "../utils/logger.js";
const logger = curriedLogger(import.meta);
export const authInstance = axios.create({
baseURL: accountsAPIURL,
timeout: 20000,
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"Authorization": "Basic " + (Buffer.from(process.env.CLIENT_ID + ":" + process.env.CLIENT_SECRET).toString("base64"))
},
});
const uncappedAxiosInstance = axios.create({
baseURL: baseAPIURL,
timeout: 20000,
headers: {
"Content-Type": "application/json"
},
});
export const axiosInstance = rateLimit(uncappedAxiosInstance, {
maxRequests: 10,
perMilliseconds: 5000,
});
axiosInstance.interceptors.request.use(config => {
logger.http("API call", {
url: config.url,
method: config.method,
params: config.params ?? {},
headers: Object.keys(config.headers),
});
return config;
});
axiosInstance.interceptors.response.use(
(response) => response,
(error) => {
logger.warn("AxiosError", {
error: {
name: error.name,
code: error.code,
message: error.message,
},
req: error.config,
});
return Promise.reject(error);
}
);

54
api/axios.ts Normal file
View File

@ -0,0 +1,54 @@
// TODO: rate limit module is busted (CJS types), do something for rate limiting
import axios, { type AxiosInstance } from "axios";
import { baseAPIURL, accountsAPIURL } from "../constants.ts";
import curriedLogger from "../utils/logger.ts";
const logger = curriedLogger(import.meta.filename);
const authInstance: AxiosInstance = axios.create({
baseURL: accountsAPIURL,
timeout: 20000,
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization:
"Basic " +
Buffer.from(
process.env["CLIENT_ID"] + ":" + process.env["CLIENT_SECRET"]
).toString("base64"),
},
});
const axiosInstance: AxiosInstance = axios.create({
baseURL: baseAPIURL,
timeout: 20000,
headers: {
"Content-Type": "application/json",
},
});
axiosInstance.interceptors.request.use((config) => {
logger.http("API call", {
url: config.url,
method: config.method,
params: config.params ?? {},
headers: Object.keys(config.headers),
});
return config;
});
axiosInstance.interceptors.response.use(
(response) => response,
(error) => {
logger.warn("AxiosError", {
error: {
name: error.name,
code: error.code,
message: error.message,
},
req: error.config,
});
return Promise.reject(error);
}
);
export { authInstance, axiosInstance };

View File

@ -1,153 +0,0 @@
import curriedLogger from "../utils/logger.js";
const logger = curriedLogger(import.meta);
import * as typedefs from "../typedefs.js";
import { axiosInstance } from "./axios.js";
const logPrefix = "Spotify API: ";
/**
* Spotify API - one-off request handler
* @param {typedefs.Req} req convenient auto-placing headers from middleware (not a good approach?)
* @param {typedefs.Res} res handle failure responses here itself (not a good approach?)
* @param {import("axios").Method} method HTTP method
* @param {string} path request path
* @param {import("axios").AxiosRequestConfig} config request params, headers, etc.
* @param {any} data request body
* @param {boolean} inlineData true if data is to be placed inside config
*/
export const singleRequest = async (req, res, method, path, config = {}, data = null, inlineData = false) => {
let resp;
config.headers = { ...config.headers, ...req.sessHeaders };
try {
if (!data || (data && inlineData)) {
if (data)
config.data = data ?? null;
resp = await axiosInstance[method.toLowerCase()](path, config);
} else
resp = await axiosInstance[method.toLowerCase()](path, data, config);
logger.debug(logPrefix + "Successful response received.");
return resp;
} catch (error) {
if (error.response) {
// Non 2XX response received
let logMsg;
if (error.response.status >= 400 && error.response.status < 600) {
res.status(error.response.status).send(error.response.data);
logMsg = "" + error.response.status
}
else {
res.sendStatus(error.response.status);
logMsg = "???";
}
logger.warn(logPrefix + logMsg, {
response: {
data: error.response.data,
status: error.response.status,
}
});
} else if (error.request) {
// No response received
res.status(504).send({ message: "No response from Spotify" });
logger.error(logPrefix + "No response", { error });
} else {
// Something happened in setting up the request that triggered an Error
res.status(500).send({ message: "Internal Server Error" });
logger.error(logPrefix + "Request failed?", { error });
}
return null;
};
}
export const getUserProfile = async (req, res) => {
const response = await singleRequest(req, res,
"GET", "/me",
{ headers: { Authorization: `Bearer ${req.session.accessToken}` } }
);
return res.headersSent ? null : response.data;
}
export const getUserPlaylistsFirstPage = async (req, res) => {
const response = await singleRequest(req, res,
"GET",
`/users/${req.session.user.id}/playlists`,
{
params: {
offset: 0,
limit: 50,
},
});
return res.headersSent ? null : response.data;
}
export const getUserPlaylistsNextPage = async (req, res, nextURL) => {
const response = await singleRequest(
req, res, "GET", nextURL);
return res.headersSent ? null : response.data;
}
export const getPlaylistDetailsFirstPage = async (req, res, initialFields, playlistID) => {
const response = await singleRequest(req, res,
"GET",
`/playlists/${playlistID}/`,
{
params: {
fields: initialFields
},
});
return res.headersSent ? null : response.data;
}
export const getPlaylistDetailsNextPage = async (req, res, nextURL) => {
const response = await singleRequest(
req, res, "GET", nextURL);
return res.headersSent ? null : response.data;
}
export const addItemsToPlaylist = async (req, res, nextBatch, playlistID) => {
const response = await singleRequest(req, res,
"POST",
`/playlists/${playlistID}/tracks`,
{},
{ uris: nextBatch }, false
)
return res.headersSent ? null : response.data;
}
export const removeItemsFromPlaylist = async (req, res, nextBatch, playlistID, snapshotID) => {
// API doesn't document this kind of deletion via the 'positions' field
// but see here: https://github.com/spotipy-dev/spotipy/issues/95#issuecomment-2263634801
const response = await singleRequest(req, res,
"DELETE",
`/playlists/${playlistID}/tracks`,
{},
// axios delete method doesn't have separate arg for body so hv to put it in config
{ positions: nextBatch, snapshot_id: snapshotID }, true
);
return res.headersSent ? null : response.data;
}
export const checkPlaylistEditable = async (req, res, playlistID, userID) => {
let checkFields = ["collaborative", "owner(id)"];
const checkFromData = await getPlaylistDetailsFirstPage(req, res, checkFields.join(), playlistID);
if (res.headersSent) return false;
// https://web.archive.org/web/20241226081630/https://developer.spotify.com/documentation/web-api/concepts/playlists#:~:text=A%20playlist%20can%20also%20be%20made%20collaborative
// playlist is editable if it's collaborative (and thus private) or owned by the user
if (checkFromData.collaborative !== true &&
checkFromData.owner.id !== userID) {
res.status(403).send({
message: "You cannot edit this playlist, you must be the owner/the playlist must be collaborative",
playlistID: playlistID
});
logger.info("user cannot edit target playlist", { playlistID: playlistID });
return false;
} else {
return true;
}
}

286
api/spotify.ts Normal file
View File

@ -0,0 +1,286 @@
import { axiosInstance } from "./axios.ts";
import curriedLogger from "../utils/logger.ts";
import { type AxiosResponse, type AxiosRequestConfig } from "axios";
import type {
AddItemsToPlaylist,
EndpointHandlerBaseArgs,
GetCurrentUsersPlaylists,
GetCurrentUsersProfile,
GetPlaylist,
GetPlaylistItems,
RemovePlaylistItems,
Req,
Res,
} from "spotify_manager/index.d.ts";
const logger = curriedLogger(import.meta.filename);
const logPrefix = "Spotify API: ";
enum allowedMethods {
Get = "get",
Post = "post",
Put = "put",
Delete = "delete",
}
/**
* Spotify API - one-off request handler
* @param req convenient auto-placing headers from middleware (not a good approach?)
* @param res handle failure responses here itself (not a good approach?)
* @param method HTTP method
* @param path request path
* @param config request params, headers, etc.
* @param data request body
* @param inlineData true if `data` is to be placed inside config (say, axios' delete method)
*/
const singleRequest = async <RespDataType>(
req: Req,
res: Res,
method: allowedMethods,
path: string,
config: AxiosRequestConfig = {},
data: any = null,
inlineData: boolean = false
): Promise<AxiosResponse<RespDataType, any> | null> => {
let resp: AxiosResponse<RespDataType, any>;
config.headers = { ...config.headers, ...req.session.authHeaders };
try {
if (!data || inlineData) {
if (data) config.data = data ?? null;
resp = await axiosInstance[method](path, config);
} else {
resp = await axiosInstance[method](path, data, config);
}
logger.debug(logPrefix + "Successful response received.");
return resp;
} catch (error: any) {
if (error.response) {
// Non 2XX response received
let logMsg;
if (error.response.status >= 400 && error.response.status < 600) {
res.status(error.response.status).send(error.response.data);
logMsg = "" + error.response.status;
} else {
res.sendStatus(error.response.status);
logMsg = "???";
}
logger.warn(logPrefix + logMsg, {
response: {
data: error.response.data,
status: error.response.status,
},
});
} else if (error.request) {
// No response received
res.status(504).send({ message: "No response from Spotify" });
logger.error(logPrefix + "No response", { error });
} else {
// Something happened in setting up the request that triggered an Error
res.status(500).send({ message: "Internal Server Error" });
logger.error(logPrefix + "Request failed?", { error });
}
return null;
}
};
interface GetCurrentUsersProfileArgs extends EndpointHandlerBaseArgs {}
const getCurrentUsersProfile: (
opts: GetCurrentUsersProfileArgs
) => Promise<GetCurrentUsersProfile | null> = async ({ req, res }) => {
const response = await singleRequest<GetCurrentUsersProfile>(
req,
res,
allowedMethods.Get,
"/me",
{
headers: { Authorization: `Bearer ${req.session.accessToken}` },
}
);
return response ? response.data : null;
};
interface GetCurrentUsersPlaylistsFirstPageArgs
extends EndpointHandlerBaseArgs {}
const getCurrentUsersPlaylistsFirstPage: (
opts: GetCurrentUsersPlaylistsFirstPageArgs
) => Promise<GetCurrentUsersPlaylists | null> = async ({ req, res }) => {
const response = await singleRequest<GetCurrentUsersPlaylists>(
req,
res,
allowedMethods.Get,
`/me/playlists`,
{
params: {
offset: 0,
limit: 50,
},
}
);
return response?.data ?? null;
};
interface GetCurrentUsersPlaylistsNextPageArgs extends EndpointHandlerBaseArgs {
nextURL: string;
}
const getCurrentUsersPlaylistsNextPage: (
opts: GetCurrentUsersPlaylistsNextPageArgs
) => Promise<GetCurrentUsersPlaylists | null> = async ({
req,
res,
nextURL,
}) => {
const response = await singleRequest<GetCurrentUsersPlaylists>(
req,
res,
allowedMethods.Get,
nextURL
);
return response?.data ?? null;
};
interface GetPlaylistDetailsFirstPageArgs extends EndpointHandlerBaseArgs {
initialFields: string;
playlistID: string;
}
const getPlaylistDetailsFirstPage: (
opts: GetPlaylistDetailsFirstPageArgs
) => Promise<GetPlaylist | null> = async ({
req,
res,
initialFields,
playlistID,
}) => {
const response = await singleRequest<GetPlaylist>(
req,
res,
allowedMethods.Get,
`/playlists/${playlistID}/`,
{
params: {
fields: initialFields,
},
}
);
return response?.data ?? null;
};
interface GetPlaylistDetailsNextPageArgs extends EndpointHandlerBaseArgs {
nextURL: string;
}
const getPlaylistDetailsNextPage: (
opts: GetPlaylistDetailsNextPageArgs
) => Promise<GetPlaylistItems | null> = async ({ req, res, nextURL }) => {
const response = await singleRequest<GetPlaylistItems>(
req,
res,
allowedMethods.Get,
nextURL
);
return response?.data ?? null;
};
interface AddItemsToPlaylistArgs extends EndpointHandlerBaseArgs {
nextBatch: string[];
playlistID: string;
}
const addItemsToPlaylist: (
opts: AddItemsToPlaylistArgs
) => Promise<AddItemsToPlaylist | null> = async ({
req,
res,
nextBatch,
playlistID,
}) => {
const response = await singleRequest<AddItemsToPlaylist>(
req,
res,
allowedMethods.Post,
`/playlists/${playlistID}/tracks`,
{},
{ uris: nextBatch },
false
);
return response?.data ?? null;
};
interface RemovePlaylistItemsArgs extends EndpointHandlerBaseArgs {
nextBatch: string[] | number[]; // see note below
playlistID: string;
snapshotID: string;
}
const removePlaylistItems: (
opts: RemovePlaylistItemsArgs
) => Promise<RemovePlaylistItems | null> = async ({
req,
res,
nextBatch,
playlistID,
snapshotID,
}) => {
// API doesn't document this kind of deletion via the 'positions' field
// but see here: https://github.com/spotipy-dev/spotipy/issues/95#issuecomment-2263634801
const response = await singleRequest<RemovePlaylistItems>(
req,
res,
allowedMethods.Delete,
`/playlists/${playlistID}/tracks`,
{},
// axios delete method doesn't have separate arg for body so hv to put it in config
{ positions: nextBatch, snapshot_id: snapshotID },
true
);
return response?.data ?? null;
};
// ---------
// non-endpoints, i.e. convenience wrappers
// ---------
interface CheckPlaylistEditableArgs extends EndpointHandlerBaseArgs {
playlistID: string;
userID: string;
}
const checkPlaylistEditable: (
opts: CheckPlaylistEditableArgs
) => Promise<boolean> = async ({ req, res, playlistID, userID }) => {
let checkFields = ["collaborative", "owner(id)"];
const checkFromData = await getPlaylistDetailsFirstPage({
req,
res,
initialFields: checkFields.join(),
playlistID,
});
if (!checkFromData) return false;
// https://web.archive.org/web/20241226081630/https://developer.spotify.com/documentation/web-api/concepts/playlists#:~:text=A%20playlist%20can%20also%20be%20made%20collaborative
// playlist is editable if it's collaborative (and thus private) or owned by the user
if (
checkFromData.collaborative !== true &&
checkFromData.owner?.id !== userID
) {
res.status(403).send({
message:
"You cannot edit this playlist, you must be the owner/the playlist must be collaborative",
playlistID,
});
logger.info("user cannot edit target playlist", { playlistID });
return false;
} else {
return true;
}
};
export {
singleRequest,
getCurrentUsersProfile,
getCurrentUsersPlaylistsFirstPage,
getCurrentUsersPlaylistsNextPage,
getPlaylistDetailsFirstPage,
getPlaylistDetailsNextPage,
addItemsToPlaylist,
removePlaylistItems,
checkPlaylistEditable,
};

View File

@ -1,18 +0,0 @@
import curriedLogger from "../utils/logger.js";
const logger = curriedLogger(import.meta);
import * as typedefs from "../typedefs.js";
/**
* @param {typedefs.Req} req
* @param {typedefs.Res} res
*/
export const __controller_func = async (req, res) => {
try {
} catch (error) {
res.status(500).send({ message: "Internal Server Error" });
logger.error("__controller_func", { error });
return;
}
}

View File

@ -0,0 +1,16 @@
import type { RequestHandler } from "express";
import curriedLogger from "../utils/logger.ts";
const logger = curriedLogger(import.meta.filename);
const __controller_func: RequestHandler = async (req, res) => {
try {
return null;
} catch (error) {
res.status(500).send({ message: "Internal Server Error" });
logger.error("__controller_func", { error });
return null;
}
};
export { __controller_func };

View File

@ -1,14 +0,0 @@
import { Router } from "express";
const router = Router();
import { validate } from "../validators/index.js";
router.get(
);
router.post(
);
export default router;

10
boilerplates/route.ts Normal file
View File

@ -0,0 +1,10 @@
import { Router } from "express";
const router: Router = Router();
import { validate } from "../validators/index.ts";
router.get("");
router.post("");
export default router;

View File

@ -1,17 +0,0 @@
import { body, header, param, query } from "express-validator";
import * as typedefs from "../typedefs.js";
/**
* @param {typedefs.Req} req
* @param {typedefs.Res} res
* @param {typedefs.Next} next
*/
export const __validator_func = async (req, res, next) => {
await body("field_name")
.notEmpty()
.withMessage("field_name not defined in body")
.run(req);
next();
}

13
boilerplates/validator.ts Normal file
View File

@ -0,0 +1,13 @@
import type { RequestHandler } from "express";
import { body, header, param, query } from "express-validator";
const __validator_func: RequestHandler = async (req, _res, next) => {
await body("field_name")
.notEmpty()
.withMessage("field_name not defined in body")
.run(req);
next();
};
export { __validator_func };

View File

@ -1,3 +0,0 @@
// https://github.com/motdotla/dotenv/issues/133#issuecomment-255298822
import DotenvFlow from "dotenv-flow";
export default DotenvFlow.config();

18
config/dotenv.ts Normal file
View File

@ -0,0 +1,18 @@
// https://github.com/motdotla/dotenv/issues/133#issuecomment-255298822
// explanation: ESM import statements execute first
// so the .config gets called after all other imports in index.ts
// 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
import { config, type DotenvConfigOutput } from "dotenv";
const result: DotenvConfigOutput = config({
path: [
`.env.${process.env["NODE_ENV"]}.local`,
`.env.${process.env["NODE_ENV"]}`,
".env.local",
".env",
],
});
export default result;

View File

@ -1,29 +0,0 @@
import curriedLogger from "../utils/logger.js";
const logger = curriedLogger(import.meta);
const connConfigs = {
development: {
username: process.env.DB_USER || "postgres",
password: process.env.DB_PASSWD || "",
database: process.env.DB_NAME || "postgres",
host: process.env.DB_HOST || "127.0.0.1",
port: process.env.DB_PORT || 5432,
},
test: {
use_env_variable: "DB_URL", // use connection string for non-dev env
},
production: {
use_env_variable: "DB_URL", // use connection string for non-dev env
// dialectOptions: {
// ssl: true,
// },
}
}
// common config
for (const conf in connConfigs) {
connConfigs[conf]["logging"] = (msg) => logger.debug(msg);
connConfigs[conf]["dialect"] = process.env.DB_DIALECT || "postgres";
}
export default connConfigs;

25
config/sequelize.ts Normal file
View File

@ -0,0 +1,25 @@
import type { SequelizeOptions } from "sequelize-typescript";
import curriedLogger from "../utils/logger.ts";
const logger = curriedLogger(import.meta.filename);
type ConnConfigs = Record<string, SequelizeOptions>;
// env-specific config
const connConfigs: ConnConfigs = {
development: {},
test: {},
production: {
// dialectOptions: {
// ssl: true,
// },
},
};
// common config
for (const conf in connConfigs) {
connConfigs[conf]!.logging = (msg: any) => logger.debug(msg);
connConfigs[conf]!.dialect = "postgres";
}
export default connConfigs;

View File

@ -1,17 +0,0 @@
export const accountsAPIURL = "https://accounts.spotify.com";
export const baseAPIURL = "https://api.spotify.com/v1";
export const sessionName = "spotify-manager";
export const stateKey = "spotify_auth_state";
export const scopes = {
// ImageUpload: "ugc-image-upload",
AccessPrivatePlaylists: "playlist-read-private",
AccessCollaborativePlaylists: "playlist-read-collaborative",
ModifyPublicPlaylists: "playlist-modify-public",
ModifyPrivatePlaylists: "playlist-modify-private",
// ModifyFollow: "user-follow-modify",
AccessFollow: "user-follow-read",
ModifyLibrary: "user-library-modify",
AccessLibrary: "user-library-read",
AccessUser: "user-read-private",
};

16
constants.ts Normal file
View File

@ -0,0 +1,16 @@
const accountsAPIURL = "https://accounts.spotify.com";
const baseAPIURL = "https://api.spotify.com/v1";
const sessionName = "spotify-manager";
const stateKey = "spotify_auth_state";
const requiredScopes = {
// Playlists
GetCollaborativePlaylists: "playlist-read-collaborative",
GetPrivatePlaylists: "playlist-read-private",
ModifyPrivatePlaylists: "playlist-modify-private",
ModifyPublicPlaylists: "playlist-modify-public",
// User
AccessUser: "user-read-private",
};
export { accountsAPIURL, baseAPIURL, sessionName, stateKey, requiredScopes };

View File

@ -1,48 +1,51 @@
import { authInstance } from "../api/axios.js";
import { authInstance } from "../api/axios.ts";
import { getCurrentUsersProfile } from "../api/spotify.ts";
import * as typedefs from "../typedefs.js";
import { scopes, stateKey, accountsAPIURL, sessionName } from "../constants.js";
import {
requiredScopes,
stateKey,
accountsAPIURL,
sessionName,
} from "../constants.ts";
import type { RequestHandler } from "express";
import generateRandString from "../utils/generateRandString.js";
import { getUserProfile } from "../api/spotify.js";
import curriedLogger from "../utils/logger.js";
const logger = curriedLogger(import.meta);
import { generateRandString } from "../utils/generateRandString.ts";
import curriedLogger from "../utils/logger.ts";
const logger = curriedLogger(import.meta.filename);
/**
* Stateful redirect to Spotify login with credentials
* @param {typedefs.Req} req
* @param {typedefs.Res} res
*/
export const login = (_req, res) => {
const login: RequestHandler = async (_req, res) => {
try {
const state = generateRandString(16);
res.cookie(stateKey, state);
const scope = Object.values(scopes).join(" ");
const scope = Object.values(requiredScopes).join(" ");
res.redirect(
`${accountsAPIURL}/authorize?` +
new URLSearchParams({
response_type: "code",
client_id: process.env.CLIENT_ID,
scope: scope,
redirect_uri: process.env.REDIRECT_URI,
state: state
}).toString()
new URLSearchParams({
response_type: "code",
client_id: process.env["CLIENT_ID"],
scope: scope,
redirect_uri: process.env["REDIRECT_URI"],
state: state,
} as Record<string, string>).toString()
);
return;
return null;
} catch (error) {
res.status(500).send({ message: "Internal Server Error" });
logger.error("login", { error });
return;
return null;
}
}
};
/**
* Exchange authorization code for refresh and access tokens
* @param {typedefs.Req} req
* @param {typedefs.Res} res
*/
export const callback = async (req, res) => {
const callback: RequestHandler = async (req, res) => {
try {
const { code, state, error } = req.query;
const storedState = req.cookies ? req.cookies[stateKey] : null;
@ -51,22 +54,22 @@ export const callback = async (req, res) => {
if (state === null || state !== storedState) {
res.status(409).send({ message: "Invalid state" });
logger.warn("state mismatch");
return;
return null;
} else if (error) {
res.status(401).send({ message: "Auth callback error" });
logger.error("callback error", { error });
return;
return null;
} else {
// get auth tokens
res.clearCookie(stateKey);
const authForm = {
code: code,
redirect_uri: process.env.REDIRECT_URI,
grant_type: "authorization_code"
}
redirect_uri: process.env["REDIRECT_URI"],
grant_type: "authorization_code",
} as Record<string, string>;
const authPayload = (new URLSearchParams(authForm)).toString();
const authPayload = new URLSearchParams(authForm).toString();
const tokenResponse = await authInstance.post("/api/token", authPayload);
@ -76,88 +79,96 @@ export const callback = async (req, res) => {
req.session.refreshToken = tokenResponse.data.refresh_token;
} else {
logger.error("login failed", { statusCode: tokenResponse.status });
res.status(tokenResponse.status).send({ message: "Error: Login failed" });
res
.status(tokenResponse.status)
.send({ message: "Error: Login failed" });
}
const userData = await getUserProfile(req, res);
if (res.headersSent) return;
const userData = await getCurrentUsersProfile({ req, res });
if (!userData) return null;
/** @type {typedefs.User} */
req.session.user = {
username: userData.display_name,
username: userData.display_name ?? "",
id: userData.id,
};
// 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 });
return;
return null;
}
} catch (error) {
res.status(500).send({ message: "Internal Server Error" });
logger.error("callback", { error });
return;
return null;
}
}
};
/**
* Request new access token using refresh token
* @param {typedefs.Req} req
* @param {typedefs.Res} res
*/
export const refresh = async (req, res) => {
const refresh: RequestHandler = async (req, res) => {
try {
const authForm = {
refresh_token: req.session.refreshToken,
refresh_token: req.session.refreshToken ?? "",
grant_type: "refresh_token",
}
};
const authPayload = (new URLSearchParams(authForm)).toString();
const authPayload = new URLSearchParams(authForm).toString();
// TODO: types for this and other auth endpoints... but is it necessary?
const response = await authInstance.post("/api/token", authPayload);
if (response.status === 200) {
req.session.accessToken = response.data.access_token;
req.session.refreshToken = response.data.refresh_token ?? req.session.refreshToken; // refresh token rotation
req.session.refreshToken =
response.data.refresh_token ?? req.session.refreshToken; // refresh token rotation
res.status(200).send({ message: "OK" });
logger.debug(`Access token refreshed${(response.data.refresh_token !== null) ? " and refresh token updated" : ""}.`);
return;
logger.debug(
`Access token refreshed${
response.data.refresh_token !== null
? " and refresh token updated"
: ""
}.`
);
return null;
} else {
res.status(response.status).send({ message: "Error: Refresh token flow failed." });
res
.status(response.status)
.send({ message: "Error: Refresh token flow failed." });
logger.error("refresh failed", { statusCode: response.status });
return;
return null;
}
} catch (error) {
res.status(500).send({ message: "Internal Server Error" });
logger.error("refresh", { error });
return;
return null;
}
};
/**
* Clear session
* @param {typedefs.Req} req
* @param {typedefs.Res} res
*/
export const logout = async (req, res) => {
const logout: RequestHandler = async (req, res) => {
try {
const delSession = req.session.destroy((error) => {
if (Object.keys(error).length) {
res.status(500).send({ message: "Internal Server Error" });
logger.error("Error while logging out", { error });
return;
} else {
res.clearCookie(sessionName);
// res.status(200).send({ message: "OK" });
res.redirect(process.env.APP_URI + "?logout=success");
res.redirect(process.env["APP_URI"] + "?logout=success");
logger.debug("Logged out.", { sessionID: delSession.id });
return;
}
})
});
return null;
} catch (error) {
res.status(500).send({ message: "Internal Server Error" });
logger.error("logout", { error });
return;
return null;
}
}
};
export { login, callback, refresh, logout };

View File

@ -1,52 +1,73 @@
import * as typedefs from "../typedefs.js";
import curriedLogger from "../utils/logger.js";
const logger = curriedLogger(import.meta);
import { getUserPlaylistsFirstPage, getUserPlaylistsNextPage, getPlaylistDetailsFirstPage, getPlaylistDetailsNextPage, addItemsToPlaylist, removeItemsFromPlaylist, checkPlaylistEditable } from "../api/spotify.js";
import { parseSpotifyLink } from "../utils/spotifyURITransformer.js";
import { randomBool, sleep } from "../utils/flake.js";
import myGraph from "../utils/graph.js";
import { Op } from "sequelize";
import models, { sequelize } from "../models/index.js";
const Playlists = models.playlists;
const Links = models.links;
import {
getCurrentUsersPlaylistsFirstPage,
getCurrentUsersPlaylistsNextPage,
getPlaylistDetailsFirstPage,
getPlaylistDetailsNextPage,
addItemsToPlaylist,
removePlaylistItems,
checkPlaylistEditable,
} from "../api/spotify.ts";
import type { RequestHandler } from "express";
import type {
EndpointHandlerBaseArgs,
LinkModel_Edge,
PlaylistModel_Pl,
URIObject,
} from "spotify_manager/index.d.ts";
import seqConn from "../models/index.ts";
import myGraph from "../utils/graph.ts";
import { parseSpotifyLink } from "../utils/spotifyUriTransformer.ts";
// import { randomBool, sleep } from "../utils/flake.ts";
// load db models
import Playlists from "../models/playlists.ts";
import Links from "../models/links.ts";
import curriedLogger from "../utils/logger.ts";
const logger = curriedLogger(import.meta.filename);
/**
* Sync user's Spotify data
* @param {typedefs.Req} req
* @param {typedefs.Res} res
*/
export const updateUser = async (req, res) => {
const updateUser: RequestHandler = async (req, res) => {
try {
let currentPlaylists = [];
let currentPlaylists: PlaylistModel_Pl[] = [];
if (!req.session.user)
throw new ReferenceError("sessionData does not have user object");
const uID = req.session.user.id;
// get first 50
const respData = await getUserPlaylistsFirstPage(req, res);
if (res.headersSent) return;
const respData = await getCurrentUsersPlaylistsFirstPage({ req, res });
if (!respData) return null;
currentPlaylists = respData.items.map(playlist => {
currentPlaylists = respData.items.map((playlist) => {
return {
playlistID: playlist.id,
playlistName: playlist.name
}
playlistName: playlist.name,
};
});
let nextURL = respData.next;
// keep getting batches of 50 till exhausted
while (nextURL) {
const nextData = await getUserPlaylistsNextPage(req, res, nextURL);
if (res.headersSent) return;
const nextData = await getCurrentUsersPlaylistsNextPage({
req,
res,
nextURL,
});
if (!nextData) return null;
currentPlaylists.push(
...nextData.items.map(playlist => {
...nextData.items.map((playlist) => {
return {
playlistID: playlist.id,
playlistName: playlist.name
}
playlistName: playlist.name,
};
})
);
@ -57,17 +78,20 @@ export const updateUser = async (req, res) => {
attributes: ["playlistID", "playlistName"],
raw: true,
where: {
userID: uID
userID: uID,
},
});
const deleted = [];
const added = [];
const renamed = [];
const deleted: PlaylistModel_Pl[] = [];
const added: PlaylistModel_Pl[] = [];
const renamed: { playlistID: string; oldName: string; newName: string }[] =
[];
if (oldPlaylists.length) {
const oldMap = new Map(oldPlaylists.map((p) => [p.playlistID, p]));
const currentMap = new Map(currentPlaylists.map((p) => [p.playlistID, p]));
const currentMap = new Map(
currentPlaylists.map((p) => [p.playlistID, p])
);
// Check for added and renamed playlists
currentPlaylists.forEach((pl) => {
@ -96,9 +120,12 @@ export const updateUser = async (req, res) => {
added.push(...currentPlaylists);
}
let removedLinks = 0, delNum = 0, updateNum = 0, addPls = [];
let removedLinks = 0,
delNum = 0,
updateNum = 0,
addPls = [];
const deletedIDs = deleted.map(pl => pl.playlistID);
const deletedIDs = deleted.map((pl) => pl.playlistID);
if (deleted.length) {
// clean up any links dependent on the playlists
removedLinks = await Links.destroy({
@ -109,87 +136,94 @@ export const updateUser = async (req, res) => {
[Op.or]: [
{ from: { [Op.in]: deletedIDs } },
{ to: { [Op.in]: deletedIDs } },
]
}
]
}
})
],
},
],
},
});
// only then remove
delNum = await Playlists.destroy({
where: { playlistID: deletedIDs, userID: uID }
where: { playlistID: deletedIDs, userID: uID },
});
if (delNum !== deleted.length) {
res.status(500).send({ message: "Internal Server Error" });
logger.error("Could not remove all old playlists", { error: new Error("Playlists.destroy failed?") });
return;
logger.error("Could not remove all old playlists", {
error: new Error("Playlists.destroy failed?"),
});
return null;
}
}
if (added.length) {
addPls = await Playlists.bulkCreate(
added.map(pl => { return { ...pl, userID: uID } }),
added.map((pl) => {
return { ...pl, userID: uID };
}),
{ validate: true }
);
if (addPls.length !== added.length) {
res.status(500).send({ message: "Internal Server Error" });
logger.error("Could not add all new playlists", { error: new Error("Playlists.bulkCreate failed?") });
return;
logger.error("Could not add all new playlists", {
error: new Error("Playlists.bulkCreate failed?"),
});
return null;
}
}
const transaction = await sequelize.transaction();
try {
for (const { playlistID, newName } of renamed) {
const updateRes = await Playlists.update(
{ playlistName: newName },
{ where: { playlistID, userID: uID } },
{ transaction }
);
updateNum += Number(updateRes[0]);
}
await transaction.commit();
await seqConn.transaction(async (transaction) => {
for (const { playlistID, newName } of renamed) {
const updateRes = await Playlists.update(
{ playlistName: newName },
{ where: { playlistID, userID: uID }, transaction }
);
updateNum += Number(updateRes[0]);
}
});
} catch (error) {
await transaction.rollback();
res.status(500).send({ message: "Internal Server Error" });
logger.error("Could not update playlist names", { error: new Error("Playlists.update failed?") });
return;
logger.error("Could not update playlist names", {
error: new Error("Playlists.update failed?"),
});
return null;
}
res.status(200).send({ message: "Updated user data.", removedLinks: removedLinks > 0 });
res
.status(200)
.send({ message: "Updated user data.", removedLinks: removedLinks > 0 });
logger.debug("Updated user data", {
delLinks: removedLinks,
delPls: delNum,
addPls: addPls.length,
updatedPls: updateNum
updatedPls: updateNum,
});
return;
return null;
} catch (error) {
res.status(500).send({ message: "Internal Server Error" });
logger.error("updateUser", { error });
return;
return null;
}
}
};
/**
* Fetch user's stored playlists and links
* @param {typedefs.Req} req
* @param {typedefs.Res} res
*/
export const fetchUser = async (req, res) => {
const fetchUser: RequestHandler = async (req, res) => {
try {
// if (randomBool(0.5)) {
// res.status(404).send({ message: "Not Found" });
// return;
// return null;
// }
if (!req.session.user)
throw new ReferenceError("sessionData does not have user object");
const uID = req.session.user.id;
const currentPlaylists = await Playlists.findAll({
attributes: ["playlistID", "playlistName"],
raw: true,
where: {
userID: uID
userID: uID,
},
});
@ -197,31 +231,34 @@ export const fetchUser = async (req, res) => {
attributes: ["from", "to"],
raw: true,
where: {
userID: uID
userID: uID,
},
});
res.status(200).send({
playlists: currentPlaylists,
links: currentLinks
links: currentLinks,
});
logger.debug("Fetched user data", { pls: currentPlaylists.length, links: currentLinks.length });
return;
logger.debug("Fetched user data", {
pls: currentPlaylists.length,
links: currentLinks.length,
});
return null;
} catch (error) {
res.status(500).send({ message: "Internal Server Error" });
logger.error("fetchUser", { error });
return;
return null;
}
}
};
/**
* Create link between playlists!
* @param {typedefs.Req} req
* @param {typedefs.Res} res
*/
export const createLink = async (req, res) => {
const createLink: RequestHandler = async (req, res) => {
try {
// await sleep(1000);
if (!req.session.user)
throw new ReferenceError("sessionData does not have user object");
const uID = req.session.user.id;
let fromPl, toPl;
@ -231,87 +268,89 @@ export const createLink = async (req, res) => {
if (fromPl.type !== "playlist" || toPl.type !== "playlist") {
res.status(400).send({ message: "Link is not a playlist" });
logger.info("non-playlist link provided", { from: fromPl, to: toPl });
return;
return null;
}
} catch (error) {
res.status(400).send({ message: "Could not parse link" });
logger.warn("parseSpotifyLink", { error });
return;
return null;
}
let playlists = await Playlists.findAll({
const playlists = (await Playlists.findAll({
attributes: ["playlistID"],
raw: true,
where: { userID: uID }
});
playlists = playlists.map(pl => pl.playlistID);
where: { userID: uID },
})) as unknown as PlaylistModel_Pl[];
const playlistIDs = playlists.map((pl) => pl.playlistID);
// if playlists are unknown
if (![fromPl, toPl].every(pl => playlists.includes(pl.id))) {
if (![fromPl, toPl].every((pl) => playlistIDs.includes(pl.id))) {
res.status(404).send({ message: "Playlists out of sync." });
logger.warn("unknown playlists, resync");
return;
return null;
}
// check if exists
const existingLink = await Links.findOne({
where: {
[Op.and]: [
{ userID: uID },
{ from: fromPl.id },
{ to: toPl.id }
]
}
[Op.and]: [{ userID: uID }, { from: fromPl.id }, { to: toPl.id }],
},
});
if (existingLink) {
res.status(409).send({ message: "Link already exists!" });
logger.info("link already exists");
return;
return null;
}
const allLinks = await Links.findAll({
const allLinks = (await Links.findAll({
attributes: ["from", "to"],
raw: true,
where: { userID: uID }
});
where: { userID: uID },
})) as unknown as LinkModel_Edge[];
const newGraph = new myGraph(playlists, [...allLinks, { from: fromPl.id, to: toPl.id }]);
const newGraph = new myGraph(playlistIDs, [
...allLinks,
{ from: fromPl.id, to: toPl.id },
]);
if (newGraph.detectCycle()) {
res.status(400).send({ message: "Proposed link cannot cause a cycle in the graph" });
res
.status(400)
.send({ message: "Proposed link cannot cause a cycle in the graph" });
logger.warn("potential cycle detected");
return;
return null;
}
const newLink = await Links.create({
userID: uID,
from: fromPl.id,
to: toPl.id
to: toPl.id,
});
if (!newLink) {
res.status(500).send({ message: "Internal Server Error" });
logger.error("Could not create link", { error: new Error("Links.create failed?") });
return;
logger.error("Could not create link", {
error: new Error("Links.create failed?"),
});
return null;
}
res.status(201).send({ message: "Created link." });
logger.debug("Created link");
return;
return null;
} catch (error) {
res.status(500).send({ message: "Internal Server Error" });
logger.error("createLink", { error });
return;
return null;
}
}
};
/**
* Remove link between playlists
* @param {typedefs.Req} req
* @param {typedefs.Res} res
*/
export const removeLink = async (req, res) => {
*/
const removeLink: RequestHandler = async (req, res) => {
try {
if (!req.session.user)
throw new Error("sessionData does not have user object");
const uID = req.session.user.id;
let fromPl, toPl;
@ -321,103 +360,122 @@ export const removeLink = async (req, res) => {
if (fromPl.type !== "playlist" || toPl.type !== "playlist") {
res.status(400).send({ message: "Link is not a playlist" });
logger.info("non-playlist link provided", { from: fromPl, to: toPl });
return;
return null;
}
} catch (error) {
res.status(400).send({ message: "Could not parse link" });
logger.warn("parseSpotifyLink", { error });
return;
return null;
}
// check if exists
const existingLink = await Links.findOne({
where: {
[Op.and]: [
{ userID: uID },
{ from: fromPl.id },
{ to: toPl.id }
]
}
[Op.and]: [{ userID: uID }, { from: fromPl.id }, { to: toPl.id }],
},
});
if (!existingLink) {
res.status(409).send({ message: "Link does not exist!" });
logger.warn("link does not exist");
return;
return null;
}
const removedLink = await Links.destroy({
where: {
[Op.and]: [
{ userID: uID },
{ from: fromPl.id },
{ to: toPl.id }
]
}
[Op.and]: [{ userID: uID }, { from: fromPl.id }, { to: toPl.id }],
},
});
if (!removedLink) {
res.status(500).send({ message: "Internal Server Error" });
logger.error("Could not remove link", { error: new Error("Links.destroy failed?") });
return;
logger.error("Could not remove link", {
error: new Error("Links.destroy failed?"),
});
return null;
}
res.status(200).send({ message: "Deleted link." });
logger.debug("Deleted link");
return;
return null;
} catch (error) {
res.status(500).send({ message: "Internal Server Error" });
logger.error("removeLink", { error });
return;
return null;
}
}
};
/**
*
* @param {typedefs.Req} req
* @param {typedefs.Res} res
* @param {string} playlistID
*/
const _getPlaylistTracks = async (req, res, playlistID) => {
interface _GetPlaylistTracksArgs extends EndpointHandlerBaseArgs {
playlistID: string;
}
interface _GetPlaylistTracks {
tracks: {
is_local: boolean;
uri: string;
}[];
snapshot_id: string;
}
const _getPlaylistTracks: (
opts: _GetPlaylistTracksArgs
) => Promise<_GetPlaylistTracks | null> = async ({ req, res, playlistID }) => {
let initialFields = ["tracks(next,items(is_local,track(uri)))"];
let mainFields = ["next", "items(is_local,track(uri))"];
const respData = await getPlaylistDetailsFirstPage(req, res, initialFields.join(), playlistID);
if (res.headersSent) return;
const respData = await getPlaylistDetailsFirstPage({
req,
res,
initialFields: initialFields.join(),
playlistID,
});
if (!respData) return null;
const pl: _GetPlaylistTracks = {
tracks: [],
snapshot_id: respData.snapshot_id,
};
let nextURL;
let pl = {};
// varying fields again smh
if (respData.tracks.next) {
pl.next = new URL(respData.tracks.next);
pl.next.searchParams.set("fields", mainFields.join());
pl.next = pl.next.href;
nextURL = new URL(respData.tracks.next);
nextURL.searchParams.set("fields", mainFields.join());
nextURL = nextURL.href;
}
pl.tracks = respData.tracks.items.map((playlist_item) => {
return {
is_local: playlist_item.is_local,
uri: playlist_item.track.uri
}
uri: playlist_item.track.uri,
};
});
// keep getting batches of 50 till exhausted
while (pl.next) {
const nextData = await getPlaylistDetailsNextPage(req, res, pl.next);
if (res.headersSent) return;
while (nextURL) {
const nextData = await getPlaylistDetailsNextPage({
req,
res,
nextURL,
});
if (!nextData) return null;
pl.tracks.push(
...nextData.items.map((playlist_item) => {
return {
is_local: playlist_item.is_local,
uri: playlist_item.track.uri
}
uri: playlist_item.track.uri,
};
})
);
pl.next = nextData.next;
nextURL = nextData.next;
}
delete pl.next;
return pl;
}
};
interface _PopulateSingleLinkCoreArgs extends EndpointHandlerBaseArgs {
link: {
from: URIObject;
to: URIObject;
};
}
/**
* Add tracks to the link-head playlist,
* that are present in the link-tail playlist but not in the link-head playlist,
@ -434,49 +492,63 @@ const _getPlaylistTracks = async (req, res, playlistID) => {
* after populateMissingInLink, pl_a will have tracks: a, b, c, e, d
*
* CANNOT populate local files; Spotify API does not support it yet.
*
* @param {typedefs.Req} req
* @param {typedefs.Res} res
* @param {{from: typedefs.URIObject, to: typedefs.URIObject}} link
* @returns {Promise<{toAddNum: number, localNum: number} | undefined>}
*/
const _populateSingleLinkCore = async (req, res, link) => {
const _populateSingleLinkCore: (
opts: _PopulateSingleLinkCoreArgs
) => Promise<{ toAddNum: number; localNum: number } | null> = async ({
req,
res,
link,
}) => {
try {
const fromPl = link.from, toPl = link.to;
const fromPl = link.from,
toPl = link.to;
const fromPlaylist = await _getPlaylistTracks(req, res, fromPl.id);
const toPlaylist = await _getPlaylistTracks(req, res, toPl.id);
const fromPlaylist = await _getPlaylistTracks({
req,
res,
playlistID: fromPl.id,
});
const toPlaylist = await _getPlaylistTracks({
req,
res,
playlistID: toPl.id,
});
const fromTrackURIs = fromPlaylist.tracks.map(track => track.uri);
let toTrackURIs = toPlaylist.tracks.
filter(track => !track.is_local). // API doesn't support adding local files to playlists yet
filter(track => !fromTrackURIs.includes(track.uri)). // only ones missing from the 'from' playlist
map(track => track.uri);
if (!fromPlaylist || !toPlaylist) return null;
const fromTrackURIs = fromPlaylist.tracks.map((track) => track.uri);
let toTrackURIs = toPlaylist.tracks
.filter((track) => !track.is_local) // API doesn't support adding local files to playlists yet
.filter((track) => !fromTrackURIs.includes(track.uri)) // only ones missing from the 'from' playlist
.map((track) => track.uri);
const toAddNum = toTrackURIs.length;
const localNum = toPlaylist.tracks.filter(track => track.is_local).length;
const localNum = toPlaylist.tracks.filter((track) => track.is_local).length;
// append to end in batches of 100
while (toTrackURIs.length > 0) {
const nextBatch = toTrackURIs.splice(0, 100);
const addData = await addItemsToPlaylist(req, res, nextBatch, fromPl.id);
if (res.headersSent) return;
const addData = await addItemsToPlaylist({
req,
res,
nextBatch,
playlistID: fromPl.id,
});
if (!addData) return null;
}
return { toAddNum, localNum };
} catch (error) {
res.status(500).send({ message: "Internal Server Error" });
logger.error("_populateSingleLinkCore", { error });
return;
return null;
}
}
};
/**
* @param {typedefs.Req} req
* @param {typedefs.Res} res
*/
export const populateSingleLink = async (req, res) => {
const populateSingleLink: RequestHandler = async (req, res) => {
try {
if (!req.session.user)
throw new Error("sessionData does not have user object");
const uID = req.session.user.id;
const link = { from: req.body.from, to: req.body.to };
let fromPl, toPl;
@ -487,51 +559,63 @@ export const populateSingleLink = async (req, res) => {
if (fromPl.type !== "playlist" || toPl.type !== "playlist") {
res.status(400).send({ message: "Link is not a playlist" });
logger.info("non-playlist link provided", link);
return;
return null;
}
} catch (error) {
res.status(400).send({ message: "Could not parse link" });
logger.warn("parseSpotifyLink", { error });
return;
return null;
}
// check if exists
const existingLink = await Links.findOne({
where: {
[Op.and]: [
{ userID: uID },
{ from: fromPl.id },
{ to: toPl.id }
]
}
[Op.and]: [{ userID: uID }, { from: fromPl.id }, { to: toPl.id }],
},
});
if (!existingLink) {
res.status(409).send({ message: "Link does not exist!" });
logger.warn("link does not exist", { link });
return;
return null;
}
if (!await checkPlaylistEditable(req, res, fromPl.id, uID))
return;
if (
!(await checkPlaylistEditable({
req,
res,
playlistID: fromPl.id,
userID: uID,
}))
)
return null;
const result = await _populateSingleLinkCore(req, res, { from: fromPl, to: toPl });
const result = await _populateSingleLinkCore({
req,
res,
link: { from: fromPl, to: toPl },
});
if (result) {
const { toAddNum, localNum } = result;
let logMsg;
logMsg = toAddNum > 0 ? "Added " + toAddNum + " tracks" : "No tracks to add";
logMsg += localNum > 0 ? "; could not process " + localNum + " local files" : ".";
logMsg =
toAddNum > 0 ? "Added " + toAddNum + " tracks" : "No tracks to add";
logMsg +=
localNum > 0 ? "; could not process " + localNum + " local files" : ".";
res.status(200).send({ message: logMsg });
logger.debug(logMsg, { toAddNum, localNum });
}
return;
return null;
} catch (error) {
res.status(500).send({ message: "Internal Server Error" });
logger.error("populateSingleLink", { error });
return;
return null;
}
}
};
interface _PruneSingleLinkCoreArgs extends EndpointHandlerBaseArgs {
link: { from: URIObject; to: URIObject };
}
/**
* Remove tracks from the link-tail playlist,
* that are present in the link-tail playlist but not in the link-head playlist.
@ -546,53 +630,64 @@ export const populateSingleLink = async (req, res) => {
*
* after pruneSingleLink, pl_b will have tracks: b, c
*
* @param {typedefs.Req} req
* @param {typedefs.Res} res
* @param {{from: typedefs.URIObject, to: typedefs.URIObject}} link
* @returns {Promise<{toDelNum: number} | undefined>}
*/
const _pruneSingleLinkCore = async (req, res, link) => {
const _pruneSingleLinkCore: (
opts: _PruneSingleLinkCoreArgs
) => Promise<{ toDelNum: number } | null> = async ({ req, res, link }) => {
try {
const fromPl = link.from, toPl = link.to;
const fromPl = link.from,
toPl = link.to;
const fromPlaylist = await _getPlaylistTracks(req, res, fromPl.id);
const toPlaylist = await _getPlaylistTracks(req, res, toPl.id);
const fromTrackURIs = fromPlaylist.tracks.map(track => track.uri);
let indexedToTrackURIs = toPlaylist.tracks;
indexedToTrackURIs.forEach((track, index) => {
track.position = index;
const fromPlaylist = await _getPlaylistTracks({
req,
res,
playlistID: fromPl.id,
});
const toPlaylist = await _getPlaylistTracks({
req,
res,
playlistID: toPl.id,
});
let indexes = indexedToTrackURIs.filter(track => !fromTrackURIs.includes(track.uri)); // only those missing from the 'from' playlist
indexes = indexes.map(track => track.position); // get track positions
if (!fromPlaylist || !toPlaylist) return null;
const fromTrackURIs = fromPlaylist.tracks.map((track) => track.uri);
const indexedToTrackURIs = toPlaylist.tracks.map((track, index) => {
return { ...track, position: index };
});
let indexes = indexedToTrackURIs
.filter((track) => !fromTrackURIs.includes(track.uri)) // only those missing from the 'from' playlist
.map((track) => track.position); // get track positions
const toDelNum = indexes.length;
// remove in batches of 100 (from reverse, to preserve positions while modifying)
let currentSnapshot = toPlaylist.snapshot_id;
while (indexes.length) {
while (indexes.length > 0) {
const nextBatch = indexes.splice(Math.max(indexes.length - 100, 0), 100);
const delResponse = await removeItemsFromPlaylist(req, res, nextBatch, toPl.id, currentSnapshot);
if (res.headersSent) return;
const delResponse = await removePlaylistItems({
req,
res,
nextBatch,
playlistID: toPl.id,
snapshotID: currentSnapshot,
});
if (!delResponse) return null;
currentSnapshot = delResponse.snapshot_id;
}
return { toDelNum };
} catch (error) {
res.status(500).send({ message: "Internal Server Error" });
logger.error("_pruneSingleLinkCore", { error })
return;
logger.error("_pruneSingleLinkCore", { error });
return null;
}
}
};
/**
* @param {typedefs.Req} req
* @param {typedefs.Res} res
*/
export const pruneSingleLink = async (req, res) => {
const pruneSingleLink: RequestHandler = async (req, res) => {
try {
if (!req.session.user)
throw new Error("sessionData does not have user object");
const uID = req.session.user.id;
const link = { from: req.body.from, to: req.body.to };
@ -603,43 +698,62 @@ export const pruneSingleLink = async (req, res) => {
if (fromPl.type !== "playlist" || toPl.type !== "playlist") {
res.status(400).send({ message: "Link is not a playlist" });
logger.info("non-playlist link provided", link);
return;
return null;
}
} catch (error) {
} catch (error: any) {
res.status(400).send({ message: error.message });
logger.warn("parseSpotifyLink", { error });
return;
return null;
}
// check if exists
const existingLink = await Links.findOne({
where: {
[Op.and]: [
{ userID: uID },
{ from: fromPl.id },
{ to: toPl.id }
]
}
[Op.and]: [{ userID: uID }, { from: fromPl.id }, { to: toPl.id }],
},
});
if (!existingLink) {
res.status(409).send({ message: "Link does not exist!" });
logger.warn("link does not exist", { link });
return;
return null;
}
if (!await checkPlaylistEditable(req, res, toPl.id, uID))
return;
if (
!(await checkPlaylistEditable({
req,
res,
playlistID: toPl.id,
userID: uID,
}))
)
return null;
const result = await _pruneSingleLinkCore(req, res, { from: fromPl, to: toPl });
const result = await _pruneSingleLinkCore({
req,
res,
link: {
from: fromPl,
to: toPl,
},
});
if (result) {
const { toDelNum } = result;
res.status(200).send({ message: `Removed ${toDelNum} tracks.` });
logger.debug(`Pruned ${toDelNum} tracks`, { toDelNum });
}
return;
return null;
} catch (error) {
res.status(500).send({ message: "Internal Server Error" });
logger.error("pruneSingleLink", { error });
return;
return null;
}
}
};
export {
updateUser,
fetchUser,
createLink,
removeLink,
populateSingleLink,
pruneSingleLink,
};

View File

@ -1,155 +0,0 @@
import curriedLogger from "../utils/logger.js";
const logger = curriedLogger(import.meta);
import * as typedefs from "../typedefs.js";
import { getUserPlaylistsFirstPage, getUserPlaylistsNextPage, getPlaylistDetailsFirstPage, getPlaylistDetailsNextPage } from "../api/spotify.js";
import { parseSpotifyLink } from "../utils/spotifyURITransformer.js";
/**
* Retrieve list of all of user's playlists
* @param {typedefs.Req} req
* @param {typedefs.Res} res
*/
export const fetchUserPlaylists = async (req, res) => {
try {
let userPlaylists = {};
// get first 50
const respData = await getUserPlaylistsFirstPage(req, res);
if (res.headersSent) return;
userPlaylists.total = respData.total;
userPlaylists.items = respData.items.map((playlist) => {
return {
uri: playlist.uri,
images: playlist.images,
name: playlist.name,
total: playlist.tracks.total
}
});
userPlaylists.next = respData.next;
// keep getting batches of 50 till exhausted
while (userPlaylists.next) {
const nextData = await getUserPlaylistsNextPage(req, res, userPlaylists.next);
if (res.headersSent) return;
userPlaylists.items.push(
...nextData.items.map((playlist) => {
return {
uri: playlist.uri,
images: playlist.images,
name: playlist.name,
total: playlist.tracks.total
}
})
);
userPlaylists.next = nextData.next;
}
delete userPlaylists.next;
res.status(200).send(userPlaylists);
logger.debug("Fetched user playlists", { num: userPlaylists.total });
return;
} catch (error) {
res.status(500).send({ message: "Internal Server Error" });
logger.error("fetchUserPlaylists", { error });
return;
}
}
/**
* Retrieve an entire playlist
* @param {typedefs.Req} req
* @param {typedefs.Res} res
*/
export const fetchPlaylistDetails = async (req, res) => {
try {
let playlist = {};
/** @type {typedefs.URIObject} */
let uri;
let initialFields = ["collaborative", "description", "images", "name", "owner(uri,display_name)", "public",
"snapshot_id", "tracks(next,total,items(is_local,track(name,uri)))"];
let mainFields = ["next,items(is_local,track(name,uri))"];
try {
uri = parseSpotifyLink(req.query.playlist_link)
if (uri.type !== "playlist") {
res.status(400).send({ message: "Link is not a playlist" });
logger.warn("non-playlist link provided", { uri });
return;
}
} catch (error) {
res.status(400).send({ message: error.message });
logger.warn("parseSpotifyLink", { error });
return;
}
const respData = await getPlaylistDetailsFirstPage(req, res, initialFields.join(), uri.id);
if (res.headersSent) return;
// TODO: this whole section needs to be DRYer
// look into serializr
playlist.name = respData.name;
playlist.description = respData.description;
playlist.collaborative = respData.collaborative;
playlist.public = respData.public;
playlist.images = [...respData.images];
playlist.owner = { ...respData.owner };
playlist.snapshot_id = respData.snapshot_id;
playlist.total = respData.tracks.total;
// previous fields get carried over to the next URL, but most of these fields are not present in the new endpoint
// API shouldn't be returning such URLs, the problem's in the API ig...
if (respData.tracks.next) {
playlist.next = new URL(respData.tracks.next);
playlist.next.searchParams.set("fields", mainFields.join());
playlist.next = playlist.next.href;
}
playlist.tracks = respData.tracks.items.map((playlist_item) => {
return {
is_local: playlist_item.is_local,
track: {
name: playlist_item.track.name,
type: playlist_item.track.type,
uri: playlist_item.track.uri
}
}
});
// keep getting batches of 50 till exhausted
while (playlist.next) {
const nextData = await getPlaylistDetailsNextPage(req, res, playlist.next);
if (res.headersSent) return;
playlist.tracks.push(
...nextData.items.map((playlist_item) => {
return {
is_local: playlist_item.is_local,
track: {
name: playlist_item.track.name,
type: playlist_item.track.type,
uri: playlist_item.track.uri
}
}
})
);
playlist.next = nextData.next;
}
delete playlist.next;
res.status(200).send(playlist);
logger.debug("Fetched playlist tracks", { num: playlist.tracks.length });
return;
} catch (error) {
res.status(500).send({ message: "Internal Server Error" });
logger.error("getPlaylistDetails", { error });
return;
}
}

55
controllers/playlists.ts Normal file
View File

@ -0,0 +1,55 @@
import {
getCurrentUsersPlaylistsFirstPage,
getCurrentUsersPlaylistsNextPage,
} from "../api/spotify.ts";
import type { RequestHandler } from "express";
import type {
Pagination,
SimplifiedPlaylistObject,
} from "spotify_manager/index.d.ts";
import curriedLogger from "../utils/logger.ts";
const logger = curriedLogger(import.meta.filename);
/**
* Get user's playlists
*/
const fetchUserPlaylists: RequestHandler = async (req, res) => {
try {
// get first 50
const respData = await getCurrentUsersPlaylistsFirstPage({ req, res });
if (!respData) return null;
let tmpData = structuredClone(respData);
const userPlaylists: Pick<
Pagination<SimplifiedPlaylistObject>,
"items" | "total"
> = {
items: [...tmpData.items],
total: tmpData.total,
};
let nextURL = respData.next;
// keep getting batches of 50 till exhausted
while (nextURL) {
const nextData = await getCurrentUsersPlaylistsNextPage({
req,
res,
nextURL,
});
if (!nextData) return null;
userPlaylists.items.push(...nextData.items);
nextURL = nextData.next;
}
res.status(200).send(userPlaylists);
logger.debug("Fetched user playlists", { num: userPlaylists.total });
return null;
} catch (error) {
res.status(500).send({ message: "Internal Server Error" });
logger.error("fetchUserPlaylists", { error });
return null;
}
};
export { fetchUserPlaylists };

135
index.js
View File

@ -1,135 +0,0 @@
import _ from "./config/dotenv.js";
import { promisify } from "util";
import express from "express";
import session from "express-session";
import cors from "cors";
import cookieParser from "cookie-parser";
import helmet from "helmet";
import { createClient } from 'redis';
import { RedisStore } from "connect-redis";
import { sessionName } from "./constants.js";
import { sequelize } from "./models/index.js";
import { isAuthenticated } from "./middleware/authCheck.js";
import { getUserProfile } from "./api/spotify.js";
import curriedLogger from "./utils/logger.js";
const logger = curriedLogger(import.meta);
const app = express();
// Enable this if you run behind a proxy (e.g. nginx)
app.set("trust proxy", process.env.TRUST_PROXY);
// Configure Redis client and connect
const redisClient = createClient({
socket: {
host: process.env.REDIS_HOST,
port: process.env.REDIS_PORT,
}
});
redisClient.connect()
.then(() => {
logger.info("Connected to Redis store");
})
.catch((error) => {
logger.error("Redis connection error", { error });
cleanupFunc();
});
const redisStore = new RedisStore({ client: redisClient });
// Configure session middleware
app.use(session({
name: sessionName,
store: redisStore,
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
domain: process.env.BASE_DOMAIN,
httpOnly: true, // if true prevent client side JS from reading the cookie
maxAge: 7 * 24 * 60 * 60 * 1000, // 1 week
sameSite: process.env.NODE_ENV === "development" ? "lax" : "none", // cross-site for production
secure: process.env.NODE_ENV === "development" ? false : true, // if true only transmit cookie over https
}
}));
app.use(cors({
origin: process.env.APP_URI,
credentials: true
}));
app.use(helmet({
crossOriginOpenerPolicy: { policy: process.env.NODE_ENV === "development" ? "unsafe-none" : "same-origin" }
}));
app.disable("x-powered-by");
app.use(cookieParser());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Static
app.use(express.static(import.meta.dirname + "/static"));
// Healthcheck
app.use("/health", (req, res) => {
res.status(200).send({ message: "OK" });
return;
});
app.use("/auth-health", isAuthenticated, async (req, res) => {
try {
await getUserProfile(req, res);
if (res.headersSent) return;
res.status(200).send({ message: "OK" });
return;
} catch (error) {
res.status(500).send({ message: "Internal Server Error" });
logger.error("authHealthCheck", { error });
return;
}
});
import authRoutes from "./routes/auth.js";
import playlistRoutes from "./routes/playlists.js";
import operationRoutes from "./routes/operations.js";
// Routes
app.use("/api/auth/", authRoutes);
app.use("/api/playlists", isAuthenticated, playlistRoutes);
app.use("/api/operations", isAuthenticated, operationRoutes);
// Fallbacks
app.use((req, res) => {
res.status(404).send(
"Guess the <a href=\"https://github.com/20kaushik02/spotify-manager\">cat's</a> out of the bag!"
);
logger.info("404", { url: req.url });
return;
});
const port = process.env.PORT || 5000;
const server = app.listen(port, () => {
logger.info(`App Listening on port ${port}`);
});
const cleanupFunc = (signal) => {
if (signal)
logger.debug(`${signal} signal received, shutting down now...`);
Promise.allSettled([
redisClient.disconnect,
sequelize.close(),
promisify(server.close),
]).then(() => {
logger.info("Cleaned up, exiting.");
process.exit(0);
});
}
["SIGHUP", "SIGINT", "SIGQUIT", "SIGTERM", "SIGUSR1", "SIGUSR2"].forEach((signal) => {
process.on(signal, () => cleanupFunc(signal));
});

164
index.ts Normal file
View File

@ -0,0 +1,164 @@
import _ from "./config/dotenv.ts";
import { promisify } from "util";
import express from "express";
import session from "express-session";
import cors from "cors";
import cookieParser from "cookie-parser";
import helmet from "helmet";
import { createClient } from "redis";
import { RedisStore } from "connect-redis";
import { sessionName } from "./constants.ts";
import seqConn from "./models/index.ts";
import { isAuthenticated } from "./middleware/authCheck.ts";
import { getCurrentUsersProfile } from "./api/spotify.ts";
import curriedLogger from "./utils/logger.ts";
const logger = curriedLogger(import.meta.filename);
const app = express();
// check env vars
if (
isNaN(Number(process.env["TRUST_PROXY"])) ||
![0, 1].includes(Number(process.env["TRUST_PROXY"]))
) {
throw new TypeError("TRUST_PROXY must be 0 or 1");
}
if (isNaN(Number(process.env["REDIS_PORT"]))) {
throw new TypeError("REDIS_PORT must be a number");
}
if (!process.env["SESSION_SECRET"]) {
throw new TypeError("SESSION_SECRET cannot be undefined");
}
// Enable this if you run behind a proxy (e.g. nginx)
app.set("trust proxy", process.env["TRUST_PROXY"]);
// Configure Redis client and connect
const redisClient = createClient({
socket: {
host: process.env["REDIS_HOST"],
port: Number(process.env["REDIS_PORT"]),
},
});
redisClient
.connect()
.then(() => {
logger.info("Connected to Redis store");
})
.catch((error) => {
logger.error("Redis connection error", { error });
cleanupFunc();
});
const redisStore = new RedisStore({ client: redisClient });
// Configure session middleware
app.use(
session({
name: sessionName,
store: redisStore,
secret: process.env["SESSION_SECRET"],
resave: false,
saveUninitialized: false,
cookie: {
domain: process.env["BASE_DOMAIN"],
httpOnly: true, // if true prevent client side JS from reading the cookie
maxAge: 7 * 24 * 60 * 60 * 1000, // 1 week
sameSite: process.env["NODE_ENV"] === "development" ? "lax" : "none", // cross-site for production
secure: process.env["NODE_ENV"] === "development" ? false : true, // if true only transmit cookie over https
},
})
);
app.use(
cors({
origin: process.env["APP_URI"],
credentials: true,
})
);
app.use(
helmet({
crossOriginOpenerPolicy: {
policy:
process.env["NODE_ENV"] === "development"
? "unsafe-none"
: "same-origin",
},
})
);
app.disable("x-powered-by");
app.use(cookieParser());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Static
app.use(express.static(import.meta.dirname + "/static"));
// Healthcheck
app.use("/health", (_req, res) => {
res.status(200).send({ message: "OK" });
return null;
});
app.use("/auth-health", isAuthenticated, async (req, res) => {
try {
const respData = await getCurrentUsersProfile({ req, res });
if (!respData) return null;
res.status(200).send({ message: "OK" });
return null;
} catch (error) {
res.status(500).send({ message: "Internal Server Error" });
logger.error("authHealthCheck", { error });
return null;
}
});
import authRoutes from "./routes/auth.ts";
import playlistRoutes from "./routes/playlists.ts";
import operationRoutes from "./routes/operations.ts";
// Routes
app.use("/api/auth/", authRoutes);
app.use("/api/playlists", isAuthenticated, playlistRoutes);
app.use("/api/operations", isAuthenticated, operationRoutes);
// Fallbacks
app.use((req, res) => {
res
.status(404)
.send(
'Guess the <a href="https://github.com/20kaushik02/spotify-manager">cat\'s</a> out of the bag!'
);
logger.info("404", { url: req.url });
return null;
});
const port = process.env["PORT"] || 5000;
const server = app.listen(port, () => {
logger.info(`App Listening on port ${port}`);
});
const cleanupFunc = (signal?: string) => {
if (signal) logger.debug(`${signal} signal received, shutting down now...`);
Promise.allSettled([
redisClient.disconnect,
seqConn.close(),
promisify(server.close),
]).then(() => {
logger.info("Cleaned up, exiting.");
process.exit(0);
});
};
["SIGHUP", "SIGINT", "SIGQUIT", "SIGTERM", "SIGUSR1", "SIGUSR2"].forEach(
(signal) => {
process.on(signal, () => cleanupFunc(signal));
}
);

View File

@ -1,33 +0,0 @@
import { sessionName } from "../constants.js";
import * as typedefs from "../typedefs.js";
import curriedLogger from "../utils/logger.js";
const logger = curriedLogger(import.meta);
/**
* middleware to check if access token is present
* @param {typedefs.Req} req
* @param {typedefs.Res} res
* @param {typedefs.Next} next
*/
export const isAuthenticated = (req, res, next) => {
if (req.session.accessToken) {
req.sessHeaders = {
"Authorization": `Bearer ${req.session.accessToken}`,
// "X-RateLimit-SessID": `${req.sessionID}_${req.session.user.username}`
};
next();
} else {
const delSession = req.session.destroy((error) => {
if (Object.keys(error).length) {
res.status(500).send({ message: "Internal Server Error" });
logger.error("session.destroy", { error });
return;
} else {
res.clearCookie(sessionName);
res.status(401).send({ message: "Unauthorized" });
logger.debug("Session invalid, destroyed.", { sessionID: delSession.id });
return;
}
});
}
}

31
middleware/authCheck.ts Normal file
View File

@ -0,0 +1,31 @@
import type { AxiosRequestHeaders } from "axios";
import type { RequestHandler } from "express";
import { sessionName } from "../constants.ts";
import curriedLogger from "../utils/logger.ts";
const logger = curriedLogger(import.meta.filename);
export const isAuthenticated: RequestHandler = (req, res, next) => {
if (req.session.accessToken) {
req.session.authHeaders = {
Authorization: `Bearer ${req.session.accessToken}`,
} as AxiosRequestHeaders;
next();
} else {
const delSession = req.session.destroy((error) => {
if (Object.keys(error).length) {
res.status(500).send({ message: "Internal Server Error" });
logger.error("session.destroy", { error });
return null;
} else {
res.clearCookie(sessionName);
res.status(401).send({ message: "Unauthorized" });
logger.debug("Session invalid, destroyed.", {
sessionID: delSession.id,
});
return null;
}
});
}
};

View File

@ -1,5 +1,5 @@
"use strict";
/** @type {import("sequelize-cli").Migration} */
import { type Migration } from "sequelize-cli";
export default {
up: async function (queryInterface, Sequelize) {
await queryInterface.createTable("playlists", {
@ -7,28 +7,28 @@ export default {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: Sequelize.INTEGER
type: Sequelize.INTEGER,
},
playlistID: {
type: Sequelize.STRING
type: Sequelize.STRING,
},
playlistName: {
type: Sequelize.STRING
type: Sequelize.STRING,
},
userID: {
type: Sequelize.STRING
type: Sequelize.STRING,
},
createdAt: {
allowNull: false,
type: Sequelize.DATE
type: Sequelize.DATE,
},
updatedAt: {
allowNull: false,
type: Sequelize.DATE
}
type: Sequelize.DATE,
},
});
},
down: async function (queryInterface, Sequelize) {
down: async function (queryInterface, _Sequelize) {
await queryInterface.dropTable("playlists");
}
};
},
} as Migration;

View File

@ -1,5 +1,5 @@
"use strict";
/** @type {import("sequelize-cli").Migration} */
import { type Migration } from "sequelize-cli";
export default {
up: async function (queryInterface, Sequelize) {
await queryInterface.createTable("links", {
@ -7,28 +7,28 @@ export default {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: Sequelize.INTEGER
type: Sequelize.INTEGER,
},
userID: {
type: Sequelize.STRING
type: Sequelize.STRING,
},
from: {
type: Sequelize.STRING
type: Sequelize.STRING,
},
to: {
type: Sequelize.STRING
type: Sequelize.STRING,
},
createdAt: {
allowNull: false,
type: Sequelize.DATE
type: Sequelize.DATE,
},
updatedAt: {
allowNull: false,
type: Sequelize.DATE
}
type: Sequelize.DATE,
},
});
},
down: async function (queryInterface, Sequelize) {
down: async function (queryInterface, _Sequelize) {
await queryInterface.dropTable("links");
}
};
},
} as Migration;

View File

@ -1,63 +0,0 @@
"use strict";
import { readdirSync } from "fs";
import { basename as _basename } from "path";
const basename = _basename(import.meta.filename);
import Sequelize from "sequelize";
import curriedLogger from "../utils/logger.js";
const logger = curriedLogger(import.meta);
import seqConfig from "../config/sequelize.js"
const env = process.env.NODE_ENV || "development";
const config = seqConfig[env];
const db = {};
let sequelize;
if (config.use_env_variable) {
sequelize = new Sequelize(process.env[config.use_env_variable], config);
} else {
sequelize = new Sequelize(config.database, config.username, config.password, config);
}
(async () => {
try {
await sequelize.authenticate();
logger.debug("Sequelize auth success");
} catch (error) {
logger.error("Sequelize auth error", { error });
throw error;
}
})();
// Read model definitions from folder
const modelFiles = readdirSync(import.meta.dirname)
.filter(
(file) => file.indexOf('.') !== 0
&& file !== basename
&& file.slice(-3) === '.js',
);
await Promise.all(modelFiles.map(async file => {
const model = await import(`./${file}`);
if (!model.default) {
return;
}
const namedModel = model.default(sequelize, Sequelize.DataTypes);
db[namedModel.name] = namedModel;
}))
// Setup defined associations
Object.keys(db).forEach(modelName => {
if (db[modelName].associate) {
db[modelName].associate(db);
}
});
// clean ts up
db.sequelize = sequelize;
db.Sequelize = Sequelize;
export { sequelize as sequelize };
export { Sequelize as Sequelize };
export default db;

35
models/index.ts Normal file
View File

@ -0,0 +1,35 @@
"use strict";
import { Sequelize } from "sequelize-typescript";
import seqConfig from "../config/sequelize.ts";
import links from "./links.ts";
import playlists from "./playlists.ts";
import curriedLogger from "../utils/logger.ts";
const logger = curriedLogger(import.meta.filename);
if (!process.env["NODE_ENV"])
throw new TypeError("Node environment not defined");
if (!process.env["DB_URI"])
throw new TypeError("Database connection URI not defined");
// Initialize
const config = seqConfig[process.env["NODE_ENV"]];
const seqConn: Sequelize = new Sequelize(process.env["DB_URI"], config);
// Check connection
(async () => {
try {
await seqConn.authenticate();
logger.info("Sequelize auth success");
} catch (error) {
logger.error("Sequelize auth error", { error });
throw error;
}
})();
// Load models
seqConn.addModels([links, playlists]);
export default seqConn;

View File

@ -1,23 +0,0 @@
"use strict";
import { Model } from "sequelize";
export default (sequelize, DataTypes) => {
class links extends Model {
/**
* Helper method for defining associations.
* This method is not a part of Sequelize lifecycle.
* The `models/index` file will call this method automatically.
*/
static associate(models) {
// define association here
}
}
links.init({
userID: DataTypes.STRING,
from: DataTypes.STRING,
to: DataTypes.STRING
}, {
sequelize,
modelName: "links",
});
return links;
};

23
models/links.ts Normal file
View File

@ -0,0 +1,23 @@
"use strict";
import {
AllowNull,
Column,
DataType,
Model,
Table,
} from "sequelize-typescript";
@Table
class links extends Model<Partial<links>> {
@AllowNull(false)
@Column(DataType.STRING)
declare userID: string;
@AllowNull(false)
@Column(DataType.STRING)
declare from: string;
@AllowNull(false)
@Column(DataType.STRING)
declare to: string;
}
export default links;

View File

@ -1,23 +0,0 @@
"use strict";
import { Model } from "sequelize";
export default (sequelize, DataTypes) => {
class playlists extends Model {
/**
* Helper method for defining associations.
* This method is not a part of Sequelize lifecycle.
* The `models/index` file will call this method automatically.
*/
static associate(models) {
// define association here
}
}
playlists.init({
playlistID: DataTypes.STRING,
playlistName: DataTypes.STRING,
userID: DataTypes.STRING
}, {
sequelize,
modelName: "playlists",
});
return playlists;
};

24
models/playlists.ts Normal file
View File

@ -0,0 +1,24 @@
"use strict";
import {
AllowNull,
Column,
DataType,
Model,
Table,
} from "sequelize-typescript";
@Table
class playlists extends Model<Partial<playlists>> {
@AllowNull(false)
@Column(DataType.STRING)
declare playlistID: string;
@AllowNull(false)
@Column(DataType.STRING)
declare playlistName: string;
@AllowNull(false)
@Column(DataType.STRING)
declare userID: string;
}
export default playlists;

700
package-lock.json generated
View File

@ -9,19 +9,20 @@
"version": "0",
"license": "MIT",
"dependencies": {
"axios": "^1.7.9",
"axios-rate-limit": "^1.4.0",
"axios": "^1.8.2",
"connect-redis": "^8.0.1",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv-flow": "^4.1.0",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express-session": "^1.18.1",
"express-validator": "^7.2.0",
"helmet": "^8.0.0",
"pg": "^8.13.1",
"redis": "^4.7.0",
"sequelize": "^6.37.5",
"reflect-metadata": "^0.2.2",
"sequelize": "^6.37.6",
"sequelize-typescript": "^2.1.6",
"serializr": "^3.0.3",
"winston": "^3.17.0"
},
@ -30,11 +31,14 @@
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/express-session": "^1.18.1",
"@types/node": "^22.10.5",
"@types/node": "^22.13.10",
"@types/sequelize": "^4.28.20",
"@types/validator": "^13.12.2",
"cross-env": "^7.0.3",
"nodemon": "^3.1.9",
"sequelize-cli": "^6.6.2",
"typescript": "^5.7.3"
"tsx": "^4.19.3",
"typescript": "^5.8.2"
}
},
"node_modules/@colors/colors": {
@ -55,6 +59,431 @@
"kuler": "^2.0.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.1.tgz",
"integrity": "sha512-kfYGy8IdzTGy+z0vFGvExZtxkFlA4zAxgKEahG9KE1ScBjpQnFsNOX8KTU5ojNru5ed5CVoJYXFtoxaq5nFbjQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.1.tgz",
"integrity": "sha512-dp+MshLYux6j/JjdqVLnMglQlFu+MuVeNrmT5nk6q07wNhCdSnB7QZj+7G8VMUGh1q+vj2Bq8kRsuyA00I/k+Q==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.1.tgz",
"integrity": "sha512-50tM0zCJW5kGqgG7fQ7IHvQOcAn9TKiVRuQ/lN0xR+T2lzEFvAi1ZcS8DiksFcEpf1t/GYOeOfCAgDHFpkiSmA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.1.tgz",
"integrity": "sha512-GCj6WfUtNldqUzYkN/ITtlhwQqGWu9S45vUXs7EIYf+7rCiiqH9bCloatO9VhxsL0Pji+PF4Lz2XXCES+Q8hDw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.1.tgz",
"integrity": "sha512-5hEZKPf+nQjYoSr/elb62U19/l1mZDdqidGfmFutVUjjUZrOazAtwK+Kr+3y0C/oeJfLlxo9fXb1w7L+P7E4FQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.1.tgz",
"integrity": "sha512-hxVnwL2Dqs3fM1IWq8Iezh0cX7ZGdVhbTfnOy5uURtao5OIVCEyj9xIzemDi7sRvKsuSdtCAhMKarxqtlyVyfA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.1.tgz",
"integrity": "sha512-1MrCZs0fZa2g8E+FUo2ipw6jw5qqQiH+tERoS5fAfKnRx6NXH31tXBKI3VpmLijLH6yriMZsxJtaXUyFt/8Y4A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.1.tgz",
"integrity": "sha512-0IZWLiTyz7nm0xuIs0q1Y3QWJC52R8aSXxe40VUxm6BB1RNmkODtW6LHvWRrGiICulcX7ZvyH6h5fqdLu4gkww==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.1.tgz",
"integrity": "sha512-NdKOhS4u7JhDKw9G3cY6sWqFcnLITn6SqivVArbzIaf3cemShqfLGHYMx8Xlm/lBit3/5d7kXvriTUGa5YViuQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.1.tgz",
"integrity": "sha512-jaN3dHi0/DDPelk0nLcXRm1q7DNJpjXy7yWaWvbfkPvI+7XNSc/lDOnCLN7gzsyzgu6qSAmgSvP9oXAhP973uQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.1.tgz",
"integrity": "sha512-OJykPaF4v8JidKNGz8c/q1lBO44sQNUQtq1KktJXdBLn1hPod5rE/Hko5ugKKZd+D2+o1a9MFGUEIUwO2YfgkQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.1.tgz",
"integrity": "sha512-nGfornQj4dzcq5Vp835oM/o21UMlXzn79KobKlcs3Wz9smwiifknLy4xDCLUU0BWp7b/houtdrgUz7nOGnfIYg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.1.tgz",
"integrity": "sha512-1osBbPEFYwIE5IVB/0g2X6i1qInZa1aIoj1TdL4AaAb55xIIgbg8Doq6a5BzYWgr+tEcDzYH67XVnTmUzL+nXg==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.1.tgz",
"integrity": "sha512-/6VBJOwUf3TdTvJZ82qF3tbLuWsscd7/1w+D9LH0W/SqUgM5/JJD0lrJ1fVIfZsqB6RFmLCe0Xz3fmZc3WtyVg==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.1.tgz",
"integrity": "sha512-nSut/Mx5gnilhcq2yIMLMe3Wl4FK5wx/o0QuuCLMtmJn+WeWYoEGDN1ipcN72g1WHsnIbxGXd4i/MF0gTcuAjQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.1.tgz",
"integrity": "sha512-cEECeLlJNfT8kZHqLarDBQso9a27o2Zd2AQ8USAEoGtejOrCYHNtKP8XQhMDJMtthdF4GBmjR2au3x1udADQQQ==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.1.tgz",
"integrity": "sha512-xbfUhu/gnvSEg+EGovRc+kjBAkrvtk38RlerAzQxvMzlB4fXpCFCeUAYzJvrnhFtdeyVCDANSjJvOvGYoeKzFA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.1.tgz",
"integrity": "sha512-O96poM2XGhLtpTh+s4+nP7YCCAfb4tJNRVZHfIE7dgmax+yMP2WgMd2OecBuaATHKTHsLWHQeuaxMRnCsH8+5g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.1.tgz",
"integrity": "sha512-X53z6uXip6KFXBQ+Krbx25XHV/NCbzryM6ehOAeAil7X7oa4XIq+394PWGnwaSQ2WRA0KI6PUO6hTO5zeF5ijA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.1.tgz",
"integrity": "sha512-Na9T3szbXezdzM/Kfs3GcRQNjHzM6GzFBeU1/6IV/npKP5ORtp9zbQjvkDJ47s6BCgaAZnnnu/cY1x342+MvZg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.1.tgz",
"integrity": "sha512-T3H78X2h1tszfRSf+txbt5aOp/e7TAz3ptVKu9Oyir3IAOFPGV6O9c2naym5TOriy1l0nNf6a4X5UXRZSGX/dw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.1.tgz",
"integrity": "sha512-2H3RUvcmULO7dIE5EWJH8eubZAI4xw54H1ilJnRNZdeo8dTADEZ21w6J22XBkXqGJbe0+wnNJtw3UXRoLJnFEg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.1.tgz",
"integrity": "sha512-GE7XvrdOzrb+yVKB9KsRMq+7a2U/K5Cf/8grVFRAGJmfADr/e/ODQ134RK2/eeHqYV5eQRFxb1hY7Nr15fv1NQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.1.tgz",
"integrity": "sha512-uOxSJCIcavSiT6UnBhBzE8wy3n0hOkJsBOzy7HDAuTDE++1DJMRRVCPGisULScHL+a/ZwdXPpXD3IyFKjA7K8A==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.1.tgz",
"integrity": "sha512-Y1EQdcfwMSeQN/ujR5VayLOJ1BHaK+ssyk0AEzPjC+t1lITgsnccPqFjb6V+LsTp/9Iov4ysfjxLaGJ9RPtkVg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@ -141,6 +570,13 @@
"@redis/client": "^1.0.0"
}
},
"node_modules/@types/bluebird": {
"version": "3.5.42",
"resolved": "https://registry.npmjs.org/@types/bluebird/-/bluebird-3.5.42.tgz",
"integrity": "sha512-Jhy+MWRlro6UjVi578V/4ZGNfeCOcNCp0YaFNIUGFKlImowqwb1O/22wDVk3FDGMLqxdpOV3qQHD5fPEH4hK6A==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/body-parser": {
"version": "1.19.5",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz",
@ -160,6 +596,16 @@
"@types/node": "*"
}
},
"node_modules/@types/continuation-local-storage": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/@types/continuation-local-storage/-/continuation-local-storage-3.2.7.tgz",
"integrity": "sha512-Q7dPOymVpRG5Zpz90/o26+OAqOG2Sw+FED7uQmTrJNCF/JAPTylclZofMxZKd6W7g1BDPmT9/C/jX0ZcSNTQwQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/cookie-parser": {
"version": "1.4.8",
"resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.8.tgz",
@ -191,6 +637,7 @@
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz",
"integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/body-parser": "*",
"@types/express-serve-static-core": "^4.17.33",
@ -225,6 +672,13 @@
"integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==",
"dev": true
},
"node_modules/@types/lodash": {
"version": "4.17.16",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.16.tgz",
"integrity": "sha512-HX7Em5NYQAXKW+1T+FiuG27NGwzJfCX3s1GjOa7ujxZa52kjJLOr4FUxT+giF6Tgxv1e+/czV/iTtBw27WTU9g==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/mime": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
@ -237,9 +691,10 @@
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="
},
"node_modules/@types/node": {
"version": "22.13.9",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.9.tgz",
"integrity": "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw==",
"version": "22.13.10",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.10.tgz",
"integrity": "sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw==",
"license": "MIT",
"dependencies": {
"undici-types": "~6.20.0"
}
@ -266,6 +721,19 @@
"@types/node": "*"
}
},
"node_modules/@types/sequelize": {
"version": "4.28.20",
"resolved": "https://registry.npmjs.org/@types/sequelize/-/sequelize-4.28.20.tgz",
"integrity": "sha512-XaGOKRhdizC87hDgQ0u3btxzbejlF+t6Hhvkek1HyphqCI4y7zVBIVAGmuc4cWJqGpxusZ1RiBToHHnNK/Edlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/bluebird": "*",
"@types/continuation-local-storage": "*",
"@types/lodash": "*",
"@types/validator": "*"
}
},
"node_modules/@types/serve-static": {
"version": "1.15.7",
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz",
@ -285,7 +753,8 @@
"node_modules/@types/validator": {
"version": "13.12.2",
"resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.12.2.tgz",
"integrity": "sha512-6SlHBzUW8Jhf3liqrGGXyTJSIFe4nqlJ5A5KaMZ2l/vbM3Wh3KSybots/wfWVzNLK4D1NZluDlSQIbIEPx6oyA=="
"integrity": "sha512-6SlHBzUW8Jhf3liqrGGXyTJSIFe4nqlJ5A5KaMZ2l/vbM3Wh3KSybots/wfWVzNLK4D1NZluDlSQIbIEPx6oyA==",
"license": "MIT"
},
"node_modules/abbrev": {
"version": "2.0.0",
@ -370,31 +839,20 @@
}
},
"node_modules/axios": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.8.1.tgz",
"integrity": "sha512-NN+fvwH/kV01dYUQ3PTOZns4LWtWhOFCAhQ/pHb88WQ1hNe5V/dvFwc4VJcDL11LT9xSX0QtsR8sWUuyOuOq7g==",
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.8.2.tgz",
"integrity": "sha512-ls4GYBm5aig9vWx8AWDSGLpnpDQRtWAfrjU+EuytuODrFBkqesN2RkOQCBzrA1RQNHw1SmRMSDDDSwzNAYQ6Rg==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/axios-rate-limit": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/axios-rate-limit/-/axios-rate-limit-1.4.0.tgz",
"integrity": "sha512-uM5PbmSUdSle1I+59Av/wpLuNRobfatIR+FyylSoHcVHT20ohjflNnLMEHZQr7N2QVG/Wlt8jekIPhWwoKtpXQ==",
"dependencies": {
"axios": ">=0.18.0"
},
"peerDependencies": {
"axios": "*"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
},
"node_modules/binary-extensions": {
"version": "2.3.0",
@ -441,7 +899,6 @@
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"dev": true,
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@ -707,8 +1164,7 @@
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"dev": true
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="
},
"node_modules/config-chain": {
"version": "1.1.13",
@ -792,6 +1248,7 @@
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz",
"integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"cross-spawn": "^7.0.1"
},
@ -869,6 +1326,7 @@
"version": "16.4.7",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz",
"integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
@ -876,17 +1334,6 @@
"url": "https://dotenvx.com"
}
},
"node_modules/dotenv-flow": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/dotenv-flow/-/dotenv-flow-4.1.0.tgz",
"integrity": "sha512-0cwP9jpQBQfyHwvE0cRhraZMkdV45TQedA8AAUZMsFzvmLcQyc1HPv+oX0OOYwLFjIlvgVepQ+WuQHbqDaHJZg==",
"dependencies": {
"dotenv": "^16.0.0"
},
"engines": {
"node": ">= 12.0.0"
}
},
"node_modules/dottie": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/dottie/-/dottie-2.0.6.tgz",
@ -1070,6 +1517,47 @@
"es6-symbol": "^3.1.1"
}
},
"node_modules/esbuild": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.1.tgz",
"integrity": "sha512-BGO5LtrGC7vxnqucAe/rmvKdJllfGaYWdyABvyMoXQlfYMb2bbRuReWR5tEGE//4LcNJj9XrkovTqNYRFZHAMQ==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.25.1",
"@esbuild/android-arm": "0.25.1",
"@esbuild/android-arm64": "0.25.1",
"@esbuild/android-x64": "0.25.1",
"@esbuild/darwin-arm64": "0.25.1",
"@esbuild/darwin-x64": "0.25.1",
"@esbuild/freebsd-arm64": "0.25.1",
"@esbuild/freebsd-x64": "0.25.1",
"@esbuild/linux-arm": "0.25.1",
"@esbuild/linux-arm64": "0.25.1",
"@esbuild/linux-ia32": "0.25.1",
"@esbuild/linux-loong64": "0.25.1",
"@esbuild/linux-mips64el": "0.25.1",
"@esbuild/linux-ppc64": "0.25.1",
"@esbuild/linux-riscv64": "0.25.1",
"@esbuild/linux-s390x": "0.25.1",
"@esbuild/linux-x64": "0.25.1",
"@esbuild/netbsd-arm64": "0.25.1",
"@esbuild/netbsd-x64": "0.25.1",
"@esbuild/openbsd-arm64": "0.25.1",
"@esbuild/openbsd-x64": "0.25.1",
"@esbuild/sunos-x64": "0.25.1",
"@esbuild/win32-arm64": "0.25.1",
"@esbuild/win32-ia32": "0.25.1",
"@esbuild/win32-x64": "0.25.1"
}
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@ -1333,6 +1821,12 @@
"node": ">=10"
}
},
"node_modules/fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
"license": "ISC"
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@ -1407,6 +1901,19 @@
"node": ">= 0.4"
}
},
"node_modules/get-tsconfig": {
"version": "4.10.0",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.0.tgz",
"integrity": "sha512-kGzZ3LWWQcGIAmg6iWvXn0ei6WDtV26wzHRMwDSzmAbcXrTEXxHy6IehI6/4eT6VRKyMP1eF1VqwrVUmE/LR7A==",
"dev": true,
"license": "MIT",
"dependencies": {
"resolve-pkg-maps": "^1.0.0"
},
"funding": {
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/glob": {
"version": "10.4.5",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
@ -1573,6 +2080,17 @@
"node >= 0.4.0"
]
},
"node_modules/inflight": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
"deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
"license": "ISC",
"dependencies": {
"once": "^1.3.0",
"wrappy": "1"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
@ -1874,7 +2392,6 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"dependencies": {
"brace-expansion": "^1.1.7"
},
@ -2042,6 +2559,15 @@
"node": ">= 0.8"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/one-time": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz",
@ -2064,6 +2590,15 @@
"node": ">= 0.8"
}
},
"node_modules/path-is-absolute": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@ -2339,6 +2874,12 @@
"@redis/time-series": "1.1.0"
}
},
"node_modules/reflect-metadata": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
"license": "Apache-2.0"
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@ -2368,6 +2909,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/retry-as-promised": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/retry-as-promised/-/retry-as-promised-7.1.1.tgz",
@ -2462,6 +3013,7 @@
"url": "https://opencollective.com/sequelize"
}
],
"license": "MIT",
"dependencies": {
"@types/debug": "^4.1.8",
"@types/validator": "^13.7.17",
@ -2543,6 +3095,45 @@
"node": ">= 10.0.0"
}
},
"node_modules/sequelize-typescript": {
"version": "2.1.6",
"resolved": "https://registry.npmjs.org/sequelize-typescript/-/sequelize-typescript-2.1.6.tgz",
"integrity": "sha512-Vc2N++3en346RsbGjL3h7tgAl2Y7V+2liYTAOZ8XL0KTw3ahFHsyAUzOwct51n+g70I1TOUDgs06Oh6+XGcFkQ==",
"license": "MIT",
"dependencies": {
"glob": "7.2.0"
},
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"@types/node": "*",
"@types/validator": "*",
"reflect-metadata": "*",
"sequelize": ">=6.20.1"
}
},
"node_modules/sequelize-typescript/node_modules/glob": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz",
"integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==",
"deprecated": "Glob versions prior to v9 are no longer supported",
"license": "ISC",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.0.4",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/sequelize/node_modules/debug": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
@ -2921,6 +3512,26 @@
"node": ">= 14.0.0"
}
},
"node_modules/tsx": {
"version": "4.19.3",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.19.3.tgz",
"integrity": "sha512-4H8vUNGNjQ4V2EOoGw005+c+dGuPSnhpPBPHBtsZdGZBk/iJb4kguGlPWaZTZ3q5nMtFOEsY0nRDlh9PJyd6SQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.25.0",
"get-tsconfig": "^4.7.5"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/type": {
"version": "2.7.3",
"resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz",
@ -2944,6 +3555,7 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz",
"integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -3206,6 +3818,12 @@
"node": ">=8"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",

View File

@ -2,13 +2,13 @@
"name": "spotify-manager",
"version": "0",
"description": "Personal Spotify playlist manager",
"exports": "./index.js",
"exports": "./index.ts",
"type": "module",
"scripts": {
"dev": "cross-env NODE_ENV=development nodemon --delay 2 --exitcrash index.js",
"dev": "cross-env NODE_ENV=development tsx watch index.ts",
"test_setup": "npm i && cross-env NODE_ENV=test npx sequelize-cli db:migrate",
"test": "cross-env NODE_ENV=test node index.js",
"prod": "NODE_ENV=production node index.js"
"test": "NODE_ENV=test tsx index.ts",
"prod": "NODE_ENV=production tsx index.ts"
},
"repository": {
"type": "git",
@ -21,19 +21,20 @@
},
"homepage": "https://github.com/20kaushik02/spotify-manager#readme",
"dependencies": {
"axios": "^1.7.9",
"axios-rate-limit": "^1.4.0",
"axios": "^1.8.2",
"connect-redis": "^8.0.1",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv-flow": "^4.1.0",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express-session": "^1.18.1",
"express-validator": "^7.2.0",
"helmet": "^8.0.0",
"pg": "^8.13.1",
"redis": "^4.7.0",
"sequelize": "^6.37.5",
"reflect-metadata": "^0.2.2",
"sequelize": "^6.37.6",
"sequelize-typescript": "^2.1.6",
"serializr": "^3.0.3",
"winston": "^3.17.0"
},
@ -42,10 +43,13 @@
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/express-session": "^1.18.1",
"@types/node": "^22.10.5",
"@types/node": "^22.13.10",
"@types/sequelize": "^4.28.20",
"@types/validator": "^13.12.2",
"cross-env": "^7.0.3",
"nodemon": "^3.1.9",
"sequelize-cli": "^6.6.2",
"typescript": "^5.7.3"
"tsx": "^4.19.3",
"typescript": "^5.8.2"
}
}

View File

@ -1,29 +0,0 @@
import { Router } from "express";
const router = Router();
import { login, callback, refresh, logout } from "../controllers/auth.js";
import { isAuthenticated } from "../middleware/authCheck.js";
import { validate } from "../validators/index.js";
router.get(
"/login",
login
);
router.get(
"/callback",
callback
);
router.get(
"/refresh",
isAuthenticated,
refresh
);
router.get(
"/logout",
logout
);
export default router;

16
routes/auth.ts Normal file
View File

@ -0,0 +1,16 @@
import { Router } from "express";
const authRouter: Router = Router();
import { login, callback, refresh, logout } from "../controllers/auth.ts";
import { isAuthenticated } from "../middleware/authCheck.ts";
import { validate } from "../validators/index.ts";
authRouter.get("/login", login);
authRouter.get("/callback", callback);
authRouter.get("/refresh", isAuthenticated, refresh);
authRouter.get("/logout", logout);
export default authRouter;

View File

@ -1,47 +0,0 @@
import { Router } from "express";
const router = Router();
import { updateUser, fetchUser, createLink, removeLink, populateSingleLink, pruneSingleLink } from "../controllers/operations.js";
import { createLinkValidator, removeLinkValidator, populateSingleLinkValidator, pruneSingleLinkValidator } from "../validators/operations.js";
import { validate } from "../validators/index.js";
router.put(
"/update",
updateUser
);
router.get(
"/fetch",
fetchUser
);
router.post(
"/link",
createLinkValidator,
validate,
createLink
);
router.delete(
"/link",
removeLinkValidator,
validate,
removeLink
);
router.put(
"/populate/link",
populateSingleLinkValidator,
validate,
populateSingleLink
);
router.put(
"/prune/link",
pruneSingleLinkValidator,
validate,
pruneSingleLink
);
export default router;

43
routes/operations.ts Normal file
View File

@ -0,0 +1,43 @@
import { Router } from "express";
const opRouter: Router = Router();
import {
updateUser,
fetchUser,
createLink,
removeLink,
populateSingleLink,
pruneSingleLink,
} from "../controllers/operations.ts";
import {
createLinkValidator,
removeLinkValidator,
populateSingleLinkValidator,
pruneSingleLinkValidator,
} from "../validators/operations.ts";
import { validate } from "../validators/index.ts";
opRouter.put("/update", updateUser);
opRouter.get("/fetch", fetchUser);
opRouter.post("/link", createLinkValidator, validate, createLink);
opRouter.delete("/link", removeLinkValidator, validate, removeLink);
opRouter.put(
"/populate/link",
populateSingleLinkValidator,
validate,
populateSingleLink
);
opRouter.put(
"/prune/link",
pruneSingleLinkValidator,
validate,
pruneSingleLink
);
export default opRouter;

View File

@ -1,21 +0,0 @@
import { Router } from "express";
const router = Router();
import { fetchUserPlaylists, fetchPlaylistDetails } from "../controllers/playlists.js";
import { getPlaylistDetailsValidator } from "../validators/playlists.js";
import { validate } from "../validators/index.js";
router.get(
"/me",
fetchUserPlaylists
);
router.get(
"/details",
getPlaylistDetailsValidator,
validate,
fetchPlaylistDetails
);
export default router;

10
routes/playlists.ts Normal file
View File

@ -0,0 +1,10 @@
import { Router } from "express";
const router: Router = Router();
import { fetchUserPlaylists } from "../controllers/playlists.ts";
import { validate } from "../validators/index.ts";
router.get("/me", fetchUserPlaylists);
export default router;

113
tsconfig.json Normal file
View File

@ -0,0 +1,113 @@
{
"exclude": [
"./boilerplates"
],
"compilerOptions": {
/* Visit https://aka.ms/tsconfig to read more about this file */
/* Projects */
"incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
"composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"target": "ESNext", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
"lib": [
"ESNext"
], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
// "jsx": "preserve", /* Specify what JSX code is generated. */
// "libReplacement": true, /* Enable lib replacement. */
"experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
"emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
"useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
"moduleDetection": "force", /* Control what method is used to detect module-format JS files. */
/* Modules */
"module": "NodeNext", /* Specify what module code is generated. */
// "rootDir": "./", /* Specify the root folder within your source files. */
"moduleResolution": "nodenext", /* Specify how TypeScript looks up a file from a given module specifier. */
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
"typeRoots": [
"./node_modules/@types",
"./types",
], /* Specify multiple folders that act like './node_modules/@types'. */
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
"allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
"rewriteRelativeImportExtensions": true, /* Rewrite '.ts', '.tsx', '.mts', and '.cts' file extensions in relative import paths to their JavaScript equivalent in output files. */
"resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
"resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
// "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
// "noUncheckedSideEffectImports": true, /* Check side effect imports. */
"resolveJsonModule": true, /* Enable importing .json files. */
// "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
/* JavaScript Support */
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
/* Emit */
"declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
"declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
"sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "noEmit": true, /* Disable emitting files from a compilation. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
"outDir": "./tsout", /* Specify an output folder for all emitted files. */
// "removeComments": true, /* Disable emitting comments. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
// "newLine": "crlf", /* Set the newline character for emitting files. */
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
/* Interop Constraints */
"isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
"verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
"isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */
// "erasableSyntaxOnly": true, /* Do not allow runtime constructs that are not part of ECMAScript. */
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
/* Type Checking */
"strict": true, /* Enable all strict type-checking options. */
"noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
"strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
"strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
"strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
"strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
// "strictBuiltinIteratorReturn": true, /* Built-in iterators are instantiated with a 'TReturn' type of 'undefined' instead of 'any'. */
"noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
"useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
"alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
"noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
"noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
"exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
"noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
"noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
"noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
"noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
"noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
"allowUnusedLabels": false, /* Disable error reporting for unused labels. */
"allowUnreachableCode": false, /* Disable error reporting for unreachable code. */
/* Completeness */
"skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
}
}

View File

@ -1,67 +0,0 @@
/**
* @typedef {import("module")} Module
*
* @typedef {import("express").Request} Req
* @typedef {import("express").Response} Res
* @typedef {import("express").NextFunction} Next
*
* @typedef {{
* type: string,
* is_local: boolean,
* id: string,
* artist?: string,
* album?: string,
* title?: string,
* duration?: number
* }} URIObject
*
* @typedef {{
* username: string,
* uri: string
* }} User
*
* @typedef {{
* name: string,
* uri: string,
* }} SimplifiedPlaylist
*
* @typedef {{
* name: string
* }} Album
*
* @typedef {{
* name: string
* }} Artist
*
* @typedef {{
* uri: string,
* name: string,
* artists: Artist[]
* album: Album,
* is_local: boolean,
* }} Track
*
* @typedef {{
* added_at: string,
* track: Track,
* }} PlaylistTrack
*
* @typedef {{
* url: string,
* height: number,
* width: number
* }} ImageObject
*
* @typedef {{
* uri: string,
* name: string,
* description: string,
* collaborative: boolean,
* public: boolean,
* owner: User,
* images: ImageObject[],
* tracks: PlaylistTrack[],
* }} PlaylistDetails
*/
export default {};

14
types/express-session.d.ts vendored Normal file
View File

@ -0,0 +1,14 @@
import type { AxiosRequestHeaders } from "axios";
import type { User } from "spotify_manager/index.d.ts";
declare module "express-session" {
// added properties
interface SessionData {
accessToken: string;
refreshToken: string;
authHeaders: AxiosRequestHeaders;
user: User;
}
}
export {};

View File

@ -0,0 +1,59 @@
// COMMON
export type AlbumType = "album" | "single" | "compilation";
/**
* The markets in which the album is available: {@link https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2|ISO 3166-1 alpha-2 country codes}
*
* NOTE: an album is considered available in a market when at least 1 of its tracks is available in that market.
*/
export type AvailableMarkets = string[];
export type CopyrightObject = {
text: string;
type: string;
};
export type ExternalURLs = {
spotify: string;
};
export type ExternalIDs = {
isrc: string;
ean: string;
upc: string;
};
export type Followers = {
href: string | null;
total: number;
};
export type ImageObject = {
/** valid for 24 hours from retrieval */ url: string;
/** in pixels */ height: number | null;
/** in pixels */ width: number | null;
};
export type LinkedFrom = {
external_urls?: ExternalURLs;
href?: string;
id?: string;
type?: "track";
uri?: string;
};
export type Pagination<T> = {
href: string;
limit: number;
next: string | null;
offset: number;
previous: string | null;
total: number;
items: T[];
};
export type PaginationByCursor<T> = {
href?: string;
limit?: number;
next?: string;
cursors?: {
after?: string;
before?: string;
};
total?: number;
items?: T[];
};
export type Restrictions = {
reason: "market" | "product" | "explicit";
};

View File

@ -0,0 +1,28 @@
// APPLICATION
export type URIObject = {
type: string;
is_local: boolean;
id: string;
artist?: string;
album?: string;
title?: string;
duration?: number;
};
export type User = {
username: string;
id: string;
};
export interface PlaylistModel_Pl {
playlistID: string;
playlistName: string;
}
export interface PlaylistModel extends PlaylistModel_Pl {
userID: string;
}
export interface LinkModel_Edge {
from: string;
to: string;
}

View File

@ -0,0 +1,134 @@
import type {
ImageObject,
Pagination,
PaginationByCursor,
} from "./common.types.ts";
import type {
AlbumObject,
ArtistObject,
ArtistsAlbumObject,
EpisodeObject,
PlaylistObject,
PlaylistTrackObject,
SavedAlbumObject,
SavedEpisodeObject,
SavedShowObject,
SavedTrackObject,
ShowObject,
SimplifiedAlbumObject,
SimplifiedEpisodeObject,
SimplifiedPlaylistObject,
SimplifiedShowObject,
SimplifiedTrackObject,
SimplifiedUserObject,
TrackObject,
UserObject,
} from "./objects.types.ts";
// GET method
// Albums
export type GetAlbum = AlbumObject;
export type GetSeveralAlbums = { albums: AlbumObject[] };
export type GetAlbumTracks = Pagination<SimplifiedTrackObject>;
export type GetUsersSavedAlbums = Pagination<SavedAlbumObject>;
export type CheckUsersSavedAlbums = boolean[];
export type GetNewReleases = { albums: Pagination<SimplifiedAlbumObject> };
// Artists
export type GetArtist = ArtistObject;
export type GetSeveralArtists = { artists: ArtistObject[] };
export type GetArtistsAlbums = Pagination<ArtistsAlbumObject>;
export type GetArtistsTopTracks = { tracks: TrackObject[] };
// Episodes
export type GetEpisode = EpisodeObject;
export type GetSeveralEpisodes = { episodes: EpisodeObject[] };
export type GetUsersSavedEpisodes = Pagination<SavedEpisodeObject>;
// Shows
export type GetShow = ShowObject;
export type GetSeveralShows = { shows: SimplifiedShowObject[] };
export type GetShowEpisodes = Pagination<SimplifiedEpisodeObject>;
export type GetUsersSavedShows = Pagination<SavedShowObject>;
// Playlists
export type GetPlaylist = PlaylistObject;
export type GetPlaylistItems = Pagination<PlaylistTrackObject>;
export type GetCurrentUsersPlaylists = Pagination<SimplifiedPlaylistObject>;
export type GetUsersPlaylists = GetCurrentUsersPlaylists;
export type GetPlaylistCoverImage = ImageObject[];
// Tracks
export type GetTrack = TrackObject;
export type GetSeveralTracks = { tracks: TrackObject[] };
export type GetUsersSavedTracks = Pagination<SavedTrackObject>;
export type CheckUsersSavedTracks = boolean[];
// Users
export type GetCurrentUsersProfile = UserObject;
export type GetUsersTopItems =
| Pagination<ArtistObject>
| Pagination<TrackObject>;
export type GetUsersProfile = SimplifiedUserObject;
export type GetFollowedArtists = { artists: PaginationByCursor<ArtistObject> };
export type CheckIfUserFollowsArtistsOrNot = boolean[];
export type CheckIfCurrentUserFollowsPlaylist = boolean[];
// POST method
// Albums
// Artists
// Episodes
// Shows
// Playlists
export type AddItemsToPlaylist = { snapshot_id: string };
export type CreatePlaylist = PlaylistObject;
// Tracks
// Users
// PUT method
// Albums
export type SaveAlbumsForCurrentUser = {};
// Artists
// Episodes
// Shows
// Playlists
export type ChangePlaylistDetails = {};
export type UpdatePlaylistItems = { snapshot_id: string };
export type AddCustomPlaylistCoverImage = {};
// Tracks
export type SaveTracksForCurrentUser = {};
// Users
export type FollowPlaylist = {};
export type FollowArtistsOrUsers = {};
// DELETE method
// Albums
export type RemoveUsersSavedAlbums = {};
// Artists
// Episodes
// Shows
// Playlists
export type RemovePlaylistItems = { snapshot_id: string };
// Tracks
export type RemoveUsersSavedTracks = {};
// Users
export type UnfollowPlaylist = {};
export type UnfollowArtistsOrUsers = {};
// <insert other method> method
// Albums
// Artists
// Episodes
// Shows
// Playlists
// Tracks
// Users

5
types/spotify_manager/index.d.ts vendored Normal file
View File

@ -0,0 +1,5 @@
export * from "./common.types.ts";
export * from "./custom.types.ts";
export * from "./endpoints.types.ts";
export * from "./objects.types.ts";
export * from "./shorthands.types.ts";

View File

@ -0,0 +1,189 @@
import type {
AlbumType,
AvailableMarkets,
CopyrightObject,
ExternalIDs,
ExternalURLs,
Followers,
ImageObject,
LinkedFrom,
Pagination,
Restrictions,
} from "./common.types.ts";
// OBJECTS
export interface SimplifiedArtistObject {
external_urls: ExternalURLs;
href: string;
id: string;
name: string;
type: "artist";
uri: string;
}
export interface ArtistObject extends SimplifiedArtistObject {
followers: Followers;
genres: string[];
images: ImageObject[];
popularity: number;
}
export interface SimplifiedAlbumObject {
album_type: AlbumType;
artists: SimplifiedArtistObject[];
available_markets: AvailableMarkets;
external_urls: ExternalURLs;
// genres: string[]; // deprecated
href: string;
id: string;
images: ImageObject[];
name: string;
release_date: string;
release_date_precision: "year" | "month" | "day";
restrictions?: Restrictions;
total_tracks: number;
type: "album";
uri: string;
}
export interface ArtistsAlbumObject extends SimplifiedAlbumObject {
album_group: "album" | "single" | "compilation" | "appears_on";
}
export interface AlbumObject extends SimplifiedAlbumObject {
copyrights: CopyrightObject[];
external_ids: ExternalIDs;
label: string;
popularity: number;
tracks: Pagination<SimplifiedTrackObject>;
}
export type SavedAlbumObject = {
added_at: string;
album: AlbumObject;
};
export interface SimplifiedEpisodeObject {
description: string;
html_description: string;
duration_ms: number;
explicit: boolean;
external_urls: ExternalURLs;
href: string;
id: string;
images: ImageObject[];
is_externally_hosted: boolean;
is_playable: boolean;
languages: string[];
name: string;
release_date: string;
release_date_precision: string;
type: "episode";
uri: string;
restrictions?: Restrictions;
}
export interface EpisodeObject extends SimplifiedEpisodeObject {
show: ShowObject;
}
export type SavedEpisodeObject = {
added_at: string;
episode: EpisodeObject;
};
export interface SimplifiedShowObject {
available_markets?: AvailableMarkets;
copyrights?: CopyrightObject[];
description?: string;
html_description?: string;
explicit?: boolean;
external_urls?: ExternalURLs;
href?: string;
id?: string;
images?: ImageObject[];
is_externally_hosted?: boolean;
languages?: string[];
media_type?: string;
name?: string;
publisher?: string;
type: "show";
uri?: string;
total_episodes?: number;
}
export interface ShowObject extends SimplifiedShowObject {
episodes?: Pagination<SimplifiedEpisodeObject>;
}
export type SavedShowObject = {
added_at?: string;
show?: SimplifiedShowObject;
};
export interface SimplifiedTrackObject {
artists: SimplifiedArtistObject[];
available_markets: AvailableMarkets;
disc_number: number;
duration_ms: number;
explicit: boolean;
external_urls: ExternalURLs;
href: string;
id: string;
is_playable?: boolean;
linked_from?: LinkedFrom;
restrictions?: Restrictions;
name: string;
// preview_url?: string; // deprecated
track_number: number;
type: "track";
uri: string;
is_local: boolean;
}
export interface TrackObject extends SimplifiedTrackObject {
album: SimplifiedAlbumObject;
external_ids: ExternalIDs;
popularity: number;
}
export type SavedTrackObject = {
added_at: string;
track: TrackObject;
};
export interface SimplifiedUserObject {
display_name: string | null;
external_urls: ExternalURLs;
followers: Followers;
href: string;
id: string;
images: ImageObject[];
type: "user";
uri: string;
}
export interface UserObject extends SimplifiedUserObject {
country?: string;
email?: string;
explicit_content?: {
filter_enabled: boolean;
filter_locked: boolean;
};
product?: string;
}
export type PlaylistTrackObject = {
added_at: string | null;
added_by: SimplifiedUserObject | null;
is_local: boolean;
track: TrackObject | EpisodeObject;
};
interface BasePlaylistObject {
collaborative: boolean;
description: string | null;
external_urls: ExternalURLs;
href: string;
id: string;
images: ImageObject[];
name: string;
owner: SimplifiedUserObject;
public: boolean | null;
snapshot_id: string;
type: "playlist";
uri: string;
}
export interface SimplifiedPlaylistObject extends BasePlaylistObject {
tracks: {
href: string;
total: number;
};
}
export interface PlaylistObject extends BasePlaylistObject {
tracks: Pagination<PlaylistTrackObject>;
followers: Followers;
}

View File

@ -0,0 +1,10 @@
import type { Request, Response, NextFunction } from "express";
export type Req = Request;
export type Res = Response;
export type Next = NextFunction;
export interface EndpointHandlerBaseArgs {
req: Req;
res: Res;
}

View File

@ -1,3 +0,0 @@
export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
export const randomBool = (chance_of_failure = 0.25) => Math.random() < chance_of_failure;

7
utils/flake.ts Normal file
View File

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

View File

@ -1,10 +1,9 @@
/**
* Generates a random string containing numbers and letters
* @param {number} length The length of the string
* @return {string} The generated string
*/
export default (length) => {
const possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
export const generateRandString = (length: number): string => {
const possible =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
let text = "";
for (let i = 0; i < length; i++) {

View File

@ -1,7 +1,5 @@
import curriedLogger from "./logger.js";
const logger = curriedLogger(import.meta);
import * as typedefs from "../typedefs.js";
export type GNode = string;
export type GEdge = { from: string; to: string };
/**
* Directed graph, may or may not be connected.
@ -21,46 +19,38 @@ import * as typedefs from "../typedefs.js";
* let g = new myGraph(nodes, edges);
* console.log(g.detectCycle()); // true
* ```
*/
*/
export class myGraph {
nodes: GNode[];
edges: GEdge[];
/**
* @param {string[]} nodes Graph nodes IDs
* @param {{ from: string, to: string }[]} edges Graph edges b/w nodes
*/
constructor(nodes, edges) {
this.nodes = [...nodes];
* @param nodes Graph nodes IDs
* @param edges Graph edges b/w nodes
*/
constructor(nodes: GNode[], edges: GEdge[]) {
this.nodes = structuredClone(nodes);
this.edges = structuredClone(edges);
}
/**
* @param {string} node
* @returns {string[]}
*/
getDirectHeads(node) {
return this.edges.filter(edge => edge.to == node).map(edge => edge.from);
getDirectHeads(node: GNode): GNode[] {
return this.edges
.filter((edge) => edge.to == node)
.map((edge) => edge.from);
}
/**
* @param {string} node
* @returns {{ from: string, to: string }[]}
*/
getDirectHeadEdges(node) {
return this.edges.filter(edge => edge.to == node);
getDirectHeadEdges(node: GNode): GEdge[] {
return this.edges.filter((edge) => edge.to == node);
}
/**
* BFS
* @param {string} node
* @returns {string[]}
*/
getAllHeads(node) {
const headSet = new Set();
const toVisit = new Set(); // queue
/** BFS */
getAllHeads(node: GNode): GNode[] {
const headSet = new Set<GNode>();
const toVisit = new Set<GNode>(); // queue
toVisit.add(node);
while (toVisit.size > 0) {
const nextNode = toVisit.values().next().value;
const nextNode = toVisit.values().next().value!;
const nextHeads = this.getDirectHeads(nextNode);
nextHeads.forEach(head => {
nextHeads.forEach((head) => {
headSet.add(head);
toVisit.add(head);
});
@ -69,35 +59,29 @@ export class myGraph {
return [...headSet];
}
/**
* @param {string} node
* @returns {string[]}
*/
getDirectTails(node) {
return this.edges.filter(edge => edge.from == node).map(edge => edge.to);
getDirectTails(node: GNode): GNode[] {
return this.edges
.filter((edge) => edge.from == node)
.map((edge) => edge.to);
}
/**
* @param {string} node
* @returns {{ from: string, to: string }[]}
*/
getDirectTailEdges(node) {
return this.edges.filter(edge => edge.from == node);
*/
getDirectTailEdges(node: GNode): GEdge[] {
return this.edges.filter((edge) => edge.from == node);
}
/**
* BFS
* @param {string} node
* @returns {string[]}
*/
getAllTails(node) {
const tailSet = new Set();
const toVisit = new Set(); // queue
/** BFS */
getAllTails(node: GNode): GNode[] {
const tailSet = new Set<GNode>();
const toVisit = new Set<GNode>(); // queue
toVisit.add(node);
while (toVisit.size > 0) {
const nextNode = toVisit.values().next().value;
const nextNode = toVisit.values().next().value!;
const nextTails = this.getDirectTails(nextNode);
nextTails.forEach(tail => {
nextTails.forEach((tail) => {
tailSet.add(tail);
toVisit.add(tail);
});
@ -106,14 +90,11 @@ export class myGraph {
return [...tailSet];
}
/**
* Kahn's topological sort
* @returns {string[]}
*/
topoSort() {
let inDegree = {};
let zeroInDegreeQueue = [];
let topologicalOrder = [];
/** Kahn's topological sort */
topoSort(): GNode[] {
let inDegree: Record<string, number> = {};
let zeroInDegreeQueue: GNode[] = [];
let topologicalOrder: GNode[] = [];
// Initialize inDegree of all nodes to 0
for (let node of this.nodes) {
@ -122,7 +103,7 @@ export class myGraph {
// Calculate inDegree of each node
for (let edge of this.edges) {
inDegree[edge.to]++;
inDegree[edge.to]!++;
}
// Collect nodes with 0 inDegree
@ -135,10 +116,10 @@ export class myGraph {
// process nodes with 0 inDegree
while (zeroInDegreeQueue.length > 0) {
let node = zeroInDegreeQueue.shift();
topologicalOrder.push(node);
topologicalOrder.push(node!);
for (let tail of this.getDirectTails(node)) {
inDegree[tail]--;
for (let tail of this.getDirectTails(node!)) {
inDegree[tail]!--;
if (inDegree[tail] === 0) {
zeroInDegreeQueue.push(tail);
}
@ -147,11 +128,8 @@ export class myGraph {
return topologicalOrder;
}
/**
* Check if the graph contains a cycle
* @returns {boolean}
*/
detectCycle() {
/** Check if the graph contains a cycle */
detectCycle(): boolean {
// If topological order includes all nodes, no cycle exists
return this.topoSort().length < this.nodes.length;
}

View File

@ -1,19 +0,0 @@
/**
* Stringifies only values of a JSON object, including nested ones
*
* @param {any} obj JSON object
* @param {string} delimiter Delimiter of final string
* @returns {string}
*/
export const getNestedValuesString = (obj, delimiter = ", ") => {
let values = [];
for (key in obj) {
if (typeof obj[key] !== "object") {
values.push(obj[key]);
} else {
values = values.concat(getNestedValuesString(obj[key]));
}
}
return values.join(delimiter);
}

16
utils/jsonTransformer.ts Normal file
View File

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

View File

@ -1,67 +0,0 @@
import path from "path";
import { createLogger, transports, config, format } from "winston";
import * as typedefs from "../typedefs.js";
const { combine, label, timestamp, printf, errors } = format;
const getLabel = (callingModule) => {
if (!callingModule.filename) return "repl";
const parts = callingModule.filename?.split(path.sep);
return path.join(parts[parts.length - 2], parts.pop());
};
const allowedErrorKeys = ["name", "code", "message", "stack"];
const metaFormat = (meta) => {
if (Object.keys(meta).length > 0)
return "\n" + JSON.stringify(meta, null, "\t");
return "";
}
const logFormat = printf(({ level, message, label, timestamp, ...meta }) => {
if (meta.error) { // if the error was passed
for (const key in meta.error) {
if (!allowedErrorKeys.includes(key)) {
delete meta.error[key];
}
}
const { stack, ...rest } = meta.error;
return `${timestamp} [${label}] ${level}: ${message}${metaFormat(rest)}\n` +
`${stack ?? ""}`;
}
return `${timestamp} [${label}] ${level}: ${message}${metaFormat(meta)}`;
});
/**
* Creates a curried function, and call it with the module in use to get logs with filename
* @param {typedefs.Module} callingModule The module from which the logger is called (ESM - import.meta)
*/
export const curriedLogger = (callingModule) => {
let winstonLogger = createLogger({
levels: config.npm.levels,
format: combine(
errors({ stack: true }),
label({ label: getLabel(callingModule) }),
timestamp({ format: "YYYY-MM-DD HH:mm:ss" }),
logFormat,
),
transports: [
new transports.Console({ level: "info" }),
new transports.File({
filename: import.meta.dirname + "/../logs/debug.log",
level: "debug",
maxsize: 10485760,
}),
new transports.File({
filename: import.meta.dirname + "/../logs/error.log",
level: "error",
maxsize: 1048576,
}),
]
});
winstonLogger.on("error", (error) => winstonLogger.error("Error inside logger", { error }));
return winstonLogger;
}
export default curriedLogger;

76
utils/logger.ts Normal file
View File

@ -0,0 +1,76 @@
import path from "path";
import { createLogger, transports, config, format, type Logger } from "winston";
const { combine, label, timestamp, printf, errors } = format;
const getLabel = (callingModuleName: string) => {
if (!callingModuleName) return "repl";
const parts = callingModuleName.split(path.sep);
return path.join(
parts[parts.length - 2] ?? "",
parts[parts.length - 1] ?? ""
);
};
const allowedErrorKeys = ["name", "code", "message", "stack"];
const metaFormat = (meta: Record<string, unknown>) => {
if (Object.keys(meta).length > 0)
return "\n" + JSON.stringify(meta, null, "\t");
return "";
};
const logFormat = printf(({ level, message, label, timestamp, ...meta }) => {
if (meta["error"]) {
const sanitizedError = Object.fromEntries(
Object.entries(meta["error"]).filter(([key]) =>
allowedErrorKeys.includes(key)
)
);
const { stack, ...rest } = sanitizedError;
return (
`${timestamp} [${label}] ${level}: ${message}${metaFormat(rest)}\n` +
`${stack ?? ""}`
);
}
return `${timestamp} [${label}] ${level}: ${message}${metaFormat(meta)}`;
});
const loggerCache = new Map<string, ReturnType<typeof createLogger>>();
const curriedLogger = (callingModuleName: string): Logger => {
if (loggerCache.has(callingModuleName)) {
return loggerCache.get(callingModuleName)!;
}
const winstonLogger = createLogger({
levels: config.npm.levels,
format: combine(
errors({ stack: true }),
label({ label: getLabel(callingModuleName) }),
timestamp({ format: "YYYY-MM-DD HH:mm:ss" }),
logFormat
),
transports: [
new transports.Console({ level: "info" }),
new transports.File({
filename: import.meta.dirname + "/../logs/debug.log",
level: "debug",
maxsize: 10485760,
}),
new transports.File({
filename: import.meta.dirname + "/../logs/error.log",
level: "error",
maxsize: 1048576,
}),
],
});
winstonLogger.on("error", (error) =>
winstonLogger.error("Error inside logger", { error })
);
loggerCache.set(callingModuleName, winstonLogger);
return winstonLogger;
};
export default curriedLogger;

View File

@ -1,49 +1,57 @@
import * as typedefs from "../typedefs.js";
import type { URIObject } from "spotify_manager/index.d.ts";
/** @type {RegExp} */
const base62Pattern = /^[A-Za-z0-9]+$/;
const base62Pattern: RegExp = /^[A-Za-z0-9]+$/;
/**
* Returns type and ID from a Spotify URI
* @see {@link https://developer.spotify.com/documentation/web-api/concepts/spotify-uris-ids|Spotify URIs and IDs}
* @param {string} uri Spotify URI - can be of an album, track, playlist, user, episode, etc.
* @returns {typedefs.URIObject}
* @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
*/
export const parseSpotifyURI = (uri) => {
const parseSpotifyURI = (uri: string): URIObject => {
const parts = uri.split(":");
if (parts[0] !== "spotify") {
throw new TypeError(`${uri} is not a valid Spotify URI`);
}
let type = parts[1];
let type = parts[1] ?? "";
// Local file format: spotify:local:<artist>:<album>:<title>:<duration>
if (type === "local") {
// Local file format: spotify:local:<artist>:<album>:<title>:<duration>
let idParts = parts.slice(2);
if (idParts.length < 4) {
if (idParts.length !== 4) {
throw new TypeError(`${uri} is not a valid local file URI`);
}
// URL decode artist, album, and title
const artist = decodeURIComponent(idParts[0] || "");
const album = decodeURIComponent(idParts[1] || "");
const title = decodeURIComponent(idParts[2]);
const duration = parseInt(idParts[3], 10);
// NOTE: why do i have to do non-null assertion here...
const artist = decodeURIComponent(idParts[0] ?? "");
const album = decodeURIComponent(idParts[1] ?? "");
const title = decodeURIComponent(idParts[2] ?? "");
let duration = parseInt(idParts[3] ?? "", 10);
if (isNaN(duration)) {
throw new TypeError(`${uri} has an invalid duration`);
let uriObj: URIObject = {
type: "track",
is_local: true,
artist,
album,
title,
id: "",
};
if (!isNaN(duration)) {
uriObj.duration = duration;
}
// throw new TypeError(`${uri} has an invalid duration`);
return { type: "track", is_local: true, artist, album, title, duration };
return uriObj;
} else {
// Not a local file
if (parts.length !== 3) {
throw new TypeError(`${uri} is not a valid Spotify URI`);
}
const id = parts[2];
const id = parts[2] ?? "";
if (!base62Pattern.test(id)) {
throw new TypeError(`${uri} has an invalid ID`);
@ -51,16 +59,16 @@ export const parseSpotifyURI = (uri) => {
return { type, is_local: false, id };
}
}
};
/**
* Returns type and ID from a Spotify link
* @param {string} link Spotify URL - can be of an album, track, playlist, user, episode, etc.
* @returns {typedefs.URIObject}
* @throws {TypeError} If the input is not a valid Spotify link
*/
export const parseSpotifyLink = (link) => {
const localPattern = /^https:\/\/open\.spotify\.com\/local\/([^\/]*)\/([^\/]*)\/([^\/]+)\/(\d+)$/;
const parseSpotifyLink = (link: string): URIObject => {
const localPattern =
/^https:\/\/open\.spotify\.com\/local\/([^\/]*)\/([^\/]*)\/([^\/]+)\/(\d+)$/;
const standardPattern = /^https:\/\/open\.spotify\.com\/([^\/]+)\/([^\/?]+)/;
if (localPattern.test(link)) {
@ -71,16 +79,24 @@ export const parseSpotifyLink = (link) => {
}
// URL decode artist, album, and title
const artist = decodeURIComponent(matches[1] || "");
const album = decodeURIComponent(matches[2] || "");
const title = decodeURIComponent(matches[3]);
const duration = parseInt(matches[4], 10);
const artist = decodeURIComponent(matches[1] ?? "");
const album = decodeURIComponent(matches[2] ?? "");
const title = decodeURIComponent(matches[3] ?? "");
const duration = parseInt(matches[4] ?? "", 10);
if (isNaN(duration)) {
throw new TypeError(`${link} has an invalid duration`);
}
return { type: "track", is_local: true, artist, album, title, duration };
return {
type: "track",
is_local: true,
artist,
album,
title,
duration,
id: "",
};
} else if (standardPattern.test(link)) {
// Not a local file
const matches = link.match(standardPattern);
@ -88,8 +104,8 @@ export const parseSpotifyLink = (link) => {
throw new TypeError(`${link} is not a valid Spotify link`);
}
const type = matches[1];
const id = matches[2];
const type = matches[1] ?? "";
const id = matches[2] ?? "";
if (!base62Pattern.test(id)) {
throw new TypeError(`${link} has an invalid ID`);
@ -99,14 +115,10 @@ export const parseSpotifyLink = (link) => {
} else {
throw new TypeError(`${link} is not a valid Spotify link`);
}
}
};
/**
* Builds URI string from a URIObject
* @param {typedefs.URIObject} uriObj
* @returns {string}
*/
export const buildSpotifyURI = (uriObj) => {
/** Builds URI string from a URIObject */
const buildSpotifyURI = (uriObj: URIObject): string => {
if (uriObj.is_local) {
const artist = encodeURIComponent(uriObj.artist ?? "");
const album = encodeURIComponent(uriObj.album ?? "");
@ -115,14 +127,10 @@ export const buildSpotifyURI = (uriObj) => {
return `spotify:local:${artist}:${album}:${title}:${duration}`;
}
return `spotify:${uriObj.type}:${uriObj.id}`;
}
};
/**
* Builds link from a URIObject
* @param {typedefs.URIObject} uriObj
* @returns {string}
*/
export const buildSpotifyLink = (uriObj) => {
/** Builds link from a URIObject */
const buildSpotifyLink = (uriObj: URIObject): string => {
if (uriObj.is_local) {
const artist = encodeURIComponent(uriObj.artist ?? "");
const album = encodeURIComponent(uriObj.album ?? "");
@ -130,5 +138,7 @@ export const buildSpotifyLink = (uriObj) => {
const duration = uriObj.duration ? uriObj.duration.toString() : "";
return `https://open.spotify.com/local/${artist}/${album}/${title}/${duration}`;
}
return `https://open.spotify.com/${uriObj.type}/${uriObj.id}`
}
return `https://open.spotify.com/${uriObj.type}/${uriObj.id}`;
};
export { parseSpotifyLink, parseSpotifyURI, buildSpotifyLink, buildSpotifyURI };

View File

@ -1,42 +0,0 @@
import { validationResult } from "express-validator";
import * as typedefs from "../typedefs.js";
import { getNestedValuesString } from "../utils/jsonTransformer.js";
import curriedLogger from "../utils/logger.js";
const logger = curriedLogger(import.meta);
/**
* Refer: https://stackoverflow.com/questions/58848625/access-messages-in-express-validator
*
* @param {typedefs.Req} req
* @param {typedefs.Res} res
* @param {typedefs.Next} next
*/
export const validate = (req, res, next) => {
const errors = validationResult(req);
if (errors.isEmpty()) {
return next();
}
const extractedErrors = [];
errors.array().forEach(err => {
if (err.type === "alternative") {
err.nestedErrors.forEach(nestedErr => {
extractedErrors.push({
[nestedErr.path]: nestedErr.msg
});
});
} else if (err.type === "field") {
extractedErrors.push({
[err.path]: err.msg
});
}
});
res.status(400).json({
message: getNestedValuesString(extractedErrors),
errors: extractedErrors
});
logger.warn("invalid request", { extractedErrors });
return;
}

38
validators/index.ts Normal file
View File

@ -0,0 +1,38 @@
import { validationResult } from "express-validator";
import type { RequestHandler } from "express";
import { getNestedValuesString } from "../utils/jsonTransformer.ts";
import curriedLogger from "../utils/logger.ts";
const logger = curriedLogger(import.meta.filename);
/** Refer: https://stackoverflow.com/questions/58848625/access-messages-in-express-validator */
export const validate: RequestHandler = (req, res, next) => {
const errors = validationResult(req);
if (errors.isEmpty()) {
return next();
}
const extractedErrors: Record<string, string>[] = [];
errors.array().forEach((err) => {
if (err.type === "alternative") {
err.nestedErrors.forEach((nestedErr) => {
extractedErrors.push({
[nestedErr.path]: nestedErr.msg,
});
});
} else if (err.type === "field") {
extractedErrors.push({
[err.path]: err.msg,
});
}
});
res.status(400).send({
message: getNestedValuesString(extractedErrors),
errors: extractedErrors,
});
logger.warn("invalid request", { extractedErrors });
return null;
};

View File

@ -1,27 +0,0 @@
import { body, header, param, query } from "express-validator";
import * as typedefs from "../typedefs.js";
/**
* @param {typedefs.Req} req
* @param {typedefs.Res} res
* @param {typedefs.Next} next
*/
export const createLinkValidator = async (req, res, next) => {
await body("from")
.notEmpty()
.withMessage("from not defined in body")
.isURL()
.withMessage("from must be a valid link")
.run(req);
await body("to")
.notEmpty()
.withMessage("to not defined in body")
.isURL()
.withMessage("to must be a valid link")
.run(req);
next();
}
export { createLinkValidator as removeLinkValidator };
export { createLinkValidator as populateSingleLinkValidator };
export { createLinkValidator as pruneSingleLinkValidator };

25
validators/operations.ts Normal file
View File

@ -0,0 +1,25 @@
import { body } from "express-validator";
import type { RequestHandler } from "express";
const createLinkValidator: RequestHandler = async (req, _res, next) => {
await body("from")
.notEmpty()
.withMessage("from not defined in body")
.isURL()
.withMessage("from must be a valid link")
.run(req);
await body("to")
.notEmpty()
.withMessage("to not defined in body")
.isURL()
.withMessage("to must be a valid link")
.run(req);
next();
};
export {
createLinkValidator,
createLinkValidator as removeLinkValidator,
createLinkValidator as populateSingleLinkValidator,
createLinkValidator as pruneSingleLinkValidator,
};

View File

@ -1,17 +0,0 @@
import { body, header, param, query } from "express-validator";
import * as typedefs from "../typedefs.js";
/**
* @param {typedefs.Req} req
* @param {typedefs.Res} res
* @param {typedefs.Next} next
*/
export const getPlaylistDetailsValidator = async (req, res, next) => {
await query("playlist_link")
.notEmpty()
.withMessage("playlist_link not defined in query")
.isURL()
.withMessage("playlist_link must be a valid link")
.run(req);
next();
}

14
validators/playlists.ts Normal file
View File

@ -0,0 +1,14 @@
import { query } from "express-validator";
import type { RequestHandler } from "express";
const getPlaylistDetailsValidator: RequestHandler = async (req, _res, next) => {
await query("playlist_link")
.notEmpty()
.withMessage("playlist_link not defined in query")
.isURL()
.withMessage("playlist_link must be a valid link")
.run(req);
next();
};
export { getPlaylistDetailsValidator };