mirror of
https://github.com/20kaushik02/spotify-manager.git
synced 2026-01-25 06:04:05 +00:00
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:
@@ -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
7
utils/flake.ts
Normal 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));
|
||||
@@ -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++) {
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
16
utils/jsonTransformer.ts
Normal 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);
|
||||
};
|
||||
@@ -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
76
utils/logger.ts
Normal 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;
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user