Skip to content

Commit 4b4d82f

Browse files
authored
Merge pull request #214 from opentok/add-audio-streamer
Add Audio Connector API
2 parents cd28a0b + b8b2704 commit 4b4d82f

File tree

10 files changed

+269
-9
lines changed

10 files changed

+269
-9
lines changed

.bumpversion.cfg

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[bumpversion]
2-
current_version = 3.3.0
2+
current_version = 3.4.0
33
commit = True
44
tag = False
55

CHANGES.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
# Release v3.4.0
2+
3+
- Support for Audio Connector API via `connect_audio_to_websocket` method
4+
15
# Release v3.3.0
26

37
- Support for Experience Composer (Render) API

README.rst

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -551,6 +551,33 @@ For more information about OpenTok live streaming broadcasts, see the
551551
`Broadcast developer guide <https://tokbox.com/developer/guides/broadcast/>`_.
552552

553553

554+
Connecting audio to a WebSocket
555+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
556+
You can send audio to a WebSocket with the ``opentok.connect_audio_to_websocket`` method.
557+
For more information, see the
558+
`Audio Connector developer guide <https://tokbox.com/developer/guides/audio-connector/>`_.
559+
560+
.. code:: python
561+
562+
websocket_options = {"uri": "wss://service.com/ws-endpoint"}
563+
websocket_audio_connection = opentok.connect_audio_to_websocket(session_id, opentok_token, websocket_options)
564+
565+
Additionally, you can list only the specific streams you want to send to the WebSocket, and/or the additional headers that are sent,
566+
by adding these fields to the ``websocket_options`` object.
567+
568+
.. code:: python
569+
570+
websocket_options = {
571+
"uri": "wss://service.com/ws-endpoint",
572+
"streams": [
573+
"streamId-1",
574+
"streamId-2"
575+
],
576+
"headers": {
577+
"headerKey": "headerValue"
578+
}
579+
}
580+
554581
Configuring Timeout
555582
-------------------
556583
Timeout is passed in the Client constructor:

opentok/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,4 @@
1515
from .sip_call import SipCall
1616
from .broadcast import Broadcast, BroadcastStreamModes
1717
from .render import Render, RenderList
18+
from .websocket_audio_connection import WebSocketAudioConnection

opentok/endpoints.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,3 +208,9 @@ def get_render_url(self, render_id: str = None):
208208
url += "/" + render_id
209209

210210
return url
211+
212+
def get_audio_connector_url(self):
213+
"""Returns URLs for working with the Audio Connector API."""
214+
url = self.api_url + "/v2/project/" + self.api_key + "/connect"
215+
216+
return url

