diff --git a/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/controller/RecommendationsController.java b/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/controller/RecommendationsController.java index bb7f578d2..fc79af0ec 100644 --- a/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/controller/RecommendationsController.java +++ b/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/controller/RecommendationsController.java @@ -12,33 +12,48 @@ @Slf4j @RestController @RequestMapping("/recommend") +@CrossOrigin(origins = "*") public class RecommendationsController { @Autowired private RecommendationsService recommendationsService; - @PostMapping("/") - public ResponseEntity getRecommendation(@RequestBody GetRecommendationsDto getRecommendationsDto) { + @PostMapping + public ResponseEntity getRecommendation(@RequestBody(required = false) GetRecommendationsDto getRecommendationsDto) { try { - log.info("(getRecommendation) getRecommendationsDto = " + getRecommendationsDto.toString()); + log.info("Getting recommendations with DTO: {}", getRecommendationsDto); + + // If no DTO provided or no seeds set, use default values + if (getRecommendationsDto == null || + (getRecommendationsDto.getSeedArtistId() == null && + getRecommendationsDto.getSeedTrack() == null && + getRecommendationsDto.getSeedGenres() == null)) { + + getRecommendationsDto = new GetRecommendationsDto(); + getRecommendationsDto.setAmount(10); + // Using Tame Impala as default seed artist + getRecommendationsDto.setSeedArtistId("4NHQUGzhtTLFvgF5SZesLK"); + // Adding a default genre as well for better recommendations + getRecommendationsDto.setSeedGenres("alternative,indie"); + } + Recommendations recommendations = recommendationsService.getRecommendation(getRecommendationsDto); - return ResponseEntity.status(HttpStatus.OK).body(recommendations); + return ResponseEntity.ok(recommendations); } catch (Exception e) { - log.error("getRecommendation error : " + e); - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e.getMessage()); + log.error("Error getting recommendations: ", e); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(null); } } @GetMapping("/playlist/{playListId}") - public ResponseEntity getRecommendationWithPlayList(@PathVariable("playListId") String playListId) { + public ResponseEntity getRecommendationWithPlayList(@PathVariable("playListId") String playListId) { try { - log.info("(getRecommendationWithPlayList) playListId = " + playListId); + log.info("Getting recommendations for playlist: {}", playListId); Recommendations recommendations = recommendationsService.getRecommendationWithPlayList(playListId); - return ResponseEntity.status(HttpStatus.OK).body(recommendations); + return ResponseEntity.ok(recommendations); } catch (Exception e) { - log.error("getRecommendationWithPlayList error : " + e); - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e.getMessage()); + log.error("Error getting recommendations for playlist: ", e); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(null); } } - } \ No newline at end of file diff --git a/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/model/AudioFeatureAverages.java b/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/model/AudioFeatureAverages.java new file mode 100644 index 000000000..756574206 --- /dev/null +++ b/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/model/AudioFeatureAverages.java @@ -0,0 +1,15 @@ +package com.yen.SpotifyPlayList.model; + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class AudioFeatureAverages { + private double energy; + private double acousticness; + private double danceability; + private double instrumentalness; + private double liveness; + private double valence; +} \ No newline at end of file diff --git a/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/model/SpotifyRecommendationsResponse.java b/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/model/SpotifyRecommendationsResponse.java new file mode 100644 index 000000000..aa01d22ec --- /dev/null +++ b/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/model/SpotifyRecommendationsResponse.java @@ -0,0 +1,25 @@ +package com.yen.SpotifyPlayList.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Data; +import se.michaelthelin.spotify.model_objects.specification.Track; + +import java.util.List; + +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class SpotifyRecommendationsResponse { + private List tracks; + private List seeds; + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class RecommendationSeed { + private Integer afterFilteringSize; + private Integer afterRelinkingSize; + private String href; + private String id; + private Integer initialPoolSize; + private String type; + } +} \ No newline at end of file diff --git a/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/model/dto/GetRecommendationsWithFeatureDto.java b/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/model/dto/GetRecommendationsWithFeatureDto.java index 131e934a2..8defebe75 100644 --- a/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/model/dto/GetRecommendationsWithFeatureDto.java +++ b/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/model/dto/GetRecommendationsWithFeatureDto.java @@ -2,7 +2,9 @@ import com.neovisionaries.i18n.CountryCode; import lombok.Data; -import lombok.ToString; +import lombok.Builder; +import lombok.AllArgsConstructor; +import lombok.NoArgsConstructor; /** * { acousticness: 0.359, analysisUrl: @@ -12,22 +14,22 @@ * timeSignature: 4, trackHref: "https://api.spotify.com/v1/tracks/7FJC2pF6zMliU7Lvk0GBDV", type: * "AUDIO_FEATURES", uri: "spotify:track:7FJC2pF6zMliU7Lvk0GBDV", valence: 0.336 }, */ -@ToString @Data +@Builder +@NoArgsConstructor +@AllArgsConstructor public class GetRecommendationsWithFeatureDto { private int amount = 10; private CountryCode market = CountryCode.JP; private int maxPopularity = 100; private int minPopularity = 0; - private String seedArtistId; // e.g. : 0LcJLqbBmaGUft1e9Mm8HV - private String seedGenres; - private String seedTrack; // e.g. 01iyCAUm8EvOFqVWYJ3dVX - private int targetPopularity = 50; - private double danceability = 0; - private double energy = 0; - private double instrumentalness = 0; - private double liveness = 0; - private double loudness = 0; - private double speechiness = 0; - private double tempo = 0; + private String seedArtistIds; // Multiple artists, comma-separated + private String seedTracks; // Multiple tracks, comma-separated + private float danceability = 0.5f; + private float energy = 0.5f; + private float instrumentalness = 0.0f; + private float liveness = 0.0f; + private float acousticness = 0.5f; + private float valence = 0.5f; + private float tempo = 120.0f; } diff --git a/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/service/RecommendationsService.java b/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/service/RecommendationsService.java index caee4af00..88f1e0527 100644 --- a/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/service/RecommendationsService.java +++ b/springSpotifyPlayList/backend/SpotifyPlayList/src/main/java/com/yen/SpotifyPlayList/service/RecommendationsService.java @@ -1,168 +1,226 @@ package com.yen.SpotifyPlayList.service; +import com.yen.SpotifyPlayList.model.AudioFeatureAverages; +import com.yen.SpotifyPlayList.model.SpotifyRecommendationsResponse; import com.yen.SpotifyPlayList.model.dto.GetRecommendationsDto; import com.yen.SpotifyPlayList.model.dto.GetRecommendationsWithFeatureDto; import lombok.extern.slf4j.Slf4j; import org.apache.hc.core5.http.ParseException; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.*; import org.springframework.stereotype.Service; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; import se.michaelthelin.spotify.SpotifyApi; import se.michaelthelin.spotify.exceptions.SpotifyWebApiException; -import se.michaelthelin.spotify.model_objects.specification.ArtistSimplified; import se.michaelthelin.spotify.model_objects.specification.AudioFeatures; import se.michaelthelin.spotify.model_objects.specification.Recommendations; import se.michaelthelin.spotify.model_objects.specification.Track; import se.michaelthelin.spotify.requests.data.browse.GetRecommendationsRequest; import java.io.IOException; -import java.util.List; -import java.util.Random; +import java.util.*; import java.util.stream.Collectors; @Service @Slf4j public class RecommendationsService { - @Autowired private AuthService authService; + private static final String SPOTIFY_API_URL = "https://api.spotify.com/v1"; - @Autowired private PlayListService playListService; + @Autowired + private AuthService authService; - @Autowired private TrackService trackService; + @Autowired + private PlayListService playListService; - private SpotifyApi spotifyApi; + @Autowired + private TrackService trackService; - public RecommendationsService() {} + private SpotifyApi spotifyApi; + private final RestTemplate restTemplate; - public Recommendations getRecommendation(GetRecommendationsDto getRecommendationsDto) - throws SpotifyWebApiException { - try { - this.spotifyApi = authService.initializeSpotifyApi(); - GetRecommendationsRequest getRecommendationsRequest = - prepareRecommendationsRequest(getRecommendationsDto); - Recommendations recommendations = getRecommendationsRequest.execute(); - log.info("Fetched recommendations: {}", recommendations); - return recommendations; - } catch (IOException | SpotifyWebApiException | ParseException e) { - log.error("Error fetching recommendations: {}", e.getMessage()); - throw new SpotifyWebApiException("getRecommendation error: " + e.getMessage()); + public RecommendationsService() { + this.restTemplate = new RestTemplate(); } - } - - public Recommendations getRecommendationWithPlayList(String playListId) - throws SpotifyWebApiException { - try { - this.spotifyApi = authService.initializeSpotifyApi(); - List audioFeaturesList = playListService.getSongFeatureByPlayList(playListId); - log.debug(">>> audioFeaturesList = " + audioFeaturesList); - - // Use functional programming to calculate the averages and cast Double to float - // TODO : modify GetRecommendationsWithFeatureDto with attr as "float" type, and modify below - // code (e.g. : averagingDouble) - double energy = - audioFeaturesList.stream().collect(Collectors.averagingDouble(AudioFeatures::getEnergy)); - double acousticness = - audioFeaturesList.stream() - .collect(Collectors.averagingDouble(AudioFeatures::getAcousticness)); - double danceability = - audioFeaturesList.stream() - .collect(Collectors.averagingDouble(AudioFeatures::getDanceability)); - double liveness = - audioFeaturesList.stream() - .collect(Collectors.averagingDouble(AudioFeatures::getLiveness)); - double loudness = - audioFeaturesList.stream() - .collect(Collectors.averagingDouble(AudioFeatures::getLoudness)); - double speechiness = - audioFeaturesList.stream() - .collect(Collectors.averagingDouble(AudioFeatures::getSpeechiness)); - - GetRecommendationsWithFeatureDto featureDto = new GetRecommendationsWithFeatureDto(); - featureDto.setEnergy(energy); - // featureDto.setAcousticness(acousticness); - featureDto.setDanceability(danceability); - featureDto.setLiveness(liveness); - featureDto.setLoudness(loudness); - featureDto.setSpeechiness(speechiness); - - // TODO : get seed features from playList - //featureDto.setSeedArtistId("4sJCsXNYmUMeumUKVz4Abm"); - featureDto.setSeedArtistId(getRandomSeedArtistId(audioFeaturesList)); - featureDto.setSeedTrack(getRandomSeedTrackId(audioFeaturesList)); - - GetRecommendationsRequest getRecommendationsRequest = - prepareRecommendationsRequestWithPlayList(featureDto); - Recommendations recommendations = getRecommendationsRequest.execute(); - - return recommendations; - } catch (Exception e) { - log.error("Error fetching recommendations with playlist features: {}", e.getMessage()); - throw new SpotifyWebApiException("getRecommendationWithPlayList error: " + e.getMessage()); + + public Recommendations getRecommendation(GetRecommendationsDto getRecommendationsDto) + throws SpotifyWebApiException { + try { + this.spotifyApi = authService.initializeSpotifyApi(); + + // Validate seeds + boolean hasSeedArtist = getRecommendationsDto.getSeedArtistId() != null && !getRecommendationsDto.getSeedArtistId().isEmpty(); + boolean hasSeedGenres = getRecommendationsDto.getSeedGenres() != null && !getRecommendationsDto.getSeedGenres().isEmpty(); + boolean hasSeedTrack = getRecommendationsDto.getSeedTrack() != null && !getRecommendationsDto.getSeedTrack().isEmpty(); + + if (!hasSeedArtist && !hasSeedGenres && !hasSeedTrack) { + throw new SpotifyWebApiException("At least one seed (artist, genre, or track) is required"); + } + + // Build request using the library's builder + GetRecommendationsRequest.Builder requestBuilder = spotifyApi.getRecommendations() + .limit(getRecommendationsDto.getAmount()) + .market(getRecommendationsDto.getMarket()); + + // Add seeds + if (hasSeedArtist) { + requestBuilder.seed_artists(getRecommendationsDto.getSeedArtistId()); + log.info("Added seed artist: {}", getRecommendationsDto.getSeedArtistId()); + } + if (hasSeedGenres) { + requestBuilder.seed_genres(getRecommendationsDto.getSeedGenres()); + log.info("Added seed genres: {}", getRecommendationsDto.getSeedGenres()); + } + if (hasSeedTrack) { + requestBuilder.seed_tracks(getRecommendationsDto.getSeedTrack()); + log.info("Added seed track: {}", getRecommendationsDto.getSeedTrack()); + } + + // Add optional parameters + if (getRecommendationsDto.getMinPopularity() > 0) { + requestBuilder.min_popularity(getRecommendationsDto.getMinPopularity()); + } + if (getRecommendationsDto.getMaxPopularity() < 100) { + requestBuilder.max_popularity(getRecommendationsDto.getMaxPopularity()); + } + if (getRecommendationsDto.getTargetPopularity() > 0) { + requestBuilder.target_popularity(getRecommendationsDto.getTargetPopularity()); + } + + // Build and execute request + GetRecommendationsRequest request = requestBuilder.build(); + + log.info("Making request to Spotify API with access token: {}", spotifyApi.getAccessToken()); + log.info("Request URI: {}", request.getUri()); + + Recommendations recommendations = request.execute(); + log.info("Successfully fetched recommendations"); + + return recommendations; + } catch (IOException | ParseException e) { + log.error("Error making request to Spotify API: {}", e.getMessage(), e); + throw new SpotifyWebApiException("Error making request to Spotify API: " + e.getMessage()); + } catch (SpotifyWebApiException e) { + log.error("Spotify API error: {}", e.getMessage(), e); + throw e; + } catch (Exception e) { + log.error("Unexpected error: {}", e.getMessage(), e); + throw new SpotifyWebApiException("Unexpected error: " + e.getMessage()); + } + } + + public Recommendations getRecommendationWithPlayList(String playListId) + throws SpotifyWebApiException { + try { + this.spotifyApi = authService.initializeSpotifyApi(); + List audioFeaturesList = playListService.getSongFeatureByPlayList(playListId); + log.debug("Retrieved audio features for playlist {}: {} features", playListId, audioFeaturesList.size()); + + // Calculate average features + AudioFeatureAverages averages = calculateAudioFeatureAverages(audioFeaturesList); + log.debug("Calculated audio feature averages: {}", averages); + + // Get seed tracks and artists + List seedTracks = selectTopSeedTracks(audioFeaturesList, 2); + List seedArtists = selectTopSeedArtists(audioFeaturesList, 2); + + GetRecommendationsWithFeatureDto featureDto = createFeatureDto(averages, seedTracks, seedArtists); + + GetRecommendationsRequest request = prepareRecommendationsRequestWithPlayList(featureDto); + Recommendations recommendations = request.execute(); + log.info("Successfully generated recommendations based on playlist {}", playListId); + + return recommendations; + } catch (Exception e) { + log.error("Error fetching recommendations with playlist features: {}", e.getMessage(), e); + throw new SpotifyWebApiException("Failed to get recommendations: " + e.getMessage()); + } + } + + private AudioFeatureAverages calculateAudioFeatureAverages(List features) { + if (features == null || features.isEmpty()) { + throw new IllegalArgumentException("Audio features list cannot be null or empty"); + } + + return AudioFeatureAverages.builder() + .energy(features.stream().mapToDouble(AudioFeatures::getEnergy).average().orElse(0.5)) + .acousticness(features.stream().mapToDouble(AudioFeatures::getAcousticness).average().orElse(0.5)) + .danceability(features.stream().mapToDouble(AudioFeatures::getDanceability).average().orElse(0.5)) + .instrumentalness(features.stream().mapToDouble(AudioFeatures::getInstrumentalness).average().orElse(0.5)) + .liveness(features.stream().mapToDouble(AudioFeatures::getLiveness).average().orElse(0.5)) + .valence(features.stream().mapToDouble(AudioFeatures::getValence).average().orElse(0.5)) + .build(); + } + + private GetRecommendationsWithFeatureDto createFeatureDto(AudioFeatureAverages averages, + List seedTracks, + List seedArtists) { + return GetRecommendationsWithFeatureDto.builder() + .amount(10) + .seedTracks(String.join(",", seedTracks)) + .seedArtistIds(String.join(",", seedArtists)) + .danceability((float) averages.getDanceability()) + .energy((float) averages.getEnergy()) + .instrumentalness((float) averages.getInstrumentalness()) + .liveness((float) averages.getLiveness()) + .acousticness((float) averages.getAcousticness()) + .valence((float) averages.getValence()) + .build(); } - } - - private GetRecommendationsRequest prepareRecommendationsRequestWithPlayList( - GetRecommendationsWithFeatureDto featureDto) - throws IOException, SpotifyWebApiException, ParseException { - return spotifyApi - .getRecommendations() - .limit(featureDto.getAmount()) - .market(featureDto.getMarket()) - .max_popularity(featureDto.getMaxPopularity()) - .min_popularity(featureDto.getMinPopularity()) - .seed_artists(featureDto.getSeedArtistId()) - .seed_genres(featureDto.getSeedGenres()) - .seed_tracks(featureDto.getSeedTrack()) - // TODO : undo float cast once modify GetRecommendationsWithFeatureDto with attr as "float" type - .target_danceability((float) featureDto.getDanceability()) - .target_energy((float) featureDto.getEnergy()) - .target_instrumentalness((float) featureDto.getInstrumentalness()) - .target_liveness((float) featureDto.getLiveness()) - .target_loudness((float) featureDto.getLoudness()) - .target_speechiness((float) featureDto.getSpeechiness()) - .build(); - } - - private GetRecommendationsRequest prepareRecommendationsRequest(GetRecommendationsDto dto) { - return spotifyApi - .getRecommendations() - .limit(dto.getAmount()) - .market(dto.getMarket()) - .max_popularity(dto.getMaxPopularity()) - .min_popularity(dto.getMinPopularity()) - .seed_artists(dto.getSeedArtistId()) - .seed_genres(dto.getSeedGenres()) - .seed_tracks(dto.getSeedTrack()) - .target_popularity(dto.getTargetPopularity()) - .build(); - } - - private String getRandomSeedArtistId(List audioFeaturesList) { - if (audioFeaturesList == null || audioFeaturesList.size() == 0) { - throw new RuntimeException("getRandomSeedArtistId can not be null"); + + private List selectTopSeedTracks(List audioFeaturesList, int count) { + if (audioFeaturesList == null || audioFeaturesList.isEmpty()) { + throw new IllegalArgumentException("Audio features list cannot be null or empty"); + } + + return audioFeaturesList.stream() + .sorted(Comparator.comparingDouble(AudioFeatures::getEnergy).reversed()) + .limit(count) + .map(feature -> getTrackIdFromTrackUrl(feature.getTrackHref())) + .collect(Collectors.toList()); } - Random random = new Random(); - int randomInt = random.nextInt(audioFeaturesList.size()); - String trackId = audioFeaturesList.get(randomInt).getId(); - Track track = trackService.getTrackInfo(trackId); - log.info(">>> track = " + track); - return track.getArtists()[0].getId(); - } - - private String getRandomSeedTrackId(List audioFeaturesList) { - if (audioFeaturesList == null || audioFeaturesList.size() == 0) { - throw new RuntimeException("audioFeaturesList can not be null"); + + private List selectTopSeedArtists(List audioFeaturesList, int count) { + if (audioFeaturesList == null || audioFeaturesList.isEmpty()) { + throw new IllegalArgumentException("Audio features list cannot be null or empty"); + } + + Set uniqueArtists = new HashSet<>(); + return audioFeaturesList.stream() + .map(feature -> { + String trackId = feature.getId(); + Track track = trackService.getTrackInfo(trackId); + return track.getArtists()[0].getId(); + }) + .filter(uniqueArtists::add) // Only keep unique artists + .limit(count) + .collect(Collectors.toList()); } - Random random = new Random(); - int randomInt = random.nextInt(audioFeaturesList.size()); - String trackHref = audioFeaturesList.get(randomInt).getTrackHref(); - return getTrackIdFromTrackUrl(trackHref); - } - - private String getTrackIdFromTrackUrl(String trackUrl) { - if (trackUrl == null) { - throw new RuntimeException("trackUrl can not be null"); + + private GetRecommendationsRequest prepareRecommendationsRequestWithPlayList( + GetRecommendationsWithFeatureDto featureDto) { + return spotifyApi.getRecommendations() + .limit(featureDto.getAmount()) + .market(featureDto.getMarket()) + .max_popularity(featureDto.getMaxPopularity()) + .min_popularity(featureDto.getMinPopularity()) + .seed_artists(featureDto.getSeedArtistIds()) + .seed_tracks(featureDto.getSeedTracks()) + .target_danceability(featureDto.getDanceability()) + .target_energy(featureDto.getEnergy()) + .target_instrumentalness(featureDto.getInstrumentalness()) + .target_liveness(featureDto.getLiveness()) + .target_acousticness(featureDto.getAcousticness()) + .target_valence(featureDto.getValence()) + .build(); } - return trackUrl.split("tracks")[1].replace("/", ""); - } + private String getTrackIdFromTrackUrl(String trackUrl) { + if (trackUrl == null) { + throw new IllegalArgumentException("Track URL cannot be null"); + } + return trackUrl.split("tracks")[1].replace("/", ""); + } } \ No newline at end of file diff --git a/springSpotifyPlayList/doc/fix_recommend_api_ref.md b/springSpotifyPlayList/doc/fix_recommend_api_ref.md new file mode 100644 index 000000000..c46dec357 --- /dev/null +++ b/springSpotifyPlayList/doc/fix_recommend_api_ref.md @@ -0,0 +1,2 @@ +https://developer.spotify.com/blog/2024-11-27-changes-to-the-web-api +https://developer.spotify.com/documentation/web-api/reference/get-recommendations \ No newline at end of file