Skip to content

Commit a460134

Browse files
committed
added audio streamer lite functionality and tests
1 parent 8762853 commit a460134

File tree

6 files changed

+254
-5
lines changed

6 files changed

+254
-5
lines changed

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 .webhook_audio_connection import WebhookAudioConnection

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_streamer_url(self):
213+
"""Returns URLs for working with the Audio Streamer (lite) API."""
214+
url = self.api_url + "/v2/project/" + self.api_key + "/connect"
215+
216+
return url

opentok/exceptions.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,3 +116,15 @@ class BroadcastHLSOptionsError(OpenTokException):
116116
117117
dvr and lowLatency modes cannot both be set to true in a broadcast.
118118
"""
119+
120+
121+
class InvalidWebsocketOptionsError(OpenTokException):
122+
"""
123+
Indicates that the websocket options selected are invalid.
124+
"""
125+
126+
127+
class InvalidMediaModeError(OpenTokException):
128+
"""
129+
Indicates that the media mode selected was not valid for the type of request made.
130+
"""

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 .webhook_audio_connection import WebhookAudioConnection
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 stream_audio_to_websocket(self, session_id: str, opentok_token: str, websocket_options: dict):
1808+
"""
1809+
Connects audio streams to a specified webhook URI.
1810+
For more information, see the `Audio Streamer 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 Streamer 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 Streamer 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_streamer_url(),
1830+
json.dumps(payload),
1831+
self.get_json_headers(),
1832+
self.proxies,
1833+
)
1834+
1835+
response = requests.post(
1836+
self.endpoints.get_audio_streamer_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 WebhookAudioConnection(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 Streamer 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 webhook 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/webhook_audio_connection.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import json
2+
from six import iteritems
3+
4+
class WebhookAudioConnection:
5+
"""Represents information about the audio streaming of an OpenTok session to a webhook."""
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 webhook 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 webhook audio connection's attributes.
18+
"""
19+
return dict((k, v) for k, v in iteritems(self.__dict__))
20+

tests/test_audio_streamer.py

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
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, WebhookAudioConnection, __version__
10+
from opentok.exceptions import InvalidWebsocketOptionsError, InvalidMediaModeError
11+
from .validate_jwt import validate_jwt_header
12+
13+
14+
class OpenTokAudioStreamerLiteTest(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_stream_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 = {
43+
"uri": "wss://service.com/ws-endpoint"
44+
}
45+
46+
webhook_audio_connection = self.opentok.stream_audio_to_websocket(self.session_id, self.token, websocket_options)
47+
48+
validate_jwt_header(self, httpretty.last_request().headers[u("x-opentok-auth")])
49+
expect(httpretty.last_request().headers[u("user-agent")]).to(
50+
contain(u("OpenTok-Python-SDK/") + __version__)
51+
)
52+
expect(httpretty.last_request().headers[u("content-type")]).to(
53+
equal(u("application/json"))
54+
55+
)
56+
# non-deterministic json encoding. have to decode to test it properly
57+
if PY2:
58+
body = json.loads(httpretty.last_request().body)
59+
if PY3:
60+
body = json.loads(httpretty.last_request().body.decode("utf-8"))
61+
62+
expect(body).to(have_key(u("token")))
63+
expect(webhook_audio_connection).to(be_a(WebhookAudioConnection))
64+
expect(webhook_audio_connection).to(
65+
have_property(u("id"), u("b0a5a8c7-dc38-459f-a48d-a7f2008da853"))
66+
)
67+
expect(webhook_audio_connection).to(
68+
have_property(u("connectionId"), u("e9f8c166-6c67-440d-994a-04fb6dfed007"))
69+
)
70+
71+
@httpretty.activate
72+
def test_stream_audio_to_websocket_custom_options(self):
73+
httpretty.register_uri(
74+
httpretty.POST,
75+
u(f"https://api.opentok.com/v2/project/{self.api_key}/connect"),
76+
body=self.response_body,
77+
status=200,
78+
content_type=u("application/json"),
79+
)
80+
81+
websocket_options = {
82+
"uri": "wss://service.com/ws-endpoint",
83+
"streams": ["stream-id-1", "stream-id-2"],
84+
"headers": {
85+
"websocketHeader": "Sent via Audio Streamer API"
86+
}
87+
}
88+
89+
webhook_audio_connection = self.opentok.stream_audio_to_websocket(self.session_id, self.token, websocket_options)
90+
91+
validate_jwt_header(self, httpretty.last_request().headers[u("x-opentok-auth")])
92+
expect(httpretty.last_request().headers[u("user-agent")]).to(
93+
contain(u("OpenTok-Python-SDK/") + __version__)
94+
)
95+
expect(httpretty.last_request().headers[u("content-type")]).to(
96+
equal(u("application/json"))
97+
98+
)
99+
# non-deterministic json encoding. have to decode to test it properly
100+
if PY2:
101+
body = json.loads(httpretty.last_request().body)
102+
if PY3:
103+
body = json.loads(httpretty.last_request().body.decode("utf-8"))
104+
105+
expect(body).to(have_key(u("token")))
106+
expect(webhook_audio_connection).to(be_a(WebhookAudioConnection))
107+
expect(webhook_audio_connection).to(
108+
have_property(u("id"), u("b0a5a8c7-dc38-459f-a48d-a7f2008da853"))
109+
)
110+
expect(webhook_audio_connection).to(
111+
have_property(u("connectionId"), u("e9f8c166-6c67-440d-994a-04fb6dfed007"))
112+
)
113+
114+
@httpretty.activate
115+
def test_stream_audio_to_websocket_media_mode_error(self):
116+
httpretty.register_uri(
117+
httpretty.POST,
118+
u(f"https://api.opentok.com/v2/project/{self.api_key}/connect"),
119+
body={},
120+
status=409,
121+
content_type=u("application/json"),
122+
)
123+
124+
websocket_options = {
125+
"uri": "wss://service.com/ws-endpoint"
126+
}
127+
128+
session_id = "session-where-mediaMode=relayed-was-selected"
129+
130+
with self.assertRaises(InvalidMediaModeError) as context:
131+
self.opentok.stream_audio_to_websocket(session_id, self.token, websocket_options)
132+
self.assertTrue("Only routed sessions are allowed to initiate Audio Streamer WebSocket connections." in str(context.exception))
133+
134+
validate_jwt_header(self, httpretty.last_request().headers[u("x-opentok-auth")])
135+
expect(httpretty.last_request().headers[u("user-agent")]).to(
136+
contain(u("OpenTok-Python-SDK/") + __version__)
137+
)
138+
expect(httpretty.last_request().headers[u("content-type")]).to(
139+
equal(u("application/json"))
140+
)
141+
142+
def test_stream_audio_to_websocket_invalid_options_type_error(self):
143+
websocket_options = "wss://service.com/ws-endpoint"
144+
with self.assertRaises(InvalidWebsocketOptionsError) as context:
145+
self.opentok.stream_audio_to_websocket(self.session_id, self.token, websocket_options)
146+
self.assertTrue("Must pass websocket options as a dictionary." in str(context.exception))
147+
148+
def test_stream_audio_to_websocket_missing_uri_error(self):
149+
websocket_options = {}
150+
with self.assertRaises(InvalidWebsocketOptionsError) as context:
151+
self.opentok.stream_audio_to_websocket(self.session_id, self.token, websocket_options)
152+
self.assertTrue("Provide a webhook URI." in str(context.exception))

0 commit comments

Comments
 (0)