diff --git a/application-server/java/README.md b/application-server/java/README.md index b320817e..770521c9 100644 --- a/application-server/java/README.md +++ b/application-server/java/README.md @@ -1,12 +1,12 @@ -# Basic Java +# OpenVidu Java -Basic server application built for Java with Spring Boot. It internally uses [livekit-server-sdk-kotlin](https://github.com/livekit/server-sdk-kotlin). +OpenVidu server application built for Java with Spring Boot. It internally uses [livekit-server-sdk-kotlin](https://github.com/livekit/server-sdk-kotlin). For further information, check the [tutorial documentation](https://livekit-tutorials.openvidu.io/tutorials/application-server/java/). ## Prerequisites -- [Java >=17](https://www.java.com/en/download/) +- [Java >=21](https://www.java.com/en/download/) - [Maven](https://maven.apache.org/download.cgi) ## Run diff --git a/application-server/java/pom.xml b/application-server/java/pom.xml index c67d6d41..2348a7fc 100644 --- a/application-server/java/pom.xml +++ b/application-server/java/pom.xml @@ -6,18 +6,18 @@ org.springframework.boot spring-boot-starter-parent - 3.3.4 + 3.4.3 io.openvidu - basic-java + openvidu-java 0.0.1-SNAPSHOT basic-java - Basic server application built for Java with Spring Boot + OpenVidu server application built for Java with Spring Boot - 17 + 21 @@ -28,7 +28,7 @@ io.livekit livekit-server - 0.8.2 + 0.8.5 org.springframework.boot diff --git a/application-server/java/src/main/java/io/openvidu/basic/java/BasicJavaApplication.java b/application-server/java/src/main/java/io/openvidu/java/OpenViduJavaApplication.java similarity index 60% rename from application-server/java/src/main/java/io/openvidu/basic/java/BasicJavaApplication.java rename to application-server/java/src/main/java/io/openvidu/java/OpenViduJavaApplication.java index eb29f11b..bf3e49f4 100644 --- a/application-server/java/src/main/java/io/openvidu/basic/java/BasicJavaApplication.java +++ b/application-server/java/src/main/java/io/openvidu/java/OpenViduJavaApplication.java @@ -1,13 +1,13 @@ -package io.openvidu.basic.java; +package io.openvidu.java; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication -public class BasicJavaApplication { +public class OpenViduJavaApplication { public static void main(String[] args) { - SpringApplication.run(BasicJavaApplication.class, args); + SpringApplication.run(OpenViduJavaApplication.class, args); } } diff --git a/application-server/java/src/main/java/io/openvidu/java/controllers/EgressController.java b/application-server/java/src/main/java/io/openvidu/java/controllers/EgressController.java new file mode 100644 index 00000000..68054186 --- /dev/null +++ b/application-server/java/src/main/java/io/openvidu/java/controllers/EgressController.java @@ -0,0 +1,397 @@ +package io.openvidu.java.controllers; + +import java.util.List; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.protobuf.InvalidProtocolBufferException; +import com.google.protobuf.util.JsonFormat; + +import io.livekit.server.EgressServiceClient; +import io.livekit.server.EncodedOutputs; +import jakarta.annotation.PostConstruct; +import livekit.LivekitEgress.DirectFileOutput; +import livekit.LivekitEgress.EgressInfo; +import livekit.LivekitEgress.EncodedFileOutput; +import livekit.LivekitEgress.EncodedFileType; +import livekit.LivekitEgress.StreamOutput; +import livekit.LivekitEgress.StreamProtocol; + +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; + +@CrossOrigin(origins = "*") +@RestController +@RequestMapping("/egresses") +public class EgressController { + + private static final Logger LOGGER = LoggerFactory.getLogger(EgressController.class); + + @Value("${livekit.url}") + private String LIVEKIT_URL; + + @Value("${livekit.api.key}") + private String LIVEKIT_API_KEY; + + @Value("${livekit.api.secret}") + private String LIVEKIT_API_SECRET; + + private EgressServiceClient egressClient; + + @PostConstruct + public void init() { + egressClient = EgressServiceClient.createClient(LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET); + } + + /** + * Create a new RoomComposite egress + * + * @param params JSON object with roomName + * @return JSON object with the created egress + */ + @PostMapping("/room-composite") + public ResponseEntity> createRoomCompositeEgress(@RequestBody Map params) { + String roomName = params.get("roomName"); + + if (roomName == null) { + return ResponseEntity.badRequest() + .body(Map.of("errorMessage", "'roomName' is required")); + } + + try { + EncodedFileOutput output = EncodedFileOutput.newBuilder() + .setFilepath("{room_name}-{room_id}-{time}") + .setFileType(EncodedFileType.MP4) + .build(); + EgressInfo egress = egressClient.startRoomCompositeEgress(roomName, output, "grid") + .execute() + .body(); + return ResponseEntity.ok(Map.of("egress", convertToJson(egress))); + } catch (Exception e) { + String errorMessage = "Error creating RoomComposite egress"; + LOGGER.error(errorMessage, e); + return ResponseEntity.internalServerError() + .body(Map.of("errorMessage", errorMessage)); + } + } + + /** + * Create a new RoomComposite egress to stream to a URL + * + * @param params JSON object with roomName and streamUrl + * @return JSON object with the created egress + */ + @PostMapping("/stream") + public ResponseEntity> createStreamEgress(@RequestBody Map params) { + String roomName = params.get("roomName"); + String streamUrl = params.get("streamUrl"); + + if (roomName == null || streamUrl == null) { + return ResponseEntity.badRequest() + .body(Map.of("errorMessage", "'roomName' and 'streamUrl' are required")); + } + + try { + StreamOutput output = StreamOutput.newBuilder() + .setProtocol(StreamProtocol.RTMP) + .addUrls(streamUrl) + .build(); + EgressInfo egress = egressClient.startRoomCompositeEgress(roomName, output, "grid") + .execute() + .body(); + return ResponseEntity.ok(Map.of("egress", convertToJson(egress))); + } catch (Exception e) { + String errorMessage = "Error creating RoomComposite egress"; + LOGGER.error(errorMessage, e); + return ResponseEntity.internalServerError() + .body(Map.of("errorMessage", errorMessage)); + } + } + + /** + * Create a new Participant egress + * + * @param params JSON object with roomName and participantIdentity + * @return JSON object with the created egress + */ + @PostMapping("/participant") + public ResponseEntity> createParticipantEgress(@RequestBody Map params) { + String roomName = params.get("roomName"); + String participantIdentity = params.get("participantIdentity"); + + if (roomName == null || participantIdentity == null) { + return ResponseEntity.badRequest() + .body(Map.of("errorMessage", "'roomName' and 'participantIdentity' are required")); + } + + try { + EncodedFileOutput output = EncodedFileOutput.newBuilder() + .setFilepath("{room_name}-{room_id}-{publisher_identity}-{time}") + .setFileType(EncodedFileType.MP4) + .build(); + EncodedOutputs outputs = new EncodedOutputs(output, null, null, null); + EgressInfo egress = egressClient.startParticipantEgress(roomName, participantIdentity, outputs, false) + .execute() + .body(); + return ResponseEntity.ok(Map.of("egress", convertToJson(egress))); + } catch (Exception e) { + String errorMessage = "Error creating Participant egress"; + LOGGER.error(errorMessage, e); + return ResponseEntity.internalServerError() + .body(Map.of("errorMessage", errorMessage)); + } + } + + /** + * Create a new TrackComposite egress + * + * @param params JSON object with roomName, videoTrackId and audioTrackId + * @return JSON object with the created egress + */ + @PostMapping("/track-composite") + public ResponseEntity> createTrackCompositeEgress(@RequestBody Map params) { + String roomName = params.get("roomName"); + String videoTrackId = params.get("videoTrackId"); + String audioTrackId = params.get("audioTrackId"); + + if (roomName == null || videoTrackId == null || audioTrackId == null) { + return ResponseEntity.badRequest() + .body(Map.of("errorMessage", "'roomName', 'videoTrackId' and 'audioTrackId' are required")); + } + + try { + EncodedFileOutput output = EncodedFileOutput.newBuilder() + .setFilepath("{room_name}-{room_id}-{publisher_identity}-{time}") + .setFileType(EncodedFileType.MP4) + .build(); + EgressInfo egress = egressClient.startTrackCompositeEgress(roomName, output, audioTrackId, videoTrackId) + .execute() + .body(); + return ResponseEntity.ok(Map.of("egress", convertToJson(egress))); + } catch (Exception e) { + String errorMessage = "Error creating TrackComposite egress"; + LOGGER.error(errorMessage, e); + return ResponseEntity.internalServerError() + .body(Map.of("errorMessage", errorMessage)); + } + } + + /** + * Create a new Track egress + * + * @param params JSON object with roomName and trackId + * @return JSON object with the created egress + */ + @PostMapping("/track") + public ResponseEntity> createTrackEgress(@RequestBody Map params) { + String roomName = params.get("roomName"); + String trackId = params.get("trackId"); + + if (roomName == null || trackId == null) { + return ResponseEntity.badRequest() + .body(Map.of("errorMessage", "'roomName' and 'trackId' are required")); + } + + try { + DirectFileOutput output = DirectFileOutput.newBuilder() + .setFilepath("{room_name}-{room_id}-{publisher_identity}-{track_source}-{track_id}-{time}") + .build(); + EgressInfo egress = egressClient.startTrackEgress(roomName, output, trackId) + .execute() + .body(); + return ResponseEntity.ok(Map.of("egress", convertToJson(egress))); + } catch (Exception e) { + String errorMessage = "Error creating Track egress"; + LOGGER.error(errorMessage, e); + return ResponseEntity.internalServerError() + .body(Map.of("errorMessage", errorMessage)); + } + } + + /** + * Create a new Web egress + * + * @param params JSON object with url + * @return JSON object with the created egress + */ + @PostMapping("/web") + public ResponseEntity> createWebEgress(@RequestBody Map params) { + String url = params.get("url"); + + if (url == null) { + return ResponseEntity.badRequest() + .body(Map.of("errorMessage", "'url' is required")); + } + + try { + EncodedFileOutput output = EncodedFileOutput.newBuilder() + .setFilepath("{time}") + .setFileType(EncodedFileType.MP4) + .build(); + EgressInfo egress = egressClient.startWebEgress(url, output) + .execute() + .body(); + return ResponseEntity.ok(Map.of("egress", convertToJson(egress))); + } catch (Exception e) { + String errorMessage = "Error creating Web egress"; + LOGGER.error(errorMessage, e); + return ResponseEntity.internalServerError() + .body(Map.of("errorMessage", errorMessage)); + } + } + + /** + * List egresses + * If an egress ID is provided, only that egress is listed + * If a room name is provided, only egresses for that room are listed + * If active is true, only active egresses are listed + * + * @param egressId Optional egress ID to filter + * @param roomName Optional room name to filter + * @param active Optional flag to filter active egresses + * @return JSON object with the list of egresses + */ + @GetMapping + public ResponseEntity> listEgresses(@RequestParam(required = false) String egressId, + @RequestParam(required = false) String roomName, @RequestParam(required = false) Boolean active) { + try { + List egresses = egressClient.listEgress(roomName, egressId, active) + .execute() + .body(); + return ResponseEntity.ok(Map.of("egresses", convertListToJson(egresses))); + } catch (Exception e) { + String errorMessage = "Error listing egresses"; + LOGGER.error(errorMessage, e); + return ResponseEntity.internalServerError() + .body(Map.of("errorMessage", errorMessage)); + } + } + + /** + * Update egress layout + * + * @param params JSON object with layout + * @return JSON object with the updated egress + */ + @PostMapping("/{egressId}/layout") + public ResponseEntity> updateEgressLayout(@PathVariable String egressId, + @RequestBody Map params) { + String layout = params.get("layout"); + + if (layout == null) { + return ResponseEntity.badRequest() + .body(Map.of("errorMessage", "'layout' is required")); + } + + try { + EgressInfo egress = egressClient.updateLayout(egressId, layout) + .execute() + .body(); + return ResponseEntity.ok(Map.of("egress", convertToJson(egress))); + } catch (Exception e) { + String errorMessage = "Error updating egress layout"; + LOGGER.error(errorMessage, e); + return ResponseEntity.internalServerError() + .body(Map.of("errorMessage", errorMessage)); + } + } + + /** + * Add/remove stream URLs to an egress + * + * @param params JSON object with streamUrlsToAdd and streamUrlsToRemove + * @return JSON object with the updated egress + */ + @PostMapping("/{egressId}/streams") + public ResponseEntity> updateEgressStream(@PathVariable String egressId, + @RequestBody Map params) { + Object streamUrlsToAddObj = params.get("streamUrlsToAdd"); + Object streamUrlsToRemoveObj = params.get("streamUrlsToRemove"); + + if (!isStringList(streamUrlsToAddObj) || !isStringList(streamUrlsToRemoveObj)) { + return ResponseEntity.badRequest() + .body(Map.of("errorMessage", + "'streamUrlsToAdd' and 'streamUrlsToRemove' are required and must be arrays")); + } + + List streamUrlsToAdd = convertToStringList(streamUrlsToAddObj); + List streamUrlsToRemove = convertToStringList(streamUrlsToRemoveObj); + + try { + EgressInfo egress = egressClient.updateStream(egressId, streamUrlsToAdd, streamUrlsToRemove) + .execute() + .body(); + return ResponseEntity.ok(Map.of("egress", convertToJson(egress))); + } catch (Exception e) { + String errorMessage = "Error updating egress streams"; + LOGGER.error(errorMessage, e); + return ResponseEntity.internalServerError() + .body(Map.of("errorMessage", errorMessage)); + } + } + + /** + * Stop an egress + * + * @return JSON object with success message + */ + @DeleteMapping("/{egressId}") + public ResponseEntity> stopEgress(@PathVariable String egressId) { + try { + egressClient.stopEgress(egressId) + .execute(); + return ResponseEntity.ok(Map.of("message", "Egress stopped")); + } catch (Exception e) { + String errorMessage = "Error stopping egress"; + LOGGER.error(errorMessage, e); + return ResponseEntity.internalServerError() + .body(Map.of("errorMessage", errorMessage)); + } + } + + private Map convertToJson(EgressInfo egress) + throws InvalidProtocolBufferException, JsonProcessingException, JsonMappingException { + ObjectMapper objectMapper = new ObjectMapper(); + String rawJson = JsonFormat.printer().print(egress); + Map json = objectMapper.readValue(rawJson, new TypeReference>() { + }); + return json; + } + + private List> convertListToJson(List egresses) { + List> jsonList = egresses.stream().map(egress -> { + try { + return convertToJson(egress); + } catch (Exception e) { + LOGGER.error("Error parsing egress", e); + return null; + } + }).toList(); + return jsonList; + } + + private boolean isStringList(Object obj) { + return obj instanceof List list && list.stream().allMatch(String.class::isInstance); + } + + private List convertToStringList(Object obj) { + return ((List) obj).stream() + .map(String.class::cast) + .toList(); + } +} diff --git a/application-server/java/src/main/java/io/openvidu/java/controllers/IngressController.java b/application-server/java/src/main/java/io/openvidu/java/controllers/IngressController.java new file mode 100644 index 00000000..7895f762 --- /dev/null +++ b/application-server/java/src/main/java/io/openvidu/java/controllers/IngressController.java @@ -0,0 +1,242 @@ +package io.openvidu.java.controllers; + +import java.util.List; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.protobuf.InvalidProtocolBufferException; +import com.google.protobuf.util.JsonFormat; + +import io.livekit.server.IngressServiceClient; +import jakarta.annotation.PostConstruct; +import livekit.LivekitIngress.IngressInfo; +import livekit.LivekitIngress.IngressInput; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; + +@CrossOrigin(origins = "*") +@RestController +@RequestMapping("/ingresses") +public class IngressController { + + private static final Logger LOGGER = LoggerFactory.getLogger(IngressController.class); + + @Value("${livekit.url}") + private String LIVEKIT_URL; + + @Value("${livekit.api.key}") + private String LIVEKIT_API_KEY; + + @Value("${livekit.api.secret}") + private String LIVEKIT_API_SECRET; + + private IngressServiceClient ingressClient; + + @PostConstruct + public void init() { + ingressClient = IngressServiceClient.createClient(LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET); + } + + /** + * Create a new RTMP ingress + * + * @param params JSON object with roomName and participantIdentity + * @return JSON object with the created ingress + */ + @PostMapping("/rtmp") + public ResponseEntity> createRTMPIngress(@RequestBody Map params) { + String roomName = params.get("roomName"); + String participantIdentity = params.get("participantIdentity"); + + if (roomName == null || participantIdentity == null) { + return ResponseEntity.badRequest() + .body(Map.of("errorMessage", "'roomName' and 'participantIdentity' are required")); + } + + try { + IngressInfo ingress = ingressClient + .createIngress("rtmp-ingress", roomName, participantIdentity, null, IngressInput.RTMP_INPUT) + .execute() + .body(); + return ResponseEntity.ok(Map.of("ingress", convertToJson(ingress))); + } catch (Exception e) { + String errorMessage = "Error creating RTMP ingress"; + LOGGER.error(errorMessage, e); + return ResponseEntity.internalServerError() + .body(Map.of("errorMessage", errorMessage)); + } + } + + /** + * Create a new WHIP ingress + * + * @param params JSON object with roomName and participantIdentity + * @return JSON object with the created ingress + */ + @PostMapping("/whip") + public ResponseEntity> createWHIPIngress(@RequestBody Map params) { + String roomName = params.get("roomName"); + String participantIdentity = params.get("participantIdentity"); + + if (roomName == null || participantIdentity == null) { + return ResponseEntity.badRequest() + .body(Map.of("errorMessage", "'roomName' and 'participantIdentity' are required")); + } + + try { + IngressInfo ingress = ingressClient + .createIngress("whip-ingress", roomName, participantIdentity, null, IngressInput.WHIP_INPUT) + .execute() + .body(); + return ResponseEntity.ok(Map.of("ingress", convertToJson(ingress))); + } catch (Exception e) { + String errorMessage = "Error creating WHIP ingress"; + LOGGER.error(errorMessage, e); + return ResponseEntity.internalServerError() + .body(Map.of("errorMessage", errorMessage)); + } + } + + /** + * Create a new URL ingress + * + * @param params JSON object with roomName, participantIdentity and url + * @return JSON object with the created ingress + */ + @PostMapping("/url") + public ResponseEntity> createURLIngress(@RequestBody Map params) { + String roomName = params.get("roomName"); + String participantIdentity = params.get("participantIdentity"); + String url = params.get("url"); + + if (roomName == null || participantIdentity == null || url == null) { + return ResponseEntity.badRequest() + .body(Map.of("errorMessage", "'roomName', 'participantIdentity' and 'url' are required")); + } + + try { + IngressInfo ingress = ingressClient + .createIngress("url-ingress", roomName, participantIdentity, null, IngressInput.URL_INPUT, null, + null, null, null, url) + .execute() + .body(); + return ResponseEntity.ok(Map.of("ingress", convertToJson(ingress))); + } catch (Exception e) { + String errorMessage = "Error creating URL ingress"; + LOGGER.error(errorMessage, e); + return ResponseEntity.internalServerError() + .body(Map.of("errorMessage", errorMessage)); + } + } + + /** + * List ingresses + * If an ingress ID is provided, only that ingress is listed + * If a room name is provided, only ingresses for that room are listed + * + * @param ingressId Optional ingress ID to filter + * @param roomName Optional room name to filter + * @return JSON object with the list of ingresses + */ + @GetMapping + public ResponseEntity> listIngresses(@RequestParam(required = false) String ingressId, + @RequestParam(required = false) String roomName) { + try { + List ingresses = ingressClient.listIngress(roomName, ingressId) + .execute() + .body(); + return ResponseEntity.ok(Map.of("ingresses", convertListToJson(ingresses))); + } catch (Exception e) { + String errorMessage = "Error listing ingresses"; + LOGGER.error(errorMessage, e); + return ResponseEntity.internalServerError() + .body(Map.of("errorMessage", errorMessage)); + } + } + + /** + * Update ingress + * + * @param params JSON object with roomName + * @return JSON object with the updated ingress + */ + @PatchMapping("/{ingressId}") + public ResponseEntity> updateIngress(@PathVariable String ingressId, + @RequestBody Map params) { + String roomName = params.get("roomName"); + + if (roomName == null) { + return ResponseEntity.badRequest() + .body(Map.of("errorMessage", "'roomName' is required")); + } + + try { + // Know bug: participantIdentity must be provided in order to not fail, but it is not used + IngressInfo ingress = ingressClient.updateIngress(ingressId, "updated-ingress", roomName, "Ingress-Participant") + .execute() + .body(); + return ResponseEntity.ok(Map.of("ingress", convertToJson(ingress))); + } catch (Exception e) { + String errorMessage = "Error updating ingress"; + LOGGER.error(errorMessage, e); + return ResponseEntity.internalServerError() + .body(Map.of("errorMessage", errorMessage)); + } + } + + /** + * Delete ingress + * + * @return JSON object with success message + */ + @DeleteMapping("/{ingressId}") + public ResponseEntity> deleteIngress(@PathVariable String ingressId) { + try { + ingressClient.deleteIngress(ingressId) + .execute(); + return ResponseEntity.ok(Map.of("message", "Ingress deleted")); + } catch (Exception e) { + String errorMessage = "Error deleting ingress"; + LOGGER.error(errorMessage, e); + return ResponseEntity.internalServerError() + .body(Map.of("errorMessage", errorMessage)); + } + } + + private Map convertToJson(IngressInfo ingress) + throws InvalidProtocolBufferException, JsonProcessingException, JsonMappingException { + ObjectMapper objectMapper = new ObjectMapper(); + String rawJson = JsonFormat.printer().print(ingress); + Map json = objectMapper.readValue(rawJson, new TypeReference>() { + }); + return json; + } + + private List> convertListToJson(List ingresses) { + List> jsonList = ingresses.stream().map(ingress -> { + try { + return convertToJson(ingress); + } catch (Exception e) { + LOGGER.error("Error parsing ingress", e); + return null; + } + }).toList(); + return jsonList; + } +} diff --git a/application-server/java/src/main/java/io/openvidu/java/controllers/RoomController.java b/application-server/java/src/main/java/io/openvidu/java/controllers/RoomController.java new file mode 100644 index 00000000..deb09f1a --- /dev/null +++ b/application-server/java/src/main/java/io/openvidu/java/controllers/RoomController.java @@ -0,0 +1,399 @@ +package io.openvidu.java.controllers; + +import java.util.List; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.protobuf.InvalidProtocolBufferException; +import com.google.protobuf.MessageOrBuilder; +import com.google.protobuf.util.JsonFormat; + +import io.livekit.server.RoomServiceClient; +import jakarta.annotation.PostConstruct; +import livekit.LivekitModels.Room; +import livekit.LivekitModels.TrackInfo; +import livekit.LivekitModels.DataPacket; +import livekit.LivekitModels.ParticipantInfo; +import livekit.LivekitModels.ParticipantPermission; + +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; + +@CrossOrigin(origins = "*") +@RestController +@RequestMapping("/rooms") +public class RoomController { + + private static final Logger LOGGER = LoggerFactory.getLogger(RoomController.class); + + @Value("${livekit.url}") + private String LIVEKIT_URL; + + @Value("${livekit.api.key}") + private String LIVEKIT_API_KEY; + + @Value("${livekit.api.secret}") + private String LIVEKIT_API_SECRET; + + private RoomServiceClient roomClient; + + @PostConstruct + public void init() { + roomClient = RoomServiceClient.createClient(LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET); + } + + /** + * Create a new room + * + * @param params JSON object with roomName + * @return JSON object with the created room + */ + @PostMapping + public ResponseEntity> createRoom(@RequestBody Map params) { + String roomName = params.get("roomName"); + + if (roomName == null) { + return ResponseEntity.badRequest() + .body(Map.of("errorMessage", "'roomName' is required")); + } + + try { + Room room = roomClient.createRoom(roomName) + .execute() + .body(); + return ResponseEntity.ok(Map.of("room", convertToJson(room))); + } catch (Exception e) { + String errorMessage = "Error creating room"; + LOGGER.error(errorMessage, e); + return ResponseEntity.internalServerError() + .body(Map.of("errorMessage", errorMessage)); + } + } + + /** + * List rooms. + * If a room name is provided, only that room is listed + * + * @param roomName Optional room name to filter + * @return JSON object with the list of rooms + */ + @GetMapping + public ResponseEntity> listRooms(@RequestParam(required = false) String roomName) { + try { + List roomNames = roomName != null ? List.of(roomName) : null; + List rooms = roomClient.listRooms(roomNames) + .execute() + .body(); + return ResponseEntity.ok(Map.of("rooms", convertListToJson(rooms))); + } catch (Exception e) { + String errorMessage = "Error listing rooms"; + LOGGER.error(errorMessage, e); + return ResponseEntity.internalServerError() + .body(Map.of("errorMessage", errorMessage)); + } + } + + /** + * Update room metadata + * + * @param params JSON object with metadata + * @return JSON object with the updated room + */ + @PostMapping("/{roomName}/metadata") + public ResponseEntity> updateRoomMetadata(@PathVariable String roomName, + @RequestBody Map params) { + String metadata = params.get("metadata"); + + if (metadata == null) { + return ResponseEntity.badRequest() + .body(Map.of("errorMessage", "'metadata' is required")); + } + + try { + Room room = roomClient.updateRoomMetadata(roomName, metadata) + .execute() + .body(); + return ResponseEntity.ok(Map.of("room", convertToJson(room))); + } catch (Exception e) { + String errorMessage = "Error updating room metadata"; + LOGGER.error(errorMessage, e); + return ResponseEntity.internalServerError() + .body(Map.of("errorMessage", errorMessage)); + } + } + + /** + * Send data message to participants in a room + * + * @param params JSON object with data + * @return JSON object with success message + */ + @PostMapping("/{roomName}/send-data") + public ResponseEntity> sendData(@PathVariable String roomName, + @RequestBody Map params) { + Object rawData = params.get("data"); + + if (rawData == null) { + return ResponseEntity.badRequest() + .body(Map.of("errorMessage", "'data' is required")); + } + + try { + ObjectMapper objectMapper = new ObjectMapper(); + byte[] data = objectMapper.writeValueAsBytes(rawData); + roomClient.sendData(roomName, data, DataPacket.Kind.RELIABLE, List.of(), List.of(), "chat") + .execute(); + return ResponseEntity.ok(Map.of("message", "Data message sent")); + } catch (Exception e) { + String errorMessage = "Error sending data message"; + LOGGER.error(errorMessage, e); + return ResponseEntity.internalServerError() + .body(Map.of("errorMessage", errorMessage)); + } + } + + /** + * Delete a room + * + * @return JSON object with success message + */ + @DeleteMapping("/{roomName}") + public ResponseEntity> deleteRoom(@PathVariable String roomName) { + try { + roomClient.deleteRoom(roomName) + .execute(); + return ResponseEntity.ok(Map.of("message", "Room deleted")); + } catch (Exception e) { + String errorMessage = "Error deleting room"; + LOGGER.error(errorMessage, e); + return ResponseEntity.internalServerError() + .body(Map.of("errorMessage", errorMessage)); + } + } + + /** + * List participants in a room + * + * @return JSON object with the list of participants + */ + @GetMapping("/{roomName}/participants") + public ResponseEntity> listParticipants(@PathVariable String roomName) { + try { + List participants = roomClient.listParticipants(roomName) + .execute() + .body(); + return ResponseEntity.ok(Map.of("participants", convertListToJson(participants))); + } catch (Exception e) { + String errorMessage = "Error getting participants"; + LOGGER.error(errorMessage, e); + return ResponseEntity.internalServerError() + .body(Map.of("errorMessage", errorMessage)); + } + } + + /** + * Get a participant in a room + * + * @return JSON object with the participant + */ + @GetMapping("/{roomName}/participants/{participantIdentity}") + public ResponseEntity> getParticipant(@PathVariable String roomName, + @PathVariable String participantIdentity) { + try { + ParticipantInfo participant = roomClient.getParticipant(roomName, participantIdentity) + .execute() + .body(); + return ResponseEntity.ok(Map.of("participant", convertToJson(participant))); + } catch (Exception e) { + String errorMessage = "Error getting participant"; + LOGGER.error(errorMessage, e); + return ResponseEntity.internalServerError() + .body(Map.of("errorMessage", errorMessage)); + } + } + + /** + * Update a participant in a room + * + * @param params JSON object with metadata (optional) + * @return JSON object with the updated participant + */ + @PatchMapping("/{roomName}/participants/{participantIdentity}") + public ResponseEntity> updateParticipant(@PathVariable String roomName, + @PathVariable String participantIdentity, @RequestBody Map params) { + String metadata = params.get("metadata"); + + try { + ParticipantPermission permissions = ParticipantPermission.newBuilder() + .setCanPublish(false) + .setCanSubscribe(true) + .build(); + ParticipantInfo participant = roomClient + .updateParticipant(roomName, participantIdentity, null, metadata, permissions) + .execute() + .body(); + return ResponseEntity.ok(Map.of("participant", convertToJson(participant))); + } catch (Exception e) { + String errorMessage = "Error updating participant"; + LOGGER.error(errorMessage, e); + return ResponseEntity.internalServerError() + .body(Map.of("errorMessage", errorMessage)); + } + } + + /** + * Remove a participant from a room + * + * @return JSON object with success message + */ + @DeleteMapping("/{roomName}/participants/{participantIdentity}") + public ResponseEntity> removeParticipant(@PathVariable String roomName, + @PathVariable String participantIdentity) { + try { + roomClient.removeParticipant(roomName, participantIdentity) + .execute(); + return ResponseEntity.ok(Map.of("message", "Participant removed")); + } catch (Exception e) { + String errorMessage = "Error removing participant"; + LOGGER.error(errorMessage, e); + return ResponseEntity.internalServerError() + .body(Map.of("errorMessage", errorMessage)); + } + } + + /** + * Mute published track of a participant in a room + * + * @param params JSON object with trackId + * @return JSON object with updated track + */ + @PostMapping("/{roomName}/participants/{participantIdentity}/mute") + public ResponseEntity> muteParticipant(@PathVariable String roomName, + @PathVariable String participantIdentity, @RequestBody Map params) { + String trackId = params.get("trackId"); + + if (trackId == null) { + return ResponseEntity.badRequest() + .body(Map.of("errorMessage", "'trackId' is required")); + } + + try { + TrackInfo track = roomClient.mutePublishedTrack(roomName, participantIdentity, trackId, true) + .execute() + .body(); + return ResponseEntity.ok(Map.of("track", convertToJson(track))); + } catch (Exception e) { + String errorMessage = "Error muting track"; + LOGGER.error(errorMessage, e); + return ResponseEntity.internalServerError() + .body(Map.of("errorMessage", errorMessage)); + } + } + + /** + * Subscribe participant to tracks in a room + * + * @param params JSON object with list of trackIds + * @return JSON object with success message + */ + @PostMapping("/{roomName}/participants/{participantIdentity}/subscribe") + public ResponseEntity> subscribeParticipant(@PathVariable String roomName, + @PathVariable String participantIdentity, @RequestBody Map params) { + Object trackIdsObj = params.get("trackIds"); + + if (!isStringList(trackIdsObj)) { + return ResponseEntity.badRequest() + .body(Map.of("errorMessage", "'trackIds' is required and must be an array")); + } + + List trackIds = convertToStringList(trackIdsObj); + + try { + roomClient.updateSubscriptions(roomName, participantIdentity, trackIds, true) + .execute(); + return ResponseEntity.ok(Map.of("message", "Participant subscribed to tracks")); + } catch (Exception e) { + String errorMessage = "Error subscribing participant to tracks"; + LOGGER.error(errorMessage, e); + return ResponseEntity.internalServerError() + .body(Map.of("errorMessage", errorMessage)); + } + } + + /** + * Unsubscribe participant from tracks in a room + * + * @param params JSON object with list of trackIds + * @return JSON object with success message + */ + @PostMapping("/{roomName}/participants/{participantIdentity}/unsubscribe") + public ResponseEntity> unsubscribeParticipant(@PathVariable String roomName, + @PathVariable String participantIdentity, @RequestBody Map params) { + Object trackIdsObj = params.get("trackIds"); + + if (!isStringList(trackIdsObj)) { + return ResponseEntity.badRequest() + .body(Map.of("errorMessage", "'trackIds' is required and must be an array")); + } + + List trackIds = convertToStringList(trackIdsObj); + + try { + roomClient.updateSubscriptions(roomName, participantIdentity, trackIds, false) + .execute(); + return ResponseEntity.ok(Map.of("message", "Participant unsubscribed from tracks")); + } catch (Exception e) { + String errorMessage = "Error unsubscribing participant from tracks"; + LOGGER.error(errorMessage, e); + return ResponseEntity.internalServerError() + .body(Map.of("errorMessage", errorMessage)); + } + } + + private Map convertToJson(T object) + throws InvalidProtocolBufferException, JsonProcessingException, JsonMappingException { + ObjectMapper objectMapper = new ObjectMapper(); + String rawJson = JsonFormat.printer().print(object); + Map json = objectMapper.readValue(rawJson, new TypeReference>() { + }); + return json; + } + + private List> convertListToJson(List objects) { + List> jsonList = objects.stream().map(object -> { + try { + return convertToJson(object); + } catch (Exception e) { + LOGGER.error("Error parsing egress", e); + return null; + } + }).toList(); + return jsonList; + } + + private boolean isStringList(Object obj) { + return obj instanceof List list && !list.isEmpty() && list.stream().allMatch(String.class::isInstance); + } + + private List convertToStringList(Object obj) { + return ((List) obj).stream() + .map(String.class::cast) + .toList(); + } +} diff --git a/application-server/java/src/main/java/io/openvidu/basic/java/Controller.java b/application-server/java/src/main/java/io/openvidu/java/controllers/TokenController.java similarity index 55% rename from application-server/java/src/main/java/io/openvidu/basic/java/Controller.java rename to application-server/java/src/main/java/io/openvidu/java/controllers/TokenController.java index 4fc1fbbe..7f4a2bea 100644 --- a/application-server/java/src/main/java/io/openvidu/basic/java/Controller.java +++ b/application-server/java/src/main/java/io/openvidu/java/controllers/TokenController.java @@ -1,4 +1,4 @@ -package io.openvidu.basic.java; +package io.openvidu.java.controllers; import java.util.Map; @@ -7,18 +7,19 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.CrossOrigin; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import io.livekit.server.AccessToken; +import io.livekit.server.CanPublish; +import io.livekit.server.CanSubscribe; import io.livekit.server.RoomJoin; import io.livekit.server.RoomName; -import io.livekit.server.WebhookReceiver; -import livekit.LivekitWebhook.WebhookEvent; @CrossOrigin(origins = "*") @RestController -public class Controller { +@RequestMapping("/token") +public class TokenController { @Value("${livekit.api.key}") private String LIVEKIT_API_KEY; @@ -27,36 +28,30 @@ public class Controller { private String LIVEKIT_API_SECRET; /** + * Create a new token for a participant to join a room + * * @param params JSON object with roomName and participantName * @return JSON object with the JWT token */ - @PostMapping(value = "/token") + @PostMapping public ResponseEntity> createToken(@RequestBody Map params) { String roomName = params.get("roomName"); String participantName = params.get("participantName"); if (roomName == null || participantName == null) { - return ResponseEntity.badRequest().body(Map.of("errorMessage", "roomName and participantName are required")); + return ResponseEntity.badRequest() + .body(Map.of("errorMessage", "'roomName' and 'participantName' are required")); } AccessToken token = new AccessToken(LIVEKIT_API_KEY, LIVEKIT_API_SECRET); token.setName(participantName); token.setIdentity(participantName); - token.addGrants(new RoomJoin(true), new RoomName(roomName)); + token.addGrants( + new RoomJoin(true), + new RoomName(roomName), + new CanPublish(true), + new CanSubscribe(true)); return ResponseEntity.ok(Map.of("token", token.toJwt())); } - - @PostMapping(value = "/livekit/webhook", consumes = "application/webhook+json") - public ResponseEntity receiveWebhook(@RequestHeader("Authorization") String authHeader, @RequestBody String body) { - WebhookReceiver webhookReceiver = new WebhookReceiver(LIVEKIT_API_KEY, LIVEKIT_API_SECRET); - try { - WebhookEvent event = webhookReceiver.receive(body, authHeader); - System.out.println("LiveKit Webhook: " + event.toString()); - } catch (Exception e) { - System.err.println("Error validating webhook event: " + e.getMessage()); - } - return ResponseEntity.ok("ok"); - } - } diff --git a/application-server/java/src/main/java/io/openvidu/java/controllers/WebhookController.java b/application-server/java/src/main/java/io/openvidu/java/controllers/WebhookController.java new file mode 100644 index 00000000..667d00f6 --- /dev/null +++ b/application-server/java/src/main/java/io/openvidu/java/controllers/WebhookController.java @@ -0,0 +1,50 @@ +package io.openvidu.java.controllers; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import io.livekit.server.WebhookReceiver; +import jakarta.annotation.PostConstruct; +import livekit.LivekitWebhook.WebhookEvent; + +@CrossOrigin(origins = "*") +@RestController +@RequestMapping(value = "/livekit/webhook", consumes = "application/webhook+json") +public class WebhookController { + + private static final Logger LOGGER = LoggerFactory.getLogger(WebhookController.class); + + @Value("${livekit.api.key}") + private String LIVEKIT_API_KEY; + + @Value("${livekit.api.secret}") + private String LIVEKIT_API_SECRET; + + private WebhookReceiver webhookReceiver; + + @PostConstruct + public void init() { + webhookReceiver = new WebhookReceiver(LIVEKIT_API_KEY, LIVEKIT_API_SECRET); + } + + @PostMapping() + public ResponseEntity receiveWebhook(@RequestHeader("Authorization") String authHeader, + @RequestBody String body) { + try { + WebhookEvent webhookEvent = webhookReceiver.receive(body, authHeader); + System.out.println("LiveKit Webhook: " + webhookEvent.toString()); + } catch (Exception e) { + LOGGER.error("Error validating webhook event", e); + } + + return ResponseEntity.ok("ok"); + } +} diff --git a/application-server/java/src/main/resources/application.properties b/application-server/java/src/main/resources/application.properties index d17e8f71..20d916f0 100644 --- a/application-server/java/src/main/resources/application.properties +++ b/application-server/java/src/main/resources/application.properties @@ -1,7 +1,8 @@ -spring.application.name=basic-java +spring.application.name=openvidu-java server.port=${SERVER_PORT:6080} server.ssl.enabled=false # LiveKit configuration +livekit.url=${LIVEKIT_URL:http://localhost:7880} livekit.api.key=${LIVEKIT_API_KEY:devkey} livekit.api.secret=${LIVEKIT_API_SECRET:secret} diff --git a/application-server/java/src/test/java/io/openvidu/basic/java/BasicJavaApplicationTests.java b/application-server/java/src/test/java/io/openvidu/java/OpenViduJavaApplicationTests.java similarity index 69% rename from application-server/java/src/test/java/io/openvidu/basic/java/BasicJavaApplicationTests.java rename to application-server/java/src/test/java/io/openvidu/java/OpenViduJavaApplicationTests.java index 6053c33b..e810e0c8 100644 --- a/application-server/java/src/test/java/io/openvidu/basic/java/BasicJavaApplicationTests.java +++ b/application-server/java/src/test/java/io/openvidu/java/OpenViduJavaApplicationTests.java @@ -1,10 +1,10 @@ -package io.openvidu.basic.java; +package io.openvidu.java; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; @SpringBootTest -class BasicJavaApplicationTests { +class OpenViduJavaApplicationTests { @Test void contextLoads() {