Implement full LiveKit API in Java

This commit is contained in:
juancarmore 2025-02-24 15:04:05 +01:00
parent 9f5e17519d
commit e009fb598c
10 changed files with 1119 additions and 35 deletions

View File

@ -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

View File

@ -6,18 +6,18 @@
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.4</version>
<version>3.4.3</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>io.openvidu</groupId>
<artifactId>basic-java</artifactId>
<artifactId>openvidu-java</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>basic-java</name>
<description>Basic server application built for Java with Spring Boot</description>
<description>OpenVidu server application built for Java with Spring Boot</description>
<properties>
<java.version>17</java.version>
<java.version>21</java.version>
</properties>
<dependencies>
@ -28,7 +28,7 @@
<dependency>
<groupId>io.livekit</groupId>
<artifactId>livekit-server</artifactId>
<version>0.8.2</version>
<version>0.8.5</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>

View File

@ -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);
}
}

View File

@ -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<Map<String, Object>> createRoomCompositeEgress(@RequestBody Map<String, String> 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<Map<String, Object>> createStreamEgress(@RequestBody Map<String, String> 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<Map<String, Object>> createParticipantEgress(@RequestBody Map<String, String> 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<Map<String, Object>> createTrackCompositeEgress(@RequestBody Map<String, String> 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<Map<String, Object>> createTrackEgress(@RequestBody Map<String, String> 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<Map<String, Object>> createWebEgress(@RequestBody Map<String, String> 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<Map<String, Object>> listEgresses(@RequestParam(required = false) String egressId,
@RequestParam(required = false) String roomName, @RequestParam(required = false) Boolean active) {
try {
List<EgressInfo> 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<Map<String, Object>> updateEgressLayout(@PathVariable String egressId,
@RequestBody Map<String, String> 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<Map<String, Object>> updateEgressStream(@PathVariable String egressId,
@RequestBody Map<String, Object> 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<String> streamUrlsToAdd = convertToStringList(streamUrlsToAddObj);
List<String> 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<Map<String, Object>> 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<String, Object> convertToJson(EgressInfo egress)
throws InvalidProtocolBufferException, JsonProcessingException, JsonMappingException {
ObjectMapper objectMapper = new ObjectMapper();
String rawJson = JsonFormat.printer().print(egress);
Map<String, Object> json = objectMapper.readValue(rawJson, new TypeReference<Map<String, Object>>() {
});
return json;
}
private List<Map<String, Object>> convertListToJson(List<EgressInfo> egresses) {
List<Map<String, Object>> 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<String> convertToStringList(Object obj) {
return ((List<?>) obj).stream()
.map(String.class::cast)
.toList();
}
}

View File

@ -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<Map<String, Object>> createRTMPIngress(@RequestBody Map<String, String> 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<Map<String, Object>> createWHIPIngress(@RequestBody Map<String, String> 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<Map<String, Object>> createURLIngress(@RequestBody Map<String, String> 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<Map<String, Object>> listIngresses(@RequestParam(required = false) String ingressId,
@RequestParam(required = false) String roomName) {
try {
List<IngressInfo> 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<Map<String, Object>> updateIngress(@PathVariable String ingressId,
@RequestBody Map<String, String> 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<Map<String, Object>> 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<String, Object> convertToJson(IngressInfo ingress)
throws InvalidProtocolBufferException, JsonProcessingException, JsonMappingException {
ObjectMapper objectMapper = new ObjectMapper();
String rawJson = JsonFormat.printer().print(ingress);
Map<String, Object> json = objectMapper.readValue(rawJson, new TypeReference<Map<String, Object>>() {
});
return json;
}
private List<Map<String, Object>> convertListToJson(List<IngressInfo> ingresses) {
List<Map<String, Object>> jsonList = ingresses.stream().map(ingress -> {
try {
return convertToJson(ingress);
} catch (Exception e) {
LOGGER.error("Error parsing ingress", e);
return null;
}
}).toList();
return jsonList;
}
}

View File

@ -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<Map<String, Object>> createRoom(@RequestBody Map<String, String> 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<Map<String, Object>> listRooms(@RequestParam(required = false) String roomName) {
try {
List<String> roomNames = roomName != null ? List.of(roomName) : null;
List<Room> 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<Map<String, Object>> updateRoomMetadata(@PathVariable String roomName,
@RequestBody Map<String, String> 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<Map<String, Object>> sendData(@PathVariable String roomName,
@RequestBody Map<String, Object> 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<Map<String, Object>> 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<Map<String, Object>> listParticipants(@PathVariable String roomName) {
try {
List<ParticipantInfo> 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<Map<String, Object>> 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<Map<String, Object>> updateParticipant(@PathVariable String roomName,
@PathVariable String participantIdentity, @RequestBody Map<String, String> 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<Map<String, Object>> 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<Map<String, Object>> muteParticipant(@PathVariable String roomName,
@PathVariable String participantIdentity, @RequestBody Map<String, String> 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<Map<String, Object>> subscribeParticipant(@PathVariable String roomName,
@PathVariable String participantIdentity, @RequestBody Map<String, Object> 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<String> 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<Map<String, Object>> unsubscribeParticipant(@PathVariable String roomName,
@PathVariable String participantIdentity, @RequestBody Map<String, Object> 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<String> 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 <T extends MessageOrBuilder> Map<String, Object> convertToJson(T object)
throws InvalidProtocolBufferException, JsonProcessingException, JsonMappingException {
ObjectMapper objectMapper = new ObjectMapper();
String rawJson = JsonFormat.printer().print(object);
Map<String, Object> json = objectMapper.readValue(rawJson, new TypeReference<Map<String, Object>>() {
});
return json;
}
private <T extends MessageOrBuilder> List<Map<String, Object>> convertListToJson(List<T> objects) {
List<Map<String, Object>> 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<String> convertToStringList(Object obj) {
return ((List<?>) obj).stream()
.map(String.class::cast)
.toList();
}
}

View File

@ -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<Map<String, String>> createToken(@RequestBody Map<String, String> 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<String> 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");
}
}

View File

@ -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<String> 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");
}
}

View File

@ -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}

View File

@ -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() {