Skip to content

Commit

Permalink
Merge pull request #82 from fac30/spotify
Browse files Browse the repository at this point in the history
Spotify interface integration
  • Loading branch information
maxitect authored Sep 27, 2024
2 parents 6f95932 + ba43be5 commit bd6b78b
Show file tree
Hide file tree
Showing 2 changed files with 239 additions and 298 deletions.
282 changes: 239 additions & 43 deletions src/controllers/spotify/index.ts
Original file line number Diff line number Diff line change
@@ -1,67 +1,263 @@
import { SpotifyApi } from "@spotify/web-api-ts-sdk";
import SpotifyWebApi from "spotify-web-api-node";
import * as dotenv from "dotenv";
import { spotifyResponse, track } from "../../types/spotifyResponse.js";
import { spotifyQuery, spotifyFeatures } from "../../types/spotifyQuery.js";

dotenv.config();

// Access the client ID and secret from the .env file
const clientId = process.env.SPOTIFY_CLIENT_ID;
const clientSecret = process.env.SPOTIFY_CLIENT_SECRET;
/*General explanation
This script builds upon the original index.ts that searches for tracks based on genre.
The original script effectively establishes the foundation for making API calls and processing responses.
Here, we are adding to that foundation by a date range (by year) search criteria and making a subsequent
API call to retrieve audio features, then filtering them and sorting them based on the intended audio
feature results we have targeted. We are making 6 API calls of 50 (API limit) to get a pool of 300 tracks
to filer and sort for audio filters in order to get tracks that more or less meet our criteria. We are
prioritising valence and energy audio features and then danceability, tempo and acousticness (in that order).
The playlist being output produced duplicate songs or many songs by the same artists so we are
also filtering the results so we only keep the highest scoring song by a certain artist.
*/

// Initialize the Spotify SDK using client credentials
const sdk = SpotifyApi.withClientCredentials(clientId, clientSecret);
/*spotify-web-api-node module instead of @spotify/web-api-ts-sdk - why?
In order to perform more custom queries, we can use spotify-web-api-node module which allows us to build
a custom query string (find the searchQuery string variable to see how this is built) and use it to search the
API using the spotifyApi.searchTracks function.
*/

