diff --git a/README.md b/README.md index fafa56e..cbf7882 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,45 @@ -# Spotify recommender +# Spotify recommender + insights ### Google SPS Taiwan Team 6 -#### Run locally -- Navigate to working directory -- In terminal, run 'node server.js' -- Application should be running on local host port 8888 +Used Javascript, Node.js/Express.js, Python, and Google Datastore to create a Spotify playlist recommender + insights web application :) + +Music taste is unique to everyone and is hard to describe in words - with this app, you can compare your music preferences with friends and enjoy some new tracks! + +![Spotify App Flow](demo/spotify_app_flow.png) + +### Video demos +**Click on the images below** to watch the Youtube demos! + +#### Desktop view +[![Watch the desktop demo](demo/desktop_preview.png)](https://youtu.be/qDLzCbezRU8) + +#### Tablet view +[![Watch the tablet demo](demo/tablet_preview.png)](https://youtu.be/0BhAvgtzgTw) + +#### Mobile view +[![Watch the mobile demo](demo/mobile_preview.png)](https://youtu.be/YwTwcW77QB0) +[![Watch the mobile demo](demo/mobile_preview2.png)](https://youtu.be/YwTwcW77QB0) + +### Contributors +- Phoebe (UI) + - UI beginning stages: colour palette, basic design; more responsive data population by Stephanie + - Assisted looking into machine learning models in beginning stages +- Stephanie (Spotify API, frontend + backend) + - Spotify API calls: login authorization flow, access tokens, get user, user playlists, playlist and track objects, personalized user insights, search Spotify user + - Frontend: communicate with server using Javascript fetching, ie. send selected user playlists, populate DOM with playlists and data after calling API, responsive navigation + grid layout to display playlists, cookies, user login status, lots of rendering! + - Backend: Node.js server, handles user authorization, page rendering, GET/POST requests, and Spotify API calls + - Client-server communication: Javascript fetch, GET, POST, JSON formatting/parsing +- Jessica C (Datastore) + - Created Datastore entities for tracks (name, artists, audio_featrures, track_id, playlist_id) + - Implemented functions for inserting/retrieving to/from Datastore, ie. addTracks(), getTrackByPlaylist(), getTrackById() +- Jessica F (Recommender) + - Modified Spotify's recommendation function to fit our app purpose and functionality, ex. getTracks(), getTracksByPlaylistId(), getRecommendations() + - Ran tracks from Datastore playlists through recommendation function to output a final playlist of recommended songs starting from 'most recommended' + +### To run locally +- Git clone the repository +- You might have to delete the `node_modules` folder and run `npm install` +- Navigate to working directory/repository +- In Terminal/Powershell, run `node server.js` or `npm run devStart` (nodemon) +- Application should be running on local host port 8888! diff --git a/demo/desktop_preview.png b/demo/desktop_preview.png new file mode 100644 index 0000000..8c0eef0 Binary files /dev/null and b/demo/desktop_preview.png differ diff --git a/demo/mobile_preview.png b/demo/mobile_preview.png new file mode 100644 index 0000000..278d9ff Binary files /dev/null and b/demo/mobile_preview.png differ diff --git a/demo/mobile_preview2.png b/demo/mobile_preview2.png new file mode 100644 index 0000000..44d0cbc Binary files /dev/null and b/demo/mobile_preview2.png differ diff --git a/demo/spotify_app_flow.png b/demo/spotify_app_flow.png new file mode 100644 index 0000000..d6bcbf6 Binary files /dev/null and b/demo/spotify_app_flow.png differ diff --git a/demo/tablet_preview.png b/demo/tablet_preview.png new file mode 100644 index 0000000..f244ecc Binary files /dev/null and b/demo/tablet_preview.png differ diff --git a/public/css/style.css b/public/css/style.css index 3887249..122e766 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -9,10 +9,29 @@ --spotify-green: #1ed760; } +button { + background-color: white; +} + +#searchButton { + font-family: 'Montserrat', sans-serif; + background-color: #1ed760; + width: 10%; + border-radius: 10px; + padding: 0.35em 1.2em; + margin: 0 0.3em 0.3em 0; + box-sizing: border-box; + text-decoration: none; + color: white; +} + .content { font-family: 'Montserrat', sans-serif; margin: 50px; color: white; + max-width: 1200px; + margin: 0 auto; + padding: 20px; } .center-text { @@ -305,6 +324,141 @@ body { padding-left: 20px; } +:root { + --spotify-green: #1ed760; +} + +#tracks { +background-color: black; + display: grid; + grid-template-rows: 1fr; + grid-template-columns: 1fr 2fr; + + margin-top: 50px; +} + +#recs { +background-color: black; + display: grid; + grid-template-rows: 1fr; + grid-template-columns: 1fr; + + margin-top: 5%; + margin-left: 10%; + margin-bottom: 5%; + margin-right: 10%; +} + +#playlist-info { + grid-row: 1 / span 1; + grid-column: 1 / span 1; + + /* display: grid; + grid-template-rows: 3fr 1fr 1fr; */ + margin: 0px auto; +} + +#playlist-cover { + grid-row: 1 / span 1; + padding: 30px 10px; + max-width: 300px; + max-height: 300px; +} + +#playlist-name { + grid-row: 2 / span 1; + margin: 0px auto; + font-size: 24px; + font-family: 'Montserrat', 'Noto Sans TC', sans-serif; + font-weight: bold; + color: white; +} + +#track-info { + display: flex; + flex-direction: column; + color: white; + margin: 0px 300px 0px 10px; +} + +#rec-track-info { +display: flex; +flex-direction: column; +color: white; +margin: 0px 50px 0px 50px; +} + +.track { + width: 100%; + margin: 15px 10px; +} + +.track-name { + color: white; + text-decoration: none; + font-size: 20px; + font-family: 'Montserrat', 'Noto Sans TC', sans-serif; + font-weight: bold; +} + +.track-name:hover { + background: linear-gradient(180deg,rgba(255,255,255,0) 70%, #5C5C5C 70%); +} + +.duration { + color: #C2C2C2; + padding: 0px 20px; +} + +#buttons { + grid-row: 3 / span 1; + grid-column: 1 / span 1; +} + +.button { + border: none; + color: white; + padding: 15px 32px; + text-align: center; + text-decoration: none; + display: inline-block; + font-size: 16px; + margin: 4px 2px; + cursor: pointer; + border-radius: 24px; + opacity: 0.9; + transition: 0.3s; + font-family: inherit; +} + +.button:hover { + opacity: 1.0; +} + +#confirm { + background-color: var(--spotify-green); +} + +#back { + background-color: #5C5C5C; +} + +.icon { + grid-row: 1 / span 2; + grid-column: 1 / span 1; + max-width: 10px; + max-height: 10px; + margin-right: 10px; + + cursor: pointer; +} + +.artist { + margin: 10px 0px; + padding-left: 20px; + color: white; +} + @media(max-width: 1333px) { .grid-item { flex-basis: 33.33%; @@ -508,141 +662,6 @@ body { background-color: #1ed760; } - :root { - --spotify-green: #1ed760; -} - -#tracks { - background-color: black; - display: grid; - grid-template-rows: 1fr; - grid-template-columns: 1fr 2fr; - - margin-top: 50px; -} - -#recs { - background-color: black; - display: grid; - grid-template-rows: 1fr; - grid-template-columns: 1fr; - - margin-top: 5%; - margin-left: 10%; - margin-bottom: 5%; - margin-right: 10%; -} - -#playlist-info { - grid-row: 1 / span 1; - grid-column: 1 / span 1; - - display: grid; - grid-template-rows: 3fr 1fr 1fr; - margin: 0px auto; -} - -#playlist-cover { - grid-row: 1 / span 1; - padding: 30px 0px; - max-width: 300px; - max-height: 300px; -} - -#playlist-name { - grid-row: 2 / span 1; - margin: 0px auto; - font-size: 24px; - font-family: 'Montserrat', 'Noto Sans TC', sans-serif; - font-weight: bold; - color: white; -} - -#track-info { - display: flex; - flex-direction: column; - color: white; - margin: 0px 300px 0px 10px; -} - -#rec-track-info { - display: flex; - flex-direction: column; - color: white; - margin: 0px 50px 0px 50px; -} - -.track { - width: 100%; - margin: 15px 10px; -} - -.track-name { - color: white; - text-decoration: none; - font-size: 20px; - font-family: 'Montserrat', 'Noto Sans TC', sans-serif; - font-weight: bold; -} - -.track-name:hover { - background: linear-gradient(180deg,rgba(255,255,255,0) 70%, #5C5C5C 70%); -} - -.duration { - color: #C2C2C2; - padding: 0px 20px; -} - -#buttons { - grid-row: 3 / span 1; - grid-column: 1 / span 1; -} - -.button { - border: none; - color: white; - padding: 15px 32px; - text-align: center; - text-decoration: none; - display: inline-block; - font-size: 16px; - margin: 4px 2px; - cursor: pointer; - border-radius: 24px; - opacity: 0.9; - transition: 0.3s; - font-family: inherit; -} - -.button:hover { - opacity: 1.0; -} - -#confirm { - background-color: var(--spotify-green); -} - -#back { - background-color: #5C5C5C; -} - -.icon { - grid-row: 1 / span 2; - grid-column: 1 / span 1; - max-width: 10px; - max-height: 10px; - margin-right: 10px; - - cursor: pointer; -} - -.artist { - margin: 10px 0px; - padding-left: 20px; - color: white; -} - input[type=text] { font-family: 'Montserrat', sans-serif; width: 80%; @@ -667,3 +686,13 @@ input[type=submit] { text-align: center; transition: all 0.2s; } + +@media(max-width: 815px) { + #searchButton { + width: auto; + } + + input[type=text] { + width: 75%; + } +} \ No newline at end of file diff --git a/public/scripts/script.js b/public/scripts/script.js index f824b7a..4953f78 100644 --- a/public/scripts/script.js +++ b/public/scripts/script.js @@ -197,7 +197,7 @@ async function loadPlaylists() { // create span and set text to playlist title const title = document.createElement('span'); title.setAttribute('class', 'item-title'); - title.textContent = playlist.name; + title.textContent = playlist.name.substring(0,12); // create span and set text to playlist title const tracks = document.createElement('span'); diff --git a/server.js b/server.js index d478835..caac603 100644 --- a/server.js +++ b/server.js @@ -41,7 +41,8 @@ var token = "1" var user = "2" var display_name = "display_name" var friend = "friend" -var playlist = [] +var playlist = "playlist" +var playlist_array = [] function assign_global(access_token, user_id, user_display_name) { token = access_token @@ -176,6 +177,12 @@ app.get('/refresh_token', function (req, res) { }); // GET for logged in status +app.get('/playlistArray', function (req, res) { + res.send(200, { + "playlistArray": playlist_array + }) +}) + app.get('/loginstatus', function (req, res) { if (token == "1") res.send(200, { "loggedin": false }) else res.send(200, { "loggedin": true, "username": display_name }) @@ -210,14 +217,13 @@ app.get('/playlist-tracks', function (req, res) { getTracks(); function getTracks() { - console.log(playlist) - console.log(token) + console.log(playlist_array) var select_playlist; - if (playlist.length == 1) { - select_playlist = playlist[0] - } else { - select_playlist = playlist[1] + if (playlist_array.length == 0) { + select_playlist = playlist + } else { // else there is already 1 CONFIRMED playlist in the array + select_playlist = playlist_array[1] } var playlistOptions = { @@ -239,21 +245,33 @@ app.get('/playlist-tracks', function (req, res) { // set up endpoint for POST (receiving playlist id selection) app.post('/playlistid', (req, res, next) => { - console.log(req.body); + playlist = req.body.playlist; + console.log(`/playlistid: ${playlist}`) - playlist.push(req.body.playlist); - console.log(playlist) + // best practices to end + res.json({ + status: 'Success: current playlist ID stored on server' + }) +}) + +// set up endpoint for POST (receiving confirmed playlist id selection) +app.post('/confirmPlaylist', (req, res, next) => { + var temp_playlist = req.body + console.log(`/confirmPlaylist: ${temp_playlist}`) + + playlist_array = temp_playlist; + console.log(playlist_array[0]) + console.log(playlist_array[1]) // best practices to end res.json({ - status: 'Success: playlist ID stored on server', - playlist: req.body.playlist + status: 'Success: playlist ID ARRAY stored on server', + playlist: playlist_array }) }) // set up endpoint for POST app.post('/track', (req, res, next) => { - console.log(req.body) track = req.body console.log("TRACK: " + track.track_id); @@ -329,20 +347,27 @@ app.get('/userinsights', (req, res) => { }) }) + +// GET for logged in status +app.get('/loginstatus', function (req, res) { + if (token == "1") res.send(200, {"loggedin": false}) + else res.send(200, {"loggedin": true, "username": display_name}) +}) + // get a list of recommendations for a playlist app.get('/recommendations', async function (req, res) { var initPlaylist = []; - await getTracksByPlaylistId(playlist2, async (err, songs) => { - console.log("PLAYLIST ID 2: " + playlist2); + await getTracksByPlaylistId(playlist_array[1], async (err, songs) => { + console.log("PLAYLIST ID 2: " + playlist_array[1]); if (err) { console.log(err) } for (var i = 0; i < songs.length; i++) { initPlaylist.push(songs[i].track_id); }; console.log("FIRST: " + initPlaylist); - getTracksByPlaylistId(playlist, async (err, songs) => { - console.log("PLAYLIST ID: " + playlist); + getTracksByPlaylistId(playlist_array[0], async (err, songs) => { + console.log("PLAYLIST ID: " + playlist_array[0]); if (err) { console.log(err) } for (var i = 0; i < songs.length; i++) { diff --git a/views/index.ejs b/views/index.ejs index ed89310..64319e4 100644 --- a/views/index.ejs +++ b/views/index.ejs @@ -19,7 +19,7 @@

