diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..32a5917 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,2 @@ +**/node_modules +node_modules diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..27ef4ee --- /dev/null +++ b/.eslintrc @@ -0,0 +1,93 @@ +{ + "extends": [ + "airbnb-base", + "plugin:chai-friendly/recommended", + "plugin:mocha/recommended" + ], + "parserOptions": { + "ecmaVersion": 2018, + "sourceType": "script" + }, + "env": { + "es6": true, + "mocha": true, + "node": true, + "browser": false + }, + "rules": { + "import/extensions": 0, + "import/no-cycle": 0, + "import/no-unresolved": 0, + "import/no-extraneous-dependencies": 0, + "mocha/no-setup-in-describe": 0, + "mocha/no-exports": 0, + "mocha/no-skipped-tests": 0, + "comma-dangle": [ + "error", + { + "arrays": "always-multiline", + "objects": "always-multiline", + "imports": "always-multiline", + "exports": "always-multiline", + "functions": "ignore" + } + ], + "quotes": [ + 2, + "single", + { + "avoidEscape": true, + "allowTemplateLiterals": true + } + ], + "no-unneeded-ternary": 0, + "func-names": 0, + "prefer-rest-params": 0, + "prefer-arrow-callback": 0, + "arrow-body-style": 0, + "space-before-function-paren": [ + 2, + "never" + ], + "newline-per-chained-call": [ + "error", + { + "ignoreChainWithDepth": 2 + } + ], + "object-curly-spacing": [ + 2, + "never" + ], + "object-shorthand": 0, + "no-param-reassign": 0, + "no-underscore-dangle": 0, + "quote-props": [ + "error", + "consistent-as-needed" + ], + "no-console": 0, + "max-len": [ + 2, + { + "code": 120 + } + ], + "class-methods-use-this": 0, + "import/no-dynamic-require": 0, + "prefer-destructuring": 0, + "no-cond-assign": [ + "error", + "except-parens" + ], + "strict": [ + "error", + "global" + ], + "max-classes-per-file": [ + "error", + 5 + ], + "prefer-object-spread": 0 + } +} diff --git a/README.md b/README.md index b6ac9cc..a560ed4 100644 --- a/README.md +++ b/README.md @@ -39,18 +39,18 @@ Session maker for Spotify works with session definitions, which are `JSON` files "sources": [ <-- Here goes the source playlists you want to use { "name": "", - "spotifyID": "" + "spotifyIDs": [""] }, { "name": "", - "spotifyID": "" + "spotifyIDs": [""] }, ... ], "pattern": [ - "", - "", - "", + "", + "", + "", ] } ``` @@ -103,15 +103,15 @@ The session definition would look like this: "sources": [ { "name": "Salsa", - "spotifyID": "2OsWOxpOhmPUqISvPo8Cph" + "spotifyIDs": ["2OsWOxpOhmPUqISvPo8Cph"] }, { "name": "Bachata", - "spotifyID": "7LsJLGdokIxHxcmGMlXB3m" + "spotifyIDs": ["7LsJLGdokIxHxcmGMlXB3m"] }, { "name": "Kizomba", - "spotifyID": "2godpxQkfAuR9JRJ7tU5ws" + "spotifyIDs": ["2godpxQkfAuR9JRJ7tU5ws"] } ], "pattern": [ diff --git a/cmd/create-playlist.js b/cmd/create-playlist.js index 92e4a48..de0079a 100644 --- a/cmd/create-playlist.js +++ b/cmd/create-playlist.js @@ -4,8 +4,13 @@ const fs = require('fs'); const https = require('https'); const {createPlaylist, parseSession} = require('../lib'); -async function cmdCreatePlaylist({session, accessToken}) { - const definition = parseSession(fs.readFileSync(session), {encoding: 'utf-8'}); +/** + * Command for creating the playlist in Spotify + * @param {string} input.sessionPath Path to the session file + * @param {string} input.accessToken Spotify API token for performing operations + */ +async function cmdCreatePlaylist({session: sessionPath, accessToken}) { + const definition = parseSession(fs.readFileSync(sessionPath), {encoding: 'utf-8'}); console.info('Verifying access token'); let user; diff --git a/cmd/get-token.js b/cmd/get-token.js index 15b8084..d6b9a7c 100644 --- a/cmd/get-token.js +++ b/cmd/get-token.js @@ -3,7 +3,9 @@ const fs = require('fs'); const http = require('http'); const path = require('path'); - +/** + * Command for obtaining the API token in Spotify + */ async function cmdGetToken() { // We will create an embedded server for the callback const serverPort = process.env.TOKEN_SERVER_PORT ? process.env.TOKEN_SERVER_PORT : 30008; diff --git a/examples/sbk_session.json b/examples/sbk_session.json index 813acaa..80408fd 100644 --- a/examples/sbk_session.json +++ b/examples/sbk_session.json @@ -5,15 +5,21 @@ "sources": [ { "name": "Salsa", - "spotifyID": "2OsWOxpOhmPUqISvPo8Cph" + "spotifyIDs": [ + "2OsWOxpOhmPUqISvPo8Cph" + ] }, { "name": "Bachata", - "spotifyID": "7LsJLGdokIxHxcmGMlXB3m" + "spotifyIDs": [ + "7LsJLGdokIxHxcmGMlXB3m" + ] }, { "name": "Kizomba", - "spotifyID": "2godpxQkfAuR9JRJ7tU5ws" + "spotifyIDs": [ + "2godpxQkfAuR9JRJ7tU5ws" + ] } ], "pattern": [ @@ -24,4 +30,3 @@ "Kizomba" ] } - diff --git a/examples/swing_session.json b/examples/swing_session.json new file mode 100644 index 0000000..649ce22 --- /dev/null +++ b/examples/swing_session.json @@ -0,0 +1,56 @@ +{ + "title": "Swing session by BPMs and 10% Blues", + "description": "Built with spotify-session-maker", + "maxSongs": 200, + "sources": [ + { + "name": "100-120", + "spotifyIDs": [ + "7ivzWZParGOFfg6WLurw8E" + ] + }, + { + "name": "120-140", + "spotifyIDs": [ + "57TmdQaIAxm142Kec2xFrX" + ] + }, + { + "name": "140-160", + "spotifyIDs": [ + "0V1aG0b9ye5B4MPId1Znj7" + ] + }, + { + "name": "160-180", + "spotifyIDs": [ + "0Ky3d8TBOOe4VYd0AodaTL" + ] + }, + { + "name": "Over180", + "spotifyIDs": [ + "3IO3m6mfuDLvBrkpllHlJ6", + "1nDCfIMWHQDpChhitrI0sk" + ] + }, + { + "name": "Blues", + "spotifyIDs": [ + "7kwIt9o0BTtszsl3VVtHPT" + ] + } + ], + "pattern": [ + "100-120", + "120-140", + "140-160", + "160-180", + "Over180", + "100-120", + "120-140", + "140-160", + "160-180", + "Blues" + ] +} diff --git a/lib/index.js b/lib/index.js index d8a2a56..164a841 100644 --- a/lib/index.js +++ b/lib/index.js @@ -7,6 +7,10 @@ const Validator = require('jsonschema').Validator; const schema = JSON.parse(fs.readFileSync(path.join(__dirname, '../schema.json'), {encoding: 'utf-8'})); +/** + * Parse and validate the session string with the expected schema + * @param {string} sessionJson String containing the JSON specification + */ function parseSession(sessionJson) { const inputDefinition = JSON.parse(sessionJson); const validator = new Validator(); @@ -17,6 +21,20 @@ function parseSession(sessionJson) { return inputDefinition; } +/** + * Ensure that all the references in the pattern are declared in the "sources" section + * + * Example: Valid session + * Declared sources: [Salsa, Bachata, Kizomba] + * Pattern: [Salsa, Salsa, Kizomba, Kizomba, Bachata, Bachata] + * + * Example: Invalid session + * Declared sources: [Salsa, Bachata] + * Pattern: [Salsa, Bachata, Bachata, Kizomba, Salsa] + * + * @param {Object} session.sources Object with the source definition + * @param {string[]} pattern Session pattern using declared sources + */ function validateSession({sources, pattern}) { const sourceNames = sources.map(({name}) => name); const invalidDefinitions = pattern.filter((source) => !sourceNames.includes(source)); @@ -26,7 +44,10 @@ function validateSession({sources, pattern}) { } return true; } - +/** + * Shuffle the elements in the array using the Fisher-Yates algorithm + * @param {string[]} array Array with the Spotify IDs of a playlist + */ function shuffle(array) { let currentIndex = array.length; let randomIndex; @@ -44,64 +65,88 @@ function shuffle(array) { return array; } - -async function buildSourceSongLists(sources, accessToken) { - const sourceSongLists = {}; - for (let i = 0; i < sources.length; i += 1) { - const name = sources[i].name; - const spotifyID = sources[i].spotifyID; - console.info(`Getting songs from playlist ${name}`); - let stop = false; - let currentOffset = 0; - const nonShuffledArray = []; - // Gather all songs in the playlist - while (!stop) { - // eslint-disable-next-line no-await-in-loop, no-loop-func - const tracks = await new Promise((resolve, reject) => { - const params = new URLSearchParams({ - limit: 100, - offset: currentOffset, +/** + * Obtain the list of songs of a given playlist + * @param {string} spotifyID Spotify ID of the playlist + * @param {string} accessToken Spotify API token for performing operations + */ +async function getSongsFromPlaylist(spotifyID, accessToken) { + console.info(`Getting songs from playlist ${spotifyID}`); + let stop = false; + let currentOffset = 0; + const songs = []; + // Gather all songs in the playlist + while (!stop) { + // eslint-disable-next-line no-await-in-loop, no-loop-func + const tracks = await new Promise((resolve, reject) => { + const params = new URLSearchParams({ + limit: 100, + offset: currentOffset, + }); + const opts = { + hostname: 'api.spotify.com', + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + path: `/v1/playlists/${spotifyID}/tracks?${params.toString()}`, + }; + const req = https.request(opts, (res) => { + let rawData = ''; + res.on('data', (chunk) => { + rawData += Buffer.from(chunk).toString(); }); - const opts = { - hostname: 'api.spotify.com', - headers: { - 'Authorization': `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - path: `/v1/playlists/${spotifyID}/tracks?${params.toString()}`, - }; - const req = https.request(opts, (res) => { - let rawData = ''; - res.on('data', (chunk) => { - rawData += Buffer.from(chunk).toString(); - }); - res.on('end', () => { - if (res.statusCode === 200) { - const parsedData = JSON.parse(rawData); - // We need to remove local or the playlist creation will fail afterwards - resolve(parsedData.items.map(({track}) => track.uri).filter((track) => track.match('spotify:track'))); - } else { - reject(new Error(`Code: ${res.statusCode} - Msg: ${res.statusMessage}`)); - } - }); + res.on('end', () => { + if (res.statusCode === 200) { + const parsedData = JSON.parse(rawData); + // We need to remove local or the playlist creation will fail afterwards + resolve(parsedData.items.map(({track}) => track.uri).filter((track) => track.match('spotify:track'))); + } else { + reject(new Error(`Code: ${res.statusCode} - Msg: ${res.statusMessage}`)); + } }); - req.end(); }); - if (tracks.length > 0) { - nonShuffledArray.push(...tracks); - currentOffset += 100; - } else { - stop = true; - } + req.end(); + }); + if (tracks.length > 0) { + songs.push(...tracks); + currentOffset += 100; + } else { + stop = true; } - + } + // Remove duplicates + return [...new Set(songs)]; +} +/** + * Build the song lists of a given playlist group. It takes the songs from each playlist and + * performs a random shuffle + * @param {Object[]} sources List of source playlist groups + * @param {string} accessToken Spotify API token for performing operations + */ +async function buildSourceSongLists(sources, accessToken) { + const sourceSongLists = {}; + const playlistSongs = {}; + const keys = [...new Set(sources.map((source) => source.spotifyIDs).flat())]; + for (let i = 0; i < keys.length; i += 1) { + // eslint-disable-next-line no-await-in-loop, no-loop-func + playlistSongs[keys[i]] = await getSongsFromPlaylist(keys[i], accessToken); + } + sources.forEach(({name, spotifyIDs}) => { + const nonShuffledArray = [...new Set(spotifyIDs.map((id) => playlistSongs[id]).flat())]; // Once all songs are inside, shuffle the list sourceSongLists[name] = shuffle(nonShuffledArray); - } + }); return sourceSongLists; } - +/** + * Build the final session using the pattern and the different source playlist groups + * with a given maximum number of songs + * @param {string[]} session.pattern Pattern for adding songs to the session using declared source playlist groups + * @param {Number} session.maxSongs Maximum number of songs to add + * @param {Object} songLists List of songs of each source playlist group + */ function buildSessionSongList({pattern, maxSongs}, songLists) { const sessionSongList = []; const definitionLength = pattern.length; @@ -134,11 +179,17 @@ function buildSessionSongList({pattern, maxSongs}, songLists) { return sessionSongList; } -// Create session using the definition +/** + * Create the playlist in Spotify + * @param {Object} session Session definition + * @param {string} credentials.accessToken Spotify API token for performing operations + * @param {string} credentials.user Spotify user to associate the playlist to + */ async function createPlaylist(session, {accessToken, user}) { validateSession(session); const sourceSongLists = await buildSourceSongLists(session.sources, accessToken); const sessionSongList = buildSessionSongList(session, sourceSongLists); + // Create the playlist let spotifyID = ''; if (session.spotifyID) { spotifyID = session.spotifyID; @@ -176,6 +227,7 @@ async function createPlaylist(session, {accessToken, user}) { req.end(); }); } + // Add songs to the playlist let done = false; let offsetIndex = 0; console.debug(`Adding tracks to playlist ${spotifyID}`); diff --git a/package-lock.json b/package-lock.json index 7edc283..6f3e865 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,20 @@ { "name": "session-maker-for-spotify", - "version": "1.0.0", + "version": "1.0.2", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "session-maker-for-spotify", - "version": "1.0.0", + "version": "1.0.2", "license": "Apache-2.0", "dependencies": { "commander": "^9.4.1", "jsonschema": "^1.4.1" }, + "bin": { + "session-maker-for-spotify": "bin/session-maker-for-spotify" + }, "devDependencies": { "chai": "^4.3.6", "eslint": "^8.25.0", diff --git a/package.json b/package.json index d89422a..23309f9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "session-maker-for-spotify", - "version": "1.0.2", + "version": "2.0.0", "description": "Create Spotify sessions using multiple playlists", "main": "index.js", "scripts": { diff --git a/resources/server_body.html b/resources/server_body.html index 70bcb3c..68dffd7 100644 --- a/resources/server_body.html +++ b/resources/server_body.html @@ -6,6 +6,9 @@ } +

Session Maker for Spotify

+

Authentication finished successfully

+

Success! Your access token is ACCESS_TOKEN

Authorization correct. Copy the access token above, close this window and go back to the terminal

diff --git a/schema.json b/schema.json index ef6de4b..d65fdda 100644 --- a/schema.json +++ b/schema.json @@ -29,9 +29,9 @@ }, "sources": { "type": "array", - "description": "Source playlists to build the session.", + "description": "Source playlist groups to build the session.", "items": { - "$ref": "#/$defs/playlist" + "$ref": "#/$defs/playlistGroup" } }, "pattern": { @@ -43,20 +43,23 @@ } }, "$defs": { - "playlist": { + "playlistGroup": { "type": "object", "required": [ "name", - "spotifyID" + "spotifyIDs" ], "properties": { "name": { "type": "string", - "description": "Name to identify the source plalist" + "description": "Name to identify the playlist group" }, - "spotifyID": { - "type": "string", - "description": "ID inside Spotify of the playlist" + "spotifyIDs": { + "type": "array", + "description": "IDs inside Spotify of the playlists", + "items": { + "type": "string" + } } } } diff --git a/test/assets/valid_definition.json b/test/assets/valid_definition.json index 9b09913..369bee0 100644 --- a/test/assets/valid_definition.json +++ b/test/assets/valid_definition.json @@ -5,7 +5,9 @@ "sources": [ { "name": "Test", - "spotifyID": "2OsWOxpOhmPUqISvPo8Cph" + "spotifyIDs": [ + "2OsWOxpOhmPUqISvPo8Cph" + ] } ], "pattern": [ diff --git a/test/test.js b/test/test.js index d25ae2d..8fda93a 100644 --- a/test/test.js +++ b/test/test.js @@ -4,15 +4,29 @@ const expect = require('chai').expect; const {parseSession} = require('../lib'); -describe('Schema test', function () { - it('should accept a valid schema', function () { +describe('Schema test', function() { + it('should accept a valid schema', function() { const inputJSON = fs.readFileSync(path.join(__dirname, 'assets/valid_definition.json'), {encoding: 'utf-8'}); let def; expect(def = parseSession(inputJSON)).to.not.throw; expect(def.title).to.have.string('This is ok'); }); - it('should reject an invalid schema', function () { + it('should accept the schema in te examples folder (sbk_session.json)', function() { + const inputJSON = fs.readFileSync(path.join(__dirname, '../examples/sbk_session.json'), {encoding: 'utf-8'}); + let def; + expect(def = parseSession(inputJSON)).to.not.throw; + expect(def.title).to.have.string('My awesome'); + }); + + it('should accept the schema in te examples folder (swing_session.json)', function() { + const inputJSON = fs.readFileSync(path.join(__dirname, '../examples/swing_session.json'), {encoding: 'utf-8'}); + let def; + expect(def = parseSession(inputJSON)).to.not.throw; + expect(def.title).to.have.string('Blues'); + }); + + it('should reject an invalid schema', function() { const inputJSON = fs.readFileSync(path.join(__dirname, 'assets/invalid_definition.json'), {encoding: 'utf-8'}); expect(() => parseSession(inputJSON)).to.throw(Error); });