diff --git a/bbb-common-message/src/main/java/org/bigbluebutton/common2/redis/RedisStorageService.java b/bbb-common-message/src/main/java/org/bigbluebutton/common2/redis/RedisStorageService.java
index 554938e3b0bb841aba44f92270ea4e38c959b0ad..00506598398feaf6b6fa7892f4b0cbc4d08649e3 100755
--- a/bbb-common-message/src/main/java/org/bigbluebutton/common2/redis/RedisStorageService.java
+++ b/bbb-common-message/src/main/java/org/bigbluebutton/common2/redis/RedisStorageService.java
@@ -19,8 +19,10 @@
 
 package org.bigbluebutton.common2.redis;
 
+import java.util.HashMap;
 import java.util.Map;
 
+import com.sun.org.apache.xpath.internal.operations.Bool;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -29,6 +31,7 @@ import io.lettuce.core.RedisClient;
 import io.lettuce.core.RedisURI;
 import io.lettuce.core.api.StatefulRedisConnection;
 import io.lettuce.core.api.sync.RedisCommands;
+import org.apache.commons.codec.digest.DigestUtils;
 
 public class RedisStorageService extends RedisAwareCommunicator {
 
@@ -56,6 +59,37 @@ public class RedisStorageService extends RedisAwareCommunicator {
         log.info("RedisStorageService Stopped");
     }
 
+    public String generateSingleUseCaptionToken(String recordId, String caption, Long expirySeconds) {
+        Map<String, String> data = new HashMap<String, String>();
+        data.put("recordId", recordId);
+        data.put("caption", caption);
+
+        String token = DigestUtils.sha1Hex(recordId + caption + System.currentTimeMillis());
+        String key = "captions:" + token + ":singleusetoken";
+        RedisCommands<String, String> commands = connection.sync();
+        commands.multi();
+        commands.hmset(key, data);
+        commands.expire(key, expirySeconds);
+        commands.exec();
+
+        return token;
+    }
+
+    public Boolean validateSingleUseCaptionToken(String token, String recordId, String caption) {
+        String key = "captions:" + token + ":singleusetoken";
+        RedisCommands<String, String> commands = connection.sync();
+        Boolean keyExist = commands.exists(key) == 1;
+        if (keyExist) {
+            Map <String, String> data = commands.hgetall(key);
+            if (data.get("recordId").equals(recordId) && data.get("caption").equals(caption)) {
+                commands.del(key);
+                return true;
+            }
+        }
+
+        return false;
+    }
+
     public void recordMeetingInfo(String meetingId, Map<String, String> info) {
         recordMeeting(Keys.MEETING_INFO + meetingId, info);
     }
diff --git a/bbb-common-web/src/main/java/org/bigbluebutton/api/ApiParams.java b/bbb-common-web/src/main/java/org/bigbluebutton/api/ApiParams.java
index e239f97f905e3837e2f1578d84ec0227808d6687..715757404985418eb1a4a282d0eb789aae1a9fdd 100755
--- a/bbb-common-web/src/main/java/org/bigbluebutton/api/ApiParams.java
+++ b/bbb-common-web/src/main/java/org/bigbluebutton/api/ApiParams.java
@@ -48,6 +48,7 @@ public class ApiParams {
     public static final String PARENT_MEETING_ID = "parentMeetingID";
     public static final String PASSWORD = "password";
     public static final String RECORD = "record";
+    public static final String RECORD_ID = "recordID";
     public static final String REDIRECT = "redirect";
     public static final String SEQUENCE = "sequence";
     public static final String VOICE_BRIDGE = "voiceBridge";
diff --git a/bbb-common-web/src/main/java/org/bigbluebutton/api/MeetingService.java b/bbb-common-web/src/main/java/org/bigbluebutton/api/MeetingService.java
index bdc8b9798dbe48469830e14945614bddc82e8b9d..ac0540fdcaf41c417dc1377b4f227bedc844c6a9 100755
--- a/bbb-common-web/src/main/java/org/bigbluebutton/api/MeetingService.java
+++ b/bbb-common-web/src/main/java/org/bigbluebutton/api/MeetingService.java
@@ -403,21 +403,48 @@ public class MeetingService implements MessageListener {
       return null;
   }
 
+  public Boolean validateTextTrackSingleUseToken(String recordId, String caption, String token) {
+    return recordingService.validateTextTrackSingleUseToken(recordId, caption, token);
+  }
+
   public String getRecordingTextTracks(String recordId) {
     return recordingService.getRecordingTextTracks(recordId);
   }
 
   public String putRecordingTextTrack(String recordId, String kind, String lang, File file, String label,
-          String origFilename, String trackId) {
+          String origFilename, String trackId, String contentType, String tempFilename) {
+
+    Map<String, Object> logData = new HashMap<>();
+    logData.put("recordId", recordId);
+    logData.put("kind", kind);
+    logData.put("lang", lang);
+    logData.put("label", label);
+    logData.put("origFilename", origFilename);
+    logData.put("contentType", contentType);
+    logData.put("tempFilename", tempFilename);
+    logData.put("logCode", "recording_captions_uploaded");
+    logData.put("description", "Captions for recording uploaded.");
+
+    Gson gson = new Gson();
+    String logStr = gson.toJson(logData);
+    log.info(" --analytics-- data={}", logStr);
 
       UploadedTrack track = new UploadedTrack(recordId, kind, lang, label, origFilename, file, trackId,
-              getCaptionTrackInboxDir());
+              getCaptionTrackInboxDir(), contentType, tempFilename);
       return recordingService.putRecordingTextTrack(track);
   }
 
   public String getCaptionTrackInboxDir() {
   	return recordingService.getCaptionTrackInboxDir();
-	}
+  }
+  
+  public String getCaptionsDir() {
+    return recordingService.getCaptionsDir();
+  }
+
+  public boolean isRecordingExist(String recordId) {
+    return recordingService.isRecordingExist(recordId);
+  }
 
   public String getRecordings2x(List<String> idList, List<String> states, Map<String, String> metadataFilters) {
     return recordingService.getRecordings2x(idList, states, metadataFilters);
diff --git a/bbb-common-web/src/main/java/org/bigbluebutton/api/RecordingService.java b/bbb-common-web/src/main/java/org/bigbluebutton/api/RecordingService.java
index 946d34c81b53985da561e04df5b5e56235dfbacd..69c86878aa576dc0535ea1e4401b61d0a3d1a425 100755
--- a/bbb-common-web/src/main/java/org/bigbluebutton/api/RecordingService.java
+++ b/bbb-common-web/src/main/java/org/bigbluebutton/api/RecordingService.java
@@ -54,6 +54,8 @@ public class RecordingService {
     private String recordStatusDir;
     private String captionsDir;
     private String presentationBaseDir;
+    private String defaultServerUrl;
+    private String defaultTextTrackUrl;
 
     private void copyPresentationFile(File presFile, File dlownloadableFile) {
         try {
@@ -168,8 +170,12 @@ public class RecordingService {
         return recs;
     }
 
+    public Boolean validateTextTrackSingleUseToken(String recordId, String caption, String token) {
+        return recordingServiceHelper.validateTextTrackSingleUseToken(recordId, caption, token);
+    }
+
     public String getRecordingTextTracks(String recordId) {
-        return recordingServiceHelper.getRecordingTextTracks(recordId, captionsDir);
+        return recordingServiceHelper.getRecordingTextTracks(recordId, captionsDir, getCaptionFileUrlDirectory());
     }
 
     public String putRecordingTextTrack(UploadedTrack track) {
@@ -242,6 +248,16 @@ public class RecordingService {
         return ids;
     }
 
+    public boolean isRecordingExist(String recordId) {
+        List<String> publishList = getAllRecordingIds(publishedDir);
+        List<String> unpublishList = getAllRecordingIds(unpublishedDir);
+        if (publishList.contains(recordId) || unpublishList.contains(recordId)) {
+            return true;
+        }
+
+        return false;
+    }
+
     public boolean existAnyRecording(List<String> idList) {
         List<String> publishList = getAllRecordingIds(publishedDir);
         List<String> unpublishList = getAllRecordingIds(unpublishedDir);
@@ -374,6 +390,14 @@ public class RecordingService {
         presentationBaseDir = dir;
     }
 
+    public void setDefaultServerUrl(String url) {
+        defaultServerUrl = url;
+    }
+
+    public void setDefaultTextTrackUrl(String url) {
+        defaultTextTrackUrl = url;
+    }
+
     public void setPublishedDir(String dir) {
         publishedDir = dir;
     }
@@ -662,7 +686,16 @@ public class RecordingService {
         return baseDir;
     }
 
-		public String getCaptionTrackInboxDir() {
-			return captionsDir + File.separatorChar + "inbox";
-		}
+    public String getCaptionTrackInboxDir() {
+        return captionsDir + File.separatorChar + "inbox";
+    }
+
+    public String getCaptionsDir() {
+      return captionsDir;
+    }
+
+    public String getCaptionFileUrlDirectory() {
+        return defaultTextTrackUrl + "/textTrack/";
+    }
+
 }
diff --git a/bbb-common-web/src/main/java/org/bigbluebutton/api/util/RecordingMetadataReaderHelper.java b/bbb-common-web/src/main/java/org/bigbluebutton/api/util/RecordingMetadataReaderHelper.java
index 6c7f045e43f3d063af610925f1f4b8c20026bca7..8b52e5f81f4f0050b9a6e5ff41e1bf71d049afe7 100755
--- a/bbb-common-web/src/main/java/org/bigbluebutton/api/util/RecordingMetadataReaderHelper.java
+++ b/bbb-common-web/src/main/java/org/bigbluebutton/api/util/RecordingMetadataReaderHelper.java
@@ -16,8 +16,12 @@ public class RecordingMetadataReaderHelper {
 
   private RecordingServiceGW recordingServiceGW;
 
-  public String getRecordingTextTracks(String recordId, String captionsDir) {
-    return recordingServiceGW.getRecordingTextTracks(recordId, captionsDir);
+  public Boolean validateTextTrackSingleUseToken(String recordId, String caption, String token) {
+    return recordingServiceGW.validateTextTrackSingleUseToken(recordId, caption, token);
+  }
+
+  public String getRecordingTextTracks(String recordId, String captionsDir, String captionsBaseUrl) {
+    return recordingServiceGW.getRecordingTextTracks(recordId, captionsDir, captionsBaseUrl);
   }
 
   public String putRecordingTextTrack(UploadedTrack track) {
diff --git a/bbb-common-web/src/main/java/org/bigbluebutton/api2/RecordingServiceGW.java b/bbb-common-web/src/main/java/org/bigbluebutton/api2/RecordingServiceGW.java
index 987d9f96b59a20cc3d1801e0cddc3cea864b8c05..82c20372f32cad318dddbfb69c9953b801d58c2d 100755
--- a/bbb-common-web/src/main/java/org/bigbluebutton/api2/RecordingServiceGW.java
+++ b/bbb-common-web/src/main/java/org/bigbluebutton/api2/RecordingServiceGW.java
@@ -13,6 +13,7 @@ public interface RecordingServiceGW {
   String getRecordings2x(ArrayList<RecordingMetadata> recs);
   Option<RecordingMetadata> getRecordingMetadata(File xml);
   boolean saveRecordingMetadata(File xml, RecordingMetadata metadata);
-  String getRecordingTextTracks(String recordId, String captionsDir);
+  boolean validateTextTrackSingleUseToken(String recordId, String caption, String token);
+  String getRecordingTextTracks(String recordId, String captionsDir, String captionBasUrl);
   String putRecordingTextTrack(UploadedTrack track);
 }
diff --git a/bbb-common-web/src/main/scala/org/bigbluebutton/api2/BbbWebApiGWApp.scala b/bbb-common-web/src/main/scala/org/bigbluebutton/api2/BbbWebApiGWApp.scala
index 383a329bc6884e3dcf4d470f5d3fa3a41687fc47..1dc38356bc1b2a9d513afe4581d7e2a78b31373a 100755
--- a/bbb-common-web/src/main/scala/org/bigbluebutton/api2/BbbWebApiGWApp.scala
+++ b/bbb-common-web/src/main/scala/org/bigbluebutton/api2/BbbWebApiGWApp.scala
@@ -38,6 +38,19 @@ class BbbWebApiGWApp(
 
   val redisPass = if (redisPassword != "") Some(redisPassword) else None
   val redisConfig = RedisConfig(redisHost, redisPort, redisPass, redisExpireKey)
+
+  var redisStorage = new RedisStorageService()
+  redisStorage.setHost(redisConfig.host)
+  redisStorage.setPort(redisConfig.port)
+  val redisPassStr = redisConfig.password match {
+    case Some(pass) => pass
+    case None       => ""
+  }
+  redisStorage.setPassword(redisPassStr)
+  redisStorage.setExpireKey(redisConfig.expireKey)
+  redisStorage.setClientName("BbbWebRedisStore")
+  redisStorage.start()
+
   private val redisPublisher = new RedisPublisher(system, "BbbWebPub", redisConfig)
 
   private val msgSender: MessageSender = new MessageSender(redisPublisher)
@@ -274,4 +287,13 @@ class BbbWebApiGWApp(
       msgToAkkaAppsEventBus.publish(MsgToAkkaApps(toAkkaAppsChannel, event))
     }
   }
+
+/*** Caption API ***/
+  def generateSingleUseCaptionToken(recordId: String, caption: String, expirySeconds: Long): String = {
+    redisStorage.generateSingleUseCaptionToken(recordId, caption, expirySeconds)
+  }
+
+  def validateSingleUseCaptionToken(token: String, meetingId: String, caption: String): Boolean = {
+    redisStorage.validateSingleUseCaptionToken(token, meetingId, caption)
+  }
 }
diff --git a/bbb-common-web/src/main/scala/org/bigbluebutton/api2/domain/TextTracks.scala b/bbb-common-web/src/main/scala/org/bigbluebutton/api2/domain/TextTracks.scala
index cb337dc63ca0443abdbb99a1ffcb961d31a1878b..874baee5c5ccc73ddab84ae530c740d7a4afb96f 100755
--- a/bbb-common-web/src/main/scala/org/bigbluebutton/api2/domain/TextTracks.scala
+++ b/bbb-common-web/src/main/scala/org/bigbluebutton/api2/domain/TextTracks.scala
@@ -12,21 +12,25 @@ case class UploadedTrack(
     origFilename: String,
     track:        File,
     trackId:      String,
-    inboxDir:     String
+    inboxDir:     String,
+    contentType:  String,
+    tempFilename: String
 )
 case class UploadedTrackInfo(
-    recordId:     String,
-    kind:         String,
-    lang:         String,
-    label:        String,
-    origFilename: String
+    record_id:         String,
+    kind:              String,
+    lang:              String,
+    label:             String,
+    original_filename: String,
+    content_type:      String,
+    temp_filename:     String
 )
 case class Track(
+    href:   String,
     kind:   String,
-    lang:   String,
     label:  String,
-    source: String,
-    href:   String
+    lang:   String,
+    source: String
 )
 case class GetRecTextTracksResult(
     returncode: String,
diff --git a/bbb-common-web/src/main/scala/org/bigbluebutton/api2/util/RecMetaXmlHelper.scala b/bbb-common-web/src/main/scala/org/bigbluebutton/api2/util/RecMetaXmlHelper.scala
index 6b078329a4eb14a269ee086ceb6f2ce8cb897701..b23f4693fdef501061d4b80c4345d6f364f2598f 100755
--- a/bbb-common-web/src/main/scala/org/bigbluebutton/api2/util/RecMetaXmlHelper.scala
+++ b/bbb-common-web/src/main/scala/org/bigbluebutton/api2/util/RecMetaXmlHelper.scala
@@ -4,23 +4,27 @@ import java.io.{ File, FileOutputStream, FileWriter, IOException }
 import java.nio.channels.Channels
 import java.nio.charset.StandardCharsets
 import java.util
+import java.nio.file.{ Files, Paths }
 
 import com.google.gson.Gson
 import org.bigbluebutton.api.domain.RecordingMetadata
-import org.bigbluebutton.api2.RecordingServiceGW
+import org.bigbluebutton.api2.{ BbbWebApiGWApp, RecordingServiceGW }
 import org.bigbluebutton.api2.domain._
 
 import scala.xml.{ Elem, PrettyPrinter, XML }
 import scala.collection.JavaConverters._
 import scala.collection.mutable.{ Buffer, ListBuffer, Map }
 import scala.collection.Iterable
-
 import java.io.IOException
 import java.nio.charset.Charset
 import java.nio.file.Files
 import java.nio.file.Paths
 
-class RecMetaXmlHelper extends RecordingServiceGW with LogHelper {
+import com.google.gson.internal.LinkedTreeMap
+
+import scala.util.Try
+
+class RecMetaXmlHelper(gw: BbbWebApiGWApp) extends RecordingServiceGW with LogHelper {
 
   val SUCCESS = "SUCCESS"
   val FAILED = "FAILED"
@@ -188,19 +192,43 @@ class RecMetaXmlHelper extends RecordingServiceGW with LogHelper {
     }
   }
 
-  def getRecordingTextTracks(recordId: String, captionsDir: String): String = {
+  def validateTextTrackSingleUseToken(recordId: String, caption: String, token: String): Boolean = {
+    gw.validateSingleUseCaptionToken(token, recordId, caption)
+  }
+
+  def getRecordingsCaptionsJson(recordId: String, captionsDir: String, captionBaseUrl: String): String = {
     val gson = new Gson()
     var returnResponse: String = ""
     val captionsFilePath = captionsDir + File.separatorChar + recordId + File.separatorChar + CAPTIONS_FILE
 
     readCaptionJsonFile(captionsFilePath, StandardCharsets.UTF_8) match {
       case Some(captions) =>
-        val ctracks = gson.fromJson(captions, classOf[util.ArrayList[Track]])
-        val result1 = GetRecTextTracksResult(SUCCESS, ctracks)
-        val response1 = GetRecTextTracksResp(result1)
-        val respText1 = gson.toJson(response1)
+        val ctracks = gson.fromJson(captions, classOf[java.util.List[LinkedTreeMap[String, String]]])
+
+        val list = new util.ArrayList[Track]()
+        val it = ctracks.iterator()
 
-        returnResponse = respText1
+        while (it.hasNext()) {
+          val mapTrack = it.next()
+          val caption = mapTrack.get("kind") + "_" + mapTrack.get("lang") + ".vtt"
+          val singleUseToken = gw.generateSingleUseCaptionToken(recordId, caption, 60 * 60)
+
+          list.add(new Track(
+            // captionBaseUrl contains the '/' so no need to put one before singleUseToken
+            href = captionBaseUrl + singleUseToken + '/' + recordId + '/' + caption,
+            kind = mapTrack.get("kind"),
+            label = mapTrack.get("label"),
+            lang = mapTrack.get("lang"),
+            source = mapTrack.get("source")
+          ))
+        }
+        val textTracksResult = GetRecTextTracksResult(SUCCESS, list)
+
+        val textTracksResponse = GetRecTextTracksResp(textTracksResult)
+        val textTracksJson = gson.toJson(textTracksResponse)
+        //  parse(textTracksJson).transformField{case JField(x, v) if x == "value" && v == JString("Company")=> JField("value1",JString("Company1"))}
+
+        returnResponse = textTracksJson
       case None =>
         val resFailed = GetRecTextTracksResultFailed(FAILED, "noCaptionsFound", "No captions found for " + recordId)
         val respFailed = GetRecTextTracksRespFailed(resFailed)
@@ -212,6 +240,21 @@ class RecMetaXmlHelper extends RecordingServiceGW with LogHelper {
     returnResponse
   }
 
+  def getRecordingTextTracks(recordId: String, captionsDir: String, captionBaseUrl: String): String = {
+    val gson = new Gson()
+    var returnResponse: String = ""
+    val recordingPath = captionsDir + File.separatorChar + recordId
+    if (!Files.exists(Paths.get(recordingPath))) {
+      val resFailed = GetRecTextTracksResultFailed(FAILED, "noRecordings", "No recording found for " + recordId)
+      val respFailed = GetRecTextTracksRespFailed(resFailed)
+      returnResponse = gson.toJson(respFailed)
+    } else {
+      returnResponse = getRecordingsCaptionsJson(recordId, captionsDir, captionBaseUrl)
+    }
+
+    returnResponse
+  }
+
   def saveCaptionsFile(captionsDir: String, captionsTracks: String): Boolean = {
     val path = captionsDir + File.separatorChar + CAPTIONS_FILE
     val fileWriter = new FileWriter(path)
@@ -232,18 +275,25 @@ class RecMetaXmlHelper extends RecordingServiceGW with LogHelper {
     }
   }
 
+  def mv(oldName: String, newName: String) =
+    Try(new File(oldName).renameTo(new File(newName))).getOrElse(false)
+
   def saveTrackInfoFile(trackInfoJson: String, trackInfoFilePath: String): Boolean = {
+    // Need to create intermediate file to prevent race where the file is processed before
+    // contents have been written.
+    val tempTrackInfoFilePath = trackInfoFilePath + ".tmp"
+
     var result = false
-    val fileWriter = new FileWriter(trackInfoFilePath)
+    val fileWriter = new FileWriter(tempTrackInfoFilePath)
     try {
       fileWriter.write(trackInfoJson)
       result = true
     } catch {
       case ioe: IOException =>
-        logger.info("Failed to write caption.json {}", trackInfoFilePath)
+        logger.info("Failed to write caption.json {}", tempTrackInfoFilePath)
         result = false
       case ex: Exception =>
-        logger.info("Exception while writing {}", trackInfoFilePath)
+        logger.info("Exception while writing {}", tempTrackInfoFilePath)
         logger.info("Exception details: {}", ex.getMessage)
         result = false
     } finally {
@@ -251,6 +301,11 @@ class RecMetaXmlHelper extends RecordingServiceGW with LogHelper {
       fileWriter.close()
     }
 
+    if (result) {
+      // Rename so that the captions processor will pick up the uploaded captions.
+      result = mv(tempTrackInfoFilePath, trackInfoFilePath)
+    }
+
     result
   }
 
@@ -258,11 +313,13 @@ class RecMetaXmlHelper extends RecordingServiceGW with LogHelper {
     val trackInfoFilePath = track.inboxDir + File.separatorChar + track.trackId + "-track.json"
 
     val trackInfo = new UploadedTrackInfo(
-      recordId = track.recordId,
+      record_id = track.recordId,
       kind = track.kind,
       lang = track.lang,
       label = track.label,
-      origFilename = track.origFilename
+      original_filename = track.origFilename,
+      temp_filename = track.tempFilename,
+      content_type = track.contentType
     )
 
     val gson = new Gson()
diff --git a/bbb-common-web/src/test/scala/org/bigbluebutton/api2/util/RecMetaXmlHelperTests.scala b/bbb-common-web/src/test/scala/org/bigbluebutton/api2/util/RecMetaXmlHelperTests .scala
similarity index 100%
rename from bbb-common-web/src/test/scala/org/bigbluebutton/api2/util/RecMetaXmlHelperTests.scala
rename to bbb-common-web/src/test/scala/org/bigbluebutton/api2/util/RecMetaXmlHelperTests .scala
diff --git a/bigbluebutton-config/bin/bbb-record b/bigbluebutton-config/bin/bbb-record
index 74bf7301202df18dbd6c237648783e86e19b7d7f..b2c2cbd338e7c58a0247df07f79f4469f60d30e8 100755
--- a/bigbluebutton-config/bin/bbb-record
+++ b/bigbluebutton-config/bin/bbb-record
@@ -35,6 +35,7 @@
 #   2016-07-02 FFD  Updates for 1.1-beta
 #   2016-10-17 GTR  Stricter rule for detection of recording directories names
 #   2017-04-28 FFD  Updated references to systemd processing units
+#   2019-05-13 GTR  Delete caption files
 
 #set -e
 #set -x
@@ -372,6 +373,10 @@ if [ $DELETE ]; then
     rm -rf /var/log/bigbluebutton/$type/*$MEETING_ID*
   done
 
+  rm -rf /var/bigbluebutton/captions/$MEETING_ID*
+  rm -f /var/bigbluebutton/inbox/$MEETING_ID*.json
+  rm -f /var/bigbluebutton/inbox/$MEETING_ID*.txt
+
   rm -rf /var/bigbluebutton/recording/raw/$MEETING_ID*
 
   rm -rf /usr/share/red5/webapps/video/streams/$MEETING_ID
@@ -402,7 +407,9 @@ if [ $DELETEALL ]; then
   done
 
   rm -rf /var/bigbluebutton/recording/raw/*
-  
+
+  rm -f /var/bigbluebutton/captions/inbox/*
+
   find /usr/share/red5/webapps/video/streams -name "*.flv" -exec rm '{}' \;
   find /usr/share/red5/webapps/video-broadcast/streams -name "*.flv" -exec rm '{}' \;
   rm -f /var/bigbluebutton/screenshare/*.flv
@@ -411,6 +418,7 @@ if [ $DELETEALL ]; then
   for meeting in $(ls /var/bigbluebutton | grep "^[0-9a-f]\{40\}-[[:digit:]]\{13\}$"); do
     echo "deleting: $meeting"
     rm -rf /var/bigbluebutton/$meeting
+    rm -rf /var/bigbluebutton/captions/$meeting
   done
 fi
 
diff --git a/bigbluebutton-web/bbb-web.nginx b/bigbluebutton-web/bbb-web.nginx
old mode 100644
new mode 100755
index 6b0e66f12994163171dea8062f7a21d9bd175d2d..b8b331c2994724bc03eadbb52f4ca43152b77046
--- a/bigbluebutton-web/bbb-web.nginx
+++ b/bigbluebutton-web/bbb-web.nginx
@@ -73,4 +73,46 @@
 			proxy_set_header         Content-Length "";
 			proxy_set_header         X-Original-URI $request_uri;
 		}
+
+        location ~ "^/bigbluebutton\/textTrack\/(?<textTrackToken>[a-zA-Z0-9]+)\/(?<recordId>[a-zA-Z0-9_-]+)\/(?<textTrack>.+)$" {
+            # Workaround IE refusal to set cookies in iframe
+            add_header P3P 'CP="No P3P policy available"';
+
+            # Allow 30M uploaded presentation document.
+            client_max_body_size       30m;
+            client_body_buffer_size    128k;
+
+            proxy_connect_timeout      90;
+            proxy_send_timeout         90;
+            proxy_read_timeout         90;
+
+            proxy_buffer_size          4k;
+            proxy_buffers              4 32k;
+            proxy_busy_buffers_size    64k;
+            proxy_temp_file_write_size 64k;
+
+            include    fastcgi_params;
+
+            proxy_request_buffering off;
+
+            # Send a sub-request to allow bbb-web to refuse before loading
+            auth_request /bigbluebutton/textTrack/validateAuthToken;
+
+            default_type text/plain;
+            alias    /var/bigbluebutton/captions/$recordId/$textTrack;
+
+        }
+
+        location = /bigbluebutton/textTrack/validateAuthToken {
+            internal;
+            proxy_pass         http://127.0.0.1:8090;
+            proxy_redirect     default;
+            proxy_set_header   X-Forwarded-For   $proxy_add_x_forwarded_for;
+
+            proxy_set_header        X-textTrack-token $textTrackToken;
+            proxy_set_header        X-textTrack-recordId $recordId;
+            proxy_set_header        X-textTrack-track $textTrack;
+            proxy_set_header        X-Original-URI $request_uri;
+        }
+
 	}
diff --git a/bigbluebutton-web/build.gradle b/bigbluebutton-web/build.gradle
index 03b25c8d1b34ba814e23e7644a74297f8caeb783..5dfb4f6434b5c3c4def4d98ee58b70f040281f97 100755
--- a/bigbluebutton-web/build.gradle
+++ b/bigbluebutton-web/build.gradle
@@ -84,6 +84,9 @@ dependencies {
   compile "org.libreoffice:ridl:5.4.2"
   compile "org.libreoffice:juh:5.4.2"
   compile "org.libreoffice:jurt:5.4.2"
+  // https://mvnrepository.com/artifact/commons-fileupload/commons-fileupload
+  compile group: 'commons-fileupload', name: 'commons-fileupload', version: '1.4'
+
   //--- BigBlueButton Dependencies End
   console "org.grails:grails-console"
   profile "org.grails.profiles:web"
diff --git a/bigbluebutton-web/grails-app/conf/bigbluebutton.properties b/bigbluebutton-web/grails-app/conf/bigbluebutton.properties
index 3b5e9ee7f27128125d617dac56a32035fbecce15..34630aa569ddd5472213598889229df543c9ccdd 100755
--- a/bigbluebutton-web/grails-app/conf/bigbluebutton.properties
+++ b/bigbluebutton-web/grails-app/conf/bigbluebutton.properties
@@ -201,7 +201,8 @@ keepEvents=false
 #----------------------------------------------------
 # This URL is where the BBB client is accessible. When a user sucessfully
 # enters a name and password, she is redirected here to load the client.
-bigbluebutton.web.serverURL=http://ritz-20.blindside-dev.com
+# Do not commit changes to this field.
+bigbluebutton.web.serverURL=https://bigbluebutton.example.com
 
 
 #----------------------------------------------------
@@ -326,3 +327,5 @@ lockSettingsLockOnJoin=true
 lockSettingsLockOnJoinConfigurable=false
 
 allowDuplicateExtUserid=true
+
+defaultTextTrackUrl=${bigbluebutton.web.serverURL}/bigbluebutton
diff --git a/bigbluebutton-web/grails-app/conf/spring/resources.xml b/bigbluebutton-web/grails-app/conf/spring/resources.xml
index 8fa2f6571a724c00141033d9749529bad47727f7..a5320caf48f1e312bff6dcbfdc8ccd7695849c58 100755
--- a/bigbluebutton-web/grails-app/conf/spring/resources.xml
+++ b/bigbluebutton-web/grails-app/conf/spring/resources.xml
@@ -71,6 +71,7 @@ with BigBlueButton; if not, see <http://www.gnu.org/licenses/>.
     </bean>
 
     <bean id="recordingServiceGW" class="org.bigbluebutton.api2.util.RecMetaXmlHelper">
+        <constructor-arg index="0" ref="bbbWebApiGWApp"/>
     </bean>
 
     <bean id="presDownloadService" class="org.bigbluebutton.presentation.PresentationUrlDownloadService"
@@ -89,6 +90,8 @@ with BigBlueButton; if not, see <http://www.gnu.org/licenses/>.
         <property name="captionsDir" value="${captionsDir}"/>
         <property name="recordingServiceHelper" ref="recordingServiceHelper"/>
         <property name="presentationBaseDir" value="${presentationDir}"/>
+        <property name="defaultServerUrl" value="${bigbluebutton.web.serverURL}"/>
+        <property name="defaultTextTrackUrl" value="${defaultTextTrackUrl}"/>
     </bean>
 
     <bean id="configServiceHelper" class="org.bigbluebutton.api.ClientConfigServiceHelperImp"/>
diff --git a/bigbluebutton-web/grails-app/controllers/org/bigbluebutton/web/UrlMappings.groovy b/bigbluebutton-web/grails-app/controllers/org/bigbluebutton/web/UrlMappings.groovy
index 5b871c37f20e227fbed2df35e50db7dc1dab4b49..10d573ce34a7026da877fe57d62a894146f7c503 100755
--- a/bigbluebutton-web/grails-app/controllers/org/bigbluebutton/web/UrlMappings.groovy
+++ b/bigbluebutton-web/grails-app/controllers/org/bigbluebutton/web/UrlMappings.groovy
@@ -83,8 +83,12 @@ class UrlMappings {
       action = [GET: 'guestWaitHandler']
     }
 
+    "/bigbluebutton/textTrack/validateAuthToken"(controller: "recording") {
+      action = [GET: 'checkTextTrackAuthToken']
+    }
+
     "/bigbluebutton/api/getRecordingTextTracks"(controller: "recording") {
-      action = [GET: 'getRecordingTextTracks']
+      action = [GET: 'getRecordingTextTracksHandler', POST: 'getRecordingTextTracksHandler']
     }
 
     "/bigbluebutton/api/putRecordingTextTrack"(controller: "recording") {
diff --git a/bigbluebutton-web/grails-app/controllers/org/bigbluebutton/web/controllers/RecordingController.groovy b/bigbluebutton-web/grails-app/controllers/org/bigbluebutton/web/controllers/RecordingController.groovy
index a3f01d80c608be846a6c0e29711e7cb3a428b256..88ba14f09ad6967a95c14eef332267be00cfb907 100755
--- a/bigbluebutton-web/grails-app/controllers/org/bigbluebutton/web/controllers/RecordingController.groovy
+++ b/bigbluebutton-web/grails-app/controllers/org/bigbluebutton/web/controllers/RecordingController.groovy
@@ -1,41 +1,95 @@
 package org.bigbluebutton.web.controllers
 
-import org.bigbluebutton.api.MeetingService;
-import org.bigbluebutton.api.ParamsProcessorUtil;
-import org.apache.commons.lang.StringUtils;
-import org.bigbluebutton.api.ApiErrors;
+import grails.web.context.ServletContextHolder
+import groovy.json.JsonBuilder
+import org.bigbluebutton.api.MeetingService
+import org.bigbluebutton.api.ParamsProcessorUtil
+import org.bigbluebutton.api.util.ResponseBuilder
+import org.bigbluebutton.api.ApiErrors
+import org.bigbluebutton.api.ApiParams
+import org.apache.commons.lang3.StringUtils
+import org.json.JSONArray
+import org.springframework.web.multipart.commons.CommonsMultipartFile
+import org.apache.commons.lang.LocaleUtils
 
 class RecordingController {
   private static final String CONTROLLER_NAME = 'RecordingController'
+  protected static final String RESP_CODE_SUCCESS = 'SUCCESS'
+  protected static final String RESP_CODE_FAILED = 'FAILED'
+  protected static Boolean REDIRECT_RESPONSE = true
 
-  MeetingService meetingService;
+  MeetingService meetingService
   ParamsProcessorUtil paramsProcessorUtil
+  ResponseBuilder responseBuilder = initResponseBuilder()
 
-  def getRecordingTextTracks = {
+  def initResponseBuilder = {
+    String protocol = this.getClass().getResource("").getProtocol()
+    if (Objects.equals(protocol, "jar")) {
+      // Application running inside a JAR file
+      responseBuilder = new ResponseBuilder(getClass().getClassLoader(), "/WEB-INF/freemarker")
+    } else if (Objects.equals(protocol, "file")) {
+      // Application unzipped and running outside a JAR file
+      String templateLoc = ServletContextHolder.servletContext.getRealPath("/WEB-INF/freemarker")
+      // We should never have a null `templateLoc`
+      responseBuilder = new ResponseBuilder(new File(templateLoc))
+    }
+  }
+
+  def checkTextTrackAuthToken = {
+    try {
+      def textTrackToken = request.getHeader("x-textTrack-token")
+      def textTrackRecordId = request.getHeader("x-textTrack-recordId")
+      def textTrackTrack = request.getHeader("x-textTrack-track")
+
+      def isValid = false
+      if (textTrackToken != null &&
+              textTrackRecordId != null &&
+              textTrackTrack != null) {
+        isValid = meetingService.validateTextTrackSingleUseToken(textTrackRecordId, textTrackTrack, textTrackToken)
+      }
 
+      response.addHeader("Cache-Control", "no-cache")
+      response.contentType = 'plain/text'
+      if (isValid) {
+        response.setStatus(200)
+        response.outputStream << 'authorized'
+      } else {
+        response.setStatus(401)
+        response.outputStream << 'unauthorized'
+      }
+    } catch (IOException e) {
+      log.error("Error while checking text track token.\n" + e.getMessage())
+      response.setStatus(401)
+      response.outputStream << 'unauthorized'
+    }
+  }
+
+  /******************************************************
+   * GET RECORDING TEXT TRACKS API
+   ******************************************************/
+  def getRecordingTextTracksHandler = {
     String API_CALL = "getRecordingTextTracks"
     log.debug CONTROLLER_NAME + "#${API_CALL}"
 
     // BEGIN - backward compatibility
     if (StringUtils.isEmpty(params.checksum)) {
-      respondWithError("paramError", "Missing param checksum.")
+      invalid("checksumError", "You did not pass the checksum security check")
       return
     }
 
     if (StringUtils.isEmpty(params.recordID)) {
-      respondWithError("paramError", "Missing param recordID.");
+      invalid("missingParamRecordID", "You must specify a recordID.")
       return
     }
 
-    String recordId = StringUtils.strip(params.recordID)
-
-    // Do we agree on the checksum? If not, complain.
-    //if (! paramsProcessorUtil.isChecksumSame(API_CALL, params.checksum, request.getQueryString())) {
-    //    respondWithError("checksumError", "You did not pass the checksum security check.")
-    //    return
-    //}
-
-    String result = meetingService.getRecordingTextTracks(recordId)
+    if (!paramsProcessorUtil.isChecksumSame(API_CALL, params.checksum, request.getQueryString())) {
+      invalid("checksumError", "You did not pass the checksum security check")
+      return
+    }
+    // END - backward compatibility
+    
+    String recId = StringUtils.strip(params.recordID)
+    String result = meetingService.getRecordingTextTracks(recId)
 
     response.addHeader("Cache-Control", "no-cache")
     withFormat {
@@ -49,19 +103,21 @@ class RecordingController {
     response.addHeader("Cache-Control", "no-cache")
     withFormat {
       json {
-        render(contentType: "application/json") {
-          response() {
-            returncode = "FAILED"
-            messageKey = errorKey
-            messsage = errorMessage
-          }
+        log.debug "Rendering as json"
+        def builder = new JsonBuilder()
+        builder.response {
+          returncode RESP_CODE_FAILED
+          messageKey errorKey
+          message errorMessage
         }
+        render(contentType: "application/json", text: builder.toPrettyString())
       }
     }
   }
 
   def putRecordingTextTrack = {
-    log.debug CONTROLLER_NAME + "#putRecordingTextTrack"
+    String API_CALL = "putRecordingTextTrack"
+    log.debug CONTROLLER_NAME + "#${API_CALL}"
 
     // BEGIN - backward compatibility
     if (StringUtils.isEmpty(params.checksum)) {
@@ -70,26 +126,57 @@ class RecordingController {
     }
 
     if (StringUtils.isEmpty(params.recordID)) {
-      respondWithError("paramError", "Missing param recordID.");
+      respondWithError("paramError", "Missing param recordID.")
       return
     }
 
     String recordId = StringUtils.strip(params.recordID)
+    log.debug("Captions for recordID: " + recordId)
+
+    if (!paramsProcessorUtil.isChecksumSame(API_CALL, params.checksum, request.getQueryString())) {
+      invalid("checksumError", "You did not pass the checksum security check")
+      return
+    }
+
+    if (!meetingService.isRecordingExist(recordId)) {
+      respondWithError("noRecordings", "No recording was found for " + recordId)
+      return
+    }
 
     if (StringUtils.isEmpty(params.kind)) {
-      respondWithError("paramError", "Missing param kind.");
+      respondWithError("paramError", "Missing param kind.")
       return
     }
 
     String captionsKind = StringUtils.strip(params.kind)
+    log.debug("Captions kind: " + captionsKind)
 
+    def isAllowedKind = captionsKind in ['subtitles', 'captions']
+    if (!isAllowedKind) {
+      respondWithError("invalidKind", "Invalid kind parameter, expected='subtitles|captions' actual=" + captionsKind)
+      return
+    }
+
+    Locale locale
     if (StringUtils.isEmpty(params.lang)) {
-      respondWithError("paramError", "Missing param lang.");
+      respondWithError("paramError", "Missing param lang.")
+      return
+    }
+
+    String paramsLang = StringUtils.strip(params.lang)
+    log.debug("Captions lang: " + paramsLang)
+
+
+    try {
+      locale = LocaleUtils.toLocale(paramsLang)
+      log.debug("Captions locale: " + locale.toLanguageTag())
+    } catch (IllegalArgumentException e) {
+      respondWithError("invalidLang", "Malformed lang param, received=" + paramsLang)
       return
     }
 
-    String captionsLang = StringUtils.strip(params.lang)
-    String captionsLabel = captionsLang
+    String captionsLang = locale.toLanguageTag()
+    String captionsLabel = locale.getDisplayLanguage()
 
     if (!StringUtils.isEmpty(params.label)) {
       captionsLabel = StringUtils.strip(params.label)
@@ -97,15 +184,22 @@ class RecordingController {
 
     def uploadedCaptionsFile = request.getFile('file')
     if (uploadedCaptionsFile && !uploadedCaptionsFile.empty) {
+      CommonsMultipartFile contentType = uploadedCaptionsFile.contentType
+      def fileContentType = null
+      if (contentType != null) {
+        fileContentType = contentType.getContentType()
+      }
+      log.debug("Captions content type: " + fileContentType)
       def origFilename = uploadedCaptionsFile.getOriginalFilename()
       def trackId = recordId + "-" + System.currentTimeMillis()
-      def captionsFilePath = meetingService.getCaptionTrackInboxDir() + File.separatorChar + trackId + "-track.txt"
+      def tempFilename = trackId + "-track.txt"
+      def captionsFilePath = meetingService.getCaptionTrackInboxDir() + File.separatorChar + tempFilename
       def captionsFile = new File(captionsFilePath)
 
       uploadedCaptionsFile.transferTo(captionsFile)
 
       String result = meetingService.putRecordingTextTrack(recordId, captionsKind,
-          captionsLang, captionsFile, captionsLabel, origFilename, trackId)
+          captionsLang, captionsFile, captionsLabel, origFilename, trackId, fileContentType, tempFilename)
 
       response.addHeader("Cache-Control", "no-cache")
       withFormat {
@@ -118,16 +212,78 @@ class RecordingController {
       response.addHeader("Cache-Control", "no-cache")
       withFormat {
         json {
-          render(contentType: "application/json") {
-            response = {
-              returncode = "FAILED"
-              messageKey = "empty_uploaded_text_track"
-              message = "Empty uploaded text track."
-            }
+          def builder = new JsonBuilder()
+          builder.response {
+            returncode RESP_CODE_FAILED
+            messageKey = "empty_uploaded_text_track"
+            message = "Empty uploaded text track."
           }
+          render(contentType: "application/json", text: builder.toPrettyString())
         }
       }
     }
 
   }
+
+  private void invalid(key, msg, redirectResponse = false) {
+    // Note: This xml scheme will be DEPRECATED.
+    log.debug CONTROLLER_NAME + "#invalid " + msg
+    if (redirectResponse) {
+      ArrayList<Object> errors = new ArrayList<Object>()
+      Map<String, String> errorMap = new LinkedHashMap<String, String>()
+      errorMap.put("key", key)
+      errorMap.put("message", msg)
+      errors.add(errorMap)
+
+      JSONArray errorsJSONArray = new JSONArray(errors)
+      log.debug "JSON Errors {}", errorsJSONArray.toString()
+
+      respondWithRedirect(errorsJSONArray)
+    } else {
+      response.addHeader("Cache-Control", "no-cache")
+      withFormat {
+        xml {
+          render(text: responseBuilder.buildError(key, msg, RESP_CODE_FAILED), contentType: "text/xml")
+        }
+        json {
+          log.debug "Rendering as json"
+          def builder = new JsonBuilder()
+          builder.response {
+            returncode RESP_CODE_FAILED
+            messageKey key
+            message msg
+          }
+          render(contentType: "application/json", text: builder.toPrettyString())
+        }
+      }
+    }
+  }
+
+  private void respondWithRedirect(errorsJSONArray) {
+    String logoutUrl = paramsProcessorUtil.getDefaultLogoutUrl()
+    URI oldUri = URI.create(logoutUrl)
+
+    if (!StringUtils.isEmpty(params.logoutURL)) {
+      try {
+        oldUri = URI.create(params.logoutURL)
+      } catch (Exception e) {
+        // Do nothing, the variable oldUri was already initialized
+      }
+    }
+
+    String newQuery = oldUri.getQuery()
+
+    if (newQuery == null) {
+      newQuery = "errors="
+    } else {
+      newQuery += "&" + "errors="
+    }
+    newQuery += errorsJSONArray
+
+    URI newUri = new URI(oldUri.getScheme(), oldUri.getAuthority(), oldUri.getPath(), newQuery, oldUri.getFragment())
+
+    log.debug "Constructed logout URL {}", newUri.toString()
+    redirect(url: newUri)
+  }
+
 }
\ No newline at end of file
diff --git a/record-and-playback/.rubocop.yml b/record-and-playback/.rubocop.yml
new file mode 100644
index 0000000000000000000000000000000000000000..f244a1aeb3b76fdb0dcd6e70ca8ce30960415c4e
--- /dev/null
+++ b/record-and-playback/.rubocop.yml
@@ -0,0 +1,12 @@
+AllCops:
+  TargetRubyVersion: 2.3 # System ruby on Ubuntu 16.04
+Layout/AlignHash:
+  EnforcedHashRocketStyle: [ key, table ]
+Metrics:
+  Enabled: false
+Style/AsciiComments:
+  AllowedChars: [ © ]
+Style/TrailingCommaInArrayLiteral:
+  EnforcedStyleForMultiline: consistent_comma
+Style/TrailingCommaInHashLiteral:
+  EnforcedStyleForMultiline: consistent_comma
diff --git a/record-and-playback/core/Gemfile b/record-and-playback/core/Gemfile
index 035c7f8924d165767e6b73c80d2101d813740824..375fc602601ec1320e211cd15e94792a75b502d3 100644
--- a/record-and-playback/core/Gemfile
+++ b/record-and-playback/core/Gemfile
@@ -17,17 +17,24 @@
 #
 
 
-source "http://rubygems.org"
+source "https://rubygems.org"
 
-gem "redis"
-gem "nokogiri"
-gem "loofah"
-gem "rubyzip"
+gem "absolute_time"
 gem "builder"
-gem "trollop", "2.1.3"
-gem "open4"
 gem "fastimage"
-gem "absolute_time"
-gem "jwt"
-gem "java_properties"
 gem "fnv"
+gem "java_properties"
+gem "journald-logger"
+gem "jwt"
+gem "locale"
+gem "loofah"
+gem "nokogiri"
+gem "open4"
+gem "rb-inotify"
+gem "redis"
+gem "rubyzip"
+gem "trollop", "2.1.3"
+
+group :test, optional: true do
+  gem "rubocop", "~> 0.71.0"
+end
diff --git a/record-and-playback/core/Gemfile.lock b/record-and-playback/core/Gemfile.lock
index c056c89ec66729236cb3d294ff971f6524470a6a..42f6e5f60a974efa5138a328bed36da2d8ab6548 100644
--- a/record-and-playback/core/Gemfile.lock
+++ b/record-and-playback/core/Gemfile.lock
@@ -1,23 +1,45 @@
 GEM
-  remote: http://rubygems.org/
+  remote: https://rubygems.org/
   specs:
     absolute_time (1.0.0)
+    ast (2.4.0)
     builder (3.2.3)
     crass (1.0.4)
     fastimage (2.1.5)
+    ffi (1.11.1)
     fnv (0.2.0)
+    jaro_winkler (1.5.2)
     java_properties (0.0.4)
-    jwt (2.1.0)
+    journald-logger (2.0.4)
+      journald-native (~> 1.0)
+    journald-native (1.0.11)
+    jwt (2.2.1)
+    locale (2.1.2)
     loofah (2.2.3)
       crass (~> 1.0.2)
       nokogiri (>= 1.5.9)
     mini_portile2 (2.4.0)
-    nokogiri (1.10.1)
+    nokogiri (1.10.3)
       mini_portile2 (~> 2.4.0)
     open4 (1.3.4)
-    redis (4.1.0)
-    rubyzip (1.2.2)
+    parallel (1.17.0)
+    parser (2.6.3.0)
+      ast (~> 2.4.0)
+    rainbow (3.0.0)
+    rb-inotify (0.10.0)
+      ffi (~> 1.0)
+    redis (4.1.2)
+    rubocop (0.71.0)
+      jaro_winkler (~> 1.5.1)
+      parallel (~> 1.10)
+      parser (>= 2.6)
+      rainbow (>= 2.2.2, < 4.0)
+      ruby-progressbar (~> 1.7)
+      unicode-display_width (>= 1.4.0, < 1.7)
+    ruby-progressbar (1.10.1)
+    rubyzip (1.2.3)
     trollop (2.1.3)
+    unicode-display_width (1.6.0)
 
 PLATFORMS
   ruby
@@ -28,11 +50,15 @@ DEPENDENCIES
   fastimage
   fnv
   java_properties
+  journald-logger
   jwt
+  locale
   loofah
   nokogiri
   open4
+  rb-inotify
   redis
+  rubocop (~> 0.71.0)
   rubyzip
   trollop (= 2.1.3)
 
diff --git a/record-and-playback/core/lib/recordandplayback/events_archiver.rb b/record-and-playback/core/lib/recordandplayback/events_archiver.rb
index 708b24d9ba292ca86bd9a0475f4e2ea19ff99d88..27a19041f12080a7abaeb3bbb8d37f6d05d8cf8e 100755
--- a/record-and-playback/core/lib/recordandplayback/events_archiver.rb
+++ b/record-and-playback/core/lib/recordandplayback/events_archiver.rb
@@ -28,7 +28,7 @@ require 'yaml'
 require 'fileutils'
 
 module BigBlueButton  
-  $bbb_props = YAML::load(File.open('../../core/scripts/bigbluebutton.yml'))
+  $bbb_props = YAML::load(File.open(File.expand_path('../../../scripts/bigbluebutton.yml', __FILE__)))
   $recording_dir = $bbb_props['recording_dir']
   $raw_recording_dir = "#{$recording_dir}/raw"
 
diff --git a/record-and-playback/core/lib/recordandplayback/generators/captions.rb b/record-and-playback/core/lib/recordandplayback/generators/captions.rb
new file mode 100644
index 0000000000000000000000000000000000000000..892764f76bf32faeef6a24fd92e4d33893fb0ddd
--- /dev/null
+++ b/record-and-playback/core/lib/recordandplayback/generators/captions.rb
@@ -0,0 +1,49 @@
+# Copyright © 2019 BigBlueButton Inc. and by respective authors.
+#
+# This file is part of BigBlueButton open source conferencing system.
+#
+# BigBlueButton is free software: you can redistribute it and/or modify it
+# under the terms of the GNU Lesser General Public License as published by the
+# Free Software Foundation, either version 3 of the License, or (at your
+# option) any later version.
+#
+# BigBlueButton is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for more
+# details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with BigBlueButton.  If not, see <http://www.gnu.org/licenses/>.
+
+require File.expand_path('../../edl', __FILE__)
+
+module BigBlueButton
+
+  # Convert a caption file in some format to WebVTT.
+  #
+  # content_type is optional - if provided it should be the mime type of
+  # the caption file format. Automatic probing will be performed if it is
+  # null.
+  #
+  # Returns true on success, and false on failure. If conversion fails, the
+  # out_filename file will be deleted.
+  def self.convert_caption_webvtt(in_filename, content_type, out_filename)
+    ffmpeg_cmd = [*FFMPEG]
+    # Input. For now ignore content type and use only automatic probing.
+    ffmpeg_cmd += ["-i", in_filename]
+    # Select only the first subtitle track. This makes ffmpeg error out if
+    # it doesn't find a subtitle track.
+    ffmpeg_cmd += ["-map", "0:s"]
+    # Output.
+    ffmpeg_cmd += ["-f", "webvtt", out_filename]
+    Dir.chdir(File.dirname(out_filename)) do
+      exitstatus = exec_ret(*ffmpeg_cmd)
+      return true if exitstatus == 0
+    end
+
+    # FFmpeg creates the output file even if conversion fails. Clean it up.
+    FileUtils.rm_f(out_filename)
+    return false
+  end
+
+end
diff --git a/record-and-playback/core/scripts/bigbluebutton.yml b/record-and-playback/core/scripts/bigbluebutton.yml
index f121f70e8049156c08fe1f401c7d9d9f8191c6ca..c5dd4006a34f92f76d7feb35199b9b25652e959d 100755
--- a/record-and-playback/core/scripts/bigbluebutton.yml
+++ b/record-and-playback/core/scripts/bigbluebutton.yml
@@ -25,6 +25,7 @@ log_dir: /var/log/bigbluebutton
 events_dir: /var/bigbluebutton/events
 recording_dir: /var/bigbluebutton/recording
 published_dir: /var/bigbluebutton/published
+captions_dir: /var/bigbluebutton/captions
 playback_host: 127.0.0.1
 playback_protocol: http
 
diff --git a/record-and-playback/core/scripts/rap-caption-inbox.rb b/record-and-playback/core/scripts/rap-caption-inbox.rb
new file mode 100755
index 0000000000000000000000000000000000000000..58a7a7ba5137e43ef1d60c374feedb45a867ea40
--- /dev/null
+++ b/record-and-playback/core/scripts/rap-caption-inbox.rb
@@ -0,0 +1,166 @@
+#!/usr/bin/ruby
+# frozen_string_literal: true
+
+# Copyright © 2019 BigBlueButton Inc. and by respective authors.
+#
+# This file is part of the BigBlueButton open source conferencing system.
+#
+# BigBlueButton is free software: you can redistribute it and/or modify it
+# under the terms of the GNU Lesser General Public License as published by the
+# Free Software Foundation, either version 3 of the License, or (at your
+# option) any later version.
+#
+# BigBlueButton is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for more
+# details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with BigBlueButton.  If not, see <https://www.gnu.org/licenses/>.
+
+require 'rubygems'
+require 'bundler/setup'
+
+require File.expand_path('../lib/recordandplayback', __dir__)
+
+require 'journald/logger'
+require 'locale'
+require 'rb-inotify'
+require 'yaml'
+
+# Read configuration and set up logger
+
+props = File.open(File.expand_path('bigbluebutton.yml', __dir__)) do |bbb_yml|
+  YAML.safe_load(bbb_yml)
+end
+
+logger = Journald::Logger.new('bbb-rap-caption-inbox')
+BigBlueButton.logger = logger
+
+captions_dir = props['captions_dir']
+unless captions_dir
+  logger.error('captions_dir was not defined in bigbluebutton.yml')
+  exit(1)
+end
+captions_inbox_dir = File.join(captions_dir, 'inbox')
+
+# Internal error classes
+
+# Base class for internal errors
+class CaptionError < StandardError
+end
+
+# Indicates that uploaded caption files are invalid (unrecoverable)
+class InvalidCaptionError < CaptionError
+end
+
+# Implementation
+
+caption_file_notify = proc do |json_filename|
+  # There's a possible race condition where we can be notified twice for a new
+  # file. That's fine, just do nothing the second time.
+  return unless File.exist?(json_filename)
+
+  logger.info("Found new caption index file #{json_filename}")
+
+  # TODO: Rather than do anything directly in this script, it should create a
+  # queue job (resque?) that does the actual work.
+
+  captions_work_base = File.join(props['recording_dir'], 'caption', 'inbox')
+  new_caption_info = File.open(json_filename) { |file| JSON.parse(file.read) }
+  record_id = new_caption_info['record_id']
+  logger.tag(record_id: record_id) do
+    begin
+      # Read the existing captions index file
+      # TODO: This is racy if multiple tools are editing the captions.json file
+      index_filename = File.join(captions_dir, record_id, 'captions.json')
+      captions_info =
+        begin
+          File.open(index_filename) { |file| JSON.parse(file.read) }
+        rescue StandardError
+          # No captions file or cannot be read, assume none present
+          []
+        end
+
+      temp_filename = new_caption_info['temp_filename']
+      raise InvalidCaptionError, 'Temp filename is blank' if temp_filename.nil? || temp_filename.empty?
+
+      src_filename = File.join(captions_inbox_dir, temp_filename)
+
+      langtag = Locale::Tag::Rfc.parse(new_caption_info['lang'])
+      raise InvalidCaptionError, 'Language tag is not well-formed' unless langtag
+
+      # Remove the info for an existing matching track, and add the new one
+      captions_info.delete_if do |caption_info|
+        caption_info['lang'] == new_caption_info['lang'] &&
+          caption_info['kind'] == new_caption_info['kind']
+      end
+      captions_info << {
+        'kind'   => new_caption_info['kind'],
+        'label'  => new_caption_info['label'],
+        'lang'   => langtag.to_s,
+        'source' => 'upload',
+      }
+
+      captions_work = File.join(captions_work_base, record_id)
+      FileUtils.mkdir_p(captions_work)
+      dest_filename = "#{new_caption_info['kind']}_#{new_caption_info['lang']}.vtt"
+      tmp_dest = File.join(captions_work, dest_filename)
+      final_dest_dir = File.join(captions_dir, record_id)
+      final_dest = File.join(final_dest_dir, dest_filename)
+
+      # Convert the received caption file to WebVTT
+      ffmpeg_cmd = [
+        'ffmpeg', '-y', '-v', 'warning', '-nostats', '-nostdin',
+        '-i', src_filename, '-map', '0:s',
+        '-f', 'webvtt', tmp_dest,
+      ]
+      ret = BigBlueButton.exec_ret(*ffmpeg_cmd)
+      raise InvalidCaptionError, 'FFmpeg could not read input' unless ret.zero?
+
+      FileUtils.mkdir_p(final_dest_dir)
+      FileUtils.mv(tmp_dest, final_dest)
+
+      # Finally, save the updated index file that references the new caption
+      File.open(index_filename, 'w') do |file|
+        file.write(JSON.pretty_generate(captions_info))
+      end
+
+      Dir.glob(File.expand_path('captions/*', __dir__)) do |caption_script|
+        next unless File.file?(caption_script) && File.executable?(caption_script)
+
+        logger.info("Running caption integration script #{caption_script}")
+        ret = BigBlueButton.exec_ret(caption_script, '--record-id', record_id)
+        logger.warn('Caption integration script failed') unless ret.zero?
+      end
+
+      logger.info('Removing files from inbox directory')
+      FileUtils.rm_f(src_filename) if src_filename
+      FileUtils.rm_f(json_filename)
+    rescue InvalidCaptionError => e
+      logger.exception(e)
+
+      logger.info('Deleting invalid files from inbox directory')
+      FileUtils.rm_f(src_filename) if src_filename
+      FileUtils.rm_f(json_filename)
+    ensure
+      FileUtils.rm_rf(File.join(captions_work_base, record_id))
+    end
+  end
+end
+
+logger.info("Setting up inotify watch on #{captions_inbox_dir}")
+notifier = INotify::Notifier.new
+notifier.watch(captions_inbox_dir, :moved_to, :create) do |event|
+  next unless event.name.end_with?('-track.json')
+
+  caption_file_notify.call(event.absolute_name)
+end
+
+logger.info('Checking for missed/skipped caption files')
+Dir.glob(File.join(captions_inbox_dir, '*-track.json')).each do |filename|
+  caption_file_notify.call(filename)
+end
+
+logger.info('Waiting for new caption files...')
+notifier.run
diff --git a/record-and-playback/core/scripts/rap-process-worker.rb b/record-and-playback/core/scripts/rap-process-worker.rb
index 24fd8a7cfe672744daee368598ef6a7a360e9905..4f062b9f8d41e7eef1026bf2daebd24019d01c65 100755
--- a/record-and-playback/core/scripts/rap-process-worker.rb
+++ b/record-and-playback/core/scripts/rap-process-worker.rb
@@ -45,6 +45,12 @@ def process_archived_meetings(recording_dir)
 
     step_succeeded = true
 
+    # Generate captions
+    ret = BigBlueButton.exec_ret('ruby', 'utils/captions.rb', '-m', meeting_id)
+    if ret != 0
+      BigBlueButton.logger.warn("Failed to generate caption files #{ret}")
+    end
+
     # Iterate over the list of recording processing scripts to find available
     # types. For now, we look for the ".rb" extension - TODO other scripting
     # languages?
diff --git a/record-and-playback/core/scripts/utils/captions.rb b/record-and-playback/core/scripts/utils/captions.rb
new file mode 100644
index 0000000000000000000000000000000000000000..5df148f90abbcb0e0bfac28643585c5352eb0c7f
--- /dev/null
+++ b/record-and-playback/core/scripts/utils/captions.rb
@@ -0,0 +1,103 @@
+# Set encoding to utf-8
+# encoding: UTF-8
+
+#
+# BigBlueButton open source conferencing system - http://www.bigbluebutton.org/
+#
+# Copyright (c) 2019 BigBlueButton Inc. and by respective authors (see below).
+#
+# This program is free software; you can redistribute it and/or modify it under the
+# terms of the GNU Lesser General Public License as published by the Free Software
+# Foundation; either version 3.0 of the License, or (at your option) any later
+# version.
+#
+# BigBlueButton is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
+# PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License along
+# with BigBlueButton; if not, see <http://www.gnu.org/licenses/>.
+#
+
+# For DEVELOPMENT
+# Allows us to run the script manually
+# require File.expand_path('../../../../core/lib/recordandplayback', __FILE__)
+
+# For PRODUCTION
+require File.expand_path('../../../lib/recordandplayback', __FILE__)
+
+require 'rubygems'
+require 'trollop'
+require 'yaml'
+require 'json'
+
+opts = Trollop::options do
+  opt :meeting_id, "Meeting id to archive", :type => String
+end
+
+meeting_id = opts[:meeting_id]
+
+# This script lives in scripts/archive/steps while properties.yaml lives in scripts/
+props = YAML::load(File.open('../../core/scripts/bigbluebutton.yml'))
+
+recording_dir = props['recording_dir']
+raw_archive_dir = "#{recording_dir}/raw/#{meeting_id}"
+BigBlueButton.logger.info("Setting process dir")
+BigBlueButton.logger.info("setting captions dir")
+captions_dir = props['captions_dir']
+
+log_dir = props['log_dir']
+
+target_dir = "#{recording_dir}/process/presentation/#{meeting_id}"
+
+# Generate captions.json for API
+def create_api_captions_file(captions_meeting_dir)
+  BigBlueButton.logger.info("Generating closed captions for API")
+
+  captions = JSON.load(File.new("#{captions_meeting_dir}/captions_playback.json"))
+  captions_json = []
+  captions.each do |track|
+    caption = {}
+    caption[:kind] = :captions
+    caption[:label] = track['localeName']
+    caption[:lang] = track['locale']
+    caption[:source] = :live
+    captions_json << caption
+  end
+
+  File.open("#{captions_meeting_dir}/captions.json", "w") do |f|
+    f.write(captions_json.to_json)
+  end
+end
+
+if not FileTest.directory?(target_dir)
+
+  captions_meeting_dir = "#{captions_dir}/#{meeting_id}"
+
+  FileUtils.mkdir_p "#{log_dir}/presentation"
+  logger = Logger.new("#{log_dir}/presentation/process-#{meeting_id}.log", 'daily')
+  BigBlueButton.logger = logger
+  BigBlueButton.logger.info("Processing script captions.rb")
+  FileUtils.mkdir_p target_dir
+
+  begin
+    BigBlueButton.logger.info("Generating closed captions")
+    FileUtils.mkdir_p captions_meeting_dir
+    ret = BigBlueButton.exec_ret('utils/gen_webvtt', '-i', raw_archive_dir, '-o', captions_meeting_dir)
+    if ret != 0
+      raise "Generating closed caption files failed"
+    end
+
+    FileUtils.cp("#{captions_meeting_dir}/captions.json", "#{captions_meeting_dir}/captions_playback.json")
+    create_api_captions_file(captions_meeting_dir)
+    FileUtils.rm "#{captions_meeting_dir}/captions_playback.json"
+
+  rescue Exception => e
+    BigBlueButton.logger.error(e.message)
+    e.backtrace.each do |traceline|
+      BigBlueButton.logger.error(traceline)
+    end
+    exit 1
+  end
+
+end
diff --git a/record-and-playback/core/systemd/bbb-rap-caption-inbox.service b/record-and-playback/core/systemd/bbb-rap-caption-inbox.service
new file mode 100644
index 0000000000000000000000000000000000000000..2796163c81de831c9ad72ae49caa54a55b526a7c
--- /dev/null
+++ b/record-and-playback/core/systemd/bbb-rap-caption-inbox.service
@@ -0,0 +1,9 @@
+[Unit]
+Description=BigBlueButton recording caption upload handler
+
+[Service]
+Type=simple
+ExecStart=/usr/local/bigbluebutton/core/scripts/rap-caption-inbox.rb
+User=bigbluebutton
+Slice=bbb_record_core.slice
+Restart=on-failure
diff --git a/record-and-playback/deploy.sh b/record-and-playback/deploy.sh
index 1e43a5840aa2d3d55616f48200f852989a70e9a0..a29d8c9d1a76b0db2d06036758b7d8f0d0e006e5 100755
--- a/record-and-playback/deploy.sh
+++ b/record-and-playback/deploy.sh
@@ -40,21 +40,65 @@ function deploy_format() {
 
 deploy_format "presentation"
 
-sudo mkdir -p /var/bigbluebutton/events/
-sudo mkdir -p /var/bigbluebutton/playback/
-sudo mkdir -p /var/bigbluebutton/recording/raw/
-sudo mkdir -p /var/bigbluebutton/recording/process/
-sudo mkdir -p /var/bigbluebutton/recording/publish/
-sudo mkdir -p /var/bigbluebutton/recording/status/ended/
-sudo mkdir -p /var/bigbluebutton/recording/status/recorded/
-sudo mkdir -p /var/bigbluebutton/recording/status/archived/
-sudo mkdir -p /var/bigbluebutton/recording/status/processed/
-sudo mkdir -p /var/bigbluebutton/recording/status/sanity/
+CAPTIONS_DIR=/var/bigbluebutton/captions/
+if [ ! -d "$CAPTIONS_DIR" ]; then
+  sudo mkdir -p $CAPTIONS_DIR
+fi
+
+EVENTS_DIR=/var/bigbluebutton/events/
+if [ ! -d "$EVENTS_DIR" ]; then
+  sudo mkdir -p $EVENTS_DIR
+fi
+
+PLAYBACK_DIR=/var/bigbluebutton/playback/
+if [ ! -d "$PLAYBACK_DIR" ]; then
+  sudo mkdir -p $PLAYBACK_DIR
+fi
+
+REC_RAW_DIR=/var/bigbluebutton/recording/raw/
+if [ ! -d "$REC_RAW_DIR" ]; then
+  sudo mkdir -p $REC_RAW_DIR
+fi
+
+REC_PROC_DIR=/var/bigbluebutton/recording/process/
+if [ ! -d "$REC_PROC_DIR" ]; then
+  sudo mkdir -p $REC_PROC_DIR
+fi
+
+REC_PUB_DIR=/var/bigbluebutton/recording/publish/
+if [ ! -d "$REC_PUB_DIR" ]; then
+  sudo mkdir -p $REC_PUB_DIR
+fi
+
+REC_STATUS_ENDED_DIR=/var/bigbluebutton/recording/status/ended/
+if [ ! -d "$REC_STATUS_ENDED_DIR" ]; then
+  sudo mkdir -p $REC_STATUS_ENDED_DIR
+fi
+
+REC_STATUS_RECORDED_DIR=/var/bigbluebutton/recording/status/recorded/
+if [ ! -d "$REC_STATUS_RECORDED_DIR" ]; then
+  sudo mkdir -p $REC_STATUS_RECORDED_DIR
+fi
+
+REC_STATUS_ARCHIVED_DIR=/var/bigbluebutton/recording/status/archived/
+if [ ! -d "$REC_STATUS_ARCHIVED_DIR" ]; then
+  sudo mkdir -p $REC_STATUS_ARCHIVED_DIR
+fi
+
+REC_STATUS_PROCESSED_DIR=/var/bigbluebutton/recording/status/processed/
+if [ ! -d "$REC_STATUS_PROCESSED_DIR" ]; then
+  sudo mkdir -p $REC_STATUS_PROCESSED_DIR
+fi
+
+REC_STATUS_SANITY_DIR=/var/bigbluebutton/recording/status/sanity/
+if [ ! -d "$REC_STATUS_SANITY_DIR" ]; then
+  sudo mkdir -p $REC_STATUS_SANITY_DIR
+fi
 
 sudo mv /usr/local/bigbluebutton/core/scripts/*.nginx /etc/bigbluebutton/nginx/
 sudo service nginx reload
 sudo chown -R bigbluebutton:bigbluebutton /var/bigbluebutton/ /var/log/bigbluebutton/
 sudo chown -R red5:red5 /var/bigbluebutton/screenshare/
 
-cd /usr/local/bigbluebutton/core/
-sudo bundle install
\ No newline at end of file
+#cd /usr/local/bigbluebutton/core/
+#sudo bundle install
\ No newline at end of file
diff --git a/record-and-playback/presentation/scripts/caption/presentation b/record-and-playback/presentation/scripts/caption/presentation
new file mode 100755
index 0000000000000000000000000000000000000000..8f991b760ee26fe18084128f97b6838dba44381d
--- /dev/null
+++ b/record-and-playback/presentation/scripts/caption/presentation
@@ -0,0 +1,140 @@
+#!/usr/bin/ruby
+# frozen_string_literal: true
+
+# Copyright © 2019 BigBlueButton Inc. and by respective authors.
+#
+# This file is part of the BigBlueButton open source conferencing system.
+#
+# BigBlueButton is free software: you can redistribute it and/or modify it
+# under the terms of the GNU Lesser General Public License as published by the
+# Free Software Foundation, either version 3 of the License, or (at your
+# option) any later version.
+#
+# BigBlueButton is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for more
+# details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with BigBlueButton.  If not, see <https://www.gnu.org/licenses/>.
+
+require 'rubygems'
+require 'bundler/setup'
+
+require 'optparse'
+require 'journald/logger'
+
+# Parse command-line options
+
+recording_id = nil
+OptionParser.new do |opts|
+  opts.on('--record-id=REC', 'Recording ID to copy captions into') do |rec|
+    recording_id = rec
+  end
+end.parse!
+
+unless recording_id
+  warn('--record-id was not specified')
+  exit(1)
+end
+
+# Read configuration and set up logger
+
+props = File.open(File.expand_path('../bigbluebutton.yml', __dir__)) do |bbb_yml|
+  YAML.safe_load(bbb_yml)
+end
+presentation_props = File.open(File.expand_path('../presentation.yml', __dir__)) do |pres_yml|
+  YAML.safe_load(pres_yml)
+end
+
+logger = Journald::Logger.new('caption/presentation')
+logger.tag(record_id: recording_id)
+
+captions_dir = props['captions_dir']
+publish_dir = presentation_props['publish_dir']
+
+unless captions_dir
+  logger.error('captions_dir was not defined in bigbluebutton.yml')
+  exit(1)
+end
+
+unless File.directory?(File.join(publish_dir, recording_id))
+  logger.error('Published recording directory for this recording does not exist')
+  exit(0)
+end
+
+# Load the captions index file and recording playback caption list
+
+begin
+  logger.info('Loading recording playback captions list')
+  playback_captions_path = File.join(publish_dir, recording_id, 'captions.json')
+  playback_captions = File.open(playback_captions_path) do |json|
+    JSON.parse(json.read)
+  end
+rescue Errno::ENOENT
+  logger.info("Playback doesn't have a captions.json - old playback format version?")
+  logger.info('Triggering recording reprocessing.')
+  archive_done_file = ''
+  File.open(archive_done_file, 'w') do |archive_done|
+    archive_done.write('Reprocessing for captions')
+  end
+  # Treat this as success, the captions will be integrated during reprocessing
+  exit(0)
+end
+
+# Captions index file
+begin
+  logger.info('Loading captions index file')
+  captions_path = File.join(captions_dir, recording_id, 'captions.json')
+  captions = File.open(captions_path) do |json|
+    JSON.parse(json.read)
+  end
+rescue Errno::ENOENT
+  captions = []
+end
+
+# Copy the new caption files over the existing ones in the playback
+
+captions.each do |caption|
+  kind = caption['kind']
+  lang = caption['lang']
+  label = caption['label']
+
+  # TODO: the presentation playback format needs to be updated to support
+  # kind != captions
+  next unless kind == 'captions'
+
+  logger.info("Copying file for kind=#{kind} lang=#{lang}")
+  dest_kind = case kind
+              when 'captions' then 'caption'
+              else kind
+              end
+
+  FileUtils.cp(
+    File.join(captions_dir, recording_id, "#{kind}_#{lang}.vtt"),
+    File.join(publish_dir, recording_id, "#{dest_kind}_#{lang}.vtt")
+  )
+
+  # Remove any existing matching tracks from the playback captions list...
+  playback_captions.reject! do |playback_caption|
+    playback_caption['locale'] == lang && (
+      playback_caption['kind'] == kind ||
+      (playback_caption['kind'].nil? && kind == 'captions')
+    )
+  end
+  # ...and add the new one.
+  playback_captions << {
+    'kind'       => kind,
+    'locale'     => lang,
+    'localeName' => label,
+  }
+end
+
+# Save the updated playback captions list
+
+logger.info('Saving updated playback captions list')
+# Sort the list by label so the selection menu looks nice
+playback_captions.sort { |a, b| a['localeName'] <=> b['localeName'] }
+File.open(playback_captions_path, 'w') do |json|
+  json.write(JSON.pretty_generate(playback_captions))
+end
diff --git a/record-and-playback/presentation/scripts/process/presentation.rb b/record-and-playback/presentation/scripts/process/presentation.rb
index d7d594eb839720174d67b132c752fec34a297419..4a8e140547fbdb8666a9146210004f5403eabc6b 100755
--- a/record-and-playback/presentation/scripts/process/presentation.rb
+++ b/record-and-playback/presentation/scripts/process/presentation.rb
@@ -47,6 +47,10 @@ recording_dir = props['recording_dir']
 raw_archive_dir = "#{recording_dir}/raw/#{meeting_id}"
 log_dir = props['log_dir']
 
+BigBlueButton.logger.info("setting captions dir")
+captions_dir = props['captions_dir']
+captions_meeting_dir = "#{captions_dir}/#{meeting_id}"
+
 target_dir = "#{recording_dir}/process/presentation/#{meeting_id}"
 if not FileTest.directory?(target_dir)
   FileUtils.mkdir_p "#{log_dir}/presentation"
@@ -198,11 +202,22 @@ if not FileTest.directory?(target_dir)
       FileUtils.cp_r("#{pres_dir}/thumbnails", "#{target_pres_dir}/thumbnails")
     end
 
-    BigBlueButton.logger.info("Generating closed captions")
-    ret = BigBlueButton.exec_ret('utils/gen_webvtt', '-i', raw_archive_dir, '-o', target_dir)
-    if ret != 0
-      raise "Generating closed caption files failed"
+    BigBlueButton.logger.info("Copying closed captions")
+
+    captions = JSON.load(File.new("#{captions_meeting_dir}/captions.json"))
+    captions_json = []
+    captions.each do |track|
+      caption = {}
+      caption[:localeName] = track['label']
+      caption[:locale] = track['lang']
+      captions_json << caption
+      FileUtils.cp("#{captions_meeting_dir}/caption_" + track['lang'] + ".vtt", target_dir)
     end
+
+    File.open("#{target_dir}/captions.json", "w") do |f|
+      f.write(captions_json.to_json)
+    end
+
     captions = JSON.load(File.new("#{target_dir}/captions.json", 'r'))
 
     if not presentation_text.empty?
diff --git a/record-and-playback/presentation/scripts/publish/presentation.rb b/record-and-playback/presentation/scripts/publish/presentation.rb
index f347eb339429b1de4729c164194bb95e6064810b..072b2f3d69362d647fe5a910502ba4eb5bdff006 100755
--- a/record-and-playback/presentation/scripts/publish/presentation.rb
+++ b/record-and-playback/presentation/scripts/publish/presentation.rb
@@ -1185,6 +1185,9 @@ begin
     $process_dir = "#{recording_dir}/process/presentation/#{$meeting_id}"
     BigBlueButton.logger.info("setting publish dir")
     publish_dir = $presentation_props['publish_dir']
+    BigBlueButton.logger.info("setting captions dir")
+    captions_dir = bbb_props['captions_dir']
+    captions_meeting_dir = "#{captions_dir}/#{$meeting_id}"
     BigBlueButton.logger.info("setting playback url info")
     playback_protocol = bbb_props['playback_protocol']
     playback_host = bbb_props['playback_host']
@@ -1225,6 +1228,21 @@ begin
           BigBlueButton.logger.info("Copied audio.ogg file")
         end
 
+        BigBlueButton.logger.info("Copying caption files to #{target_dir}")
+        captions = JSON.load(File.new("#{captions_meeting_dir}/captions.json"))
+        captions_json = []
+        captions.each do |track|
+          caption = {}
+          caption[:localeName] = track['label']
+          caption[:locale] = track['lang']
+          captions_json << caption
+          FileUtils.cp("#{captions_meeting_dir}/caption_" + track['lang'] + ".vtt", target_dir)
+        end
+
+        File.open("#{target_dir}/captions.json", "w") do |f|
+          f.write(captions_json.to_json)
+        end
+
         if File.exist?("#{$process_dir}/captions.json")
           BigBlueButton.logger.info("Copying caption files")
           FileUtils.cp("#{$process_dir}/captions.json", package_dir)