From 9ea4d6134780c945d6c36192dac74bcd80c70a34 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Thu, 7 Sep 2023 03:05:12 +0100 Subject: [PATCH 1/3] add captions API --- CHANGES.md | 3 ++ README.rst | 30 +++++++++++ opentok/captions.py | 12 +++++ opentok/endpoints.py | 16 ++++-- opentok/exceptions.py | 36 ++++--------- opentok/opentok.py | 112 ++++++++++++++++++++++++++++++++++++++--- tests/test_captions.py | 69 +++++++++++++++++++++++++ 7 files changed, 241 insertions(+), 37 deletions(-) create mode 100644 opentok/captions.py create mode 100644 tests/test_captions.py diff --git a/CHANGES.md b/CHANGES.md index 336c90e..34b6108 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,6 @@ +# Release v3.8.0 +- Added support for the [Captions API](https://tokbox.com/developer/guides/live-captions/) + # Release v3.7.1 - Fixed an issue with end-to-end encryption not being called correctly when creating a new session diff --git a/README.rst b/README.rst index ba7ca91..8adf8e7 100644 --- a/README.rst +++ b/README.rst @@ -640,6 +640,36 @@ by adding these fields to the ``websocket_options`` object. } } + +Using the Live Captions API +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +You can enable live captioning for an OpenTok session with the ``opentok.start_captions`` method. +For more information, see the +`Live Captions API developer guide `. + +.. code:: python + + captions = opentok.start_captions(session_id, opentok_token) + +You can also specify optional parameters, as shown below. + +.. code:: python + + captions = opentok.start_captions( + session_id, + opentok_token, + language_code='en-GB', + max_duration=10000, + partial_captions=False, + status_callback_url='https://example.com', + ) + +You can stop an ongoing live captioning session by calling the ``opentok.stop_captions`` method. + +.. code:: python + + opentok.stop_captions(captions_id) + Configuring Timeout ------------------- Timeout is passed in the Client constructor: diff --git a/opentok/captions.py b/opentok/captions.py new file mode 100644 index 0000000..dd1a041 --- /dev/null +++ b/opentok/captions.py @@ -0,0 +1,12 @@ +import json + + +class Captions: + """Represents information about a captioning session.""" + + def __init__(self, kwargs): + self.captions_id = kwargs.get("captionsId") + + def json(self): + """Returns a JSON representation of the captioning session information.""" + return json.dumps(self, default=lambda o: o.__dict__, sort_keys=True, indent=4) diff --git a/opentok/endpoints.py b/opentok/endpoints.py index ab5bfa5..78b0470 100644 --- a/opentok/endpoints.py +++ b/opentok/endpoints.py @@ -174,7 +174,7 @@ def get_dtmf_specific_url(self, session_id, connection_id): return url def get_archive_stream(self, archive_id=None): - """this method returns urls for working with streamModes in archives""" + """this method returns the url for working with streamModes in archives""" url = ( self.api_url + "/v2/project/" @@ -187,7 +187,7 @@ def get_archive_stream(self, archive_id=None): return url def get_broadcast_stream(self, broadcast_id=None): - """this method returns urls for working with streamModes in broadcasts""" + """this method returns the url for working with streamModes in broadcasts""" url = ( self.api_url + "/v2/project/" @@ -200,7 +200,7 @@ def get_broadcast_stream(self, broadcast_id=None): return url def get_render_url(self, render_id: str = None): - "Returns URLs for working with the Render API." "" + "Returns the URL for working with the Render API." "" url = self.api_url + "/v2/project/" + self.api_key + "/render" if render_id: url += "/" + render_id @@ -208,7 +208,15 @@ def get_render_url(self, render_id: str = None): return url def get_audio_connector_url(self): - """Returns URLs for working with the Audio Connector API.""" + """Returns the URL for working with the Audio Connector API.""" url = self.api_url + "/v2/project/" + self.api_key + "/connect" return url + + def get_captions_url(self, captions_id: str = None): + """Returns the URL for working with the Captions API.""" + url = self.api_url + '/v2/project/' + self.api_key + '/captions' + if captions_id: + url += f'/{captions_id}/stop' + + return url diff --git a/opentok/exceptions.py b/opentok/exceptions.py index bece5af..f5eb909 100644 --- a/opentok/exceptions.py +++ b/opentok/exceptions.py @@ -1,54 +1,40 @@ class OpenTokException(Exception): """Defines exceptions thrown by the OpenTok SDK.""" - pass - class RequestError(OpenTokException): """Indicates an error during the request. Most likely an error connecting to the OpenTok API servers. (HTTP 500 error). """ - pass - class AuthError(OpenTokException): """Indicates that the problem was likely with credentials. Check your API key and API secret and try again. """ - pass - class NotFoundError(OpenTokException): """Indicates that the element requested was not found. Check the parameters of the request. """ - pass - class ArchiveError(OpenTokException): """Indicates that there was a archive specific problem, probably the status of the requested archive is invalid. """ - pass - class SignalingError(OpenTokException): """Indicates that there was a signaling specific problem, one of the parameter is invalid or the type|data string doesn't have a correct size""" - pass - class GetStreamError(OpenTokException): """Indicates that the data in the request is invalid, or the session_id or stream_id are invalid""" - pass - class ForceDisconnectError(OpenTokException): """ @@ -57,38 +43,30 @@ class ForceDisconnectError(OpenTokException): is not connected to the session """ - pass - class SipDialError(OpenTokException): """ Indicates that there was a SIP dial specific problem: - The Session ID passed in is invalid or you attempt to start a SIP call for a session + The Session ID ed in is invalid or you attempt to start a SIP call for a session that does not use the OpenTok Media Router. """ - pass - class SetStreamClassError(OpenTokException): """ Indicates that there is invalid data in the JSON request. - It may also indicate that invalid layout options have been passed + It may also indicate that invalid layout options have been ed """ - pass - class BroadcastError(OpenTokException): """ Indicates that data in your request data is invalid JSON. It may also indicate - that you passed in invalid layout options. Or you have exceeded the limit of five + that you ed in invalid layout options. Or you have exceeded the limit of five simultaneous RTMP streams for an OpenTok session. Or you specified and invalid resolution. Or The broadcast has already started for the session """ - pass - class DTMFError(OpenTokException): """ @@ -101,8 +79,6 @@ class ArchiveStreamModeError(OpenTokException): Indicates that the archive is configured with a streamMode that does not support stream manipulation. """ - pass - class BroadcastStreamModeError(OpenTokException): """ @@ -134,3 +110,9 @@ class InvalidMediaModeError(OpenTokException): """ Indicates that the media mode selected was not valid for the type of request made. """ + + +class CaptioningAlreadyInProgressError(OpenTokException): + """ + Indicates that captioning was requested for an OpenTok session where live captions have already started. + """ diff --git a/opentok/opentok.py b/opentok/opentok.py index 554cbd9..a6e2df6 100644 --- a/opentok/opentok.py +++ b/opentok/opentok.py @@ -1,4 +1,5 @@ -from datetime import datetime # generate_token +from datetime import datetime +from functools import partial # generate_token from typing import List, Optional # imports List, Optional type hint import calendar # generate_token import base64 # generate_token @@ -27,6 +28,7 @@ from .endpoints import Endpoints from .session import Session from .archives import Archive, ArchiveList, OutputModes, StreamModes +from .captions import Captions from .render import Render, RenderList from .stream import Stream from .streamlist import StreamList @@ -52,6 +54,7 @@ DTMFError, InvalidWebSocketOptionsError, InvalidMediaModeError, + CaptioningAlreadyInProgressError, ) @@ -1730,7 +1733,6 @@ def start_render( url, max_duration=7200, resolution="1280x720", - status_callback_url=None, properties: dict = None, ): """ @@ -1776,7 +1778,7 @@ def start_render( elif response.status_code == 400: """ The HTTP response has a 400 status code in the following cases: - You do not pass in a session ID or you pass in an invalid session ID. + You did not pass in a session ID or you passed in an invalid session ID. You specify an invalid value for input parameters. """ raise RequestError(response.json().get("message")) @@ -1810,7 +1812,7 @@ def get_render(self, render_id): return Render(response.json()) elif response.status_code == 400: raise RequestError( - "Invalid request. This response may indicate that data in your request is invalid JSON. Or it may indicate that you do not pass in a session ID." + "Invalid request. This response may indicate that data in your request is invalid JSON. Or it may indicate that you did not pass in a session ID." ) elif response.status_code == 403: raise AuthError("You passed in an invalid OpenTok API key or JWT token.") @@ -1843,7 +1845,7 @@ def stop_render(self, render_id): return response elif response.status_code == 400: raise RequestError( - "Invalid request. This response may indicate that data in your request is invalid JSON. Or it may indicate that you do not pass in a session ID." + "Invalid request. This response may indicate that data in your request is invalid JSON. Or it may indicate that you did not pass in a session ID." ) elif response.status_code == 403: raise AuthError("You passed in an invalid OpenTok API key or JWT token.") @@ -1932,7 +1934,7 @@ def connect_audio_to_websocket( elif response.status_code == 400: """ The HTTP response has a 400 status code in the following cases: - You did not pass in a session ID or you pass in an invalid session ID. + You did not pass in a session ID or you passed in an invalid session ID. You specified an invalid value for input parameters. """ raise RequestError(response.json().get("message")) @@ -1953,6 +1955,104 @@ def validate_websocket_options(self, options): if "uri" not in options: raise InvalidWebSocketOptionsError("Provide a WebSocket URI.") + def start_captions( + self, + session_id: str, + opentok_token: str, + language_code: str = "en-US", + max_duration: int = 14400, + partial_captions: bool = True, + status_callback_url: str = None, + ): + """ + Starts real-time Live Captions for an OpenTok Session. The maximum allowed duration is 4 hours, after which the audio + captioning will stop without any effect on the ongoing OpenTok Session. + An event will be posted to your callback URL if provided when starting the captions. + + Each OpenTok Session supports only one audio captioning session. For more information about the Live Captions feature, + see the Live Captions developer guide . + + :param String 'session_id': The OpenTok session ID. The audio from participants publishing into this session will be used to generate the captions. + :param String 'opentok_token': A valid OpenTok token with role set to Moderator. + :param String 'language_code' Optional: The BCP-47 code for a spoken language used on this call. + :param Integer 'max_duration' Optional: The maximum duration for the audio captioning, in seconds. + :param Boolean 'partial_captions' Optional: Whether to enable this to faster captioning at the cost of some inaccuracies. + :param String 'status_callback_url' Optional: 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. + """ + + payload = { + "sessionId": session_id, + "token": opentok_token, + "languageCode": language_code, + "maxDuration": max_duration, + "partialCaptions": partial_captions, + "statusCallbackUrl": status_callback_url, + } + + logger.debug( + "POST to %r with params %r, headers %r, proxies %r", + self.endpoints.get_captions_url(), + json.dumps(payload), + self.get_json_headers(), + self.proxies, + ) + + response = requests.post( + self.endpoints.get_captions_url(), + json=payload, + headers=self.get_json_headers(), + proxies=self.proxies, + timeout=self.timeout, + ) + + if response and response.status_code == 200: + return Captions(response.json()) + elif response.status_code == 400: + """ + The HTTP response has a 400 status code in the following cases: + You did not pass in a session ID or you passed in an invalid session ID. + You specified an invalid value for input parameters. + """ + raise RequestError(response.json().get("message")) + elif response.status_code == 403: + raise AuthError("You passed in an invalid OpenTok API key or JWT.") + elif response.status_code == 409: + raise CaptioningAlreadyInProgressError( + "Live captions have already started for this OpenTok Session." + ) + else: + raise RequestError("An unexpected error occurred", response.status_code) + + def stop_captions(self, captions_id: str): + """ + Stops live captioning for the specified captioning session. + + :param String captions_id: The ID of the captioning session to stop. + """ + + logger.debug( + "POST to %r with headers %r, proxies %r", + self.endpoints.get_captions_url(captions_id), + self.get_json_headers(), + self.proxies, + ) + + response = requests.post( + self.endpoints.get_captions_url(captions_id), + headers=self.get_json_headers(), + proxies=self.proxies, + timeout=self.timeout, + ) + + if response and response.status_code == 202: + return None + elif response.status_code == 403: + raise AuthError("You passed in an invalid OpenTok API key or JWT.") + elif response.status_code == 404: + raise NotFoundError("No matching captionsId was found.") + else: + raise RequestError("An unexpected error occurred", response.status_code) + def _sign_string(self, string, secret): return hmac.new( secret.encode("utf-8"), string.encode("utf-8"), hashlib.sha1 diff --git a/tests/test_captions.py b/tests/test_captions.py new file mode 100644 index 0000000..ab80ee6 --- /dev/null +++ b/tests/test_captions.py @@ -0,0 +1,69 @@ +import unittest +import textwrap +import httpretty +import json +from sure import expect + +from expects import * +from opentok import Client, __version__ +from opentok.captions import Captions + + +class OpenTokCaptionsTest(unittest.TestCase): + def setUp(self): + self.api_key = "123456" + self.api_secret = "1234567890abcdef1234567890abcdef1234567890" + self.opentok = Client(self.api_key, self.api_secret) + self.session_id = "2_MX4xMDBfjE0Mzc2NzY1NDgwMTJ-TjMzfn4" + self.token = "1234-5678-9012" + self.response_body = textwrap.dedent( + """ \ + { + "captionsId": "7c0680fc-6274-4de5-a66f-d0648e8d3ac2" + } + """ + ) + + @httpretty.activate + def test_start_captions(self): + httpretty.register_uri( + httpretty.POST, + f"https://api.opentok.com/v2/project/{self.api_key}/captions", + body=self.response_body, + status=200, + content_type="application/json", + ) + + captions = self.opentok.start_captions( + self.session_id, + self.token, + language_code='en-GB', + max_duration=10000, + partial_captions=False, + status_callback_url='https://example.com', + ) + + body = json.loads(httpretty.last_request().body.decode("utf-8")) + expect(body).to(have_key("token")) + + expect(captions).to(be_a(Captions)) + expect(captions).to( + have_property("captions_id", "7c0680fc-6274-4de5-a66f-d0648e8d3ac2") + ) + + @httpretty.activate + def test_stop_captions(self): + httpretty.register_uri( + httpretty.POST, + f"https://api.opentok.com/v2/project/{self.api_key}/captions/7c0680fc-6274-4de5-a66f-d0648e8d3ac2/stop", + status=202, + content_type="application/json", + ) + + self.opentok.stop_captions(captions_id='7c0680fc-6274-4de5-a66f-d0648e8d3ac2') + + request_url = httpretty.last_request().url + assert ( + request_url + == 'https://api.opentok.com/v2/project/123456/captions/7c0680fc-6274-4de5-a66f-d0648e8d3ac2/stop' + ) From 5462c8e1cac2d497ef390ebff39d736309a9decf Mon Sep 17 00:00:00 2001 From: maxkahan Date: Thu, 7 Sep 2023 03:06:04 +0100 Subject: [PATCH 2/3] =?UTF-8?q?Bump=20version:=203.7.1=20=E2=86=92=203.8.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- opentok/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index cf74125..7b9455b 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 3.7.1 +current_version = 3.8.0 commit = True tag = False diff --git a/opentok/version.py b/opentok/version.py index cb29373..c89f75e 100644 --- a/opentok/version.py +++ b/opentok/version.py @@ -1,3 +1,3 @@ # see: http://legacy.python.org/dev/peps/pep-0440/#public-version-identifiers -__version__ = "3.7.1" +__version__ = "3.8.0" From d28fc9a5c88058ec79a7b697eed15115e8258f0e Mon Sep 17 00:00:00 2001 From: maxkahan Date: Thu, 7 Sep 2023 03:17:10 +0100 Subject: [PATCH 3/3] fixing typos --- opentok/exceptions.py | 6 +++--- opentok/opentok.py | 3 +-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/opentok/exceptions.py b/opentok/exceptions.py index f5eb909..ae30026 100644 --- a/opentok/exceptions.py +++ b/opentok/exceptions.py @@ -47,7 +47,7 @@ class ForceDisconnectError(OpenTokException): class SipDialError(OpenTokException): """ Indicates that there was a SIP dial specific problem: - The Session ID ed in is invalid or you attempt to start a SIP call for a session + The Session ID passed in is invalid or you attempt to start a SIP call for a session that does not use the OpenTok Media Router. """ @@ -55,14 +55,14 @@ class SipDialError(OpenTokException): class SetStreamClassError(OpenTokException): """ Indicates that there is invalid data in the JSON request. - It may also indicate that invalid layout options have been ed + It may also indicate that invalid layout options have been passed. """ class BroadcastError(OpenTokException): """ Indicates that data in your request data is invalid JSON. It may also indicate - that you ed in invalid layout options. Or you have exceeded the limit of five + that you passed in invalid layout options. Or you have exceeded the limit of five simultaneous RTMP streams for an OpenTok session. Or you specified and invalid resolution. Or The broadcast has already started for the session """ diff --git a/opentok/opentok.py b/opentok/opentok.py index a6e2df6..61bdf65 100644 --- a/opentok/opentok.py +++ b/opentok/opentok.py @@ -1,5 +1,4 @@ -from datetime import datetime -from functools import partial # generate_token +from datetime import datetime # generate_token from typing import List, Optional # imports List, Optional type hint import calendar # generate_token import base64 # generate_token