diff --git a/openvidu-server/src/main/java/io/openvidu/server/OpenViduServer.java b/openvidu-server/src/main/java/io/openvidu/server/OpenViduServer.java index c0d9109d..3b0bd110 100644 --- a/openvidu-server/src/main/java/io/openvidu/server/OpenViduServer.java +++ b/openvidu-server/src/main/java/io/openvidu/server/OpenViduServer.java @@ -23,7 +23,6 @@ import java.net.URL; import java.util.List; import javax.annotation.PostConstruct; -import javax.ws.rs.ProcessingException; import org.kurento.jsonrpc.JsonUtils; import org.kurento.jsonrpc.internal.server.config.JsonRpcConfiguration; @@ -57,7 +56,7 @@ import io.openvidu.server.kurento.KurentoClientProvider; import io.openvidu.server.kurento.core.KurentoSessionEventsHandler; import io.openvidu.server.kurento.core.KurentoSessionManager; import io.openvidu.server.kurento.kms.FixedOneKmsManager; -import io.openvidu.server.recording.ComposedRecordingService; +import io.openvidu.server.recording.service.RecordingManager; import io.openvidu.server.rpc.RpcHandler; import io.openvidu.server.rpc.RpcNotificationService; import io.openvidu.server.utils.CommandExecutor; @@ -81,7 +80,7 @@ public class OpenViduServer implements JsonRpcConfigurer { public static final String KMSS_URIS_PROPERTY = "kms.uris"; public static String wsUrl; - + public static String httpUrl; @Bean @@ -147,8 +146,8 @@ public class OpenViduServer implements JsonRpcConfigurer { @Bean @ConditionalOnMissingBean - public ComposedRecordingService composedRecordingService() { - return new ComposedRecordingService(); + public RecordingManager recordingManager() { + return new RecordingManager(); } @Bean @@ -200,7 +199,7 @@ public class OpenViduServer implements JsonRpcConfigurer { case "local": break; - + case "": break; @@ -233,59 +232,10 @@ public class OpenViduServer implements JsonRpcConfigurer { OpenViduServer.wsUrl = OpenViduServer.wsUrl.substring(0, OpenViduServer.wsUrl.length() - 1); } - boolean recordingModuleEnabled = openviduConf.isRecordingModuleEnabled(); - if (recordingModuleEnabled) { - ComposedRecordingService recordingService = composedRecordingService(); - recordingService.setRecordingVersion(openviduConf.getOpenViduRecordingVersion()); - log.info("Recording module required: Downloading openvidu/openvidu-recording:" - + openviduConf.getOpenViduRecordingVersion() + " Docker image (800 MB aprox)"); - - boolean imageExists = false; - try { - imageExists = recordingService.recordingImageExistsLocally(); - } catch (ProcessingException exception) { - String message = "Exception connecting to Docker daemon: "; - if ("docker".equals(openviduConf.getSpringProfile())) { - final String NEW_LINE = System.getProperty("line.separator"); - message += "make sure you include the following flags in your \"docker run\" command:" + - NEW_LINE + " -e openvidu.recording.path=/YOUR/PATH/TO/VIDEO/FILES" + - NEW_LINE + " -e MY_UID=$(id -u $USER)" + - NEW_LINE + " -v /var/run/docker.sock:/var/run/docker.sock" + - NEW_LINE + " -v /YOUR/PATH/TO/VIDEO/FILES:/YOUR/PATH/TO/VIDEO/FILES" + - NEW_LINE; - } else { - message += "you need Docker installed in this machine to enable OpenVidu recording service"; - } - log.error(message); - throw new RuntimeException(message); - } - - if (imageExists) { - log.info("Docker image already exists locally"); - } else { - Thread t = new Thread(() -> { - boolean keep = true; - log.info("Downloading "); - while (keep) { - System.out.print("."); - try { - Thread.sleep(1000); - } catch (InterruptedException e) { - keep = false; - log.info("\nDownload complete"); - } - } - }); - t.start(); - recordingService.downloadRecordingImage(); - t.interrupt(); - t.join(); - log.info("Docker image available"); - } - - recordingService.initRecordingPath(); + if (this.openviduConfig().isRecordingModuleEnabled()) { + this.recordingManager().initializeRecordingManager(); } - + httpUrl = openviduConf.getFinalUrl(); log.info("OpenVidu Server using " + type + " URL: [" + OpenViduServer.wsUrl + "]"); } 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 b60d15dd..9edb581a 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 @@ -17,9 +17,7 @@ package io.openvidu.server.cdr; -import java.util.Arrays; import java.util.Iterator; -import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; @@ -32,6 +30,7 @@ import io.openvidu.server.core.MediaOptions; import io.openvidu.server.core.Participant; import io.openvidu.server.core.Session; import io.openvidu.server.recording.Recording; +import io.openvidu.server.recording.service.RecordingManager; /** * CDR logger to register all information of a Session. @@ -69,6 +68,7 @@ import io.openvidu.server.recording.Recording; * - webrtcConnectionDestroyed.reason: "unsubscribe", "unpublish", "disconnect", "networkDisconnect", "openviduServerStopped" * - participantLeft.reason: "unsubscribe", "unpublish", "disconnect", "networkDisconnect", "openviduServerStopped" * - sessionDestroyed.reason: "lastParticipantLeft", "openviduServerStopped" + * - recordingStopped.reason: "recordingStoppedByServer", "lastParticipantLeft", "sessionClosedByServer", "openviduServerStopped" * * [OPTIONAL_PROPERTIES]: * - receivingFrom: only if connection = "INBOUND" @@ -91,9 +91,6 @@ public class CallDetailRecord { private Map> subscriptions = new ConcurrentHashMap<>(); private Map recordings = new ConcurrentHashMap<>(); - private final List lastParticipantLeftReasons = Arrays.asList( - new String[] { "disconnect", "forceDisconnectByUser", "forceDisconnectByServer", "networkDisconnect" }); - public CallDetailRecord(CDRLogger logger) { this.logger = logger; } @@ -108,7 +105,7 @@ public class CallDetailRecord { public void recordSessionDestroyed(String sessionId, String reason) { CDREvent e = this.sessions.remove(sessionId); if (openviduConfig.isCdrEnabled()) - this.logger.log(new CDREventSession(e, this.finalReason(reason))); + this.logger.log(new CDREventSession(e, RecordingManager.finalReason(reason))); } public void recordParticipantJoined(Participant participant, String sessionId) { @@ -124,7 +121,8 @@ public class CallDetailRecord { this.logger.log(new CDREventParticipant(e, reason)); } - public void recordNewPublisher(Participant participant, String sessionId, MediaOptions mediaOptions, Long timestamp) { + public void recordNewPublisher(Participant participant, String sessionId, MediaOptions mediaOptions, + Long timestamp) { CDREventWebrtcConnection publisher = new CDREventWebrtcConnection(sessionId, participant.getParticipantPublicId(), mediaOptions, null, timestamp); this.publications.put(participant.getParticipantPublicId(), publisher); @@ -181,15 +179,7 @@ public class CallDetailRecord { public void recordRecordingStopped(String sessionId, Recording recording, String reason) { CDREventRecording recordingStartedEvent = this.recordings.remove(recording.getId()); if (openviduConfig.isCdrEnabled()) - this.logger.log(new CDREventRecording(recordingStartedEvent, this.finalReason(reason))); - } - - private String finalReason(String reason) { - if (lastParticipantLeftReasons.contains(reason)) { - return "lastParticipantLeft"; - } else { - return reason; - } + this.logger.log(new CDREventRecording(recordingStartedEvent, RecordingManager.finalReason(reason))); } } diff --git a/openvidu-server/src/main/java/io/openvidu/server/config/SecurityConfig.java b/openvidu-server/src/main/java/io/openvidu/server/config/SecurityConfig.java index 37c89e9d..3c89b2b7 100644 --- a/openvidu-server/src/main/java/io/openvidu/server/config/SecurityConfig.java +++ b/openvidu-server/src/main/java/io/openvidu/server/config/SecurityConfig.java @@ -24,7 +24,6 @@ import org.springframework.security.config.annotation.authentication.builders.Au import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; import org.springframework.security.config.annotation.web.configurers.ExpressionUrlAuthorizationConfigurer; -import org.springframework.security.config.http.SessionCreationPolicy; @Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { diff --git a/openvidu-server/src/main/java/io/openvidu/server/core/SessionEventsHandler.java b/openvidu-server/src/main/java/io/openvidu/server/core/SessionEventsHandler.java index 64fa0437..6e53f44e 100644 --- a/openvidu-server/src/main/java/io/openvidu/server/core/SessionEventsHandler.java +++ b/openvidu-server/src/main/java/io/openvidu/server/core/SessionEventsHandler.java @@ -143,8 +143,7 @@ public class SessionEventsHandler { // Metadata associated to new participant notifParams.addProperty(ProtocolElements.PARTICIPANTJOINED_USER_PARAM, participant.getParticipantPublicId()); - notifParams.addProperty(ProtocolElements.PARTICIPANTJOINED_CREATEDAT_PARAM, - participant.getCreatedAt()); + notifParams.addProperty(ProtocolElements.PARTICIPANTJOINED_CREATEDAT_PARAM, participant.getCreatedAt()); notifParams.addProperty(ProtocolElements.PARTICIPANTJOINED_METADATA_PARAM, participant.getFullMetadata()); @@ -404,12 +403,17 @@ public class SessionEventsHandler { evictedParticipant.getParticipantPublicId()); params.addProperty(ProtocolElements.PARTICIPANTEVICTED_REASON_PARAM, reason); - rpcNotificationService.sendNotification(evictedParticipant.getParticipantPrivateId(), - ProtocolElements.PARTICIPANTEVICTED_METHOD, params); - for (Participant p : participants) { - rpcNotificationService.sendNotification(p.getParticipantPrivateId(), + if (!ProtocolElements.RECORDER_PARTICIPANT_PUBLICID.equals(evictedParticipant.getParticipantPublicId())) { + // Do not send a message when evicting RECORDER participant + rpcNotificationService.sendNotification(evictedParticipant.getParticipantPrivateId(), ProtocolElements.PARTICIPANTEVICTED_METHOD, params); } + for (Participant p : participants) { + if (!ProtocolElements.RECORDER_PARTICIPANT_PUBLICID.equals(evictedParticipant.getParticipantPublicId())) { + rpcNotificationService.sendNotification(p.getParticipantPrivateId(), + ProtocolElements.PARTICIPANTEVICTED_METHOD, params); + } + } } public void sendRecordingStartedNotification(Session session, Recording recording) { @@ -550,6 +554,9 @@ public class SessionEventsHandler { private Set filterParticipantsByRole(ParticipantRole[] roles, Set participants) { return participants.stream().filter(part -> { + if (ProtocolElements.RECORDER_PARTICIPANT_PUBLICID.equals(part.getParticipantPublicId())) { + return false; + } boolean isRole = false; for (ParticipantRole role : roles) { isRole = role.equals(part.getToken().getRole()); 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 d6341c8e..6dcb60e9 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 @@ -44,7 +44,7 @@ import io.openvidu.server.config.OpenviduConfig; import io.openvidu.server.coturn.CoturnCredentialsService; import io.openvidu.server.coturn.TurnCredentials; import io.openvidu.server.kurento.core.KurentoTokenOptions; -import io.openvidu.server.recording.ComposedRecordingService; +import io.openvidu.server.recording.service.RecordingManager; public abstract class SessionManager { @@ -54,7 +54,7 @@ public abstract class SessionManager { protected SessionEventsHandler sessionEventsHandler; @Autowired - protected ComposedRecordingService recordingService; + protected RecordingManager recordingManager; @Autowired protected CallDetailRecord CDR; @@ -440,19 +440,21 @@ public abstract class SessionManager { this.closeSessionAndEmptyCollections(session, reason); - if (recordingService.sessionIsBeingRecorded(session.getSessionId())) { - recordingService.stopRecording(session, null, reason); - } - return participants; } - public void closeSessionAndEmptyCollections(Session session, String reason) { + protected void closeSessionAndEmptyCollections(Session session, String reason) { + + if (openviduConfig.isRecordingModuleEnabled() + && this.recordingManager.sessionIsBeingRecorded(session.getSessionId())) { + recordingManager.stopRecording(session, null, RecordingManager.finalReason(reason)); + } + if (session.close(reason)) { sessionEventsHandler.onSessionClosed(session.getSessionId(), reason); } - sessions.remove(session.getSessionId()); + sessions.remove(session.getSessionId()); sessionProperties.remove(session.getSessionId()); sessionCreationTime.remove(session.getSessionId()); sessionidParticipantpublicidParticipant.remove(session.getSessionId()); diff --git a/openvidu-server/src/main/java/io/openvidu/server/kurento/OpenViduKurentoClientSessionInfo.java b/openvidu-server/src/main/java/io/openvidu/server/kurento/OpenViduKurentoClientSessionInfo.java index b8e4d07d..ca6afb58 100644 --- a/openvidu-server/src/main/java/io/openvidu/server/kurento/OpenViduKurentoClientSessionInfo.java +++ b/openvidu-server/src/main/java/io/openvidu/server/kurento/OpenViduKurentoClientSessionInfo.java @@ -17,8 +17,6 @@ package io.openvidu.server.kurento; -import io.openvidu.server.kurento.KurentoClientSessionInfo; - /** * Implementation of the session info interface, contains a participant's * private id and the session's id. 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 50b83921..b2d59e1b 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 @@ -53,19 +53,21 @@ import io.openvidu.server.core.MediaOptions; import io.openvidu.server.core.Participant; import io.openvidu.server.kurento.TrackType; import io.openvidu.server.kurento.endpoint.KmsEvent; +import io.openvidu.server.kurento.endpoint.KmsMediaEvent; import io.openvidu.server.kurento.endpoint.MediaEndpoint; import io.openvidu.server.kurento.endpoint.PublisherEndpoint; import io.openvidu.server.kurento.endpoint.SdpType; import io.openvidu.server.kurento.endpoint.SubscriberEndpoint; +import io.openvidu.server.recording.service.RecordingManager; public class KurentoParticipant extends Participant { private static final Logger log = LoggerFactory.getLogger(KurentoParticipant.class); - private OpenviduConfig openviduConfig; - private InfoHandler infoHandler; private CallDetailRecord CDR; + private OpenviduConfig openviduConfig; + private RecordingManager recordingManager; private boolean webParticipant = true; @@ -79,11 +81,15 @@ public class KurentoParticipant extends Participant { private final ConcurrentMap subscribers = new ConcurrentHashMap(); public KurentoParticipant(Participant participant, KurentoSession kurentoSession, MediaPipeline pipeline, - InfoHandler infoHandler, CallDetailRecord CDR, OpenviduConfig openviduConfig) { + InfoHandler infoHandler, CallDetailRecord CDR, OpenviduConfig openviduConfig, + RecordingManager recordingManager) { super(participant.getParticipantPrivateId(), participant.getParticipantPublicId(), participant.getToken(), participant.getClientMetadata(), participant.getLocation(), participant.getPlatform(), participant.getCreatedAt()); + this.infoHandler = infoHandler; + this.CDR = CDR; this.openviduConfig = openviduConfig; + this.recordingManager = recordingManager; this.session = kurentoSession; this.pipeline = pipeline; this.publisher = new PublisherEndpoint(webParticipant, this, participant.getParticipantPublicId(), pipeline, @@ -94,8 +100,6 @@ public class KurentoParticipant extends Participant { getNewOrExistingSubscriber(other.getParticipantPublicId()); } } - this.infoHandler = infoHandler; - this.CDR = CDR; } public void createPublishingEndpoint(MediaOptions mediaOptions) { @@ -233,6 +237,11 @@ public class KurentoParticipant extends Participant { log.info("PARTICIPANT {}: Is now publishing video in room {}", this.getParticipantPublicId(), this.session.getSessionId()); + if (this.openviduConfig.isRecordingModuleEnabled() + && this.recordingManager.sessionIsBeingRecordedIndividual(session.getSessionId())) { + this.recordingManager.startOneIndividualStreamRecording(session, null, null, this); + } + CDR.recordNewPublisher(this, this.session.getSessionId(), this.publisher.getMediaOptions(), this.publisher.createdAt()); @@ -425,9 +434,15 @@ public class KurentoParticipant extends Participant { private void releasePublisherEndpoint(String reason) { if (publisher != null && publisher.getEndpoint() != null) { - // Store streamId from publisher's map + // Remove streamId from publisher's map this.session.publishedStreamIds.remove(this.getPublisherStreamId()); + if (this.openviduConfig.isRecordingModuleEnabled() + && this.recordingManager.sessionIsBeingRecordedIndividual(session.getSessionId())) { + this.recordingManager.stopOneIndividualStreamRecording(session.getSessionId(), + this.getPublisherStreamId()); + } + publisher.unregisterErrorListeners(); for (MediaElement el : publisher.getMediaElements()) { releaseElement(getParticipantPublicId(), el); @@ -507,53 +522,6 @@ public class KurentoParticipant extends Participant { * " | TIMESTAMP: " + System.currentTimeMillis(); log.debug(msg); * this.infoHandler.sendInfo(msg); }); * - * endpoint.getWebEndpoint().addMediaFlowInStateChangeListener((event) -> { - * String msg1 = " Media flow in state change (" + - * endpoint.getEndpoint().getName() + ") -> " + "STATE: " + - * event.getState() + " | SOURCE: " + event.getSource().getName() + " | PAD: " + - * event.getPadName() + " | MEDIATYPE: " + event.getMediaType() + - * " | TIMESTAMP: " + System.currentTimeMillis(); - * - * endpoint.flowInMedia.put(event.getSource().getName() + "/" + - * event.getMediaType(), event.getSource()); - * - * String msg2; - * - * if (endpoint.flowInMedia.values().size() != 2) { msg2 = - * " THERE ARE LESS FLOW IN MEDIA'S THAN EXPECTED IN " + - * endpoint.getEndpoint().getName() + " (" + - * endpoint.flowInMedia.values().size() + ")"; } else { msg2 = - * " NUMBER OF FLOW IN MEDIA'S IS NOW CORRECT IN " + - * endpoint.getEndpoint().getName() + " (" + - * endpoint.flowInMedia.values().size() + ")"; } - * - * log.debug(msg1); log.debug(msg2); this.infoHandler.sendInfo(msg1); - * this.infoHandler.sendInfo(msg2); }); - * - * endpoint.getWebEndpoint().addMediaFlowOutStateChangeListener((event) -> { - * String msg1 = " Media flow out state change (" + - * endpoint.getEndpoint().getName() + ") -> " + "STATE: " + - * event.getState() + " | SOURCE: " + event.getSource().getName() + " | PAD: " + - * event.getPadName() + " | MEDIATYPE: " + event.getMediaType() + - * " | TIMESTAMP: " + System.currentTimeMillis(); - * - * endpoint.flowOutMedia. @SuppressWarnings("unchecked") - * put(event.getSource().getName() + "/" + event.getMediaType(), - * event.getSource()); - * - * String msg2; - * - * if (endpoint.flowOutMedia.values().size() != 2) { msg2 = - * " THERE ARE LESS FLOW OUT MEDIA'S THAN EXPECTED IN " + - * endpoint.getEndpoint().getName() + " (" + - * endpoint.flowOutMedia.values().size() + ")"; } else { msg2 = - * " NUMBER OF FLOW OUT MEDIA'S IS NOW CORRECT IN " + - * endpoint.getEndpoint().getName() + " (" + - * endpoint.flowOutMedia.values().size() + ")"; } - * - * log.debug(msg1); log.debug(msg2); this.infoHandler.sendInfo(msg1); - * this.infoHandler.sendInfo(msg2); }); - * * endpoint.getWebEndpoint().addMediaSessionStartedListener((event) -> { String * msg = " Media session started (" + * endpoint.getEndpoint().getName() + ") | TIMESTAMP: " + @@ -568,15 +536,8 @@ public class KurentoParticipant extends Participant { * * endpoint.getWebEndpoint().addMediaStateChangedListener((event) -> { String * msg = " Media state changed (" + - * endpoint.getEndpoint().getName() + ") from " + event.getOldState() + - * " to " + event.getNewState(); log.debug(msg); this.infoHandler.sendInfo(msg); - * }); - * - * endpoint.getWebEndpoint().addConnectionStateChangedListener((event) -> { - * String msg = " Connection state changed (" + - * endpoint.getEndpoint().getName() + ") from " + event.getOldState() + - * " to " + event.getNewState() + " | TIMESTAMP: " + System.currentTimeMillis(); - * log.debug(msg); this.infoHandler.sendInfo(msg); }); + * endpoint.getEndpoint().getName() + ") from " + event.getOldState() + " to " + + * event.getNewState(); log.debug(msg); this.infoHandler.sendInfo(msg); }); * * endpoint.getWebEndpoint().addIceCandidateFoundListener((event) -> { String * msg = " ICE CANDIDATE FOUND (" + @@ -584,38 +545,13 @@ public class KurentoParticipant extends Participant { * event.getCandidate().getCandidate() + " | TIMESTAMP: " + * System.currentTimeMillis(); log.debug(msg); this.infoHandler.sendInfo(msg); * }); - * - * endpoint.getWebEndpoint().addIceComponentStateChangeListener((event) -> { - * String msg = " ICE COMPONENT STATE CHANGE (" + - * endpoint.getEndpoint().getName() + "): for component " + - * event.getComponentId() + " - STATE: " + event.getState() + " | TIMESTAMP: " + - * System.currentTimeMillis(); log.debug(msg); this.infoHandler.sendInfo(msg); - * }); - * - * endpoint.getWebEndpoint().addIceGatheringDoneListener((event) -> { String msg - * = " ICE GATHERING DONE! (" + - * endpoint.getEndpoint().getName() + ")" + " | TIMESTAMP: " + - * System.currentTimeMillis(); log.debug(msg); this.infoHandler.sendInfo(msg); - * }); */ endpoint.getWebEndpoint().addMediaFlowInStateChangeListener(event -> { String msg1 = "Media flow in state change (" + endpoint.getEndpoint().getName() + ") -> " + "STATE: " + event.getState() + " | SOURCE: " + event.getSource().getName() + " | PAD: " + event.getPadName() + " | MEDIATYPE: " + event.getMediaType() + " | TIMESTAMP: " + System.currentTimeMillis(); - - /* - * endpoint.flowInMedia.put(event.getSource().getName(), event.getMediaType()); - * if (endpoint.getPublisher().getMediaOptions().hasAudio() && - * endpoint.getPublisher().getMediaOptions().hasVideo() && - * endpoint.flowInMedia.values().size() == 2) { - */ - endpoint.kmsEvents.add(new KmsEvent(event, endpoint.createdAt())); - /* - * } else if (endpoint.flowInMedia.values().size() == 1) { - * endpoint.kmsEvents.add(new KmsEvent(event, endpoint.createdAt())); } - */ - + endpoint.kmsEvents.add(new KmsMediaEvent(event, event.getMediaType(), endpoint.createdAt())); log.info(msg1); this.infoHandler.sendInfo(msg1); }); @@ -624,19 +560,7 @@ public class KurentoParticipant extends Participant { String msg1 = "Media flow out state change (" + endpoint.getEndpoint().getName() + ") -> " + "STATE: " + event.getState() + " | SOURCE: " + event.getSource().getName() + " | PAD: " + event.getPadName() + " | MEDIATYPE: " + event.getMediaType() + " | TIMESTAMP: " + System.currentTimeMillis(); - - /* - * endpoint.flowOutMedia.put(event.getSource().getName(), event.getMediaType()); - * if (endpoint.getPublisher().getMediaOptions().hasAudio() && - * endpoint.getPublisher().getMediaOptions().hasVideo() && - * endpoint.flowOutMedia.values().size() == 2) { - */ - endpoint.kmsEvents.add(new KmsEvent(event, endpoint.createdAt())); - /* - * } else if (endpoint.flowOutMedia.values().size() == 1) { - * endpoint.kmsEvents.add(new KmsEvent(event)); } - */ - + endpoint.kmsEvents.add(new KmsMediaEvent(event, event.getMediaType(), endpoint.createdAt())); log.info(msg1); this.infoHandler.sendInfo(msg1); }); @@ -661,7 +585,7 @@ public class KurentoParticipant extends Participant { }); endpoint.getEndpoint().addMediaTranscodingStateChangeListener(event -> { - endpoint.kmsEvents.add(new KmsEvent(event, endpoint.createdAt())); + endpoint.kmsEvents.add(new KmsMediaEvent(event, event.getMediaType(), endpoint.createdAt())); }); endpoint.getWebEndpoint().addIceComponentStateChangeListener(event -> { diff --git a/openvidu-server/src/main/java/io/openvidu/server/kurento/core/KurentoSession.java b/openvidu-server/src/main/java/io/openvidu/server/kurento/core/KurentoSession.java index 99b01670..8e2750cc 100644 --- a/openvidu-server/src/main/java/io/openvidu/server/kurento/core/KurentoSession.java +++ b/openvidu-server/src/main/java/io/openvidu/server/kurento/core/KurentoSession.java @@ -47,6 +47,7 @@ import io.openvidu.server.cdr.CallDetailRecord; import io.openvidu.server.config.OpenviduConfig; import io.openvidu.server.core.Participant; import io.openvidu.server.core.Session; +import io.openvidu.server.recording.service.RecordingManager; /** * @author Pablo Fuente (pablofuenteperez@gmail.com) @@ -57,6 +58,7 @@ public class KurentoSession implements Session { public static final int ASYNC_LATCH_TIMEOUT = 30; private OpenviduConfig openviduConfig; + private RecordingManager recordingManager; private final ConcurrentMap participants = new ConcurrentHashMap<>(); private String sessionId; @@ -85,7 +87,8 @@ public class KurentoSession implements Session { public KurentoSession(String sessionId, Long startTime, SessionProperties sessionProperties, KurentoClient kurentoClient, KurentoSessionEventsHandler kurentoSessionHandler, - boolean destroyKurentoClient, CallDetailRecord CDR, OpenviduConfig openviduConfig) { + boolean destroyKurentoClient, CallDetailRecord CDR, OpenviduConfig openviduConfig, + RecordingManager recordingManager) { this.sessionId = sessionId; this.sessionProperties = sessionProperties; this.kurentoClient = kurentoClient; @@ -93,6 +96,7 @@ public class KurentoSession implements Session { this.kurentoSessionHandler = kurentoSessionHandler; this.CDR = CDR; this.openviduConfig = openviduConfig; + this.recordingManager = recordingManager; this.startTime = startTime; log.debug("New SESSION instance with id '{}'", sessionId); } @@ -113,7 +117,7 @@ public class KurentoSession implements Session { createPipeline(); KurentoParticipant kurentoParticipant = new KurentoParticipant(participant, this, getPipeline(), - kurentoSessionHandler.getInfoHandler(), this.CDR, this.openviduConfig); + kurentoSessionHandler.getInfoHandler(), this.CDR, this.openviduConfig, this.recordingManager); participants.put(participant.getParticipantPrivateId(), kurentoParticipant); filterStates.forEach((filterId, state) -> { 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 0174c7cd..547c3c2f 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 @@ -170,25 +170,37 @@ public class KurentoSessionManager extends SessionManager { reason); if (remainingParticipants.isEmpty()) { - log.info("No more participants in session '{}', removing it and closing it", sessionId); - this.closeSessionAndEmptyCollections(session, reason); - showTokens(); + 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()); + } else { + log.info("No more participants in session '{}', removing it and closing it", sessionId); + this.closeSessionAndEmptyCollections(session, reason); + showTokens(); + } } else if (remainingParticipants.size() == 1 && openviduConfig.isRecordingModuleEnabled() && MediaMode.ROUTED.equals(session.getSessionProperties().mediaMode()) + && this.recordingManager.sessionIsBeingRecordedComposed(sessionId) && ProtocolElements.RECORDER_PARTICIPANT_PUBLICID .equals(remainingParticipants.iterator().next().getParticipantPublicId())) { if (RecordingMode.ALWAYS.equals(session.getSessionProperties().recordingMode())) { // Immediately stop recording when last real participant left if // RecordingMode.ALWAYS log.info("Last participant left. Stopping recording for session {}", sessionId); - recordingService.stopRecording(session, null, reason); + recordingManager.stopRecording(session, null, reason); evictParticipant(session.getParticipantByPublicId(ProtocolElements.RECORDER_PARTICIPANT_PUBLICID), null, null, "EVICT_RECORDER"); } 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 countdown for stopping recording of session {}", sessionId); - recordingService.initAutomaticRecordingStopThread(session.getSessionId()); + log.info("Last participant left. Starting 2 minutes countdown for stopping recording of session {}", + sessionId); + recordingManager.initAutomaticRecordingStopThread(session.getSessionId()); } } @@ -277,20 +289,21 @@ public class KurentoSessionManager extends SessionManager { && MediaMode.ROUTED.equals(session.getSessionProperties().mediaMode()) && session.getActivePublishers() == 0) { if (RecordingMode.ALWAYS.equals(session.getSessionProperties().recordingMode()) - && !recordingService.sessionIsBeingRecorded(session.getSessionId())) { + && !recordingManager.sessionIsBeingRecorded(session.getSessionId())) { // Insecure session recording new Thread(() -> { - recordingService.startRecording(session, + recordingManager.startRecording(session, new RecordingProperties.Builder().name("") + .outputMode(io.openvidu.java.client.Recording.OutputMode.COMPOSED) .recordingLayout(session.getSessionProperties().defaultRecordingLayout()) .customLayout(session.getSessionProperties().defaultCustomLayout()).build()); }).start(); } else if (RecordingMode.MANUAL.equals(session.getSessionProperties().recordingMode()) - && recordingService.sessionIsBeingRecorded(session.getSessionId())) { + && recordingManager.sessionIsBeingRecorded(session.getSessionId())) { // Abort automatic recording stop (user published before timeout) log.info("Participant {} published before timeout finished. Aborting automatic recording stop", participant.getParticipantPublicId()); - boolean stopAborted = recordingService.abortAutomaticRecordingStopThread(session.getSessionId()); + boolean stopAborted = recordingManager.abortAutomaticRecordingStopThread(session.getSessionId()); if (stopAborted) { log.info("Automatic recording stopped succesfully aborted"); } else { @@ -491,7 +504,7 @@ public class KurentoSessionManager extends SessionManager { this.kurentoClient = kcProvider.getKurentoClient(kcSessionInfo); session = new KurentoSession(sessionId, this.sessionCreationTime.get(sessionId), sessionProperties, kurentoClient, kurentoSessionEventsHandler, kcProvider.destroyWhenUnused(), this.CDR, - this.openviduConfig); + this.openviduConfig, this.recordingManager); KurentoSession oldSession = (KurentoSession) sessions.putIfAbsent(sessionId, session); if (oldSession != null) { diff --git a/openvidu-server/src/main/java/io/openvidu/server/kurento/endpoint/KmsEvent.java b/openvidu-server/src/main/java/io/openvidu/server/kurento/endpoint/KmsEvent.java index f86a6cc1..499d16e3 100644 --- a/openvidu-server/src/main/java/io/openvidu/server/kurento/endpoint/KmsEvent.java +++ b/openvidu-server/src/main/java/io/openvidu/server/kurento/endpoint/KmsEvent.java @@ -1,3 +1,20 @@ +/* + * (C) Copyright 2017-2018 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.kurento.endpoint; import org.kurento.client.MediaEvent; diff --git a/openvidu-server/src/main/java/io/openvidu/server/kurento/endpoint/KmsMediaEvent.java b/openvidu-server/src/main/java/io/openvidu/server/kurento/endpoint/KmsMediaEvent.java new file mode 100644 index 00000000..3a328bb5 --- /dev/null +++ b/openvidu-server/src/main/java/io/openvidu/server/kurento/endpoint/KmsMediaEvent.java @@ -0,0 +1,32 @@ +/* + * (C) Copyright 2017-2018 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.kurento.endpoint; + +import org.kurento.client.MediaEvent; +import org.kurento.client.MediaType; + +public class KmsMediaEvent extends KmsEvent { + + MediaType mediaType; + + public KmsMediaEvent(MediaEvent event, MediaType mediaType, long createdAt) { + super(event, createdAt); + this.mediaType = mediaType; + } + +} diff --git a/openvidu-server/src/main/java/io/openvidu/server/kurento/endpoint/MediaEndpoint.java b/openvidu-server/src/main/java/io/openvidu/server/kurento/endpoint/MediaEndpoint.java index 482d64bf..84a3603f 100644 --- a/openvidu-server/src/main/java/io/openvidu/server/kurento/endpoint/MediaEndpoint.java +++ b/openvidu-server/src/main/java/io/openvidu/server/kurento/endpoint/MediaEndpoint.java @@ -19,9 +19,7 @@ package io.openvidu.server.kurento.endpoint; import java.util.LinkedList; import java.util.List; -import java.util.Map; import java.util.Queue; -import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.CountDownLatch; @@ -32,7 +30,6 @@ import org.kurento.client.IceCandidate; import org.kurento.client.ListenerSubscription; import org.kurento.client.MediaElement; import org.kurento.client.MediaPipeline; -import org.kurento.client.MediaType; import org.kurento.client.OnIceCandidateEvent; import org.kurento.client.RtpEndpoint; import org.kurento.client.SdpEndpoint; @@ -85,9 +82,6 @@ public abstract class MediaEndpoint { private final List receivedCandidateList = new LinkedList(); private LinkedList candidates = new LinkedList(); - public Map flowInMedia = new ConcurrentHashMap<>(); - public Map flowOutMedia = new ConcurrentHashMap<>(); - public String selectedLocalIceCandidate; public String selectedRemoteIceCandidate; public Queue kmsEvents = new ConcurrentLinkedQueue<>(); diff --git a/openvidu-server/src/main/java/io/openvidu/server/kurento/kms/FixedOneKmsManager.java b/openvidu-server/src/main/java/io/openvidu/server/kurento/kms/FixedOneKmsManager.java index b6b41731..b49bfc92 100644 --- a/openvidu-server/src/main/java/io/openvidu/server/kurento/kms/FixedOneKmsManager.java +++ b/openvidu-server/src/main/java/io/openvidu/server/kurento/kms/FixedOneKmsManager.java @@ -18,19 +18,42 @@ package io.openvidu.server.kurento.kms; import org.kurento.client.KurentoClient; - -import io.openvidu.server.kurento.kms.Kms; -import io.openvidu.server.kurento.kms.KmsManager; +import org.kurento.client.KurentoConnectionListener; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; public class FixedOneKmsManager extends KmsManager { - public FixedOneKmsManager(String kmsWsUri) { - this(kmsWsUri, 1); - } + private static final Logger log = LoggerFactory.getLogger(FixedOneKmsManager.class); - public FixedOneKmsManager(String kmsWsUri, int numKmss) { - for (int i = 0; i < numKmss; i++) { - this.addKms(new Kms(KurentoClient.create(kmsWsUri), kmsWsUri)); - } - } + public FixedOneKmsManager(String kmsWsUri) { + this(kmsWsUri, 1); + } + + public FixedOneKmsManager(String kmsWsUri, int numKmss) { + for (int i = 0; i < numKmss; i++) { + this.addKms(new Kms(KurentoClient.create(kmsWsUri, new KurentoConnectionListener() { + + @Override + public void reconnected(boolean isReconnected) { + log.warn("Kurento Client reconnected ({}) to KMS with uri {}", isReconnected, kmsWsUri); + } + + @Override + public void disconnected() { + log.warn("Kurento Client disconnected from KMS with uri {}", kmsWsUri); + } + + @Override + public void connectionFailed() { + log.warn("Kurento Client failed connecting to KMS with uri {}", kmsWsUri); + } + + @Override + public void connected() { + log.warn("Kurento Client is now connected to KMS with uri {}", kmsWsUri); + } + }), kmsWsUri)); + } + } } 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 new file mode 100644 index 00000000..a9ae8a77 --- /dev/null +++ b/openvidu-server/src/main/java/io/openvidu/server/recording/RecorderEndpointWrapper.java @@ -0,0 +1,131 @@ +/* + * (C) Copyright 2017-2018 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 org.kurento.client.RecorderEndpoint; + +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; + + long startTime; + long endTime; + long size; + + public RecorderEndpointWrapper(RecorderEndpoint recorder, String connectionId, String recordingId, String streamId, + String clientData, String serverData, boolean hasAudio, boolean hasVideo, String typeOfVideo) { + this.recorder = recorder; + this.connectionId = connectionId; + this.recordingId = recordingId; + this.streamId = streamId; + this.clientData = clientData; + this.serverData = serverData; + this.hasAudio = hasAudio; + this.hasVideo = hasVideo; + this.typeOfVideo = typeOfVideo; + } + + public RecorderEndpoint getRecorder() { + return recorder; + } + + public String getConnectionId() { + return connectionId; + } + + public String getRecordingId() { + return recordingId; + } + + public String getStreamId() { + return streamId; + } + + public String getClientData() { + return clientData; + } + + public String getServerData() { + return serverData; + } + + public long getStartTime() { + return startTime; + } + + public void setStartTime(long startTime) { + this.startTime = startTime; + } + + public long getEndTime() { + return endTime; + } + + public void setEndTime(long endTime) { + this.endTime = endTime; + } + + public long getSize() { + return size; + } + + public void setSize(long size) { + this.size = size; + } + + public boolean hasAudio() { + return hasAudio; + } + + public boolean hasVideo() { + return hasVideo; + } + + public String getTypeOfVideo() { + return typeOfVideo; + } + + public JsonObject toJson() { + JsonObject json = new JsonObject(); + json.addProperty("connectionId", this.connectionId); + json.addProperty("streamId", this.streamId); + json.addProperty("clientData", this.clientData); + json.addProperty("serverData", this.serverData); + json.addProperty("startTime", this.startTime); + json.addProperty("endTime", this.endTime); + json.addProperty("duration", this.endTime - this.startTime); + json.addProperty("size", this.size); + json.addProperty("hasAudio", this.hasAudio); + json.addProperty("hasVideo", this.hasVideo); + if (this.hasVideo) { + json.addProperty("typeOfVideo", this.typeOfVideo); + } + return json; + } + +} 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 c04bd72e..8383f644 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 @@ -24,16 +24,7 @@ import io.openvidu.java.client.RecordingProperties; public class Recording { - public enum Status { - starting, // The recording is starting (cannot be stopped) - started, // The recording has started and is going on - stopped, // The recording has finished OK - available, // The recording is available for downloading. This status is reached for all - // stopped recordings if property 'openvidu.recording.public-access' is true - failed; // The recording has failed - } - - private Recording.Status status; + private io.openvidu.java.client.Recording.Status status; private String id; private String sessionId; @@ -49,7 +40,7 @@ public class Recording { this.sessionId = sessionId; this.createdAt = System.currentTimeMillis(); this.id = id; - this.status = Status.started; + this.status = io.openvidu.java.client.Recording.Status.started; this.recordingProperties = recordingProperties; } @@ -70,16 +61,27 @@ public class Recording { } this.hasAudio = json.get("hasAudio").getAsBoolean(); this.hasVideo = json.get("hasVideo").getAsBoolean(); - this.status = Status.valueOf(json.get("status").getAsString()); - this.recordingProperties = new RecordingProperties.Builder().name(json.get("name").getAsString()) - .recordingLayout(RecordingLayout.valueOf(json.get("recordingLayout").getAsString())).build(); + this.status = io.openvidu.java.client.Recording.Status.valueOf(json.get("status").getAsString()); + + 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); + if (io.openvidu.java.client.Recording.OutputMode.COMPOSED.equals(outputMode)) { + RecordingLayout recordingLayout = RecordingLayout.valueOf(json.get("recordingLayout").getAsString()); + builder.recordingLayout(recordingLayout); + if (RecordingLayout.CUSTOM.equals(recordingLayout)) { + builder.customLayout(json.get("customLayout").getAsString()); + } + } + this.recordingProperties = builder.build(); } - public Status getStatus() { + public io.openvidu.java.client.Recording.Status getStatus() { return status; } - public void setStatus(Status status) { + public void setStatus(io.openvidu.java.client.Recording.Status status) { this.status = status; } @@ -95,6 +97,10 @@ public class Recording { return this.recordingProperties.name(); } + public io.openvidu.java.client.Recording.OutputMode getOutputMode() { + return this.recordingProperties.outputMode(); + } + public RecordingLayout getRecordingLayout() { return this.recordingProperties.recordingLayout(); } @@ -103,6 +109,10 @@ public class Recording { return this.recordingProperties.customLayout(); } + public RecordingProperties getRecordingProperties() { + return this.recordingProperties; + } + public String getSessionId() { return sessionId; } @@ -163,9 +173,12 @@ public class Recording { JsonObject json = new JsonObject(); json.addProperty("id", this.id); json.addProperty("name", this.recordingProperties.name()); - json.addProperty("recordingLayout", this.recordingProperties.recordingLayout().name()); - if (RecordingLayout.CUSTOM.equals(this.recordingProperties.recordingLayout())) { - json.addProperty("customLayout", this.recordingProperties.customLayout()); + json.addProperty("outputMode", this.getOutputMode().name()); + if (io.openvidu.java.client.Recording.OutputMode.COMPOSED.equals(this.recordingProperties.outputMode())) { + json.addProperty("recordingLayout", this.recordingProperties.recordingLayout().name()); + if (RecordingLayout.CUSTOM.equals(this.recordingProperties.recordingLayout())) { + json.addProperty("customLayout", this.recordingProperties.customLayout()); + } } json.addProperty("sessionId", this.sessionId); json.addProperty("createdAt", this.createdAt); diff --git a/openvidu-server/src/main/java/io/openvidu/server/recording/RecordingInfoUtils.java b/openvidu-server/src/main/java/io/openvidu/server/recording/RecordingInfoUtils.java index 27847a36..e1288376 100644 --- a/openvidu-server/src/main/java/io/openvidu/server/recording/RecordingInfoUtils.java +++ b/openvidu-server/src/main/java/io/openvidu/server/recording/RecordingInfoUtils.java @@ -17,6 +17,7 @@ package io.openvidu.server.recording; +import java.io.File; import java.io.FileNotFoundException; import java.io.FileReader; import java.io.IOException; @@ -38,35 +39,35 @@ public class RecordingInfoUtils { private JsonObject videoStream; private JsonObject audioStream; - public RecordingInfoUtils(String fullVideoPath) throws FileNotFoundException, IOException, OpenViduException { + private String infoFilePath; + public RecordingInfoUtils(String infoFilePath) throws FileNotFoundException, IOException, OpenViduException { + + this.infoFilePath = infoFilePath; this.parser = new JsonParser(); try { - this.json = parser.parse(new FileReader(fullVideoPath)).getAsJsonObject(); + this.json = parser.parse(new FileReader(infoFilePath)).getAsJsonObject(); } catch (JsonIOException | JsonSyntaxException e) { // Recording metadata from ffprobe is not a JSON: video file is corrupted throw new OpenViduException(Code.RECORDING_FILE_EMPTY_ERROR, "The recording file is corrupted"); } - if (this.json.size() == 0) { // Recording metadata from ffprobe is an emtpy JSON throw new OpenViduException(Code.RECORDING_FILE_EMPTY_ERROR, "The recording file is empty"); } this.jsonFormat = json.get("format").getAsJsonObject(); - JsonArray streams = json.get("streams").getAsJsonArray(); for (int i = 0; i < streams.size(); i++) { JsonObject stream = streams.get(i).getAsJsonObject(); - if ("video".equals(stream.get("codec_type").toString())) { + if ("video".equals(stream.get("codec_type").getAsString())) { this.videoStream = stream; - } else if ("audio".equals(stream.get("codec_type").toString())) { + } else if ("audio".equals(stream.get("codec_type").getAsString())) { this.audioStream = stream; } } - } public double getDurationInSeconds() { @@ -124,4 +125,8 @@ public class RecordingInfoUtils { return audioStream.get("codec_long_name").toString(); } + public boolean deleteFilePath() { + return new File(this.infoFilePath).delete(); + } + } diff --git a/openvidu-server/src/main/java/io/openvidu/server/recording/ComposedRecordingService.java b/openvidu-server/src/main/java/io/openvidu/server/recording/service/ComposedRecordingService.java similarity index 52% rename from openvidu-server/src/main/java/io/openvidu/server/recording/ComposedRecordingService.java rename to openvidu-server/src/main/java/io/openvidu/server/recording/service/ComposedRecordingService.java index 583b8f62..1e8e636e 100644 --- a/openvidu-server/src/main/java/io/openvidu/server/recording/ComposedRecordingService.java +++ b/openvidu-server/src/main/java/io/openvidu/server/recording/service/ComposedRecordingService.java @@ -15,35 +15,22 @@ * */ -package io.openvidu.server.recording; +package io.openvidu.server.recording.service; import java.io.File; -import java.io.FileReader; import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; import java.util.ArrayList; -import java.util.Collection; -import java.util.HashSet; import java.util.List; import java.util.Map; -import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ScheduledFuture; -import java.util.concurrent.ScheduledThreadPoolExecutor; import java.util.concurrent.TimeUnit; -import java.util.stream.Collectors; import javax.ws.rs.ProcessingException; -import org.apache.commons.io.FilenameUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; -import org.springframework.stereotype.Service; import com.github.dockerjava.api.DockerClient; import com.github.dockerjava.api.command.CreateContainerCmd; @@ -60,8 +47,6 @@ 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 com.google.gson.JsonObject; -import com.google.gson.JsonParser; import io.openvidu.client.OpenViduException; import io.openvidu.client.OpenViduException.Code; @@ -70,58 +55,42 @@ import io.openvidu.java.client.RecordingProperties; import io.openvidu.server.OpenViduServer; import io.openvidu.server.config.OpenviduConfig; import io.openvidu.server.core.Session; -import io.openvidu.server.core.SessionEventsHandler; +import io.openvidu.server.recording.Recording; +import io.openvidu.server.recording.RecordingInfoUtils; import io.openvidu.server.utils.CommandExecutor; -@Service -public class ComposedRecordingService { +public class ComposedRecordingService extends RecordingService { private static final Logger log = LoggerFactory.getLogger(ComposedRecordingService.class); - @Autowired - private OpenviduConfig openviduConfig; - - @Autowired - private SessionEventsHandler sessionHandler; - private Map containers = new ConcurrentHashMap<>(); private Map sessionsContainers = new ConcurrentHashMap<>(); - private Map startingRecordings = new ConcurrentHashMap<>(); - private Map startedRecordings = new ConcurrentHashMap<>(); - private Map sessionsRecordings = new ConcurrentHashMap<>(); - private final Map> automaticRecordingStopThreads = new ConcurrentHashMap<>(); - - private ScheduledThreadPoolExecutor automaticRecordingStopExecutor = new ScheduledThreadPoolExecutor( - Runtime.getRuntime().availableProcessors()); private final String IMAGE_NAME = "openvidu/openvidu-recording"; private String IMAGE_TAG; - private final String RECORDING_ENTITY_FILE = ".recording."; private DockerClient dockerClient; - public ComposedRecordingService() { + public ComposedRecordingService(RecordingManager recordingManager, OpenviduConfig openviduConfig) { + super(recordingManager, openviduConfig); DockerClientConfig config = DefaultDockerClientConfig.createDefaultConfigBuilder().build(); this.dockerClient = DockerClientBuilder.getInstance(config).build(); } - public Recording startRecording(Session session, RecordingProperties properties) { + @Override + public Recording startRecording(Session session, RecordingProperties properties) throws OpenViduException { List envs = new ArrayList<>(); - String shortSessionId = session.getSessionId().substring(session.getSessionId().lastIndexOf('/') + 1, - session.getSessionId().length()); - String recordingId = this.getFreeRecordingId(session.getSessionId(), shortSessionId); - if (properties.name() == null || properties.name().isEmpty()) { - // No name provided for the recording file - properties = new RecordingProperties.Builder().name(recordingId) - .recordingLayout(properties.recordingLayout()).customLayout(properties.customLayout()).build(); - } + PropertiesRecordingId updatePropertiesAndRecordingId = this.setFinalRecordingNameAndGetFreeRecordingId(session, + properties); + properties = updatePropertiesAndRecordingId.properties; + String recordingId = updatePropertiesAndRecordingId.recordingId; Recording recording = new Recording(session.getSessionId(), recordingId, properties); - this.sessionsRecordings.put(session.getSessionId(), recording); - this.sessionHandler.setRecordingStarted(session.getSessionId(), recording); - this.startingRecordings.put(recording.getId(), recording); + this.recordingManager.sessionsRecordings.put(session.getSessionId(), recording); + this.recordingManager.sessionHandler.setRecordingStarted(session.getSessionId(), recording); + this.recordingManager.startingRecordings.put(recording.getId(), recording); String uid = null; try { @@ -133,7 +102,7 @@ public class ComposedRecordingService { e.printStackTrace(); } - String layoutUrl = this.getLayoutUrl(recording, shortSessionId); + String layoutUrl = this.getLayoutUrl(recording, this.getShortSessionId(session)); envs.add("URL=" + layoutUrl); envs.add("RESOLUTION=1920x1080"); @@ -147,37 +116,38 @@ public class ComposedRecordingService { log.info(recording.toJson().toString()); log.info("Recorder connecting to url {}", layoutUrl); - String containerId = this.runRecordingContainer(envs, "recording_" + recordingId); + String containerId; + try { + containerId = this.runRecordingContainer(envs, "recording_" + recordingId); + } catch (Exception e) { + this.cleanRecordingMapsAndReturnContainerId(recording); + throw new OpenViduException(Code.RECORDING_START_ERROR_CODE, + "Couldn't initialize recording container. Error: " + e.getMessage()); + } - this.waitForVideoFileNotEmpty(properties.name()); + this.waitForVideoFileNotEmpty(recording); this.sessionsContainers.put(session.getSessionId(), containerId); - recording.setStatus(Recording.Status.started); + recording.setStatus(io.openvidu.java.client.Recording.Status.started); - this.startedRecordings.put(recording.getId(), recording); - this.startingRecordings.remove(recording.getId()); + this.recordingManager.startedRecordings.put(recording.getId(), recording); + this.recordingManager.startingRecordings.remove(recording.getId()); return recording; } - public Recording stopRecording(Session session, String recordingId, String reason) { - Recording recording; - String containerId; + @Override + public Recording stopRecording(Session session, Recording recording, String reason) { + String containerId = cleanRecordingMapsAndReturnContainerId(recording); + final String recordingId = recording.getId(); if (session == null) { log.warn( "Existing recording {} does not have an active session associated. This usually means the recording" + " layout did not join a recorded participant or the recording has been automatically" + " stopped after last user left and timeout passed", - recordingId); - recording = this.startedRecordings.remove(recordingId); - containerId = this.sessionsContainers.remove(recording.getSessionId()); - this.sessionsRecordings.remove(recording.getSessionId()); - } else { - recording = this.sessionsRecordings.remove(session.getSessionId()); - containerId = this.sessionsContainers.remove(session.getSessionId()); - this.startedRecordings.remove(recording.getId()); + recording.getId()); } if (containerId == null) { @@ -205,8 +175,9 @@ public class ComposedRecordingService { containerClosed = true; log.warn("Container {} for closed session {} succesfully stopped and removed", containerIdAux, session.getSessionId()); - log.warn("Deleting unusable files for recording {}", recording.getId()); - if (HttpStatus.NO_CONTENT.equals(this.deleteRecordingFromHost(recording.getId(), true))) { + log.warn("Deleting unusable files for recording {}", recordingId); + if (HttpStatus.NO_CONTENT + .equals(this.recordingManager.deleteRecordingFromHost(recordingId, true))) { log.warn("Files properly deleted"); } } @@ -234,12 +205,12 @@ public class ComposedRecordingService { try { stopped = latch.await(60, TimeUnit.SECONDS); } catch (InterruptedException e) { - recording.setStatus(Recording.Status.failed); + recording.setStatus(io.openvidu.java.client.Recording.Status.failed); failRecordingCompletion(containerId, new OpenViduException(Code.RECORDING_COMPLETION_ERROR_CODE, "The recording completion process has been unexpectedly interrupted")); } if (!stopped) { - recording.setStatus(Recording.Status.failed); + recording.setStatus(io.openvidu.java.client.Recording.Status.failed); failRecordingCompletion(containerId, new OpenViduException(Code.RECORDING_COMPLETION_ERROR_CODE, "The recording completion process couldn't finish in 60 seconds")); } @@ -250,28 +221,25 @@ public class ComposedRecordingService { // Update recording attributes reading from video report file try { RecordingInfoUtils infoUtils = new RecordingInfoUtils( - this.openviduConfig.getOpenViduRecordingPath() + recording.getId() + ".info"); + this.openviduConfig.getOpenViduRecordingPath() + recordingId + "/" + recordingId + ".info"); - if (openviduConfig.getOpenViduRecordingPublicAccess()) { - recording.setStatus(Recording.Status.available); - } else { - recording.setStatus(Recording.Status.stopped); - } + recording.setStatus(io.openvidu.java.client.Recording.Status.stopped); recording.setDuration(infoUtils.getDurationInSeconds()); recording.setSize(infoUtils.getSizeInBytes()); recording.setHasAudio(infoUtils.hasAudio()); recording.setHasVideo(infoUtils.hasVideo()); - if (openviduConfig.getOpenViduRecordingPublicAccess()) { - recording.setUrl(this.openviduConfig.getFinalUrl() + "recordings/" + recording.getName() + ".mp4"); - } + infoUtils.deleteFilePath(); + + recording = this.recordingManager.updateRecordingUrl(recording); } catch (IOException e) { + recording.setStatus(io.openvidu.java.client.Recording.Status.failed); throw new OpenViduException(Code.RECORDING_REPORT_ERROR_CODE, "There was an error generating the metadata report file for the recording"); } if (session != null) { - this.sessionHandler.sendRecordingStoppedNotification(session, recording, reason); + this.recordingManager.sessionHandler.sendRecordingStoppedNotification(session, recording, reason); } } return recording; @@ -306,19 +274,7 @@ public class ComposedRecordingService { } } - public boolean sessionIsBeingRecorded(String sessionId) { - return (this.sessionsRecordings.get(sessionId) != null); - } - - public Recording getStartedRecording(String recordingId) { - return this.startedRecordings.get(recordingId); - } - - public Recording getStartingRecording(String recordingId) { - return this.startingRecordings.get(recordingId); - } - - private String runRecordingContainer(List envs, String containerName) { + 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) @@ -334,7 +290,10 @@ public class ComposedRecordingService { log.error( "The container name {} is already in use. Probably caused by a session with unique publisher re-publishing a stream", containerName); - return null; + throw e; + } catch (NotFoundException e) { + log.error("Docker image {} couldn't be found in docker host", IMAGE_NAME + ":" + IMAGE_TAG); + throw e; } } @@ -358,159 +317,13 @@ public class ComposedRecordingService { return imageExists; } - public Collection getAllRecordings() { - return this.getAllRecordingsFromHost(); - } - - public Collection getStartingRecordings() { - return this.startingRecordings.values(); - } - - public Collection getStartedRecordings() { - return this.startedRecordings.values(); - } - - public Collection getFinishedRecordings() { - return this.getAllRecordingsFromHost().stream() - .filter(recording -> (recording.getStatus().equals(Recording.Status.stopped) - || recording.getStatus().equals(Recording.Status.available))) - .collect(Collectors.toSet()); - } - - public File initRecordingPath() throws OpenViduException { - try { - Path path = Files.createDirectories(Paths.get(this.openviduConfig.getOpenViduRecordingPath())); - - if (!Files.isWritable(path)) { - throw new OpenViduException(Code.RECORDING_PATH_NOT_VALID, - "The recording path '" + this.openviduConfig.getOpenViduRecordingPath() - + "' is not valid. Reason: OpenVidu Server process needs write permissions"); - } - - log.info("Recording path: {}", this.openviduConfig.getOpenViduRecordingPath()); - return path.toFile(); - } catch (IOException e) { - throw new OpenViduException(Code.RECORDING_PATH_NOT_VALID, - "The recording path '" + this.openviduConfig.getOpenViduRecordingPath() + "' is not valid. Reason: " - + e.getClass().getName()); - } - } - - private Recording getRecordingFromHost(String recordingId) { - log.info(this.openviduConfig.getOpenViduRecordingPath() + RECORDING_ENTITY_FILE + recordingId); - File file = new File(this.openviduConfig.getOpenViduRecordingPath() + RECORDING_ENTITY_FILE + recordingId); - log.info("File exists: " + file.exists()); - return this.getRecordingFromEntityFile(file); - } - - private Set getAllRecordingsFromHost() { - File folder = new File(this.openviduConfig.getOpenViduRecordingPath()); - File[] files = folder.listFiles(); - - if (files == null) { - files = initRecordingPath().listFiles(); - } - - Set recordingEntities = new HashSet<>(); - for (int i = 0; i < files.length; i++) { - Recording recording = this.getRecordingFromEntityFile(files[i]); - if (recording != null) { - if (openviduConfig.getOpenViduRecordingPublicAccess()) { - if (Recording.Status.stopped.equals(recording.getStatus())) { - recording.setStatus(Recording.Status.available); - recording.setUrl( - this.openviduConfig.getFinalUrl() + "recordings/" + recording.getName() + ".mp4"); - } - } - recordingEntities.add(recording); - } - } - return recordingEntities; - } - - private Set getRecordingIdsFromHost() { - File folder = new File(this.openviduConfig.getOpenViduRecordingPath()); - File[] files = folder.listFiles(); - - if (files == null) { - files = initRecordingPath().listFiles(); - } - - Set fileNamesNoExtension = new HashSet<>(); - for (int i = 0; i < files.length; i++) { - if (files[i].isFile() && !files[i].getName().startsWith(RECORDING_ENTITY_FILE)) { - fileNamesNoExtension.add(FilenameUtils.removeExtension(files[i].getName())); - } - } - return fileNamesNoExtension; - } - - public HttpStatus deleteRecordingFromHost(String recordingId, boolean force) { - - if (!force && (this.startedRecordings.containsKey(recordingId) - || this.startingRecordings.containsKey(recordingId))) { - // Cannot delete an active recording - return HttpStatus.CONFLICT; - } - - Recording recording = getRecordingFromHost(recordingId); - if (recording == null) { - return HttpStatus.NOT_FOUND; - } - - String name = getRecordingFromHost(recordingId).getName(); - - File folder = new File(this.openviduConfig.getOpenViduRecordingPath()); - File[] files = folder.listFiles(); - for (int i = 0; i < files.length; i++) { - if (files[i].isFile() && isFileFromRecording(files[i], recordingId, name)) { - files[i].delete(); - } - } - - return HttpStatus.NO_CONTENT; - } - - private Recording getRecordingFromEntityFile(File file) { - if (file.isFile() && file.getName().startsWith(RECORDING_ENTITY_FILE)) { - JsonObject json = null; - try { - json = new JsonParser().parse(new FileReader(file)).getAsJsonObject(); - } catch (IOException e) { - return null; - } - return new Recording(json); - } - return null; - } - - private boolean isFileFromRecording(File file, String recordingId, String recordingName) { - return (((recordingId + ".info").equals(file.getName())) - || ((RECORDING_ENTITY_FILE + recordingId).equals(file.getName())) - || (recordingName + ".mp4").equals(file.getName()) || (recordingId + ".jpg").equals(file.getName())); - } - - private String getFreeRecordingId(String sessionId, String shortSessionId) { - Set recordingIds = this.getRecordingIdsFromHost(); - String recordingId = shortSessionId; - boolean isPresent = recordingIds.contains(recordingId); - int i = 1; - - while (isPresent) { - recordingId = shortSessionId + "-" + i; - i++; - isPresent = recordingIds.contains(recordingId); - } - - return recordingId; - } - - private void waitForVideoFileNotEmpty(String videoName) { + private void waitForVideoFileNotEmpty(Recording recording) { boolean isPresent = false; while (!isPresent) { try { Thread.sleep(150); - File f = new File(this.openviduConfig.getOpenViduRecordingPath() + videoName + ".mp4"); + File f = new File(this.openviduConfig.getOpenViduRecordingPath() + recording.getId() + "/" + + recording.getName() + ".mp4"); isPresent = ((f.isFile()) && (f.length() > 0)); } catch (InterruptedException e) { e.printStackTrace(); @@ -547,23 +360,14 @@ public class ComposedRecordingService { return finalUrl; } - public void setRecordingVersion(String version) { + 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; } - public void initAutomaticRecordingStopThread(String sessionId) { - final String recordingId = this.sessionsRecordings.get(sessionId).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); - } - - public boolean abortAutomaticRecordingStopThread(String sessionId) { - ScheduledFuture future = this.automaticRecordingStopThreads.remove(sessionId); - return future.cancel(false); - } - } 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 new file mode 100644 index 00000000..479bd56e --- /dev/null +++ b/openvidu-server/src/main/java/io/openvidu/server/recording/service/RecordingManager.java @@ -0,0 +1,441 @@ +/* + * (C) Copyright 2017-2018 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.service; + +import java.io.File; +import java.io.FileReader; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +import javax.ws.rs.ProcessingException; + +import org.kurento.client.MediaProfileSpecType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import io.openvidu.client.OpenViduException; +import io.openvidu.client.OpenViduException.Code; +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.recording.Recording; + +public class RecordingManager { + + private static final Logger log = LoggerFactory.getLogger(RecordingManager.class); + + RecordingService recordingService; + private ComposedRecordingService composedRecordingService; + private SingleStreamRecordingService singleStreamRecordingService; + + @Autowired + protected SessionEventsHandler sessionHandler; + + @Autowired + protected OpenviduConfig openviduConfig; + + protected Map startingRecordings = new ConcurrentHashMap<>(); + protected Map startedRecordings = new ConcurrentHashMap<>(); + protected Map sessionsRecordings = new ConcurrentHashMap<>(); + private final Map> automaticRecordingStopThreads = new ConcurrentHashMap<>(); + + private ScheduledThreadPoolExecutor automaticRecordingStopExecutor = new ScheduledThreadPoolExecutor( + Runtime.getRuntime().availableProcessors()); + + static final String RECORDING_ENTITY_FILE = ".recording."; + + private static final List LAST_PARTICIPANT_LEFT_REASONS = Arrays.asList( + new String[] { "disconnect", "forceDisconnectByUser", "forceDisconnectByServer", "networkDisconnect" }); + + public SessionEventsHandler getSessionEventsHandler() { + return this.sessionHandler; + } + + public void initializeRecordingManager() { + + 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(); + } catch (ProcessingException exception) { + String message = "Exception connecting to Docker daemon: "; + if ("docker".equals(openviduConfig.getSpringProfile())) { + final String NEW_LINE = System.getProperty("line.separator"); + message += "make sure you include the following flags in your \"docker run\" command:" + NEW_LINE + + " -e openvidu.recording.path=/YOUR/PATH/TO/VIDEO/FILES" + NEW_LINE + + " -e MY_UID=$(id -u $USER)" + NEW_LINE + " -v /var/run/docker.sock:/var/run/docker.sock" + + NEW_LINE + " -v /YOUR/PATH/TO/VIDEO/FILES:/YOUR/PATH/TO/VIDEO/FILES" + NEW_LINE; + } else { + message += "you need Docker installed in this machine to enable OpenVidu recording service"; + } + log.error(message); + throw new RuntimeException(message); + } + + if (imageExists) { + log.info("Docker image already exists locally"); + } else { + Thread t = new Thread(() -> { + boolean keep = true; + log.info("Downloading "); + while (keep) { + System.out.print("."); + try { + Thread.sleep(1000); + } catch (InterruptedException e) { + keep = false; + log.info("\nDownload complete"); + } + } + }); + t.start(); + recServiceAux.downloadRecordingImage(); + t.interrupt(); + try { + t.join(); + } catch (InterruptedException e) { + e.printStackTrace(); + } + log.info("Docker image available"); + } + this.initRecordingPath(); + + this.recordingService = recServiceAux; + } + + public Recording startRecording(Session session, RecordingProperties properties) throws OpenViduException { + Recording recording = null; + try { + switch (properties.outputMode()) { + case COMPOSED: + recording = this.composedRecordingService.startRecording(session, properties); + break; + case INDIVIDUAL: + recording = this.singleStreamRecordingService.startRecording(session, properties); + break; + } + } catch (OpenViduException e) { + throw e; + } + if (session.getActivePublishers() == 0) { + // Init automatic recording stop if there are now publishers when starting + // recording + this.initAutomaticRecordingStopThread(session.getSessionId()); + } + return recording; + } + + public Recording stopRecording(Session session, String recordingId, String reason) { + Recording recording; + if (session == null) { + recording = this.startedRecordings.remove(recordingId); + } else { + recording = this.sessionsRecordings.remove(session.getSessionId()); + } + switch (recording.getOutputMode()) { + case COMPOSED: + recording = this.composedRecordingService.stopRecording(session, recording, reason); + break; + case INDIVIDUAL: + recording = this.singleStreamRecordingService.stopRecording(session, recording, reason); + break; + } + return recording; + } + + public void startOneIndividualStreamRecording(Session session, String recordingId, MediaProfileSpecType profile, + Participant participant) { + Recording recording = this.sessionsRecordings.get(session.getSessionId()); + if (recording == null) { + log.error("Cannot start recording of new stream {}. Session {} is not being recorded", + participant.getPublisherStreamId(), session.getSessionId()); + } + if (io.openvidu.java.client.Recording.OutputMode.INDIVIDUAL.equals(recording.getOutputMode())) { + final CountDownLatch startedCountDown = new CountDownLatch(1); + this.singleStreamRecordingService.startOneIndividualStreamRecording(session, recordingId, profile, + participant, startedCountDown); + } + } + + public void stopOneIndividualStreamRecording(String sessionId, String streamId) { + Recording recording = this.sessionsRecordings.get(sessionId); + if (io.openvidu.java.client.Recording.OutputMode.INDIVIDUAL.equals(recording.getOutputMode())) { + final CountDownLatch stoppedCountDown = new CountDownLatch(1); + this.singleStreamRecordingService.stopOneIndividualStreamRecording(sessionId, streamId, stoppedCountDown); + try { + if (!stoppedCountDown.await(5, TimeUnit.SECONDS)) { + log.error("Error waiting for recorder endpoint of stream {} to stop in session {}", streamId, + sessionId); + } + } catch (InterruptedException e) { + log.error("Exception while waiting for state change", e); + } + } + } + + public boolean sessionIsBeingRecorded(String sessionId) { + return (this.sessionsRecordings.get(sessionId) != null); + } + + public boolean sessionIsBeingRecordedIndividual(String sessionId) { + Recording rec = this.sessionsRecordings.get(sessionId); + return (rec != null && io.openvidu.java.client.Recording.OutputMode.INDIVIDUAL.equals(rec.getOutputMode())); + } + + public boolean sessionIsBeingRecordedComposed(String sessionId) { + Recording rec = this.sessionsRecordings.get(sessionId); + return (rec != null && io.openvidu.java.client.Recording.OutputMode.COMPOSED.equals(rec.getOutputMode())); + } + + public Recording getStartedRecording(String recordingId) { + return this.startedRecordings.get(recordingId); + } + + public Recording getStartingRecording(String recordingId) { + return this.startingRecordings.get(recordingId); + } + + public Collection getFinishedRecordings() { + return this.getAllRecordingsFromHost().stream() + .filter(recording -> (recording.getStatus().equals(io.openvidu.java.client.Recording.Status.stopped) + || recording.getStatus().equals(io.openvidu.java.client.Recording.Status.available))) + .collect(Collectors.toSet()); + } + + public Recording getRecording(String recordingId) { + return this.getRecordingFromHost(recordingId); + } + + public Collection getAllRecordings() { + return this.getAllRecordingsFromHost(); + } + + public String getFreeRecordingId(String sessionId, String shortSessionId) { + Set recordingIds = this.getRecordingIdsFromHost(); + String recordingId = shortSessionId; + boolean isPresent = recordingIds.contains(recordingId); + int i = 1; + + while (isPresent) { + recordingId = shortSessionId + "-" + i; + i++; + isPresent = recordingIds.contains(recordingId); + } + + return recordingId; + } + + public HttpStatus deleteRecordingFromHost(String recordingId, boolean force) { + + if (!force && (this.startedRecordings.containsKey(recordingId) + || this.startingRecordings.containsKey(recordingId))) { + // Cannot delete an active recording + return HttpStatus.CONFLICT; + } + + Recording recording = getRecordingFromHost(recordingId); + if (recording == null) { + return HttpStatus.NOT_FOUND; + } + + File folder = new File(this.openviduConfig.getOpenViduRecordingPath()); + File[] files = folder.listFiles(); + for (int i = 0; i < files.length; i++) { + if (files[i].isDirectory() && files[i].getName().equals(recordingId)) { + // Correct folder. Delete all content and the folder itself + File[] allContents = files[i].listFiles(); + if (allContents != null) { + for (File file : allContents) { + file.delete(); + } + } + files[i].delete(); + break; + } + } + + return HttpStatus.NO_CONTENT; + } + + public Recording getRecordingFromEntityFile(File file) { + if (file.isFile() && file.getName().startsWith(RecordingManager.RECORDING_ENTITY_FILE)) { + JsonObject json = null; + try { + json = new JsonParser().parse(new FileReader(file)).getAsJsonObject(); + } catch (IOException e) { + return null; + } + return new Recording(json); + } + return null; + } + + public void initAutomaticRecordingStopThread(String sessionId) { + final String recordingId = this.sessionsRecordings.get(sessionId).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); + } + + public boolean abortAutomaticRecordingStopThread(String sessionId) { + ScheduledFuture future = this.automaticRecordingStopThreads.remove(sessionId); + if (future != null) { + return future.cancel(false); + } else { + return true; + } + } + + public Recording updateRecordingUrl(Recording recording) { + if (openviduConfig.getOpenViduRecordingPublicAccess()) { + if (io.openvidu.java.client.Recording.Status.stopped.equals(recording.getStatus())) { + + String extension; + switch (recording.getOutputMode()) { + case COMPOSED: + extension = "mp4"; + break; + case INDIVIDUAL: + extension = "zip"; + break; + default: + extension = "mp4"; + } + + recording.setUrl(this.openviduConfig.getFinalUrl() + "recordings/" + recording.getId() + "/" + + recording.getName() + "." + extension); + recording.setStatus(io.openvidu.java.client.Recording.Status.available); + } + } + return recording; + } + + private Recording getRecordingFromHost(String recordingId) { + log.info(this.openviduConfig.getOpenViduRecordingPath() + recordingId + "/" + + RecordingManager.RECORDING_ENTITY_FILE + recordingId); + File file = new File(this.openviduConfig.getOpenViduRecordingPath() + recordingId + "/" + + RecordingManager.RECORDING_ENTITY_FILE + recordingId); + log.info("File exists: " + file.exists()); + Recording recording = this.getRecordingFromEntityFile(file); + if (recording != null) { + this.updateRecordingUrl(recording); + } + return recording; + } + + private Set getAllRecordingsFromHost() { + File folder = new File(this.openviduConfig.getOpenViduRecordingPath()); + File[] files = folder.listFiles(); + + Set recordingEntities = new HashSet<>(); + for (int i = 0; i < files.length; i++) { + if (files[i].isDirectory()) { + File[] innerFiles = files[i].listFiles(); + for (int j = 0; j < innerFiles.length; j++) { + Recording recording = this.getRecordingFromEntityFile(innerFiles[j]); + if (recording != null) { + this.updateRecordingUrl(recording); + recordingEntities.add(recording); + } + } + } + } + return recordingEntities; + } + + private Set getRecordingIdsFromHost() { + File folder = new File(this.openviduConfig.getOpenViduRecordingPath()); + File[] files = folder.listFiles(); + + Set fileNamesNoExtension = new HashSet<>(); + for (int i = 0; i < files.length; i++) { + if (files[i].isDirectory()) { + File[] innerFiles = files[i].listFiles(); + for (int j = 0; j < innerFiles.length; j++) { + if (innerFiles[j].isFile() + && innerFiles[j].getName().startsWith(RecordingManager.RECORDING_ENTITY_FILE)) { + fileNamesNoExtension + .add(innerFiles[j].getName().replaceFirst(RecordingManager.RECORDING_ENTITY_FILE, "")); + break; + } + } + } + } + return fileNamesNoExtension; + } + + private File initRecordingPath() throws OpenViduException { + try { + Path path = Files.createDirectories(Paths.get(this.openviduConfig.getOpenViduRecordingPath())); + + if (!Files.isWritable(path)) { + throw new OpenViduException(Code.RECORDING_PATH_NOT_VALID, + "The recording path '" + this.openviduConfig.getOpenViduRecordingPath() + + "' is not valid. Reason: OpenVidu Server process needs write permissions"); + } + + log.info("Recording path: {}", this.openviduConfig.getOpenViduRecordingPath()); + return path.toFile(); + } catch (IOException e) { + throw new OpenViduException(Code.RECORDING_PATH_NOT_VALID, + "The recording path '" + this.openviduConfig.getOpenViduRecordingPath() + "' is not valid. Reason: " + + e.getClass().getName()); + } + } + + public static String finalReason(String reason) { + if (RecordingManager.LAST_PARTICIPANT_LEFT_REASONS.contains(reason)) { + return "lastParticipantLeft"; + } else { + return reason; + } + } + +} 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 new file mode 100644 index 00000000..8ac87d94 --- /dev/null +++ b/openvidu-server/src/main/java/io/openvidu/server/recording/service/RecordingService.java @@ -0,0 +1,85 @@ +/* + * (C) Copyright 2017-2018 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.service; + +import io.openvidu.client.OpenViduException; +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; + +public abstract class RecordingService { + + protected OpenviduConfig openviduConfig; + protected RecordingManager recordingManager; + + RecordingService(RecordingManager recordingManager, OpenviduConfig openviduConfig) { + this.recordingManager = recordingManager; + this.openviduConfig = openviduConfig; + } + + public abstract Recording startRecording(Session session, RecordingProperties properties) throws OpenViduException; + + public abstract Recording stopRecording(Session session, Recording recording, String reason); + + protected RecordingProperties setFinalRecordingName(Session session, RecordingProperties properties) { + // TODO Auto-generated method stub + return null; + } + + protected PropertiesRecordingId setFinalRecordingNameAndGetFreeRecordingId(Session session, + RecordingProperties properties) { + String recordingId = this.recordingManager.getFreeRecordingId(session.getSessionId(), + this.getShortSessionId(session)); + if (properties.name() == null || properties.name().isEmpty()) { + // No name provided for the recording file. Use recordingId + RecordingProperties.Builder builder = new RecordingProperties.Builder().name(recordingId) + .outputMode(properties.outputMode()); + if (io.openvidu.java.client.Recording.OutputMode.COMPOSED.equals(properties.outputMode())) { + builder.recordingLayout(properties.recordingLayout()); + if (RecordingLayout.CUSTOM.equals(properties.recordingLayout())) { + builder.customLayout(properties.customLayout()); + } + } + properties = builder.build(); + } + return new PropertiesRecordingId(properties, recordingId); + } + + protected String getShortSessionId(Session session) { + return session.getSessionId().substring(session.getSessionId().lastIndexOf('/') + 1, + session.getSessionId().length()); + } + + /** + * Simple wrapper for returning update RecordingProperties and a free + * recordingId when starting a new recording + */ + protected class PropertiesRecordingId { + + RecordingProperties properties; + String recordingId; + + PropertiesRecordingId(RecordingProperties properties, String recordingId) { + this.properties = properties; + this.recordingId = recordingId; + } + } + +} 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 new file mode 100644 index 00000000..07d10c85 --- /dev/null +++ b/openvidu-server/src/main/java/io/openvidu/server/recording/service/SingleStreamRecordingService.java @@ -0,0 +1,456 @@ +/* + * (C) Copyright 2017-2018 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.service; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.FileReader; +import java.io.IOException; +import java.io.Reader; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import org.apache.commons.io.FilenameUtils; +import org.kurento.client.ErrorEvent; +import org.kurento.client.EventListener; +import org.kurento.client.MediaPipeline; +import org.kurento.client.MediaProfileSpecType; +import org.kurento.client.MediaType; +import org.kurento.client.RecorderEndpoint; +import org.kurento.client.RecordingEvent; +import org.kurento.client.StoppedEvent; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + +import io.openvidu.client.OpenViduException; +import io.openvidu.client.OpenViduException.Code; +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.kurento.core.KurentoParticipant; +import io.openvidu.server.kurento.endpoint.PublisherEndpoint; +import io.openvidu.server.recording.RecorderEndpointWrapper; +import io.openvidu.server.recording.Recording; +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) { + super(recordingManager, openviduConfig); + } + + @Override + public Recording startRecording(Session session, RecordingProperties properties) throws OpenViduException { + + PropertiesRecordingId updatePropertiesAndRecordingId = this.setFinalRecordingNameAndGetFreeRecordingId(session, + properties); + properties = updatePropertiesAndRecordingId.properties; + String recordingId = updatePropertiesAndRecordingId.recordingId; + + recorders.put(session.getSessionId(), new ConcurrentHashMap()); + + final CountDownLatch recordingStartedCountdown = new CountDownLatch(session.getActivePublishers()); + + for (Participant p : session.getParticipants()) { + if (p.isStreaming()) { + + MediaProfileSpecType profile = null; + try { + profile = generateMediaProfile(properties, p); + } catch (OpenViduException e) { + log.error( + "Cannot start single stream recorder for stream {} in session {}: {}. Skipping to next stream being published", + p.getPublisherStreamId(), session.getSessionId(), e.getMessage()); + continue; + } + this.startOneIndividualStreamRecording(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"); + } + } catch (InterruptedException e) { + recording.setStatus(io.openvidu.java.client.Recording.Status.failed); + log.error("Exception while waiting for state change", e); + } + + if (session.getActivePublishers() == 0) { + // Recording started for a session with some user connected but no publishers + // Must create recording root folder for storing metadata archive + this.fileWriter.createFolder(this.openviduConfig.getOpenViduRecordingPath() + recording.getId()); + } + + 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); + + return recording; + } + + @Override + public Recording stopRecording(Session session, Recording recording, String reason) { + + final int numberOfActiveRecorders = recorders.get(recording.getSessionId()).size(); + final CountDownLatch stoppedCountDown = new CountDownLatch(numberOfActiveRecorders); + + for (RecorderEndpointWrapper wrapper : recorders.get(recording.getSessionId()).values()) { + this.stopOneIndividualStreamRecording(recording.getSessionId(), wrapper.getStreamId(), stoppedCountDown); + } + try { + if (!stoppedCountDown.await(5, TimeUnit.SECONDS)) { + log.error("Error waiting for some recorder endpoint to stop in session {}", recording.getSessionId()); + } + } catch (InterruptedException e) { + log.error("Exception while waiting for state change", e); + } + + this.recordingManager.sessionsRecordings.remove(recording.getSessionId()); + this.recordingManager.startedRecordings.remove(recording.getId()); + 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); + } + + return recording; + } + + public void startOneIndividualStreamRecording(Session session, String recordingId, MediaProfileSpecType profile, + Participant participant, CountDownLatch globalStartLatch) { + log.info("Starting single stream recorder for stream {} in session {}", participant.getPublisherStreamId(), + session.getSessionId()); + + if (recordingId == null) { + // Stream is being recorded because is a new publisher in an ongoing recorded + // session. If recordingId is defined is because Stream is being recorded from + // "startRecording" method + Recording recording = this.recordingManager.sessionsRecordings.get(session.getSessionId()); + recordingId = recording.getId(); + + try { + profile = generateMediaProfile(recording.getRecordingProperties(), participant); + } catch (OpenViduException e) { + log.error("Cannot start single stream recorder for stream {} in session {}: {}", + participant.getPublisherStreamId(), session.getSessionId(), e.getMessage()); + return; + } + } + + KurentoParticipant kurentoParticipant = (KurentoParticipant) participant; + MediaPipeline pipeline = kurentoParticipant.getPublisher().getPipeline(); + + RecorderEndpoint recorder = new RecorderEndpoint.Builder(pipeline, + "file://" + this.openviduConfig.getOpenViduRecordingPath() + recordingId + "/" + + participant.getPublisherStreamId() + ".webm").withMediaProfile(profile).build(); + + recorder.addRecordingListener(new EventListener() { + @Override + public void onEvent(RecordingEvent event) { + recorders.get(session.getSessionId()).get(participant.getPublisherStreamId()) + .setStartTime(System.currentTimeMillis()); + log.info("Recording started event for stream {}", participant.getPublisherStreamId()); + globalStartLatch.countDown(); + } + }); + + recorder.addErrorListener(new EventListener() { + @Override + public void onEvent(ErrorEvent event) { + log.error(event.getErrorCode() + " " + event.getDescription()); + } + }); + + connectAccordingToProfile(kurentoParticipant.getPublisher(), recorder, profile); + + RecorderEndpointWrapper wrapper = new RecorderEndpointWrapper(recorder, participant.getParticipantPublicId(), + recordingId, participant.getPublisherStreamId(), participant.getClientMetadata(), + participant.getServerMetadata(), kurentoParticipant.getPublisher().getMediaOptions().hasAudio(), + kurentoParticipant.getPublisher().getMediaOptions().hasVideo(), + kurentoParticipant.getPublisher().getMediaOptions().getTypeOfVideo()); + + recorders.get(session.getSessionId()).put(participant.getPublisherStreamId(), wrapper); + wrapper.getRecorder().record(); + } + + public void stopOneIndividualStreamRecording(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() { + @Override + public void onEvent(StoppedEvent event) { + wrapper.setEndTime(System.currentTimeMillis()); + generateIndividualMetadataFile(wrapper); + log.info("Recording stopped event for stream {}", streamId); + globalStopLatch.countDown(); + } + }); + wrapper.getRecorder().stop(); + } else { + log.error("Stream {} wasn't being recorded in session {}", streamId, sessionId); + } + } + + private MediaProfileSpecType generateMediaProfile(RecordingProperties properties, Participant participant) + throws OpenViduException { + + KurentoParticipant kParticipant = (KurentoParticipant) participant; + MediaProfileSpecType profile = null; + + boolean streamHasAudio = kParticipant.getPublisher().getMediaOptions().hasAudio(); + boolean streamHasVideo = kParticipant.getPublisher().getMediaOptions().hasVideo(); + boolean propertiesHasAudio = properties.hasAudio(); + boolean propertiesHasVideo = properties.hasVideo(); + + if (streamHasAudio) { + if (streamHasVideo) { + // Stream has both audio and video tracks + + if (propertiesHasAudio) { + if (propertiesHasVideo) { + profile = MediaProfileSpecType.WEBM; + } 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)\""); + } + } else { + // Stream has audio track only + + if (propertiesHasAudio) { + 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, + "RecordingProperties set to \"hasAudio(false)\" but stream is audio-only"); + } + } + } else if (streamHasVideo) { + // Stream has video track only + + if (propertiesHasVideo) { + 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, + "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, + "Stream has no track at all. Cannot be recorded"); + } + return profile; + } + + private void connectAccordingToProfile(PublisherEndpoint publisherEndpoint, RecorderEndpoint recorder, + MediaProfileSpecType profile) { + switch (profile) { + case WEBM: + publisherEndpoint.connect(recorder, MediaType.AUDIO); + publisherEndpoint.connect(recorder, MediaType.VIDEO); + break; + case WEBM_AUDIO_ONLY: + publisherEndpoint.connect(recorder, MediaType.AUDIO); + break; + case WEBM_VIDEO_ONLY: + publisherEndpoint.connect(recorder, MediaType.VIDEO); + break; + default: + throw new UnsupportedOperationException("Unsupported profile when single stream recording: " + profile); + } + } + + 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"); + wrapper.setSize(videoFile.length()); + String metadataFilePath = filesPath + INDIVIDUAL_STREAM_METADATA_FILE + wrapper.getStreamId(); + String metadataFileContent = wrapper.toJson().toString(); + this.fileWriter.createAndWriteFile(metadataFilePath, metadataFileContent); + } + + private Recording sealMetadataFiles(Recording recording) { + // Must update recording "status" (to stopped), "duration" (min startTime of all + // individual recordings) and "size" (sum of all individual recordings size) + + String folderPath = this.openviduConfig.getOpenViduRecordingPath() + recording.getId() + "/"; + + String metadataFilePath = folderPath + RecordingManager.RECORDING_ENTITY_FILE + recording.getId(); + String syncFilePath = folderPath + recording.getId() + ".json"; + + recording = this.recordingManager.getRecordingFromEntityFile(new File(metadataFilePath)); + + long minStartTime = Long.MAX_VALUE; + long maxEndTime = 0; + long accumulatedSize = 0; + + File folder = new File(folderPath); + File[] files = folder.listFiles(); + + Reader reader = null; + Gson gson = new Gson(); + + // Sync metadata json object to store in "RECORDING_ID.json" + JsonObject json = new JsonObject(); + json.addProperty("createdAt", recording.getCreatedAt()); + json.addProperty("id", recording.getId()); + json.addProperty("name", recording.getName()); + json.addProperty("sessionId", recording.getSessionId()); + JsonArray jsonArrayFiles = new JsonArray(); + + for (int i = 0; i < files.length; i++) { + if (files[i].isFile() && files[i].getName().startsWith(INDIVIDUAL_STREAM_METADATA_FILE)) { + try { + reader = new FileReader(files[i].getAbsolutePath()); + } catch (FileNotFoundException e) { + log.error("Error reading file {}. Error: {}", files[i].getAbsolutePath(), e.getMessage()); + } + RecorderEndpointWrapper wr = gson.fromJson(reader, RecorderEndpointWrapper.class); + minStartTime = Math.min(minStartTime, wr.getStartTime()); + maxEndTime = Math.max(maxEndTime, wr.getEndTime()); + accumulatedSize += wr.getSize(); + + JsonObject jsonFile = new JsonObject(); + jsonFile.addProperty("connectionId", wr.getConnectionId()); + jsonFile.addProperty("streamId", wr.getStreamId()); + 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()); + if (wr.hasVideo()) { + jsonFile.addProperty("typeOfVideo", wr.getTypeOfVideo()); + } + jsonFile.addProperty("startTimeOffset", wr.getStartTime() - recording.getCreatedAt()); + jsonFile.addProperty("endTimeOffset", wr.getEndTime() - recording.getCreatedAt()); + + jsonArrayFiles.add(jsonFile); + } + } + + 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"); + + return recording; + } + + private void generateZipFileAndCleanFolder(String folder, String fileName) { + FileOutputStream fos = null; + ZipOutputStream zipOut = null; + + final File[] files = new File(folder).listFiles(); + + try { + fos = new FileOutputStream(folder + fileName); + zipOut = new ZipOutputStream(fos); + + for (int i = 0; i < files.length; i++) { + String fileExtension = FilenameUtils.getExtension(files[i].getName()); + + if (files[i].isFile() && (fileExtension.equals("json") || fileExtension.equals("webm"))) { + + // Zip video files and json sync metadata file + FileInputStream fis = new FileInputStream(files[i]); + ZipEntry zipEntry = new ZipEntry(files[i].getName()); + zipOut.putNextEntry(zipEntry); + byte[] bytes = new byte[1024]; + int length; + while ((length = fis.read(bytes)) >= 0) { + zipOut.write(bytes, 0, length); + } + fis.close(); + + } + if (!files[i].getName().startsWith(RecordingManager.RECORDING_ENTITY_FILE)) { + // Clean inspected file if it is not + files[i].delete(); + } + } + } catch (IOException e) { + log.error("Error generating ZIP file {}. Error: {}", folder + fileName, e.getMessage()); + } finally { + try { + zipOut.close(); + fos.close(); + } catch (IOException e) { + log.error("Error closing FileOutputStream or ZipOutputStream. Error: {}", e.getMessage()); + e.printStackTrace(); + } + + } + } + +} diff --git a/openvidu-server/src/main/java/io/openvidu/server/recording/WaitForContainerStoppedCallback.java b/openvidu-server/src/main/java/io/openvidu/server/recording/service/WaitForContainerStoppedCallback.java similarity index 96% rename from openvidu-server/src/main/java/io/openvidu/server/recording/WaitForContainerStoppedCallback.java rename to openvidu-server/src/main/java/io/openvidu/server/recording/service/WaitForContainerStoppedCallback.java index 05c2ee96..e9a520c0 100644 --- a/openvidu-server/src/main/java/io/openvidu/server/recording/WaitForContainerStoppedCallback.java +++ b/openvidu-server/src/main/java/io/openvidu/server/recording/service/WaitForContainerStoppedCallback.java @@ -15,7 +15,7 @@ * */ -package io.openvidu.server.recording; +package io.openvidu.server.recording.service; import java.io.Closeable; import java.io.IOException; 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 30f49a65..315c1eb9 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 @@ -19,7 +19,6 @@ package io.openvidu.server.rest; import java.util.Collection; import java.util.Map; -import java.util.NoSuchElementException; import java.util.concurrent.ConcurrentHashMap; import org.springframework.beans.factory.annotation.Autowired; @@ -52,8 +51,8 @@ import io.openvidu.server.core.ParticipantRole; import io.openvidu.server.core.Session; import io.openvidu.server.core.SessionManager; import io.openvidu.server.kurento.core.KurentoTokenOptions; -import io.openvidu.server.recording.ComposedRecordingService; import io.openvidu.server.recording.Recording; +import io.openvidu.server.recording.service.RecordingManager; /** * @@ -68,7 +67,7 @@ public class SessionRestController { private SessionManager sessionManager; @Autowired - private ComposedRecordingService recordingService; + private RecordingManager recordingManager; @Autowired private OpenviduConfig openviduConfig; @@ -148,7 +147,7 @@ public class SessionRestController { Session session = this.sessionManager.getSession(sessionId); if (session != null) { JsonObject response = (webRtcStats == true) ? session.withStatsToJson() : session.toJson(); - response.addProperty("recording", this.recordingService.sessionIsBeingRecorded(sessionId)); + response.addProperty("recording", this.recordingManager.sessionIsBeingRecorded(sessionId)); return new ResponseEntity<>(response.toString(), getResponseHeaders(), HttpStatus.OK); } else { return new ResponseEntity<>(HttpStatus.NOT_FOUND); @@ -163,7 +162,7 @@ public class SessionRestController { JsonArray jsonArray = new JsonArray(); sessions.forEach(s -> { JsonObject sessionJson = (webRtcStats == true) ? s.withStatsToJson() : s.toJson(); - sessionJson.addProperty("recording", this.recordingService.sessionIsBeingRecorded(s.getSessionId())); + sessionJson.addProperty("recording", this.recordingManager.sessionIsBeingRecorded(s.getSessionId())); jsonArray.add(sessionJson); }); json.addProperty("numberOfElements", sessions.size()); @@ -298,6 +297,7 @@ public class SessionRestController { String sessionId = (String) params.get("session"); String name = (String) params.get("name"); + String outputModeString = (String) params.get("outputMode"); String recordingLayoutString = (String) params.get("recordingLayout"); String customLayout = (String) params.get("customLayout"); @@ -322,28 +322,49 @@ public class SessionRestController { return new ResponseEntity<>(HttpStatus.BAD_REQUEST); } if (!(session.getSessionProperties().mediaMode().equals(MediaMode.ROUTED)) - || this.recordingService.sessionIsBeingRecorded(session.getSessionId())) { + || this.recordingManager.sessionIsBeingRecorded(session.getSessionId())) { // Session is not in ROUTED MediMode or it is already being recorded return new ResponseEntity<>(HttpStatus.CONFLICT); } - RecordingLayout recordingLayout; - if (recordingLayoutString == null || recordingLayoutString.isEmpty()) { - // "recordingLayout" parameter not defined. Use global layout from - // SessionProperties (it is always configured as it has RecordingLayout.BEST_FIT - // as default value) - recordingLayout = session.getSessionProperties().defaultRecordingLayout(); - } else { - recordingLayout = RecordingLayout.valueOf(recordingLayoutString); + io.openvidu.java.client.Recording.OutputMode outputMode; + try { + outputMode = io.openvidu.java.client.Recording.OutputMode.valueOf(outputModeString); + } catch (Exception e) { + outputMode = io.openvidu.java.client.Recording.OutputMode.COMPOSED; + } + RecordingProperties.Builder builder = new RecordingProperties.Builder().name(name).outputMode(outputMode); + + if (outputMode.equals(io.openvidu.java.client.Recording.OutputMode.COMPOSED)) { + RecordingLayout recordingLayout; + if (recordingLayoutString == null || recordingLayoutString.isEmpty()) { + // "recordingLayout" parameter not defined. Use global layout from + // SessionProperties (it is always configured as it has RecordingLayout.BEST_FIT + // as default value) + recordingLayout = session.getSessionProperties().defaultRecordingLayout(); + } else { + recordingLayout = RecordingLayout.valueOf(recordingLayoutString); + } + + builder.recordingLayout(recordingLayout); + + if (RecordingLayout.CUSTOM.equals(recordingLayout)) { + customLayout = (customLayout == null || customLayout.isEmpty()) + ? session.getSessionProperties().defaultCustomLayout() + : customLayout; + builder.customLayout(customLayout); + } + + builder.build(); } - customLayout = (customLayout == null || customLayout.isEmpty()) - ? session.getSessionProperties().defaultCustomLayout() - : customLayout; - - Recording startedRecording = this.recordingService.startRecording(session, new RecordingProperties.Builder() - .name(name).recordingLayout(recordingLayout).customLayout(customLayout).build()); - return new ResponseEntity<>(startedRecording.toJson().toString(), getResponseHeaders(), HttpStatus.OK); + try { + Recording startedRecording = this.recordingManager.startRecording(session, builder.build()); + return new ResponseEntity<>(startedRecording.toJson().toString(), getResponseHeaders(), HttpStatus.OK); + } catch (OpenViduException e) { + return new ResponseEntity<>("Error starting recording: " + e.getMessage(), getResponseHeaders(), + HttpStatus.INTERNAL_SERVER_ERROR); + } } @RequestMapping(value = "/recordings/stop/{recordingId}", method = RequestMethod.POST) @@ -354,24 +375,24 @@ public class SessionRestController { return new ResponseEntity<>(HttpStatus.BAD_REQUEST); } - Recording recording = recordingService.getStartedRecording(recordingId); + Recording recording = recordingManager.getStartedRecording(recordingId); if (recording == null) { - if (recordingService.getStartingRecording(recordingId) != null) { + if (recordingManager.getStartingRecording(recordingId) != null) { // Recording is still starting return new ResponseEntity<>(HttpStatus.NOT_ACCEPTABLE); } // Recording does not exist return new ResponseEntity<>(HttpStatus.NOT_FOUND); } - if (!this.recordingService.sessionIsBeingRecorded(recording.getSessionId())) { + if (!this.recordingManager.sessionIsBeingRecorded(recording.getSessionId())) { // Session is not being recorded return new ResponseEntity<>(HttpStatus.CONFLICT); } Session session = sessionManager.getSession(recording.getSessionId()); - Recording stoppedRecording = this.recordingService.stopRecording(session, recording.getId(), + Recording stoppedRecording = this.recordingManager.stopRecording(session, recording.getId(), "recordingStoppedByServer"); if (session != null) { @@ -386,27 +407,26 @@ public class SessionRestController { @RequestMapping(value = "/recordings/{recordingId}", method = RequestMethod.GET) public ResponseEntity getRecording(@PathVariable("recordingId") String recordingId) { try { - Recording recording = this.recordingService.getAllRecordings().stream() - .filter(rec -> rec.getId().equals(recordingId)).findFirst().get(); - if (Recording.Status.started.equals(recording.getStatus()) - && recordingService.getStartingRecording(recording.getId()) != null) { - recording.setStatus(Recording.Status.starting); + Recording recording = this.recordingManager.getRecording(recordingId); + if (io.openvidu.java.client.Recording.Status.started.equals(recording.getStatus()) + && recordingManager.getStartingRecording(recording.getId()) != null) { + recording.setStatus(io.openvidu.java.client.Recording.Status.starting); } return new ResponseEntity<>(recording.toJson().toString(), getResponseHeaders(), HttpStatus.OK); - } catch (NoSuchElementException e) { + } catch (Exception e) { return new ResponseEntity<>(HttpStatus.NOT_FOUND); } } @RequestMapping(value = "/recordings", method = RequestMethod.GET) public ResponseEntity getAllRecordings() { - Collection recordings = this.recordingService.getAllRecordings(); + Collection recordings = this.recordingManager.getAllRecordings(); JsonObject json = new JsonObject(); JsonArray jsonArray = new JsonArray(); recordings.forEach(rec -> { - if (Recording.Status.started.equals(rec.getStatus()) - && recordingService.getStartingRecording(rec.getId()) != null) { - rec.setStatus(Recording.Status.starting); + if (io.openvidu.java.client.Recording.Status.started.equals(rec.getStatus()) + && recordingManager.getStartingRecording(rec.getId()) != null) { + rec.setStatus(io.openvidu.java.client.Recording.Status.starting); } jsonArray.add(rec.toJson()); }); @@ -417,7 +437,7 @@ public class SessionRestController { @RequestMapping(value = "/recordings/{recordingId}", method = RequestMethod.DELETE) public ResponseEntity deleteRecording(@PathVariable("recordingId") String recordingId) { - return new ResponseEntity<>(this.recordingService.deleteRecordingFromHost(recordingId, false)); + return new ResponseEntity<>(this.recordingManager.deleteRecordingFromHost(recordingId, false)); } private ResponseEntity generateErrorResponse(String errorMessage, String path, HttpStatus status) { diff --git a/openvidu-server/src/main/java/io/openvidu/server/utils/CustomFileWriter.java b/openvidu-server/src/main/java/io/openvidu/server/utils/CustomFileWriter.java new file mode 100644 index 00000000..e3a4f794 --- /dev/null +++ b/openvidu-server/src/main/java/io/openvidu/server/utils/CustomFileWriter.java @@ -0,0 +1,63 @@ +/* + * (C) Copyright 2017-2018 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.utils; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStreamWriter; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class CustomFileWriter { + + private static final Logger log = LoggerFactory.getLogger(CustomFileWriter.class); + + public void createAndWriteFile(String filePath, String text) { + try { + this.writeAndCloseOnOutputStreamWriter(new FileOutputStream(filePath), text); + } catch (IOException e) { + log.error("Couldn't create file {}. Error: ", filePath, e.getMessage()); + } + } + + public void overwriteFile(String filePath, String text) { + try { + this.writeAndCloseOnOutputStreamWriter(new FileOutputStream(filePath, false), text); + } catch (IOException e) { + log.error("Couldn't overwrite file {}. Error: ", filePath, e.getMessage()); + } + } + + public boolean createFolder(String path) { + return new File(path).mkdir(); + } + + private void writeAndCloseOnOutputStreamWriter(FileOutputStream fos, String text) throws IOException { + OutputStreamWriter osw = null; + try { + osw = new OutputStreamWriter(fos); + osw.write(text); + } finally { + osw.close(); + fos.close(); + } + } + +} diff --git a/openvidu-test-e2e/src/test/java/io/openvidu/test/e2e/OpenViduTestAppE2eTest.java b/openvidu-test-e2e/src/test/java/io/openvidu/test/e2e/OpenViduTestAppE2eTest.java index 487a3164..4c101523 100644 --- a/openvidu-test-e2e/src/test/java/io/openvidu/test/e2e/OpenViduTestAppE2eTest.java +++ b/openvidu-test-e2e/src/test/java/io/openvidu/test/e2e/OpenViduTestAppE2eTest.java @@ -1033,7 +1033,7 @@ public class OpenViduTestAppE2eTest { @Test @DisplayName("Remote record") - void remoteRecordTest() throws Exception { + void remoteComposedRecordTest() throws Exception { setupBrowser("chrome"); log.info("Remote record"); @@ -1131,13 +1131,11 @@ public class OpenViduTestAppE2eTest { user.getEventManager().waitUntilEventReaches("recordingStopped", 1); String recordingsPath = "/opt/openvidu/recordings/"; - File file1 = new File(recordingsPath + sessionName + ".mp4"); - File file2 = new File(recordingsPath + ".recording." + sessionName); - File file3 = new File(recordingsPath + sessionName + ".info"); + File file1 = new File(recordingsPath + sessionName + "/" + sessionName + ".mp4"); + File file2 = new File(recordingsPath + sessionName + "/" + ".recording." + sessionName); Assert.assertTrue(file1.exists() || file1.length() > 0); Assert.assertTrue(file2.exists() || file2.length() > 0); - Assert.assertTrue(file3.exists() || file3.length() > 0); Assert.assertTrue( this.recordedFileFine(file1, new OpenVidu(OPENVIDU_URL, OPENVIDU_SECRET).getRecording(sessionName))); @@ -1159,7 +1157,6 @@ public class OpenViduTestAppE2eTest { Assert.assertFalse(file1.exists()); Assert.assertFalse(file2.exists()); - Assert.assertFalse(file3.exists()); user.getDriver().findElement(By.id("close-dialog-btn")).click(); @@ -1167,6 +1164,12 @@ public class OpenViduTestAppE2eTest { } + @Test + @DisplayName("Remote record") + void remoteIndividualRecordTest() throws Exception { + + } + @Test @DisplayName("REST API: Fetch all, fetch one, force disconnect, force unpublish, close session") void restApiFetchForce() throws Exception {