From 7074009fabbf5a5c0e2209793123b2efc2eeb020 Mon Sep 17 00:00:00 2001 From: Kaushik Narayan R Date: Sun, 28 Jul 2024 01:20:10 +0530 Subject: [PATCH] opn: update user's playlists --- controllers/operations.js | 113 ++++++++++++++++++ controllers/playlists.js | 8 +- index.js | 3 + .../20240727162141-create-user-playlists.js | 31 +++++ models/index.js | 55 +++++++++ models/userplaylists.js | 24 ++++ routes/operations.js | 12 ++ utils/logger.js | 12 +- 8 files changed, 253 insertions(+), 5 deletions(-) create mode 100644 controllers/operations.js create mode 100644 migrations/20240727162141-create-user-playlists.js create mode 100644 models/index.js create mode 100644 models/userplaylists.js create mode 100644 routes/operations.js diff --git a/controllers/operations.js b/controllers/operations.js new file mode 100644 index 0000000..dd8dbe8 --- /dev/null +++ b/controllers/operations.js @@ -0,0 +1,113 @@ +const logger = require("../utils/logger")(module); +const { axiosInstance } = require("../utils/axios"); +const { parseSpotifyUri } = require("../utils/spotifyUriTransformer"); + +const typedefs = require("../typedefs"); +/** @type {typedefs.Model} */ +const userPlaylists = require("../models").userPlaylists; + +/** + * Store user's playlists + * @param {typedefs.Req} req + * @param {typedefs.Res} res + */ +const updateUser = async (req, res) => { + try { + let currentPlaylists = []; + const userURI = parseSpotifyUri(req.session.user.uri); + + // get first 50 + const response = await axiosInstance.get( + `/users/${userURI.id}/playlists`, + { + params: { + offset: 0, + limit: 50, + }, + headers: { + ...req.authHeader + } + } + ); + + if (response.status >= 400 && response.status < 500) + return res.status(response.status).send(response.data); + + currentPlaylists = response.data.items.map(playlist => parseSpotifyUri(playlist.uri).id); + nextURL = response.data.next; + + // keep getting batches of 50 till exhausted + while (nextURL) { + const nextResponse = await axiosInstance.get( + nextURL, // absolute URL from previous response which has params + { + headers: { + ...req.authHeader + } + } + ); + if (response.status >= 400 && response.status < 500) + return res.status(response.status).send(response.data); + + currentPlaylists.push( + ...nextResponse.data.items.map(playlist => parseSpotifyUri(playlist.uri).id) + ); + + nextURL = nextResponse.data.next; + } + + + let oldPlaylists = await userPlaylists.findAll({ + attributes: ["playlistID"], + raw: true, + where: { + userID: userURI.id + }, + }); + + let toRemove, toAdd; + if (oldPlaylists.length) { + // existing user + oldPlaylists = oldPlaylists.map(pl => pl.playlistID); + const currentSet = new Set(currentPlaylists); + const oldSet = new Set(oldPlaylists); + + toAdd = currentPlaylists.filter(current => !oldSet.has(current)); + toRemove = oldPlaylists.filter(old => !currentSet.has(old)); + } else { + // new user + toAdd = currentPlaylists; + toRemove = []; + } + + if (toRemove.length) { + const cleanedUser = await userPlaylists.destroy({ + where: { playlistID: toRemove } + }); + if (cleanedUser !== toRemove.length) { + logger.error("Could not remove old playlists", { error: new Error("model.destroy failed?") }); + return res.sendStatus(500); + } + } + + if (toAdd.length) { + const updatedUser = await userPlaylists.bulkCreate( + toAdd.map((pl) => { return { playlistID: pl, userID: userURI.id } }), + { validate: true } + ); + if (updatedUser.length !== toAdd.length) { + logger.error("Could not add new playlists", { error: new Error("model.bulkCreate failed?") }); + return res.sendStatus(500); + } + } + + return res.sendStatus(200); + } catch (error) { + logger.error('updateUser', { error }); + return res.sendStatus(500); + } +} + +module.exports = { + updateUser +}; diff --git a/controllers/playlists.js b/controllers/playlists.js index 34b3ec6..49c4925 100644 --- a/controllers/playlists.js +++ b/controllers/playlists.js @@ -130,9 +130,11 @@ const getPlaylistDetails = async (req, res) => { // 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... - playlist.next = new URL(response.data.tracks.next); - playlist.next.searchParams.set("fields", mainFields.join()); - playlist.next = playlist.next.href; + if (response.data.tracks.next) { + playlist.next = new URL(response.data.tracks.next); + playlist.next.searchParams.set("fields", mainFields.join()); + playlist.next = playlist.next.href; + } playlist.tracks = response.data.tracks.items.map((playlist_item) => { return { is_local: playlist_item.is_local, diff --git a/index.js b/index.js index 4440c5f..a6e8644 100644 --- a/index.js +++ b/index.js @@ -9,6 +9,7 @@ const cookieParser = require('cookie-parser'); const helmet = require("helmet"); const SQLiteStore = require("connect-sqlite3")(session); +const db = require("./models"); const logger = require("./utils/logger")(module); @@ -51,6 +52,7 @@ app.use(express.static(__dirname + '/static')); // Routes app.use("/api/auth/", require("./routes/auth")); app.use("/api/playlists", require("./routes/playlists")); +app.use("/api/operations", require("./routes/operations")); // Fallbacks app.use((_req, res) => { @@ -67,6 +69,7 @@ const server = app.listen(port, () => { const cleanupFunc = (signal) => { Promise.allSettled([ + db.sequelize.close(), util.promisify(server.close), ]).then(() => { if (signal) diff --git a/migrations/20240727162141-create-user-playlists.js b/migrations/20240727162141-create-user-playlists.js new file mode 100644 index 0000000..8bda3b3 --- /dev/null +++ b/migrations/20240727162141-create-user-playlists.js @@ -0,0 +1,31 @@ +'use strict'; +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('userPlaylists', { + id: { + allowNull: false, + autoIncrement: true, + primaryKey: true, + type: Sequelize.INTEGER + }, + playlistID: { + type: Sequelize.STRING + }, + userID: { + type: Sequelize.STRING + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }); + }, + async down(queryInterface, Sequelize) { + await queryInterface.dropTable('userPlaylists'); + } +}; \ No newline at end of file diff --git a/models/index.js b/models/index.js new file mode 100644 index 0000000..2b9265e --- /dev/null +++ b/models/index.js @@ -0,0 +1,55 @@ +"use strict"; +const fs = require("fs"); +const path = require("path"); +const Sequelize = require("sequelize"); +const typedefs = require("../typedefs"); +const logger = require("../utils/logger")(module); +const basename = path.basename(__filename); +const env = process.env.NODE_ENV || "development"; +const config = require(__dirname + "/../config/sequelize.js")[env]; +const db = {}; + +/** @type {typedefs.Sequelize} */ +let sequelize; +if (config.use_env_variable) { + sequelize = new Sequelize(process.env[config.use_env_variable], config, { + logging: (msg) => logger.debug(msg) + }); +} else { + sequelize = new Sequelize(config.database, config.username, config.password, config, { + logging: (msg) => logger.debug(msg) + }); +} + +(async () => { + try { + await sequelize.authenticate(); + logger.info("Sequelize auth success"); + } catch (error) { + logger.error("Sequelize auth error", { err }); + 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; + }); + +// Setup defined associations +Object.keys(db).forEach(modelName => { + if (db[modelName].associate) { + db[modelName].associate(db); + } +}); + +db.sequelize = sequelize; +db.Sequelize = Sequelize; + +module.exports = db; \ No newline at end of file diff --git a/models/userplaylists.js b/models/userplaylists.js new file mode 100644 index 0000000..a46a6c9 --- /dev/null +++ b/models/userplaylists.js @@ -0,0 +1,24 @@ +'use strict'; +const { + Model +} = require('sequelize'); +module.exports = (sequelize, DataTypes) => { + class userPlaylists extends Model { + /** + * Helper method for defining associations. + * This method is not a part of Sequelize lifecycle. + * The `models/index` file will call this method automatically. + */ + static associate(models) { + // define association here + } + } + userPlaylists.init({ + playlistID: DataTypes.STRING, + userID: DataTypes.STRING + }, { + sequelize, + modelName: 'userPlaylists', + }); + return userPlaylists; +}; \ No newline at end of file diff --git a/routes/operations.js b/routes/operations.js new file mode 100644 index 0000000..0a7e926 --- /dev/null +++ b/routes/operations.js @@ -0,0 +1,12 @@ +const router = require('express').Router(); + +const { updateUser } = require('../controllers/operations'); +const { isAuthenticated } = require('../middleware/authCheck'); + +router.post( + "/update", + isAuthenticated, + updateUser +); + +module.exports = router; diff --git a/utils/logger.js b/utils/logger.js index c05d500..9f06351 100644 --- a/utils/logger.js +++ b/utils/logger.js @@ -56,8 +56,16 @@ const logger = (callingModule) => { ), transports: [ new transports.Console({ level: 'debug' }), - new transports.File({ filename: __dirname + '/../logs/debug.log', level: 'debug' }), - new transports.File({ filename: __dirname + '/../logs/error.log', level: 'error' }), + new transports.File({ + filename: __dirname + '/../logs/debug.log', + level: 'debug', + maxsize: 10485760, + }), + new transports.File({ + filename: __dirname + '/../logs/error.log', + level: 'error', + maxsize: 10485760, + }), ] }); }