diff --git a/backend/API_DOCUMENTATION.md b/backend/API_DOCUMENTATION.md index 690ee97..64c3875 100644 --- a/backend/API_DOCUMENTATION.md +++ b/backend/API_DOCUMENTATION.md @@ -39,6 +39,8 @@ Authorization: Bearer #### Get Radio Station Queue +#### Get Recommended Queue + ## Radio Station Management #### Create Radio Station @@ -274,6 +276,39 @@ Retrieves the main song queue for a radio station. Returns a list of song object } ``` +#### Get Recommended Queue + +**Endpoint:** `GET /api/songs/queue/recommended?radioStationId={radioStationId}¤tSongId={currentSongId}` + +**Query Parameters:** + +- `radioStationId` (required): The ID of the radio station +- `currentSongId` (optional): The ID of the currently playing song for similarity analysis + +**Response:** + +```json +{ + "success": true, + "message": "Recommended queue retrieved successfully", + "data": [ + { + "id": "4iV5W9uYEdYUVa79Axb7Rh", + "popularity": 85, + "tempo": 120.5, + "audioFeatures": { + "danceability": 0.8, + "energy": 0.7, + "valence": 0.6, + "acousticness": 0.1, + "instrumentalness": 0.0, + "speechiness": 0.04 + } + } + ] +} +``` + ## Error Responses All error responses follow this format: diff --git a/backend/src/main/java/com/serena/backend/controller/SongController.java b/backend/src/main/java/com/serena/backend/controller/SongController.java index 403784b..3fd5c65 100644 --- a/backend/src/main/java/com/serena/backend/controller/SongController.java +++ b/backend/src/main/java/com/serena/backend/controller/SongController.java @@ -4,14 +4,17 @@ import com.serena.backend.dto.ApiResponse; import com.serena.backend.dto.AddSongToClientQueueRequest; import com.serena.backend.model.Song; import com.serena.backend.service.RadioStationService; +import com.serena.backend.service.QueueService; import com.serena.backend.service.JwtService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.*; +import java.util.List; import java.util.Optional; import java.util.Queue; +import java.util.ArrayList; @RestController @RequestMapping("/api/songs") @@ -21,6 +24,9 @@ public class SongController { @Autowired private RadioStationService radioStationService; + @Autowired + private QueueService queueService; + @Autowired private JwtService jwtService; @@ -53,4 +59,39 @@ public class SongController { .body(new ApiResponse<>(false, "Radio station not found", null)); } } + + @GetMapping("/queue/recommended") + public ResponseEntity>> getRecommendedQueue( + @RequestParam String radioStationId, + @RequestParam(required = false) String currentSongId) { + + Optional stationOpt = radioStationService.getRadioStation(radioStationId); + + if (stationOpt.isEmpty()) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(new ApiResponse<>(false, "Radio station not found", null)); + } + + com.serena.backend.model.RadioStation station = stationOpt.get(); + + List queueList = new ArrayList<>(station.getSongQueue()); + + if (queueList.isEmpty()) { + return ResponseEntity.ok(new ApiResponse<>(true, "Empty queue", queueList)); + } + + Song currentSong = null; + if (currentSongId != null && !currentSongId.isEmpty()) { + currentSong = queueList.stream() + .filter(song -> song.getId().equals(currentSongId)) + .findFirst() + .orElse(null); + } + + List clients = radioStationService.getConnectedClients(radioStationId); + + List sortedQueue = queueService.sortQueue(currentSong, queueList, clients); + + return ResponseEntity.ok(new ApiResponse<>(true, "Recommended queue retrieved successfully", sortedQueue)); + } } diff --git a/backend/src/main/java/com/serena/backend/model/Song.java b/backend/src/main/java/com/serena/backend/model/Song.java index 0e92555..88d378e 100644 --- a/backend/src/main/java/com/serena/backend/model/Song.java +++ b/backend/src/main/java/com/serena/backend/model/Song.java @@ -57,4 +57,28 @@ public class Song { public void setAudioFeatures(Map audioFeatures) { this.audioFeatures = audioFeatures; } + + public double getAcousticness() { + return audioFeatures.getOrDefault("acousticness", 0.0); + } + + public double getDanceability() { + return audioFeatures.getOrDefault("danceability", 0.0); + } + + public double getEnergy() { + return audioFeatures.getOrDefault("energy", 0.0); + } + + public double getInstrumentalness() { + return audioFeatures.getOrDefault("instrumentalness", 0.0); + } + + public double getSpeechiness() { + return audioFeatures.getOrDefault("speechiness", 0.0); + } + + public double getValence() { + return audioFeatures.getOrDefault("valence", 0.0); + } } diff --git a/backend/src/main/java/com/serena/backend/service/QueueService.java b/backend/src/main/java/com/serena/backend/service/QueueService.java new file mode 100644 index 0000000..de50171 --- /dev/null +++ b/backend/src/main/java/com/serena/backend/service/QueueService.java @@ -0,0 +1,182 @@ +package com.serena.backend.service; + +import com.serena.backend.model.Song; +import com.serena.backend.model.Client; +import org.springframework.stereotype.Service; + +import java.util.*; +import java.util.stream.Collectors; + +@Service +public class QueueService { + + private static final double W1_PREFERRED_QUEUE = 0.3; // preferred queue position + private static final double W2_POPULARITY = 0.2; // popularity + private static final double W3_TEMPO_SIMILARITY = 0.2; // tempo similarity + private static final double W4_AUDIO_FEATURES = 0.3; // audio feature distance + + public List sortQueue(Song currentSong, List queue, List clients) { + if (queue == null || queue.isEmpty()) { + return new ArrayList<>(); + } + + if (currentSong == null) { + return queue.stream() + .sorted((s1, s2) -> Integer.compare(s2.getPopularity(), s1.getPopularity())) + .collect(Collectors.toList()); + } + + Map songScores = new HashMap<>(); + + for (Song song : queue) { + double totalScore = calculateRecommendationScore(currentSong, song, clients, queue); + songScores.put(song, totalScore); + } + + return queue.stream() + .sorted((s1, s2) -> Double.compare(songScores.get(s2), songScores.get(s1))) + .collect(Collectors.toList()); + } + + private double calculateRecommendationScore(Song currentSong, Song song, List clients, List queue) { + double p1 = calculatePreferredQueueScore(song, clients); + double p2 = calculatePopularityScore(song); + double p3 = calculateTempoSimilarityScore(currentSong, song, queue); + double p4 = calculateAudioFeatureScore(currentSong, song); + + return W1_PREFERRED_QUEUE * p1 + W2_POPULARITY * p2 + W3_TEMPO_SIMILARITY * p3 + W4_AUDIO_FEATURES * p4; + } + + /** + * P1: Preferred Queue Position Score + * For each client, find the song's rank in their preferred queue and normalize. + */ + private double calculatePreferredQueueScore(Song song, List clients) { + if (clients == null || clients.isEmpty()) { + return 0.0; + } + + List clientScores = new ArrayList<>(); + + for (Client client : clients) { + Queue preferredQueue = client.getPreferredQueue(); + if (preferredQueue == null || preferredQueue.isEmpty()) { + continue; + } + + List preferredList = new ArrayList<>(preferredQueue); + int position = -1; + + for (int i = 0; i < preferredList.size(); i++) { + if (preferredList.get(i).getId().equals(song.getId())) { + position = i + 1; // 1-based position + break; + } + } + + if (position > 0) { + // Normalize: 1 = most preferred (position 1), approaches 0 for later positions + double normalizedScore = 1.0 - ((double) (position - 1) / preferredList.size()); + clientScores.add(normalizedScore); + } + } + + return clientScores.isEmpty() ? 0.0 : clientScores.stream().mapToDouble(Double::doubleValue).average().orElse(0.0); + } + + /** + * P2: Popularity Score + * Normalize popularity from 0-100 to 0.0-1.0 + */ + private double calculatePopularityScore(Song song) { + return song.getPopularity() / 100.0; + } + + /** + * P3: Tempo Similarity Score + * Calculate similarity based on tempo difference, normalized by max tempo. + */ + private double calculateTempoSimilarityScore(Song currentSong, Song song, List queue) { + double currentTempo = currentSong.getTempo(); + double songTempo = song.getTempo(); + + // Find max tempo among current song and queue + double maxTempo = Math.max(currentTempo, + queue.stream().mapToDouble(Song::getTempo).max().orElse(currentTempo)); + + if (maxTempo == 0) { + return 1.0; + } + + double tempoDistance = Math.abs(currentTempo - songTempo); + return 1.0 - (tempoDistance / maxTempo); + } + + /** + * P4: Audio Feature Distance Score + * Calculate Euclidean distance between audio features and invert for similarity + * score. + */ + private double calculateAudioFeatureScore(Song currentSong, Song song) { + double distanceSquared = 0.0; + int featureCount = 0; + + // Calculate Euclidean distance for each audio feature + double currentAcousticness = currentSong.getAcousticness(); + double songAcousticness = song.getAcousticness(); + distanceSquared += Math.pow(currentAcousticness - songAcousticness, 2); + featureCount++; + + double currentDanceability = currentSong.getDanceability(); + double songDanceability = song.getDanceability(); + distanceSquared += Math.pow(currentDanceability - songDanceability, 2); + featureCount++; + + double currentEnergy = currentSong.getEnergy(); + double songEnergy = song.getEnergy(); + distanceSquared += Math.pow(currentEnergy - songEnergy, 2); + featureCount++; + + double currentInstrumentalness = currentSong.getInstrumentalness(); + double songInstrumentalness = song.getInstrumentalness(); + distanceSquared += Math.pow(currentInstrumentalness - songInstrumentalness, 2); + featureCount++; + + double currentSpeechiness = currentSong.getSpeechiness(); + double songSpeechiness = song.getSpeechiness(); + distanceSquared += Math.pow(currentSpeechiness - songSpeechiness, 2); + featureCount++; + + double currentValence = currentSong.getValence(); + double songValence = song.getValence(); + distanceSquared += Math.pow(currentValence - songValence, 2); + featureCount++; + + if (featureCount == 0) { + return 0.0; + } + + double distance = Math.sqrt(distanceSquared); + + double maxDistance = Math.sqrt(featureCount); + double normalizedDistance = distance / maxDistance; + + return 1.0 - normalizedDistance; + } + + public static double getW1PreferredQueue() { + return W1_PREFERRED_QUEUE; + } + + public static double getW2Popularity() { + return W2_POPULARITY; + } + + public static double getW3TempoSimilarity() { + return W3_TEMPO_SIMILARITY; + } + + public static double getW4AudioFeatures() { + return W4_AUDIO_FEATURES; + } +}