From 379a71e893e6bc2018a1565298c04e42961f7a34 Mon Sep 17 00:00:00 2001 From: Sina Madani Date: Tue, 5 Sep 2023 14:51:13 +0100 Subject: [PATCH] Added Captions API (#245) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Added Captions API * Bump version: v4.12.0 → v4.13.0 * Fixed bad character --- .bumpversion.cfg | 2 +- README.md | 4 +- build.gradle | 2 +- src/main/java/com/opentok/Caption.java | 45 +++++ .../java/com/opentok/CaptionProperties.java | 165 ++++++++++++++++++ src/main/java/com/opentok/OpenTok.java | 52 +++++- .../java/com/opentok/constants/Version.java | 2 +- .../java/com/opentok/util/HttpClient.java | 81 ++++++++- .../java/com/opentok/test/OpenTokTest.java | 88 ++++++++++ 9 files changed, 431 insertions(+), 10 deletions(-) create mode 100644 src/main/java/com/opentok/Caption.java create mode 100644 src/main/java/com/opentok/CaptionProperties.java diff --git a/.bumpversion.cfg b/.bumpversion.cfg index c101e7c..b72ddcd 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,7 +1,7 @@ [bumpversion] commit = True tag = False -current_version = v4.12.0 +current_version = v4.13.0 parse = (?P\d+)\.(?P\d+)\.(?P\d+)(\-(?P[a-z]+)(?P\d+))? serialize = {major}.{minor}.{patch}-{release}{build} diff --git a/README.md b/README.md index 8f9bb8f..2d30073 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ When you use Maven as your build tool, you can manage dependencies in the `pom.x com.tokbox opentok-server-sdk - 4.12.0 + 4.13.0 ``` @@ -44,7 +44,7 @@ When you use Gradle as your build tool, you can manage dependencies in the `buil ```groovy dependencies { - compile group: 'com.tokbox', name: 'opentok-server-sdk', version: '4.12.0' + compile group: 'com.tokbox', name: 'opentok-server-sdk', version: '4.13.0' } ``` diff --git a/build.gradle b/build.gradle index 87ec9ef..a7f31e6 100644 --- a/build.gradle +++ b/build.gradle @@ -11,7 +11,7 @@ plugins { group = 'com.tokbox' archivesBaseName = 'opentok-server-sdk' -version = '4.12.0' +version = '4.13.0' sourceCompatibility = "1.8" targetCompatibility = "1.8" diff --git a/src/main/java/com/opentok/Caption.java b/src/main/java/com/opentok/Caption.java new file mode 100644 index 0000000..c5b6b15 --- /dev/null +++ b/src/main/java/com/opentok/Caption.java @@ -0,0 +1,45 @@ +/** + * OpenTok Java SDK + * Copyright (C) 2023 Vonage. + * http://www.tokbox.com + * + * Licensed under The MIT License (MIT). See LICENSE file for more information. + */ +package com.opentok; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonValue; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * Represents the response from {@link OpenTok#startCaptions(String, String, CaptionProperties)}. + */ +@JsonIgnoreProperties(ignoreUnknown=true) +public class Caption { + private String captionsId; + + /** + * The unique ID for the audio captioning session. + * + * @return The captions UUID as a string. + */ + @JsonProperty("captionsId") + public String getCaptionsId() { + return captionsId; + } + + @Override + public String toString() { + try { + return new ObjectMapper().writeValueAsString(this); + } catch (JsonProcessingException e) { + return ""; + } + } +} diff --git a/src/main/java/com/opentok/CaptionProperties.java b/src/main/java/com/opentok/CaptionProperties.java new file mode 100644 index 0000000..77982d2 --- /dev/null +++ b/src/main/java/com/opentok/CaptionProperties.java @@ -0,0 +1,165 @@ +/** + * OpenTok Java SDK + * Copyright (C) 2023 Vonage. + * http://www.tokbox.com + * + * Licensed under The MIT License (MIT). See LICENSE file for more information. + */ +package com.opentok; + +/** + * Defines values for the properties parameter of the + * {@link OpenTok#startCaptions(String, String, CaptionProperties)} method. + * + * @see OpenTok#startCaptions(String, String, CaptionProperties) + */ +public class CaptionProperties { + private final String statusCallbackUrl, languageCode; + private final int maxDuration; + private final boolean partialCaptions; + + private CaptionProperties(Builder builder) { + statusCallbackUrl = builder.statusCallbackUrl; + languageCode = builder.languageCode; + maxDuration = builder.maxDuration; + partialCaptions = builder.partialCaptions; + } + + /** + * A publicly reachable URL controlled by the customer and capable of generating the content to + * be rendered without user intervention. The minimum length of the URL is 15 characters and the + * maximum length is 2048 characters. For more information, see + * + * Live Caption status updates. + * + * @return The status callback URL as a string, or {@code null} if not set. + */ + public String getStatusCallbackUrl() { + return statusCallbackUrl; + } + + /** + * The BCP-47 code for a spoken language used on this call. + * + * @return The language code as a string. + */ + public String getLanguageCode() { + return languageCode; + } + + /** + * The maximum duration for the audio captioning, in seconds. + * + * @return The maximum captioning duration as an integer. + */ + public int getMaxDuration() { + return maxDuration; + } + + /** + * Whether faster captioning is enabled at the cost of some degree of inaccuracies. + * + * @return {@code true} if the partial captions setting is enabled. + */ + public boolean partialCaptions() { + return partialCaptions; + } + + /** + * Entry point for constructing an instance of this class. + * + * @return A new Builder. + */ + public static Builder Builder() { + return new Builder(); + } + + /** + * Used to create a CaptionProperties object. + * + * @see CaptionProperties + */ + public static class Builder { + private String languageCode = "en-US", statusCallbackUrl; + private int maxDuration = 14400; + private boolean partialCaptions = true; + + private Builder() { + } + + /** + * The BCP-47 code for a spoken language used on this call. The default value is "en-US". The following + * language codes are supported: "en-AU" (English, Australia), "en-GB" (Englsh, UK), "es-US" (English, US), + * "zh-CN" (Chinese, Simplified), "fr-FR" (French), "fr-CA" (French, Canadian), "de-DE" (German), + * "hi-IN" (Hindi, Indian), "it-IT" (Italian), "ja-JP" (Japanese), "ko-KR" (Korean), + * "pt-BR" (Portuguese, Brazilian), "th-TH" (Thai). + * + * @param languageCode The BCP-47 language code as a string. + * + * @return This Builder with the languageCode property setting. + */ + public Builder languageCode(String languageCode) { + if (languageCode == null || languageCode.length() != 5 || languageCode.charAt(2) != '-') { + throw new IllegalArgumentException("Invalid language code."); + } + this.languageCode = languageCode; + return this; + } + + /** + * A publicly reachable URL controlled by the customer and capable of generating the content to + * be rendered without user intervention. The minimum length of the URL is 15 characters and the + * maximum length is 2048 characters. For more information, see + * + * Live Caption status updates. + * + * @param statusCallbackUrl The status callback URL as a string. + * + * @return This Builder with the statusCallbackUrl property setting. + */ + public Builder statusCallbackUrl(String statusCallbackUrl) { + if (statusCallbackUrl == null || statusCallbackUrl.length() < 15 || statusCallbackUrl.length() > 2048) { + throw new IllegalArgumentException("Status callback URL must be between 15 and 2048 characters."); + } + this.statusCallbackUrl = statusCallbackUrl; + return this; + } + + /** + * The maximum duration for the audio captioning, in seconds. + * The default value is 14,400 seconds (4 hours), the maximum duration allowed. + * + * @param maxDuration The maximum captions duration in seconds. + * + * @return This Builder with the maxDuration property setting. + */ + public Builder maxDuration(int maxDuration) { + if ((this.maxDuration = maxDuration) < 0 || maxDuration > 14400) { + throw new IllegalArgumentException("Max duration must be positive and less than 14400 seconds."); + } + return this; + } + + /** + * Whether to enable this to faster captioning at the cost of some degree of inaccuracies. + * The default value is {@code true}. + * + * @param partialCaptions Whether to enable faster captions. + * + * @return This Builder with the partialCaptions property setting. + */ + public Builder partialCaptions(boolean partialCaptions) { + this.partialCaptions = partialCaptions; + return this; + } + + /** + * Builds the CaptionProperties object. + * + * @return The CaptionProperties object with this builder's settings. + */ + public CaptionProperties build() { + return new CaptionProperties(this); + } + } +} diff --git a/src/main/java/com/opentok/OpenTok.java b/src/main/java/com/opentok/OpenTok.java index 1d80849..d141433 100644 --- a/src/main/java/com/opentok/OpenTok.java +++ b/src/main/java/com/opentok/OpenTok.java @@ -52,7 +52,8 @@ public class OpenTok { broadcastReader = new ObjectMapper().readerFor(Broadcast.class), renderReader = new ObjectMapper().readerFor(Render.class), renderListReader = new ObjectMapper().readerForListOf(Render.class), - connectReader = new ObjectMapper().readerFor(AudioConnector.class); + connectReader = new ObjectMapper().readerFor(AudioConnector.class), + captionReader = new ObjectMapper().readerFor(Caption.class); static final String defaultApiUrl = "https://api.opentok.com"; @@ -978,6 +979,55 @@ public List listRenders(Integer offset, Integer count) throws OpenTokExc } } + /** + * Use the Live Captions API to transcribe audio streams and generate real-time captions for your application. + * Live Captions is enabled by default for all projects, and it is a usage-based product. The Live Captions + * feature is only supported in routed sessions (sessions that use the OpenTok Media Router). You can send up to + * 50 audio streams from a single Vonage session at a time to the transcription service for captions. + * + * @param sessionId The session ID of the OpenTok session. The audio from Publishers publishing into + * this session will be used to generate the captions. + * + * @param token A valid OpenTok token with role set to Moderator. + * + * @param properties The {@link CaptionProperties} object defining optional properties of the live captioning. + * + * @return A {@link Caption} response containing the captions ID for this call. + * + * @throws OpenTokException + */ + public Caption startCaptions(String sessionId, String token, CaptionProperties properties) throws OpenTokException { + if (StringUtils.isEmpty(sessionId)) { + throw new InvalidArgumentException("Session ID is required."); + } + if (StringUtils.isEmpty(token)) { + throw new InvalidArgumentException("Token is required."); + } + String captions = client.startCaption(sessionId, token, + properties != null ? properties : CaptionProperties.Builder().build() + ); + try { + return captionReader.readValue(captions); + } catch (JsonProcessingException e) { + throw new RequestException("Exception mapping json: " + e.getMessage()); + } + } + + /** + * Use this method to stop live captions for a session. + * + * @param captionsId The unique ID for the audio captioning session, + * as obtained from {@linkplain Caption#getCaptionsId()}. + * + * @throws OpenTokException + */ + public void stopCaptions(String captionsId) throws OpenTokException { + if (StringUtils.isEmpty(captionsId)) { + throw new InvalidArgumentException("Captions id is required."); + } + client.stopCaption(captionsId); + } + /** * Used to create an OpenTok object with advanced settings. You can set * the request timeout for API calls and a proxy to use for API calls. diff --git a/src/main/java/com/opentok/constants/Version.java b/src/main/java/com/opentok/constants/Version.java index 0712938..b5c0716 100644 --- a/src/main/java/com/opentok/constants/Version.java +++ b/src/main/java/com/opentok/constants/Version.java @@ -8,5 +8,5 @@ package com.opentok.constants; public class Version { - public static final String VERSION = "4.12.0"; + public static final String VERSION = "4.13.0"; } diff --git a/src/main/java/com/opentok/util/HttpClient.java b/src/main/java/com/opentok/util/HttpClient.java index a257d24..da4816f 100644 --- a/src/main/java/com/opentok/util/HttpClient.java +++ b/src/main/java/com/opentok/util/HttpClient.java @@ -17,7 +17,6 @@ import com.opentok.*; import com.opentok.constants.DefaultApiUrl; import com.opentok.constants.DefaultUserAgent; -import com.opentok.constants.Version; import com.opentok.exception.InvalidArgumentException; import com.opentok.exception.OpenTokException; import com.opentok.exception.RequestException; @@ -34,7 +33,6 @@ import java.net.Proxy; import java.net.SocketAddress; import java.util.*; -import java.util.Map.Entry; import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; @@ -1214,7 +1212,6 @@ public String getRender(String renderId) throws OpenTokException { String url = this.apiUrl + "/v2/project/" + this.apiKey + "/render/" + renderId; Future request = this.prepareGet(url) - .setHeader("Content-Type", "application/json") .setHeader("Accept", "application/json") .execute(); @@ -1260,7 +1257,7 @@ public void stopRender(String renderId) throws OpenTokException { " response code: " + response.getStatusCode()); } } catch (InterruptedException | ExecutionException e) { - throw new RequestException("Could not start render", e); + throw new RequestException("Could not stop render", e); } } @@ -1292,6 +1289,82 @@ public String listRenders(Integer offset, Integer count) throws OpenTokException throw new RequestException("Could not start render", e); } } + + public String startCaption(String sessionId, String token, CaptionProperties properties) throws OpenTokException { + String url = this.apiUrl + "/v2/project/" + this.apiKey + "/captions"; + + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + try { + JsonFactory factory = new JsonFactory(); + JsonGenerator jGenerator = factory.createGenerator(outputStream); + jGenerator.writeStartObject(); + jGenerator.writeStringField("sessionId", sessionId); + jGenerator.writeStringField("token", token); + jGenerator.writeStringField("languageCode", properties.getLanguageCode()); + jGenerator.writeNumberField("maxDuration", properties.getMaxDuration()); + jGenerator.writeBooleanField("partialCaptions", properties.partialCaptions()); + String statusCallbackUrl = properties.getStatusCallbackUrl(); + if (StringUtils.isNotEmpty(statusCallbackUrl)) { + jGenerator.writeStringField("statusCallbackUrl", statusCallbackUrl); + } + jGenerator.writeEndObject(); + jGenerator.close(); + outputStream.close(); + } + catch (Exception e) { + throw new OpenTokException("Could not start live captions. The JSON body encoding failed.", e); + } + + Future request = this.preparePost(url) + .setHeader("Content-Type", "application/json") + .setHeader("Accept", "application/json") + .setBody(outputStream.toString()) + .execute(); + + try { + Response response = request.get(); + switch (response.getStatusCode()) { + case 200: case 202: + return response.getResponseBody(); + case 400: + throw new RequestException("Invalid request. This response may indicate that data in your request data is invalid JSON."); + case 403: + throw new RequestException("You passed in an invalid OpenTok API key or JWT."); + case 409: + throw new RequestException("Live captions have already started for this OpenTok session."); + case 500: + throw new RequestException("Could not stop live captions. A server error occurred."); + default: + throw new RequestException("Could not stop render. The server response was invalid." + + " response code: " + response.getStatusCode()); + } + } catch (InterruptedException | ExecutionException e) { + throw new RequestException("Could not stop captions", e); + } + } + + public void stopCaption(String captionsId) throws OpenTokException { + String url = this.apiUrl + "/v2/project/" + this.apiKey + "/captions/" + captionsId + "/stop"; + try { + Response response = this.preparePost(url).execute().get(); + switch (response.getStatusCode()) { + case 200: case 202: + return; + case 403: + throw new RequestException("You passed in an invalid OpenTok API key or JWT."); + case 404: + throw new RequestException("No live caption matching the specified ID was found."); + case 500: + throw new RequestException("Could not stop live captions. A server error occurred."); + default: + throw new RequestException("Could not stop render. The server response was invalid." + + " response code: " + response.getStatusCode()); + } + } catch (InterruptedException | ExecutionException e) { + throw new RequestException("Could not stop captions", e); + } + } + public enum ProxyAuthScheme { BASIC, DIGEST, diff --git a/src/test/java/com/opentok/test/OpenTokTest.java b/src/test/java/com/opentok/test/OpenTokTest.java index 286cade..e92207a 100644 --- a/src/test/java/com/opentok/test/OpenTokTest.java +++ b/src/test/java/com/opentok/test/OpenTokTest.java @@ -2762,4 +2762,92 @@ public void testConnectProperties() throws Exception { new AudioConnectorProperties.Builder(uriStr).addStream(" ").build() ); } + + @Test + public void testStartCaptions() throws Exception { + String sessionId = "1_MX4yNzA4NjYxMn5-MTU0NzA4MDUyMTEzNn5sOXU5ZnlWYXplRnZGblV4RUo3dXJpZk1-fg"; + String token = "A valid OpenTok token with the role set to moderator"; + String statusCallbackUrl = "https://send-status-to.me"; + String languageCode = "en-GB"; + int maxDuration = 1800; + boolean partialCaptions = false; + String captionsId = "7c0680fc-6274-4de5-a66f-d0648e8d3ac2"; + String url = "/v2/project/" + this.apiKey + "/captions"; + stubFor(post(urlEqualTo(url)) + .withHeader("Accept", equalTo("application/json")) + .withHeader("Content-Type", equalTo("application/json")) + .withRequestBody(equalToJson("{\n" + + " \"sessionId\": \""+sessionId+"\",\n" + + " \"token\": \""+token+"\",\n" + + " \"languageCode\": \""+languageCode+"\",\n" + + " \"maxDuration\": "+maxDuration+",\n" + + " \"partialCaptions\": "+partialCaptions+",\n" + + " \"statusCallbackUrl\": \""+statusCallbackUrl+"\"\n" + + "}" + )).willReturn(aResponse() + .withStatus(202) + .withHeader("Content-Type", "application/json") + .withBody("{\n" + + " \"captionsId\": \""+captionsId+"\"\n" + + "}" + ) + ) + ); + + CaptionProperties properties = CaptionProperties.Builder() + .languageCode(languageCode) + .statusCallbackUrl(statusCallbackUrl) + .maxDuration(maxDuration) + .partialCaptions(partialCaptions) + .build(); + + Caption caption = sdk.startCaptions(sessionId, token, properties); + assertNotNull(caption); + assertEquals(captionsId, caption.getCaptionsId()); + assertTrue(caption.toString().contains(captionsId)); + + verify(postRequestedFor(urlMatching(url))); + assertTrue(Helpers.verifyTokenAuth(apiKey, apiSecret, + findAll(postRequestedFor(urlMatching(url))))); + Helpers.verifyUserAgent(); + + assertThrows(InvalidArgumentException.class, () -> sdk.startCaptions("", token, properties)); + assertThrows(InvalidArgumentException.class, () -> sdk.startCaptions(sessionId, "", properties)); + + stubFor(post(urlEqualTo(url)).willReturn(aResponse().withStatus(409))); + assertThrows(RequestException.class, () -> sdk.startCaptions(sessionId, token, null)); + } + + @Test + public void testCaptionProperties() throws Exception { + CaptionProperties.Builder builder = CaptionProperties.Builder(); + CaptionProperties properties = builder.build(); + assertEquals(14400, properties.getMaxDuration()); + assertEquals("en-US", properties.getLanguageCode()); + assertTrue(properties.partialCaptions()); + assertNull(properties.getStatusCallbackUrl()); + + assertThrows(IllegalArgumentException.class, () -> builder.maxDuration(14401).build()); + assertThrows(IllegalArgumentException.class, () -> builder.maxDuration(-1).build()); + assertThrows(IllegalArgumentException.class, () -> builder.statusCallbackUrl("invalid").build()); + assertThrows(IllegalArgumentException.class, () -> builder.languageCode("invalid").build()); + } + + @Test + public void testStopCaptions() throws Exception { + String captionsId = UUID.randomUUID().toString(); + String url = "/v2/project/" + this.apiKey + "/captions/" + captionsId + "/stop"; + stubFor(post(urlEqualTo(url)).willReturn(aResponse().withStatus(202))); + + sdk.stopCaptions(captionsId); + + verify(postRequestedFor(urlMatching(url))); + assertTrue(Helpers.verifyTokenAuth(apiKey, apiSecret, + findAll(deleteRequestedFor(urlMatching(url))))); + Helpers.verifyUserAgent(); + + assertThrows(InvalidArgumentException.class, () -> sdk.stopCaptions("")); + stubFor(post(urlEqualTo(url)).willReturn(aResponse().withStatus(404))); + assertThrows(RequestException.class, () -> sdk.stopCaptions(captionsId)); + } } \ No newline at end of file