diff --git a/openvidu-recording-java/pom.xml b/openvidu-recording-java/pom.xml index dec5e5ac..8954aea9 100644 --- a/openvidu-recording-java/pom.xml +++ b/openvidu-recording-java/pom.xml @@ -19,14 +19,10 @@ UTF-8 - 1.8 + 11 io.openvidu.recording.java.App openvidu - - 5.0.3 - 1.3.2 - 3.1.0 @@ -39,6 +35,11 @@ + + org.jetbrains.kotlin + kotlin-stdlib + 1.5.21 + org.springframework.boot @@ -49,55 +50,19 @@ spring-boot-devtools - io.openvidu - openvidu-java-client - 2.27.0 + io.livekit + livekit-server + 0.5.7 - org.junit.jupiter - junit-jupiter-api - test + com.squareup.okhttp3 + okhttp + 4.12.0 - io.github.bonigarcia - selenium-jupiter - ${selenium-jupiter.version} - test + org.json + json + 20231013 - - io.github.bonigarcia - webdrivermanager - 2.2.0 - test - - - org.junit.platform - junit-platform-runner - test - - - org.junit.jupiter - junit-jupiter-engine - test - - - ws.schild - jave-all-deps - 2.4.6 - test - - - ws.schild - jave-core - 2.4.6 - test - - - io.openvidu - openvidu-test-browsers - 1.0.0 - test - - diff --git a/openvidu-recording-java/src/main/java/io/openvidu/recording/java/MyRestController.java b/openvidu-recording-java/src/main/java/io/openvidu/recording/java/MyRestController.java index 8e1d4280..c5549a49 100644 --- a/openvidu-recording-java/src/main/java/io/openvidu/recording/java/MyRestController.java +++ b/openvidu-recording-java/src/main/java/io/openvidu/recording/java/MyRestController.java @@ -1,402 +1,251 @@ package io.openvidu.recording.java; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.Date; import java.util.List; import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; + +import javax.annotation.PostConstruct; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.util.ResourceUtils; +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.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RestController; -import com.google.gson.Gson; -import com.google.gson.JsonArray; -import com.google.gson.JsonObject; +import org.json.JSONObject; -import io.openvidu.java.client.ConnectionProperties; -import io.openvidu.java.client.ConnectionType; -import io.openvidu.java.client.OpenVidu; -import io.openvidu.java.client.OpenViduHttpException; -import io.openvidu.java.client.OpenViduJavaClientException; -import io.openvidu.java.client.OpenViduRole; -import io.openvidu.java.client.Recording; -import io.openvidu.java.client.RecordingProperties; -import io.openvidu.java.client.Session; +import io.livekit.server.*; +import livekit.LivekitEgress; +import livekit.LivekitEgress.EgressInfo; +import livekit.LivekitEgress.EncodedFileOutput; +import livekit.LivekitEgress.EncodedFileType; +import livekit.LivekitEgress.EncodedFileOutput.Builder; + +@CrossOrigin(origins = "*") @RestController -@RequestMapping("/recording-java/api") public class MyRestController { + @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; + + @Value("${RECORDINGS_PATH}") + private String RECORDINGS_PATH; + // OpenVidu object as entrypoint of the SDK - private OpenVidu openVidu; + private EgressServiceClient egressClient; - // Collection to pair session names and OpenVidu Session objects - private Map mapSessions = new ConcurrentHashMap<>(); - // Collection to pair session names and tokens (the inner Map pairs tokens and - // role associated) - private Map> mapSessionNamesTokens = new ConcurrentHashMap<>(); - // Collection to pair session names and recording objects - private Map sessionRecordings = new ConcurrentHashMap<>(); + @PostConstruct() + public void initialize() { - // URL where our OpenVidu server is listening - private String OPENVIDU_URL; - // Secret shared with our OpenVidu server - private String SECRET; - - public MyRestController(@Value("${openvidu.secret}") String secret, @Value("${openvidu.url}") String openviduUrl) { - this.SECRET = secret; - this.OPENVIDU_URL = openviduUrl; - this.openVidu = new OpenVidu(OPENVIDU_URL, SECRET); + String livekitUrlHostname = LIVEKIT_URL.replaceFirst("^ws:", "http:").replaceFirst("^wss:", "https:"); + this.egressClient = EgressServiceClient.create(livekitUrlHostname, LIVEKIT_API_KEY, LIVEKIT_API_SECRET, true); } /*******************/ /*** Session API ***/ /*******************/ - @RequestMapping(value = "/get-token", method = RequestMethod.POST) - public ResponseEntity getToken(@RequestBody Map sessionNameParam) { + /** + * @param params The JSON object with roomName and participantName + * @return The JWT token + */ + @PostMapping("/token") + public ResponseEntity getToken(@RequestBody(required = true) Map params) { + String roomName = params.get("roomName"); + String participantName = params.get("participantName"); + JSONObject response = new JSONObject(); - System.out.println("Getting sessionId and token | {sessionName}=" + sessionNameParam); - - // The video-call to connect ("TUTORIAL") - String sessionName = (String) sessionNameParam.get("sessionName"); - - // Role associated to this user - OpenViduRole role = OpenViduRole.PUBLISHER; - - // Build connectionProperties object with the serverData and the role - ConnectionProperties connectionProperties = new ConnectionProperties.Builder().type(ConnectionType.WEBRTC) - .role(role).data("user_data").build(); - - JsonObject responseJson = new JsonObject(); - - if (this.mapSessions.get(sessionName) != null) { - // Session already exists - System.out.println("Existing session " + sessionName); - try { - - // Generate a new token with the recently created connectionProperties - String token = this.mapSessions.get(sessionName).createConnection(connectionProperties).getToken(); - - // Update our collection storing the new token - this.mapSessionNamesTokens.get(sessionName).put(token, role); - - // Prepare the response with the token - responseJson.addProperty("0", token); - - // Return the response to the client - return new ResponseEntity<>(responseJson, HttpStatus.OK); - - } catch (OpenViduJavaClientException e1) { - // If internal error generate an error message and return it to client - return getErrorResponse(e1); - } catch (OpenViduHttpException e2) { - if (404 == e2.getStatus()) { - // Invalid sessionId (user left unexpectedly). Session object is not valid - // anymore. Clean collections and continue as new session - this.mapSessions.remove(sessionName); - this.mapSessionNamesTokens.remove(sessionName); - } - } + if (roomName == null || participantName == null) { + return new ResponseEntity<>(HttpStatus.BAD_REQUEST); } - // New session - System.out.println("New session " + sessionName); - try { + // By default, tokens expire 6 hours after generation. + // You may override this by using token.setTtl(long millis). + AccessToken token = new AccessToken(LIVEKIT_API_KEY, LIVEKIT_API_SECRET); + token.setName(participantName); + token.setIdentity(participantName); - // Create a new OpenVidu Session - Session session = this.openVidu.createSession(); - // Generate a new token with the recently created connectionProperties - String token = session.createConnection(connectionProperties).getToken(); + JSONObject metadata = new JSONObject(); + metadata.put("livekitUrl", LIVEKIT_URL); + // add metadata to the token, which will be available in the participant's + // metadata + token.setMetadata(metadata.toString()); + token.addGrants(new RoomJoin(true), new RoomName(roomName)); - // Store the session and the token in our collections - this.mapSessions.put(sessionName, session); - this.mapSessionNamesTokens.put(sessionName, new ConcurrentHashMap<>()); - this.mapSessionNamesTokens.get(sessionName).put(token, role); - - // Prepare the response with the sessionId and the token - responseJson.addProperty("0", token); - - // Return the response to the client - return new ResponseEntity<>(responseJson, HttpStatus.OK); - - } catch (Exception e) { - // If error generate an error message and return it to client - return getErrorResponse(e); - } - } - - @RequestMapping(value = "/remove-user", method = RequestMethod.POST) - public ResponseEntity removeUser(@RequestBody Map sessionNameToken) throws Exception { - - System.out.println("Removing user | {sessionName, token}=" + sessionNameToken); - - // Retrieve the params from BODY - String sessionName = (String) sessionNameToken.get("sessionName"); - String token = (String) sessionNameToken.get("token"); - - // If the session exists - if (this.mapSessions.get(sessionName) != null && this.mapSessionNamesTokens.get(sessionName) != null) { - - // If the token exists - if (this.mapSessionNamesTokens.get(sessionName).remove(token) != null) { - // User left the session - if (this.mapSessionNamesTokens.get(sessionName).isEmpty()) { - // Last user left: session must be removed - this.mapSessions.remove(sessionName); - } - return new ResponseEntity<>(HttpStatus.OK); - } else { - // The TOKEN wasn't valid - System.out.println("Problems in the app server: the TOKEN wasn't valid"); - return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); - } - - } else { - // The SESSION does not exist - System.out.println("Problems in the app server: the SESSION does not exist"); - return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); - } - } - - @RequestMapping(value = "/close-session", method = RequestMethod.DELETE) - public ResponseEntity closeSession(@RequestBody Map sessionName) throws Exception { - - System.out.println("Closing session | {sessionName}=" + sessionName); - - // Retrieve the param from BODY - String session = (String) sessionName.get("sessionName"); - - // If the session exists - if (this.mapSessions.get(session) != null && this.mapSessionNamesTokens.get(session) != null) { - Session s = this.mapSessions.get(session); - s.close(); - this.mapSessions.remove(session); - this.mapSessionNamesTokens.remove(session); - this.sessionRecordings.remove(s.getSessionId()); - return new ResponseEntity<>(HttpStatus.OK); - } else { - // The SESSION does not exist - System.out.println("Problems in the app server: the SESSION does not exist"); - return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); - } - } - - @RequestMapping(value = "/fetch-info", method = RequestMethod.POST) - public ResponseEntity fetchInfo(@RequestBody Map sessionName) { - try { - System.out.println("Fetching session info | {sessionName}=" + sessionName); - - // Retrieve the param from BODY - String session = (String) sessionName.get("sessionName"); - - // If the session exists - if (this.mapSessions.get(session) != null && this.mapSessionNamesTokens.get(session) != null) { - Session s = this.mapSessions.get(session); - boolean changed = s.fetch(); - System.out.println("Any change: " + changed); - return new ResponseEntity<>(this.sessionToJson(s), HttpStatus.OK); - } else { - // The SESSION does not exist - System.out.println("Problems in the app server: the SESSION does not exist"); - return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); - } - } catch (OpenViduJavaClientException | OpenViduHttpException e) { - e.printStackTrace(); - return getErrorResponse(e); - } - } - - @RequestMapping(value = "/fetch-all", method = RequestMethod.GET) - public ResponseEntity fetchAll() { - try { - System.out.println("Fetching all session info"); - boolean changed = this.openVidu.fetch(); - System.out.println("Any change: " + changed); - JsonArray jsonArray = new JsonArray(); - for (Session s : this.openVidu.getActiveSessions()) { - jsonArray.add(this.sessionToJson(s)); - } - return new ResponseEntity<>(jsonArray, HttpStatus.OK); - } catch (OpenViduJavaClientException | OpenViduHttpException e) { - e.printStackTrace(); - return getErrorResponse(e); - } - } - - @RequestMapping(value = "/force-disconnect", method = RequestMethod.DELETE) - public ResponseEntity forceDisconnect(@RequestBody Map params) { - try { - // Retrieve the param from BODY - String session = (String) params.get("sessionName"); - String connectionId = (String) params.get("connectionId"); - - // If the session exists - if (this.mapSessions.get(session) != null && this.mapSessionNamesTokens.get(session) != null) { - Session s = this.mapSessions.get(session); - s.forceDisconnect(connectionId); - return new ResponseEntity<>(HttpStatus.OK); - } else { - // The SESSION does not exist - return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); - } - } catch (OpenViduJavaClientException | OpenViduHttpException e) { - e.printStackTrace(); - return getErrorResponse(e); - } - } - - @RequestMapping(value = "/force-unpublish", method = RequestMethod.DELETE) - public ResponseEntity forceUnpublish(@RequestBody Map params) { - try { - // Retrieve the param from BODY - String session = (String) params.get("sessionName"); - String streamId = (String) params.get("streamId"); - - // If the session exists - if (this.mapSessions.get(session) != null && this.mapSessionNamesTokens.get(session) != null) { - Session s = this.mapSessions.get(session); - s.forceUnpublish(streamId); - return new ResponseEntity<>(HttpStatus.OK); - } else { - // The SESSION does not exist - return new ResponseEntity<>(HttpStatus.NOT_FOUND); - } - } catch (OpenViduJavaClientException | OpenViduHttpException e) { - e.printStackTrace(); - return getErrorResponse(e); - } + response.put("token", token.toJwt()); + return new ResponseEntity<>(response.toMap(), HttpStatus.OK); } /*******************/ /** Recording API **/ /*******************/ - @RequestMapping(value = "/recording/start", method = RequestMethod.POST) + @RequestMapping(value = "/recordings/start", method = RequestMethod.POST) public ResponseEntity startRecording(@RequestBody Map params) { - String sessionId = (String) params.get("session"); - Recording.OutputMode outputMode = Recording.OutputMode.valueOf((String) params.get("outputMode")); - boolean hasAudio = (boolean) params.get("hasAudio"); - boolean hasVideo = (boolean) params.get("hasVideo"); - - RecordingProperties properties = new RecordingProperties.Builder().outputMode(outputMode).hasAudio(hasAudio) - .hasVideo(hasVideo).build(); - - System.out.println("Starting recording for session " + sessionId + " with properties {outputMode=" + outputMode - + ", hasAudio=" + hasAudio + ", hasVideo=" + hasVideo + "}"); - try { - Recording recording = this.openVidu.startRecording(sessionId, properties); - this.sessionRecordings.put(sessionId, true); - return new ResponseEntity<>(recording, HttpStatus.OK); - } catch (OpenViduJavaClientException | OpenViduHttpException e) { - return new ResponseEntity<>(e.getMessage(), HttpStatus.BAD_REQUEST); + String roomName = (String) params.get("roomName"); + String outputMode = (String) params.get("outputMode"); + Boolean videoOnly = (Boolean) params.get("videoOnly"); + Boolean audioOnly = (Boolean) params.get("audioOnly"); + String audioTrackId = (String) params.get("audioTrackId"); + String videoTrackId = (String) params.get("videoTrackId"); + + Builder outputBuilder = LivekitEgress.EncodedFileOutput.newBuilder() + .setFileType(EncodedFileType.DEFAULT_FILETYPE) + .setFilepath("/recordings/" + roomName + "-" + new Date().getTime()) + .setDisableManifest(true); + + EncodedFileOutput output = outputBuilder.build(); + + System.out.println("Starting recording " + roomName); + + LivekitEgress.EgressInfo egressInfo; + + if ("COMPOSED".equals(outputMode)) { + + System.out.println("Starting COMPOSED recording " + roomName); + egressInfo = this.egressClient + .startRoomCompositeEgress(roomName, output, "grid", null, null, audioOnly, videoOnly) + .execute().body(); + } else if ("INDIVIDUAL".equals(outputMode)) { + System.out.println("Starting INDIVIDUAL recording " + roomName); + egressInfo = this.egressClient.startTrackCompositeEgress(roomName, output, audioTrackId, videoTrackId) + .execute().body(); + } else { + return ResponseEntity.badRequest().body("outputMode is required"); + } + + return ResponseEntity.ok().body(generateEgressInfoResponse(egressInfo)); + + } catch (Exception e) { + System.out.println("Error starting recording " + e.getMessage()); + return ResponseEntity.badRequest().body("Error starting recording"); } } - @RequestMapping(value = "/recording/stop", method = RequestMethod.POST) + @RequestMapping(value = "/recordings/stop", method = RequestMethod.POST) public ResponseEntity stopRecording(@RequestBody Map params) { - String recordingId = (String) params.get("recording"); + + String recordingId = (String) params.get("recordingId"); + + if (recordingId == null) { + return ResponseEntity.badRequest().body("recordingId is required"); + } System.out.println("Stoping recording | {recordingId}=" + recordingId); try { - Recording recording = this.openVidu.stopRecording(recordingId); - this.sessionRecordings.remove(recording.getSessionId()); - return new ResponseEntity<>(recording, HttpStatus.OK); - } catch (OpenViduJavaClientException | OpenViduHttpException e) { - return new ResponseEntity<>(e.getMessage(), HttpStatus.BAD_REQUEST); + LivekitEgress.EgressInfo egressInfo = this.egressClient.stopEgress(recordingId).execute().body(); + return ResponseEntity.ok().body(generateEgressInfoResponse(egressInfo)); + } catch (Exception e) { + System.out.println("Error stoping recording " + e.getMessage()); + return ResponseEntity.badRequest().body("Error stoping recording"); } } - @RequestMapping(value = "/recording/delete", method = RequestMethod.DELETE) - public ResponseEntity deleteRecording(@RequestBody Map params) { - String recordingId = (String) params.get("recording"); - - System.out.println("Deleting recording | {recordingId}=" + recordingId); + @RequestMapping(value = "/recordings", method = RequestMethod.DELETE) + public ResponseEntity deleteRecordings() { try { - this.openVidu.deleteRecording(recordingId); - return new ResponseEntity<>(HttpStatus.OK); - } catch (OpenViduJavaClientException | OpenViduHttpException e) { - return new ResponseEntity<>(e.getMessage(), HttpStatus.BAD_REQUEST); + File recordingsDir = ResourceUtils.getFile("classpath:static"); + deleteFiles(new File(RECORDINGS_PATH)); + deleteFiles(new File(recordingsDir.getAbsolutePath())); + JSONObject response = new JSONObject(); + response.put("message", "All recordings deleted"); + + return ResponseEntity.ok().body(response.toMap()); + } catch (IOException e) { + e.printStackTrace(); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Error deleting recordings"); } } - @RequestMapping(value = "/recording/get/{recordingId}", method = RequestMethod.GET) - public ResponseEntity getRecording(@PathVariable(value = "recordingId") String recordingId) { - - System.out.println("Getting recording | {recordingId}=" + recordingId); - - try { - Recording recording = this.openVidu.getRecording(recordingId); - return new ResponseEntity<>(recording, HttpStatus.OK); - } catch (OpenViduJavaClientException | OpenViduHttpException e) { - return new ResponseEntity<>(e.getMessage(), HttpStatus.BAD_REQUEST); - } - } - - @RequestMapping(value = "/recording/list", method = RequestMethod.GET) + @RequestMapping(value = "/recordings/list", method = RequestMethod.GET) public ResponseEntity listRecordings() { System.out.println("Listing recordings"); - try { - List recordings = this.openVidu.listRecordings(); + List recordings = new ArrayList<>(); - return new ResponseEntity<>(recordings, HttpStatus.OK); - } catch (OpenViduJavaClientException | OpenViduHttpException e) { - return new ResponseEntity<>(e.getMessage(), HttpStatus.BAD_REQUEST); + try { + File recordingsDir = ResourceUtils.getFile("classpath:static"); + Files.walk(Path.of(RECORDINGS_PATH)).forEach(filePath -> { + JSONObject recordingsMap = new JSONObject(); + + if (Files.isRegularFile(filePath)) { + String fileName = filePath.getFileName().toString(); + String destinationPath = recordingsDir.getAbsolutePath() + File.separator + fileName; + + try { + Files.copy(filePath, Path.of(destinationPath), StandardCopyOption.REPLACE_EXISTING); + } catch (IOException e) { + e.printStackTrace(); + } + + recordingsMap.put("name", fileName); + recordingsMap.put("path", "/" + fileName); + + recordings.add(recordingsMap); + } + }); + } catch (IOException e) { + e.printStackTrace(); + return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); + } + + JSONObject response = new JSONObject(); + response.put("recordings", recordings); + return new ResponseEntity<>(response.toMap(), HttpStatus.OK); + } + + private void deleteFiles(File directory) { + if (directory.exists() && directory.isDirectory()) { + File[] files = directory.listFiles(); + if (files != null) { + for (File file : files) { + if (file.isDirectory()) { + deleteFiles(file); + } else { + if (file.delete()) { + System.out.println("Deleted file: " + file.getAbsolutePath()); + } else { + System.out.println("Failed to delete file: " + file.getAbsolutePath()); + } + } + } + } } } - private ResponseEntity getErrorResponse(Exception e) { - JsonObject json = new JsonObject(); - json.addProperty("cause", e.getCause().toString()); - json.addProperty("error", e.getMessage()); - json.addProperty("exception", e.getClass().getCanonicalName()); - return new ResponseEntity<>(json, HttpStatus.INTERNAL_SERVER_ERROR); - } + private Map generateEgressInfoResponse(EgressInfo egressInfo) { + JSONObject info = new JSONObject(); + JSONObject response = new JSONObject(); + + info.put("egressId", egressInfo.getEgressId()); + info.put("roomName", egressInfo.getRoomName()); + info.put("status", egressInfo.getStatus().toString()); + + response.put("info", info); + return response.toMap(); - protected JsonObject sessionToJson(Session session) { - Gson gson = new Gson(); - JsonObject json = new JsonObject(); - json.addProperty("sessionId", session.getSessionId()); - json.addProperty("customSessionId", session.getProperties().customSessionId()); - json.addProperty("recording", session.isBeingRecorded()); - json.addProperty("mediaMode", session.getProperties().mediaMode().name()); - json.addProperty("recordingMode", session.getProperties().recordingMode().name()); - json.add("defaultRecordingProperties", - gson.toJsonTree(session.getProperties().defaultRecordingProperties()).getAsJsonObject()); - JsonObject connections = new JsonObject(); - connections.addProperty("numberOfElements", session.getConnections().size()); - JsonArray jsonArrayConnections = new JsonArray(); - session.getConnections().forEach(con -> { - JsonObject c = new JsonObject(); - c.addProperty("connectionId", con.getConnectionId()); - c.addProperty("role", con.getRole().name()); - c.addProperty("token", con.getToken()); - c.addProperty("clientData", con.getClientData()); - c.addProperty("serverData", con.getServerData()); - JsonArray pubs = new JsonArray(); - con.getPublishers().forEach(p -> { - pubs.add(gson.toJsonTree(p).getAsJsonObject()); - }); - JsonArray subs = new JsonArray(); - con.getSubscribers().forEach(s -> { - subs.add(s); - }); - c.add("publishers", pubs); - c.add("subscribers", subs); - jsonArrayConnections.add(c); - }); - connections.add("content", jsonArrayConnections); - json.add("connections", connections); - return json; } } diff --git a/openvidu-recording-java/src/main/resources/application-container.properties b/openvidu-recording-java/src/main/resources/application-container.properties index 8a4b7c07..5ab5cc01 100644 --- a/openvidu-recording-java/src/main/resources/application-container.properties +++ b/openvidu-recording-java/src/main/resources/application-container.properties @@ -3,6 +3,7 @@ spring.profiles.active=container server.port: 3000 server.ssl.enabled: false -openvidu.url: http://localhost:5000/ -openvidu.secret: MY_SECRET -openvidu.publicurl: ngrok + +LIVEKIT_URL: ws://localhost:7880/ +LIVEKIT_API_KEY: http://localhost:4443/ +LIVEKIT_API_SECRET: MY_SECRET \ No newline at end of file diff --git a/openvidu-recording-java/src/main/resources/application.properties b/openvidu-recording-java/src/main/resources/application.properties index 61bef2ed..93773915 100644 --- a/openvidu-recording-java/src/main/resources/application.properties +++ b/openvidu-recording-java/src/main/resources/application.properties @@ -6,5 +6,7 @@ server.ssl.key-store-type: JKS server.ssl.key-alias: openvidu-selfsigned spring.http.converters.preferred-json-mapper=gson -openvidu.url: http://localhost:4443/ -openvidu.secret: MY_SECRET +LIVEKIT_URL: ws://localhost:7880/ +LIVEKIT_API_KEY: key1 +LIVEKIT_API_SECRET: abcdefghijklmnopqrstuvwxyz123456 +RECORDINGS_PATH: ../../openvidu-lk/local-deployment/cluster/recordings \ No newline at end of file diff --git a/openvidu-recording-java/src/main/resources/static/app.js b/openvidu-recording-java/src/main/resources/static/app.js index 9cc03476..ce00514f 100644 --- a/openvidu-recording-java/src/main/resources/static/app.js +++ b/openvidu-recording-java/src/main/resources/static/app.js @@ -1,415 +1,289 @@ -var OV; -var session; - -var sessionName; +var LivekitClient = window.LivekitClient; +var room; +var myRoomName; var token; +var nickname; var numVideos = 0; +var localVideoPublication; +var localAudioPublication; /* OPENVIDU METHODS */ -function joinSession() { - +function joinRoom() { // --- 0) Change the button --- - - document.getElementById("join-btn").disabled = true; - document.getElementById("join-btn").innerHTML = "Joining..."; - getToken(function () { + document.getElementById('join-btn').disabled = true; + document.getElementById('join-btn').innerHTML = 'Joining...'; + const myParticipantName = `Participant${Math.floor(Math.random() * 100)}`; + const myRoomName = $('#roomName').val(); - // --- 1) Get an OpenVidu object --- + room = new LivekitClient.Room(); - OV = new OpenVidu(); - - // --- 2) Init a session --- - - session = OV.initSession(); - - // --- 3) Specify the actions when events take place in the session --- - - session.on('connectionCreated', event => { - pushEvent(event); - }); - - session.on('connectionDestroyed', event => { - pushEvent(event); - }); - - // On every new Stream received... - session.on('streamCreated', event => { - pushEvent(event); - - // Subscribe to the Stream to receive it - // HTML video will be appended to element with 'video-container' id - var subscriber = session.subscribe(event.stream, 'video-container'); - - // When the HTML video has been appended to DOM... - subscriber.on('videoElementCreated', event => { - pushEvent(event); - // Add a new HTML element for the user's name and nickname over its video + room.on( + LivekitClient.RoomEvent.TrackSubscribed, + (track, publication, participant) => { + const element = track.attach(); + element.id = track.sid; + document.getElementById('video-container').appendChild(element); + if (track.kind === 'video') { + var audioTrackId; + var videoTrackId; + participant.getTracks().forEach((track) => { + if (track.kind === 'audio') { + audioTrackId = track.trackInfo.sid; + } else if (track.kind === 'video') { + videoTrackId = track.trackInfo.sid; + } + }); + addIndividualRecordingButton(element.id, videoTrackId, audioTrackId); updateNumVideos(1); - }); + } + } + ); - // When the HTML video has been appended to DOM... - subscriber.on('videoElementDestroyed', event => { - pushEvent(event); - // Add a new HTML element for the user's name and nickname over its video + // On every new Track destroyed... + room.on( + LivekitClient.RoomEvent.TrackUnsubscribed, + (track, publication, participant) => { + track.detach(); + document.getElementById(track.sid)?.remove(); + if (track.kind === 'video') { + // removeUserData(participant); updateNumVideos(-1); - }); - - // When the subscriber stream has started playing media... - subscriber.on('streamPlaying', event => { - pushEvent(event); - }); - }); - - session.on('streamDestroyed', event => { - pushEvent(event); - }); - - session.on('sessionDisconnected', event => { - pushEvent(event); - if (event.reason !== 'disconnect') { - removeUser(); } - if (event.reason !== 'sessionClosedByServer') { - session = null; - numVideos = 0; - $('#join').show(); - $('#session').hide(); - } - }); + } + ); - session.on('recordingStarted', event => { - pushEvent(event); - }); + room.on(LivekitClient.RoomEvent.RecordingStatusChanged, (isRecording) => { + console.log('Recording status changed: ' + status); + if (!isRecording) { + listRecordings(); + } + }); - session.on('recordingStopped', event => { - pushEvent(event); - }); + getToken(myRoomName, myParticipantName).then(async (token) => { + const livekitUrl = getLivekitUrlFromMetadata(token); - // On every asynchronous exception... - session.on('exception', (exception) => { - console.warn(exception); - }); + try { + await room.connect(livekitUrl, token); - // --- 4) Connect to the session passing the retrieved token and some more data from - // the client (in this case a JSON with the nickname chosen by the user) --- + var participantName = $('#user').val(); + $('#room-title').text(myRoomName); + $('#join').hide(); + $('#room').show(); - session.connect(token) - .then(() => { + const [audioPublication, videoPublication] = await Promise.all([ + room.localParticipant.setMicrophoneEnabled(true), + room.localParticipant.setCameraEnabled(true), + ]); + localVideoPublication = videoPublication; + localAudioPublication = audioPublication; - // --- 5) Set page layout for active call --- - - $('#session-title').text(sessionName); - $('#join').hide(); - $('#session').show(); - - // --- 6) Get your own camera stream --- - - var publisher = OV.initPublisher('video-container', { - audioSource: undefined, // The source of audio. If undefined default microphone - videoSource: undefined, // The source of video. If undefined default webcam - publishAudio: true, // Whether you want to start publishing with your audio unmuted or not - publishVideo: true, // Whether you want to start publishing with your video enabled or not - resolution: '640x480', // The resolution of your video - frameRate: 30, // The frame rate of your video - insertMode: 'APPEND', // How the video is inserted in the target element 'video-container' - mirror: false // Whether to mirror your local video or not - }); - - // --- 7) Specify the actions when events take place in our publisher --- - - // When the publisher stream has started playing media... - publisher.on('accessAllowed', event => { - pushEvent({ - type: 'accessAllowed' - }); - }); - - publisher.on('accessDenied', event => { - pushEvent(event); - }); - - publisher.on('accessDialogOpened', event => { - pushEvent({ - type: 'accessDialogOpened' - }); - }); - - publisher.on('accessDialogClosed', event => { - pushEvent({ - type: 'accessDialogClosed' - }); - }); - - // When the publisher stream has started playing media... - publisher.on('streamCreated', event => { - pushEvent(event); - }); - - // When our HTML video has been added to DOM... - publisher.on('videoElementCreated', event => { - pushEvent(event); - updateNumVideos(1); - $(event.element).prop('muted', true); // Mute local video - }); - - // When the HTML video has been appended to DOM... - publisher.on('videoElementDestroyed', event => { - pushEvent(event); - // Add a new HTML element for the user's name and nickname over its video - updateNumVideos(-1); - }); - - // When the publisher stream has started playing media... - publisher.on('streamPlaying', event => { - pushEvent(event); - }); - - // --- 8) Publish your stream --- - - session.publish(publisher); - - }) - .catch(error => { - console.warn('There was an error connecting to the session:', error.code, error.message); - enableBtn(); - }); + console.log('Connected to room ' + myRoomName); + const element = videoPublication.track.attach(); + element.id = videoPublication.track.sid; + document.getElementById('video-container').appendChild(element); + addIndividualRecordingButton( + element.id, + videoPublication.track.sid, + audioPublication.track.sid + ); + updateNumVideos(1); + } catch (error) { + console.warn( + 'There was an error connecting to the room:', + error.code, + error.message + ); + enableBtn(); + } return false; }); } -function leaveSession() { +function leaveRoom() { + room.disconnect(); + room = null; + + $('#video-container').empty(); + numVideos = 0; + + $('#join').show(); + $('#room').hide(); - // --- 9) Leave the session by calling 'disconnect' method over the Session object --- - session.disconnect(); enableBtn(); - } /* OPENVIDU METHODS */ -function enableBtn (){ - document.getElementById("join-btn").disabled = false; - document.getElementById("join-btn").innerHTML = "Join!"; +function enableBtn() { + document.getElementById('join-btn').disabled = false; + document.getElementById('join-btn').innerHTML = 'Join!'; } /* APPLICATION REST METHODS */ -function getToken(callback) { - sessionName = $("#sessionName").val(); // Video-call chosen by the user - - httpRequest( - 'POST', - 'recording-java/api/get-token', { - sessionName: sessionName - }, - 'Request of TOKEN gone WRONG:', - res => { - token = res[0]; // Get token from response - console.warn('Request of TOKEN gone WELL (TOKEN:' + token + ')'); - callback(token); // Continue the join operation - } - ); +function getToken(roomName, participantName) { + return new Promise((resolve, reject) => { + // Video-call chosen by the user + httpRequest( + 'POST', + 'token', + { roomName, participantName }, + 'Error generating token', + (response) => resolve(response.token) + ); + }); } -function removeUser() { - httpRequest( - 'POST', - 'recording-java/api/remove-user', { - sessionName: sessionName, - token: token - }, - 'User couldn\'t be removed from session', - res => { - console.warn("You have been removed from session " + sessionName); - } - ); -} +async function httpRequest(method, url, body, errorMsg, successCallback) { + try { + const response = await fetch(url, { + method, + headers: { + 'Content-Type': 'application/json', + }, + body: method === 'GET' ? undefined : JSON.stringify(body), + }); -function closeSession() { - httpRequest( - 'DELETE', - 'recording-java/api/close-session', { - sessionName: sessionName - }, - 'Session couldn\'t be closed', - res => { - console.warn("Session " + sessionName + " has been closed"); - } - ); -} - -function fetchInfo() { - httpRequest( - 'POST', - 'recording-java/api/fetch-info', { - sessionName: sessionName - }, - 'Session couldn\'t be fetched', - res => { - console.warn("Session info has been fetched"); - $('#textarea-http').text(JSON.stringify(res, null, "\t")); - } - ); -} - -function fetchAll() { - httpRequest( - 'GET', - 'recording-java/api/fetch-all', {}, - 'All session info couldn\'t be fetched', - res => { - console.warn("All session info has been fetched"); - $('#textarea-http').text(JSON.stringify(res, null, "\t")); - } - ); -} - -function forceDisconnect() { - httpRequest( - 'DELETE', - 'recording-java/api/force-disconnect', { - sessionName: sessionName, - connectionId: document.getElementById('forceValue').value - }, - 'Connection couldn\'t be closed', - res => { - console.warn("Connection has been closed"); - } - ); -} - -function forceUnpublish() { - httpRequest( - 'DELETE', - 'recording-java/api/force-unpublish', { - sessionName: sessionName, - streamId: document.getElementById('forceValue').value - }, - 'Stream couldn\'t be closed', - res => { - console.warn("Stream has been closed"); - } - ); -} - -function httpRequest(method, url, body, errorMsg, callback) { - $('#textarea-http').text(''); - var http = new XMLHttpRequest(); - http.open(method, url, true); - http.setRequestHeader('Content-type', 'application/json'); - http.addEventListener('readystatechange', processRequest, false); - http.send(JSON.stringify(body)); - - function processRequest() { - if (http.readyState == 4) { - if (http.status == 200) { - try { - callback(JSON.parse(http.responseText)); - } catch (e) { - callback(e); - } - } else { - console.warn(errorMsg + ' (' + http.status + ')'); - console.warn(http.responseText); - $('#textarea-http').text(errorMsg + ": HTTP " + http.status + " (" + http.responseText + ")"); - } + if (response.ok) { + const data = await response.json(); + successCallback(data); + } else { + console.warn(errorMsg); + console.warn('Error: ' + response.statusText); } + } catch (error) { + console.error(error); } } -function startRecording() { - var outputMode = $('input[name=outputMode]:checked').val(); +function startComposedRecording() { var hasAudio = $('#has-audio-checkbox').prop('checked'); var hasVideo = $('#has-video-checkbox').prop('checked'); + httpRequest( 'POST', - 'recording-java/api/recording/start', { - session: session.sessionId, - outputMode: outputMode, - hasAudio: hasAudio, - hasVideo: hasVideo + 'recordings/start', + { + roomName: room.roomInfo.name, + outputMode: 'COMPOSED', + videoOnly: hasVideo && !hasAudio, + audioOnly: hasAudio && !hasVideo, }, 'Start recording WRONG', - res => { + (res) => { console.log(res); document.getElementById('forceRecordingId').value = res.id; checkBtnsRecordings(); - $('#textarea-http').text(JSON.stringify(res, null, "\t")); + $('#textarea-http').text(JSON.stringify(res, null, '\t')); } ); } -function stopRecording() { - var forceRecordingId = document.getElementById('forceRecordingId').value; +function startIndividualRecording(videoTrackId, audioTrackId) { + return new Promise((resolve, reject) => { + httpRequest( + 'POST', + 'recordings/start', + { + roomName: room.roomInfo.name, + outputMode: 'INDIVIDUAL', + audioTrackId, + videoTrackId, + }, + 'Start recording WRONG', + (res) => { + console.log(res); + $('#textarea-http').text(JSON.stringify(res.info, null, '\t')); + resolve(res); + } + ); + }); +} + +function stopRecording(id) { + var forceRecordingId = id ? id : $('#forceRecordingId').val(); httpRequest( 'POST', - 'recording-java/api/recording/stop', { - recording: forceRecordingId + 'recordings/stop', + { + recordingId: forceRecordingId, }, 'Stop recording WRONG', - res => { + (res) => { console.log(res); - $('#textarea-http').text(JSON.stringify(res, null, "\t")); - } - ); -} - -function deleteRecording() { - var forceRecordingId = document.getElementById('forceRecordingId').value; - httpRequest( - 'DELETE', - 'recording-java/api/recording/delete', { - recording: forceRecordingId - }, - 'Delete recording WRONG', - res => { - console.log("DELETE ok"); - $('#textarea-http').text("DELETE ok"); - } - ); -} - -function getRecording() { - var forceRecordingId = document.getElementById('forceRecordingId').value; - httpRequest( - 'GET', - 'recording-java/api/recording/get/' + forceRecordingId, {}, - 'Get recording WRONG', - res => { - console.log(res); - $('#textarea-http').text(JSON.stringify(res, null, "\t")); + $('#forceRecordingId').val(''); + $('#textarea-http').text(JSON.stringify(res.info, null, '\t')); } ); } function listRecordings() { - httpRequest( - 'GET', - 'recording-java/api/recording/list', {}, - 'List recordings WRONG', - res => { - console.log(res); - $('#textarea-http').text(JSON.stringify(res, null, "\t")); + httpRequest('GET', 'recordings/list', {}, 'List recordings WRONG', (res) => { + console.log(res); + $('#recording-list').empty(); + if (res.recordings && res.recordings.length > 0) { + res.recordings.forEach((recording) => { + var li = document.createElement('li'); + var a = document.createElement('a'); + a.href = recording.path; + a.target = '_blank'; + a.appendChild(document.createTextNode(recording.name)); + li.appendChild(a); + $('#recording-list').append(li); + }); + $('#delete-recordings-btn').prop('disabled', res.recordings.length === 0); } - ); + }); +} + +function deleteRecordings() { + httpRequest('DELETE', 'recordings', {}, 'Delete recordings WRONG', (res) => { + console.log(res); + $('#recording-list').empty(); + $('#delete-recordings-btn').prop('disabled', true); + $('#textarea-http').text(JSON.stringify(res, null, '\t')); + }); } /* APPLICATION REST METHODS */ - - /* APPLICATION BROWSER METHODS */ events = ''; -window.onbeforeunload = function () { // Gracefully leave session - if (session) { +window.onbeforeunload = function () { + // Gracefully leave room + if (room) { removeUser(); - leaveSession(); + leaveRoom(); + } +}; + +function getLivekitUrlFromMetadata(token) { + if (!token) throw new Error('Trying to get metadata from an empty token'); + try { + const base64Url = token.split('.')[1]; + const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); + const jsonPayload = decodeURIComponent( + window + .atob(base64) + .split('') + .map((c) => { + return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2); + }) + .join('') + ); + + const payload = JSON.parse(jsonPayload); + if (!payload?.metadata) throw new Error('Token does not contain metadata'); + const metadata = JSON.parse(payload.metadata); + return metadata.livekitUrl; + } catch (error) { + throw new Error('Error decoding and parsing token: ' + error); } } @@ -432,40 +306,43 @@ function updateNumVideos(i) { } } -function checkBtnsForce() { - if (document.getElementById("forceValue").value === "") { - document.getElementById('buttonForceUnpublish').disabled = true; - document.getElementById('buttonForceDisconnect').disabled = true; - } else { - document.getElementById('buttonForceUnpublish').disabled = false; - document.getElementById('buttonForceDisconnect').disabled = false; - } -} - function checkBtnsRecordings() { - if (document.getElementById("forceRecordingId").value === "") { - document.getElementById('buttonGetRecording').disabled = true; + if (document.getElementById('forceRecordingId').value === '') { document.getElementById('buttonStopRecording').disabled = true; - document.getElementById('buttonDeleteRecording').disabled = true; } else { - document.getElementById('buttonGetRecording').disabled = false; document.getElementById('buttonStopRecording').disabled = false; - document.getElementById('buttonDeleteRecording').disabled = false; } } -function pushEvent(event) { - events += (!events ? '' : '\n') + event.type; - $('#textarea-events').text(events); +function addIndividualRecordingButton(elementId, videoTrackId, audioTrackId) { + const div = document.createElement('div'); + + var button = document.createElement('button'); + // button.id = elementId + '-button'; + button.className = 'recording-track-button btn btn-sm'; + + button.innerHTML = 'Record Track'; + button.style = 'position: absolute; left: 0; z-index: 1000;'; + + button.onclick = async () => { + if (button.innerHTML === 'Record Track') { + button.innerHTML = 'Stop Recording'; + button.className = 'recording-track-button btn btn-sm btn-danger'; + var res = await startIndividualRecording(videoTrackId, audioTrackId); + button.id = res.info.egressId; + } else { + button.innerHTML = 'Record Track'; + button.className = 'recording-track-button btn btn-sm'; + stopRecording(button.id); + } + }; + div.appendChild(button); + var element = document.getElementById(elementId); + element.parentNode.insertBefore(div, element.nextSibling); } function clearHttpTextarea() { $('#textarea-http').text(''); } -function clearEventsTextarea() { - $('#textarea-events').text(''); - events = ''; -} - -/* APPLICATION BROWSER METHODS */ \ No newline at end of file +/* APPLICATION BROWSER METHODS */ diff --git a/openvidu-recording-java/src/main/resources/static/images/subscriber-msg.jpg b/openvidu-recording-java/src/main/resources/static/images/subscriber-msg.jpg deleted file mode 100644 index 1666fb40..00000000 Binary files a/openvidu-recording-java/src/main/resources/static/images/subscriber-msg.jpg and /dev/null differ diff --git a/openvidu-recording-java/src/main/resources/static/index.html b/openvidu-recording-java/src/main/resources/static/index.html index afdcdf52..1ded9bdd 100644 --- a/openvidu-recording-java/src/main/resources/static/index.html +++ b/openvidu-recording-java/src/main/resources/static/index.html @@ -1,7 +1,7 @@ - openvidu-recording-java + openvidu-recording-node @@ -17,7 +17,7 @@ - +