Skip to content

Commit 470ec70

Browse files
Add probing a stream (#23)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
1 parent 5fe1e86 commit 470ec70

File tree

6 files changed

+259
-14
lines changed

6 files changed

+259
-14
lines changed

go2rtc_client/models.py

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from __future__ import annotations
44

55
from dataclasses import dataclass, field
6-
from typing import Any, Literal
6+
from typing import Generic, Literal, TypeVar
77

88
from awesomeversion import AwesomeVersion
99
from mashumaro import field_options
@@ -19,6 +19,20 @@ def deserialize(self, value: str) -> AwesomeVersion:
1919
return AwesomeVersion(value)
2020

2121

22+
_T = TypeVar("_T")
23+
24+
25+
class _EmptyListInsteadNoneSerializer(SerializationStrategy, Generic[_T]):
26+
def serialize(self, value: list[_T]) -> list[_T]:
27+
return value
28+
29+
def deserialize(self, value: list[_T] | None) -> list[_T]:
30+
if value is None:
31+
return []
32+
33+
return value
34+
35+
2236
@dataclass
2337
class ApplicationInfo(DataClassORJSONMixin):
2438
"""Application info model.
@@ -39,26 +53,22 @@ class Streams(DataClassORJSONMixin):
3953

4054

4155
@dataclass
42-
class Stream:
56+
class Stream(DataClassORJSONMixin):
4357
"""Stream model."""
4458

45-
producers: list[Producer]
46-
47-
@classmethod
48-
def __pre_deserialize__(cls, d: dict[Any, Any]) -> dict[Any, Any]:
49-
"""Pre deserialize."""
50-
# Ensure producers is always a list
51-
if "producers" in d and d["producers"] is None:
52-
d["producers"] = []
53-
54-
return d
59+
producers: list[Producer] = field(
60+
metadata=field_options(serialization_strategy=_EmptyListInsteadNoneSerializer())
61+
)
5562

5663

5764
@dataclass
5865
class Producer:
5966
"""Producer model."""
6067

6168
url: str
69+
medias: list[str] = field(
70+
metadata=field_options(serialization_strategy=_EmptyListInsteadNoneSerializer())
71+
)
6272

6373

6474
@dataclass

go2rtc_client/rest.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,19 @@ async def add(self, name: str, source: str) -> None:
133133
params={"name": name, "src": [source, f"ffmpeg:{name}#audio=opus"]},
134134
)
135135

136+
@handle_error
137+
async def probe(
138+
self, stream_name: str, *, audio: str | None = None, video: str | None = None
139+
) -> Stream:
140+
"""Probe a stream."""
141+
params = {"src": stream_name}
142+
if audio:
143+
params["audio"] = audio
144+
if video:
145+
params["video"] = video
146+
resp = await self._client.request("GET", self.PATH, params=params)
147+
return Stream.from_dict(await resp.json())
148+
136149

137150
class Go2RtcRestClient:
138151
"""Rest client for go2rtc server."""

tests/__snapshots__/test_rest.ambr

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,57 @@
99
'version': '1.9.4',
1010
})
1111
# ---
12+
# name: test_probe_success[audio and video][deserialized]
13+
dict({
14+
'producers': list([
15+
dict({
16+
'medias': list([
17+
'video, recvonly, H264',
18+
'audio, recvonly, MPEG4-GENERIC/16000',
19+
'audio, sendonly, PCMU/8000',
20+
]),
21+
'url': 'rtsp://x:x@192.168.x.x:554/Preview_01_sub',
22+
}),
23+
]),
24+
})
25+
# ---
26+
# name: test_probe_success[audio and video][serialized]
27+
'{"producers":[{"url":"rtsp://x:x@192.168.x.x:554/Preview_01_sub","medias":["video, recvonly, H264","audio, recvonly, MPEG4-GENERIC/16000","audio, sendonly, PCMU/8000"]}]}'
28+
# ---
29+
# name: test_probe_success[audio only][deserialized]
30+
dict({
31+
'producers': list([
32+
dict({
33+
'medias': list([
34+
'video, recvonly, H264',
35+
'audio, recvonly, MPEG4-GENERIC/16000',
36+
'audio, sendonly, PCMU/8000',
37+
]),
38+
'url': 'rtsp://x:x@192.168.x.x:554/Preview_01_sub',
39+
}),
40+
]),
41+
})
42+
# ---
43+
# name: test_probe_success[audio only][serialized]
44+
'{"producers":[{"url":"rtsp://x:x@192.168.x.x:554/Preview_01_sub","medias":["video, recvonly, H264","audio, recvonly, MPEG4-GENERIC/16000","audio, sendonly, PCMU/8000"]}]}'
45+
# ---
46+
# name: test_probe_success[video only][deserialized]
47+
dict({
48+
'producers': list([
49+
dict({
50+
'medias': list([
51+
'video, recvonly, H264',
52+
'audio, recvonly, MPEG4-GENERIC/16000',
53+
'audio, sendonly, PCMU/8000',
54+
]),
55+
'url': 'rtsp://x:x@192.168.x.x:554/Preview_01_sub',
56+
}),
57+
]),
58+
})
59+
# ---
60+
# name: test_probe_success[video only][serialized]
61+
'{"producers":[{"url":"rtsp://x:x@192.168.x.x:554/Preview_01_sub","medias":["video, recvonly, H264","audio, recvonly, MPEG4-GENERIC/16000","audio, sendonly, PCMU/8000"]}]}'
62+
# ---
1263
# name: test_streams_get[empty]
1364
dict({
1465
})
@@ -18,6 +69,10 @@
1869
'camera.12mp_fluent': dict({
1970
'producers': list([
2071
dict({
72+
'medias': list([
73+
'video, recvonly, H264',
74+
'audio, recvonly, MPEG4-GENERIC/16000',
75+
]),
2176
'url': 'rtsp://test:test@192.168.10.105:554/Preview_06_sub',
2277
}),
2378
]),
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
streams: dial tcp 192.168.10.107:554: i/o timeout

tests/fixtures/probe_success.json

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
{
2+
"producers": [
3+
{
4+
"id": 2,
5+
"format_name": "rtsp",
6+
"protocol": "rtsp+tcp",
7+
"remote_addr": "192.168.x.x:554",
8+
"url": "rtsp://x:x@192.168.x.x:554/Preview_01_sub",
9+
"sdp": "v=0\r\no=- 1730880192000275 1 IN IP4 192.168.x.x\r\ns=Session streamed by \"preview\"\r\nt=0 0\r\na=tool:BC Streaming Media v202210012022.10.01\r\na=type:broadcast\r\na=control:*\r\na=range:npt=now-\r\na=x-qt-text-nam:Session streamed by \"preview\"\r\nm=video 0 RTP/AVP 96\r\nc=IN IP4 0.0.0.0\r\nb=AS:8192\r\na=rtpmap:96 H264/90000\r\na=fmtp:96 packetization-mode=1;profile-level-id=640033;sprop-parameter-sets=Z2QAM6wVFKCgPZA=,aO48sA==\r\na=recvonly\r\na=control:track1\r\nm=audio 0 RTP/AVP 97\r\nc=IN IP4 0.0.0.0\r\nb=AS:8192\r\na=rtpmap:97 MPEG4-GENERIC/16000\r\na=fmtp:97 streamtype=5;profile-level-id=1;mode=AAC-hbr;sizelength=13;indexlength=3;indexdeltalength=3;config=1408\r\na=recvonly\r\na=control:track2\r\nm=audio 0 RTP/AVP 0\r\na=control:track3\r\na=rtpmap:0 PCMU/8000\r\na=sendonly",
10+
"user_agent": "go2rtc/1.9.6",
11+
"medias": [
12+
"video, recvonly, H264",
13+
"audio, recvonly, MPEG4-GENERIC/16000",
14+
"audio, sendonly, PCMU/8000"
15+
],
16+
"receivers": [
17+
{
18+
"id": 3,
19+
"codec": {
20+
"codec_name": "h264",
21+
"codec_type": "video",
22+
"level": 51,
23+
"profile": "High"
24+
},
25+
"childs": [4]
26+
},
27+
{
28+
"id": 5,
29+
"codec": {
30+
"codec_name": "aac",
31+
"codec_type": "audio",
32+
"sample_rate": 16000
33+
},
34+
"childs": [6]
35+
}
36+
],
37+
"senders": [
38+
{
39+
"id": 8,
40+
"codec": {
41+
"codec_name": "pcm_mulaw",
42+
"codec_type": "audio",
43+
"sample_rate": 8000
44+
},
45+
"parent": 7
46+
}
47+
]
48+
}
49+
],
50+
"consumers": [
51+
{
52+
"id": 1,
53+
"format_name": "probe",
54+
"protocol": "http",
55+
"remote_addr": "172.19.x.x:47622 forwarded 192.168.x.x",
56+
"user_agent": "Mozilla/5.0 (X11; Linux x86_64; rv:132.0) Gecko/20100101 Firefox/132.0",
57+
"medias": [
58+
"video, sendonly, ALL",
59+
"audio, sendonly, ALL",
60+
"audio, recvonly, ANY"
61+
],
62+
"receivers": [
63+
{
64+
"id": 7,
65+
"codec": {
66+
"codec_name": "ANY",
67+
"codec_type": ""
68+
},
69+
"childs": [8]
70+
}
71+
],
72+
"senders": [
73+
{
74+
"id": 4,
75+
"codec": {
76+
"codec_name": "h264",
77+
"codec_type": "video",
78+
"level": 51,
79+
"profile": "High"
80+
},
81+
"parent": 3
82+
},
83+
{
84+
"id": 6,
85+
"codec": {
86+
"codec_name": "aac",
87+
"codec_type": "audio",
88+
"sample_rate": 16000
89+
},
90+
"parent": 5
91+
}
92+
]
93+
}
94+
]
95+
}

tests/test_rest.py

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@
1111
from awesomeversion import AwesomeVersion
1212
import pytest
1313

14-
from go2rtc_client.exceptions import Go2RtcVersionError
15-
from go2rtc_client.models import WebRTCSdpOffer
14+
from go2rtc_client.exceptions import Go2RtcClientError, Go2RtcVersionError
15+
from go2rtc_client.models import Stream, WebRTCSdpOffer
1616
from go2rtc_client.rest import _ApplicationClient, _StreamClient, _WebRTCClient
1717
from tests import load_fixture
1818

@@ -146,3 +146,74 @@ async def test_webrtc_offer(
146146
WebRTCSdpOffer("v=0..."),
147147
)
148148
assert resp == snapshot
149+
150+
151+
async def _test_probe(
152+
responses: aioresponses,
153+
rest_client: Go2RtcRestClient,
154+
filename: str,
155+
status_code: int,
156+
additional_params: dict[str, str],
157+
) -> Stream:
158+
"""Test probing a stream."""
159+
camera = "camera.test"
160+
params = [f"{k}={v}" for k, v in additional_params.items()]
161+
responses.get(
162+
f"{URL}{_StreamClient.PATH}?src={camera}&{'&'.join(params)}",
163+
status=status_code,
164+
body=load_fixture(filename),
165+
)
166+
return await rest_client.streams.probe(camera, **additional_params)
167+
168+
169+
@pytest.mark.parametrize(
170+
"additional_params",
171+
[
172+
{"audio": "all", "video": "all"},
173+
{"audio": "all"},
174+
{"video": "all"},
175+
],
176+
ids=[
177+
"audio and video",
178+
"audio only",
179+
"video only",
180+
],
181+
)
182+
async def test_probe_success(
183+
responses: aioresponses,
184+
rest_client: Go2RtcRestClient,
185+
snapshot: SnapshotAssertion,
186+
additional_params: dict[str, str],
187+
) -> None:
188+
"""Test probing a stream."""
189+
resp = await _test_probe(
190+
responses, rest_client, "probe_success.json", 200, additional_params
191+
)
192+
assert resp == snapshot(name="deserialized")
193+
assert isinstance(resp, Stream)
194+
assert resp.to_json() == snapshot(name="serialized")
195+
196+
197+
@pytest.mark.parametrize(
198+
"additional_params",
199+
[
200+
{"audio": "all", "video": "all"},
201+
{"audio": "all"},
202+
{"video": "all"},
203+
],
204+
ids=[
205+
"audio and video",
206+
"audio only",
207+
"video only",
208+
],
209+
)
210+
async def test_probe_camera_offline(
211+
responses: aioresponses,
212+
rest_client: Go2RtcRestClient,
213+
additional_params: dict[str, str],
214+
) -> None:
215+
"""Test probing a stream, where the camera is offline."""
216+
with pytest.raises(Go2RtcClientError):
217+
await _test_probe(
218+
responses, rest_client, "probe_camera_offline.txt", 500, additional_params
219+
)

0 commit comments

Comments
 (0)