opentok/exceptions.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,12 +107,22 @@ class BroadcastStreamModeError(OpenTokException):
107107
Indicates that the broadcast is configured with a streamMode that does not support stream manipulation.
108108
"""
109109

110-
pass
111-
112110

113111
class BroadcastHLSOptionsError(OpenTokException):
114112
"""
115113
Indicates that HLS options have been set incorrectly.
116114
117115
dvr and lowLatency modes cannot both be set to true in a broadcast.
118116
"""
117+
118+
119+
class InvalidWebSocketOptionsError(OpenTokException):
120+
"""
121+
Indicates that the WebSocket options selected are invalid.
122+
"""
123+
124+
125+
class InvalidMediaModeError(OpenTokException):
126+
"""
127+
Indicates that the media mode selected was not valid for the type of request made.
128+
"""

opentok/opentok.py

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616
import random # _create_jwt_auth_header
1717
import logging # logging
1818
import warnings # Native. Used for notifying deprecations
19-
import os
2019

2120

2221
# compat
@@ -33,6 +32,7 @@
3332
from .streamlist import StreamList
3433
from .sip_call import SipCall
3534
from .broadcast import Broadcast, BroadcastStreamModes
35+
from .websocket_audio_connection import WebSocketAudioConnection
3636
from .exceptions import (
3737
ArchiveStreamModeError,
3838
BroadcastHLSOptionsError,
@@ -48,8 +48,9 @@
4848
SipDialError,
4949
SetStreamClassError,
5050
BroadcastError,
51-
DTMFError
52-
51+
DTMFError,
52+
InvalidWebSocketOptionsError,
53+
InvalidMediaModeError
5354
)
5455

5556

@@ -1653,7 +1654,7 @@ def start_render(self, session_id, opentok_token, url, max_duration=7200, resolu
16531654
`Experience Composer developer guide <https://tokbox.com/developer/guides/experience-composer>`_.
16541655
16551656
:param String 'session_id': The session ID of the OpenTok session that will include the Experience Composer stream.
1656-
:param String 'token': A valid OpenTok token with a Publisher role and (optionally) connection data to be associated with the output stream.
1657+
:param String 'opentok_token': A valid OpenTok token with a Publisher role and (optionally) connection data to be associated with the output stream.
16571658
:param String 'url': A publically reachable URL controlled by the customer and capable of generating the content to be rendered without user intervention.
16581659
: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.
16591660
: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).
@@ -1699,7 +1700,6 @@ def start_render(self, session_id, opentok_token, url, max_duration=7200, resolu
16991700
else:
17001701
raise RequestError("An unexpected error occurred", response.status_code)
17011702

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

1807+
def connect_audio_to_websocket(self, session_id: str, opentok_token: str, websocket_options: dict):
1808+
"""
1809+
Connects audio streams to a specified WebSocket URI.
1810+
For more information, see the `Audio Connector developer guide <https://tokbox.com/developer/guides/audio-streamer/>`.
1811+
1812+
: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).
1813+
:param String 'opentok_token': The OpenTok token to be used for the Audio Connector connection to the OpenTok session.
1814+
:param Dictionary 'websocket_options': Included options for the WebSocket.
1815+
String 'uri': A publicly reachable WebSocket URI to be used for the destination of the audio stream (such as "wss://example.com/ws-endpoint").
1816+
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.
1817+
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.
1818+
"""
1819+
self.validate_websocket_options(websocket_options)
1820+
1821+
payload = {
1822+
"sessionId": session_id,
1823+
"token": opentok_token,
1824+
"websocket": websocket_options
1825+
}
1826+
1827+
logger.debug(
1828+
"POST to %r with params %r, headers %r, proxies %r",
1829+
self.endpoints.get_audio_connector_url(),
1830+
json.dumps(payload),
1831+
self.get_json_headers(),
1832+
self.proxies,
1833+
)
1834+
1835+
response = requests.post(
1836+
self.endpoints.get_audio_connector_url(),
1837+
json=payload,
1838+
headers=self.get_json_headers(),
1839+
proxies=self.proxies,
1840+
timeout=self.timeout,
1841+
)
1842+
1843+
if response and response.status_code == 200:
1844+
return WebSocketAudioConnection(response.json())
1845+
elif response.status_code == 400:
1846+
"""
1847+
The HTTP response has a 400 status code in the following cases:
1848+
You did not pass in a session ID or you pass in an invalid session ID.
1849+
You specified an invalid value for input parameters.
1850+
"""
1851+
raise RequestError(response.json().get("message"))
1852+
elif response.status_code == 403:
1853+
raise AuthError("You passed in an invalid OpenTok API key or JWT token.")
1854+
elif response.status_code == 409:
1855+
raise InvalidMediaModeError("Only routed sessions are allowed to initiate Audio Connector WebSocket connections.")
1856+
else:
1857+
raise RequestError("An unexpected error occurred", response.status_code)
1858+
1859+
def validate_websocket_options(self, options):
1860+
if type(options) is not dict:
1861+
raise InvalidWebSocketOptionsError('Must pass WebSocket options as a dictionary.')
1862+
if 'uri' not in options:
1863+
raise InvalidWebSocketOptionsError('Provide a WebSocket URI.')
1864+
18071865
def _sign_string(self, string, secret):
18081866
return hmac.new(
18091867
secret.encode("utf-8"), string.encode("utf-8"), hashlib.sha1

opentok/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
# see: http://legacy.python.org/dev/peps/pep-0440/#public-version-identifiers
2-
__version__ = "3.3.0"
2+
__version__ = "3.4.0"
33

opentok/websocket_audio_connection.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import json
2+
from six import iteritems
3+
4+
class WebSocketAudioConnection:
5+
"""Represents information about the audio connection of an OpenTok session to a WebSocket."""
6+
7+
def __init__(self, kwargs):
8+
self.id = kwargs.get("id")
9+
self.connectionId = kwargs.get("connectionId")
10+
11+
def json(self):
12+
"""Returns a JSON representation of the WebSocket audio connection information."""
13+
return json.dumps(self, default=lambda o: o.__dict__, sort_keys=True, indent=4)
14+
15+
def attrs(self):
16+
"""
17+
Returns a dictionary of the WebSocket audio connection's attributes.
18+
"""
19+
return dict((k, v) for k, v in iteritems(self.__dict__))

tests/test_audio_connector.py

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import unittest
2+
import textwrap
3+
import httpretty
4+
import json
5+
from sure import expect
6+
7+
from six import u, PY2, PY3
8+
from expects import *
9+
from opentok import Client, WebSocketAudioConnection, __version__
10+
from opentok.exceptions import InvalidWebSocketOptionsError, InvalidMediaModeError
11+
from .validate_jwt import validate_jwt_header
12+
13+
14+
class OpenTokAudioConnectorLiteTest(unittest.TestCase):
15+
def setUp(self):
16+
self.api_key = u("123456")
17+
self.api_secret = u("1234567890abcdef1234567890abcdef1234567890")
18+
self.opentok = Client(self.api_key, self.api_secret)
19+
self.session_id = u("2_MX4xMDBfjE0Mzc2NzY1NDgwMTJ-TjMzfn4")
20+
self.token = u("1234-5678-9012")
21+
self.response_body = textwrap.dedent(
22+
u(
23+
""" \
24+
{
25+
"id": "b0a5a8c7-dc38-459f-a48d-a7f2008da853",
26+
"connectionId": "e9f8c166-6c67-440d-994a-04fb6dfed007"
27+
}
28+
"""
29+
)
30+
)
31+
32+
@httpretty.activate
33+
def test_connect_audio_to_websocket(self):
34+
httpretty.register_uri(
35+
httpretty.POST,
36+
u(f"https://api.opentok.com/v2/project/{self.api_key}/connect"),
37+
body=self.response_body,
38+
status=200,
39+
content_type=u("application/json"),
40+
)
41+
42+
websocket_options = {"uri": "wss://service.com/ws-endpoint"}
43+
44+
websocket_audio_connection = self.opentok.connect_audio_to_websocket(
45+
self.session_id, self.token, websocket_options
46+
)
47+
48+
validate_jwt_header(self, httpretty.last_request().headers[u("x-opentok-auth")])
49+
expect(httpretty.last_request().headers[u("user-agent")]).to(contain(u("OpenTok-Python-SDK/") + __version__))
50+
expect(httpretty.last_request().headers[u("content-type")]).to(equal(u("application/json")))
51+
# non-deterministic json encoding. have to decode to test it properly
52+
if PY2:
53+
body = json.loads(httpretty.last_request().body)
54+
if PY3:
55+
body = json.loads(httpretty.last_request().body.decode("utf-8"))
56+
57+
expect(body).to(have_key(u("token")))
58+
expect(websocket_audio_connection).to(be_a(WebSocketAudioConnection))
59+
expect(websocket_audio_connection).to(have_property(u("id"), u("b0a5a8c7-dc38-459f-a48d-a7f2008da853")))
60+
expect(websocket_audio_connection).to(
61+
have_property(u("connectionId"), u("e9f8c166-6c67-440d-994a-04fb6dfed007"))
62+
)
63+
64+
@httpretty.activate
65+
def test_connect_audio_to_websocket_custom_options(self):
66+
httpretty.register_uri(
67+
httpretty.POST,
68+
u(f"https://api.opentok.com/v2/project/{self.api_key}/connect"),
69+
body=self.response_body,
70+
status=200,
71+
content_type=u("application/json"),
72+
)
73+
74+
websocket_options = {
75+
"uri": "wss://service.com/ws-endpoint",
76+
"streams": ["stream-id-1", "stream-id-2"],
77+
"headers": {"WebSocketHeader": "Sent via Audio Connector API"},
78+
}
79+
80+
websocket_audio_connection = self.opentok.connect_audio_to_websocket(
81+
self.session_id, self.token, websocket_options
82+
)
83+
84+
validate_jwt_header(self, httpretty.last_request().headers[u("x-opentok-auth")])
85+
expect(httpretty.last_request().headers[u("user-agent")]).to(contain(u("OpenTok-Python-SDK/") + __version__))
86+
expect(httpretty.last_request().headers[u("content-type")]).to(equal(u("application/json")))
87+
# non-deterministic json encoding. have to decode to test it properly
88+
if PY2:
89+
body = json.loads(httpretty.last_request().body)
90+
if PY3:
91+
body = json.loads(httpretty.last_request().body.decode("utf-8"))
92+
93+
expect(body).to(have_key(u("token")))
94+
expect(websocket_audio_connection).to(be_a(WebSocketAudioConnection))
95+
expect(websocket_audio_connection).to(have_property(u("id"), u("b0a5a8c7-dc38-459f-a48d-a7f2008da853")))
96+
expect(websocket_audio_connection).to(
97+
have_property(u("connectionId"), u("e9f8c166-6c67-440d-994a-04fb6dfed007"))
98+
)
99+
100+
@httpretty.activate
101+
def test_connect_audio_to_websocket_media_mode_error(self):
102+
httpretty.register_uri(
103+
httpretty.POST,
104+
u(f"https://api.opentok.com/v2/project/{self.api_key}/connect"),
105+
body={},
106+
status=409,
107+
content_type=u("application/json"),
108+
)
109+
110+
websocket_options = {"uri": "wss://service.com/ws-endpoint"}
111+
112+
session_id = "session-where-mediaMode=relayed-was-selected"
113+
114+
with self.assertRaises(InvalidMediaModeError) as context:
115+
self.opentok.connect_audio_to_websocket(session_id, self.token, websocket_options)
116+
self.assertTrue(
117+
"Only routed sessions are allowed to initiate Audio Connector WebSocket connections."
118+
in str(context.exception)
119+
)
120+
121+
validate_jwt_header(self, httpretty.last_request().headers[u("x-opentok-auth")])
122+
expect(httpretty.last_request().headers[u("user-agent")]).to(contain(u("OpenTok-Python-SDK/") + __version__))
123+
expect(httpretty.last_request().headers[u("content-type")]).to(equal(u("application/json")))
124+
125+
def test_connect_audio_to_websocket_invalid_options_type_error(self):
126+
websocket_options = "wss://service.com/ws-endpoint"
127+
with self.assertRaises(InvalidWebSocketOptionsError) as context:
128+
self.opentok.connect_audio_to_websocket(self.session_id, self.token, websocket_options)
129+
self.assertTrue("Must pass WebSocket options as a dictionary." in str(context.exception))
130+
131+
def test_connect_audio_to_websocket_missing_uri_error(self):
132+
websocket_options = {}
133+
with self.assertRaises(InvalidWebSocketOptionsError) as context:
134+
self.opentok.connect_audio_to_websocket(self.session_id, self.token, websocket_options)
135+
self.assertTrue("Provide a WebSocket URI." in str(context.exception))

0 commit comments

Comments
 (0)