diff --git a/README.md b/README.md index d91b3fe..dd0c68c 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,9 @@ # spotify-manager Personal Spotify playlist manager. Features inbound! + +## notes + +- graphing + - stores links as from-to pairs + - fetches all playlists and links of the user into memory and then works with data. assumption is graphs won't be too big diff --git a/controllers/operations.js b/controllers/operations.js index 9d7de20..2dd7801 100644 --- a/controllers/operations.js +++ b/controllers/operations.js @@ -2,6 +2,7 @@ const typedefs = require("../typedefs"); const logger = require("../utils/logger")(module); const { axiosInstance } = require("../utils/axios"); +const myGraph = require("../utils/graph"); const { parseSpotifyUri, parseSpotifyLink } = require("../utils/spotifyUriTransformer"); @@ -93,7 +94,6 @@ const updateUser = async (req, res) => { toRemove = []; } let toRemoveIDs = toRemove.map(pl => pl.playlistID); - logger.debug("removeIDs", { toRemoveIDs }); let removedLinks = 0; if (toRemove.length) { @@ -138,7 +138,7 @@ const updateUser = async (req, res) => { } /** - * Fetch user's stored playlists + * Fetch user's stored playlists and links * @param {typedefs.Req} req * @param {typedefs.Res} res */ @@ -146,7 +146,7 @@ const fetchUser = async (req, res) => { try { const userURI = parseSpotifyUri(req.session.user.uri); - let currentPlaylists = await Playlists.findAll({ + const currentPlaylists = await Playlists.findAll({ attributes: ["playlistID", "playlistName"], raw: true, where: { @@ -154,7 +154,18 @@ const fetchUser = async (req, res) => { }, }); - return res.status(200).send(currentPlaylists); + const currentLinks = await Links.findAll({ + attributes: ["from", "to"], + raw: true, + where: { + userID: userURI.id + }, + }); + + return res.status(200).send({ + playlists: currentPlaylists, + links: currentLinks + }); } catch (error) { logger.error('fetchUser', { error }); return res.sendStatus(500); @@ -175,19 +186,17 @@ const createLink = async (req, res) => { fromPl = parseSpotifyLink(req.body["from"]); toPl = parseSpotifyLink(req.body["to"]); if (fromPl.type !== "playlist" || toPl.type !== "playlist") { - return res.sendStatus(400); + return res.status(400).send({ message: "Invalid Spotify playlist link" }); } } catch (error) { logger.error("parseSpotifyLink", { error }); - return res.sendStatus(400); + return res.status(400).send({ message: "Invalid Spotify playlist link" }); } let playlists = await Playlists.findAll({ attributes: ["playlistID"], raw: true, - where: { - userID: userURI.id - } + where: { userID: userURI.id } }); playlists = playlists.map(pl => pl.playlistID); @@ -212,6 +221,19 @@ const createLink = async (req, res) => { return res.sendStatus(409); } + const allLinks = await Links.findAll({ + attributes: ["from", "to"], + raw: true, + where: { userID: userURI.id } + }); + + const newGraph = new myGraph(playlists, [...allLinks, { from: fromPl.id, to: toPl.id }]); + + if (newGraph.detectCycle()) { + logger.error("potential cycle detected"); + return res.status(400).send({ message: "Proposed link cannot cause a cycle in the graph" }); + } + const newLink = await Links.create({ userID: userURI.id, from: fromPl.id, @@ -244,11 +266,11 @@ const removeLink = async (req, res) => { fromPl = parseSpotifyLink(req.body["from"]); toPl = parseSpotifyLink(req.body["to"]); if (fromPl.type !== "playlist" || toPl.type !== "playlist") { - return res.sendStatus(400); + return res.status(400).send({ message: "Invalid Spotify playlist link" }); } } catch (error) { logger.error("parseSpotifyLink", { error }); - return res.sendStatus(400); + return res.status(400).send({ message: "Invalid Spotify playlist link" }); } // check if exists diff --git a/controllers/playlists.js b/controllers/playlists.js index 49c4925..f281d2d 100644 --- a/controllers/playlists.js +++ b/controllers/playlists.js @@ -98,11 +98,11 @@ const getPlaylistDetails = async (req, res) => { try { uri = parseSpotifyLink(req.query.playlist_link) if (uri.type !== "playlist") { - return res.status(400).send("Not a playlist link"); + return res.status(400).send({ message: "Invalid Spotify playlist link" }); } } catch (error) { logger.error("parseSpotifyLink", { error }); - return res.sendStatus(400); + return res.status(400).send({ message: "Invalid Spotify playlist link" }); } const response = await axiosInstance.get( diff --git a/routes/operations.js b/routes/operations.js index 5614a61..73ad4fe 100644 --- a/routes/operations.js +++ b/routes/operations.js @@ -23,7 +23,7 @@ router.post( createLinkValidator, validate, createLink -) +); router.delete( "/link", @@ -31,5 +31,6 @@ router.delete( removeLinkValidator, validate, removeLink -) +); + module.exports = router; diff --git a/utils/graph.js b/utils/graph.js new file mode 100644 index 0000000..5b0c85e --- /dev/null +++ b/utils/graph.js @@ -0,0 +1,79 @@ +const logger = require("./logger")(module); + +const typedefs = require("../typedefs"); + +/** + * Directed graph, may or may not be connected. + * + * NOTE: Assumes that nodes and edges are valid. + */ +class myGraph { + /** + * @param {string[]} nodes Graph nodes IDs + * @param {{ from: string, to: string }[]} edges Graph edges b/w nodes + */ + constructor(nodes, edges) { + this.nodes = [...nodes]; + this.edges = structuredClone(edges); + } + + /** + * @param {type} node + * @returns {string[]} + */ + getNeighbors(node) { + return this.edges.filter(edge => edge.from == node).map(edge => edge.to); + } + + /** + * Kahn's topological sort + * @returns {string[]} + */ + topoSort() { + let inDegree = {}; + let zeroInDegreeQueue = []; + let topologicalOrder = []; + + // Initialize inDegree of all nodes to 0 + for (let node of this.nodes) { + inDegree[node] = 0; + } + + // Calculate inDegree of each node + for (let edge of this.edges) { + inDegree[edge.to]++; + } + + // Collect nodes with 0 inDegree + for (let node of this.nodes) { + if (inDegree[node] === 0) { + zeroInDegreeQueue.push(node); + } + } + + // process nodes with 0 inDegree + while (zeroInDegreeQueue.length > 0) { + let node = zeroInDegreeQueue.shift(); + topologicalOrder.push(node); + + for (let neighbor of this.getNeighbors(node)) { + inDegree[neighbor]--; + if (inDegree[neighbor] === 0) { + zeroInDegreeQueue.push(neighbor); + } + } + } + return topologicalOrder; + } + + /** + * Check if the graph contains a cycle + * @returns {boolean} + */ + detectCycle() { + // If topological order includes all nodes, no cycle exists + return this.topoSort().length < this.nodes.length; + } +} + +module.exports = myGraph;