Using ChatGPT to Recommend Songs Based on your Listening History
Rohan Kumar
With all the recent developments in Artificial Intelligence, it is easier than ever to create systems that can provide personalized feedback and recommendations. Moreover, with how accessible Spotify has made all its user’s listening data, we can leverage ChatGPT and Spotify’s free public API service to create a very simple website that provides reasonably accurate recommendations based on your own listening history.
In this blogpost, we will go through the full process of creating a functional website, which will do the following:
- Authenticate the user using Spotify.
- Access and display the user’s most played tracks and artists.
- Use prompt-engineering principles to generate accurate and effective recommendation responses from OpenAI’s GPT-3.5-Turbo Model.
- Decode the list of songs outputted by GPT-3.5-Turbo and send them back to Spotify to get the song’s information.
- Display the list of recommended songs to the user along with links for them to listen on Spotify.
We will neglect much of the front-end and set up the bare bones application, which can be edited and refined using CSS Styling and more React components.
If you want to see what a fully developed version of this project could look like, check out my website here.
While this sounds like an involved project, it can actually be completed in a surprisingly few number of lines of code. We will go through everything, step-by-step. So even if you have very limited experience working with React or JavaScript, you should be able to follow everything we will do.
Setting up Spotify API
To start, we first need to set up Spotify API. We do this by navigating to the following link and setting up a new app. For now, most of the information you put in does not matter. The only thing that does matter is the “Redirect URI” field.
When the user clicks the login button on our website, they will be directed to a site under Spotify’s domain. Once the user allows permissions to our website through Spotify’s website, they will be redirected to our Redirect URI, which should ideally take them back to our website. For now, since we are working locally, we can set it as “http://localhost:3000”.
Something to note is that Spotify’s API Service, at the time of creating this blogpost, is entirely free, which makes it perfect for a small side project or something for education purposes.
Once we initialize our app with Spotify, we can navigate to Settings. There we will see two important items on top: Client ID and App Status.
- The Client ID is something we will use as our key allowing us to connect with Spotify’s authentication service.
- The App Status, which should be set to Development Mode by default, indicates that the website will only allow users that are manually registered for this app to be authenticated. By default, this will only include your own personal account that you used to create the app. If you want to add a few friends, you can manually add their emails on the User Management tab. But if you want your website to be publicly accessible, you will need to submit an Extension Request, which may take up to 6 weeks for Spotify to process.
Setting up our App
To get started, we will initialize a new React project. Personally, I like to begin using the create-react-app template since it is the easiest to work with, but any template will do. To do this, navigate to terminal and to the directory of where you want your project to be and type
npx create-react-app song-recommender
This will initialize a project called “song-recommender” with a bunch of general dependencies / libraries, allowing us to immediately start working on our website. To start, we type
npm start
npm i axios
We can clean up the project by deleting the App.css, App.test.js, index.css, logo.svg, setupTests.js, and reportWebVitals.js files. We can also modify the index.js code to take it out of safe mode, which prevents our Website from making extra API calls.
index.js should look like this:
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<App />
);
Lastly, we can remove everything from the App.js file so it looks like this:
import React from "react"
import axios from "axios"
const App = () => {
return (
<div> Hello World </div>
)
}
export default App
If your project is working correctly, you should see “Hello World” written on your browser on “http://localhost:3000”. Great! We can finally begin.
Implementing User Authentication with Spotify
The first thing we want to do is set up a basic login screen, which helps us access the user’s data. Spotify’s API has a lot of documentation on the different types of authentication flows that you could potentially use. Different flows have differing levels of security and complexity. For the sake of this project, we will use Implicit Grant Flow, which is the easiest of the 4 to set up. However, refactoring your code to work with Authorization Code with PKCE, which is the most secure method, is not too difficult. If you want to read more about this, you can read Spotify’s official documentation here.
The general idea of Implicit Grant Flow is that once the user logs in through Spotify’s portal, Spotify will send the user back to our site with additional information (called a token) in the URI. We can then parse through this new URI and save the token to use for any API calls we need.
We will start by creating a simple login-screen component (which will just be a plain button that says “Login to Spotify”). We will do this by creating a Login.js file and by typing the following code:
import React from 'react'
const LoginButton = () => {
return (
<a> Login to Spotify </a>
)
}
export default LoginButton
To display this login button on our website, we need to modify App.js and its import statements. We will also import the useState and useEffect hooks which we will use later. Together, it should look like this:
import React, { useState, useEffect } from "react"
import LoginButton from "./Login"
const App = () => {
return (
<LoginButton />
)
}
export default App
Next, we will add functionality to our login button. If you want more information for how Implicit Grant Flow works, you can look at Spotify’s Documentation here. To add functionality for the button, we need to add the appropriate href tag to our button so that it links to the correct endpoint. We can do this by defining the following constants.
const CLIENT_ID = "YOUR CLIENT ID FROM SPOTIFY API WEBSITE"
const REDIRECT_URI = "http://localhost:3000"
const AUTH_ENDPOINT = "https://accounts.spotify.com/authorize"
const RESPONSE_TYPE = "token"
const SCOPES = ['user-top-read']
const loginEndpoint = `${AUTH_ENDPOINT}?client_id=${CLIENT_ID}&scope=${SCOPES}&redirect_uri=${REDIRECT_URI}&response_type=${RESPONSE_TYPE}&show_dialog=true`
We add the scope ‘user-top-read’, because we need permission from the user to look at their listening history (including their top artists and tracks). We can connect this to our login button by modifying our <a> tag in Login.js as follows:
<a href={loginEndpoint}> Login to Spotify </a>
Now if you click the button on our website, it should navigate you to Spotify’s authentication portal and then redirect you back to the website. You should also see a modified URI on your browser, which contains a special access token.
Next, we need to decode this token from the URI and use it to make our API calls. We can decode the access token by adding the following code into App.js:
// ...
const App = () => {
const [token, setToken] = useState('')
const getTokenFromURI = () => {
const oldToken = window.localStorage.getItem("token")
if (oldToken) { return oldToken }
const hash = window.location.hash
if (!hash) { return '' }
const newToken = hash.substring(1).split("&").find(elem => elem.startsWith("access_token")).split("=")[1]
return newToken
}
useEffect(() => {
setToken(getTokenFromURI())
window.location.hash = ""
window.localStorage.setItem("token", token)
}, [])
// ...
}
export default App
In short, we are looking at the URI, defining the constant hash to be the substring of the URI after the “#”. If we have a “#” in the URI and we do not already have a previous token, we decode hash and update our state variable token. By placing this function in a useEffect hook with no dependencies, we ensure that this only runs once upon opening the page rather than on every frame. If you want to read up more about the useState and useEffect hooks, you can look at React’s official documentation here.
Since we have a state variable for the user’s Spotify token, we effectively have a way of telling that the user is properly authenticated. Using this, we can create two separate views for the user based on whether or not they are logged in. To implement this, we modify the App.js code to include the following:
// ...
import Navbar from "./Navbar"
const App = () => {
// ...
if (token === '') {
return (
// Can replace with more complex login screen component
<LoginButton />
)
}
return (
<div>
<Navbar />
<MusicList />
</div>
)
}
export default App
We also need to make the Navbar.js component accordingly. For the sake of this blogpost, we will make a very simple Navbar component with the bare minimum amount of information onscreen. If you want to learn more about CSS styling and how to implement good front-end web development practices, you can read more in freeCodeCamp’s post on styling React Apps here.
If you want to see what a fully developed version of this project could look like, navigate here.
Before we build our Navbar component, we need to declare the following state variables and pass them into our other components as props in our App.js file:
// Imports
const App = () => {
// ...
const [trackList, setTrackList] = useState([])
const [artistList, setArtistList] = useState([])
const [recList, setRecList] = useState([])
const [searchType, setSearchType] = useState('tracks')
const [searchLength, setSearchLength] = useState('short_term')
// ...
return (
<div>
<Navbar
setSearchType = {setSearchType}
setSearchLength = {setSearchLength}
setToken = {setToken}
/>
</div>
)
}
export default App
Our bare bones Navbar.js component will include the following features:
- Dropdown to choose between Tracks, Artists, and Recommendations, which will be the 3 main features of the website
- Dropdown to choose one of Short Term, Medium Term, and Long Term, which will determine the time range which Spotify will look at
- Log Out Button
We can initialize these 3 features and set up their functionality with our previously declared state variables using the following code:
import React from 'react'
const Navbar = (props) => {
return (
<div>
<select onChange={e => props.setSearchType(e.target.value)}>
<option value="tracks"> Tracks </option>
<option value="artists"> Artists </option>
<option value="recommendations"> Recommendations </option>
</select>
<select onChange={e => props.setSearchLength(e.target.value)}>
<option value="short_term"> Short Term </option>
<option value="medium_term"> Medium Term </option>
<option value="long_term"> Long Term </option>
</select>
<button> Log Out </button>
</div>
)
}
export default Navbar
Setting up our Server to Process API Calls
Before we can display any of the user’s data onscreen, we need to be able to process API calls for Spotify and OpenAI. To do this, we are going to set up a Node.js Express server so that we can perform these calls securely.
If you have never worked with Node.js before or do not understand what setting up a web server means, Fireship has an extremely easy to follow and informative video explaining how to set up your first server step-by-step that you can check out here.
Outside of the src folder in our project, we will create a new folder for our server. Inside our terminal, we will navigate to this folder and run the following commands to initialize our project and install all the dependencies which we will use:
npm init -y
npm i axios cors dotenv express openai
At this point you will need to get your own personal API key from OpenAI. You can do this here. You will need to enable billing on your OpenAI account and will get charged for the API calls you make. However, the price for making a call to gpt-3.5-turbo, which is the model we will be using is a fraction of a cent per API call so you do not need to worry about being billed a significant amount of money.
Once you have this API key, you can copy and paste in a .env file inside your server folder. This allows your server.js file to access the information but will not display the information publicly if you do decide to deploy your website.
Your .env file should look as follows:
OPENAI_API_KEY = "YOUR-OPENAI-API-KEY-HERE"
Our server.js file will look as follows:
require("dotenv").config();
const cors = require('cors');
const { Configuration, OpenAIApi } = require("openai");
const axios = require("axios");
const express = require("express");
const app = express();
app.use(cors());
app.use(express.json());
const port = process.env.PORT || 4000;
const configuration = new Configuration({ apiKey: process.env.OPENAI_API_KEY });
const openai = new OpenAIApi(configuration);
// ASK-OPEN-AI -- MAKE REQUEST TO CHATGPT API
// PARAMETERS:
// - prompt (required): Prompt which will be sent to GPT3.5-Turbo
app.get('/ask-open-ai', async (req, res) => {
const prompt = req.query.prompt;
try {
if (prompt == null) {
throw new Error("No prompt provided.");
}
const response = await openai.createChatCompletion({
model: "gpt-3.5-turbo",
messages: [
{
role: "assistant",
content: prompt
},
],
});
const completion = response.data.choices[0].message.content;
return res.status(200).json({
success: true,
message: completion,
});
} catch (error) {
return res.status(400).json({
success: false,
message: error.message
});
}
});
// GET-TOP-SPOTIFY -- GET USER MOST LISTENED TO DATA
// PARAMETERS:
// - search_type (required): Search type used in API call (must be 'tracks' or 'artists)
// - access_token (required): User Spotify access token to allow us to look at their account data
// - time_range (required): Length of search used in Spotify API call(must be 'short_term', 'medium_term', or 'long_term')
// - offset (optional): Index from which to start getting top 50 data (we will use offset 49 to get user's 51-99) (Default Value: 0)
app.get('/get-top-spotify', async (req, res) => {
const search_type = req.query.search_type;
const access_token = req.query.access_token;
const time_range = req.query.time_range;
const offset = req.query.offset;
try {
if (access_token == null) {
throw new Error("No access token provided.");
}
if (search_type !== 'tracks' && search_type !== 'artists') {
throw new Error("Invalid search query provided.");
}
if (time_range !== 'short_term' && time_range !== 'medium_term' && time_range !== 'long_term') {
throw new Error("Invalid time range provided.");
}
if (offset == null) {
offset = 0;
}
const response = await axios.get(`https://api.spotify.com/v1/me/top/${search_type}?`, {
headers: {
Authorization: `Bearer ${access_token}`
},
params: {
limit: 50,
offset: offset,
time_range: time_range
}
});
completion = response.data.items
return res.status(200).json({
success: true,
message: completion,
});
} catch (error) {
console.log(error.message)
return res.status(400).json({
success: false,
message: error.message
})
}
})
// SEARCH-SPOTIFY -- GET MOST RELEVANT TRACK OBJECT FROM SPOTIFY BASED ON SEARCH QUERY
// PARAMETERS:
// - search_query (required): Query used to find track (Similar to searching on Spotify App)
// - access_token (required): User Spotify access token to allow us to make Spotify searches
app.get('/search-spotify', async (req, res) => {
const search_query = req.query.search_query;
const access_token = req.query.access_token;
try {
if (access_token == null) {
throw new Error("No access token provided.");
}
if (search_query == null) {
throw new Error("No search query provided.");
}
const response = await axios.get(`https://api.spotify.com/v1/search?q=${search_query}&type=track&limit=1`, {
headers: { Authorization: `Bearer ${access_token}` },
});
completion = response.data.tracks.items[0]
return res.status(200).json({
success: true,
message: completion,
});
} catch (error) {
return res.status(400).json({
success: false,
message: error.message
})
}
})
app.listen(port, () => console.log(`Server is running on port ${port}`));
To summarize, we have declared a web server which we can access locally at http://localhost:4000. Without getting into too much detail of how Express.js servers work, we have created the following 3 endpoints:
- ask-open-ai — allows us to pass in a search query and receive a response from GPT-3.5-Turbo
- get-top-spotify — allows us to access the user’s top 99 tracks or artists in short, medium, and long term
- search-spotify — allows us to pass in a search query and returns the most relevant song based on our search
Our web-server already has the necessary authentication information stored in its .env file, which allows us to make these calls securely. Although we are the only ones who will be using these endpoints, it is good practice to handle all edge cases, such as if someone makes an API call to our server without inputting a search query or does not provide their access token. In these cases, we output a result with error messages and report the error to make it easier to debug.
If you have never worked with Express.js servers before, I recommend to look over the code above and try to understand every line of code and its purpose. For the sake of this blogpost, we will move on.
To start your server, run the following command in terminal after navigating to your server folder.
node server.js
If everything is done correctly, you should see a message that “Server is running on port 4000”.
We can test that our endpoints are working by making an http request in our browser. Copy and paste the following link into your browser and verify you are seeing an appropriate response from GPT3.5:
http://localhost:4000/ask-open-ai?prompt='What is the most listened to track on Spotify of all time'
The response should look something like this:
We can not as easily test the Spotify endpoints since we need an access token from Spotify to use them, which will be easier to do in the context of our actual app, so we will move on.
Connecting to our Web Server’s Spotify Endpoint
Now that our server is fully set up, we can go back to our front-end and set up the code that makes these API calls and stores the important information from the data that we receive back.
In App.js, we add the following asynchronous function call:
const GetUserInfo = async (searchType, offset) => {
const response = await axios.get(`${PORT}/get-top-spotify`, {
params: {
search_type: searchType,
access_token: token,
time_range: searchLength,
offset: offset
}
})
return response.data.message;
}
This function takes in a searchType (either ‘tracks’ or ‘artists’) and an offset value and returns a list of 50 Track or Artist objects with the corresponding information.
Then, we need the following function that will decode the information returned from this function.
const UnwrapSpotifyData = (items, itemType) => {
try {
if (itemType === 'artists') {
return items.map(artist => (
{ name: artist.name, picture: artist.images[0].url, link: artist.external_urls.spotify }
))
}
return items.map(track => (
{ name: track.name, artist: track.artists[0].name, picture: track.album.images[0].url, explicit: track.explicit, duration: track.duration_ms, link: track.external_urls.spotify }
))
} catch (error) {
console.log(error.message)
}
}
This takes every object returned by the API call and stores the name, picture URL, link, and other such features in a JavaScript object which we can access in our MusicList.js component.
Lastly, we want to invoke these functions for both artists and tracks each time the user changes their search length. And we want these functions to change our state variables that we have already declared. We can implement this using the following useEffect hook.
useEffect(() => {
const fetchData = async () => {
try {
const first50TracksResponse = await GetUserInfo('tracks', 0)
const last50TracksResponse = await GetUserInfo('tracks', 49)
const first50ArtistsResponse = await GetUserInfo('artists', 0)
const last50ArtistsResponse = await GetUserInfo('artists', 49)
const first50Tracks = UnwrapSpotifyData(first50TracksResponse, 'tracks')
const last50Tracks = UnwrapSpotifyData(last50TracksResponse, 'tracks').slice(1)
const first50Artists = UnwrapSpotifyData(first50ArtistsResponse, 'artists')
const last50Artists = UnwrapSpotifyData(last50ArtistsResponse, 'artists').slice(1)
setTrackList([...first50Tracks, ...last50Tracks]);
setArtistList([...first50Artists, ...last50Artists]);
} catch (error) {
console.error('Error fetching data:', error);
}
}
fetchData();
}, [searchLength]);
(Note: We slice off the first element in each of the last50Artists/Tracks arrays so that we do not repeat the 50th ranked track/artist twice in our list)
This invokes both of the functions we just created 4 times and stores the JS objects in our trackList and artistList state variables. Because we put searchLength in the dependency array, these functions will be called every time the user changes the search length through the dropdown.
The last step to have a working website is to finally make our MusicList.js component. We will pass it the following prop to it in App.js:
const App = () => {
// ...
return (
<div>
<Navbar
setSearchType = {setSearchType}
setSearchLength = {setSearchLength}
setToken = {setToken}
/>
<MusicList
listInfo = {searchType == 'tracks' ? trackList : artistList}
/>
</div>
)
}
export default App
This ternary operator gives it the appropriate list based on our searchType state variable. Our bare bones MusicList.js file will only display the name of the artist/track, the artist’s name (if the item is a track), and each item’s ranking. However, with the information we can gather from the API, we can potentially display an explicit symbol for explicit songs, the album cover, the link to the song on Spotify, as well as various other Spotify metrics like song popularity. Spotify API offers many possibilities.
Our simplified MusicList.js file looks like this:
import React from 'react'
const MusicList = (props) => {
return (
props.listInfo.map((item, index) =>
<div> {index + 1}. {item.name} {item.artist ? ` --- ${item.artist}` : ''}</div>
)
)
}
export default MusicList
Lastly, anytime we are working with asynchronous API calls, it is good practice to provide the user some sort of feedback to know that the website is responsive. This will especially apply with our API calls to OpenAI which can take up to 30 seconds sometimes. To do this, we will create a state variable that displays a spinner when set to true. This will show the user that something is happening when they click the various dropdowns and buttons.
We implement this by doing the following:
- Create a Navbar.css file and add the following code:
.spinner {
display: grid;
width: 15px;
height: 15px;
border: 2px solid #33CC33;
border-top-color: transparent;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
2. Create the isLoading state variable in App.js and pass it as a prop into our Navbar component:
// Imports
const App = () => {
// ...
const [isLoading, setIsLoading] = useState(false)
// ...
return (
<div>
<Navbar
setSearchType = {setSearchType}
setSearchLength = {setSearchLength}
setToken = {setToken}
isLoading = {isLoading}
/>
// ...
</div>
)
}
export default App
3. In Navbar.js import the CSS file and conditionally display the spinner:
// Imports
import './Navbar.css'
const Navbar = (props) => {
return (
<div>
// ...
{ props.isLoading && <div className="spinner"></div> }
</div>
)
}
export default Navbar
4. In App.js, update isLoading state variable whenever we make API calls:
useEffect(() => {
const fetchData = async () => {
try {
setIsLoading(true)
const first50TracksResponse = await GetUserInfo('tracks', 0)
// ...
setArtistList([...first50Artists, ...last50Artists]);
setIsLoading(false)
} catch (error) {
console.error('Error fetching data:', error);
setIsLoading(false)
}
}
fetchData();
}, [searchLength]);
With that, we have fully finished the Spotify portion of the website. We can now successfully access the user’s top tracks and artists for all 3 search lengths. Now we can begin to connect this information with GPT3.5-Turbo and start making recommendations.
Connecting to our Web Server’s Open AI Endpoint
Although OpenAI claims to not store any of its data it collects through API calls, it is important to not give the user’s information to other third party sources without first asking for permission. We will do this in the form of a button when the user opens the recommendation tab. By default, we will not send any of the user’s data to OpenAI. The user will have to manually press the button (i.e: giving permission) each time they want to get more recommendations from GPT3.5-Turbo.
We start by creating a new RecList.js component initialized to this:
import React from 'react'
import axios from 'axios'
const RecList = (props) => {
return (
<div>RecList</div>
)
}
export default RecList
We will also make the necessary imports and conditional statements to display this component if our searchType state variable is set to ‘recommendations’. We will also pass in the necessary props.
// ...
import RecList from './RecList'
const App = () => {
// ...
return (
<div>
<Navbar
setSearchType = {setSearchType}
setSearchLength = {setSearchLength}
setToken = {setToken}
/>
{ (searchType === 'recommendations') &&
<RecList
recList = {recList}
setRecList = {setRecList}
trackList = {trackList}
isLoading = {isLoading}
setIsLoading = {setIsLoading}
token = {token}
/>
}
{ (searchType !== 'recommendations') &&
<MusicList
listInfo = {searchType == 'tracks' ? trackList : artistList}
/>
}
</div>
)
}
export default App
Next, we need to set up our basic RecList.js component as follows:
import React from 'react'
import axios from 'axios'
const RecList = (props) => {
const PORT = 'http://localhost:4000'
const getRecList = async (iter = 0) => {
// Will fill in next step
}
return (
<div>
{ props.recList.map((item, index) =>
<div> {index + 1}. {item.name} {item.artist ? ` --- ${item.artist}` : ''}</div>
)}
<button onClick={() => getRecList()}>Get Recommendations</button>
</div>
)
}
export default RecList
It has 1 function, which will make all the necessary API calls to get the information from OpenAI and Spotify and has a list as well as a button to allow the user to opt in to sending their information to OpenAI. Once the user presses the button, the OpenAI API call is called.
To perform these API calls, we do the following:
const getRecList = async (iter = 0) => {
const MAX_ITERATIONS = 3
if (props.trackList.length === 0 || iter >= MAX_ITERATIONS) { return }
try {
props.setIsLoading(true);
const currSongList = props.trackList.map(song => `${song.name}--${song.artist}`).join('\n');
const response = await axios.get(`${PORT}/ask-open-ai`, {
params: {
prompt: `You are a music recommendation service used to give recommendations to users after being given their most listened to songs.
Your task is to suggest 25 songs similar to the 100 songs that the user has been listening to the most.
Your suggestions should include music of similar genres and ideally feature songs by similar artists that the user has been listening to.
Here is the list of 100 songs that the user has been listening to the most:
${currSongList}
Please omit any introductions and format your respond as a simple list of songs separated by newlines.
Sample response:
"""
song1--artist1
song2--artist2
song3--artist3
.....
"""
Do NOT provide a numbered list.`
}
})
const suggestedSongsRawList = response.data.message.split('\n')
if (suggestedSongsRawList.length < 20) {
getRecList(iter+1)
return
}
for (const song of suggestedSongsRawList) {
const response = await axios.get(`${PORT}/search-spotify`, {
params: {
search_query: song,
access_token: props.token
}
})
const track = response.data.message
const songItem = { name: track.name, artist: track.artists[0].name, picture: track.album.images[0].url, duration: track.duration_ms, link: track.external_urls.spotify };
props.setRecList(prevList => [...prevList, songItem]);
}
props.setIsLoading(false);
} catch (error) {
console.log(error.message)
if (props.recList.length === 0) {
getRecList(iter + 1)
}
}
}
Essentially our function attempts to make the API call to OpenAI’s GPT3.5-turbo model and gives it the user’s current trackList information to look for recommendations. If GPT3.5-turbo’s response is not correctly formatted or any obvious errors are found, the function is called again. This can repeat up to MAX_ITERATIONS (3) times.
Each track-artist pair is then sent to our webserver’s search-spotify endpoint which essentially acts like Spotify’s search bar. We pick the first song that shows up from our search and add it to our recList variable. At this point, your website should be giving you custom recommendations everytime you click the Get Recommendations button.
If everything is done correctly, your final product should look like the following: