openvidu-server manual recording

pull/30/merge
pabloFuente 2018-03-14 11:21:58 +01:00
parent c63e02bd66
commit acc1139374
15 changed files with 911 additions and 219 deletions

View File

@ -48,7 +48,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.RecordingService;
import io.openvidu.server.recording.ComposedRecordingService;
import io.openvidu.server.rest.NgrokRestController;
import io.openvidu.server.rpc.RpcHandler;
import io.openvidu.server.rpc.RpcNotificationService;
@ -150,6 +150,7 @@ public class OpenViduServer implements JsonRpcConfigurer {
NEW_LINE;
System.out.println(str);
OpenViduServer.publicUrl = ngrok.getNgrokServerUrl().replaceFirst("https://", "wss://");
openviduConf.setFinalUrl(ngrok.getNgrokServerUrl());
} catch (Exception e) {
System.err.println("Ngrok URL was configured, but there was an error connecting to ngrok: "
@ -161,6 +162,7 @@ public class OpenViduServer implements JsonRpcConfigurer {
case "docker":
try {
OpenViduServer.publicUrl = "wss://" + getContainerIp() + ":" + openviduConf.getServerPort();
openviduConf.setFinalUrl("https://" + getContainerIp() + ":" + openviduConf.getServerPort());
} catch (Exception e) {
System.err.println("Docker container IP was configured, but there was an error obtaining IP: "
+ e.getClass().getName() + " " + e.getMessage());
@ -171,6 +173,9 @@ public class OpenViduServer implements JsonRpcConfigurer {
case "local":
break;
case "docker-local":
break;
default:
URL url = new URL(publicUrl);
@ -178,6 +183,9 @@ public class OpenViduServer implements JsonRpcConfigurer {
type = "custom";
OpenViduServer.publicUrl = publicUrl.replaceFirst("https://", "wss://");
OpenViduServer.publicUrl = publicUrl.replaceFirst("http://", "wss://");
openviduConf.setFinalUrl(url.toString());
if (!OpenViduServer.publicUrl.startsWith("wss://")) {
OpenViduServer.publicUrl = "wss://" + OpenViduServer.publicUrl;
}
@ -191,6 +199,7 @@ public class OpenViduServer implements JsonRpcConfigurer {
if (OpenViduServer.publicUrl == null) {
type = "local";
OpenViduServer.publicUrl = "wss://localhost:" + openviduConf.getServerPort();
openviduConf.setFinalUrl("https://localhost:" + openviduConf.getServerPort());
}
if (OpenViduServer.publicUrl.endsWith("/")) {
@ -199,15 +208,22 @@ public class OpenViduServer implements JsonRpcConfigurer {
boolean recordingModuleEnabled = openviduConf.isRecordingModuleEnabled();
if (recordingModuleEnabled) {
RecordingService recordingService = context.getBean(RecordingService.class);
System.out.println("Recording module required: Downloading openvidu/openvidu-recording Docker image (800 MB aprox)");
ComposedRecordingService recordingService = context.getBean(ComposedRecordingService.class);
System.out.println(
"Recording module required: Downloading openvidu/openvidu-recording Docker image (800 MB aprox)");
boolean imageExists = false;
try {
imageExists = recordingService.recordingImageExistsLocally();
} catch (ProcessingException exception) {
log.error("Exception connecting to Docker daemon: you need Docker installed in this machine to enable OpenVidu recorder service");
throw new RuntimeException("Exception connecting to Docker daemon: you need Docker installed in this machine to enable OpenVidu recorder service");
String message = "Exception connecting to Docker daemon: ";
if ("docker-local".equals(openviduConf.getOpenViduPublicUrl())) {
message += "make sure you include flag \"-v /var/run/docker.sock:/var/run/docker.sock\" in \"docker run\" command";
} else {
message += "you need Docker installed in this machine to enable OpenVidu recorder service";
}
log.error(message);
throw new RuntimeException(message);
}
if (imageExists) {

View File

@ -7,7 +7,7 @@ import org.springframework.stereotype.Component;
public class OpenviduConfig {
@Value("${openvidu.publicurl}")
private String openviduPublicUrl; // local, ngrok, docker, [FINAL_URL]
private String openviduPublicUrl; // local, docker-local, ngrok, docker, [FINAL_URL]
@Value("${server.port}")
private String serverPort;
@ -27,6 +27,8 @@ public class OpenviduConfig {
@Value("${openvidu.recording.free-access}")
boolean openviduRecordingFreeAccess;
private String finalUrl;
public String getOpenViduPublicUrl() {
return this.openviduPublicUrl;
}
@ -59,4 +61,16 @@ public class OpenviduConfig {
return this.openviduRecordingFreeAccess;
}
public void setOpenViduRecordingPath(String recordingPath) {
this.openviduRecordingPath = recordingPath;
}
public String getFinalUrl() {
return finalUrl;
}
public void setFinalUrl(String finalUrl) {
this.finalUrl = finalUrl.endsWith("/") ? (finalUrl) : (finalUrl + "/");
}
}

View File

@ -21,6 +21,11 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter {
.authorizeRequests()
.antMatchers(HttpMethod.POST, "/api/sessions").authenticated()
.antMatchers(HttpMethod.POST, "/api/tokens").authenticated()
.antMatchers(HttpMethod.POST, "/api/recordings/start").authenticated()
.antMatchers(HttpMethod.POST, "/api/recordings/stop").authenticated()
.antMatchers(HttpMethod.GET, "/api/recordings").authenticated()
.antMatchers(HttpMethod.GET, "/api/recordings/**").authenticated()
.antMatchers(HttpMethod.DELETE, "/api/recordings/**").authenticated()
.antMatchers("/").authenticated();
if (openviduConf.getOpenViduRecordingFreeAccess()) {

View File

@ -0,0 +1,17 @@
package io.openvidu.server.config;
import org.springframework.boot.context.embedded.ConfigurableEmbeddedServletContainer;
import org.springframework.boot.context.embedded.EmbeddedServletContainerCustomizer;
import org.springframework.boot.context.embedded.MimeMappings;
import org.springframework.stereotype.Component;
@Component
public class ServletCustomizer implements EmbeddedServletContainerCustomizer {
@Override
public void customize(ConfigurableEmbeddedServletContainer container) {
MimeMappings mappings = new MimeMappings(MimeMappings.DEFAULT);
mappings.add("mkv","video/x-matroska");
container.setMimeMappings(mappings);
}
}

View File

@ -24,4 +24,6 @@ public interface Session {
Participant getParticipantByPublicId(String participantPublicId);
int getActivePublishers();
}

View File

@ -1,7 +1,5 @@
package io.openvidu.server.core;
import java.math.BigInteger;
import java.security.SecureRandom;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
@ -10,6 +8,7 @@ import java.util.stream.Collectors;
import javax.annotation.PreDestroy;
import org.apache.commons.lang3.RandomStringUtils;
import org.kurento.jsonrpc.message.Request;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -61,6 +60,15 @@ public abstract class SessionManager {
public void evictParticipant(String participantPrivateId) throws OpenViduException {
}
/**
* Returns a Session given its id
*
* @return Session
*/
public Session getSession(String sessionId) {
return sessions.get(sessionId);
}
/**
* Returns all currently active (opened) sessions.
*
@ -141,7 +149,7 @@ public abstract class SessionManager {
public String newSessionId(SessionProperties sessionProperties) {
String sessionId = OpenViduServer.publicUrl;
sessionId += "/" + new BigInteger(130, new SecureRandom()).toString(32);
sessionId += "/" + RandomStringUtils.randomAlphanumeric(16).toLowerCase();
this.sessionidTokenTokenobj.put(sessionId, new ConcurrentHashMap<>());
this.sessionidParticipantpublicidParticipant.put(sessionId, new ConcurrentHashMap<>());
@ -155,7 +163,7 @@ public abstract class SessionManager {
if (this.sessionidParticipantpublicidParticipant.get(sessionId) != null
&& this.sessionidTokenTokenobj.get(sessionId) != null) {
if (isMetadataFormatCorrect(serverMetadata)) {
String token = new BigInteger(130, new SecureRandom()).toString(32);
String token = RandomStringUtils.randomAlphanumeric(16).toLowerCase();
this.sessionidTokenTokenobj.get(sessionId).put(token, new Token(token, role, serverMetadata));
showTokens();
return token;
@ -226,12 +234,12 @@ public abstract class SessionManager {
public Participant newParticipant(String sessionId, String participantPrivatetId, Token token,
String clientMetadata) {
if (this.sessionidParticipantpublicidParticipant.get(sessionId) != null) {
String participantPublicId = new BigInteger(130, new SecureRandom()).toString(32);
String participantPublicId = RandomStringUtils.randomAlphanumeric(16).toLowerCase();
ConcurrentHashMap<String, Participant> participantpublicidParticipant = this.sessionidParticipantpublicidParticipant
.get(sessionId);
while (participantpublicidParticipant.containsKey(participantPublicId)) {
// Avoid random 'participantpublicid' collisions
participantPublicId = new BigInteger(130, new SecureRandom()).toString(32);
participantPublicId = RandomStringUtils.randomAlphanumeric(16).toLowerCase();
}
Participant p = new Participant(participantPrivatetId, participantPublicId, token, clientMetadata);
this.sessionidParticipantpublicidParticipant.get(sessionId).put(participantPublicId, p);

View File

@ -220,6 +220,7 @@ public class KurentoSession implements Session {
}
}
@Override
public int getActivePublishers() {
return activePublishers.get();
}

View File

@ -18,6 +18,7 @@ import com.google.gson.JsonSyntaxException;
import io.openvidu.client.OpenViduException;
import io.openvidu.client.OpenViduException.Code;
import io.openvidu.client.internal.ProtocolElements;
import io.openvidu.java.client.ArchiveLayout;
import io.openvidu.java.client.ArchiveMode;
import io.openvidu.java.client.MediaMode;
import io.openvidu.java.client.SessionProperties;
@ -26,7 +27,7 @@ import io.openvidu.server.kurento.KurentoClientProvider;
import io.openvidu.server.kurento.KurentoClientSessionInfo;
import io.openvidu.server.kurento.OpenViduKurentoClientSessionInfo;
import io.openvidu.server.kurento.endpoint.SdpType;
import io.openvidu.server.recording.RecordingService;
import io.openvidu.server.recording.ComposedRecordingService;
import io.openvidu.server.rpc.RpcHandler;
import io.openvidu.server.config.OpenviduConfig;
import io.openvidu.server.core.MediaOptions;
@ -44,7 +45,7 @@ public class KurentoSessionManager extends SessionManager {
private KurentoSessionEventsHandler sessionHandler;
@Autowired
private RecordingService recordingService;
private ComposedRecordingService recordingService;
@Autowired
OpenviduConfig openviduConfig;
@ -62,7 +63,7 @@ public class KurentoSessionManager extends SessionManager {
SessionProperties properties = sessionProperties.get(sessionId);
if (properties == null && this.isInsecureParticipant(participant.getParticipantPrivateId())) {
properties = new SessionProperties.Builder().mediaMode(MediaMode.ROUTED)
.archiveMode(ArchiveMode.ALWAYS).build();
.archiveMode(ArchiveMode.ALWAYS).archiveLayout(ArchiveLayout.BEST_FIT).build();
}
createSession(kcSessionInfo, properties);
}
@ -148,12 +149,11 @@ public class KurentoSessionManager extends SessionManager {
log.warn("Session '{}' removed and closed", sessionId);
}
if (
remainingParticipants.size() == 1 &&
openviduConfig.isRecordingModuleEnabled() &&
MediaMode.ROUTED.equals(session.getSessionProperties().mediaMode()) &&
ProtocolElements.RECORDER_PARTICIPANT_ID_PUBLICID.equals(remainingParticipants.iterator().next().getParticipantPublicId())
) {
if (remainingParticipants.size() == 1 && openviduConfig.isRecordingModuleEnabled()
&& MediaMode.ROUTED.equals(session.getSessionProperties().mediaMode())
&& ArchiveMode.ALWAYS.equals(session.getSessionProperties().archiveMode())
&& ProtocolElements.RECORDER_PARTICIPANT_ID_PUBLICID
.equals(remainingParticipants.iterator().next().getParticipantPublicId())) {
log.info("Last participant left. Stopping recording for session {}", sessionId);
evictParticipant(session.getParticipantByPublicId("RECORDER").getParticipantPrivateId());
recordingService.stopRecording(session);

View File

@ -0,0 +1,402 @@
package io.openvidu.server.recording;
import java.io.File;
import java.io.FileReader;
import java.io.IOException;
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.TimeUnit;
import java.util.stream.Collectors;
import javax.ws.rs.ProcessingException;
import org.apache.commons.io.FilenameUtils;
import org.json.simple.JSONObject;
import org.json.simple.parser.JSONParser;
import org.json.simple.parser.ParseException;
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;
import com.github.dockerjava.api.command.CreateContainerResponse;
import com.github.dockerjava.api.command.ExecCreateCmdResponse;
import com.github.dockerjava.api.exception.ConflictException;
import com.github.dockerjava.api.exception.DockerClientException;
import com.github.dockerjava.api.exception.InternalServerErrorException;
import com.github.dockerjava.api.exception.NotFoundException;
import com.github.dockerjava.api.model.Bind;
import com.github.dockerjava.api.model.Volume;
import com.github.dockerjava.core.DefaultDockerClientConfig;
import com.github.dockerjava.core.DockerClientBuilder;
import com.github.dockerjava.core.DockerClientConfig;
import com.github.dockerjava.core.command.ExecStartResultCallback;
import com.github.dockerjava.core.command.PullImageResultCallback;
import io.openvidu.client.OpenViduException;
import io.openvidu.client.OpenViduException.Code;
import io.openvidu.server.CommandExecutor;
import io.openvidu.server.OpenViduServer;
import io.openvidu.server.config.OpenviduConfig;
import io.openvidu.server.core.Session;
@Service
public class ComposedRecordingService {
private Logger log = LoggerFactory.getLogger(ComposedRecordingService.class);
@Autowired
OpenviduConfig openviduConfig;
private Map<String, String> containers = new ConcurrentHashMap<>();
private Map<String, String> sessionsContainers = new ConcurrentHashMap<>();
private Map<String, Recording> startingRecordings = new ConcurrentHashMap<>();
private Map<String, Recording> startedRecordings = new ConcurrentHashMap<>();
private Map<String, Recording> sessionsRecordings = new ConcurrentHashMap<>();
private final String IMAGE_NAME = "openvidu/openvidu-recording";
private final String RECORDING_ENTITY_FILE = ".recording.";
private DockerClient dockerClient;
public ComposedRecordingService() {
DockerClientConfig config = DefaultDockerClientConfig.createDefaultConfigBuilder().build();
this.dockerClient = DockerClientBuilder.getInstance(config).build();
}
public Recording startRecording(Session session) {
List<String> envs = new ArrayList<>();
String shortSessionId = session.getSessionId().substring(session.getSessionId().lastIndexOf('/') + 1,
session.getSessionId().length());
String videoId = this.getFreeRecordingId(session.getSessionId(), shortSessionId);
String secret = openviduConfig.getOpenViduSecret();
Recording recording = new Recording(session.getSessionId(), videoId, videoId);
this.sessionsRecordings.put(session.getSessionId(), recording);
this.startingRecordings.put(recording.getId(), recording);
String uid = null;
try {
uid = System.getenv("MY_UID");
if (uid == null) {
uid = CommandExecutor.execCommand("/bin/sh", "-c", "id -u " + System.getProperty("user.name"));
}
} catch (IOException | InterruptedException e) {
e.printStackTrace();
}
String location = OpenViduServer.publicUrl.replaceFirst("wss://", "");
String layoutUrl = session.getSessionProperties().archiveLayout().name().toLowerCase().replaceAll("_", "-");
envs.add("URL=https://OPENVIDUAPP:" + secret + "@" + location + "/#/layout-" + layoutUrl + "/" + shortSessionId
+ "/" + secret);
envs.add("RESOLUTION=1920x1080");
envs.add("FRAMERATE=30");
envs.add("VIDEO_NAME=" + videoId);
envs.add("VIDEO_FORMAT=mkv");
envs.add("USER_ID=" + uid);
envs.add("RECORDING_JSON=" + recording.toJson().toJSONString());
log.info(recording.toJson().toJSONString());
log.debug("Recorder connecting to url {}",
"https://OPENVIDUAPP:" + secret + "@localhost:8443/#/layout-best-fit/" + shortSessionId + "/" + secret);
String containerId = this.runRecordingContainer(envs, "recording_" + videoId);
this.waitForVideoFileNotEmpty(videoId);
this.sessionsContainers.put(session.getSessionId(), containerId);
recording.setStatus(Recording.Status.started);
this.startedRecordings.put(recording.getId(), recording);
this.startingRecordings.remove(recording.getId());
return recording;
}
public Recording stopRecording(Session session) {
Recording recording = this.sessionsRecordings.remove(session.getSessionId());
String containerId = this.sessionsContainers.remove(session.getSessionId());
this.startedRecordings.remove(recording.getId());
// Gracefully stop ffmpeg process
ExecCreateCmdResponse execCreateCmdResponse = dockerClient.execCreateCmd(containerId).withAttachStdout(true)
.withAttachStderr(true).withCmd("bash", "-c", "echo 'q' > stop").exec();
try {
dockerClient.execStartCmd(execCreateCmdResponse.getId()).exec(new ExecStartResultCallback())
.awaitCompletion();
} catch (InterruptedException e) {
e.printStackTrace();
}
// Wait for the container to be gracefully self-stopped
CountDownLatch latch = new CountDownLatch(1);
WaitForContainerStoppedCallback callback = new WaitForContainerStoppedCallback(latch);
dockerClient.waitContainerCmd(containerId).exec(callback);
boolean stopped = false;
try {
stopped = latch.await(60, TimeUnit.SECONDS);
} catch (InterruptedException e) {
recording.setStatus(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);
failRecordingCompletion(containerId, new OpenViduException(Code.RECORDING_COMPLETION_ERROR_CODE,
"The recording completion process couldn't finish in 60 seconds"));
}
// Remove container
this.removeDockerContainer(containerId);
// Update recording attributes reading from video report file
try {
RecordingInfoUtils infoUtils = new RecordingInfoUtils(
this.openviduConfig.getOpenViduRecordingPath() + recording.getName() + ".info");
if (openviduConfig.getOpenViduRecordingFreeAccess()) {
recording.setStatus(Recording.Status.available);
} else {
recording.setStatus(Recording.Status.stopped);
}
recording.setDuration(infoUtils.getDurationInSeconds());
recording.setSize(infoUtils.getSizeInBytes());
recording.setHasAudio(infoUtils.hasAudio());
recording.setHasVideo(infoUtils.hasVideo());
if (openviduConfig.getOpenViduRecordingFreeAccess()) {
recording.setUrl(this.openviduConfig.getFinalUrl() + "recordings/" + recording.getName() + ".mkv");
}
} catch (IOException | ParseException e) {
throw new OpenViduException(Code.RECORDING_REPORT_ERROR_CODE,
"There was an error generating the metadata report file for the recording");
}
return recording;
}
public boolean recordingImageExistsLocally() {
boolean imageExists = false;
try {
dockerClient.inspectImageCmd(IMAGE_NAME).exec();
imageExists = true;
} catch (NotFoundException nfe) {
imageExists = false;
} catch (ProcessingException e) {
throw e;
}
return imageExists;
}
public void downloadRecordingImage() {
try {
dockerClient.pullImageCmd(IMAGE_NAME).exec(new PullImageResultCallback()).awaitSuccess();
} catch (NotFoundException | InternalServerErrorException e) {
if (imageExistsLocally(IMAGE_NAME)) {
log.info("Docker image '{}' exists locally", IMAGE_NAME);
} else {
throw e;
}
} catch (DockerClientException e) {
log.info("Error on Pulling '{}' image. Probably because the user has stopped the execution", IMAGE_NAME);
throw e;
}
}
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<String> envs, String containerName) {
Volume volume1 = new Volume("/recordings");
CreateContainerCmd cmd = dockerClient.createContainerCmd(IMAGE_NAME).withName(containerName).withEnv(envs)
.withNetworkMode("host").withVolumes(volume1)
.withBinds(new Bind(openviduConfig.getOpenViduRecordingPath(), volume1));
CreateContainerResponse container = null;
try {
container = cmd.exec();
dockerClient.startContainerCmd(container.getId()).exec();
containers.put(container.getId(), containerName);
log.info("Container ID: {}", container.getId());
return container.getId();
} catch (ConflictException e) {
log.error(
"The container name {} is already in use. Probably caused by a session with unique publisher re-publishing a stream",
containerName);
return null;
}
}
private void removeDockerContainer(String containerId) {
dockerClient.removeContainerCmd(containerId).exec();
containers.remove(containerId);
}
private void stopDockerContainer(String containerId) {
dockerClient.stopContainerCmd(containerId).exec();
}
private boolean imageExistsLocally(String imageName) {
boolean imageExists = false;
try {
dockerClient.inspectImageCmd(imageName).exec();
imageExists = true;
} catch (NotFoundException nfe) {
imageExists = false;
}
return imageExists;
}
public Collection<Recording> getAllRecordings() {
return this.getRecordingEntitiesFromHost();
}
public Collection<Recording> getStartingRecordings() {
return this.startingRecordings.values();
}
public Collection<Recording> getStartedRecordings() {
return this.startedRecordings.values();
}
public Collection<Recording> getFinishedRecordings() {
return this.getRecordingEntitiesFromHost().stream()
.filter(recording -> (recording.getStatus().equals(Recording.Status.stopped)
|| recording.getStatus().equals(Recording.Status.available)))
.collect(Collectors.toSet());
}
private Set<String> getRecordingIdsFromHost() {
File folder = new File(this.openviduConfig.getOpenViduRecordingPath());
File[] files = folder.listFiles();
Set<String> 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;
}
private Set<Recording> getRecordingEntitiesFromHost() {
File folder = new File(this.openviduConfig.getOpenViduRecordingPath());
File[] files = folder.listFiles();
Set<Recording> recordingEntities = new HashSet<>();
for (int i = 0; i < files.length; i++) {
Recording recording = this.getRecordingFromFile(files[i]);
if (recording != null) {
if (openviduConfig.getOpenViduRecordingFreeAccess()) {
if (Recording.Status.stopped.equals(recording.getStatus())) {
recording.setStatus(Recording.Status.available);
recording.setUrl(
this.openviduConfig.getFinalUrl() + "recordings/" + recording.getName() + ".mkv");
}
}
recordingEntities.add(recording);
}
}
return recordingEntities;
}
public HttpStatus deleteRecordingFromHost(String recordingId) {
if (this.startedRecordings.containsKey(recordingId) || this.startingRecordings.containsKey(recordingId)) {
// Cannot delete an active recording
return HttpStatus.CONFLICT;
}
File folder = new File(this.openviduConfig.getOpenViduRecordingPath());
File[] files = folder.listFiles();
int numFilesDeleted = 0;
for (int i = 0; i < files.length; i++) {
if (files[i].isFile() && isFileFromRecording(files[i], recordingId)) {
files[i].delete();
numFilesDeleted++;
}
}
HttpStatus status;
if (numFilesDeleted == 3) {
status = HttpStatus.NO_CONTENT;
} else {
status = HttpStatus.NOT_FOUND;
}
return status;
}
private Recording getRecordingFromFile(File file) {
if (file.isFile() && file.getName().startsWith(RECORDING_ENTITY_FILE)) {
JSONParser parser = new JSONParser();
JSONObject json = null;
try {
json = (JSONObject) parser.parse(new FileReader(file));
} catch (IOException | ParseException e) {
return null;
}
return new Recording(json);
}
return null;
}
private boolean isFileFromRecording(File file, String recordingId) {
return (((recordingId + ".info").equals(file.getName())) || ((recordingId + ".mkv").equals(file.getName()))
|| ((".recording." + recordingId).equals(file.getName())));
}
private String getFreeRecordingId(String sessionId, String shortSessionId) {
Set<String> 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 recordingId) {
boolean isPresent = false;
while (!isPresent) {
try {
Thread.sleep(150);
File f = new File(this.openviduConfig.getOpenViduRecordingPath() + recordingId + ".mkv");
isPresent = ((f.isFile()) && (f.length() > 0));
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
private void failRecordingCompletion(String containerId, OpenViduException e) {
this.stopDockerContainer(containerId);
this.removeDockerContainer(containerId);
throw e;
}
}

View File

@ -0,0 +1,145 @@
package io.openvidu.server.recording;
import org.json.simple.JSONObject;
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.free-access' is true
failed; // The recording has failed
}
private Status status;
private String id;
private String name;
private String sessionId;
private long createdAt; // milliseconds (UNIX Epoch time)
private long size = 0; // bytes
private double duration = 0; // seconds
private String url;
private boolean hasAudio = true;
private boolean hasVideo = true;
public Recording(String sessionId, String id, String name) {
this.sessionId = sessionId;
this.createdAt = System.currentTimeMillis();
this.id = id;
this.name = id; // For now the name of the recording file is the same as its id
this.status = Status.started;
}
public Recording(JSONObject json) {
this.id = (String) json.get("id");
this.name = (String) json.get("name");
this.sessionId = (String) json.get("sessionId");
this.createdAt = (long) json.get("createdAt");
this.size = (long) json.get("size");
this.duration = (double) json.get("duration");
this.url = (String) json.get("url");
this.hasAudio = (boolean) json.get("hasAudio");
this.hasVideo = (boolean) json.get("hasVideo");
this.status = Status.valueOf((String) json.get("status"));
}
public Status getStatus() {
return status;
}
public void setStatus(Status status) {
this.status = status;
}
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getSessionId() {
return sessionId;
}
public void setSessionId(String sessionId) {
this.sessionId = sessionId;
}
public long getCreatedAt() {
return createdAt;
}
public void setCreatedAt(long createdAt) {
this.createdAt = createdAt;
}
public long getSize() {
return size;
}
public void setSize(long l) {
this.size = l;
}
public double getDuration() {
return duration;
}
public void setDuration(double duration) {
this.duration = duration;
}
public String getUrl() {
return url;
}
public void setUrl(String url) {
this.url = url;
}
public boolean hasAudio() {
return hasAudio;
}
public void setHasAudio(boolean hasAudio) {
this.hasAudio = hasAudio;
}
public boolean hasVideo() {
return hasVideo;
}
public void setHasVideo(boolean hasVideo) {
this.hasVideo = hasVideo;
}
@SuppressWarnings("unchecked")
public JSONObject toJson() {
JSONObject json = new JSONObject();
json.put("id", this.id);
json.put("name", this.name);
json.put("sessionId", this.sessionId);
json.put("createdAt", this.createdAt);
json.put("size", this.size);
json.put("duration", this.duration);
json.put("url", this.url);
json.put("hasAudio", this.hasAudio);
json.put("hasVideo", this.hasVideo);
json.put("status", this.status.toString());
return json;
}
}

View File

@ -0,0 +1,104 @@
package io.openvidu.server.recording;
import java.io.FileNotFoundException;
import java.io.FileReader;
import java.io.IOException;
import org.json.simple.JSONArray;
import org.json.simple.JSONObject;
import org.json.simple.parser.JSONParser;
import org.json.simple.parser.ParseException;
import io.openvidu.client.OpenViduException;
import io.openvidu.client.OpenViduException.Code;
public class RecordingInfoUtils {
private JSONParser parser;
private JSONObject json;
private JSONObject jsonFormat;
private JSONObject videoStream;
private JSONObject audioStream;
public RecordingInfoUtils(String fullVideoPath)
throws FileNotFoundException, IOException, ParseException, OpenViduException {
this.parser = new JSONParser();
this.json = (JSONObject) parser.parse(new FileReader(fullVideoPath));
if (json.isEmpty()) {
// Recording metadata from ffprobe is empty: video file is corrupted or empty
throw new OpenViduException(Code.RECORDING_FILE_EMPTY_ERROR, "The recording file is empty or corrupted");
}
this.jsonFormat = (JSONObject) json.get("format");
JSONArray streams = (JSONArray) json.get("streams");
for (int i = 0; i < streams.size(); i++) {
JSONObject stream = (JSONObject) streams.get(i);
if ("video".equals(stream.get("codec_type").toString())) {
this.videoStream = stream;
} else if ("audio".equals(stream.get("codec_type").toString())) {
this.audioStream = stream;
}
}
}
public double getDurationInSeconds() {
return Double.parseDouble(jsonFormat.get("duration").toString());
}
public int getSizeInBytes() {
return Integer.parseInt(jsonFormat.get("size").toString());
}
public int getNumberOfStreams() {
return Integer.parseInt(jsonFormat.get("nb_streams").toString());
}
public int getBitRate() {
return (Integer.parseInt(jsonFormat.get("bit_rate").toString()) / 1000);
}
public boolean hasVideo() {
return this.videoStream != null;
}
public boolean hasAudio() {
return this.audioStream != null;
}
public int videoWidth() {
return Integer.parseInt(videoStream.get("width").toString());
}
public int videoHeight() {
return Integer.parseInt(videoStream.get("height").toString());
}
public int getVideoFramerate() {
String frameRate = videoStream.get("r_frame_rate").toString();
String[] frameRateParts = frameRate.split("/");
return Integer.parseInt(frameRateParts[0]) / Integer.parseInt(frameRateParts[1]);
}
public String getVideoCodec() {
return videoStream.get("codec_name").toString();
}
public String getLongVideoCodec() {
return videoStream.get("codec_long_name").toString();
}
public String getAudioCodec() {
return audioStream.get("codec_name").toString();
}
public String getLongAudioCodec() {
return audioStream.get("codec_long_name").toString();
}
}

View File

@ -1,174 +0,0 @@
package io.openvidu.server.recording;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import javax.ws.rs.ProcessingException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.github.dockerjava.api.DockerClient;
import com.github.dockerjava.api.command.CreateContainerCmd;
import com.github.dockerjava.api.command.CreateContainerResponse;
import com.github.dockerjava.api.command.ExecCreateCmdResponse;
import com.github.dockerjava.api.exception.ConflictException;
import com.github.dockerjava.api.exception.DockerClientException;
import com.github.dockerjava.api.exception.InternalServerErrorException;
import com.github.dockerjava.api.exception.NotFoundException;
import com.github.dockerjava.api.model.Bind;
import com.github.dockerjava.api.model.Volume;
import com.github.dockerjava.core.DefaultDockerClientConfig;
import com.github.dockerjava.core.DockerClientBuilder;
import com.github.dockerjava.core.DockerClientConfig;
import com.github.dockerjava.core.command.ExecStartResultCallback;
import com.github.dockerjava.core.command.PullImageResultCallback;
import io.openvidu.server.CommandExecutor;
import io.openvidu.server.OpenViduServer;
import io.openvidu.server.config.OpenviduConfig;
import io.openvidu.server.core.Session;
@Service
public class RecordingService {
private Logger log = LoggerFactory.getLogger(RecordingService.class);
@Autowired
OpenviduConfig openviduConfig;
private static final Map<String, String> createdContainers = new HashMap<>();
private final String IMAGE_NAME = "openvidu/openvidu-recording";
private DockerClient dockerClient;
private Map<String, String> recordingSessions = new HashMap<>();;
public RecordingService() {
DockerClientConfig config = DefaultDockerClientConfig.createDefaultConfigBuilder().build();
this.dockerClient = DockerClientBuilder.getInstance(config).build();
}
public void startRecording(Session session) {
List<String> envs = new ArrayList<>();
String shortSessionId = session.getSessionId().substring(session.getSessionId().lastIndexOf('/') + 1,
session.getSessionId().length());
String secret = openviduConfig.getOpenViduSecret();
String uid = null;
try {
uid = System.getenv("MY_UID");
if (uid==null) {
uid = CommandExecutor.execCommand("/bin/sh", "-c", "id -u " + System.getProperty("user.name"));
}
} catch (IOException | InterruptedException e) {
e.printStackTrace();
}
String location = OpenViduServer.publicUrl.replaceFirst("wss://", "");
envs.add("URL=https://OPENVIDUAPP:" + secret + "@" + location + "/#/layout-best-fit/" + shortSessionId + "/"
+ secret);
envs.add("RESOLUTION=1920x1080");
envs.add("FRAMERATE=30");
envs.add("VIDEO_NAME=" + shortSessionId);
envs.add("VIDEO_FORMAT=mp4");
envs.add("USER_ID=" + uid);
System.out.println(
"https://OPENVIDUAPP:" + secret + "@localhost:8443/#/layout-best-fit/" + shortSessionId + "/" + secret);
String containerId = this.runRecordingContainer(envs, "recording" + shortSessionId);
this.recordingSessions.putIfAbsent(session.getSessionId(), containerId);
}
public void stopRecording(Session session) {
String containerId = this.recordingSessions.remove(session.getSessionId());
ExecCreateCmdResponse execCreateCmdResponse = dockerClient.execCreateCmd(containerId)
.withAttachStdout(true)
.withAttachStderr(true)
.withCmd("bash", "-c", "echo 'q' > stop")
.exec();
try {
dockerClient.execStartCmd(execCreateCmdResponse.getId()).exec(new ExecStartResultCallback()).awaitCompletion();
} catch (InterruptedException e) {
e.printStackTrace();
}
this.stopDockerContainer(containerId);
this.removeDockerContainer(containerId);
}
public boolean recordingImageExistsLocally() {
boolean imageExists = false;
try {
dockerClient.inspectImageCmd(IMAGE_NAME).exec();
imageExists = true;
} catch (NotFoundException nfe) {
imageExists = false;
} catch (ProcessingException e) {
throw e;
}
return imageExists;
}
public void downloadRecordingImage() {
try {
dockerClient.pullImageCmd(IMAGE_NAME).exec(new PullImageResultCallback()).awaitSuccess();
} catch (NotFoundException | InternalServerErrorException e) {
if (imageExistsLocally(IMAGE_NAME)) {
log.info("Docker image '{}' exists locally", IMAGE_NAME);
} else {
throw e;
}
} catch (DockerClientException e) {
log.info("Error on Pulling '{}' image. Probably because the user has stopped the execution",
IMAGE_NAME);
throw e;
}
}
private String runRecordingContainer(List<String> envs, String containerName) {
Volume volume1 = new Volume("/recordings");
CreateContainerCmd cmd = dockerClient.createContainerCmd(IMAGE_NAME).withName(containerName).withEnv(envs)
.withNetworkMode("host").withVolumes(volume1)
.withBinds(new Bind(openviduConfig.getOpenViduRecordingPath(), volume1));
CreateContainerResponse container = null;
try {
container = cmd.exec();
dockerClient.startContainerCmd(container.getId()).exec();
createdContainers.put(container.getId(), containerName);
log.info("Container ID: {}", container.getId());
return container.getId();
} catch (ConflictException e) {
log.error(
"The container name {} is already in use. Probably caused by a session with unique publisher re-publishing a stream",
containerName);
return null;
}
}
private void removeDockerContainer(String containerId) {
dockerClient.removeContainerCmd(containerId).exec();
createdContainers.remove(containerId);
}
private void stopDockerContainer(String containerId) {
dockerClient.stopContainerCmd(containerId).exec();
}
private boolean imageExistsLocally(String imageName) {
boolean imageExists = false;
try {
dockerClient.inspectImageCmd(imageName).exec();
imageExists = true;
} catch (NotFoundException nfe) {
imageExists = false;
}
return imageExists;
}
}

View File

@ -19,6 +19,8 @@ public class RecordingsHttpHandler extends WebMvcConfigurerAdapter {
String recordingsPath = openviduConfig.getOpenViduRecordingPath();
recordingsPath = recordingsPath.endsWith("/") ? recordingsPath : recordingsPath + "/";
openviduConfig.setOpenViduRecordingPath(recordingsPath);
registry.addResourceHandler("/recordings/**").addResourceLocations("file:" + recordingsPath);
}

View File

@ -0,0 +1,43 @@
package io.openvidu.server.recording;
import java.io.Closeable;
import java.io.IOException;
import java.util.concurrent.CountDownLatch;
import com.github.dockerjava.api.async.ResultCallback;
import com.github.dockerjava.api.model.WaitResponse;
public class WaitForContainerStoppedCallback implements ResultCallback<WaitResponse> {
CountDownLatch latch;
public WaitForContainerStoppedCallback(CountDownLatch latch) {
this.latch = latch;
}
@Override
public void close() throws IOException {
// TODO Auto-generated method stub
}
@Override
public void onComplete() {
latch.countDown();
}
@Override
public void onError(Throwable arg0) {
// TODO Auto-generated method stub
}
@Override
public void onStart(Closeable arg0) {
// TODO Auto-generated method stub
}
@Override
public void onNext(WaitResponse arg0) {
// TODO Auto-generated method stub
}
}

View File

@ -18,14 +18,18 @@ package io.openvidu.server.rest;
import static org.kurento.commons.PropertiesManager.getProperty;
import java.util.Collection;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Set;
import org.json.simple.JSONArray;
import org.json.simple.JSONObject;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
@ -37,7 +41,10 @@ import io.openvidu.java.client.ArchiveMode;
import io.openvidu.java.client.MediaMode;
import io.openvidu.java.client.SessionProperties;
import io.openvidu.server.core.ParticipantRole;
import io.openvidu.server.core.Session;
import io.openvidu.server.core.SessionManager;
import io.openvidu.server.recording.Recording;
import io.openvidu.server.recording.ComposedRecordingService;
/**
*
@ -54,6 +61,9 @@ public class SessionRestController {
@Autowired
private SessionManager sessionManager;
@Autowired
private ComposedRecordingService recordingService;
@RequestMapping(value = "/sessions", method = RequestMethod.GET)
public Set<String> getAllSessions() {
return sessionManager.getSessions();
@ -95,7 +105,7 @@ public class SessionRestController {
} catch (IllegalArgumentException e) {
return this.generateErrorResponse("ArchiveMode " + params.get("archiveMode") + " | " + "ArchiveLayout "
+ params.get("archiveLayout") + " | " + "MediaMode " + params.get("mediaMode")
+ " are not defined", "/api/tokens");
+ " are not defined", "/api/tokens", HttpStatus.BAD_REQUEST);
}
}
@ -104,7 +114,7 @@ public class SessionRestController {
String sessionId = sessionManager.newSessionId(sessionProperties);
JSONObject responseJson = new JSONObject();
responseJson.put("id", sessionId);
return new ResponseEntity<JSONObject>(responseJson, HttpStatus.OK);
return new ResponseEntity<>(responseJson, HttpStatus.OK);
}
@SuppressWarnings("unchecked")
@ -123,25 +133,122 @@ public class SessionRestController {
responseJson.put("role", role.toString());
responseJson.put("data", metadata);
responseJson.put("token", token);
return new ResponseEntity<JSONObject>(responseJson, HttpStatus.OK);
return new ResponseEntity<>(responseJson, HttpStatus.OK);
} catch (IllegalArgumentException e) {
return this.generateErrorResponse("Role " + params.get("role") + " is not defined", "/api/tokens");
return this.generateErrorResponse("Role " + params.get("role") + " is not defined", "/api/tokens",
HttpStatus.BAD_REQUEST);
} catch (OpenViduException e) {
return this.generateErrorResponse(
"Metadata [" + params.get("data") + "] unexpected format. Max length allowed is 1000 chars",
"/api/tokens");
"/api/tokens", HttpStatus.BAD_REQUEST);
}
}
@RequestMapping(value = "/recordings/start", method = RequestMethod.POST)
public ResponseEntity<JSONObject> startRecordingSession(@RequestBody Map<?, ?> params) {
String sessionId = (String) params.get("session");
if (sessionId == null) {
// "session" parameter not found
return new ResponseEntity<JSONObject>(HttpStatus.BAD_REQUEST);
}
Session session = sessionManager.getSession(sessionId);
if (session == null) {
// Session does not exist
return new ResponseEntity<JSONObject>(HttpStatus.NOT_FOUND);
}
if (session.getParticipants().isEmpty()) {
// Session has no participants
return new ResponseEntity<JSONObject>(HttpStatus.BAD_REQUEST);
}
if (!(session.getSessionProperties().mediaMode().equals(MediaMode.ROUTED))
|| this.recordingService.sessionIsBeingRecorded(session.getSessionId())) {
// Session is not in ROUTED MediMode or it is already being recorded
return new ResponseEntity<JSONObject>(HttpStatus.CONFLICT);
}
Recording startedRecording = this.recordingService.startRecording(session);
return new ResponseEntity<>(startedRecording.toJson(), HttpStatus.OK);
}
@RequestMapping(value = "/recordings/stop/{recordingId}", method = RequestMethod.POST)
public ResponseEntity<JSONObject> stopRecordingSession(@PathVariable("recordingId") String recordingId) {
if (recordingId == null) {
// "recordingId" parameter not found
return new ResponseEntity<JSONObject>(HttpStatus.BAD_REQUEST);
}
Recording recording = recordingService.getStartedRecording(recordingId);
if (recording == null) {
if (recordingService.getStartingRecording(recordingId) != null) {
// Recording is still starting
return new ResponseEntity<JSONObject>(HttpStatus.NOT_ACCEPTABLE);
}
// Recording does not exist
return new ResponseEntity<JSONObject>(HttpStatus.NOT_FOUND);
}
if (!this.recordingService.sessionIsBeingRecorded(recording.getSessionId())) {
// Session is not being recorded
return new ResponseEntity<JSONObject>(HttpStatus.CONFLICT);
}
Recording stoppedRecording = this.recordingService
.stopRecording(sessionManager.getSession(recording.getSessionId()));
return new ResponseEntity<>(stoppedRecording.toJson(), HttpStatus.OK);
}
@RequestMapping(value = "/recordings/{recordingId}", method = RequestMethod.GET)
public ResponseEntity<JSONObject> 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);
}
return new ResponseEntity<>(recording.toJson(), HttpStatus.OK);
} catch (NoSuchElementException e) {
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
}
}
@SuppressWarnings("unchecked")
private ResponseEntity<JSONObject> generateErrorResponse(String errorMessage, String path) {
@RequestMapping(value = "/recordings", method = RequestMethod.GET)
public ResponseEntity<JSONObject> getAllRecordings() {
Collection<Recording> recordings = this.recordingService.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);
}
jsonArray.add(rec.toJson());
});
json.put("count", recordings.size());
json.put("items", jsonArray);
return new ResponseEntity<>(json, HttpStatus.OK);
}
@RequestMapping(value = "/recordings/{recordingId}", method = RequestMethod.DELETE)
public ResponseEntity<JSONObject> deleteRecording(@PathVariable("recordingId") String recordingId) {
return new ResponseEntity<>(this.recordingService.deleteRecordingFromHost(recordingId));
}
@SuppressWarnings("unchecked")
private ResponseEntity<JSONObject> generateErrorResponse(String errorMessage, String path, HttpStatus status) {
JSONObject responseJson = new JSONObject();
responseJson.put("timestamp", System.currentTimeMillis());
responseJson.put("status", HttpStatus.BAD_REQUEST.value());
responseJson.put("error", HttpStatus.BAD_REQUEST.getReasonPhrase());
responseJson.put("status", status.value());
responseJson.put("error", status.getReasonPhrase());
responseJson.put("message", errorMessage);
responseJson.put("path", path);
return new ResponseEntity<JSONObject>(responseJson, HttpStatus.BAD_REQUEST);
return new ResponseEntity<JSONObject>(responseJson, status);
}
}