This Spotify Recommender is a team project from SPS Taiwan Google program.
This recommender will use machine learning algorithms to recommend songs based on a user's input playlist from Spotify. The algorithm will recommend a list of songs based on the features of the playlist

-
Click User Home in the navigation bar to access the recommender. Click insights to access general info about your musical tastes based on your Spotify history.

+
Click User Home in the navigation bar to access the recommender. Click Insights to access general info about your musical tastes based on your Spotify history.

diff --git a/views/playlist.ejs b/views/playlist.ejs index 0f12a28..758da10 100644 --- a/views/playlist.ejs +++ b/views/playlist.ejs @@ -38,13 +38,23 @@ } var playlist_id = "playlist-id" + var playlist_2 = "" + var readyToRecommend = false; - // sends playlist_id to server to fetch playlists + // sends playlist_id to server to fetch tracks async function postplaylist() { playlist_id = await getParameterByName('playlist'); + playlist_2 = await getParameterByName('playlist2'); + if (playlist_2) { + readyToRecommend = true; + } + clearQuery() console.log(`Selected playlist ID: ${playlist_id}`); + console.log(`Second playlist id: ${playlist_2}`); + console.log(readyToRecommend); + postData('/playlistid', { playlist: playlist_id }) @@ -53,103 +63,117 @@ }) } - loadTracks() - - // get JSON object of track's audio features to pass into Datastore track entity - // async function getAudioFeatures(track_id) { - // const response = await fetch(`https://api.spotify.com/v1/audio-features/${track_id}`, { - // headers: { - // 'Authorization': ' Bearer ' + access_token, - // } - // }) - // const json = await response.json() - // // console.log(json) - // return json - // } + loadTracks() - var temp_playlist = []; + // get JSON object of track's audio features to pass into Datastore track entity + // async function getAudioFeatures(track_id) { + // const response = await fetch(`https://api.spotify.com/v1/audio-features/${track_id}`, { + // headers: { + // 'Authorization': ' Bearer ' + access_token, + // } + // }) + // const json = await response.json() + // // console.log(json) + // return json + // } + + var temp_playlist = [] + var playlist_id_array = [] var onPlay = false; async function loadTracks() { await postplaylist(); fetch('/playlist-tracks') - .then(response => { - if (response.status != 200) { - console.log(`Error ${response.status}`) - } - return response.json() - }) - .then(playlist => { - const app = document.getElementById('tracks'); - // two big divs for playlist and tracks - const playlistInfo = document.createElement('div'); - playlistInfo.setAttribute('id', 'playlist-info'); - const trackInfo = document.createElement('div'); - trackInfo.setAttribute('id', 'track-info'); - app.appendChild(playlistInfo); - app.appendChild(trackInfo); - // playlist image - const playlist_img = document.createElement('img'); - playlist_img.setAttribute('id', 'playlist-cover'); - playlist_img.setAttribute('alt', 'Playlist Cover'); - if (playlist.images.length > 0) { - playlist_img.setAttribute('src', playlist.images[0].url); - } - else { - // placeholder image - playlist_img.setAttribute('src', 'http://www.scottishculture.org/themes/scottishculture/images/music_placeholder.png'); + .then(response => { + if (response.status != 200) { + console.log(`Error ${response.status}`) + } + return response.json() + }) + .then(async playlist => { + const app = document.getElementById('tracks'); + // two big divs for playlist and tracks + const playlistInfo = document.createElement('div'); + playlistInfo.setAttribute('id', 'playlist-info'); + const trackInfo = document.createElement('div'); + trackInfo.setAttribute('id', 'track-info'); + app.appendChild(playlistInfo); + app.appendChild(trackInfo); + // playlist image + const playlist_img = document.createElement('img'); + playlist_img.setAttribute('id', 'playlist-cover'); + playlist_img.setAttribute('alt', 'Playlist Cover'); + if (playlist.images.length > 0) { + playlist_img.setAttribute('src', playlist.images[0].url); + } + else { + // placeholder image + playlist_img.setAttribute('src', 'http://www.scottishculture.org/themes/scottishculture/images/music_placeholder.png'); + } + playlistInfo.appendChild(playlist_img); + // playlist name + const playlist_name = document.createElement('p'); + playlist_name.setAttribute('id', 'playlist-name'); + playlist_name.textContent = playlist.name; + playlistInfo.appendChild(playlist_name); + // container for confirm/back buttons + const buttons_div = document.createElement('div'); + buttons_div.setAttribute('id', 'buttons'); + playlistInfo.appendChild(buttons_div); + // confirm button + const confirm = document.createElement('a'); + confirm.setAttribute('id', 'confirm'); + confirm.setAttribute('class', 'button'); + if (!readyToRecommend) { + confirm.href = `/searchuser/?playlist=${playlist_id}`; + } else { + confirm.href = `/recommend`; + } + confirm.textContent = 'Confirm'; + confirm.setAttribute('onclick', 'confirm()'); + + // let array = await getPlaylistArray(); + // // now our local playlist id array is populated + + // console.log(array); + // if (array.length == 2) { + // confirm.setAttribute('href', '/recommend'); + // } else if (array.length < 2) { + // confirm.setAttribute('href', '/searchuser'); + // } + + buttons.appendChild(confirm); + // back button + const back = document.createElement('a'); + back.setAttribute('id', 'back'); + back.setAttribute('class', 'button'); + back.setAttribute('href', '/userhome'); + back.textContent = 'Back'; + buttons.appendChild(back); + + var hr_added = false; + playlist.tracks.items.forEach(async (item) => { + var artists = [] + item.track.artists.forEach(artist => { + artists.push(artist.name) + }) + var temp_track = { + artists: artists, + audio_features: [], + name: item.track.name, + track_id: item.track.id, + playlist_id: playlist_id + }; + temp_playlist.push(temp_track); + + // division line + if (hr_added) { + const hr = document.createElement('hr'); + trackInfo.appendChild(hr); + } else { + hr_added = true; } - playlistInfo.appendChild(playlist_img); - // playlist name - const playlist_name = document.createElement('p'); - playlist_name.setAttribute('id', 'playlist-name'); - playlist_name.textContent = playlist.name; - playlistInfo.appendChild(playlist_name); - // container for confirm/back buttons - const buttons_div = document.createElement('div'); - buttons_div.setAttribute('id', 'buttons'); - playlistInfo.appendChild(buttons_div); - // confirm button - - const confirm = document.createElement('a'); - confirm.setAttribute('id', 'confirm'); - confirm.setAttribute('class', 'button'); - confirm.textContent = 'Confirm'; - confirm.setAttribute('onclick', 'confirm()'); - confirm.setAttribute('href', `/recommend`); - buttons.appendChild(confirm); - // back button - const back = document.createElement('a'); - back.setAttribute('id', 'back'); - back.setAttribute('class', 'button'); - back.setAttribute('href', '/userhome'); - back.textContent = 'Back'; - buttons.appendChild(back); - - // - - var hr_added = false; - playlist.tracks.items.forEach(async (item) => { - var artists = [] - item.track.artists.forEach(artist => { - artists.push(artist.name) - }) - var temp_track = { - artists: artists, - audio_features: [], - name: item.track.name, - track_id: item.track.id, - playlist_id: playlist_id - }; - temp_playlist.push(temp_track); - // division line - if (hr_added) { - const hr = document.createElement('hr'); - trackInfo.appendChild(hr); - } else { - hr_added = true; - } // create a row container for each track const track = document.createElement('div'); @@ -218,6 +242,7 @@ } async function confirm() { + for (var i = 0; i < temp_playlist.length; ++i) { postData('/track', temp_playlist[i]) .then(res => { @@ -225,8 +250,20 @@ playlist_id = res.playlist_id; }); } - // await recommend(); - }; + + if (readyToRecommend) { + pushPlaylistArray(); + } + } + + async function pushPlaylistArray() { + console.log(`posting playlist array: ${playlist_id}, ${playlist_2}`) + postData('/confirmPlaylist', [playlist_id, playlist_2]) + .then(res => { + console.log(res) + }) + } + // fetch post request async function postData(url = '', data = {}) { @@ -240,4 +277,42 @@ return response.json() } + async function getPlaylistArray() { + var temp = [] + + await fetch('/playlistArray') + .then (response => { + if (response.status != 200) { + console.log(`Error ${response.status}`) + } + return response.json() + }) + .then (data => { + playlist_id_array = data.playlistArray; + temp = data.playlistArray; + console.log(`THE TWO PLAYLISTS ON BROWSER ARE: ${playlist_id_array}`); + }) + .catch (err => { + console.log(err); + }) + console.log(`temp: ${temp}`) + return temp; + } + + // fetch get request + // async function getData(url = '', params = []) { + // params.forEach(async (param) => { + // url += "/" + param + // }); + // const response = await fetch(url, { + // method: 'GET', + // headers: { + // 'Content-Type': 'application/json' + // } + // }) + // return response.json() + // } + + // runRecommendations() + \ No newline at end of file diff --git a/views/recommend.ejs b/views/recommend.ejs index 3b5d9b7..9c556ab 100644 --- a/views/recommend.ejs +++ b/views/recommend.ejs @@ -14,7 +14,7 @@ -

here are your recommendations!

+

Your recommended playlist:

diff --git a/views/searchuser.ejs b/views/searchuser.ejs index b327abd..8a97671 100644 --- a/views/searchuser.ejs +++ b/views/searchuser.ejs @@ -15,24 +15,56 @@
-

Userhome

+

Search user


- + +