Skip to content

Commit

Permalink
Merge pull request #214 from opentok/add-audio-streamer
Browse files Browse the repository at this point in the history
Add Audio Connector API
  • Loading branch information
maxkahan authored Mar 14, 2023
2 parents cd28a0b + b8b2704 commit 4b4d82f
Show file tree
Hide file tree
Showing 10 changed files with 269 additions and 9 deletions.
2 changes: 1 addition & 1 deletion .bumpversion.cfg
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[bumpversion]
current_version = 3.3.0
current_version = 3.4.0
commit = True
tag = False

Expand Down
4 changes: 4 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
# Release v3.4.0

- Support for Audio Connector API via `connect_audio_to_websocket` method

# Release v3.3.0

- Support for Experience Composer (Render) API
Expand Down
27 changes: 27 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -551,6 +551,33 @@ For more information about OpenTok live streaming broadcasts, see the
`Broadcast developer guide <https://tokbox.com/developer/guides/broadcast/>`_.


Connecting audio to a WebSocket
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
You can send audio to a WebSocket with the ``opentok.connect_audio_to_websocket`` method.
For more information, see the
`Audio Connector developer guide <https://tokbox.com/developer/guides/audio-connector/>`_.

.. code:: python
websocket_options = {"uri": "wss://service.com/ws-endpoint"}
websocket_audio_connection = opentok.connect_audio_to_websocket(session_id, opentok_token, websocket_options)
Additionally, you can list only the specific streams you want to send to the WebSocket, and/or the additional headers that are sent,
by adding these fields to the ``websocket_options`` object.

.. code:: python
websocket_options = {
"uri": "wss://service.com/ws-endpoint",
"streams": [
"streamId-1",
"streamId-2"
],
"headers": {
"headerKey": "headerValue"
}
}
Configuring Timeout
-------------------
Timeout is passed in the Client constructor:
Expand Down
1 change: 1 addition & 0 deletions opentok/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@
from .sip_call import SipCall
from .broadcast import Broadcast, BroadcastStreamModes
from .render import Render, RenderList
from .websocket_audio_connection import WebSocketAudioConnection
6 changes: 6 additions & 0 deletions opentok/endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,3 +208,9 @@ def get_render_url(self, render_id: str = None):
url += "/" + render_id

return url

def get_audio_connector_url(self):
"""Returns URLs for working with the Audio Connector API."""
url = self.api_url + "/v2/project/" + self.api_key + "/connect"

return url
14 changes: 12 additions & 2 deletions opentok/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,12 +107,22 @@ class BroadcastStreamModeError(OpenTokException):
Indicates that the broadcast is configured with a streamMode that does not support stream manipulation.
"""

pass


class BroadcastHLSOptionsError(OpenTokException):
"""
Indicates that HLS options have been set incorrectly.
dvr and lowLatency modes cannot both be set to true in a broadcast.
"""


class InvalidWebSocketOptionsError(OpenTokException):
"""
Indicates that the WebSocket options selected are invalid.
"""


class InvalidMediaModeError(OpenTokException):
"""
Indicates that the media mode selected was not valid for the type of request made.
"""
68 changes: 63 additions & 5 deletions opentok/opentok.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
import random # _create_jwt_auth_header
import logging # logging
import warnings # Native. Used for notifying deprecations
import os


# compat
Expand All @@ -33,6 +32,7 @@
from .streamlist import StreamList
from .sip_call import SipCall
from .broadcast import Broadcast, BroadcastStreamModes
from .websocket_audio_connection import WebSocketAudioConnection
from .exceptions import (
ArchiveStreamModeError,
BroadcastHLSOptionsError,
Expand All @@ -48,8 +48,9 @@
SipDialError,
SetStreamClassError,
BroadcastError,
DTMFError

DTMFError,
InvalidWebSocketOptionsError,
InvalidMediaModeError
)


Expand Down Expand Up @@ -1653,7 +1654,7 @@ def start_render(self, session_id, opentok_token, url, max_duration=7200, resolu
`Experience Composer developer guide <https://tokbox.com/developer/guides/experience-composer>`_.
:param String 'session_id': The session ID of the OpenTok session that will include the Experience Composer stream.
:param String 'token': A valid OpenTok token with a Publisher role and (optionally) connection data to be associated with the output stream.
:param String 'opentok_token': A valid OpenTok token with a Publisher role and (optionally) connection data to be associated with the output stream.
:param String 'url': A publically reachable URL controlled by the customer and capable of generating the content to be rendered without user intervention.
:param Integer 'maxDuration' Optional: The maximum time allowed for the Experience Composer, in seconds. After this time, it is stopped automatically, if it is still running. The maximum value is 36000 (10 hours), the minimum value is 60 (1 minute), and the default value is 7200 (2 hours). When the Experience Composer ends, its stream is unpublished and an event is posted to the callback URL, if configured in the Account Portal.
:param String 'resolution' Optional: The resolution of the Experience Composer, either "640x480" (SD landscape), "480x640" (SD portrait), "1280x720" (HD landscape), "720x1280" (HD portrait), "1920x1080" (FHD landscape), or "1080x1920" (FHD portrait). By default, this resolution is "1280x720" (HD landscape, the default).
Expand Down Expand Up @@ -1699,7 +1700,6 @@ def start_render(self, session_id, opentok_token, url, max_duration=7200, resolu
else:
raise RequestError("An unexpected error occurred", response.status_code)


def get_render(self, render_id):
"""
This method allows you to see the status of a render, which can be one of the following:
Expand Down Expand Up @@ -1804,6 +1804,64 @@ def list_renders(self, offset=0, count=50):
else:
raise RequestError("An unexpected error occurred", response.status_code)

