Merge branch 'text-tracks' of https://github.com/riadvice/bigbluebutton into riadvice-text-tracks

This commit is contained in:
Richard Alam 2019-05-30 12:30:07 -07:00
commit 9d416ee473
18 changed files with 371 additions and 50 deletions

View File

@ -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";

View File

@ -417,7 +417,11 @@ public class MeetingService implements MessageListener {
public String getCaptionTrackInboxDir() {
return recordingService.getCaptionTrackInboxDir();
}
}
public String getCaptionsDir() {
return recordingService.getCaptionsDir();
}
public String getRecordings2x(List<String> idList, List<String> states, Map<String, String> metadataFilters) {
return recordingService.getRecordings2x(idList, states, metadataFilters);

View File

@ -54,6 +54,7 @@ public class RecordingService {
private String recordStatusDir;
private String captionsDir;
private String presentationBaseDir;
private String defaultServerUrl;
private void copyPresentationFile(File presFile, File dlownloadableFile) {
try {
@ -169,7 +170,7 @@ public class RecordingService {
}
public String getRecordingTextTracks(String recordId) {
return recordingServiceHelper.getRecordingTextTracks(recordId, captionsDir);
return recordingServiceHelper.getRecordingTextTracks(recordId, captionsDir, getCaptionFileUrlDirectory(recordId));
}
public String putRecordingTextTrack(UploadedTrack track) {
@ -374,6 +375,10 @@ public class RecordingService {
presentationBaseDir = dir;
}
public void setDefaultServerUrl(String url) {
defaultServerUrl = url;
}
public void setPublishedDir(String dir) {
publishedDir = dir;
}
@ -662,7 +667,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(String recordId) {
return defaultServerUrl + "/captions/" + recordId + "/";
}
}

View File

@ -16,8 +16,8 @@ public class RecordingMetadataReaderHelper {
private RecordingServiceGW recordingServiceGW;
public String getRecordingTextTracks(String recordId, String captionsDir) {
return recordingServiceGW.getRecordingTextTracks(recordId, captionsDir);
public String getRecordingTextTracks(String recordId, String captionsDir, String captionsBaseUrl) {
return recordingServiceGW.getRecordingTextTracks(recordId, captionsDir, captionsBaseUrl);
}
public String putRecordingTextTrack(UploadedTrack track) {

View File

@ -13,6 +13,6 @@ 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);
String getRecordingTextTracks(String recordId, String captionsDir, String captionBasUrl);
String putRecordingTextTrack(UploadedTrack track);
}

View File

@ -22,11 +22,11 @@ case class UploadedTrackInfo(
origFilename: 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,

View File

@ -20,6 +20,8 @@ import java.nio.charset.Charset
import java.nio.file.Files
import java.nio.file.Paths
import com.google.gson.internal.LinkedTreeMap
class RecMetaXmlHelper extends RecordingServiceGW with LogHelper {
val SUCCESS = "SUCCESS"
@ -188,19 +190,36 @@ class RecMetaXmlHelper extends RecordingServiceGW with LogHelper {
}
}
def getRecordingTextTracks(recordId: String, captionsDir: String): String = {
def getRecordingTextTracks(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]]])
returnResponse = respText1
val list = new util.ArrayList[Track]()
val it = ctracks.iterator()
while (it.hasNext()) {
val mapTrack = it.next()
list.add(new Track(
// TODO : change this later and provide authenticated/signed URLs to fetch the caption files
href = captionBaseUrl + mapTrack.get("lang") + ".vtt",
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)

View File

@ -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

View File

@ -89,6 +89,7 @@ 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}"/>
</bean>
<bean id="configServiceHelper" class="org.bigbluebutton.api.ClientConfigServiceHelperImp"/>

View File

@ -84,7 +84,7 @@ class UrlMappings {
}
"/bigbluebutton/api/getRecordingTextTracks"(controller: "recording") {
action = [GET: 'getRecordingTextTracks']
action = [GET: 'getRecordingTextTracksHandler', POST: 'getRecordingTextTracksHandler']
}
"/bigbluebutton/api/putRecordingTextTrack"(controller: "recording") {

View File

@ -1,33 +1,71 @@
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
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))
}
}
/******************************************************
* 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)
if (!paramsProcessorUtil.isChecksumSame(API_CALL, params.checksum, request.getQueryString())) {
invalid("checksumError", "You did not pass the checksum security check")
return
}
// END - backward compatibility
// Do we agree on the checksum? If not, complain.
if (!paramsProcessorUtil.isChecksumSame(API_CALL, params.checksum, request.getQueryString())) {
errors.checksumError()
respondWithErrors(errors)
return
}
String recId = StringUtils.strip(params.recordID)
// Do we agree on the checksum? If not, complain.
//if (! paramsProcessorUtil.isChecksumSame(API_CALL, params.checksum, request.getQueryString())) {
@ -35,7 +73,7 @@ class RecordingController {
// return
//}
String result = meetingService.getRecordingTextTracks(recordId)
String result = meetingService.getRecordingTextTracks(recId)
response.addHeader("Cache-Control", "no-cache")
withFormat {
@ -49,19 +87,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,25 +110,53 @@ class RecordingController {
}
if (StringUtils.isEmpty(params.recordID)) {
respondWithError("paramError", "Missing param recordID.");
respondWithError("paramError", "Missing param recordID.")
return
} else {
String captionsDirPath = meetingService.getCaptionsDir() + File.separatorChar + StringUtils.isEmpty(params.recordID)
File captionsDir = new File(captionsDirPath);
if (!captionsDir.exists() || !captionsDir.isDirectory()) {
respondWithError("noRecordings", "No recording was found matching the provided recording ID.")
return;
}
}
String recordId = StringUtils.strip(params.recordID)
if (StringUtils.isEmpty(params.kind)) {
respondWithError("paramError", "Missing param kind.");
respondWithError("paramError", "Missing param kind.")
return
} else {
def isAllowedKind = StringUtils.strip(params.kind) in ['subtitles', 'captions']
if (!isAllowedKind) {
respondWithError("invalidKind", "The kind parameter is not set to a permitted value.")
return
}
}
String captionsKind = StringUtils.strip(params.kind)
Locale locale;
if (StringUtils.isEmpty(params.lang)) {
respondWithError("paramError", "Missing param lang.");
respondWithError("paramError", "Missing param lang.")
return
} else {
Collection<Locale> locales = new ArrayList<>();
locales.add(Locale.forLanguageTag(params.lang));
try {
List<Locale.LanguageRange> languageRanges = Locale.LanguageRange.parse(params.lang);
locale = Locale.lookup(languageRanges, locales);
if (locale == null) {
respondWithError("invalidLang", "The lang parameter is not a valid language tag.")
return;
}
} catch (IllegalArgumentException e) {
respondWithError("invalidLang", "The lang parameter is not a well-formed language tag.")
return;
}
}
String captionsLang = StringUtils.strip(params.lang)
String captionsLang = locale.toString()
String captionsLabel = captionsLang
if (!StringUtils.isEmpty(params.label)) {
@ -118,16 +186,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)
}
}

View File

@ -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

View File

@ -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?

View File

@ -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

View File

@ -40,6 +40,7 @@ function deploy_format() {
deploy_format "presentation"
sudo mkdir -p /var/bigbluebutton/captions/
sudo mkdir -p /var/bigbluebutton/events/
sudo mkdir -p /var/bigbluebutton/playback/
sudo mkdir -p /var/bigbluebutton/recording/raw/

View File

@ -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?

View File

@ -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)