diff --git a/backend/API_DOCUMENTATION.md b/backend/API_DOCUMENTATION.md index 7f5482d..64c3875 100644 --- a/backend/API_DOCUMENTATION.md +++ b/backend/API_DOCUMENTATION.md @@ -23,6 +23,28 @@ Authorization: Bearer #### Create Radio Station +#### Get All Radio Stations + +#### Get Radio Station by ID + +#### Delete Radio Station + +### Client Management + +#### Connect Client to Station + +### Song Queue Management + +#### Add Song to Client's Preferred Queue + +#### Get Radio Station Queue + +#### Get Recommended Queue + +## Radio Station Management + +#### Create Radio Station + Creates a new radio station and returns an owner token. **Endpoint:** `POST /radio-stations` @@ -170,6 +192,123 @@ Connects a client to a radio station using a join code. No authentication requir } ``` +### Song Queue Management + +#### Add Song to Client's Preferred Queue + +Adds a song to a specific client's preferred queue. The frontend can merge track and audio features objects using `const merged = { ...trackObj, ...audioFeaturesObj };` + +**Endpoint:** `POST /api/songs/queue` + +**Request Body:** + +```json +{ + "song": { + "id": "4iV5W9uYEdYUVa79Axb7Rh", + "popularity": 85, + "tempo": 120.5, + "danceability": 0.8, + "energy": 0.7, + "valence": 0.6, + "acousticness": 0.1, + "instrumentalness": 0.0, + "liveness": 0.1, + "speechiness": 0.04 + }, + "clientId": "client-uuid", + "radioStationId": "station-uuid" +} +``` + +**Response:** + +```json +{ + "success": true, + "message": "Song added to client's preferred queue successfully", + "data": null +} +``` + +#### Get Radio Station Queue + +Retrieves the main song queue for a radio station. Returns a list of song objects. + +**Endpoint:** `GET /api/songs/queue?radioStationId={radioStationId}` + +**Query Parameters:** + +- `radioStationId` (required): The ID of the radio station + +**Response:** + +```json +{ + "success": true, + "message": "Queue retrieved successfully", + "data": [ + { + "id": "4iV5W9uYEdYUVa79Axb7Rh", + "popularity": 85, + "tempo": 120.5, + "danceability": 0.8, + "energy": 0.7, + "valence": 0.6, + "acousticness": 0.1, + "instrumentalness": 0.0, + "liveness": 0.1, + "speechiness": 0.04 + }, + { + "id": "7ouMYWpwJ422jRcDASZB7P", + "popularity": 78, + "tempo": 95.0, + "danceability": 0.6, + "energy": 0.5, + "valence": 0.8, + "acousticness": 0.3, + "instrumentalness": 0.0, + "liveness": 0.2, + "speechiness": 0.06 + } + ] +} +``` + +#### 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: @@ -213,3 +352,71 @@ curl -X GET "http://localhost:8080/api/radio-stations" \ curl -X DELETE http://localhost:8080/api/radio-stations/{stationId} \ -H "Authorization: Bearer {ownerToken}" ``` + +### Adding a Song to Client's Preferred Queue + +```bash +curl -X POST http://localhost:8080/api/songs/queue \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer {clientToken}" \ + -d '{ + "song": { + "id": "4iV5W9uYEdYUVa79Axb7Rh", + "popularity": 85, + "tempo": 120.5, + "danceability": 0.8, + "energy": 0.7, + "valence": 0.6, + "acousticness": 0.1, + "instrumentalness": 0.0, + "liveness": 0.1, + "speechiness": 0.04 + }, + "clientId": "client-uuid", + "radioStationId": "station-uuid" + }' +``` + +### Getting Radio Station Queue + +```bash +curl -X GET "http://localhost:8080/api/songs/queue?radioStationId=station-uuid" \ + -H "Authorization: Bearer {token}" +``` + +## Frontend Integration Notes + +### Merging Track and Audio Features + +The frontend can easily merge track information with audio features using JavaScript object spread: + +```javascript +// Track object from Spotify API +const trackObj = { + id: "4iV5W9uYEdYUVa79Axb7Rh", + popularity: 85, + tempo: 120.5, +}; + +// Audio features object from Spotify API +const audioFeaturesObj = { + danceability: 0.8, + energy: 0.7, + valence: 0.6, + acousticness: 0.1, + instrumentalness: 0.0, + liveness: 0.1, + speechiness: 0.04, +}; + +// Merge objects for API request +const merged = { ...trackObj, ...audioFeaturesObj }; +``` + +### Queue Data Structure + +The queue endpoints return/accept arrays of song objects. Each song object contains: + +- Basic track info (id, popularity, tempo) +- Audio features (danceability, energy, valence, etc.) +- All properties are at the root level (flattened structure) 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 1694a5c..3fd5c65 100644 --- a/backend/src/main/java/com/serena/backend/controller/SongController.java +++ b/backend/src/main/java/com/serena/backend/controller/SongController.java @@ -1,40 +1,97 @@ package com.serena.backend.controller; import com.serena.backend.dto.ApiResponse; -import com.serena.backend.dto.ConnectClientRequest; -import com.serena.backend.dto.AddSongRequest; +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/") +@RequestMapping("/api/songs") @CrossOrigin(origins = "*") public class SongController { @Autowired private RadioStationService radioStationService; + @Autowired + private QueueService queueService; + @Autowired private JwtService jwtService; - @PostMapping - public ResponseEntity> addSong(@RequestBody AddSongRequest request) { - if (request.getSong() == null || request.getRadioStationId() == null) { + @PostMapping("/queue") + public ResponseEntity> addSongToClientQueue(@RequestBody AddSongToClientQueueRequest request) { + if (request.getSong() == null || request.getClientId() == null) { return ResponseEntity.badRequest() - .body(new ApiResponse<>(false, "Song data and radio station ID are required", null)); + .body(new ApiResponse<>(false, "Song data and client ID are required", null)); } - boolean success = radioStationService.addSongToQueue(request.getRadioStationId(), request.getSong()); + boolean success = radioStationService.addSongToClientQueue(request.getClientId(), request.getSong()); if (success) { - return ResponseEntity.ok(new ApiResponse<>(true, "Song added to queue successfully", null)); + return ResponseEntity.ok(new ApiResponse<>(true, "Song added to client's preferred queue successfully", null)); } else { return ResponseEntity.status(HttpStatus.NOT_FOUND) - .body(new ApiResponse<>(false, "Radio station not found or inactive", null)); + .body(new ApiResponse<>(false, "Client not found", null)); } } + + @GetMapping("/queue") + public ResponseEntity>> getRadioStationQueue(@RequestParam String radioStationId) { + Optional station = radioStationService.getRadioStation(radioStationId); + + if (station.isPresent()) { + Queue queue = station.get().getSongQueue(); + return ResponseEntity.ok(new ApiResponse<>(true, "Queue retrieved successfully", queue)); + } else { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .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/dto/AddSongRequest.java b/backend/src/main/java/com/serena/backend/dto/AddSongRequest.java index be69f9f..ce6ed44 100644 --- a/backend/src/main/java/com/serena/backend/dto/AddSongRequest.java +++ b/backend/src/main/java/com/serena/backend/dto/AddSongRequest.java @@ -6,7 +6,8 @@ public class AddSongRequest { private Song song; private String radioStationId; - public AddSongRequest() {} + public AddSongRequest() { + } public AddSongRequest(Song song, String radioStationId) { this.song = song; diff --git a/backend/src/main/java/com/serena/backend/dto/AddSongToClientQueueRequest.java b/backend/src/main/java/com/serena/backend/dto/AddSongToClientQueueRequest.java new file mode 100644 index 0000000..05626db --- /dev/null +++ b/backend/src/main/java/com/serena/backend/dto/AddSongToClientQueueRequest.java @@ -0,0 +1,41 @@ +package com.serena.backend.dto; + +import com.serena.backend.model.Song; + +public class AddSongToClientQueueRequest { + private Song song; + private String clientId; + private String radioStationId; + + public AddSongToClientQueueRequest() {} + + public AddSongToClientQueueRequest(Song song, String clientId, String radioStationId) { + this.song = song; + this.clientId = clientId; + this.radioStationId = radioStationId; + } + + public Song getSong() { + return song; + } + + public void setSong(Song song) { + this.song = song; + } + + public String getClientId() { + return clientId; + } + + public void setClientId(String clientId) { + this.clientId = clientId; + } + + public String getRadioStationId() { + return radioStationId; + } + + public void setRadioStationId(String radioStationId) { + this.radioStationId = radioStationId; + } +} diff --git a/backend/src/main/java/com/serena/backend/model/Client.java b/backend/src/main/java/com/serena/backend/model/Client.java index 4b5e2dd..b734aeb 100644 --- a/backend/src/main/java/com/serena/backend/model/Client.java +++ b/backend/src/main/java/com/serena/backend/model/Client.java @@ -2,16 +2,20 @@ package com.serena.backend.model; import java.time.LocalDateTime; import java.util.UUID; +import java.util.LinkedList; +import java.util.Queue; public class Client { private String id; private String username; private String radioStationId; private LocalDateTime connectedAt; + private Queue preferredQueue; public Client() { this.id = UUID.randomUUID().toString(); this.connectedAt = LocalDateTime.now(); + this.preferredQueue = new LinkedList<>(); } public Client(String username, String radioStationId) { @@ -53,4 +57,20 @@ public class Client { this.connectedAt = connectedAt; } + public Queue getPreferredQueue() { + return preferredQueue; + } + + public void setPreferredQueue(Queue preferredQueue) { + this.preferredQueue = preferredQueue; + } + + public void addSongToPreferredQueue(Song song) { + this.preferredQueue.offer(song); + } + + public Song getNextPreferredSong() { + return this.preferredQueue.poll(); + } + } diff --git a/backend/src/main/java/com/serena/backend/model/RadioStation.java b/backend/src/main/java/com/serena/backend/model/RadioStation.java index 693f480..27fb3bc 100644 --- a/backend/src/main/java/com/serena/backend/model/RadioStation.java +++ b/backend/src/main/java/com/serena/backend/model/RadioStation.java @@ -15,7 +15,7 @@ public class RadioStation { private String ownerId; private String joinCode; private LocalDateTime createdAt; - private List connectedClients; + private List connectedClients; private Queue songQueue; public RadioStation() { @@ -93,11 +93,11 @@ public class RadioStation { this.createdAt = createdAt; } - public List getConnectedClients() { + public List getConnectedClients() { return connectedClients; } - public void setConnectedClients(List connectedClients) { + public void setConnectedClients(List connectedClients) { this.connectedClients = connectedClients; } 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; + } +} diff --git a/backend/src/main/java/com/serena/backend/service/RadioStationService.java b/backend/src/main/java/com/serena/backend/service/RadioStationService.java index 9ab557b..8af4122 100644 --- a/backend/src/main/java/com/serena/backend/service/RadioStationService.java +++ b/backend/src/main/java/com/serena/backend/service/RadioStationService.java @@ -54,9 +54,10 @@ public class RadioStationService { RadioStation station = radioStations.get(stationId); if (station != null) { // Disconnect all clients + for (Client client : station.getConnectedClients()) { + clients.remove(client.getId()); + } station.getConnectedClients().clear(); - // Remove from clients map - clients.entrySet().removeIf(entry -> stationId.equals(entry.getValue().getRadioStationId())); radioStations.remove(stationId); return true; } @@ -69,7 +70,7 @@ public class RadioStationService { if (station != null && station.getJoinCode().equals(joinCode)) { Client client = new Client(username, radioStationId); clients.put(client.getId(), client); - station.getConnectedClients().add(client.getId()); + station.getConnectedClients().add(client); return Optional.of(client); } return Optional.empty(); @@ -81,7 +82,7 @@ public class RadioStationService { RadioStation station = stationOpt.get(); Client client = new Client(username, station.getId()); clients.put(client.getId(), client); - station.getConnectedClients().add(client.getId()); + station.getConnectedClients().add(client); return Optional.of(client); } return Optional.empty(); @@ -92,7 +93,7 @@ public class RadioStationService { if (client != null) { RadioStation station = radioStations.get(client.getRadioStationId()); if (station != null) { - station.getConnectedClients().remove(clientId); + station.getConnectedClients().removeIf(c -> c.getId().equals(clientId)); } clients.remove(clientId); return true; @@ -107,10 +108,7 @@ public class RadioStationService { public List getConnectedClients(String radioStationId) { RadioStation station = radioStations.get(radioStationId); if (station != null) { - return station.getConnectedClients().stream() - .map(clients::get) - .filter(client -> client != null) - .toList(); + return new ArrayList<>(station.getConnectedClients()); } return new ArrayList<>(); } @@ -132,4 +130,22 @@ public class RadioStationService { return Optional.empty(); } + // Client Queue Management + public boolean addSongToClientQueue(String clientId, Song song) { + Client client = clients.get(clientId); + if (client != null) { + client.addSongToPreferredQueue(song); + return true; + } + return false; + } + + public Optional getNextClientSong(String clientId) { + Client client = clients.get(clientId); + if (client != null) { + return Optional.ofNullable(client.getNextPreferredSong()); + } + return Optional.empty(); + } + }