def connect_audio_to_websocket(self, session_id: str, opentok_token: str, websocket_options: dict):
"""
Connects audio streams to a specified WebSocket URI.
For more information, see the `Audio Connector developer guide <https://tokbox.com/developer/guides/audio-streamer/>`.
:param String 'session_id': The OpenTok session ID that includes the OpenTok streams you want to include in the WebSocket stream. The Audio Connector feature is only supported in routed sessions (sessions that use the OpenTok Media Router).
:param String 'opentok_token': The OpenTok token to be used for the Audio Connector connection to the OpenTok session.
:param Dictionary 'websocket_options': Included options for the WebSocket.
String 'uri': A publicly reachable WebSocket URI to be used for the destination of the audio stream (such as "wss://example.com/ws-endpoint").
List 'streams' Optional: A list of stream IDs for the OpenTok streams you want to include in the WebSocket audio. If you omit this property, all streams in the session will be included.
Dictionary 'headers' Optional: An object of key-value pairs of headers to be sent to your WebSocket server with each message, with a maximum length of 512 bytes.
"""
self.validate_websocket_options(websocket_options)

payload = {
"sessionId": session_id,
"token": opentok_token,
"websocket": websocket_options
}

logger.debug(
"POST to %r with params %r, headers %r, proxies %r",
self.endpoints.get_audio_connector_url(),
json.dumps(payload),
self.get_json_headers(),
self.proxies,
)

response = requests.post(
self.endpoints.get_audio_connector_url(),
json=payload,
headers=self.get_json_headers(),
proxies=self.proxies,
timeout=self.timeout,
)

