diff --git a/.gitignore b/.gitignore index 35783cf..39ba9cf 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ functions/ build/ # Local Netlify folder .netlify +data/ +.venv/ \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..3e7bb4e --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "liveServer.settings.ignoreFiles":["data/*"], +} \ No newline at end of file diff --git a/README.md b/README.md index d3f3aa0..c248a59 100644 --- a/README.md +++ b/README.md @@ -56,8 +56,9 @@ SPOTIFY_CLIENT_ID= SPOTIFY_CLIENT_SECRET= REDIRECT_URI_AUTH=http://127.0.0.1:9000/.netlify/functions/api/auth/callback REDIRECT_URI_HOME=http://127.0.0.1:8080/playlists.html +METRICS_ENABLED= ``` -You may also need to edit the sixth line of `dist/playlist.js` if you change the port that the backend api runs off of. +You may also need to edit the sixth line of `dist/util.js` if you change the port that the backend api runs off of. At the current moment our application requires a server that is distributing the `dist` directory of the project to be able to function. This is to emulate what is eventually going to be hosted on netlify. Our dependencies include the `http-server` node module that can be used for this however, if it does not work you can install it with the following command. ``` @@ -65,6 +66,10 @@ npm install http-server ``` Editors like Visual Studio Code also have extensions that can provide a live http-server. If you decide to use something like that instead make sure you update the URIs accordingly. We will attempt to make this a more streamlined thing for running locally as the project progresses. +If metrics are enabled then on completion of going through a playlist the following metrics will be written to a file in the `data` directory. +- Per song: The total amount of time spent on the song, direction swiped, and song information. +- Per playlist: The total amount of time it took to go through the playlist. + ### Running Locally Once dependencies are installed and the `.env` file iat the moment as well due to the URI for API calls being hardcoded at the moment. s created and populated you can run the application with either of the following commands. @@ -72,10 +77,9 @@ Once dependencies are installed and the `.env` file iat the moment as well due t npm start (will just run the backend express app, use if you plan to use your own http server to distribute the frontend) npm run dev (will launch both a server to distribute the frontend and execute the backend) ``` -At the current moment we do not have a landing page set up so to check out what we have for the application you can go to either of these urls. Please note port `8080` is the default port for `http-server` and port `9000` is the default port for the express backend so if you decide to change them make sure you update the urls accordingly. +The below URL will bring you to the landing page for the application. Please note port `8080` is the default port for `http-server` and port `9000` is the default port for the express backend so if you decide to change them make sure you update the urls accordingly. ``` -http://127.0.0.1:8080/index.html (song swiping demo, mobile only) -http://127.0.0.1:9000/.netlify/functions/api/auth/login (will ask you to sign in then redirect you to the work in progres playlist selection screen) +http://127.0.0.1:8080/index.html ``` Our unit tests can be ran with the following command. diff --git a/data/IF_METRICS_ENABLED_DATA_WILL_BE_STORED_HERE b/data/IF_METRICS_ENABLED_DATA_WILL_BE_STORED_HERE new file mode 100644 index 0000000..e69de29 diff --git a/dist/cards.html b/dist/cards.html index be075ac..0f6500e 100644 --- a/dist/cards.html +++ b/dist/cards.html @@ -1,7 +1,6 @@ - - + SongSwipe @@ -13,29 +12,27 @@ - + - - + +
-
-
-
-
- Loading... -
+
+
+
+ Loading...
-
- +
+
+

