From 496d33b139d63354d290626bbe9c17e1ee28b918 Mon Sep 17 00:00:00 2001 From: pabloFuente Date: Thu, 24 Jan 2019 11:21:35 +0100 Subject: [PATCH] openvidu-server: audio-only and video-only recordings --- .../io/openvidu/client/OpenViduException.java | 30 +-- .../java/client/RecordingProperties.java | 4 +- .../server/cdr/CDREventRecording.java | 2 +- .../openvidu/server/cdr/CallDetailRecord.java | 2 +- .../openvidu/server/core/SessionManager.java | 11 +- .../kurento/core/KurentoParticipant.java | 4 +- .../kurento/core/KurentoSessionManager.java | 21 +- .../server/recording/CompositeWrapper.java | 156 ++++++++++++ .../recording/RecorderEndpointWrapper.java | 24 +- .../openvidu/server/recording/Recording.java | 8 +- .../service/ComposedRecordingService.java | 228 ++++++++++++------ .../recording/service/RecordingManager.java | 121 ++++++++-- .../recording/service/RecordingService.java | 76 +++++- .../service/SingleStreamRecordingService.java | 93 +++---- .../server/rest/SessionRestController.java | 21 +- .../io/openvidu/server/rpc/RpcHandler.java | 2 +- .../openvidu/server/utils/FormatChecker.java | 6 +- 17 files changed, 594 insertions(+), 215 deletions(-) create mode 100644 openvidu-server/src/main/java/io/openvidu/server/recording/CompositeWrapper.java diff --git a/openvidu-client/src/main/java/io/openvidu/client/OpenViduException.java b/openvidu-client/src/main/java/io/openvidu/client/OpenViduException.java index 01841680..746b5651 100644 --- a/openvidu-client/src/main/java/io/openvidu/client/OpenViduException.java +++ b/openvidu-client/src/main/java/io/openvidu/client/OpenViduException.java @@ -27,28 +27,30 @@ public class OpenViduException extends JsonRpcErrorException { TRANSPORT_ERROR_CODE(803), TRANSPORT_RESPONSE_ERROR_CODE(802), TRANSPORT_REQUEST_ERROR_CODE(801), - MEDIA_TYPE_RECORDING_PROPERTIES_ERROR_CODE(308), MEDIA_MUTE_ERROR_CODE(307), MEDIA_NOT_A_WEB_ENDPOINT_ERROR_CODE(306), MEDIA_RTP_ENDPOINT_ERROR_CODE( - 305), MEDIA_WEBRTC_ENDPOINT_ERROR_CODE( - 304), MEDIA_ENDPOINT_ERROR_CODE(303), MEDIA_SDP_ERROR_CODE(302), MEDIA_GENERIC_ERROR_CODE(301), + MEDIA_TYPE_STREAM_INCOMPATIBLE_WITH_RECORDING_PROPERTIES_ERROR_CODE(309), + MEDIA_TYPE_RECORDING_PROPERTIES_ERROR_CODE(308), MEDIA_MUTE_ERROR_CODE(307), + MEDIA_NOT_A_WEB_ENDPOINT_ERROR_CODE(306), MEDIA_RTP_ENDPOINT_ERROR_CODE(305), + MEDIA_WEBRTC_ENDPOINT_ERROR_CODE(304), MEDIA_ENDPOINT_ERROR_CODE(303), MEDIA_SDP_ERROR_CODE(302), + MEDIA_GENERIC_ERROR_CODE(301), - ROOM_CANNOT_BE_CREATED_ERROR_CODE(204), ROOM_CLOSED_ERROR_CODE(203), ROOM_NOT_FOUND_ERROR_CODE( - 202), ROOM_GENERIC_ERROR_CODE(201), + ROOM_CANNOT_BE_CREATED_ERROR_CODE(204), ROOM_CLOSED_ERROR_CODE(203), ROOM_NOT_FOUND_ERROR_CODE(202), + ROOM_GENERIC_ERROR_CODE(201), - USER_NOT_STREAMING_ERROR_CODE(105), EXISTING_USER_IN_ROOM_ERROR_CODE(104), USER_CLOSED_ERROR_CODE( - 103), USER_NOT_FOUND_ERROR_CODE(102), USER_GENERIC_ERROR_CODE(10), + USER_NOT_STREAMING_ERROR_CODE(105), EXISTING_USER_IN_ROOM_ERROR_CODE(104), USER_CLOSED_ERROR_CODE(103), + USER_NOT_FOUND_ERROR_CODE(102), USER_GENERIC_ERROR_CODE(10), - USER_UNAUTHORIZED_ERROR_CODE(401), ROLE_NOT_FOUND_ERROR_CODE(402), SESSIONID_CANNOT_BE_CREATED_ERROR_CODE( - 403), TOKEN_CANNOT_BE_CREATED_ERROR_CODE(404), EXISTING_FILTER_ALREADY_APPLIED_ERROR_CODE(405), + USER_UNAUTHORIZED_ERROR_CODE(401), ROLE_NOT_FOUND_ERROR_CODE(402), SESSIONID_CANNOT_BE_CREATED_ERROR_CODE(403), + TOKEN_CANNOT_BE_CREATED_ERROR_CODE(404), EXISTING_FILTER_ALREADY_APPLIED_ERROR_CODE(405), FILTER_NOT_APPLIED_ERROR_CODE(406), FILTER_EVENT_LISTENER_NOT_FOUND(407), USER_METADATA_FORMAT_INVALID_ERROR_CODE(500), - SIGNAL_FORMAT_INVALID_ERROR_CODE(600), SIGNAL_TO_INVALID_ERROR_CODE(601), SIGNAL_MESSAGE_INVALID_ERROR_CODE( - 602), + SIGNAL_FORMAT_INVALID_ERROR_CODE(600), SIGNAL_TO_INVALID_ERROR_CODE(601), + SIGNAL_MESSAGE_INVALID_ERROR_CODE(602), - RECORDING_PATH_NOT_VALID(708), RECORDING_FILE_EMPTY_ERROR(707), RECORDING_DELETE_ERROR_CODE( - 706), RECORDING_LIST_ERROR_CODE(705), RECORDING_STOP_ERROR_CODE(704), RECORDING_START_ERROR_CODE( - 703), RECORDING_REPORT_ERROR_CODE(702), RECORDING_COMPLETION_ERROR_CODE(701); + RECORDING_PATH_NOT_VALID(708), RECORDING_FILE_EMPTY_ERROR(707), RECORDING_DELETE_ERROR_CODE(706), + RECORDING_LIST_ERROR_CODE(705), RECORDING_STOP_ERROR_CODE(704), RECORDING_START_ERROR_CODE(703), + RECORDING_REPORT_ERROR_CODE(702), RECORDING_COMPLETION_ERROR_CODE(701); private int value; diff --git a/openvidu-java-client/src/main/java/io/openvidu/java/client/RecordingProperties.java b/openvidu-java-client/src/main/java/io/openvidu/java/client/RecordingProperties.java index d9ea42a9..cc4055ae 100644 --- a/openvidu-java-client/src/main/java/io/openvidu/java/client/RecordingProperties.java +++ b/openvidu-java-client/src/main/java/io/openvidu/java/client/RecordingProperties.java @@ -121,7 +121,7 @@ public class RecordingProperties { * Call this method to specify whether or not to record the audio track */ public RecordingProperties.Builder hasAudio(boolean hasAudio) { - this.hasAudio = true; + this.hasAudio = hasAudio; return this; } @@ -129,7 +129,7 @@ public class RecordingProperties { * Call this method to specify whether or not to record the video track */ public RecordingProperties.Builder hasVideo(boolean hasVideo) { - this.hasVideo = true; + this.hasVideo = hasVideo; return this; } diff --git a/openvidu-server/src/main/java/io/openvidu/server/cdr/CDREventRecording.java b/openvidu-server/src/main/java/io/openvidu/server/cdr/CDREventRecording.java index f9403bae..3a594af2 100644 --- a/openvidu-server/src/main/java/io/openvidu/server/cdr/CDREventRecording.java +++ b/openvidu-server/src/main/java/io/openvidu/server/cdr/CDREventRecording.java @@ -44,7 +44,7 @@ public class CDREventRecording extends CDREventEnd { json.addProperty("id", this.recording.getId()); json.addProperty("name", this.recording.getName()); json.addProperty("outputMode", this.recording.getOutputMode().name()); - if (io.openvidu.java.client.Recording.OutputMode.COMPOSED.equals(this.recording.getOutputMode())) { + if (io.openvidu.java.client.Recording.OutputMode.COMPOSED.equals(this.recording.getOutputMode()) && this.recording.hasVideo()) { json.addProperty("resolution", this.recording.getResolution()); json.addProperty("recordingLayout", this.recording.getRecordingLayout().name()); if (RecordingLayout.CUSTOM.equals(this.recording.getRecordingLayout()) diff --git a/openvidu-server/src/main/java/io/openvidu/server/cdr/CallDetailRecord.java b/openvidu-server/src/main/java/io/openvidu/server/cdr/CallDetailRecord.java index 1a84b39d..492998ba 100644 --- a/openvidu-server/src/main/java/io/openvidu/server/cdr/CallDetailRecord.java +++ b/openvidu-server/src/main/java/io/openvidu/server/cdr/CallDetailRecord.java @@ -69,7 +69,7 @@ import io.openvidu.server.recording.service.RecordingManager; * - webrtcConnectionDestroyed.reason: "unsubscribe", "unpublish", "disconnect", "networkDisconnect", "openviduServerStopped" * - participantLeft.reason: "unsubscribe", "unpublish", "disconnect", "networkDisconnect", "openviduServerStopped" * - sessionDestroyed.reason: "lastParticipantLeft", "openviduServerStopped" - * - recordingStopped.reason: "recordingStoppedByServer", "lastParticipantLeft", "sessionClosedByServer", "openviduServerStopped" + * - recordingStopped.reason: "recordingStoppedByServer", "lastParticipantLeft", "sessionClosedByServer", "automaticStop", "openviduServerStopped" * * [OPTIONAL_PROPERTIES]: * - receivingFrom: only if connection = "INBOUND" diff --git a/openvidu-server/src/main/java/io/openvidu/server/core/SessionManager.java b/openvidu-server/src/main/java/io/openvidu/server/core/SessionManager.java index 9ac58c83..7f45b307 100644 --- a/openvidu-server/src/main/java/io/openvidu/server/core/SessionManager.java +++ b/openvidu-server/src/main/java/io/openvidu/server/core/SessionManager.java @@ -45,6 +45,7 @@ import io.openvidu.server.coturn.CoturnCredentialsService; import io.openvidu.server.coturn.TurnCredentials; import io.openvidu.server.kurento.core.KurentoTokenOptions; import io.openvidu.server.recording.service.RecordingManager; +import io.openvidu.server.utils.FormatChecker; public abstract class SessionManager { @@ -65,6 +66,8 @@ public abstract class SessionManager { @Autowired protected CoturnCredentialsService coturnCredentialsService; + public FormatChecker formatChecker = new FormatChecker(); + protected ConcurrentMap sessions = new ConcurrentHashMap<>(); protected ConcurrentMap sessionProperties = new ConcurrentHashMap<>(); protected ConcurrentMap sessionCreationTime = new ConcurrentHashMap<>(); @@ -223,7 +226,7 @@ public abstract class SessionManager { new ConcurrentHashMap<>()); if (map != null) { - if (!isMetadataFormatCorrect(serverMetadata)) { + if (!formatChecker.isServerMetadataFormatCorrect(serverMetadata)) { log.error("Data invalid format"); throw new OpenViduException(Code.GENERIC_ERROR_CODE, "Data invalid format"); } @@ -317,10 +320,6 @@ public abstract class SessionManager { return false; } - public boolean isMetadataFormatCorrect(String metadata) { - return true; - } - public void newInsecureParticipant(String participantPrivateId) { this.insecureUsers.put(participantPrivateId, true); } @@ -443,7 +442,7 @@ public abstract class SessionManager { return participants; } - protected void closeSessionAndEmptyCollections(Session session, String reason) { + public void closeSessionAndEmptyCollections(Session session, String reason) { if (openviduConfig.isRecordingModuleEnabled() && this.recordingManager.sessionIsBeingRecorded(session.getSessionId())) { diff --git a/openvidu-server/src/main/java/io/openvidu/server/kurento/core/KurentoParticipant.java b/openvidu-server/src/main/java/io/openvidu/server/kurento/core/KurentoParticipant.java index 93b7a378..e6134563 100644 --- a/openvidu-server/src/main/java/io/openvidu/server/kurento/core/KurentoParticipant.java +++ b/openvidu-server/src/main/java/io/openvidu/server/kurento/core/KurentoParticipant.java @@ -238,7 +238,7 @@ public class KurentoParticipant extends Participant { this.session.getSessionId()); if (this.openviduConfig.isRecordingModuleEnabled() - && this.recordingManager.sessionIsBeingRecordedIndividual(session.getSessionId())) { + && this.recordingManager.sessionIsBeingRecorded(session.getSessionId())) { this.recordingManager.startOneIndividualStreamRecording(session, null, null, this); } @@ -438,7 +438,7 @@ public class KurentoParticipant extends Participant { this.session.publishedStreamIds.remove(this.getPublisherStreamId()); if (this.openviduConfig.isRecordingModuleEnabled() - && this.recordingManager.sessionIsBeingRecordedIndividual(session.getSessionId())) { + && this.recordingManager.sessionIsBeingRecorded(session.getSessionId())) { this.recordingManager.stopOneIndividualStreamRecording(session.getSessionId(), this.getPublisherStreamId()); } diff --git a/openvidu-server/src/main/java/io/openvidu/server/kurento/core/KurentoSessionManager.java b/openvidu-server/src/main/java/io/openvidu/server/kurento/core/KurentoSessionManager.java index 663cc8c6..dfe690d4 100644 --- a/openvidu-server/src/main/java/io/openvidu/server/kurento/core/KurentoSessionManager.java +++ b/openvidu-server/src/main/java/io/openvidu/server/kurento/core/KurentoSessionManager.java @@ -172,12 +172,14 @@ public class KurentoSessionManager extends SessionManager { if (remainingParticipants.isEmpty()) { if (openviduConfig.isRecordingModuleEnabled() && MediaMode.ROUTED.equals(session.getSessionProperties().mediaMode()) - && this.recordingManager.sessionIsBeingRecordedIndividual(sessionId)) { - // Start countdown to stop recording if RecordingMode.MANUAL (will be aborted if - // a Publisher starts before timeout) - log.info("Last participant left. Starting 2 minutes countdown for stopping recording of session {}", - sessionId); - recordingManager.initAutomaticRecordingStopThread(session.getSessionId()); + && (this.recordingManager.sessionIsBeingRecordedIndividual(sessionId) + || (this.recordingManager.sessionIsBeingRecordedComposed(sessionId) + && this.recordingManager.sessionIsBeingRecordedOnlyAudio(sessionId)))) { + // Start countdown to stop recording if INDIVIDUAL mode or COMPOSED audio-only + // (will be aborted if a Publisher starts before timeout) + log.info("Last participant left. Starting {} seconds countdown for stopping recording of session {}", + this.openviduConfig.getOpenviduRecordingAutostopTimeout(), sessionId); + recordingManager.initAutomaticRecordingStopThread(session); } else { log.info("No more participants in session '{}', removing it and closing it", sessionId); this.closeSessionAndEmptyCollections(session, reason); @@ -186,6 +188,7 @@ public class KurentoSessionManager extends SessionManager { } else if (remainingParticipants.size() == 1 && openviduConfig.isRecordingModuleEnabled() && MediaMode.ROUTED.equals(session.getSessionProperties().mediaMode()) && this.recordingManager.sessionIsBeingRecordedComposed(sessionId) + && !this.recordingManager.sessionIsBeingRecordedOnlyAudio(sessionId) && ProtocolElements.RECORDER_PARTICIPANT_PUBLICID .equals(remainingParticipants.iterator().next().getParticipantPublicId())) { if (RecordingMode.ALWAYS.equals(session.getSessionProperties().recordingMode())) { @@ -198,9 +201,9 @@ public class KurentoSessionManager extends SessionManager { } else if (RecordingMode.MANUAL.equals(session.getSessionProperties().recordingMode())) { // Start countdown to stop recording if RecordingMode.MANUAL (will be aborted if // a Publisher starts before timeout) - log.info("Last participant left. Starting 2 minutes countdown for stopping recording of session {}", - sessionId); - recordingManager.initAutomaticRecordingStopThread(session.getSessionId()); + log.info("Last participant left. Starting {} seconds countdown for stopping recording of session {}", + this.openviduConfig.getOpenviduRecordingAutostopTimeout(), sessionId); + recordingManager.initAutomaticRecordingStopThread(session); } } diff --git a/openvidu-server/src/main/java/io/openvidu/server/recording/CompositeWrapper.java b/openvidu-server/src/main/java/io/openvidu/server/recording/CompositeWrapper.java new file mode 100644 index 00000000..a033dc7a --- /dev/null +++ b/openvidu-server/src/main/java/io/openvidu/server/recording/CompositeWrapper.java @@ -0,0 +1,156 @@ +/* + * (C) Copyright 2017-2019 OpenVidu (https://openvidu.io/) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package io.openvidu.server.recording; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.kurento.client.Composite; +import org.kurento.client.ErrorEvent; +import org.kurento.client.EventListener; +import org.kurento.client.HubPort; +import org.kurento.client.MediaProfileSpecType; +import org.kurento.client.RecorderEndpoint; +import org.kurento.client.RecordingEvent; +import org.kurento.client.StoppedEvent; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.openvidu.client.OpenViduException; +import io.openvidu.client.OpenViduException.Code; +import io.openvidu.server.kurento.core.KurentoSession; +import io.openvidu.server.kurento.endpoint.PublisherEndpoint; + +public class CompositeWrapper { + + private static final Logger log = LoggerFactory.getLogger(CompositeWrapper.class); + + KurentoSession session; + Composite composite; + RecorderEndpoint recorderEndpoint; + HubPort compositeToRecorderHubPort; + Map hubPorts = new ConcurrentHashMap<>(); + Map publisherEndpoints = new ConcurrentHashMap<>(); + + AtomicBoolean isRecording = new AtomicBoolean(false); + long startTime; + long endTime; + long size; + + public CompositeWrapper(KurentoSession session, String path) { + this.session = session; + this.composite = new Composite.Builder(session.getPipeline()).build(); + this.recorderEndpoint = new RecorderEndpoint.Builder(composite.getMediaPipeline(), path) + .withMediaProfile(MediaProfileSpecType.WEBM_AUDIO_ONLY).build(); + this.compositeToRecorderHubPort = new HubPort.Builder(composite).build(); + this.compositeToRecorderHubPort.connect(recorderEndpoint); + } + + public synchronized void startCompositeRecording(CountDownLatch startLatch) { + + this.recorderEndpoint.addRecordingListener(new EventListener() { + @Override + public void onEvent(RecordingEvent event) { + startTime = System.currentTimeMillis(); + log.info("Recording started event for audio-only RecorderEndpoint of Composite in session {}", + session.getSessionId()); + startLatch.countDown(); + } + }); + + this.recorderEndpoint.addErrorListener(new EventListener() { + @Override + public void onEvent(ErrorEvent event) { + log.error(event.getErrorCode() + " " + event.getDescription()); + } + }); + + this.recorderEndpoint.record(); + } + + public synchronized void stopCompositeRecording(CountDownLatch stopLatch) { + this.recorderEndpoint.addStoppedListener(new EventListener() { + @Override + public void onEvent(StoppedEvent event) { + endTime = System.currentTimeMillis(); + log.info("Recording stopped event for audio-only RecorderEndpoint of Composite in session {}", + session.getSessionId()); + recorderEndpoint.release(); + stopLatch.countDown(); + } + }); + this.recorderEndpoint.stop(); + } + + public void connectPublisherEndpoint(PublisherEndpoint endpoint) throws OpenViduException { + HubPort hubPort = new HubPort.Builder(composite).build(); + endpoint.connect(hubPort); + String streamId = endpoint.getOwner().getPublisherStreamId(); + this.hubPorts.put(streamId, hubPort); + this.publisherEndpoints.put(streamId, endpoint); + + if (isRecording.compareAndSet(false, true)) { + // First user publishing. Starting RecorderEndpoint + final CountDownLatch startLatch = new CountDownLatch(1); + this.startCompositeRecording(startLatch); + try { + if (!startLatch.await(5, TimeUnit.SECONDS)) { + log.error("Error waiting for RecorderEndpoint of Composite to start in session {}", + session.getSessionId()); + throw new OpenViduException(Code.RECORDING_START_ERROR_CODE, + "Couldn't initialize RecorderEndpoint of Composite"); + } + log.info("RecorderEnpoint of Composite is now recording for session {}", session.getSessionId()); + } catch (InterruptedException e) { + log.error("Exception while waiting for state change", e); + } + } + + log.info("Composite for session {} has now {} connected publishers", this.session.getSessionId(), + this.composite.getChildren().size() - 1); + } + + public void disconnectPublisherEndpoint(String streamId) { + HubPort hubPort = this.hubPorts.remove(streamId); + PublisherEndpoint publisherEndpoint = this.publisherEndpoints.remove(streamId); + publisherEndpoint.disconnectFrom(hubPort); + hubPort.release(); + log.info("Composite for session {} has now {} connected publishers", this.session.getSessionId(), + this.composite.getChildren().size() - 1); + } + + public void disconnectAllPublisherEndpoints() { + this.publisherEndpoints.keySet().forEach(streamId -> { + PublisherEndpoint endpoint = this.publisherEndpoints.get(streamId); + HubPort hubPort = this.hubPorts.get(streamId); + endpoint.disconnectFrom(hubPort); + hubPort.release(); + }); + this.hubPorts.clear(); + this.publisherEndpoints.clear(); + this.composite.release(); + } + + public long getDuration() { + return this.endTime - this.startTime; + } + +} \ No newline at end of file diff --git a/openvidu-server/src/main/java/io/openvidu/server/recording/RecorderEndpointWrapper.java b/openvidu-server/src/main/java/io/openvidu/server/recording/RecorderEndpointWrapper.java index 63e46863..9217eff0 100644 --- a/openvidu-server/src/main/java/io/openvidu/server/recording/RecorderEndpointWrapper.java +++ b/openvidu-server/src/main/java/io/openvidu/server/recording/RecorderEndpointWrapper.java @@ -23,19 +23,19 @@ import com.google.gson.JsonObject; public class RecorderEndpointWrapper { - RecorderEndpoint recorder; - String connectionId; - String recordingId; - String streamId; - String clientData; - String serverData; - boolean hasAudio; - boolean hasVideo; - String typeOfVideo; + private RecorderEndpoint recorder; + private String connectionId; + private String recordingId; + private String streamId; + private String clientData; + private String serverData; + private boolean hasAudio; + private boolean hasVideo; + private String typeOfVideo; - long startTime; - long endTime; - long size; + private long startTime; + private long endTime; + private long size; public RecorderEndpointWrapper(RecorderEndpoint recorder, String connectionId, String recordingId, String streamId, String clientData, String serverData, boolean hasAudio, boolean hasVideo, String typeOfVideo) { diff --git a/openvidu-server/src/main/java/io/openvidu/server/recording/Recording.java b/openvidu-server/src/main/java/io/openvidu/server/recording/Recording.java index 39ac8f4b..8bb746dd 100644 --- a/openvidu-server/src/main/java/io/openvidu/server/recording/Recording.java +++ b/openvidu-server/src/main/java/io/openvidu/server/recording/Recording.java @@ -70,9 +70,8 @@ public class Recording { io.openvidu.java.client.Recording.OutputMode outputMode = io.openvidu.java.client.Recording.OutputMode .valueOf(json.get("outputMode").getAsString()); RecordingProperties.Builder builder = new RecordingProperties.Builder().name(json.get("name").getAsString()) - .outputMode(outputMode).hasAudio(json.get("hasAudio").getAsBoolean()) - .hasVideo(json.get("hasVideo").getAsBoolean()); - if (io.openvidu.java.client.Recording.OutputMode.COMPOSED.equals(outputMode)) { + .outputMode(outputMode).hasAudio(this.hasAudio).hasVideo(this.hasVideo); + if (io.openvidu.java.client.Recording.OutputMode.COMPOSED.equals(outputMode) && this.hasVideo) { this.resolution = json.get("resolution").getAsString(); builder.resolution(this.resolution); RecordingLayout recordingLayout = RecordingLayout.valueOf(json.get("recordingLayout").getAsString()); @@ -189,7 +188,8 @@ public class Recording { json.addProperty("id", this.id); json.addProperty("name", this.recordingProperties.name()); json.addProperty("outputMode", this.getOutputMode().name()); - if (io.openvidu.java.client.Recording.OutputMode.COMPOSED.equals(this.recordingProperties.outputMode())) { + if (io.openvidu.java.client.Recording.OutputMode.COMPOSED.equals(this.recordingProperties.outputMode()) + && this.hasVideo) { json.addProperty("resolution", this.resolution); json.addProperty("recordingLayout", this.recordingProperties.recordingLayout().name()); if (RecordingLayout.CUSTOM.equals(this.recordingProperties.recordingLayout())) { diff --git a/openvidu-server/src/main/java/io/openvidu/server/recording/service/ComposedRecordingService.java b/openvidu-server/src/main/java/io/openvidu/server/recording/service/ComposedRecordingService.java index c3ba4c54..9fb425b1 100644 --- a/openvidu-server/src/main/java/io/openvidu/server/recording/service/ComposedRecordingService.java +++ b/openvidu-server/src/main/java/io/openvidu/server/recording/service/ComposedRecordingService.java @@ -26,8 +26,6 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; -import javax.ws.rs.ProcessingException; - import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; @@ -37,8 +35,6 @@ import com.github.dockerjava.api.command.CreateContainerCmd; import com.github.dockerjava.api.command.CreateContainerResponse; import com.github.dockerjava.api.command.ExecCreateCmdResponse; import com.github.dockerjava.api.exception.ConflictException; -import com.github.dockerjava.api.exception.DockerClientException; -import com.github.dockerjava.api.exception.InternalServerErrorException; import com.github.dockerjava.api.exception.NotFoundException; import com.github.dockerjava.api.model.Bind; import com.github.dockerjava.api.model.Volume; @@ -46,7 +42,6 @@ import com.github.dockerjava.core.DefaultDockerClientConfig; import com.github.dockerjava.core.DockerClientBuilder; import com.github.dockerjava.core.DockerClientConfig; import com.github.dockerjava.core.command.ExecStartResultCallback; -import com.github.dockerjava.core.command.PullImageResultCallback; import io.openvidu.client.OpenViduException; import io.openvidu.client.OpenViduException.Code; @@ -54,7 +49,11 @@ import io.openvidu.java.client.RecordingLayout; import io.openvidu.java.client.RecordingProperties; import io.openvidu.server.OpenViduServer; import io.openvidu.server.config.OpenviduConfig; +import io.openvidu.server.core.Participant; import io.openvidu.server.core.Session; +import io.openvidu.server.kurento.core.KurentoParticipant; +import io.openvidu.server.kurento.core.KurentoSession; +import io.openvidu.server.recording.CompositeWrapper; import io.openvidu.server.recording.Recording; import io.openvidu.server.recording.RecordingInfoUtils; @@ -64,11 +63,9 @@ public class ComposedRecordingService extends RecordingService { private Map containers = new ConcurrentHashMap<>(); private Map sessionsContainers = new ConcurrentHashMap<>(); + private Map composites = new ConcurrentHashMap<>(); - private final String IMAGE_NAME = "openvidu/openvidu-recording"; - private String IMAGE_TAG; - - private DockerClient dockerClient; + DockerClient dockerClient; public ComposedRecordingService(RecordingManager recordingManager, OpenviduConfig openviduConfig) { super(recordingManager, openviduConfig); @@ -78,25 +75,69 @@ public class ComposedRecordingService extends RecordingService { @Override public Recording startRecording(Session session, RecordingProperties properties) throws OpenViduException { - List envs = new ArrayList<>(); PropertiesRecordingId updatePropertiesAndRecordingId = this.setFinalRecordingNameAndGetFreeRecordingId(session, properties); properties = updatePropertiesAndRecordingId.properties; String recordingId = updatePropertiesAndRecordingId.recordingId; + // Instantiate and store recording object Recording recording = new Recording(session.getSessionId(), recordingId, properties); - - this.recordingManager.sessionsRecordings.put(session.getSessionId(), recording); - this.recordingManager.sessionHandler.setRecordingStarted(session.getSessionId(), recording); this.recordingManager.startingRecordings.put(recording.getId(), recording); + if (properties.hasVideo()) { + // Docker container used + recording = this.startRecordingWithVideo(session, recording, properties); + } else { + // Kurento composite used + recording = this.startRecordingAudioOnly(session, recording, properties); + } + + // Update collections and return recording + this.updateCollectionsAndSendNotifCauseRecordingStarted(session, recording); + return recording; + } + + @Override + public Recording stopRecording(Session session, Recording recording, String reason) { + if (recording.hasVideo()) { + return this.stopRecordingWithVideo(session, recording, reason); + } else { + return this.stopRecordingAudioOnly(session, recording, reason); + } + } + + public void joinPublisherEndpointToComposite(Session session, String recordingId, Participant participant) + throws OpenViduException { + KurentoParticipant kurentoParticipant = (KurentoParticipant) participant; + CompositeWrapper compositeWrapper = this.composites.get(session.getSessionId()); + + try { + compositeWrapper.connectPublisherEndpoint(kurentoParticipant.getPublisher()); + } catch (OpenViduException e) { + if (Code.RECORDING_START_ERROR_CODE.getValue() == e.getCodeValue()) { + // First user publishing triggered RecorderEnpoint start, but it failed + throw e; + } + } + } + + public void removePublisherEndpointFromComposite(String sessionId, String streamId) { + CompositeWrapper compositeWrapper = this.composites.get(sessionId); + compositeWrapper.disconnectPublisherEndpoint(streamId); + } + + private Recording startRecordingWithVideo(Session session, Recording recording, RecordingProperties properties) + throws OpenViduException { + List envs = new ArrayList<>(); + String layoutUrl = this.getLayoutUrl(recording, this.getShortSessionId(session)); envs.add("URL=" + layoutUrl); + envs.add("ONLY_VIDEO=" + !properties.hasAudio()); envs.add("RESOLUTION=" + properties.resolution()); envs.add("FRAMERATE=30"); - envs.add("VIDEO_ID=" + recordingId); + envs.add("VIDEO_ID=" + recording.getId()); envs.add("VIDEO_NAME=" + properties.name()); envs.add("VIDEO_FORMAT=mp4"); envs.add("RECORDING_JSON=" + recording.toJson().toString()); @@ -106,28 +147,56 @@ public class ComposedRecordingService extends RecordingService { String containerId; try { - containerId = this.runRecordingContainer(envs, "recording_" + recordingId); + containerId = this.runRecordingContainer(envs, "recording_" + recording.getId()); } catch (Exception e) { - this.cleanRecordingMapsAndReturnContainerId(recording); - throw new OpenViduException(Code.RECORDING_START_ERROR_CODE, + this.cleanRecordingMaps(recording); + throw this.failStartRecording(session, recording, "Couldn't initialize recording container. Error: " + e.getMessage()); } - this.waitForVideoFileNotEmpty(recording); - this.sessionsContainers.put(session.getSessionId(), containerId); - recording.setStatus(io.openvidu.java.client.Recording.Status.started); - - this.recordingManager.startedRecordings.put(recording.getId(), recording); - this.recordingManager.startingRecordings.remove(recording.getId()); + try { + this.waitForVideoFileNotEmpty(recording); + } catch (OpenViduException e) { + this.cleanRecordingMaps(recording); + throw this.failStartRecording(session, recording, + "Couldn't initialize recording container. Error: " + e.getMessage()); + } return recording; } - @Override - public Recording stopRecording(Session session, Recording recording, String reason) { - String containerId = cleanRecordingMapsAndReturnContainerId(recording); + private Recording startRecordingAudioOnly(Session session, Recording recording, RecordingProperties properties) + throws OpenViduException { + + CompositeWrapper compositeWrapper = new CompositeWrapper((KurentoSession) session, + "file://" + this.openviduConfig.getOpenViduRecordingPath() + recording.getId() + "/" + properties.name() + + ".webm"); + this.composites.put(session.getSessionId(), compositeWrapper); + + for (Participant p : session.getParticipants()) { + if (p.isStreaming()) { + try { + this.joinPublisherEndpointToComposite(session, recording.getId(), p); + } catch (OpenViduException e) { + log.error("Error waiting for RecorderEndpooint of Composite to start in session {}", + session.getSessionId()); + throw this.failStartRecording(session, recording, e.getMessage()); + } + } + } + + this.generateRecordingMetadataFile(recording); + + return recording; + } + + private Recording stopRecordingWithVideo(Session session, Recording recording, String reason) { + + String containerId = this.sessionsContainers.remove(recording.getSessionId()); + this.cleanRecordingMaps(recording); + final String recordingId = recording.getId(); if (session == null) { @@ -229,46 +298,64 @@ public class ComposedRecordingService extends RecordingService { throw new OpenViduException(Code.RECORDING_REPORT_ERROR_CODE, "There was an error generating the metadata report file for the recording"); } - if (session != null) { + if (session != null && reason != null) { this.recordingManager.sessionHandler.sendRecordingStoppedNotification(session, recording, reason); } } return recording; } - public boolean recordingImageExistsLocally() { - boolean imageExists = false; - try { - dockerClient.inspectImageCmd(IMAGE_NAME + ":" + IMAGE_TAG).exec(); - imageExists = true; - } catch (NotFoundException nfe) { - imageExists = false; - } catch (ProcessingException e) { - throw e; + private Recording stopRecordingAudioOnly(Session session, Recording recording, String reason) { + String sessionId; + if (session == null) { + log.warn( + "Existing recording {} does not have an active session associated. This means the recording " + + "has been automatically stopped after last user left and {} seconds timeout passed", + recording.getId(), this.openviduConfig.getOpenviduRecordingAutostopTimeout()); + sessionId = recording.getSessionId(); + } else { + sessionId = session.getSessionId(); } - return imageExists; - } - public void downloadRecordingImage() { + CompositeWrapper compositeWrapper = this.composites.remove(sessionId); + + final CountDownLatch stoppedCountDown = new CountDownLatch(1); + compositeWrapper.stopCompositeRecording(stoppedCountDown); try { - dockerClient.pullImageCmd(IMAGE_NAME + ":" + IMAGE_TAG).exec(new PullImageResultCallback()).awaitSuccess(); - } catch (NotFoundException | InternalServerErrorException e) { - if (imageExistsLocally(IMAGE_NAME + ":" + IMAGE_TAG)) { - log.info("Docker image '{}' exists locally", IMAGE_NAME + ":" + IMAGE_TAG); - } else { - throw e; + if (!stoppedCountDown.await(5, TimeUnit.SECONDS)) { + recording.setStatus(io.openvidu.java.client.Recording.Status.failed); + log.error("Error waiting for RecorderEndpoint of Composite to stop in session {}", + recording.getSessionId()); } - } catch (DockerClientException e) { - log.info("Error on Pulling '{}' image. Probably because the user has stopped the execution", - IMAGE_NAME + ":" + IMAGE_TAG); - throw e; + } catch (InterruptedException e) { + recording.setStatus(io.openvidu.java.client.Recording.Status.failed); + log.error("Exception while waiting for state change", e); } + + compositeWrapper.disconnectAllPublisherEndpoints(); + + this.cleanRecordingMaps(recording); + + String filesPath = this.openviduConfig.getOpenViduRecordingPath() + recording.getId() + "/"; + File videoFile = new File(filesPath + recording.getName() + ".webm"); + long finalSize = videoFile.length(); + long finalDuration = compositeWrapper.getDuration(); + + this.sealRecordingMetadataFile(recording, finalSize, finalDuration, + filesPath + RecordingManager.RECORDING_ENTITY_FILE + recording.getId()); + + if (reason != null && session != null) { + this.recordingManager.sessionHandler.sendRecordingStoppedNotification(session, recording, reason); + } + + return recording; } private String runRecordingContainer(List envs, String containerName) throws Exception { Volume volume1 = new Volume("/recordings"); - CreateContainerCmd cmd = dockerClient.createContainerCmd(IMAGE_NAME + ":" + IMAGE_TAG).withName(containerName) - .withEnv(envs).withNetworkMode("host").withVolumes(volume1) + CreateContainerCmd cmd = dockerClient + .createContainerCmd(RecordingManager.IMAGE_NAME + ":" + RecordingManager.IMAGE_TAG) + .withName(containerName).withEnv(envs).withNetworkMode("host").withVolumes(volume1) .withBinds(new Bind(openviduConfig.getOpenViduRecordingPath(), volume1)); CreateContainerResponse container = null; try { @@ -283,7 +370,8 @@ public class ComposedRecordingService extends RecordingService { containerName); throw e; } catch (NotFoundException e) { - log.error("Docker image {} couldn't be found in docker host", IMAGE_NAME + ":" + IMAGE_TAG); + log.error("Docker image {} couldn't be found in docker host", + RecordingManager.IMAGE_NAME + ":" + RecordingManager.IMAGE_TAG); throw e; } } @@ -297,22 +385,14 @@ public class ComposedRecordingService extends RecordingService { dockerClient.stopContainerCmd(containerId).exec(); } - private boolean imageExistsLocally(String imageName) { - boolean imageExists = false; - try { - dockerClient.inspectImageCmd(imageName).exec(); - imageExists = true; - } catch (NotFoundException nfe) { - imageExists = false; - } - return imageExists; - } - - private void waitForVideoFileNotEmpty(Recording recording) { + private void waitForVideoFileNotEmpty(Recording recording) throws OpenViduException { boolean isPresent = false; - while (!isPresent) { + int i = 1; + int timeout = 150; // Wait for 150*150 = 22500 = 22.5 seconds + while (!isPresent && timeout <= 150) { try { Thread.sleep(150); + timeout++; File f = new File(this.openviduConfig.getOpenViduRecordingPath() + recording.getId() + "/" + recording.getName() + ".mp4"); isPresent = ((f.isFile()) && (f.length() > 0)); @@ -320,9 +400,15 @@ public class ComposedRecordingService extends RecordingService { e.printStackTrace(); } } + if (i == timeout) { + log.error("Recorder container failed generating video file (is empty) for session {}", + recording.getSessionId()); + throw new OpenViduException(Code.RECORDING_START_ERROR_CODE, + "Recorder container failed generating video file (is empty)"); + } } - private void failRecordingCompletion(String containerId, OpenViduException e) { + private void failRecordingCompletion(String containerId, OpenViduException e) throws OpenViduException { this.stopDockerContainer(containerId); this.removeDockerContainer(containerId); throw e; @@ -351,14 +437,4 @@ public class ComposedRecordingService extends RecordingService { return finalUrl; } - private String cleanRecordingMapsAndReturnContainerId(Recording recording) { - this.recordingManager.sessionsRecordings.remove(recording.getSessionId()); - this.recordingManager.startedRecordings.remove(recording.getId()); - return this.sessionsContainers.remove(recording.getSessionId()); - } - - public void setRecordingContainerVersion(String version) { - this.IMAGE_TAG = version; - } - } diff --git a/openvidu-server/src/main/java/io/openvidu/server/recording/service/RecordingManager.java b/openvidu-server/src/main/java/io/openvidu/server/recording/service/RecordingManager.java index 982a1669..aecd018d 100644 --- a/openvidu-server/src/main/java/io/openvidu/server/recording/service/RecordingManager.java +++ b/openvidu-server/src/main/java/io/openvidu/server/recording/service/RecordingManager.java @@ -44,16 +44,22 @@ import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; +import com.github.dockerjava.api.exception.DockerClientException; +import com.github.dockerjava.api.exception.InternalServerErrorException; +import com.github.dockerjava.api.exception.NotFoundException; +import com.github.dockerjava.core.command.PullImageResultCallback; import com.google.gson.JsonObject; import com.google.gson.JsonParser; import io.openvidu.client.OpenViduException; import io.openvidu.client.OpenViduException.Code; +import io.openvidu.client.internal.ProtocolElements; import io.openvidu.java.client.RecordingProperties; import io.openvidu.server.config.OpenviduConfig; import io.openvidu.server.core.Participant; import io.openvidu.server.core.Session; import io.openvidu.server.core.SessionEventsHandler; +import io.openvidu.server.core.SessionManager; import io.openvidu.server.recording.Recording; public class RecordingManager { @@ -67,6 +73,9 @@ public class RecordingManager { @Autowired protected SessionEventsHandler sessionHandler; + @Autowired + private SessionManager sessionManager; + @Autowired protected OpenviduConfig openviduConfig; @@ -79,6 +88,8 @@ public class RecordingManager { Runtime.getRuntime().availableProcessors()); static final String RECORDING_ENTITY_FILE = ".recording."; + static final String IMAGE_NAME = "openvidu/openvidu-recording"; + static String IMAGE_TAG; private static final List LAST_PARTICIPANT_LEFT_REASONS = Arrays.asList( new String[] { "disconnect", "forceDisconnectByUser", "forceDisconnectByServer", "networkDisconnect" }); @@ -89,18 +100,17 @@ public class RecordingManager { public void initializeRecordingManager() { + RecordingManager.IMAGE_TAG = openviduConfig.getOpenViduRecordingVersion(); + this.composedRecordingService = new ComposedRecordingService(this, openviduConfig); this.singleStreamRecordingService = new SingleStreamRecordingService(this, openviduConfig); - ComposedRecordingService recServiceAux = this.composedRecordingService; - recServiceAux.setRecordingContainerVersion(openviduConfig.getOpenViduRecordingVersion()); - log.info("Recording module required: Downloading openvidu/openvidu-recording:" + openviduConfig.getOpenViduRecordingVersion() + " Docker image (800 MB aprox)"); boolean imageExists = false; try { - imageExists = recServiceAux.recordingImageExistsLocally(); + imageExists = this.recordingImageExistsLocally(); } catch (ProcessingException exception) { String message = "Exception connecting to Docker daemon: "; if ("docker".equals(openviduConfig.getSpringProfile())) { @@ -133,7 +143,7 @@ public class RecordingManager { } }); t.start(); - recServiceAux.downloadRecordingImage(); + this.downloadRecordingImage(); t.interrupt(); try { t.join(); @@ -142,9 +152,8 @@ public class RecordingManager { } log.info("Docker image available"); } - this.initRecordingPath(); - this.recordingService = recServiceAux; + this.initRecordingPath(); } public Recording startRecording(Session session, RecordingProperties properties) throws OpenViduException { @@ -164,7 +173,9 @@ public class RecordingManager { if (session.getActivePublishers() == 0) { // Init automatic recording stop if there are now publishers when starting // recording - this.initAutomaticRecordingStopThread(session.getSessionId()); + log.info("No publisher in session {}. Starting {} seconds countdown for stopping recording", + session.getSessionId(), this.openviduConfig.getOpenviduRecordingAutostopTimeout()); + this.initAutomaticRecordingStopThread(session); } return recording; } @@ -172,9 +183,9 @@ public class RecordingManager { public Recording stopRecording(Session session, String recordingId, String reason) { Recording recording; if (session == null) { - recording = this.startedRecordings.remove(recordingId); + recording = this.startedRecordings.get(recordingId); } else { - recording = this.sessionsRecordings.remove(session.getSessionId()); + recording = this.sessionsRecordings.get(session.getSessionId()); } switch (recording.getOutputMode()) { case COMPOSED: @@ -195,17 +206,33 @@ public class RecordingManager { participant.getPublisherStreamId(), session.getSessionId()); } if (io.openvidu.java.client.Recording.OutputMode.INDIVIDUAL.equals(recording.getOutputMode())) { + // Start new RecorderEndpoint for this stream + log.info("Starting new RecorderEndpoint in session {} for new stream of participant {}", + session.getSessionId(), participant.getParticipantPublicId()); final CountDownLatch startedCountDown = new CountDownLatch(1); - this.singleStreamRecordingService.startOneIndividualStreamRecording(session, recordingId, profile, + this.singleStreamRecordingService.startRecorderEndpointForPublisherEndpoint(session, recordingId, profile, participant, startedCountDown); + } else if (io.openvidu.java.client.Recording.OutputMode.COMPOSED.equals(recording.getOutputMode()) + && !recording.hasVideo()) { + // Connect this stream to existing Composite recorder + log.info("Joining PublisherEndpoint to existing Composite in session {} for new stream of participant {}", + session.getSessionId(), participant.getParticipantPublicId()); + this.composedRecordingService.joinPublisherEndpointToComposite(session, recordingId, participant); } } public void stopOneIndividualStreamRecording(String sessionId, String streamId) { Recording recording = this.sessionsRecordings.get(sessionId); + if (recording == null) { + log.error("Cannot stop recording of existing stream {}. Session {} is not being recorded", streamId, + sessionId); + } if (io.openvidu.java.client.Recording.OutputMode.INDIVIDUAL.equals(recording.getOutputMode())) { + // Stop specific RecorderEndpoint for this stream + log.info("Stopping RecorderEndpoint in session {} for stream of participant {}", sessionId, streamId); final CountDownLatch stoppedCountDown = new CountDownLatch(1); - this.singleStreamRecordingService.stopOneIndividualStreamRecording(sessionId, streamId, stoppedCountDown); + this.singleStreamRecordingService.stopRecorderEndpointOfPublisherEndpoint(sessionId, streamId, + stoppedCountDown); try { if (!stoppedCountDown.await(5, TimeUnit.SECONDS)) { log.error("Error waiting for recorder endpoint of stream {} to stop in session {}", streamId, @@ -214,6 +241,12 @@ public class RecordingManager { } catch (InterruptedException e) { log.error("Exception while waiting for state change", e); } + } else if (io.openvidu.java.client.Recording.OutputMode.COMPOSED.equals(recording.getOutputMode()) + && !recording.hasVideo()) { + // Disconnect this stream from existing Composite recorder + log.info("Removing PublisherEndpoint from Composite in session {} for stream of participant {}", sessionId, + streamId); + this.composedRecordingService.removePublisherEndpointFromComposite(sessionId, streamId); } } @@ -231,6 +264,11 @@ public class RecordingManager { return (rec != null && io.openvidu.java.client.Recording.OutputMode.COMPOSED.equals(rec.getOutputMode())); } + public boolean sessionIsBeingRecordedOnlyAudio(String sessionId) { + Recording rec = this.sessionsRecordings.get(sessionId); + return (rec != null && !rec.hasVideo()); + } + public Recording getStartedRecording(String recordingId) { return this.startedRecordings.get(recordingId); } @@ -314,14 +352,27 @@ public class RecordingManager { return null; } - public void initAutomaticRecordingStopThread(String sessionId) { - final String recordingId = this.sessionsRecordings.get(sessionId).getId(); + public void initAutomaticRecordingStopThread(final Session session) { + final String recordingId = this.sessionsRecordings.get(session.getSessionId()).getId(); ScheduledFuture future = this.automaticRecordingStopExecutor.schedule(() -> { - log.info("Stopping recording {} after 2 minutes wait (no publisher published before timeout)", recordingId); - this.stopRecording(null, recordingId, "lastParticipantLeft"); - this.automaticRecordingStopThreads.remove(sessionId); - }, 2, TimeUnit.MINUTES); - this.automaticRecordingStopThreads.putIfAbsent(sessionId, future); + + log.info("Stopping recording {} after {} seconds wait (no publisher published before timeout)", recordingId, + this.openviduConfig.getOpenviduRecordingAutostopTimeout()); + + this.stopRecording(null, recordingId, "automaticStop"); + this.automaticRecordingStopThreads.remove(session.getSessionId()); + + if (session.getParticipants().size() == 0 || (session.getParticipants().size() == 1 + && session.getParticipantByPublicId(ProtocolElements.RECORDER_PARTICIPANT_PUBLICID) != null)) { + // Close session if there are no participants connected (except for RECORDER). + // This code won't be executed only when some user reconnects to the session + // but never publishing (publishers automatically abort this thread) + sessionManager.closeSessionAndEmptyCollections(session, "automaticStop"); + sessionManager.showTokens(); + } + + }, this.openviduConfig.getOpenviduRecordingAutostopTimeout(), TimeUnit.SECONDS); + this.automaticRecordingStopThreads.putIfAbsent(session.getSessionId(), future); } public boolean abortAutomaticRecordingStopThread(String sessionId) { @@ -340,7 +391,7 @@ public class RecordingManager { String extension; switch (recording.getOutputMode()) { case COMPOSED: - extension = "mp4"; + extension = recording.hasVideo() ? "mp4" : "webm"; break; case INDIVIDUAL: extension = "zip"; @@ -357,6 +408,36 @@ public class RecordingManager { return recording; } + private boolean recordingImageExistsLocally() { + boolean imageExists = false; + try { + this.composedRecordingService.dockerClient.inspectImageCmd(IMAGE_NAME + ":" + IMAGE_TAG).exec(); + imageExists = true; + } catch (NotFoundException nfe) { + imageExists = false; + } catch (ProcessingException e) { + throw e; + } + return imageExists; + } + + private void downloadRecordingImage() { + try { + this.composedRecordingService.dockerClient.pullImageCmd(IMAGE_NAME + ":" + IMAGE_TAG) + .exec(new PullImageResultCallback()).awaitSuccess(); + } catch (NotFoundException | InternalServerErrorException e) { + if (recordingImageExistsLocally()) { + log.info("Docker image '{}' exists locally", IMAGE_NAME + ":" + IMAGE_TAG); + } else { + throw e; + } + } catch (DockerClientException e) { + log.info("Error on Pulling '{}' image. Probably because the user has stopped the execution", + IMAGE_NAME + ":" + IMAGE_TAG); + throw e; + } + } + private Recording getRecordingFromHost(String recordingId) { log.info(this.openviduConfig.getOpenViduRecordingPath() + recordingId + "/" + RecordingManager.RECORDING_ENTITY_FILE + recordingId); diff --git a/openvidu-server/src/main/java/io/openvidu/server/recording/service/RecordingService.java b/openvidu-server/src/main/java/io/openvidu/server/recording/service/RecordingService.java index 9ffc0230..725455a2 100644 --- a/openvidu-server/src/main/java/io/openvidu/server/recording/service/RecordingService.java +++ b/openvidu-server/src/main/java/io/openvidu/server/recording/service/RecordingService.java @@ -17,17 +17,25 @@ package io.openvidu.server.recording.service; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import io.openvidu.client.OpenViduException; +import io.openvidu.client.OpenViduException.Code; import io.openvidu.java.client.RecordingLayout; import io.openvidu.java.client.RecordingProperties; import io.openvidu.server.config.OpenviduConfig; import io.openvidu.server.core.Session; import io.openvidu.server.recording.Recording; +import io.openvidu.server.utils.CustomFileWriter; public abstract class RecordingService { + private static final Logger log = LoggerFactory.getLogger(RecordingService.class); + protected OpenviduConfig openviduConfig; protected RecordingManager recordingManager; + protected CustomFileWriter fileWriter = new CustomFileWriter(); RecordingService(RecordingManager recordingManager, OpenviduConfig openviduConfig) { this.recordingManager = recordingManager; @@ -38,11 +46,56 @@ public abstract class RecordingService { public abstract Recording stopRecording(Session session, Recording recording, String reason); - protected RecordingProperties setFinalRecordingName(Session session, RecordingProperties properties) { - // TODO Auto-generated method stub - return null; + /** + * Generates metadata recording file (".recording.RECORDING_ID" JSON file to + * store Recording entity) + */ + protected void generateRecordingMetadataFile(Recording recording) { + String filePath = this.openviduConfig.getOpenViduRecordingPath() + recording.getId() + "/" + + RecordingManager.RECORDING_ENTITY_FILE + recording.getId(); + String text = recording.toJson().toString(); + this.fileWriter.createAndWriteFile(filePath, text); + log.info("Generated recording metadata file at {}", filePath); } + /** + * Update and overwrites metadata recording file with final values on recording + * stop (".recording.RECORDING_ID" JSON file to store Recording entity). + * + * @return updated Recording object + */ + protected Recording sealRecordingMetadataFile(Recording recording, long size, long duration, + String metadataFilePath) { + recording.setSize(size); // Size in bytes + recording.setDuration(duration > 0 ? duration : 0); // Duration in seconds + if (!io.openvidu.java.client.Recording.Status.failed.equals(recording.getStatus())) { + recording.setStatus(io.openvidu.java.client.Recording.Status.stopped); + } + this.fileWriter.overwriteFile(metadataFilePath, recording.toJson().toString()); + recording = this.recordingManager.updateRecordingUrl(recording); + + log.info("Sealed recording metadata file at {}", metadataFilePath); + + return recording; + } + + /** + * Changes recording from starting to started, updates global recording + * collections and sends RPC response to clients + */ + protected void updateCollectionsAndSendNotifCauseRecordingStarted(Session session, Recording recording) { + this.recordingManager.sessionHandler.setRecordingStarted(session.getSessionId(), recording); + this.recordingManager.sessionsRecordings.put(session.getSessionId(), recording); + this.recordingManager.startingRecordings.remove(recording.getId()); + this.recordingManager.startedRecordings.put(recording.getId(), recording); + this.recordingManager.getSessionEventsHandler().sendRecordingStartedNotification(session, recording); + } + + /** + * Returns a new available recording identifier (adding a number tag at the end + * of the sessionId if it already exists) and rebuilds RecordinProperties object + * to set the final value of "name" property + */ protected PropertiesRecordingId setFinalRecordingNameAndGetFreeRecordingId(Session session, RecordingProperties properties) { String recordingId = this.recordingManager.getFreeRecordingId(session.getSessionId(), @@ -52,7 +105,8 @@ public abstract class RecordingService { RecordingProperties.Builder builder = new RecordingProperties.Builder().name(recordingId) .outputMode(properties.outputMode()).hasAudio(properties.hasAudio()) .hasVideo(properties.hasVideo()); - if (io.openvidu.java.client.Recording.OutputMode.COMPOSED.equals(properties.outputMode())) { + if (io.openvidu.java.client.Recording.OutputMode.COMPOSED.equals(properties.outputMode()) + && properties.hasVideo()) { builder.resolution(properties.resolution()); builder.recordingLayout(properties.recordingLayout()); if (RecordingLayout.CUSTOM.equals(properties.recordingLayout())) { @@ -61,6 +115,8 @@ public abstract class RecordingService { } properties = builder.build(); } + + log.info("New recording id ({}) and final name ({})", recordingId, properties.name()); return new PropertiesRecordingId(properties, recordingId); } @@ -69,6 +125,18 @@ public abstract class RecordingService { session.getSessionId().length()); } + protected OpenViduException failStartRecording(Session session, Recording recording, String errorMessage) { + recording.setStatus(io.openvidu.java.client.Recording.Status.failed); + this.recordingManager.startingRecordings.remove(recording.getId()); + this.stopRecording(session, recording, null); + return new OpenViduException(Code.RECORDING_START_ERROR_CODE, errorMessage); + } + + protected void cleanRecordingMaps(Recording recording) { + this.recordingManager.sessionsRecordings.remove(recording.getSessionId()); + this.recordingManager.startedRecordings.remove(recording.getId()); + } + /** * Simple wrapper for returning update RecordingProperties and a free * recordingId when starting a new recording diff --git a/openvidu-server/src/main/java/io/openvidu/server/recording/service/SingleStreamRecordingService.java b/openvidu-server/src/main/java/io/openvidu/server/recording/service/SingleStreamRecordingService.java index 5ee75310..dcfa2e82 100644 --- a/openvidu-server/src/main/java/io/openvidu/server/recording/service/SingleStreamRecordingService.java +++ b/openvidu-server/src/main/java/io/openvidu/server/recording/service/SingleStreamRecordingService.java @@ -59,14 +59,12 @@ import io.openvidu.server.kurento.endpoint.PublisherEndpoint; import io.openvidu.server.recording.RecorderEndpointWrapper; import io.openvidu.server.recording.Recording; import io.openvidu.server.utils.CommandExecutor; -import io.openvidu.server.utils.CustomFileWriter; public class SingleStreamRecordingService extends RecordingService { private static final Logger log = LoggerFactory.getLogger(SingleStreamRecordingService.class); private Map> recorders = new ConcurrentHashMap<>(); - private CustomFileWriter fileWriter = new CustomFileWriter(); private final String INDIVIDUAL_STREAM_METADATA_FILE = ".stream."; public SingleStreamRecordingService(RecordingManager recordingManager, OpenviduConfig openviduConfig) { @@ -80,6 +78,9 @@ public class SingleStreamRecordingService extends RecordingService { properties); properties = updatePropertiesAndRecordingId.properties; String recordingId = updatePropertiesAndRecordingId.recordingId; + + Recording recording = new Recording(session.getSessionId(), recordingId, properties); + this.recordingManager.startingRecordings.put(recording.getId(), recording); recorders.put(session.getSessionId(), new ConcurrentHashMap()); @@ -97,23 +98,15 @@ public class SingleStreamRecordingService extends RecordingService { p.getPublisherStreamId(), session.getSessionId(), e.getMessage()); continue; } - this.startOneIndividualStreamRecording(session, recordingId, profile, p, recordingStartedCountdown); + this.startRecorderEndpointForPublisherEndpoint(session, recordingId, profile, p, + recordingStartedCountdown); } } - Recording recording = new Recording(session.getSessionId(), recordingId, properties); - recording.setStatus(io.openvidu.java.client.Recording.Status.started); - - this.recordingManager.startingRecordings.put(recording.getId(), recording); - try { if (!recordingStartedCountdown.await(5, TimeUnit.SECONDS)) { log.error("Error waiting for some recorder endpoint to start in session {}", session.getSessionId()); - recording.setStatus(io.openvidu.java.client.Recording.Status.failed); - this.recordingManager.startingRecordings.remove(recording.getId()); - this.stopRecording(session, recording, null); - throw new OpenViduException(Code.RECORDING_START_ERROR_CODE, - "Couldn't initialize some RecorderEndpoint"); + throw this.failStartRecording(session, recording, "Couldn't initialize some RecorderEndpoint"); } } catch (InterruptedException e) { recording.setStatus(io.openvidu.java.client.Recording.Status.failed); @@ -127,11 +120,7 @@ public class SingleStreamRecordingService extends RecordingService { } this.generateRecordingMetadataFile(recording); - this.recordingManager.sessionHandler.setRecordingStarted(session.getSessionId(), recording); - this.recordingManager.sessionsRecordings.put(session.getSessionId(), recording); - this.recordingManager.startingRecordings.remove(recording.getId()); - this.recordingManager.startedRecordings.put(recording.getId(), recording); - this.recordingManager.getSessionEventsHandler().sendRecordingStartedNotification(session, recording); + this.updateCollectionsAndSendNotifCauseRecordingStarted(session, recording); return recording; } @@ -143,22 +132,23 @@ public class SingleStreamRecordingService extends RecordingService { final CountDownLatch stoppedCountDown = new CountDownLatch(numberOfActiveRecorders); for (RecorderEndpointWrapper wrapper : recorders.get(recording.getSessionId()).values()) { - this.stopOneIndividualStreamRecording(recording.getSessionId(), wrapper.getStreamId(), stoppedCountDown); + this.stopRecorderEndpointOfPublisherEndpoint(recording.getSessionId(), wrapper.getStreamId(), + stoppedCountDown); } try { if (!stoppedCountDown.await(5, TimeUnit.SECONDS)) { + recording.setStatus(io.openvidu.java.client.Recording.Status.failed); log.error("Error waiting for some recorder endpoint to stop in session {}", recording.getSessionId()); } } catch (InterruptedException e) { + recording.setStatus(io.openvidu.java.client.Recording.Status.failed); log.error("Exception while waiting for state change", e); } - this.recordingManager.sessionsRecordings.remove(recording.getSessionId()); - this.recordingManager.startedRecordings.remove(recording.getId()); + this.cleanRecordingMaps(recording); this.recorders.remove(recording.getSessionId()); recording = this.sealMetadataFiles(recording); - recording = this.recordingManager.updateRecordingUrl(recording); if (reason != null && session != null) { this.recordingManager.sessionHandler.sendRecordingStoppedNotification(session, recording, reason); @@ -167,8 +157,8 @@ public class SingleStreamRecordingService extends RecordingService { return recording; } - public void startOneIndividualStreamRecording(Session session, String recordingId, MediaProfileSpecType profile, - Participant participant, CountDownLatch globalStartLatch) { + public void startRecorderEndpointForPublisherEndpoint(Session session, String recordingId, + MediaProfileSpecType profile, Participant participant, CountDownLatch globalStartLatch) { log.info("Starting single stream recorder for stream {} in session {}", participant.getPublisherStreamId(), session.getSessionId()); @@ -224,20 +214,22 @@ public class SingleStreamRecordingService extends RecordingService { wrapper.getRecorder().record(); } - public void stopOneIndividualStreamRecording(String sessionId, String streamId, CountDownLatch globalStopLatch) { + public void stopRecorderEndpointOfPublisherEndpoint(String sessionId, String streamId, + CountDownLatch globalStopLatch) { log.info("Stopping single stream recorder for stream {} in session {}", streamId, sessionId); - RecorderEndpointWrapper wrapper = this.recorders.get(sessionId).remove(streamId); - if (wrapper != null) { - wrapper.getRecorder().addStoppedListener(new EventListener() { + final RecorderEndpointWrapper finalWrapper = this.recorders.get(sessionId).remove(streamId); + if (finalWrapper != null) { + finalWrapper.getRecorder().addStoppedListener(new EventListener() { @Override public void onEvent(StoppedEvent event) { - wrapper.setEndTime(System.currentTimeMillis()); - generateIndividualMetadataFile(wrapper); + finalWrapper.setEndTime(System.currentTimeMillis()); + generateIndividualMetadataFile(finalWrapper); log.info("Recording stopped event for stream {}", streamId); + finalWrapper.getRecorder().release(); globalStopLatch.countDown(); } }); - wrapper.getRecorder().stop(); + finalWrapper.getRecorder().stop(); } else { log.error("Stream {} wasn't being recorded in session {}", streamId, sessionId); } @@ -264,12 +256,8 @@ public class SingleStreamRecordingService extends RecordingService { } else { profile = MediaProfileSpecType.WEBM_AUDIO_ONLY; } - } else if (propertiesHasVideo) { - profile = MediaProfileSpecType.WEBM_VIDEO_ONLY; } else { - // ERROR: RecordingProperties set to not record audio nor video - throw new OpenViduException(Code.MEDIA_TYPE_RECORDING_PROPERTIES_ERROR_CODE, - "RecordingProperties set to \"hasVideo(false)\" and \"hasAudio(false)\""); + profile = MediaProfileSpecType.WEBM_VIDEO_ONLY; } } else { // Stream has audio track only @@ -278,7 +266,8 @@ public class SingleStreamRecordingService extends RecordingService { profile = MediaProfileSpecType.WEBM_AUDIO_ONLY; } else { // ERROR: RecordingProperties set to video only but there's no video track - throw new OpenViduException(Code.MEDIA_TYPE_RECORDING_PROPERTIES_ERROR_CODE, + throw new OpenViduException( + Code.MEDIA_TYPE_STREAM_INCOMPATIBLE_WITH_RECORDING_PROPERTIES_ERROR_CODE, "RecordingProperties set to \"hasAudio(false)\" but stream is audio-only"); } } @@ -289,12 +278,12 @@ public class SingleStreamRecordingService extends RecordingService { profile = MediaProfileSpecType.WEBM_VIDEO_ONLY; } else { // ERROR: RecordingProperties set to audio only but there's no audio track - throw new OpenViduException(Code.MEDIA_TYPE_RECORDING_PROPERTIES_ERROR_CODE, + throw new OpenViduException(Code.MEDIA_TYPE_STREAM_INCOMPATIBLE_WITH_RECORDING_PROPERTIES_ERROR_CODE, "RecordingProperties set to \"hasVideo(false)\" but stream is video-only"); } } else { - // ERROR: Stream has no track at all - throw new OpenViduException(Code.MEDIA_TYPE_RECORDING_PROPERTIES_ERROR_CODE, + // ERROR: Stream has no track at all. This branch should never be reachable + throw new OpenViduException(Code.MEDIA_TYPE_STREAM_INCOMPATIBLE_WITH_RECORDING_PROPERTIES_ERROR_CODE, "Stream has no track at all. Cannot be recorded"); } return profile; @@ -318,13 +307,6 @@ public class SingleStreamRecordingService extends RecordingService { } } - private void generateRecordingMetadataFile(Recording recording) { - String filePath = this.openviduConfig.getOpenViduRecordingPath() + recording.getId() + "/" - + RecordingManager.RECORDING_ENTITY_FILE + recording.getId(); - String text = recording.toJson().toString(); - this.fileWriter.createAndWriteFile(filePath, text); - } - private void generateIndividualMetadataFile(RecorderEndpointWrapper wrapper) { String filesPath = this.openviduConfig.getOpenViduRecordingPath() + wrapper.getRecordingId() + "/"; File videoFile = new File(filesPath + wrapper.getStreamId() + ".webm"); @@ -381,8 +363,8 @@ public class SingleStreamRecordingService extends RecordingService { jsonFile.addProperty("size", wr.getSize()); jsonFile.addProperty("clientData", wr.getClientData()); jsonFile.addProperty("serverData", wr.getServerData()); - jsonFile.addProperty("hasAudio", wr.hasAudio()); - jsonFile.addProperty("hasVideo", wr.hasVideo()); + jsonFile.addProperty("hasAudio", wr.hasAudio() && recording.hasAudio()); + jsonFile.addProperty("hasVideo", wr.hasVideo() && recording.hasVideo()); if (wr.hasVideo()) { jsonFile.addProperty("typeOfVideo", wr.getTypeOfVideo()); } @@ -394,17 +376,14 @@ public class SingleStreamRecordingService extends RecordingService { } json.add("files", jsonArrayFiles); - - long duration = (maxEndTime - minStartTime) / 1000; - - recording.setSize(accumulatedSize); // Size in bytes - recording.setDuration(duration > 0 ? duration : 0); // Duration in seconds - recording.setStatus(io.openvidu.java.client.Recording.Status.stopped); - - this.fileWriter.overwriteFile(metadataFilePath, recording.toJson().toString()); this.fileWriter.createAndWriteFile(syncFilePath, new GsonBuilder().setPrettyPrinting().create().toJson(json)); this.generateZipFileAndCleanFolder(folderPath, recording.getName() + ".zip"); + long duration = (maxEndTime - minStartTime) / 1000; + duration = duration > 0 ? duration : 0; + + recording = this.sealRecordingMetadataFile(recording, accumulatedSize, duration, metadataFilePath); + return recording; } diff --git a/openvidu-server/src/main/java/io/openvidu/server/rest/SessionRestController.java b/openvidu-server/src/main/java/io/openvidu/server/rest/SessionRestController.java index 3ec30b0a..6b89f999 100644 --- a/openvidu-server/src/main/java/io/openvidu/server/rest/SessionRestController.java +++ b/openvidu-server/src/main/java/io/openvidu/server/rest/SessionRestController.java @@ -53,7 +53,6 @@ import io.openvidu.server.core.SessionManager; import io.openvidu.server.kurento.core.KurentoTokenOptions; import io.openvidu.server.recording.Recording; import io.openvidu.server.recording.service.RecordingManager; -import io.openvidu.server.utils.FormatChecker; /** * @@ -300,6 +299,8 @@ public class SessionRestController { String name = (String) params.get("name"); String outputModeString = (String) params.get("outputMode"); String resolution = (String) params.get("resolution"); + Boolean hasAudio = (Boolean) params.get("hasAudio"); + Boolean hasVideo = (Boolean) params.get("hasVideo"); String recordingLayoutString = (String) params.get("recordingLayout"); String customLayout = (String) params.get("customLayout"); @@ -335,15 +336,18 @@ public class SessionRestController { } catch (Exception e) { outputMode = io.openvidu.java.client.Recording.OutputMode.COMPOSED; } - RecordingProperties.Builder builder = new RecordingProperties.Builder().name(name).outputMode(outputMode); + RecordingProperties.Builder builder = new RecordingProperties.Builder().name(name).outputMode(outputMode) + .hasAudio(hasAudio != null ? hasAudio : true).hasVideo(hasVideo != null ? hasVideo : true); if (outputMode.equals(io.openvidu.java.client.Recording.OutputMode.COMPOSED)) { if (resolution != null) { - if (new FormatChecker().isAcceptableResolution(resolution)) { + if (sessionManager.formatChecker.isAcceptableRecordingResolution(resolution)) { builder.resolution(resolution); } else { - return new ResponseEntity<>(HttpStatus.UNPROCESSABLE_ENTITY); + return new ResponseEntity<>( + "Wrong \"resolution\" parameter. Acceptable values from 100 to 1999 for both width and height", + HttpStatus.UNPROCESSABLE_ENTITY); } } @@ -367,8 +371,15 @@ public class SessionRestController { } } + RecordingProperties properties = builder.build(); + if (!properties.hasAudio() && !properties.hasVideo()) { + // Cannot start a recording with both "hasAudio" and "hasVideo" to false + return new ResponseEntity<>("Cannot start a recording with both \"hasAudio\" and \"hasVideo\" set to false", + HttpStatus.UNPROCESSABLE_ENTITY); + } + try { - Recording startedRecording = this.recordingManager.startRecording(session, builder.build()); + Recording startedRecording = this.recordingManager.startRecording(session, properties); return new ResponseEntity<>(startedRecording.toJson().toString(), getResponseHeaders(), HttpStatus.OK); } catch (OpenViduException e) { return new ResponseEntity<>("Error starting recording: " + e.getMessage(), getResponseHeaders(), diff --git a/openvidu-server/src/main/java/io/openvidu/server/rpc/RpcHandler.java b/openvidu-server/src/main/java/io/openvidu/server/rpc/RpcHandler.java index ded85842..b0a502a9 100644 --- a/openvidu-server/src/main/java/io/openvidu/server/rpc/RpcHandler.java +++ b/openvidu-server/src/main/java/io/openvidu/server/rpc/RpcHandler.java @@ -208,7 +208,7 @@ public class RpcHandler extends DefaultJsonRpcHandler { String clientMetadata = getStringParam(request, ProtocolElements.JOINROOM_METADATA_PARAM); - if (sessionManager.isMetadataFormatCorrect(clientMetadata)) { + if (sessionManager.formatChecker.isServerMetadataFormatCorrect(clientMetadata)) { Token tokenObj = sessionManager.consumeToken(sessionId, participantPrivatetId, token); Participant participant; diff --git a/openvidu-server/src/main/java/io/openvidu/server/utils/FormatChecker.java b/openvidu-server/src/main/java/io/openvidu/server/utils/FormatChecker.java index d3849603..e809863a 100644 --- a/openvidu-server/src/main/java/io/openvidu/server/utils/FormatChecker.java +++ b/openvidu-server/src/main/java/io/openvidu/server/utils/FormatChecker.java @@ -19,10 +19,14 @@ package io.openvidu.server.utils; public class FormatChecker { - public boolean isAcceptableResolution(String stringResolution) { + public boolean isAcceptableRecordingResolution(String stringResolution) { // Matches every string with format "AxB", being A and B any number not starting // with 0 and 3 digits long or 4 digits long if they start with 1 return stringResolution.matches("^(?!(0))(([0-9]{3})|1([0-9]{3}))x(?!0)(([0-9]{3})|1([0-9]{3}))$"); } + + public boolean isServerMetadataFormatCorrect(String metadata) { + return true; + } }