Skip to content

Commit c05e0e9

Browse files
Ikumarapeli/recording downloader (Azure#29604)
* download and delete recording methods * lint fixes * lint fixes * review comments * fix test cases
1 parent 4e1d96d commit c05e0e9

File tree

5 files changed

+391
-9
lines changed

5 files changed

+391
-9
lines changed

sdk/communication/azure-communication-callautomation/azure/communication/callautomation/_call_recording_client.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,11 @@
44
# license information.
55
# --------------------------------------------------------------------------
66

7+
from io import BytesIO
78
from typing import TYPE_CHECKING # pylint: disable=unused-import
9+
from azure.core.pipeline.transport import HttpResponse
810
from ._models import RecordingStateResponse, StartRecordingOptions
11+
from ._content_downloader import ContentDownloader
912

1013
from ._generated.operations import CallRecordingOperations
1114

@@ -20,6 +23,7 @@ def __init__(# pylint: disable=missing-client-constructor-parameter-credential,
2023
call_recording_client: CallRecordingOperations
2124
) -> None:
2225
self._call_recording_client = call_recording_client
26+
self._downloader = ContentDownloader(call_recording_client)
2327

2428
def start_recording(
2529
self,
@@ -126,3 +130,96 @@ def get_recording_properties(
126130
recording_id = recording_id, **kwargs)
127131
return RecordingStateResponse._from_generated(# pylint:disable=protected-access
128132
recording_state_response)
133+
134+
def download_streaming(
135+
self,
136+
source_location: str,
137+
offset: int = None,
138+
length: int = None,
139+
**kwargs
140+
) -> HttpResponse:
141+
"""Download a stream of the call recording.
142+
143+
:param source_location: The source location. Required.
144+
:type source_location: str
145+
:param offset: Offset byte. Not required.
146+
:type offset: int
147+
:param length: how many bytes. Not required.
148+
:type length: int
149+
:return: HttpResponse (octet-stream)
150+
:rtype: HttpResponse (octet-stream)
151+
"""
152+
stream = self._downloader.download_streaming(
153+
source_location = source_location,
154+
offset = offset,
155+
length = length,
156+
**kwargs
157+
)
158+
return stream
159+
160+
def delete_recording(
161+
self,
162+
recording_location: str,
163+
**kwargs
164+
) -> None:
165+
"""Delete a call recording.
166+
167+
:param recording_location: The recording location. Required.
168+
:type recording_location: str
169+
"""
170+
self._downloader.delete_recording(recording_location = recording_location, **kwargs)
171+
172+
def download_to_path(
173+
self,
174+
source_location: str,
175+
destination_path: str,
176+
offset: int = None,
177+
length: int = None,
178+
**kwargs
179+
) -> None:
180+
"""Download a stream of the call recording to the destination.
181+
182+
:param source_location: The source location uri. Required.
183+
:type source_location: str
184+
:param destination_path: The destination path. Required.
185+
:type destination_path: str
186+
:param offset: Offset byte. Not required.
187+
:type offset: int
188+
:param length: how many bytes. Not required.
189+
:type length: int
190+
"""
191+
stream = self._downloader.download_streaming(source_location = source_location,
192+
offset = offset,
193+
length = length,
194+
**kwargs
195+
)
196+
with open(destination_path, 'wb') as writer:
197+
writer.write(stream.read())
198+
199+
200+
def download_to_stream(
201+
self,
202+
source_location: str,
203+
destination_stream: BytesIO,
204+
offset: int = None,
205+
length: int = None,
206+
**kwargs
207+
) -> None:
208+
"""Download a stream of the call recording to the destination.
209+
210+
:param source_location: The source location uri. Required.
211+
:type source_location: str
212+
:param destination_stream: The destination stream. Required.
213+
:type destination_stream: BytesIO
214+
:param offset: Offset byte. Not required.
215+
:type offset: int
216+
:param length: how many bytes. Not required.
217+
:type length: int
218+
"""
219+
stream = self._downloader.download_streaming(source_location = source_location,
220+
offset = offset,
221+
length = length,
222+
**kwargs
223+
)
224+
with open(destination_stream, 'wb') as writer:
225+
writer.write(stream.read())
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
# -------------------------------------------------------------------------
2+
# Copyright (c) Microsoft Corporation. All rights reserved.
3+
# Licensed under the MIT License. See License.txt in the project root for
4+
# license information.
5+
# --------------------------------------------------------------------------
6+
7+
from typing import Any
8+
9+
from urllib.parse import ParseResult, urlparse
10+
from azure.core.exceptions import (
11+
ClientAuthenticationError,
12+
HttpResponseError,
13+
ResourceExistsError,
14+
ResourceNotFoundError,
15+
ResourceNotModifiedError,
16+
map_error,
17+
)
18+
from azure.core.pipeline import PipelineResponse
19+
from azure.core.pipeline.transport import HttpResponse
20+
from azure.core.rest import HttpRequest
21+
from azure.core.utils import case_insensitive_dict
22+
23+
from ._generated import models as _models
24+
from ._generated._serialization import Serializer
25+
from ._generated.operations import CallRecordingOperations
26+
27+
_SERIALIZER = Serializer()
28+
_SERIALIZER.client_side_validation = False
29+
30+
31+
class ContentDownloader(object):
32+
def __init__(
33+
self,
34+
call_recording_client, # type: CallRecordingOperations
35+
): # type: (...) -> None
36+
37+
self._call_recording_client = call_recording_client
38+
39+
def download_streaming( # pylint: disable=inconsistent-return-statements
40+
self, source_location: str, offset: int, length: int, **kwargs: Any
41+
) -> HttpResponse:
42+
"""Download a stream of the call recording.
43+
44+
:param source_location: The source location. Required.
45+
:type source_location: str
46+
:param offset: Offset byte. Not required.
47+
:type offset: int
48+
:param length: how many bytes. Not required.
49+
:type length: int
50+
:return: HttpResponse (octet-stream)
51+
:rtype: HttpResponse (octet-stream)
52+
"""
53+
54+
if length is not None and offset is None:
55+
raise ValueError("Offset value must not be None if length is set.")
56+
if length is not None:
57+
length = offset + length - 1 # Service actually uses an end-range inclusive index
58+
59+
error_map = {
60+
401: ClientAuthenticationError,
61+
404: ResourceNotFoundError,
62+
409: ResourceExistsError,
63+
304: ResourceNotModifiedError,
64+
}
65+
error_map.update(kwargs.pop("error_map", {}) or {})
66+
67+
parsedEndpoint:ParseResult = urlparse(
68+
self._call_recording_client._config.endpoint # pylint: disable=protected-access
69+
)
70+
71+
_headers = kwargs.pop("headers", {}) or {}
72+
_params = kwargs.pop("params", {}) or {}
73+
request = build_call_recording_download_recording_request(
74+
source_location = source_location,
75+
headers =_headers,
76+
params =_params,
77+
start = offset,
78+
end = length,
79+
host = parsedEndpoint.hostname
80+
)
81+
82+
pipeline_response: PipelineResponse = self._call_recording_client._client._pipeline.run( # pylint: disable=protected-access
83+
request, stream = True, **kwargs
84+
)
85+
response = pipeline_response.http_response
86+
87+
if response.status_code in [200, 206]:
88+
return response
89+
90+
map_error(status_code = response.status_code, response = response, error_map = error_map)
91+
error = self._call_recording_client._deserialize.failsafe_deserialize( # pylint: disable=protected-access
92+
_models.CommunicationErrorResponse, pipeline_response
93+
)
94+
raise HttpResponseError(response = response, model = error)
95+
96+
def delete_recording( # pylint: disable=inconsistent-return-statements
97+
self, recording_location: str, **kwargs: Any
98+
) -> None:
99+
"""Delete a call recording.
100+
101+
:param recording_location: The recording location. Required.
102+
:type recording_location: str
103+
"""
104+
105+
error_map = {
106+
401: ClientAuthenticationError,
107+
404: ResourceNotFoundError,
108+
409: ResourceExistsError,
109+
304: ResourceNotModifiedError,
110+
}
111+
error_map.update(kwargs.pop("error_map", {}) or {})
112+
113+
parsed_endpoint:ParseResult = urlparse(
114+
self._call_recording_client._config.endpoint # pylint: disable=protected-access
115+
)
116+
117+
_headers = kwargs.pop("headers", {}) or {}
118+
_params = kwargs.pop("params", {}) or {}
119+
request = build_call_recording_delete_recording_request(
120+
recording_location = recording_location,
121+
headers =_headers,
122+
params =_params,
123+
host = parsed_endpoint.hostname
124+
)
125+
126+
pipeline_response: PipelineResponse = self._call_recording_client._client._pipeline.run( # pylint: disable=protected-access
127+
request, stream = False, **kwargs
128+
)
129+
130+
response = pipeline_response.http_response
131+
132+
if response.status_code not in [200]:
133+
map_error(status_code=response.status_code, response = response, error_map=error_map)
134+
error = self._call_recording_client._deserialize.failsafe_deserialize( # pylint: disable=protected-access
135+
_models.CommunicationErrorResponse, pipeline_response
136+
)
137+
raise HttpResponseError(response=response, model=error)
138+
139+
def build_call_recording_delete_recording_request(recording_location: str, host: str, **kwargs: Any) -> HttpRequest:
140+
_headers = case_insensitive_dict(kwargs.pop("headers", {}) or {})
141+
_params = case_insensitive_dict(kwargs.pop("params", {}) or {})
142+
143+
# Construct headers
144+
_headers["x-ms-host"] = _SERIALIZER.header("x-ms-host", host, "str")
145+
return HttpRequest(
146+
method = "DELETE",
147+
url = recording_location,
148+
params = _params,
149+
headers = _headers,
150+
**kwargs
151+
)
152+
153+
def build_call_recording_download_recording_request(source_location: str,
154+
start:int,
155+
end:int,
156+
host:str,
157+
**kwargs: Any
158+
) -> HttpRequest:
159+
_headers = case_insensitive_dict(kwargs.pop("headers", {}) or {})
160+
_params = case_insensitive_dict(kwargs.pop("params", {}) or {})
161+
162+
rangeHeader = "bytes=" + str(start)
163+
if end:
164+
rangeHeader += "-" + str(end)
165+
# Construct headers
166+
_headers["Range"] = _SERIALIZER.header("range", rangeHeader, "str")
167+
_headers["Accept"] = _SERIALIZER.header("accept", "application/json", "str")
168+
_headers["x-ms-host"] = _SERIALIZER.header("x-ms-host", host, "str")
169+
return HttpRequest(method = "GET", url = source_location, params = _params, headers = _headers, **kwargs)

sdk/communication/azure-communication-callautomation/azure/communication/callautomation/_models.py

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ def __init__(
100100
audio_channel_participant_ordering: Optional[List["CommunicationIdentifier"]] = None,
101101
recording_storage: Optional[Union[str,
102102
"RecordingStorage"]] = None,
103+
external_storage_location: Optional[str] = None,
103104
**kwargs: Any
104105
) -> None:
105106
"""
@@ -132,6 +133,9 @@ def __init__(
132133
:keyword recording_storage_type: Recording storage mode. ``External`` enables bring your own
133134
storage. Known values are: "acs" and "azureBlob".
134135
:paramtype recording_storage_type: str or
136+
:keyword external_storage_location: The location where recording is stored, when
137+
RecordingStorageType is set to 'BlobStorage'.
138+
:paramtype external_storage_location: str
135139
~azure.communication.callautomation.models.RecordingStorageType
136140
"""
137141
super().__init__(**kwargs)
@@ -142,24 +146,26 @@ def __init__(
142146
self.recording_format_type = recording_format
143147
self.audio_channel_participant_ordering = audio_channel_participant_ordering
144148
self.recording_storage_type = recording_storage
149+
self.external_storage_location = external_storage_location
145150

146151
def _to_generated(self):
147152
audio_channel_participant_ordering_list:List[CommunicationIdentifierModel] = None
148153
if self.audio_channel_participant_ordering is not None:
149-
audio_channel_participant_ordering_list=[
154+
audio_channel_participant_ordering_list = [
150155
serialize_identifier(identifier) for identifier
151156
in self.audio_channel_participant_ordering]
152157

153158
return StartCallRecordingRequestRest(
154159
call_locator=self.call_locator._to_generated(# pylint:disable=protected-access
155160
),
156-
recording_state_callback_uri=self.recording_state_callback_uri,
157-
recording_content_type=self.recording_content_type,
158-
recording_channel_type=self.recording_channel_type,
159-
recording_format_type=self.recording_format_type,
160-
audio_channel_participant_ordering=audio_channel_participant_ordering_list,
161-
recording_storage_type=self.recording_storage_type
162-
)
161+
recording_state_callback_uri = self.recording_state_callback_uri,
162+
recording_content_type = self.recording_content_type,
163+
recording_channel_type = self.recording_channel_type,
164+
recording_format_type = self.recording_format_type,
165+
audio_channel_participant_ordering = audio_channel_participant_ordering_list,
166+
recording_storage_type = self.recording_storage_type,
167+
external_storage_location = self.external_storage_location
168+
)
163169

164170

165171
class RecordingStateResponse(object):

sdk/communication/azure-communication-callautomation/azure/communication/callautomation/_shared/policy.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import urllib
99
import base64
1010
import hmac
11+
from urllib.parse import ParseResult, urlparse
1112
from azure.core.credentials import AzureKeyCredential
1213
from azure.core.pipeline.policies import SansIOHTTPPolicy
1314
from .utils import get_current_utc_time
@@ -51,7 +52,11 @@ def _sign_request(self, request):
5152
verb = request.http_request.method.upper()
5253

5354
# Get the path and query from url, which looks like https://host/path/query
54-
query_url = str(request.http_request.url[len(self._host) + 8:])
55+
parsedUrl:ParseResult = urlparse(request.http_request.url)
56+
query_url = parsedUrl.path
57+
58+
if parsedUrl.query:
59+
query_url += "?" + parsedUrl.query
5560

5661
if self._decode_url:
5762
query_url = urllib.parse.unquote(query_url)

0 commit comments

Comments
 (0)