@@ -95,6 +92,5 @@
- - + \ No newline at end of file diff --git a/dist/cards.js b/dist/cards.js index 3c3c14b..3bf00a5 100644 --- a/dist/cards.js +++ b/dist/cards.js @@ -1,3 +1,6 @@ +let total_time = undefined; +let song_time = undefined; + $(document).ready(async function () { // MAKE BUTTON PRETTY // alert("Welcome to the SongSwipe demo!\n\nShown here is the swiping interface loaded with a existing Spotify playlist.\n\nSwipe right on songs you like\nSwipe left on ones you don't!") @@ -14,6 +17,15 @@ $(document).ready(async function () { headers.set('Authorization', access_token); headers.set('Access-Control-Allow-Origin', '*'); + // Check if metrics are enabled and set a boolean to not do the stuff if that aren't enabled + let enabledUrl = new URL(`${API_URI}/metrics/enabled`); + const enabled_request = new Request(enabledUrl.toString(), { + method: 'GET', + }); + const enabled_response = await fetch(enabled_request); + const metrics_enabled = (await enabled_response.json()).enabled; + song_metrics = []; + // Hide Overlay Button until all the songs are loaded const closeButton = document.getElementById('close-overlay'); closeButton.classList.add('hidden'); @@ -383,9 +395,11 @@ $(document).ready(async function () { const overlay = document.getElementById('overlay'); // Close overlay when button is clicked - closeButton.addEventListener('click', function () { + closeButton.addEventListener('click', async function () { overlay.classList.add('hidden'); - // Starts playing by default + total_time = getSecondsSinceEpoch(); + song_time = getSecondsSinceEpoch(); + // Starts playing by default // Plays song at the start of the tracklist songPlayer(track_index); }); @@ -500,7 +514,7 @@ $(document).ready(async function () { }); // While finger is moving or mouse is being dragged... - $("#app_container").on("touchmove mousemove", function (event) { + $("#app_container").on("touchmove mousemove", async function (event) { if (tracking) { swipe_details = computeSwipeDetails(event); @@ -517,16 +531,35 @@ $(document).ready(async function () { card.style.transform = `translateX(${translateX}px) rotate(${rotateDeg}deg)`; // If the song has been completed_swipe enough to declare it left or right swipe + let swipe_time = undefined; if (swipe_details.distance > DISTANCE_TO_SWIPE) { let track_id = songs[track_index].track_id; if (swipe_details.direction === -1) { console.log(songs[track_index]); save_state = saveTrack(save_state, 'left', track_id, track_index, songs); save(playlist_id, save_state, user_id) + swipe_time = getSecondsSinceEpoch() - song_time; + song_metrics.push({ + 'track_id': track_id, + 'song_name': songs[track_index].name, + 'swipe_time': swipe_time, + 'direction': 'left' + }); + if (metrics_enabled) sendTrackTime(playlist_id, user_id, track_id, songs[track_index].name, songs[track_index].artists[0], + songs[track_index].album_name, swipe_time, 'left'); } else if (swipe_details.direction === 1) { console.log(songs[track_index]); save_state = saveTrack(save_state, 'right', track_id, track_index, songs); save(playlist_id, save_state, user_id); + swipe_time = getSecondsSinceEpoch() - song_time; + song_metrics.push({ + 'track_id': track_id, + 'song_name': songs[track_index].name, + 'swipe_time': swipe_time, + 'direction': 'right' + }); + if (metrics_enabled) sendTrackTime(playlist_id, user_id, track_id, songs[track_index].name, songs[track_index].artists[0], + songs[track_index].album_name, swipe_time, 'right'); } // Plays new song after swipe track_index += 1; @@ -537,6 +570,7 @@ $(document).ready(async function () { tracking = false; completed_swipe = true; + song_time = getSecondsSinceEpoch(); // Add transition for smooth animation $("#song_card").css({ @@ -553,7 +587,7 @@ $(document).ready(async function () { }); // Once front song is fully out of frame - $("#song_card").one('transitionend', function () { + $("#song_card").one('transitionend', async function () { // Variables for readability let $next = $("#next_song_card"); let $current = $("#song_card"); @@ -573,7 +607,7 @@ $(document).ready(async function () { $last.addClass("next_song_card").removeClass("last_song_card"); // Wait for the transition to finish, then remove transition property - $last.one("transitionend", function () { + $last.one("transitionend", async function () { // Remove all transition properties to start with clean slate for future swipes $next.css("transition", ""); $current.css("transition", ""); @@ -595,6 +629,15 @@ $(document).ready(async function () { params.set('user_id', user_id); params.set('playlist_id', playlist_id); + if (metrics_enabled) { + // song_metrics.forEach(async (element) => { + // await sendTrackTime(playlist_id, user_id, element['track_id'], element['song_name'], element['swipe_time'], element['direction']); + // }); + + let completion_time = (getSecondsSinceEpoch() - total_time) / SEC_PER_MIN; + await sendElapsedTime(playlist_id, user_id, completion_time); + } + window.location.href = window.location.pathname.replace('cards', 'stagingarea') + `?${params.toString()}`; } updateSongCard(songIndex, "last_song_card"); diff --git a/dist/playlists.js b/dist/playlists.js index 8a1c148..b979d65 100644 --- a/dist/playlists.js +++ b/dist/playlists.js @@ -49,6 +49,17 @@ $(document).ready(async function () { // Redirect to cards page card.addEventListener('click', async () => { + // Send request ot create files for metric collection will not do anything if metrics aren't enabled + let metrics_params = new URLSearchParams(); + metrics_params.set('playlist_id', playlist.id); + metrics_params.set('user_id', data_user.id); + metrics_params.set('username', data_user.display_name); + metrics_params.set('playlist_name', playlist.name); + const information_metric_request = new Request(`${API_URI}/metrics/information?${metrics_params.toString()}`, { + method: 'POST', + }); + await fetch(information_metric_request); + let params = new URLSearchParams(); params.set('playlist_id', playlist.id); diff --git a/dist/util.js b/dist/util.js index a35f3ec..77b9b76 100644 --- a/dist/util.js +++ b/dist/util.js @@ -8,6 +8,8 @@ const API_URI = function() { return uri + '/.netlify/functions/api'; }(); +const SEC_PER_MIN = 60; + const getSecondsSinceEpoch = () => Math.floor(Date.now() / 1000); /* @@ -134,4 +136,37 @@ function moveTrack(target, source, destination, trackId) { alert(`Track with ID ${trackId} not found in ${array_source}`); } return target; +} + +async function sendTrackTime(playlist_id, user_id, track_id, track_name, track_artists, track_album, swipe_time, direction) { + let params = new URLSearchParams(); + params.set('playlist_id', playlist_id); + params.set('user_id', user_id); + params.set('song_id', track_id); + params.set('song_artists', track_artists.replaceAll(",", "")); + params.set('song_album', track_album.replaceAll(",", "")); + params.set('song_name', track_name.replaceAll(",", "")); + params.set('swipe_time', swipe_time); + params.set('direction', direction); + + const request = new Request(`${API_URI}/metrics/decision?${params.toString()}`, { + method: 'POST', + }) + + const response = await fetch(request); + response.status; +} + +async function sendElapsedTime(playlist_id, user_id, total_time) { + let params = new URLSearchParams(); + params.set('playlist_id', playlist_id); + params.set('user_id', user_id); + params.set('total_time', total_time); + + const request = new Request(`${API_URI}/metrics/elapsed?${params.toString()}`, { + method: 'POST', + }) + + const response = await fetch(request); + response.status; } \ No newline at end of file diff --git a/src/api.ts b/src/api.ts index d465171..0673a5a 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,7 +1,7 @@ import express, { Router } from 'express' import serverless from 'serverless-http' import cors from 'cors'; -import { authCallback, authLogin, authRefresh, playlistBuild, playlistData, userData, userPlaylists, songPreview, songRemove, playlistCreate, songAdd } from './endpoints'; +import { authCallback, authLogin, authRefresh, playlistBuild, playlistData, userData, userPlaylists, songPreview, songRemove, playlistCreate, songAdd, metricsDecision, metricsElapsed, metricsInformation, metricsEnabled } from './endpoints'; const app = express(); const router = Router(); @@ -25,6 +25,11 @@ router.post('/playlist/add', songAdd); router.delete('/playlist/remove', songRemove); // Song related endpoints router.get('/song', songPreview); +// Testing Metrics endpoints +router.get('/metrics/enabled', metricsEnabled); +router.post('/metrics/information', metricsInformation); +router.post('/metrics/decision', metricsDecision); +router.post('/metrics/elapsed', metricsElapsed); // Netlify API setup app.use("/.netlify/functions/api", router); diff --git a/src/endpoints.ts b/src/endpoints.ts index 0684f4b..a74fb8c 100644 --- a/src/endpoints.ts +++ b/src/endpoints.ts @@ -2,6 +2,7 @@ import { ERROR_RESPONSES, generateRandomString, StatusCodes } from "./util"; import { config } from 'dotenv'; import { fetchPlaylist, fetchUserInfo, fetchUserPlaylists, buildPlaylist, removeSongsFromPlaylist, createPlaylist, addSongsToPlaylist } from "./spotify-interactions"; import { getSpotifyPreviewUrl } from "./spotify-preview"; +import * as fs from 'fs'; // Load .env with Spotify credentials & set constants for env secrets config(); @@ -9,6 +10,7 @@ const spotify_client_id = process.env.SPOTIFY_CLIENT_ID ?? ""; const spotify_client_secret = process.env.SPOTIFY_CLIENT_SECRET ?? ""; const redirect_uri = process.env.REDIRECT_URI_AUTH ?? ""; const redirect_home = process.env.REDIRECT_URI_HOME ?? ""; +const metrics_enabled = (process.env.METRICS_ENABLED ?? "") == "true" ? true : false; /* * Endpoint: /auth/login @@ -347,3 +349,136 @@ export async function songRemove(req: any, res: any) { res.status(status).json(data); } + +/* +* Endpoint: /playlist/information +* Description: Upload basic information for metrics +* +* Request: +* query_params: playlist_id, user id, username, playlist name +* +* Response: Playlist Snapshot ID +* +*/ +export async function metricsInformation(req: any, res: any) { + if (metrics_enabled) { + let playlist_id = req.query.playlist_id?.toString() ?? ""; + let user_id = req.query.user_id?.toString() ?? ""; + let username = req.query.username?.toString() ?? ""; + let playlist_name = req.query.playlist_name?.toString() ?? ""; + + if (playlist_id == "" || user_id == "" || username == "" || playlist_name == "") { + res.status(StatusCodes.BAD_REQUEST).json(ERROR_RESPONSES.MISSING_PARAM); + return; + } + + const text = `PLAYLIST_NAME=${playlist_name}\nUSER=${username}\n`; + + fs.writeFile(`./data/${user_id}_${playlist_id}_info.txt`, text, (err) => { + const headers = `id,name,artists,album,time(Sec),direction\n`; + fs.writeFile(`./data/${user_id}_${playlist_id}_actions.csv`, headers, (err) => { + if (err) { + console.log(err); + res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({'error': 'file writing error'}); + } else { + res.status(StatusCodes.OK).json({'status': 'success'}); + } + }) + + }); + + } else { + res.status(StatusCodes.BAD_REQUEST).json({ 'error': ERROR_RESPONSES.METRICS_NOT_ENABLED }); + } + +} + +/* +* Endpoint: /metrics/enabled +* Description: returns whether metrics are enabled or not +* +* Response: a json with the boolean +* +*/ +export async function metricsEnabled(req: any, res: any) { + let data = { 'enabled': metrics_enabled}; + res.status(StatusCodes.OK).json(data); +} + +/* +* Endpoint: /metrics/decision +* Description: Uploads a decision for the plays +* +* Request: +* query_params: playlist_id, user id +* body: song_id, song name, song artists, song album, swipe_time (in seconds), direction +* +* Response: Nothing just status if success or not +* +*/ +export async function metricsDecision(req: any, res: any) { + if (metrics_enabled) { + let playlist_id = req.query.playlist_id?.toString() ?? ""; + let user_id = req.query.user_id?.toString() ?? ""; + let song_id = req.query.song_id?.toString() ?? ""; + let song_name = req.query.song_name?.toString() ?? ""; + let song_artist = req.query.song_artists?.toString() ?? ""; + let song_album = req.query.song_album?.toString() ?? ""; + let swipe_time = req.query.swipe_time?.toString() ?? ""; + let direction = req.query.direction?.toString() ?? ""; + + if (playlist_id == "" || user_id == "" || song_id == "" || song_name == "" || song_artist == "" || song_album == "" || swipe_time == "" || direction == "") { + res.status(StatusCodes.BAD_REQUEST).json({ 'error': ERROR_RESPONSES.MISSING_PARAM }); + return; + } + + const line = `${song_id},${song_name},${song_artist},${song_album},${swipe_time},${direction}\n`; + fs.appendFile(`./data/${user_id}_${playlist_id}_actions.csv`, line, (err) => { + if (err) { + console.log(err); + res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({'error': 'file writing error'}); + } else { + res.status(StatusCodes.OK).json({'status': 'success'}); + } + }) + + } else { + res.status(StatusCodes.BAD_REQUEST).json({ 'error': ERROR_RESPONSES.METRICS_NOT_ENABLED }); + } +} + +/* +* Endpoint: /metrics/elapsed +* Description: Upload total elapsed time it took to go through a playlist +* +* Request: +* query_params: playlist_id, user id, total time (in minutes) +* +* Response: Nothing just status if success or not +* +*/ +export async function metricsElapsed(req: any, res: any) { + if (metrics_enabled) { + let playlist_id = req.query.playlist_id?.toString() ?? ""; + let user_id = req.query.user_id?.toString() ?? ""; + let total_time = req.query.total_time?.toString() ?? ""; + + if (playlist_id == "" || user_id == "" || total_time == "") { + res.status(StatusCodes.BAD_REQUEST).json(ERROR_RESPONSES.MISSING_PARAM); + return; + } + + const text = `TOTAL_TIME=${total_time}`; + fs.appendFile(`./data/${user_id}_${playlist_id}_info.txt`, text, (err) => { + if (err) { + console.log(err); + res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({'error': 'file writing error'}); + } else { + res.status(StatusCodes.OK).json({'status': 'success'}); + } + }) + + } else { + res.status(StatusCodes.BAD_REQUEST).json({ 'error': ERROR_RESPONSES.METRICS_NOT_ENABLED }); + } +} diff --git a/src/util.ts b/src/util.ts index f7129c3..d635cea 100644 --- a/src/util.ts +++ b/src/util.ts @@ -18,7 +18,9 @@ export const ERROR_RESPONSES = { 'NOT_FOUND': { 'error': 'resource not found' }, 'UNHANDLED': { 'error': 'unhandled response code' }, 'REFRESH_ERROR': { 'error' : 'failed to refresh access token' }, - 'RATE_LIMIT_EXCEED': { 'error' : 'application rate limit exceed' } + 'RATE_LIMIT_EXCEED': { 'error' : 'application rate limit exceed' }, + 'METRICS_NOT_ENABLED': { 'error' : 'metrics are not enabled' }, + 'MISSING_PARAM': {'error' : 'missing parameter'}, } /*