// Define an async function to search tracks based on genre
async function searchGenre(query: spotifyQuery) {
/*Typescript
We also utilize TypeScript's typing system to define clear interfaces for our data structures.
*/

// Accessing the client ID and secret from the .env file
const clientId = process.env.SPOTIFY_CLIENT_ID!;
const clientSecret = process.env.SPOTIFY_CLIENT_SECRET!;

// Initializing the Spotify API client with your credentials
const spotifyApi = new SpotifyWebApi({
clientId: clientId,
clientSecret: clientSecret,
});

// Function to obtain and set an access token
async function getAccessToken() {
const data = await spotifyApi.clientCredentialsGrant(); // Getting an access token using client credentials
spotifyApi.setAccessToken(data.body["access_token"]); // Setting the access token for future API requests
}

// Helper function to calculate the absolute difference between two numbers
function calculateFeatureDifference(a: number, b: number) {
return Math.abs(a - b);
}

// Helper function to fetch audio features for tracks in batches - this is effectively a subsequent API call to retrieve
// the audio feature settings of each song we collected from the previous 6 API calls
async function getAudioFeaturesInBatches(
trackIds: string[],
spotifyApi: SpotifyWebApi,
): Promise<SpotifyApi.AudioFeaturesObject[]> {
const audioFeatures: SpotifyApi.AudioFeaturesObject[] = [];
const batchSize = 100; // Spotify API allows up to 100 track IDs per request

// Looping through the track IDs in batches to comply with API limitations
for (let i = 0; i < trackIds.length; i += batchSize) {
const batch = trackIds.slice(i, i + batchSize); // Creating a batch of track IDs
const response = await spotifyApi.getAudioFeaturesForTracks(batch); // Fetching audio features for the batch
if (response.body.audio_features) {
audioFeatures.push(...response.body.audio_features); // Accumulating the audio features
}
}

return audioFeatures; // Returning all collected audio features
}

// Main function to search tracks based on genre, date range, and audio features
async function generatePlaylist(query: spotifyQuery) {
try {
const playlist: spotifyResponse = [];
const genre = query.genre;
console.log(`Searching for tracks in the genre: ${genre}`);

// Search for tracks by genre
const searchResults = await sdk.search(`genre:${genre}`, ["track"]);

// Check if any tracks are found
if (searchResults.tracks && searchResults.tracks.items.length > 0) {
searchResults.tracks.items.forEach((track, index) => {
const song: track = {
title: track.name,
artist: track.artists[0].name,
album: track.album.name,
releaseDate: new Date(track.album.release_date),
duration: track.duration_ms,
};

playlist.push(song);
console.log(track);
console.log(`${index + 1}. ${track.name} by ${track.artists[0].name}`);
await getAccessToken(); // Ensuring we have a valid access token before making API calls

const { genre, date, spotifyFeatures } = query; // Destructuring the query object

// Calculating the start and end dates for the 12-month range prior to the given date
const endDate = new Date(date);
const startDate = new Date(date);
startDate.setFullYear(startDate.getFullYear() - 1); // Subtracting one year to get the start date

const startYear = startDate.getFullYear();
const endYear = endDate.getFullYear();

// Creating the search query string to include genre and year range
const searchQuery = `genre:${genre} year:${startYear}-${endYear}`;

let allTracks: SpotifyApi.TrackObjectFull[] = [];

// Making multiple API calls to gather a larger set of tracks
// This expands on the original script's effective method of fetching tracks by genre
for (let i = 0; i < 6; i++) {
// Making 6 API calls to get 300 tracks in total
const searchResults = await spotifyApi.searchTracks(searchQuery, {
limit: 50,
offset: i * 50, // Using offset to fetch different sets of tracks
});
const tracks = searchResults.body.tracks?.items;
if (tracks && tracks.length > 0) {
allTracks = allTracks.concat(tracks); // Accumulating all tracks into one array
}
}

if (allTracks.length > 0) {
console.log(
`Found ${allTracks.length} tracks in the genre '${genre}' between ${startYear} and ${endYear}:`,
);

const trackIds = allTracks.map((track) => track.id); // Extracting track IDs for audio feature requests

// Fetching audio features for all tracks in batches
const audioFeatures = await getAudioFeaturesInBatches(
trackIds,
spotifyApi,
);

// Sorting tracks based on how closely they match the desired audio features
const sortedTracks = audioFeatures
?.filter((feature) => feature) // Filtering out any undefined features
.map((feature) => {
if (!feature) return null;

// Calculating differences between each track's features and the target features
return {
feature,
differences: {
valence: calculateFeatureDifference(
feature.valence,
spotifyFeatures.valence,
),
energy: calculateFeatureDifference(
feature.energy,
spotifyFeatures.energy,
),
danceability: calculateFeatureDifference(
feature.danceability,
spotifyFeatures.danceability,
),
tempo: calculateFeatureDifference(
feature.tempo,
spotifyFeatures.tempo,
),
acousticness: calculateFeatureDifference(
feature.acousticness,
spotifyFeatures.acousticness,
),
},
};
})
.filter(
(
item,
): item is {
feature: SpotifyApi.AudioFeaturesObject;
differences: spotifyFeatures;
} => item !== null,
)
.sort((a, b) => {
// Prioritizing valence and energy equally, then danceability, tempo, and acousticness
const valenceEnergyDifference =
a.differences.valence +
a.differences.energy -
(b.differences.valence + b.differences.energy);
if (valenceEnergyDifference !== 0) return valenceEnergyDifference;
return (
a.differences.danceability - b.differences.danceability ||
a.differences.tempo - b.differences.tempo ||
a.differences.acousticness - b.differences.acousticness
);
});

// Removing duplicate artists to ensure diversity in the playlist
const uniqueArtistTracks = sortedTracks.filter((item, index, self) => {
const track = allTracks.find((t) => t.id === item.feature.id);
if (!track) return false;
const artistName = track.artists[0].name;
// Keeping only the first occurrence of each artist
return (
index ===
self.findIndex((t) => {
const otherTrack = allTracks.find((t2) => t2.id === t.feature.id);
return otherTrack?.artists[0].name === artistName;
})
);
});
console.log(Object.keys(searchResults.tracks.items[0].album));

// Selecting the top 10 tracks that best match the criteria
const topTracks = uniqueArtistTracks.slice(0, 10);
const playlist: spotifyResponse = [];

if (topTracks.length > 0) {
console.log(
`Returning ${topTracks.length} tracks matching audio feature criteria:`,
);

// Displaying detailed information about each selected track
topTracks.forEach((item, index) => {
const track = allTracks.find((t) => t.id === item.feature.id);
if (track) {
const releaseDate = track.album.release_date;
console.log(
`${index + 1}. ${track.name} by ${track.artists[0].name}, Release Date: ${releaseDate}`,
);
console.log(
` Valence: ${item.feature.valence.toFixed(2)} (Target: ${spotifyFeatures.valence})`,
);
console.log(
` Energy: ${item.feature.energy.toFixed(2)} (Target: ${spotifyFeatures.energy})`,
);
console.log(
` Danceability: ${item.feature.danceability.toFixed(2)} (Target: ${spotifyFeatures.danceability})`,
);
console.log(
` Acousticness: ${item.feature.acousticness.toFixed(2)} (Target: ${spotifyFeatures.acousticness})`,
);
console.log(
` Tempo: ${item.feature.tempo.toFixed(2)} BPM (Target: ${spotifyFeatures.tempo})`,
);
}
const song: track = {
title: track.name,
artist: track.artists[0].name,
album: track.album.name,
releaseDate: new Date(track.album.release_date),
duration: track.duration_ms,
};
playlist.push(song);
});

} else {
console.log("No tracks found matching the audio feature criteria.");
}
console.log(playlist);
return playlist; // Returning the top tracks
} else {
console.log(`No tracks found for the genre: ${genre}`);
console.log(
`No tracks found in the genre '${genre}' between ${startYear} and ${endYear}`,
);
}

return searchResults.tracks?.items;
} catch (error) {
console.error("Error fetching tracks by genre:", error);
console.error("Error searching for tracks:", error);
}
}
//dummy Object

// Example usage:
// Creating a query object with desired genre, date, and audio features
const query: spotifyQuery = {
mood: "sad",
genre: "jazz",
date: new Date("2024-09-23T19:00:00.000z"),
mood: "jazzy",
date: new Date("2024-09-23T19:00:00.000Z"),
spotifyFeatures: {
valence: 0.9,
energy: 0.8,
danceability: 0.8,
acousticness: 0.1,
valence: 0.8,
energy: 0.7,
danceability: 0.6,
acousticness: 0.2,
tempo: 120,
},
};

// Call the function with a specific genre (e.g., jazz)
searchGenre(query);
// Calling the function with the query object
generatePlaylist(query);
Loading

0 comments on commit bd6b78b

Please sign in to comment.