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:
2025-03-11 15:24:45 -07:00
parent bcc39d5f38
commit a74ffc453e
68 changed files with 2795 additions and 1569 deletions

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