if response and response.status_code == 200:
return WebSocketAudioConnection(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 pass 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 token.")
elif response.status_code == 409:
raise InvalidMediaModeError("Only routed sessions are allowed to initiate Audio Connector WebSocket connections.")
else:
raise RequestError("An unexpected error occurred", response.status_code)

def validate_websocket_options(self, options):
if type(options) is not dict:
raise InvalidWebSocketOptionsError('Must pass WebSocket options as a dictionary.')
if 'uri' not in options:
raise InvalidWebSocketOptionsError('Provide a WebSocket URI.')

def _sign_string(self, string, secret):
return hmac.new(
secret.encode("utf-8"), string.encode("utf-8"), hashlib.sha1
Expand Down
2 changes: 1 addition & 1 deletion opentok/version.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
# see: http://legacy.python.org/dev/peps/pep-0440/#public-version-identifiers
__version__ = "3.3.0"
__version__ = "3.4.0"

19 changes: 19 additions & 0 deletions opentok/websocket_audio_connection.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import json
from six import iteritems

class WebSocketAudioConnection:
"""Represents information about the audio connection of an OpenTok session to a WebSocket."""

def __init__(self, kwargs):
self.id = kwargs.get("id")
self.connectionId = kwargs.get("connectionId")

def json(self):
"""Returns a JSON representation of the WebSocket audio connection information."""
return json.dumps(self, default=lambda o: o.__dict__, sort_keys=True, indent=4)

def attrs(self):
"""
Returns a dictionary of the WebSocket audio connection's attributes.
"""
return dict((k, v) for k, v in iteritems(self.__dict__))
135 changes: 135 additions & 0 deletions tests/test_audio_connector.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import unittest
import textwrap
import httpretty
import json
from sure import expect

from six import u, PY2, PY3
from expects import *
from opentok import Client, WebSocketAudioConnection, __version__
from opentok.exceptions import InvalidWebSocketOptionsError, InvalidMediaModeError
from .validate_jwt import validate_jwt_header


class OpenTokAudioConnectorLiteTest(unittest.TestCase):
def setUp(self):
self.api_key = u("123456")
self.api_secret = u("1234567890abcdef1234567890abcdef1234567890")
self.opentok = Client(self.api_key, self.api_secret)
self.session_id = u("2_MX4xMDBfjE0Mzc2NzY1NDgwMTJ-TjMzfn4")
self.token = u("1234-5678-9012")
self.response_body = textwrap.dedent(
u(
""" \
{
"id": "b0a5a8c7-dc38-459f-a48d-a7f2008da853",
"connectionId": "e9f8c166-6c67-440d-994a-04fb6dfed007"
}
"""
)
)

@httpretty.activate
def test_connect_audio_to_websocket(self):
httpretty.register_uri(
httpretty.POST,
u(f"https://api.opentok.com/v2/project/{self.api_key}/connect"),
body=self.response_body,
status=200,
content_type=u("application/json"),
)

websocket_options = {"uri": "wss://service.com/ws-endpoint"}

websocket_audio_connection = self.opentok.connect_audio_to_websocket(
self.session_id, self.token, websocket_options
)

validate_jwt_header(self, httpretty.last_request().headers[u("x-opentok-auth")])
expect(httpretty.last_request().headers[u("user-agent")]).to(contain(u("OpenTok-Python-SDK/") + __version__))
expect(httpretty.last_request().headers[u("content-type")]).to(equal(u("application/json")))
# non-deterministic json encoding. have to decode to test it properly
if PY2:
body = json.loads(httpretty.last_request().body)
if PY3:
body = json.loads(httpretty.last_request().body.decode("utf-8"))

expect(body).to(have_key(u("token")))
expect(websocket_audio_connection).to(be_a(WebSocketAudioConnection))
expect(websocket_audio_connection).to(have_property(u("id"), u("b0a5a8c7-dc38-459f-a48d-a7f2008da853")))
expect(websocket_audio_connection).to(
have_property(u("connectionId"), u("e9f8c166-6c67-440d-994a-04fb6dfed007"))
)

@httpretty.activate
def test_connect_audio_to_websocket_custom_options(self):
httpretty.register_uri(
httpretty.POST,
u(f"https://api.opentok.com/v2/project/{self.api_key}/connect"),
body=self.response_body,
status=200,
content_type=u("application/json"),
)

websocket_options = {
"uri": "wss://service.com/ws-endpoint",
"streams": ["stream-id-1", "stream-id-2"],
"headers": {"WebSocketHeader": "Sent via Audio Connector API"},
}

websocket_audio_connection = self.opentok.connect_audio_to_websocket(
self.session_id, self.token, websocket_options
)

validate_jwt_header(self, httpretty.last_request().headers[u("x-opentok-auth")])
expect(httpretty.last_request().headers[u("user-agent")]).to(contain(u("OpenTok-Python-SDK/") + __version__))
expect(httpretty.last_request().headers[u("content-type")]).to(equal(u("application/json")))
# non-deterministic json encoding. have to decode to test it properly
if PY2:
body = json.loads(httpretty.last_request().body)
if PY3:
body = json.loads(httpretty.last_request().body.decode("utf-8"))

expect(body).to(have_key(u("token")))
expect(websocket_audio_connection).to(be_a(WebSocketAudioConnection))
expect(websocket_audio_connection).to(have_property(u("id"), u("b0a5a8c7-dc38-459f-a48d-a7f2008da853")))
expect(websocket_audio_connection).to(
have_property(u("connectionId"), u("e9f8c166-6c67-440d-994a-04fb6dfed007"))
)

@httpretty.activate
def test_connect_audio_to_websocket_media_mode_error(self):
httpretty.register_uri(
httpretty.POST,
u(f"https://api.opentok.com/v2/project/{self.api_key}/connect"),
body={},
status=409,
content_type=u("application/json"),
)

websocket_options = {"uri": "wss://service.com/ws-endpoint"}

session_id = "session-where-mediaMode=relayed-was-selected"

with self.assertRaises(InvalidMediaModeError) as context:
self.opentok.connect_audio_to_websocket(session_id, self.token, websocket_options)
self.assertTrue(
"Only routed sessions are allowed to initiate Audio Connector WebSocket connections."
in str(context.exception)
)

validate_jwt_header(self, httpretty.last_request().headers[u("x-opentok-auth")])
expect(httpretty.last_request().headers[u("user-agent")]).to(contain(u("OpenTok-Python-SDK/") + __version__))
expect(httpretty.last_request().headers[u("content-type")]).to(equal(u("application/json")))

def test_connect_audio_to_websocket_invalid_options_type_error(self):
websocket_options = "wss://service.com/ws-endpoint"
with self.assertRaises(InvalidWebSocketOptionsError) as context:
self.opentok.connect_audio_to_websocket(self.session_id, self.token, websocket_options)
self.assertTrue("Must pass WebSocket options as a dictionary." in str(context.exception))

def test_connect_audio_to_websocket_missing_uri_error(self):
websocket_options = {}
with self.assertRaises(InvalidWebSocketOptionsError) as context:
self.opentok.connect_audio_to_websocket(self.session_id, self.token, websocket_options)
self.assertTrue("Provide a WebSocket URI." in str(context.exception))

0 comments on commit 4b4d82f

Please sign in to comment.