diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..6e3483a --- /dev/null +++ b/.editorconfig @@ -0,0 +1,25 @@ +# EditorConfig helps developers define and maintain consistent +# coding styles between different editors and IDEs +# editorconfig.org + +# top-most EditorConfig file +root = true + +[*] +end_of_line = lf +insert_final_newline = true + +charset = utf-8 + +indent_style = space +indent_size = 2 + +trim_trailing_whitespace = true +max_line_length = 80 + +[*.txt] +indent_style = tab +indent_size = 4 + +[*.{diff,md}] +trim_trailing_whitespace = false diff --git a/.sequelizerc b/.sequelizerc index df63de1..915c148 100644 --- a/.sequelizerc +++ b/.sequelizerc @@ -2,5 +2,5 @@ require("dotenv-flow").config(); const path = require("path"); module.exports = { - "config": path.resolve("config", "sequelize.js") -}; \ No newline at end of file + "config": path.resolve("config", "sequelize.js") +}; diff --git a/api/axios.js b/api/axios.js index 9e9d2b1..63e46c0 100644 --- a/api/axios.js +++ b/api/axios.js @@ -5,53 +5,53 @@ const { baseAPIURL, accountsAPIURL } = require("../constants"); const logger = require("../utils/logger")(module); 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")) - }, + 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" - }, + baseURL: baseAPIURL, + timeout: 20000, + headers: { + "Content-Type": "application/json" + }, }); const axiosInstance = rateLimit(uncappedAxiosInstance, { - maxRequests: 10, - perMilliseconds: 5000, + 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; + 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); - } + (response) => response, + (error) => { + logger.warn("AxiosError", { + error: { + name: error.name, + code: error.code, + message: error.message, + }, + req: error.config, + }); + return Promise.reject(error); + } ); module.exports = { - authInstance, - axiosInstance + authInstance, + axiosInstance }; diff --git a/api/spotify.js b/api/spotify.js index 8f5ef78..7a3e8cc 100644 --- a/api/spotify.js +++ b/api/spotify.js @@ -18,147 +18,147 @@ const logPrefix = "Spotify API: "; * @param {boolean} inlineData true if data is to be placed inside config */ 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); + 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 }); - } + 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; - }; + return null; + }; } 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; + const response = await singleRequest(req, res, + "GET", "/me", + { headers: { Authorization: `Bearer ${req.session.accessToken}` } } + ); + return res.headersSent ? null : response.data; } 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; + const response = await singleRequest(req, res, + "GET", + `/users/${req.session.user.id}/playlists`, + { + params: { + offset: 0, + limit: 50, + }, + }); + return res.headersSent ? null : response.data; } const getUserPlaylistsNextPage = async (req, res, nextURL) => { - const response = await singleRequest( - req, res, "GET", nextURL); - return res.headersSent ? null : response.data; + const response = await singleRequest( + req, res, "GET", nextURL); + return res.headersSent ? null : response.data; } 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; + const response = await singleRequest(req, res, + "GET", + `/playlists/${playlistID}/`, + { + params: { + fields: initialFields + }, + }); + return res.headersSent ? null : response.data; } const getPlaylistDetailsNextPage = async (req, res, nextURL) => { - const response = await singleRequest( - req, res, "GET", nextURL); - return res.headersSent ? null : response.data; + const response = await singleRequest( + req, res, "GET", nextURL); + return res.headersSent ? null : response.data; } 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; + const response = await singleRequest(req, res, + "POST", + `/playlists/${playlistID}/tracks`, + {}, + { uris: nextBatch }, false + ) + return res.headersSent ? null : response.data; } 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; + // 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; } const checkPlaylistEditable = async (req, res, playlistID, userID) => { - let checkFields = ["collaborative", "owner(id)"]; + let checkFields = ["collaborative", "owner(id)"]; - const checkFromData = await getPlaylistDetailsFirstPage(req, res, checkFields.join(), playlistID); - if (res.headersSent) return false; + 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; - } + // 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; + } } module.exports = { - singleRequest, - getUserProfile, - getUserPlaylistsFirstPage, - getUserPlaylistsNextPage, - getPlaylistDetailsFirstPage, - getPlaylistDetailsNextPage, - addItemsToPlaylist, - removeItemsFromPlaylist, - checkPlaylistEditable, -} \ No newline at end of file + singleRequest, + getUserProfile, + getUserPlaylistsFirstPage, + getUserPlaylistsNextPage, + getPlaylistDetailsFirstPage, + getPlaylistDetailsNextPage, + addItemsToPlaylist, + removeItemsFromPlaylist, + checkPlaylistEditable, +} diff --git a/boilerplates/controller.js b/boilerplates/controller.js index 9a0b9bc..9619ed6 100644 --- a/boilerplates/controller.js +++ b/boilerplates/controller.js @@ -7,15 +7,15 @@ const typedefs = require("../typedefs"); * @param {typedefs.Res} res */ const __controller_func = async (req, res) => { - try { + try { - } catch (error) { - res.status(500).send({ message: "Internal Server Error" }); - logger.error("__controller_func", { error }); - return; - } + } catch (error) { + res.status(500).send({ message: "Internal Server Error" }); + logger.error("__controller_func", { error }); + return; + } } module.exports = { - __controller_func + __controller_func }; diff --git a/boilerplates/validator.js b/boilerplates/validator.js index 790eb73..75aa736 100644 --- a/boilerplates/validator.js +++ b/boilerplates/validator.js @@ -3,19 +3,19 @@ const { body, header, param, query } = require("express-validator"); const typedefs = require("../typedefs"); /** - * @param {typedefs.Req} req - * @param {typedefs.Res} res - * @param {typedefs.Next} next + * @param {typedefs.Req} req + * @param {typedefs.Res} res + * @param {typedefs.Next} next */ const __validator_func = async (req, res, next) => { - await body("field_name") - .notEmpty() - .withMessage("field_name not defined in body") - .run(req); + await body("field_name") + .notEmpty() + .withMessage("field_name not defined in body") + .run(req); - next(); + next(); } module.exports = { - __validator_func + __validator_func } diff --git a/config/sequelize.js b/config/sequelize.js index b07716a..903e981 100644 --- a/config/sequelize.js +++ b/config/sequelize.js @@ -1,28 +1,28 @@ const logger = require("../utils/logger")(module); 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, - }, - staging: { - 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, - // }, - } + 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, + }, + staging: { + 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"; + connConfigs[conf]["logging"] = (msg) => logger.debug(msg); + connConfigs[conf]["dialect"] = process.env.DB_DIALECT || "postgres"; } module.exports = connConfigs; diff --git a/constants.js b/constants.js index aa06c72..ae6d41e 100644 --- a/constants.js +++ b/constants.js @@ -4,22 +4,22 @@ const sessionName = "spotify-manager"; const stateKey = "spotify_auth_state"; 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", + // 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", }; module.exports = { - accountsAPIURL, - baseAPIURL, - sessionName, - stateKey, - scopes + accountsAPIURL, + baseAPIURL, + sessionName, + stateKey, + scopes }; diff --git a/controllers/auth.js b/controllers/auth.js index 5efe4d9..3df72fd 100644 --- a/controllers/auth.js +++ b/controllers/auth.js @@ -13,125 +13,125 @@ const logger = require("../utils/logger")(module); * @param {typedefs.Res} res */ const login = (_req, res) => { - try { - const state = generateRandString(16); - res.cookie(stateKey, state); + try { + const state = generateRandString(16); + res.cookie(stateKey, state); - const scope = Object.values(scopes).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() - ); - return; - } catch (error) { - res.status(500).send({ message: "Internal Server Error" }); - logger.error("login", { error }); - return; - } + const scope = Object.values(scopes).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() + ); + return; + } catch (error) { + res.status(500).send({ message: "Internal Server Error" }); + logger.error("login", { error }); + return; + } } /** * Exchange authorization code for refresh and access tokens - * @param {typedefs.Req} req - * @param {typedefs.Res} res + * @param {typedefs.Req} req + * @param {typedefs.Res} res */ const callback = async (req, res) => { - try { - const { code, state, error } = req.query; - const storedState = req.cookies ? req.cookies[stateKey] : null; + try { + const { code, state, error } = req.query; + const storedState = req.cookies ? req.cookies[stateKey] : null; - // check state - if (state === null || state !== storedState) { - res.redirect(409, "/"); - logger.warn("state mismatch"); - return; - } else if (error) { - res.status(401).send({ message: "Auth callback error" }); - logger.error("callback error", { error }); - return; - } else { - // get auth tokens - res.clearCookie(stateKey); + // check state + if (state === null || state !== storedState) { + res.redirect(409, "/"); + logger.warn("state mismatch"); + return; + } else if (error) { + res.status(401).send({ message: "Auth callback error" }); + logger.error("callback error", { error }); + return; + } else { + // get auth tokens + res.clearCookie(stateKey); - const authForm = { - code: code, - redirect_uri: process.env.REDIRECT_URI, - grant_type: "authorization_code" - } + const authForm = { + code: code, + redirect_uri: process.env.REDIRECT_URI, + grant_type: "authorization_code" + } - const authPayload = (new URLSearchParams(authForm)).toString(); + const authPayload = (new URLSearchParams(authForm)).toString(); - const tokenResponse = await authInstance.post("/api/token", authPayload); + const tokenResponse = await authInstance.post("/api/token", authPayload); - if (tokenResponse.status === 200) { - logger.debug("Tokens obtained."); - req.session.accessToken = tokenResponse.data.access_token; - req.session.refreshToken = tokenResponse.data.refresh_token; - } else { - logger.error("login failed", { statusCode: tokenResponse.status }); - res.status(tokenResponse.status).send({ message: "Error: Login failed" }); - } + if (tokenResponse.status === 200) { + logger.debug("Tokens obtained."); + req.session.accessToken = tokenResponse.data.access_token; + req.session.refreshToken = tokenResponse.data.refresh_token; + } else { + logger.error("login failed", { statusCode: tokenResponse.status }); + res.status(tokenResponse.status).send({ message: "Error: Login failed" }); + } - const userData = await getUserProfile(req, res); - if (res.headersSent) return; + const userData = await getUserProfile(req, res); + if (res.headersSent) return; - /** @type {typedefs.User} */ - req.session.user = { - username: userData.display_name, - id: userData.id, - }; + /** @type {typedefs.User} */ + req.session.user = { + username: userData.display_name, + id: userData.id, + }; - // res.status(200).send({ message: "OK" }); - res.redirect(process.env.APP_URI + "?login=success"); - logger.debug("New login.", { username: userData.display_name }); - return; - } - } catch (error) { - res.status(500).send({ message: "Internal Server Error" }); - logger.error("callback", { error }); - return; - } + // res.status(200).send({ message: "OK" }); + res.redirect(process.env.APP_URI + "?login=success"); + logger.debug("New login.", { username: userData.display_name }); + return; + } + } catch (error) { + res.status(500).send({ message: "Internal Server Error" }); + logger.error("callback", { error }); + return; + } } /** * Request new access token using refresh token - * @param {typedefs.Req} req + * @param {typedefs.Req} req * @param {typedefs.Res} res */ const refresh = async (req, res) => { - try { - const authForm = { - refresh_token: req.session.refreshToken, - grant_type: "refresh_token", - } + try { + const authForm = { + refresh_token: req.session.refreshToken, + grant_type: "refresh_token", + } - const authPayload = (new URLSearchParams(authForm)).toString(); + const authPayload = (new URLSearchParams(authForm)).toString(); - const response = await authInstance.post("/api/token", authPayload); + 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 + if (response.status === 200) { + req.session.accessToken = response.data.access_token; + 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; - } else { - res.status(response.status).send({ message: "Error: Refresh token flow failed." }); - logger.error("refresh failed", { statusCode: response.status }); - return; - } - } catch (error) { - res.status(500).send({ message: "Internal Server Error" }); - logger.error("refresh", { error }); - return; - } + res.status(200).send({ message: "OK" }); + logger.debug(`Access token refreshed${(response.data.refresh_token !== null) ? " and refresh token updated" : ""}.`); + return; + } else { + res.status(response.status).send({ message: "Error: Refresh token flow failed." }); + logger.error("refresh failed", { statusCode: response.status }); + return; + } + } catch (error) { + res.status(500).send({ message: "Internal Server Error" }); + logger.error("refresh", { error }); + return; + } }; /** @@ -140,30 +140,30 @@ const refresh = async (req, res) => { * @param {typedefs.Res} res */ const logout = async (req, res) => { - try { - const delSession = req.session.destroy((error) => { - if (error) { - 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"); - logger.debug("Logged out.", { sessionID: delSession.id }); - return; - } - }) - } catch (error) { - res.status(500).send({ message: "Internal Server Error" }); - logger.error("logout", { error }); - return; - } + try { + const delSession = req.session.destroy((error) => { + if (error) { + 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"); + logger.debug("Logged out.", { sessionID: delSession.id }); + return; + } + }) + } catch (error) { + res.status(500).send({ message: "Internal Server Error" }); + logger.error("logout", { error }); + return; + } } module.exports = { - login, - callback, - refresh, - logout + login, + callback, + refresh, + logout }; diff --git a/controllers/operations.js b/controllers/operations.js index 7987c73..7f64fd8 100644 --- a/controllers/operations.js +++ b/controllers/operations.js @@ -19,112 +19,112 @@ const Links = require("../models").links; * @param {typedefs.Res} res */ const updateUser = async (req, res) => { - try { - let currentPlaylists = []; - const uID = req.session.user.id; + try { + let currentPlaylists = []; + const uID = req.session.user.id; - // get first 50 - const respData = await getUserPlaylistsFirstPage(req, res); - if (res.headersSent) return; + // get first 50 + const respData = await getUserPlaylistsFirstPage(req, res); + if (res.headersSent) return; - currentPlaylists = respData.items.map(playlist => { - return { - playlistID: playlist.id, - playlistName: playlist.name - } - }); - let nextURL = respData.next; + currentPlaylists = respData.items.map(playlist => { + return { + playlistID: playlist.id, + 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; + // keep getting batches of 50 till exhausted + while (nextURL) { + const nextData = await getUserPlaylistsNextPage(req, res, nextURL); + if (res.headersSent) return; - currentPlaylists.push( - ...nextData.items.map(playlist => { - return { - playlistID: playlist.id, - playlistName: playlist.name - } - }) - ); + currentPlaylists.push( + ...nextData.items.map(playlist => { + return { + playlistID: playlist.id, + playlistName: playlist.name + } + }) + ); - nextURL = nextData.next; - } + nextURL = nextData.next; + } - let oldPlaylists = await Playlists.findAll({ - attributes: ["playlistID"], - raw: true, - where: { - userID: uID - }, - }); + let oldPlaylists = await Playlists.findAll({ + attributes: ["playlistID"], + raw: true, + where: { + userID: uID + }, + }); - let toRemovePls, toAddPls; - if (oldPlaylists.length) { - // existing user - const currentSet = new Set(currentPlaylists.map(pl => pl.playlistID)); - const oldSet = new Set(oldPlaylists.map(pl => pl.playlistID)); + let toRemovePls, toAddPls; + if (oldPlaylists.length) { + // existing user + const currentSet = new Set(currentPlaylists.map(pl => pl.playlistID)); + const oldSet = new Set(oldPlaylists.map(pl => pl.playlistID)); - // TODO: update playlist name - toAddPls = currentPlaylists.filter(current => !oldSet.has(current.playlistID)); - toRemovePls = oldPlaylists.filter(old => !currentSet.has(old.playlistID)); - } else { - // new user - toAddPls = currentPlaylists; - toRemovePls = []; - } - let toRemovePlIDs = toRemovePls.map(pl => pl.playlistID); + // TODO: update playlist name + toAddPls = currentPlaylists.filter(current => !oldSet.has(current.playlistID)); + toRemovePls = oldPlaylists.filter(old => !currentSet.has(old.playlistID)); + } else { + // new user + toAddPls = currentPlaylists; + toRemovePls = []; + } + let toRemovePlIDs = toRemovePls.map(pl => pl.playlistID); - let removedLinks = 0, cleanedUser = 0, updatedUser = []; + let removedLinks = 0, cleanedUser = 0, updatedUser = []; - if (toRemovePls.length) { - // clean up any links dependent on the playlists - removedLinks = await Links.destroy({ - where: { - [Op.and]: [ - { userID: uID }, - { - [Op.or]: [ - { from: { [Op.in]: toRemovePlIDs } }, - { to: { [Op.in]: toRemovePlIDs } }, - ] - } - ] - } - }) + if (toRemovePls.length) { + // clean up any links dependent on the playlists + removedLinks = await Links.destroy({ + where: { + [Op.and]: [ + { userID: uID }, + { + [Op.or]: [ + { from: { [Op.in]: toRemovePlIDs } }, + { to: { [Op.in]: toRemovePlIDs } }, + ] + } + ] + } + }) - // only then remove - cleanedUser = await Playlists.destroy({ - where: { playlistID: toRemovePlIDs } - }); - if (cleanedUser !== toRemovePls.length) { - res.status(500).send({ message: "Internal Server Error" }); - logger.warn("Could not remove all old playlists", { error: new Error("Playlists.destroy failed?") }); - return; - } - } + // only then remove + cleanedUser = await Playlists.destroy({ + where: { playlistID: toRemovePlIDs } + }); + if (cleanedUser !== toRemovePls.length) { + res.status(500).send({ message: "Internal Server Error" }); + logger.warn("Could not remove all old playlists", { error: new Error("Playlists.destroy failed?") }); + return; + } + } - if (toAddPls.length) { - updatedUser = await Playlists.bulkCreate( - toAddPls.map(pl => { return { ...pl, userID: uID } }), - { validate: true } - ); - if (updatedUser.length !== toAddPls.length) { - res.status(500).send({ message: "Internal Server Error" }); - logger.error("Could not add all new playlists", { error: new Error("Playlists.bulkCreate failed?") }); - return; - } - } + if (toAddPls.length) { + updatedUser = await Playlists.bulkCreate( + toAddPls.map(pl => { return { ...pl, userID: uID } }), + { validate: true } + ); + if (updatedUser.length !== toAddPls.length) { + res.status(500).send({ message: "Internal Server Error" }); + logger.error("Could not add all new playlists", { error: new Error("Playlists.bulkCreate failed?") }); + return; + } + } - res.status(200).send({ removedLinks: removedLinks > 0 }); - logger.debug("Updated user data", { delLinks: removedLinks, delPls: cleanedUser, addPls: updatedUser.length }); - return; - } catch (error) { - res.status(500).send({ message: "Internal Server Error" }); - logger.error("updateUser", { error }); - return; - } + res.status(200).send({ removedLinks: removedLinks > 0 }); + logger.debug("Updated user data", { delLinks: removedLinks, delPls: cleanedUser, addPls: updatedUser.length }); + return; + } catch (error) { + res.status(500).send({ message: "Internal Server Error" }); + logger.error("updateUser", { error }); + return; + } } /** @@ -133,40 +133,40 @@ const updateUser = async (req, res) => { * @param {typedefs.Res} res */ const fetchUser = async (req, res) => { - try { - // if (randomBool(0.5)) { - // res.status(404).send({ message: "Not Found" }); - // return; - // } - const uID = req.session.user.id; + try { + // if (randomBool(0.5)) { + // res.status(404).send({ message: "Not Found" }); + // return; + // } + const uID = req.session.user.id; - const currentPlaylists = await Playlists.findAll({ - attributes: ["playlistID", "playlistName"], - raw: true, - where: { - userID: uID - }, - }); + const currentPlaylists = await Playlists.findAll({ + attributes: ["playlistID", "playlistName"], + raw: true, + where: { + userID: uID + }, + }); - const currentLinks = await Links.findAll({ - attributes: ["from", "to"], - raw: true, - where: { - userID: uID - }, - }); + const currentLinks = await Links.findAll({ + attributes: ["from", "to"], + raw: true, + where: { + userID: uID + }, + }); - res.status(200).send({ - playlists: currentPlaylists, - links: currentLinks - }); - logger.debug("Fetched user data", { pls: currentPlaylists.length, links: currentLinks.length }); - return; - } catch (error) { - res.status(500).send({ message: "Internal Server Error" }); - logger.error("fetchUser", { error }); - return; - } + res.status(200).send({ + playlists: currentPlaylists, + links: currentLinks + }); + logger.debug("Fetched user data", { pls: currentPlaylists.length, links: currentLinks.length }); + return; + } catch (error) { + res.status(500).send({ message: "Internal Server Error" }); + logger.error("fetchUser", { error }); + return; + } } /** @@ -175,88 +175,88 @@ const fetchUser = async (req, res) => { * @param {typedefs.Res} res */ const createLink = async (req, res) => { - try { - // await sleep(1000); - const uID = req.session.user.id; + try { + // await sleep(1000); + const uID = req.session.user.id; - let fromPl, toPl; - try { - fromPl = parseSpotifyLink(req.body.from); - toPl = parseSpotifyLink(req.body.to); - 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; - } - } catch (error) { - res.status(400).send({ message: "Could not parse link" }); - logger.warn("parseSpotifyLink", { error }); - return; - } + let fromPl, toPl; + try { + fromPl = parseSpotifyLink(req.body.from); + toPl = parseSpotifyLink(req.body.to); + 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; + } + } catch (error) { + res.status(400).send({ message: "Could not parse link" }); + logger.warn("parseSpotifyLink", { error }); + return; + } - let playlists = await Playlists.findAll({ - attributes: ["playlistID"], - raw: true, - where: { userID: uID } - }); - playlists = playlists.map(pl => pl.playlistID); + let playlists = await Playlists.findAll({ + attributes: ["playlistID"], + raw: true, + where: { userID: uID } + }); + playlists = playlists.map(pl => pl.playlistID); - // if playlists are unknown - if (![fromPl, toPl].every(pl => playlists.includes(pl.id))) { - res.status(404).send({ message: "Playlists out of sync " }); - logger.warn("unknown playlists, resync"); - return; - } + // if playlists are unknown + if (![fromPl, toPl].every(pl => playlists.includes(pl.id))) { + res.status(404).send({ message: "Playlists out of sync " }); + logger.warn("unknown playlists, resync"); + return; + } - // check if exists - const existingLink = await Links.findOne({ - where: { - [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; - } + // check if exists + const existingLink = await Links.findOne({ + where: { + [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; + } - const allLinks = await Links.findAll({ - attributes: ["from", "to"], - raw: true, - where: { userID: uID } - }); + const allLinks = await Links.findAll({ + attributes: ["from", "to"], + raw: true, + where: { userID: uID } + }); - const newGraph = new myGraph(playlists, [...allLinks, { from: fromPl.id, to: toPl.id }]); + const newGraph = new myGraph(playlists, [...allLinks, { from: fromPl.id, to: toPl.id }]); - if (newGraph.detectCycle()) { - res.status(400).send({ message: "Proposed link cannot cause a cycle in the graph" }); - logger.warn("potential cycle detected"); - return; - } + if (newGraph.detectCycle()) { + res.status(400).send({ message: "Proposed link cannot cause a cycle in the graph" }); + logger.warn("potential cycle detected"); + return; + } - const newLink = await Links.create({ - userID: uID, - from: fromPl.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; - } + const newLink = await Links.create({ + userID: uID, + from: fromPl.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; + } - res.status(201).send({ message: "Created link." }); - logger.debug("Created link"); - return; - } catch (error) { - res.status(500).send({ message: "Internal Server Error" }); - logger.error("createLink", { error }); - return; - } + res.status(201).send({ message: "Created link." }); + logger.debug("Created link"); + return; + } catch (error) { + res.status(500).send({ message: "Internal Server Error" }); + logger.error("createLink", { error }); + return; + } } @@ -266,399 +266,399 @@ const createLink = async (req, res) => { * @param {typedefs.Res} res */ const removeLink = async (req, res) => { - try { - const uID = req.session.user.id; + try { + const uID = req.session.user.id; - let fromPl, toPl; - try { - fromPl = parseSpotifyLink(req.body.from); - toPl = parseSpotifyLink(req.body.to); - 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; - } - } catch (error) { - res.status(400).send({ message: "Could not parse link" }); - logger.warn("parseSpotifyLink", { error }); - return; - } + let fromPl, toPl; + try { + fromPl = parseSpotifyLink(req.body.from); + toPl = parseSpotifyLink(req.body.to); + 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; + } + } catch (error) { + res.status(400).send({ message: "Could not parse link" }); + logger.warn("parseSpotifyLink", { error }); + return; + } - // check if exists - const existingLink = await Links.findOne({ - where: { - [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; - } + // check if exists + const existingLink = await Links.findOne({ + where: { + [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; + } - const removedLink = await Links.destroy({ - where: { - [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; - } + const removedLink = await Links.destroy({ + where: { + [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; + } - res.status(200).send({ message: "Deleted link." }); - logger.debug("Deleted link"); - return; - } catch (error) { - res.status(500).send({ message: "Internal Server Error" }); - logger.error("removeLink", { error }); - return; - } + res.status(200).send({ message: "Deleted link." }); + logger.debug("Deleted link"); + return; + } catch (error) { + res.status(500).send({ message: "Internal Server Error" }); + logger.error("removeLink", { error }); + return; + } } /** * Add tracks to the link-head playlist, * that are present in the link-tail playlist but not in the link-head playlist, * in the order that they are present in the link-tail playlist. - * + * * eg. - * + * * pl_a has tracks: a, b, c - * + * * pl_b has tracks: e, b, d - * + * * link from pl_a to pl_b exists - * + * * 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 */ const populateSingleLink = async (req, res) => { - try { - let fromPl, toPl; - const link = { from: req.body.from, to: req.body.to }; - const uID = req.session.user.id; + try { + let fromPl, toPl; + const link = { from: req.body.from, to: req.body.to }; + const uID = req.session.user.id; - try { - fromPl = parseSpotifyLink(link.from); - toPl = parseSpotifyLink(link.to); - 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; - } - } catch (error) { - res.status(400).send({ message: "Could not parse link" }); - logger.warn("parseSpotifyLink", { error }); - return; - } + try { + fromPl = parseSpotifyLink(link.from); + toPl = parseSpotifyLink(link.to); + 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; + } + } catch (error) { + res.status(400).send({ message: "Could not parse link" }); + logger.warn("parseSpotifyLink", { error }); + return; + } - // check if exists - const existingLink = await Links.findOne({ - where: { - [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; - } + // check if exists + const existingLink = await Links.findOne({ + where: { + [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; + } - if (!await checkPlaylistEditable(req, res, fromPl.id, uID)) - return; + if (!await checkPlaylistEditable(req, res, fromPl.id, uID)) + return; - let initialFields = ["tracks(next,items(is_local,track(uri)))"]; - let mainFields = ["next", "items(is_local,track(uri))"]; + let initialFields = ["tracks(next,items(is_local,track(uri)))"]; + let mainFields = ["next", "items(is_local,track(uri))"]; - const fromData = await getPlaylistDetailsFirstPage(req, res, initialFields.join(), fromPl.id); - if (res.headersSent) return; + const fromData = await getPlaylistDetailsFirstPage(req, res, initialFields.join(), fromPl.id); + if (res.headersSent) return; - let fromPlaylist = {}; - // varying fields again smh - if (fromData.tracks.next) { - fromPlaylist.next = new URL(fromData.tracks.next); - fromPlaylist.next.searchParams.set("fields", mainFields.join()); - fromPlaylist.next = fromPlaylist.next.href; - } - fromPlaylist.tracks = fromData.tracks.items.map((playlist_item) => { - return { - is_local: playlist_item.is_local, - uri: playlist_item.track.uri - } - }); + let fromPlaylist = {}; + // varying fields again smh + if (fromData.tracks.next) { + fromPlaylist.next = new URL(fromData.tracks.next); + fromPlaylist.next.searchParams.set("fields", mainFields.join()); + fromPlaylist.next = fromPlaylist.next.href; + } + fromPlaylist.tracks = fromData.tracks.items.map((playlist_item) => { + return { + is_local: playlist_item.is_local, + uri: playlist_item.track.uri + } + }); - // keep getting batches of 50 till exhausted - while (fromPlaylist.next) { - const nextData = await getPlaylistDetailsNextPage(req, res, fromPlaylist.next); - if (res.headersSent) return; + // keep getting batches of 50 till exhausted + while (fromPlaylist.next) { + const nextData = await getPlaylistDetailsNextPage(req, res, fromPlaylist.next); + if (res.headersSent) return; - fromPlaylist.tracks.push( - ...nextData.items.map((playlist_item) => { - return { - is_local: playlist_item.is_local, - uri: playlist_item.track.uri - } - }) - ); + fromPlaylist.tracks.push( + ...nextData.items.map((playlist_item) => { + return { + is_local: playlist_item.is_local, + uri: playlist_item.track.uri + } + }) + ); - fromPlaylist.next = nextData.next; - } + fromPlaylist.next = nextData.next; + } - delete fromPlaylist.next; + delete fromPlaylist.next; - const toData = await getPlaylistDetailsFirstPage(req, res, initialFields.join(), toPl.id); - if (res.headersSent) return; + const toData = await getPlaylistDetailsFirstPage(req, res, initialFields.join(), toPl.id); + if (res.headersSent) return; - let toPlaylist = {}; - // varying fields again smh - if (toData.tracks.next) { - toPlaylist.next = new URL(toData.tracks.next); - toPlaylist.next.searchParams.set("fields", mainFields.join()); - toPlaylist.next = toPlaylist.next.href; - } - toPlaylist.tracks = toData.tracks.items.map((playlist_item) => { - return { - is_local: playlist_item.is_local, - uri: playlist_item.track.uri - } - }); + let toPlaylist = {}; + // varying fields again smh + if (toData.tracks.next) { + toPlaylist.next = new URL(toData.tracks.next); + toPlaylist.next.searchParams.set("fields", mainFields.join()); + toPlaylist.next = toPlaylist.next.href; + } + toPlaylist.tracks = toData.tracks.items.map((playlist_item) => { + return { + is_local: playlist_item.is_local, + uri: playlist_item.track.uri + } + }); - // keep getting batches of 50 till exhausted - while (toPlaylist.next) { - const nextData = await getPlaylistDetailsNextPage(req, res, toPlaylist.next); - if (res.headersSent) return; + // keep getting batches of 50 till exhausted + while (toPlaylist.next) { + const nextData = await getPlaylistDetailsNextPage(req, res, toPlaylist.next); + if (res.headersSent) return; - toPlaylist.tracks.push( - ...nextData.items.map((playlist_item) => { - return { - is_local: playlist_item.is_local, - uri: playlist_item.track.uri - } - }) - ); + toPlaylist.tracks.push( + ...nextData.items.map((playlist_item) => { + return { + is_local: playlist_item.is_local, + uri: playlist_item.track.uri + } + }) + ); - toPlaylist.next = nextData.next; - } + toPlaylist.next = nextData.next; + } - delete toPlaylist.next; + delete toPlaylist.next; - 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 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 toAddNum = toTrackURIs.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; - } + // 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; + } - let logMsg; - logMsg = toAddNum > 0 ? "Added " + toAddNum + " tracks" : "No tracks to add"; - logMsg += localNum > 0 ? ", but could not add " + localNum + " local files" : "."; + let logMsg; + logMsg = toAddNum > 0 ? "Added " + toAddNum + " tracks" : "No tracks to add"; + logMsg += localNum > 0 ? ", but could not add " + localNum + " local files" : "."; - res.status(200).send({ message: logMsg }); - logger.debug(logMsg); - return; - } catch (error) { - res.status(500).send({ message: "Internal Server Error" }); - logger.error("populateSingleLink", { error }); - return; - } + res.status(200).send({ message: logMsg }); + logger.debug(logMsg); + return; + } catch (error) { + res.status(500).send({ message: "Internal Server Error" }); + logger.error("populateSingleLink", { error }); + return; + } } /** * Remove tracks from the link-tail playlist, * that are present in the link-tail playlist but not in the link-head playlist. - * + * * eg. - * + * * pl_a has tracks: a, b, c - * + * * pl_b has tracks: e, b, d, c, f, g - * + * * link from pl_a to pl_b exists - * + * * after pruneSingleLink, pl_b will have tracks: b, c - * + * * @param {typedefs.Req} req * @param {typedefs.Res} res */ const pruneSingleLink = async (req, res) => { - try { - const uID = req.session.user.id; - const link = { from: req.body.from, to: req.body.to }; + try { + const uID = req.session.user.id; + const link = { from: req.body.from, to: req.body.to }; - let fromPl, toPl; - try { - fromPl = parseSpotifyLink(link.from); - toPl = parseSpotifyLink(link.to); - 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; - } - } catch (error) { - res.status(400).send({ message: error.message }); - logger.warn("parseSpotifyLink", { error }); - return; - } + let fromPl, toPl; + try { + fromPl = parseSpotifyLink(link.from); + toPl = parseSpotifyLink(link.to); + 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; + } + } catch (error) { + res.status(400).send({ message: error.message }); + logger.warn("parseSpotifyLink", { error }); + return; + } - // check if exists - const existingLink = await Links.findOne({ - where: { - [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; - } + // check if exists + const existingLink = await Links.findOne({ + where: { + [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; + } - if (!await checkPlaylistEditable(req, res, toPl.id, uID)) - return; + if (!await checkPlaylistEditable(req, res, toPl.id, uID)) + return; - let initialFields = ["snapshot_id", "tracks(next,items(is_local,track(uri)))"]; - let mainFields = ["next", "items(is_local,track(uri))"]; + let initialFields = ["snapshot_id", "tracks(next,items(is_local,track(uri)))"]; + let mainFields = ["next", "items(is_local,track(uri))"]; - const fromData = await getPlaylistDetailsFirstPage(req, res, initialFields.join(), fromPl.id); - if (res.headersSent) return; + const fromData = await getPlaylistDetailsFirstPage(req, res, initialFields.join(), fromPl.id); + if (res.headersSent) return; - let fromPlaylist = {}; - // varying fields again smh - fromPlaylist.snapshot_id = fromData.snapshot_id; - if (fromData.tracks.next) { - fromPlaylist.next = new URL(fromData.tracks.next); - fromPlaylist.next.searchParams.set("fields", mainFields.join()); - fromPlaylist.next = fromPlaylist.next.href; - } - fromPlaylist.tracks = fromData.tracks.items.map((playlist_item) => { - return { - is_local: playlist_item.is_local, - uri: playlist_item.track.uri - } - }); + let fromPlaylist = {}; + // varying fields again smh + fromPlaylist.snapshot_id = fromData.snapshot_id; + if (fromData.tracks.next) { + fromPlaylist.next = new URL(fromData.tracks.next); + fromPlaylist.next.searchParams.set("fields", mainFields.join()); + fromPlaylist.next = fromPlaylist.next.href; + } + fromPlaylist.tracks = fromData.tracks.items.map((playlist_item) => { + return { + is_local: playlist_item.is_local, + uri: playlist_item.track.uri + } + }); - // keep getting batches of 50 till exhausted - while (fromPlaylist.next) { - const nextData = await getPlaylistDetailsNextPage(req, res, fromPlaylist.next); - if (res.headersSent) return; + // keep getting batches of 50 till exhausted + while (fromPlaylist.next) { + const nextData = await getPlaylistDetailsNextPage(req, res, fromPlaylist.next); + if (res.headersSent) return; - fromPlaylist.tracks.push( - ...nextData.items.map((playlist_item) => { - return { - is_local: playlist_item.is_local, - uri: playlist_item.track.uri - } - }) - ); + fromPlaylist.tracks.push( + ...nextData.items.map((playlist_item) => { + return { + is_local: playlist_item.is_local, + uri: playlist_item.track.uri + } + }) + ); - fromPlaylist.next = nextData.next; - } + fromPlaylist.next = nextData.next; + } - delete fromPlaylist.next; + delete fromPlaylist.next; - const toData = await getPlaylistDetailsFirstPage(req, res, initialFields.join(), toPl.id); - if (res.headersSent) return; + const toData = await getPlaylistDetailsFirstPage(req, res, initialFields.join(), toPl.id); + if (res.headersSent) return; - let toPlaylist = {}; - // varying fields again smh - toPlaylist.snapshot_id = toData.snapshot_id; - if (toData.tracks.next) { - toPlaylist.next = new URL(toData.tracks.next); - toPlaylist.next.searchParams.set("fields", mainFields.join()); - toPlaylist.next = toPlaylist.next.href; - } - toPlaylist.tracks = toData.tracks.items.map((playlist_item) => { - return { - is_local: playlist_item.is_local, - uri: playlist_item.track.uri - } - }); + let toPlaylist = {}; + // varying fields again smh + toPlaylist.snapshot_id = toData.snapshot_id; + if (toData.tracks.next) { + toPlaylist.next = new URL(toData.tracks.next); + toPlaylist.next.searchParams.set("fields", mainFields.join()); + toPlaylist.next = toPlaylist.next.href; + } + toPlaylist.tracks = toData.tracks.items.map((playlist_item) => { + return { + is_local: playlist_item.is_local, + uri: playlist_item.track.uri + } + }); - // keep getting batches of 50 till exhausted - while (toPlaylist.next) { - const nextData = await getPlaylistDetailsNextPage(req, res, toPlaylist.next); - if (res.headersSent) return; + // keep getting batches of 50 till exhausted + while (toPlaylist.next) { + const nextData = await getPlaylistDetailsNextPage(req, res, toPlaylist.next); + if (res.headersSent) return; - toPlaylist.tracks.push( - ...nextData.items.map((playlist_item) => { - return { - is_local: playlist_item.is_local, - uri: playlist_item.track.uri - } - }) - ); + toPlaylist.tracks.push( + ...nextData.items.map((playlist_item) => { + return { + is_local: playlist_item.is_local, + uri: playlist_item.track.uri + } + }) + ); - toPlaylist.next = nextData.next; - } + toPlaylist.next = nextData.next; + } - delete toPlaylist.next; + delete toPlaylist.next; - const fromTrackURIs = fromPlaylist.tracks.map(track => track.uri); - let indexedToTrackURIs = toPlaylist.tracks; + const fromTrackURIs = fromPlaylist.tracks.map(track => track.uri); + let indexedToTrackURIs = toPlaylist.tracks; - indexedToTrackURIs.forEach((track, index) => { - track.position = index; - }); + indexedToTrackURIs.forEach((track, index) => { + track.position = index; + }); - 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 + 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 - const toDelNum = indexes.length; + const toDelNum = indexes.length; - // remove in batches of 100 (from reverse, to preserve positions while modifying) - let currentSnapshot = toPlaylist.snapshot_id; - while (indexes.length) { - 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; - currentSnapshot = delResponse.snapshot_id; - } + // remove in batches of 100 (from reverse, to preserve positions while modifying) + let currentSnapshot = toPlaylist.snapshot_id; + while (indexes.length) { + 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; + currentSnapshot = delResponse.snapshot_id; + } - res.status(200).send({ message: `Removed ${toDelNum} tracks.` }); - logger.debug(`Pruned ${toDelNum} tracks`); - return; - } catch (error) { - res.status(500).send({ message: "Internal Server Error" }); - logger.error("pruneSingleLink", { error }); - return; - } + res.status(200).send({ message: `Removed ${toDelNum} tracks.` }); + logger.debug(`Pruned ${toDelNum} tracks`); + return; + } catch (error) { + res.status(500).send({ message: "Internal Server Error" }); + logger.error("pruneSingleLink", { error }); + return; + } } module.exports = { - updateUser, - fetchUser, - createLink, - removeLink, - populateSingleLink, - pruneSingleLink, + updateUser, + fetchUser, + createLink, + removeLink, + populateSingleLink, + pruneSingleLink, }; diff --git a/controllers/playlists.js b/controllers/playlists.js index 96a5606..652176c 100644 --- a/controllers/playlists.js +++ b/controllers/playlists.js @@ -10,150 +10,150 @@ const { parseSpotifyLink } = require("../utils/spotifyURITransformer"); * @param {typedefs.Res} res */ const fetchUserPlaylists = async (req, res) => { - try { - let userPlaylists = {}; + try { + let userPlaylists = {}; - // get first 50 - const respData = await getUserPlaylistsFirstPage(req, res); - if (res.headersSent) return; + // get first 50 + const respData = await getUserPlaylistsFirstPage(req, res); + if (res.headersSent) return; - userPlaylists.total = respData.total; + 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.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.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.items.push( + ...nextData.items.map((playlist) => { + return { + uri: playlist.uri, + images: playlist.images, + name: playlist.name, + total: playlist.tracks.total + } + }) + ); - userPlaylists.next = nextData.next; - } + userPlaylists.next = nextData.next; + } - delete userPlaylists.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; - } + 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 + * @param {typedefs.Req} req + * @param {typedefs.Res} res */ 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 { + 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; - } + 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; + 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; + // 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 - } - } - }); + // 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; + // 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.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; - } + playlist.next = nextData.next; + } - delete playlist.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; - } + 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; + } } module.exports = { - fetchUserPlaylists, - fetchPlaylistDetails + fetchUserPlaylists, + fetchPlaylistDetails }; diff --git a/index.js b/index.js index 158a189..2b4e77e 100644 --- a/index.js +++ b/index.js @@ -24,32 +24,32 @@ app.set("trust proxy", process.env.TRUST_PROXY); // Configure SQLite store file const sqliteStore = new SQLiteStore({ - table: "session_store", - db: "spotify-manager.db" + table: "session_store", + db: "spotify-manager.db" }); // Configure session middleware app.use(session({ - name: sessionName, - store: sqliteStore, - 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 - } + name: sessionName, + store: sqliteStore, + 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 + origin: process.env.APP_URI, + credentials: true })); app.use(helmet({ - crossOriginOpenerPolicy: { policy: process.env.NODE_ENV === "development" ? "unsafe-none" : "same-origin" } + crossOriginOpenerPolicy: { policy: process.env.NODE_ENV === "development" ? "unsafe-none" : "same-origin" } })); app.disable("x-powered-by"); @@ -62,20 +62,20 @@ app.use(express.static(__dirname + "/static")); // Healthcheck app.use("/health", (req, res) => { - res.status(200).send({ message: "OK" }); - return; + 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; - } + 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; + } }); // Routes @@ -85,32 +85,32 @@ app.use("/api/operations", isAuthenticated, require("./routes/operations")); // Fallbacks app.use((req, res) => { - res.status(404).send( - "Guess the cat's out of the bag!" - ); - logger.info("404", { url: req.url }); - return; + res.status(404).send( + "Guess the cat's 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}`); + logger.info(`App Listening on port ${port}`); }); const cleanupFunc = (signal) => { - if (signal) - logger.debug(`${signal} signal received, shutting down now...`); + if (signal) + logger.debug(`${signal} signal received, shutting down now...`); - Promise.allSettled([ - db.sequelize.close(), - util.promisify(server.close), - ]).then(() => { - logger.info("Cleaned up, exiting."); - process.exit(0); - }); + Promise.allSettled([ + db.sequelize.close(), + util.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)); + process.on(signal, () => cleanupFunc(signal)); }); diff --git a/middleware/authCheck.js b/middleware/authCheck.js index 464d1c8..5f8941c 100644 --- a/middleware/authCheck.js +++ b/middleware/authCheck.js @@ -4,33 +4,33 @@ const logger = require("../utils/logger")(module); /** * middleware to check if access token is present - * @param {typedefs.Req} req - * @param {typedefs.Res} res - * @param {typedefs.Next} next + * @param {typedefs.Req} req + * @param {typedefs.Res} res + * @param {typedefs.Next} next */ 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((err) => { - if (err) { - res.status(500).send({ message: "Internal Server Error" }); - logger.error("session.destroy", { err }); - return; - } else { - res.clearCookie(sessionName); - res.status(401).send({ message: "Unauthorized" }); - logger.debug("Session invalid, destroyed.", { sessionID: delSession.id }); - return; - } - }); - } + 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((err) => { + if (err) { + res.status(500).send({ message: "Internal Server Error" }); + logger.error("session.destroy", { err }); + return; + } else { + res.clearCookie(sessionName); + res.status(401).send({ message: "Unauthorized" }); + logger.debug("Session invalid, destroyed.", { sessionID: delSession.id }); + return; + } + }); + } } module.exports = { - isAuthenticated, + isAuthenticated, } diff --git a/migrations/20240727162141-create-playlists.js b/migrations/20240727162141-create-playlists.js index 112b616..372f380 100644 --- a/migrations/20240727162141-create-playlists.js +++ b/migrations/20240727162141-create-playlists.js @@ -31,4 +31,4 @@ module.exports = { async down(queryInterface, Sequelize) { await queryInterface.dropTable("playlists"); } -}; \ No newline at end of file +}; diff --git a/migrations/20240730101615-create-links.js b/migrations/20240730101615-create-links.js index 497729a..3c6823d 100644 --- a/migrations/20240730101615-create-links.js +++ b/migrations/20240730101615-create-links.js @@ -31,4 +31,4 @@ module.exports = { async down(queryInterface, Sequelize) { await queryInterface.dropTable("links"); } -}; \ No newline at end of file +}; diff --git a/models/index.js b/models/index.js index 8f0da0d..cf8b61f 100644 --- a/models/index.js +++ b/models/index.js @@ -10,37 +10,37 @@ const db = {}; let sequelize; if (config.use_env_variable) { - sequelize = new Sequelize(process.env[config.use_env_variable], config); + sequelize = new Sequelize(process.env[config.use_env_variable], config); } else { - sequelize = new Sequelize(config.database, config.username, config.password, config); + 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; - } + try { + await sequelize.authenticate(); + logger.debug("Sequelize auth success"); + } catch (error) { + logger.error("Sequelize auth error", { error }); + throw error; + } })(); // Read model definitions from folder fs - .readdirSync(__dirname) - .filter(file => { - return (file.indexOf(".") !== 0) && (file !== basename) && (file.slice(-3) === ".js"); - }) - .forEach(file => { - const model = require(path.join(__dirname, file))(sequelize, Sequelize.DataTypes); - db[model.name] = model; - }); + .readdirSync(__dirname) + .filter(file => { + return (file.indexOf(".") !== 0) && (file !== basename) && (file.slice(-3) === ".js"); + }) + .forEach(file => { + const model = require(path.join(__dirname, file))(sequelize, Sequelize.DataTypes); + db[model.name] = model; + }); // Setup defined associations Object.keys(db).forEach(modelName => { - if (db[modelName].associate) { - db[modelName].associate(db); - } + if (db[modelName].associate) { + db[modelName].associate(db); + } }); db.sequelize = sequelize; diff --git a/routes/auth.js b/routes/auth.js index 90286bd..f3954ed 100644 --- a/routes/auth.js +++ b/routes/auth.js @@ -5,24 +5,24 @@ const { isAuthenticated } = require("../middleware/authCheck"); const validator = require("../validators"); router.get( - "/login", - login + "/login", + login ); router.get( - "/callback", - callback + "/callback", + callback ); router.get( - "/refresh", - isAuthenticated, - refresh + "/refresh", + isAuthenticated, + refresh ); router.get( - "/logout", - logout + "/logout", + logout ); module.exports = router; diff --git a/routes/operations.js b/routes/operations.js index ff70a49..3a0efab 100644 --- a/routes/operations.js +++ b/routes/operations.js @@ -5,41 +5,41 @@ const { validate } = require("../validators"); const { createLinkValidator, removeLinkValidator, populateSingleLinkValidator, pruneSingleLinkValidator } = require("../validators/operations"); router.put( - "/update", - updateUser + "/update", + updateUser ); router.get( - "/fetch", - fetchUser + "/fetch", + fetchUser ); router.post( - "/link", - createLinkValidator, - validate, - createLink + "/link", + createLinkValidator, + validate, + createLink ); router.delete( - "/link", - removeLinkValidator, - validate, - removeLink + "/link", + removeLinkValidator, + validate, + removeLink ); router.put( - "/populate/link", - populateSingleLinkValidator, - validate, - populateSingleLink + "/populate/link", + populateSingleLinkValidator, + validate, + populateSingleLink ); router.put( - "/prune/link", - pruneSingleLinkValidator, - validate, - pruneSingleLink + "/prune/link", + pruneSingleLinkValidator, + validate, + pruneSingleLink ); module.exports = router; diff --git a/routes/playlists.js b/routes/playlists.js index 7ce094e..e386dea 100644 --- a/routes/playlists.js +++ b/routes/playlists.js @@ -5,15 +5,15 @@ const { getPlaylistDetailsValidator } = require("../validators/playlists"); const { validate } = require("../validators"); router.get( - "/me", - fetchUserPlaylists + "/me", + fetchUserPlaylists ); router.get( - "/details", - getPlaylistDetailsValidator, - validate, - fetchPlaylistDetails + "/details", + getPlaylistDetailsValidator, + validate, + fetchPlaylistDetails ); module.exports = router; diff --git a/utils/flake.js b/utils/flake.js index 1ff7c8e..e229209 100644 --- a/utils/flake.js +++ b/utils/flake.js @@ -3,5 +3,5 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const randomBool = (chance_of_failure = 0.25) => Math.random() < chance_of_failure; module.exports = { - sleep, randomBool -}; \ No newline at end of file + sleep, randomBool +}; diff --git a/utils/generateRandString.js b/utils/generateRandString.js index eb31d2c..119aa17 100644 --- a/utils/generateRandString.js +++ b/utils/generateRandString.js @@ -4,11 +4,11 @@ * @return {string} The generated string */ module.exports = (length) => { - const possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; - let text = ""; + const possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + let text = ""; - for (let i = 0; i < length; i++) { - text += possible.charAt(Math.floor(Math.random() * possible.length)); - } - return text; + for (let i = 0; i < length; i++) { + text += possible.charAt(Math.floor(Math.random() * possible.length)); + } + return text; }; diff --git a/utils/graph.js b/utils/graph.js index d4df430..84feb14 100644 --- a/utils/graph.js +++ b/utils/graph.js @@ -4,9 +4,9 @@ const typedefs = require("../typedefs"); /** * Directed graph, may or may not be connected. - * + * * NOTE: Assumes that nodes and edges are valid. - * + * * Example: * ```javascript * let nodes = ["a", "b", "c", "d", "e"]; @@ -22,80 +22,80 @@ const typedefs = require("../typedefs"); * ``` */ 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 {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 {string} node - * @returns {string[]} - */ - getDirectHeads(node) { - return this.edges.filter(edge => edge.to == node).map(edge => edge.from); - } + /** + * @param {string} node + * @returns {string[]} + */ + getDirectHeads(node) { + return this.edges.filter(edge => edge.to == node).map(edge => edge.from); + } - /** - * @param {string} node - * @returns {string[]} - */ - getDirectTails(node) { - return this.edges.filter(edge => edge.from == node).map(edge => edge.to); - } + /** + * @param {string} node + * @returns {string[]} + */ + getDirectTails(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 = []; + /** + * 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; - } + // 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]++; - } + // 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); - } - } + // 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); + // process nodes with 0 inDegree + while (zeroInDegreeQueue.length > 0) { + let node = zeroInDegreeQueue.shift(); + topologicalOrder.push(node); - for (let tail of this.getDirectTails(node)) { - inDegree[tail]--; - if (inDegree[tail] === 0) { - zeroInDegreeQueue.push(tail); - } - } - } - return topologicalOrder; - } + for (let tail of this.getDirectTails(node)) { + inDegree[tail]--; + if (inDegree[tail] === 0) { + zeroInDegreeQueue.push(tail); + } + } + } + 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; - } + /** + * 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; diff --git a/utils/jsonTransformer.js b/utils/jsonTransformer.js index bf070b5..33444c9 100644 --- a/utils/jsonTransformer.js +++ b/utils/jsonTransformer.js @@ -1,23 +1,23 @@ /** * Stringifies only values of a JSON object, including nested ones - * + * * @param {any} obj JSON object * @param {string} delimiter Delimiter of final string * @returns {string} */ 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])); - } - } + 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); + return values.join(delimiter); } module.exports = { - getNestedValuesString + getNestedValuesString } diff --git a/utils/logger.js b/utils/logger.js index c2a52b6..96f4412 100644 --- a/utils/logger.js +++ b/utils/logger.js @@ -6,31 +6,31 @@ const { combine, label, timestamp, printf, errors } = format; const typedefs = require("../typedefs"); const getLabel = (callingModule) => { - if (!callingModule.filename) return "repl"; - const parts = callingModule.filename?.split(path.sep); - return path.join(parts[parts.length - 2], parts.pop()); + 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 ""; + 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 ?? ""}`; + if (meta.error) { // if the error was passed + for (const key in meta.error) { + if (!allowedErrorKeys.includes(key)) { + delete meta.error[key]; + } } - return `${timestamp} [${label}] ${level}: ${message}${metaFormat(meta)}`; + const { stack, ...rest } = meta.error; + return `${timestamp} [${label}] ${level}: ${message}${metaFormat(rest)}\n` + + `${stack ?? ""}`; + } + return `${timestamp} [${label}] ${level}: ${message}${metaFormat(meta)}`; }); /** @@ -38,30 +38,30 @@ const logFormat = printf(({ level, message, label, timestamp, ...meta }) => { * @param {typedefs.Module} callingModule The module from which the logger is called */ 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: __dirname + "/../logs/debug.log", - level: "debug", - maxsize: 10485760, - }), - new transports.File({ - filename: __dirname + "/../logs/error.log", - level: "error", - maxsize: 1048576, - }), - ] - }); - winstonLogger.on("error", (error) => winstonLogger.error("Error inside logger", { error })); - return winstonLogger; + 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: __dirname + "/../logs/debug.log", + level: "debug", + maxsize: 10485760, + }), + new transports.File({ + filename: __dirname + "/../logs/error.log", + level: "error", + maxsize: 1048576, + }), + ] + }); + winstonLogger.on("error", (error) => winstonLogger.error("Error inside logger", { error })); + return winstonLogger; } module.exports = curriedLogger; diff --git a/utils/spotifyUriTransformer.js b/utils/spotifyUriTransformer.js index 9f65491..9611607 100644 --- a/utils/spotifyUriTransformer.js +++ b/utils/spotifyUriTransformer.js @@ -11,46 +11,46 @@ const base62Pattern = /^[A-Za-z0-9]+$/; * @throws {TypeError} If the input is not a valid Spotify URI */ const parseSpotifyURI = (uri) => { - const parts = uri.split(":"); + const parts = uri.split(":"); - if (parts[0] !== "spotify") { - throw new TypeError(`${uri} is not a valid Spotify URI`); - } + if (parts[0] !== "spotify") { + throw new TypeError(`${uri} is not a valid Spotify URI`); + } - let type = parts[1]; + let type = parts[1]; - if (type === "local") { - // Local file format: spotify:local::::<duration> - let idParts = parts.slice(2); - if (idParts.length < 4) { - throw new TypeError(`${uri} is not a valid local file URI`); - } + if (type === "local") { + // Local file format: spotify:local:<artist>:<album>:<title>:<duration> + let idParts = parts.slice(2); + 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); + // 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); - if (isNaN(duration)) { - throw new TypeError(`${uri} has an invalid duration`); - } + if (isNaN(duration)) { + throw new TypeError(`${uri} has an invalid duration`); + } - return { type: "track", is_local: true, artist, album, title, duration }; - } else { - // Not a local file - if (parts.length !== 3) { - throw new TypeError(`${uri} is not a valid Spotify URI`); - } + return { type: "track", is_local: true, artist, album, title, duration }; + } 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`); - } + if (!base62Pattern.test(id)) { + throw new TypeError(`${uri} has an invalid ID`); + } - return { type, is_local: false, id }; - } + return { type, is_local: false, id }; + } } /** @@ -60,45 +60,45 @@ const parseSpotifyURI = (uri) => { * @throws {TypeError} If the input is not a valid Spotify link */ const parseSpotifyLink = (link) => { - const localPattern = /^https:\/\/open\.spotify\.com\/local\/([^\/]*)\/([^\/]*)\/([^\/]+)\/(\d+)$/; - const standardPattern = /^https:\/\/open\.spotify\.com\/([^\/]+)\/([^\/?]+)/; + const localPattern = /^https:\/\/open\.spotify\.com\/local\/([^\/]*)\/([^\/]*)\/([^\/]+)\/(\d+)$/; + const standardPattern = /^https:\/\/open\.spotify\.com\/([^\/]+)\/([^\/?]+)/; - if (localPattern.test(link)) { - // Local file format: https://open.spotify.com/local/artist/album/title/duration - const matches = link.match(localPattern); - if (!matches) { - throw new TypeError(`${link} is not a valid Spotify local file link`); - } + if (localPattern.test(link)) { + // Local file format: https://open.spotify.com/local/artist/album/title/duration + const matches = link.match(localPattern); + if (!matches) { + throw new TypeError(`${link} is not a valid Spotify local file 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); + // 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); - if (isNaN(duration)) { - throw new TypeError(`${link} has an invalid duration`); - } + if (isNaN(duration)) { + throw new TypeError(`${link} has an invalid duration`); + } - return { type: "track", is_local: true, artist, album, title, duration }; - } else if (standardPattern.test(link)) { - // Not a local file - const matches = link.match(standardPattern); - if (!matches || matches.length < 3) { - throw new TypeError(`${link} is not a valid Spotify link`); - } + return { type: "track", is_local: true, artist, album, title, duration }; + } else if (standardPattern.test(link)) { + // Not a local file + const matches = link.match(standardPattern); + if (!matches || matches.length < 3) { + 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`); - } + if (!base62Pattern.test(id)) { + throw new TypeError(`${link} has an invalid ID`); + } - return { type, is_local: false, id }; - } else { - throw new TypeError(`${link} is not a valid Spotify link`); - } + return { type, is_local: false, id }; + } else { + throw new TypeError(`${link} is not a valid Spotify link`); + } } /** @@ -107,14 +107,14 @@ const parseSpotifyLink = (link) => { * @returns {string} */ const buildSpotifyURI = (uriObj) => { - if (uriObj.is_local) { - const artist = encodeURIComponent(uriObj.artist ?? ""); - const album = encodeURIComponent(uriObj.album ?? ""); - const title = encodeURIComponent(uriObj.title ?? ""); - const duration = uriObj.duration ? uriObj.duration.toString() : ""; - return `spotify:local:${artist}:${album}:${title}:${duration}`; - } - return `spotify:${uriObj.type}:${uriObj.id}`; + if (uriObj.is_local) { + const artist = encodeURIComponent(uriObj.artist ?? ""); + const album = encodeURIComponent(uriObj.album ?? ""); + const title = encodeURIComponent(uriObj.title ?? ""); + const duration = uriObj.duration ? uriObj.duration.toString() : ""; + return `spotify:local:${artist}:${album}:${title}:${duration}`; + } + return `spotify:${uriObj.type}:${uriObj.id}`; } /** @@ -123,19 +123,19 @@ const buildSpotifyURI = (uriObj) => { * @returns {string} */ const buildSpotifyLink = (uriObj) => { - if (uriObj.is_local) { - const artist = encodeURIComponent(uriObj.artist ?? ""); - const album = encodeURIComponent(uriObj.album ?? ""); - const title = encodeURIComponent(uriObj.title ?? ""); - 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}` + if (uriObj.is_local) { + const artist = encodeURIComponent(uriObj.artist ?? ""); + const album = encodeURIComponent(uriObj.album ?? ""); + const title = encodeURIComponent(uriObj.title ?? ""); + 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}` } module.exports = { - parseSpotifyURI, - parseSpotifyLink, - buildSpotifyURI, - buildSpotifyLink + parseSpotifyURI, + parseSpotifyLink, + buildSpotifyURI, + buildSpotifyLink } diff --git a/validators/index.js b/validators/index.js index 91cfca0..c0ff66e 100644 --- a/validators/index.js +++ b/validators/index.js @@ -7,40 +7,40 @@ const typedefs = require("../typedefs"); /** * Refer: https://stackoverflow.com/questions/58848625/access-messages-in-express-validator - * - * @param {typedefs.Req} req - * @param {typedefs.Res} res - * @param {typedefs.Next} next + * + * @param {typedefs.Req} req + * @param {typedefs.Res} res + * @param {typedefs.Next} next */ const validate = (req, res, next) => { - const errors = validationResult(req); - if (errors.isEmpty()) { - return 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 - }); - } - }); + 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; + res.status(400).json({ + message: getNestedValuesString(extractedErrors), + errors: extractedErrors + }); + logger.warn("invalid request", { extractedErrors }); + return; } module.exports = { - validate + validate }; diff --git a/validators/operations.js b/validators/operations.js index ed503de..82e5111 100644 --- a/validators/operations.js +++ b/validators/operations.js @@ -3,29 +3,29 @@ const { body, header, param, query } = require("express-validator"); const typedefs = require("../typedefs"); /** - * @param {typedefs.Req} req - * @param {typedefs.Res} res - * @param {typedefs.Next} next + * @param {typedefs.Req} req + * @param {typedefs.Res} res + * @param {typedefs.Next} next */ 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(); + 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(); } module.exports = { - createLinkValidator, - removeLinkValidator: createLinkValidator, - populateSingleLinkValidator: createLinkValidator, - pruneSingleLinkValidator: createLinkValidator, + createLinkValidator, + removeLinkValidator: createLinkValidator, + populateSingleLinkValidator: createLinkValidator, + pruneSingleLinkValidator: createLinkValidator, } diff --git a/validators/playlists.js b/validators/playlists.js index 13b310e..c9cbad4 100644 --- a/validators/playlists.js +++ b/validators/playlists.js @@ -3,20 +3,20 @@ const { body, header, param, query } = require("express-validator"); const typedefs = require("../typedefs"); /** - * @param {typedefs.Req} req - * @param {typedefs.Res} res - * @param {typedefs.Next} next + * @param {typedefs.Req} req + * @param {typedefs.Res} res + * @param {typedefs.Next} next */ 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(); + await query("playlist_link") + .notEmpty() + .withMessage("playlist_link not defined in query") + .isURL() + .withMessage("playlist_link must be a valid link") + .run(req); + next(); } module.exports = { - getPlaylistDetailsValidator + getPlaylistDetailsValidator }