From 8544fbdd75c81d3c42176af371342eccefaff47a Mon Sep 17 00:00:00 2001 From: tr00d Date: Tue, 12 Sep 2023 14:18:28 +0200 Subject: [PATCH 1/2] Add live captions support (start/stop) --- OpenTok/Caption.cs | 15 ++ OpenTok/CaptionOptions.cs | 248 +++++++++++++++++++++++++++ OpenTok/OpenTok.Captions.cs | 31 ++++ OpenTok/OpenTok.Render.cs | 1 + OpenTokTest/CaptionOptionsTest.cs | 127 ++++++++++++++ OpenTokTest/OpenTok.CaptionsTests.cs | 66 +++++++ 6 files changed, 488 insertions(+) create mode 100644 OpenTok/Caption.cs create mode 100644 OpenTok/CaptionOptions.cs create mode 100644 OpenTok/OpenTok.Captions.cs create mode 100644 OpenTokTest/CaptionOptionsTest.cs create mode 100644 OpenTokTest/OpenTok.CaptionsTests.cs diff --git a/OpenTok/Caption.cs b/OpenTok/Caption.cs new file mode 100644 index 00000000..54385814 --- /dev/null +++ b/OpenTok/Caption.cs @@ -0,0 +1,15 @@ +using System; + +namespace OpenTokSDK +{ + /// + /// Represents a success caption. + /// + public struct Caption + { + /// + /// The unique ID for the audio captioning session. + /// + public Guid CaptionsId { get; set; } + } +} \ No newline at end of file diff --git a/OpenTok/CaptionOptions.cs b/OpenTok/CaptionOptions.cs new file mode 100644 index 00000000..22b1e59c --- /dev/null +++ b/OpenTok/CaptionOptions.cs @@ -0,0 +1,248 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using EnumsNET; +using OpenTokSDK.Exception; + +namespace OpenTokSDK +{ + /// + /// Represents options to start live captions. + /// + public class CaptionOptions + { + private CaptionOptions(string sessionId, string token, LanguageCodeValue languageCode = LanguageCodeValue.EnUs, + Uri statusCallbackUrl = null, TimeSpan? maxDuration = null, bool partialCaptions = true) + { + this.LanguageCode = languageCode; + this.PartialCaptions = partialCaptions; + this.SessionId = sessionId; + this.StatusCallbackUrl = statusCallbackUrl; + this.Token = token; + this.MaxDuration = maxDuration ?? new TimeSpan(4, 0, 0); + } + + /// + /// The BCP-47 code for a spoken language used on this call. The default value is "en-US". + /// + public LanguageCodeValue LanguageCode { get; } + + /// + /// The maximum duration for the audio captioning, in seconds. The default value is 14,400 seconds (4 hours). + /// + public TimeSpan MaxDuration { get; } + + /// + /// Whether to enable this to faster captioning at the cost of some degree of inaccuracies. The default value is true. + /// + public bool PartialCaptions { get; } + + /// + /// The session ID of the OpenTok session. The audio from Publishers publishing into this session will be used to + /// generate the captions. + /// + public string SessionId { get; } + + /// + /// 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 + /// + /// . + /// + public Uri StatusCallbackUrl { get; } + + /// + /// A valid OpenTok token with role set to Moderator. + /// + public string Token { get; } + + /// + /// Initializes a caption options with mandatory values. + /// + /// + /// The session ID of the OpenTok session. The audio from Publishers publishing into this session + /// will be used to generate the captions. + /// + /// A valid OpenTok token with role set to Moderator. + /// The options. + public static CaptionOptions Build(string sessionId, string token) + { + ValidateSessionId(sessionId); + ValidateToken(token); + return new CaptionOptions(sessionId, token); + } + + /// + /// Disables partial captions. + /// + /// The options. + public CaptionOptions DisablePartialCaptions() => new CaptionOptions(this.SessionId, this.Token, + this.LanguageCode, this.StatusCallbackUrl, this.MaxDuration, false); + + /// + /// Specifies callback Url. + /// + /// + /// 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 <see + /// href="https://tokbox.com/developer/guides/live-captions/#live-caption-status-updates">Live Caption status + /// updates</see>. + /// + /// The options. + public CaptionOptions WithCallbackUrl(Uri uri) => new CaptionOptions(this.SessionId, this.Token, + this.LanguageCode, uri, this.MaxDuration, this.PartialCaptions); + + /// + /// Specifies language code. + /// + /// The BCP-47 code for a spoken language used on this call. The default value is "en-US". + /// The options. + public CaptionOptions WithLanguageCode(LanguageCodeValue code) => new CaptionOptions(this.SessionId, this.Token, + code, this.StatusCallbackUrl, this.MaxDuration, this.PartialCaptions); + + /// + /// Specifies the maximum duration. + /// + /// + /// The maximum duration for the audio captioning, in seconds. The default value is 14,400 + /// seconds (4 hours). + /// + /// The options. + public CaptionOptions WithMaxDuration(TimeSpan maxDuration) + { + ValidateMaxDuration(maxDuration); + return new CaptionOptions(this.SessionId, this.Token, this.LanguageCode, this.StatusCallbackUrl, + maxDuration, this.PartialCaptions); + } + + private static void ValidateMaxDuration(TimeSpan timeSpan) + { + if (timeSpan > new TimeSpan(4, 0, 0)) + { + throw new OpenTokException("Max duration cannot exceed 4 hours."); + } + } + + private static void ValidateSessionId(string sessionId) + { + if (string.IsNullOrWhiteSpace(sessionId)) + { + throw new OpenTokException("SessionId cannot be null or empty."); + } + } + + private static void ValidateToken(string token) + { + if (string.IsNullOrWhiteSpace(token)) + { + throw new OpenTokException("Token cannot be null or empty."); + } + } + + /// + /// Represents the BCP-47 code for a spoken language used on this call. + /// + public enum LanguageCodeValue + { + /// + /// en-AU (English, Australia) + /// + [Description("en-AU")] + EnAu, + + /// + /// en-US (English, US) + /// + [Description("en-US")] + EnUs, + + /// + /// en-GB (English, UK) + /// + [Description("en-GB")] + EnGb, + + /// + /// zh-CN (Chinese, Simplified) + /// + [Description("zh-CN")] + ZhCn, + + /// + /// fr-FR (French) + /// + [Description("fr-FR")] + FrFr, + + /// + /// fr-CA (French, Canadian) + /// + [Description("fr-CA")] + FrCa, + + /// + /// de-DE (German) + /// + [Description("de-DE")] + DeDe, + + /// + /// hi-IN (Hindi, Indian) + /// + [Description("hi-IN")] + HiIn, + + /// + /// it-IT (Italian) + /// + [Description("it-IT")] + ItIt, + + /// + /// ja-JP (Japanese) + /// + [Description("ja-JP")] + JaJp, + + /// + /// ko-KR (Korean) + /// + [Description("ko-KR")] + KoKr, + + /// + /// pt-BR (Portuguese, Brazilian) + /// + [Description("pt-BR")] + PrBr, + + /// + /// th-TH (Thai) + /// + [Description("th-TH")] + ThTh, + } + + internal Dictionary ToDataDictionary() + { + var dictionary = new Dictionary + { + {"sessionId", this.SessionId}, + {"token", this.Token}, + {"languageCode", this.LanguageCode.AsString(EnumFormat.Description)}, + {"maxDuration", this.MaxDuration.TotalMinutes}, + {"partialCaptions", this.PartialCaptions}, + }; + if (this.StatusCallbackUrl != null) + { + dictionary.Add("statusCallbackUrl", this.StatusCallbackUrl.AbsoluteUri); + } + return dictionary; + } + } +} \ No newline at end of file diff --git a/OpenTok/OpenTok.Captions.cs b/OpenTok/OpenTok.Captions.cs new file mode 100644 index 00000000..946d45cf --- /dev/null +++ b/OpenTok/OpenTok.Captions.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Newtonsoft.Json; + +namespace OpenTokSDK +{ + public partial class OpenTok + { + /// + /// + /// + /// + public async Task StartLiveCaptionsAsync(CaptionOptions options) + { + var response = await this.Client.PostAsync( + this.BuildUrl(CaptionsEndpoint), + GetHeaderDictionary("application/json"), + options.ToDataDictionary()); + return JsonConvert.DeserializeObject(response); + } + + /// + /// + /// + public Task StopLiveCaptionsAsync(Guid captionId) => + this.Client.PostAsync( + $"{this.BuildUrlWithRouteParameter(CaptionsEndpoint, captionId.ToString())}/stop", + GetHeaderDictionary("application/json"), new Dictionary()); + } +} \ No newline at end of file diff --git a/OpenTok/OpenTok.Render.cs b/OpenTok/OpenTok.Render.cs index e934b19e..06536a53 100644 --- a/OpenTok/OpenTok.Render.cs +++ b/OpenTok/OpenTok.Render.cs @@ -8,6 +8,7 @@ namespace OpenTokSDK public partial class OpenTok { private const string RenderEndpoint = "/render"; + private const string CaptionsEndpoint = "/captions"; /// /// Starts a new Experience Composer renderer for an OpenTok session. diff --git a/OpenTokTest/CaptionOptionsTest.cs b/OpenTokTest/CaptionOptionsTest.cs new file mode 100644 index 00000000..900eb521 --- /dev/null +++ b/OpenTokTest/CaptionOptionsTest.cs @@ -0,0 +1,127 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using OpenTokSDK; +using OpenTokSDK.Exception; +using Xunit; + +namespace OpenTokSDKTest +{ + public class CaptionOptionsTest + { + [Fact] + public void Build_ShouldDisablePartialCaptionsByDefault() => + Assert.True(CaptionOptions.Build("sessionId", "token").PartialCaptions); + + [Fact] + public void Build_ShouldHaveNoCallbackUri() => + Assert.Null(CaptionOptions.Build("sessionId", "token").StatusCallbackUrl); + + [Fact] + public void Build_ShouldSetDefaultLanguageCode() => Assert.Equal(CaptionOptions.LanguageCodeValue.EnUs, + CaptionOptions.Build("sessionId", "token").LanguageCode); + + [Fact] + public void Build_ShouldSetDefaultMaxDuration() => Assert.Equal(new TimeSpan(4, 0, 0), + CaptionOptions.Build("sessionId", "token").MaxDuration); + + [Fact] + public void Build_ShouldSetSessionId() => + Assert.Equal("sessionId", CaptionOptions.Build("sessionId", "token").SessionId); + + [Fact] + public void Build_ShouldSetToken() => Assert.Equal("token", + CaptionOptions.Build("sessionId", "token").Token); + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] + public void Build_ShouldThrowException_GivenSessionIdIsNullOrEmpty(string invalidSessionId) + { + void Act() => CaptionOptions.Build(invalidSessionId, "token"); + var exception = Assert.Throws(Act); + Assert.Equal("SessionId cannot be null or empty.", exception.Message); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] + public void Build_ShouldThrowException_GivenTokenIdIsNullOrEmpty(string invalidToken) + { + void Act() => CaptionOptions.Build("sessionId", invalidToken); + var exception = Assert.Throws(Act); + Assert.Equal("Token cannot be null or empty.", exception.Message); + } + + [Fact] + public void DisablePartialCaptions_ShouldDisablePartialCaptions() => Assert.False( + CaptionOptions.Build("sessionId", "token").DisablePartialCaptions().PartialCaptions); + + [Fact] + public void WithLanguageCode_ShouldSetLanguageCode() => Assert.Equal(CaptionOptions.LanguageCodeValue.FrFr, + CaptionOptions.Build("sessionId", "token").WithLanguageCode(CaptionOptions.LanguageCodeValue.FrFr) + .LanguageCode); + + [Fact] + public void WithMaxDuration_ShouldSetMaxDuration() => Assert.Equal(new TimeSpan(1, 0, 0), + CaptionOptions.Build("sessionId", "token").WithMaxDuration(new TimeSpan(1, 0, 0)) + .MaxDuration); + + [Fact] + public void WithMaxDuration_ShouldThrowException_GivenValueIsExceededMaximum() + { + void Act() => CaptionOptions.Build("sessionId", "token") + .WithMaxDuration(new TimeSpan(4, 0, 1)); + + var exception = Assert.Throws(Act); + Assert.Equal("Max duration cannot exceed 4 hours.", exception.Message); + } + + [Fact] + public void WithCallbackUrl_ShouldSetCallbackUrl() => Assert.Equal(new Uri("http://example.com"), + CaptionOptions.Build("sessionId", "token").WithCallbackUrl(new Uri("http://example.com")) + .StatusCallbackUrl); + + [Fact] + public void ToDataDictionary_ShouldReturnDataDictionary() + { + var expectedData = new Dictionary + { + {"sessionId", "sessionId"}, + {"token", "token"}, + {"languageCode", "en-AU"}, + {"maxDuration", (double)60}, + {"partialCaptions", false}, + {"statusCallbackUrl", "http://example.com/"}, + }; + var data = CaptionOptions.Build("sessionId", "token") + .WithLanguageCode(CaptionOptions.LanguageCodeValue.EnAu) + .WithMaxDuration(new TimeSpan(1, 0, 0)) + .WithCallbackUrl(new Uri("http://example.com")) + .DisablePartialCaptions() + .ToDataDictionary(); + Assert.Equal(data, expectedData); + } + + [Fact] + public void ToDataDictionary_ShouldExcludeStatusCallbackUrl_GivenUrlIsNull() + { + var expectedData = new Dictionary + { + {"sessionId", "sessionId"}, + {"token", "token"}, + {"languageCode", "en-AU"}, + {"maxDuration", (double)60}, + {"partialCaptions", false}, + }; + var data = CaptionOptions.Build("sessionId", "token") + .WithLanguageCode(CaptionOptions.LanguageCodeValue.EnAu) + .WithMaxDuration(new TimeSpan(1, 0, 0)) + .DisablePartialCaptions() + .ToDataDictionary(); + Assert.Equal(data, expectedData); + } + } +} \ No newline at end of file diff --git a/OpenTokTest/OpenTok.CaptionsTests.cs b/OpenTokTest/OpenTok.CaptionsTests.cs new file mode 100644 index 00000000..92defab0 --- /dev/null +++ b/OpenTokTest/OpenTok.CaptionsTests.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using AutoFixture; +using Moq; +using Newtonsoft.Json; +using OpenTokSDK; +using OpenTokSDK.Util; +using Xunit; + +namespace OpenTokSDKTest +{ + public class OpenTokCaptionsTests + { + private readonly int apiKey; + private readonly Mock mockClient; + private readonly OpenTok sut; + + public OpenTokCaptionsTests() + { + var fixture = new Fixture(); + this.apiKey = fixture.Create(); + this.mockClient = new Mock(); + this.sut = new OpenTok(this.apiKey, fixture.Create()) + { + Client = this.mockClient.Object, + }; + } + + [Fact] + public async Task StartLiveCaptionsAsync_ShouldReturnResponse() + { + const string contentTypeKey = "Content-Type"; + const string contentType = "application/json"; + var expectedUrl = $"v2/project/{this.apiKey}/captions"; + var expectedResponse = new Caption {CaptionsId = Guid.NewGuid()}; + var serializedResponse = JsonConvert.SerializeObject(expectedResponse); + var request = CaptionOptions.Build("sessionId", "token"); + this.mockClient.Setup(httpClient => httpClient.PostAsync( + expectedUrl, + It.Is>(dictionary => + dictionary.ContainsKey(contentTypeKey) && dictionary[contentTypeKey] == contentType), + It.Is>(dictionary => + dictionary.SequenceEqual(request.ToDataDictionary())))) + .ReturnsAsync(serializedResponse); + var response = await this.sut.StartLiveCaptionsAsync(request); + Assert.Equal(expectedResponse, response); + } + + [Fact] + public async Task StopLiveCaptionsAsync_ShouldReturnResponse() + { + const string contentTypeKey = "Content-Type"; + const string contentType = "application/json"; + var captionId = Guid.NewGuid(); + var expectedUrl = $"v2/project/{this.apiKey}/captions/{captionId}/stop"; + await this.sut.StopLiveCaptionsAsync(captionId); + this.mockClient.Verify(httpClient => httpClient.PostAsync( + expectedUrl, + It.Is>(dictionary => + dictionary.ContainsKey(contentTypeKey) && dictionary[contentTypeKey] == contentType), + It.Is>(dictionary => !dictionary.Any())), Times.Once); + } + } +} \ No newline at end of file From daa7992d903f196d6399380efd7c6ac55b33fcee Mon Sep 17 00:00:00 2001 From: tr00d Date: Tue, 12 Sep 2023 14:22:20 +0200 Subject: [PATCH 2/2] Move CaptionsEndpoint const --- OpenTok/OpenTok.Captions.cs | 2 + OpenTok/OpenTok.Render.cs | 95 ++++++++++++++++++------------------- 2 files changed, 49 insertions(+), 48 deletions(-) diff --git a/OpenTok/OpenTok.Captions.cs b/OpenTok/OpenTok.Captions.cs index 946d45cf..45c53a4b 100644 --- a/OpenTok/OpenTok.Captions.cs +++ b/OpenTok/OpenTok.Captions.cs @@ -7,6 +7,8 @@ namespace OpenTokSDK { public partial class OpenTok { + private const string CaptionsEndpoint = "/captions"; + /// /// /// diff --git a/OpenTok/OpenTok.Render.cs b/OpenTok/OpenTok.Render.cs index 06536a53..9d02cabe 100644 --- a/OpenTok/OpenTok.Render.cs +++ b/OpenTok/OpenTok.Render.cs @@ -5,70 +5,69 @@ namespace OpenTokSDK { - public partial class OpenTok - { - private const string RenderEndpoint = "/render"; - private const string CaptionsEndpoint = "/captions"; + public partial class OpenTok + { + private const string RenderEndpoint = "/render"; /// - /// Starts a new Experience Composer renderer for an OpenTok session. - /// - /// - /// For more information, see the . - /// Experience Composer developer guide. - /// - /// The rendering request. - /// The generated rendering. - public async Task StartRenderAsync(StartRenderRequest request) - { - var response = await this.Client.PostAsync( - this.BuildUrl(RenderEndpoint), - GetHeaderDictionary("application/json"), - request.ToDataDictionary()); - return JsonConvert.DeserializeObject(response); - } - - /// - /// Stops an Experience Composer renderer. + /// Retrieves an Experience Composer renderer. /// /// The Id of the rendering. - public async Task StopRenderAsync(string renderId) => - await this.Client.DeleteAsync( - this.BuildUrlWithRouteParameter(RenderEndpoint, renderId), - new Dictionary()); + public async Task GetRenderAsync(string renderId) + { + var url = this.BuildUrlWithRouteParameter(RenderEndpoint, renderId); + var response = await this.Client.GetAsync(url); + return JsonConvert.DeserializeObject(response); + } /// - /// Retrieves all Experience Composer renderers matching the provided request. + /// Retrieves all Experience Composer renderers matching the provided request. /// /// The request containing filtering options. /// The list of rendering. public async Task ListRendersAsync(ListRendersRequest request) - { - var url = this.BuildUrlWithQueryParameter(RenderEndpoint, request.ToQueryParameters()); - var response = await this.Client.GetAsync(url); - return JsonConvert.DeserializeObject(response); - } + { + var url = this.BuildUrlWithQueryParameter(RenderEndpoint, request.ToQueryParameters()); + var response = await this.Client.GetAsync(url); + return JsonConvert.DeserializeObject(response); + } /// - /// Retrieves an Experience Composer renderer. + /// Starts a new Experience Composer renderer for an OpenTok session. + /// + /// + /// For more information, see the . + /// Experience Composer developer guide. + /// + /// The rendering request. + /// The generated rendering. + public async Task StartRenderAsync(StartRenderRequest request) + { + var response = await this.Client.PostAsync( + this.BuildUrl(RenderEndpoint), + GetHeaderDictionary("application/json"), + request.ToDataDictionary()); + return JsonConvert.DeserializeObject(response); + } + + /// + /// Stops an Experience Composer renderer. /// /// The Id of the rendering. - public async Task GetRenderAsync(string renderId) - { - var url = this.BuildUrlWithRouteParameter(RenderEndpoint, renderId); - var response = await this.Client.GetAsync(url); - return JsonConvert.DeserializeObject(response); - } + public async Task StopRenderAsync(string renderId) => + await this.Client.DeleteAsync( + this.BuildUrlWithRouteParameter(RenderEndpoint, renderId), + new Dictionary()); - private string BuildUrl(string endpoint) => $"v2/project/{this.ApiKey}{endpoint}"; + private string BuildUrl(string endpoint) => $"v2/project/{this.ApiKey}{endpoint}"; - private string BuildUrlWithRouteParameter(string endpoint, string routeParameter) => - $"{this.BuildUrl(endpoint)}/{routeParameter}"; + private string BuildUrlWithQueryParameter(string endpoint, string queryParameter) => + $"{this.BuildUrl(endpoint)}?{queryParameter}"; - private string BuildUrlWithQueryParameter(string endpoint, string queryParameter) => - $"{this.BuildUrl(endpoint)}?{queryParameter}"; + private string BuildUrlWithRouteParameter(string endpoint, string routeParameter) => + $"{this.BuildUrl(endpoint)}/{routeParameter}"; - private static Dictionary GetHeaderDictionary(string contentType) => - new Dictionary {{"Content-Type", contentType}}; - } + private static Dictionary GetHeaderDictionary(string contentType) => + new Dictionary {{"Content-Type", contentType}}; + } } \ No newline at end of file