moq-rs 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- moq/__init__.py +69 -0
- moq/client.py +100 -0
- moq/origin.py +96 -0
- moq/publish.py +187 -0
- moq/py.typed +0 -0
- moq/server.py +228 -0
- moq/subscribe.py +195 -0
- moq/types.py +49 -0
- moq_rs-0.3.0.dist-info/METADATA +170 -0
- moq_rs-0.3.0.dist-info/RECORD +11 -0
- moq_rs-0.3.0.dist-info/WHEEL +4 -0
moq/__init__.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""The networking layer for Media over QUIC.
|
|
2
|
+
|
|
3
|
+
Real-time pub/sub with built-in caching, fan-out, and prioritization.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from moq_ffi import MoqSession as Session
|
|
7
|
+
|
|
8
|
+
from .client import Client
|
|
9
|
+
from .origin import Announced, AnnouncedBroadcast, Announcement, OriginConsumer, OriginProducer
|
|
10
|
+
from .publish import AudioProducer, BroadcastProducer, GroupProducer, MediaProducer, TrackProducer
|
|
11
|
+
from .server import Request, Server, Transport
|
|
12
|
+
from .subscribe import (
|
|
13
|
+
AudioConsumer,
|
|
14
|
+
BroadcastConsumer,
|
|
15
|
+
CatalogConsumer,
|
|
16
|
+
Container,
|
|
17
|
+
GroupConsumer,
|
|
18
|
+
MediaConsumer,
|
|
19
|
+
TrackConsumer,
|
|
20
|
+
)
|
|
21
|
+
from .types import (
|
|
22
|
+
Audio,
|
|
23
|
+
AudioCodec,
|
|
24
|
+
AudioDecoderOutput,
|
|
25
|
+
AudioEncoderInput,
|
|
26
|
+
AudioEncoderOutput,
|
|
27
|
+
AudioFormat,
|
|
28
|
+
AudioFrame,
|
|
29
|
+
Catalog,
|
|
30
|
+
Dimensions,
|
|
31
|
+
Frame,
|
|
32
|
+
Video,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"Announced",
|
|
37
|
+
"AnnouncedBroadcast",
|
|
38
|
+
"Announcement",
|
|
39
|
+
"Audio",
|
|
40
|
+
"AudioCodec",
|
|
41
|
+
"AudioConsumer",
|
|
42
|
+
"AudioDecoderOutput",
|
|
43
|
+
"AudioEncoderInput",
|
|
44
|
+
"AudioEncoderOutput",
|
|
45
|
+
"AudioFormat",
|
|
46
|
+
"AudioFrame",
|
|
47
|
+
"AudioProducer",
|
|
48
|
+
"BroadcastConsumer",
|
|
49
|
+
"BroadcastProducer",
|
|
50
|
+
"Catalog",
|
|
51
|
+
"CatalogConsumer",
|
|
52
|
+
"Client",
|
|
53
|
+
"Container",
|
|
54
|
+
"Dimensions",
|
|
55
|
+
"Frame",
|
|
56
|
+
"GroupConsumer",
|
|
57
|
+
"GroupProducer",
|
|
58
|
+
"MediaConsumer",
|
|
59
|
+
"MediaProducer",
|
|
60
|
+
"OriginConsumer",
|
|
61
|
+
"OriginProducer",
|
|
62
|
+
"Request",
|
|
63
|
+
"Server",
|
|
64
|
+
"Session",
|
|
65
|
+
"TrackConsumer",
|
|
66
|
+
"TrackProducer",
|
|
67
|
+
"Transport",
|
|
68
|
+
"Video",
|
|
69
|
+
]
|
moq/client.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Client wrapper — simplified connection with automatic origin wiring."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from moq_ffi import MoqClient
|
|
6
|
+
|
|
7
|
+
from .origin import Announced, AnnouncedBroadcast, OriginConsumer, OriginProducer
|
|
8
|
+
from .publish import BroadcastProducer
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Client:
|
|
12
|
+
"""High-level MoQ client with automatic origin wiring.
|
|
13
|
+
|
|
14
|
+
In simple mode (no origin provided), creates an internal origin automatically:
|
|
15
|
+
|
|
16
|
+
async with Client("https://relay.example.com") as client:
|
|
17
|
+
async for ann in client.announced():
|
|
18
|
+
...
|
|
19
|
+
|
|
20
|
+
In advanced mode, provide your own origin for full control:
|
|
21
|
+
|
|
22
|
+
origin = OriginProducer()
|
|
23
|
+
client = Client("https://relay.example.com", publish=origin, subscribe=origin)
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
url: str,
|
|
29
|
+
*,
|
|
30
|
+
tls_verify: bool = True,
|
|
31
|
+
bind: str | None = None,
|
|
32
|
+
publish: OriginProducer | None = None,
|
|
33
|
+
subscribe: OriginProducer | None = None,
|
|
34
|
+
) -> None:
|
|
35
|
+
self._url = url
|
|
36
|
+
self._tls_verify = tls_verify
|
|
37
|
+
self._bind = bind
|
|
38
|
+
|
|
39
|
+
# If neither origin is provided, create a shared internal one.
|
|
40
|
+
if publish is None and subscribe is None:
|
|
41
|
+
self._origin = OriginProducer()
|
|
42
|
+
self._publish_origin = self._origin
|
|
43
|
+
self._consume_origin = self._origin
|
|
44
|
+
else:
|
|
45
|
+
self._origin = None
|
|
46
|
+
self._publish_origin = publish
|
|
47
|
+
self._consume_origin = subscribe
|
|
48
|
+
|
|
49
|
+
self._consumer: OriginConsumer | None = None
|
|
50
|
+
self._inner: MoqClient | None = None
|
|
51
|
+
self._session = None
|
|
52
|
+
|
|
53
|
+
async def __aenter__(self):
|
|
54
|
+
self._inner = MoqClient()
|
|
55
|
+
|
|
56
|
+
if not self._tls_verify:
|
|
57
|
+
self._inner.set_tls_disable_verify(True)
|
|
58
|
+
if self._bind is not None:
|
|
59
|
+
self._inner.set_bind(self._bind)
|
|
60
|
+
|
|
61
|
+
if self._publish_origin is not None:
|
|
62
|
+
self._inner.set_publish(self._publish_origin._inner)
|
|
63
|
+
if self._consume_origin is not None:
|
|
64
|
+
self._inner.set_consume(self._consume_origin._inner)
|
|
65
|
+
|
|
66
|
+
self._session = await self._inner.connect(self._url)
|
|
67
|
+
|
|
68
|
+
# Create consumer from whichever origin handles consuming.
|
|
69
|
+
origin = self._consume_origin or self._publish_origin
|
|
70
|
+
if origin is not None:
|
|
71
|
+
self._consumer = origin.consume()
|
|
72
|
+
|
|
73
|
+
return self
|
|
74
|
+
|
|
75
|
+
async def __aexit__(self, *exc) -> None:
|
|
76
|
+
self._consumer = None
|
|
77
|
+
if self._inner is not None:
|
|
78
|
+
self._inner.cancel()
|
|
79
|
+
self._inner = None
|
|
80
|
+
self._session = None
|
|
81
|
+
|
|
82
|
+
def publish(self, path: str, broadcast: BroadcastProducer) -> None:
|
|
83
|
+
origin = self._publish_origin
|
|
84
|
+
if origin is None:
|
|
85
|
+
raise RuntimeError("no publish origin configured")
|
|
86
|
+
origin.publish(path, broadcast)
|
|
87
|
+
|
|
88
|
+
def announced(self, prefix: str = "") -> Announced:
|
|
89
|
+
if self._consumer is None:
|
|
90
|
+
raise RuntimeError("no consume origin configured")
|
|
91
|
+
return self._consumer.announced(prefix)
|
|
92
|
+
|
|
93
|
+
def announced_broadcast(self, path: str) -> AnnouncedBroadcast:
|
|
94
|
+
if self._consumer is None:
|
|
95
|
+
raise RuntimeError("no consume origin configured")
|
|
96
|
+
return self._consumer.announced_broadcast(path)
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def session(self):
|
|
100
|
+
return self._session
|
moq/origin.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Origin wrappers — manage announcements and broadcast discovery."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from moq_ffi import MoqAnnounced, MoqAnnouncedBroadcast, MoqAnnouncement, MoqOriginConsumer, MoqOriginProducer
|
|
6
|
+
|
|
7
|
+
from .publish import BroadcastProducer
|
|
8
|
+
from .subscribe import BroadcastConsumer
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Announcement:
|
|
12
|
+
"""Wraps MoqAnnouncement — a discovered broadcast."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, inner: MoqAnnouncement) -> None:
|
|
15
|
+
self._inner = inner
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def path(self) -> str:
|
|
19
|
+
return self._inner.path()
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def broadcast(self) -> BroadcastConsumer:
|
|
23
|
+
return BroadcastConsumer(self._inner.broadcast())
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Announced:
|
|
27
|
+
"""Wraps MoqAnnounced as an async iterator of Announcement."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, inner: MoqAnnounced) -> None:
|
|
30
|
+
self._inner = inner
|
|
31
|
+
|
|
32
|
+
async def __aenter__(self):
|
|
33
|
+
return self
|
|
34
|
+
|
|
35
|
+
async def __aexit__(self, *exc) -> None:
|
|
36
|
+
self.cancel()
|
|
37
|
+
|
|
38
|
+
def __aiter__(self):
|
|
39
|
+
return self
|
|
40
|
+
|
|
41
|
+
async def __anext__(self) -> Announcement:
|
|
42
|
+
result = await self._inner.next()
|
|
43
|
+
if result is None:
|
|
44
|
+
raise StopAsyncIteration
|
|
45
|
+
return Announcement(result)
|
|
46
|
+
|
|
47
|
+
def cancel(self) -> None:
|
|
48
|
+
self._inner.cancel()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class AnnouncedBroadcast:
|
|
52
|
+
"""Wraps MoqAnnouncedBroadcast — awaitable for a specific broadcast."""
|
|
53
|
+
|
|
54
|
+
def __init__(self, inner: MoqAnnouncedBroadcast) -> None:
|
|
55
|
+
self._inner = inner
|
|
56
|
+
|
|
57
|
+
async def __aenter__(self):
|
|
58
|
+
return self
|
|
59
|
+
|
|
60
|
+
async def __aexit__(self, *exc) -> None:
|
|
61
|
+
self.cancel()
|
|
62
|
+
|
|
63
|
+
async def available(self) -> BroadcastConsumer:
|
|
64
|
+
return BroadcastConsumer(await self._inner.available())
|
|
65
|
+
|
|
66
|
+
def __await__(self):
|
|
67
|
+
return self.available().__await__()
|
|
68
|
+
|
|
69
|
+
def cancel(self) -> None:
|
|
70
|
+
self._inner.cancel()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class OriginConsumer:
|
|
74
|
+
"""Wraps MoqOriginConsumer for discovering broadcasts."""
|
|
75
|
+
|
|
76
|
+
def __init__(self, inner: MoqOriginConsumer) -> None:
|
|
77
|
+
self._inner = inner
|
|
78
|
+
|
|
79
|
+
def announced(self, prefix: str = "") -> Announced:
|
|
80
|
+
return Announced(self._inner.announced(prefix))
|
|
81
|
+
|
|
82
|
+
def announced_broadcast(self, path: str) -> AnnouncedBroadcast:
|
|
83
|
+
return AnnouncedBroadcast(self._inner.announced_broadcast(path))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class OriginProducer:
|
|
87
|
+
"""Wraps MoqOriginProducer for publishing broadcasts."""
|
|
88
|
+
|
|
89
|
+
def __init__(self) -> None:
|
|
90
|
+
self._inner = MoqOriginProducer()
|
|
91
|
+
|
|
92
|
+
def consume(self) -> OriginConsumer:
|
|
93
|
+
return OriginConsumer(self._inner.consume())
|
|
94
|
+
|
|
95
|
+
def publish(self, path: str, broadcast: BroadcastProducer) -> None:
|
|
96
|
+
self._inner.publish(path, broadcast._inner)
|
moq/publish.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Producer wrappers — publish broadcasts and media tracks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from moq_ffi import (
|
|
8
|
+
MoqAudioProducer,
|
|
9
|
+
MoqBroadcastProducer,
|
|
10
|
+
MoqGroupProducer,
|
|
11
|
+
MoqMediaProducer,
|
|
12
|
+
MoqMediaStreamProducer,
|
|
13
|
+
MoqTrackProducer,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
from .types import AudioEncoderInput, AudioEncoderOutput, AudioFrame
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from .subscribe import BroadcastConsumer, GroupConsumer, TrackConsumer
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class MediaProducer:
|
|
23
|
+
"""Wraps MoqMediaProducer with a cleaner interface."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, inner: MoqMediaProducer) -> None:
|
|
26
|
+
self._inner = inner
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def name(self) -> str:
|
|
30
|
+
"""The generated media track name."""
|
|
31
|
+
return self._inner.name()
|
|
32
|
+
|
|
33
|
+
async def used(self) -> None:
|
|
34
|
+
"""Wait until this media track has at least one active subscriber."""
|
|
35
|
+
await self._inner.used()
|
|
36
|
+
|
|
37
|
+
async def unused(self) -> None:
|
|
38
|
+
"""Wait until this media track has no active subscribers."""
|
|
39
|
+
await self._inner.unused()
|
|
40
|
+
|
|
41
|
+
def write_frame(self, payload: bytes, timestamp_us: int) -> None:
|
|
42
|
+
self._inner.write_frame(payload, timestamp_us)
|
|
43
|
+
|
|
44
|
+
def finish(self) -> None:
|
|
45
|
+
self._inner.finish()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class MediaStreamProducer:
|
|
49
|
+
"""Wraps MoqMediaStreamProducer — feed a raw byte stream (e.g. Annex-B
|
|
50
|
+
H.264) and let the importer infer frame boundaries.
|
|
51
|
+
|
|
52
|
+
Built via :meth:`BroadcastProducer.publish_media_stream`. Unlike
|
|
53
|
+
:class:`MediaProducer`, no per-frame timestamps are needed; just push
|
|
54
|
+
encoder bytes as they arrive.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(self, inner: MoqMediaStreamProducer) -> None:
|
|
58
|
+
self._inner = inner
|
|
59
|
+
|
|
60
|
+
def write(self, payload: bytes) -> None:
|
|
61
|
+
"""Push raw stream bytes; whole frames are emitted as they complete."""
|
|
62
|
+
self._inner.write(payload)
|
|
63
|
+
|
|
64
|
+
def finish(self) -> None:
|
|
65
|
+
self._inner.finish()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class GroupProducer:
|
|
69
|
+
"""Writes frames into a single group on a track."""
|
|
70
|
+
|
|
71
|
+
def __init__(self, inner: MoqGroupProducer) -> None:
|
|
72
|
+
self._inner = inner
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def sequence(self) -> int:
|
|
76
|
+
"""The sequence number of this group within the track."""
|
|
77
|
+
return self._inner.sequence()
|
|
78
|
+
|
|
79
|
+
def consume(self) -> GroupConsumer:
|
|
80
|
+
"""Create a consumer that reads frames from this group."""
|
|
81
|
+
from .subscribe import GroupConsumer
|
|
82
|
+
|
|
83
|
+
return GroupConsumer(self._inner.consume())
|
|
84
|
+
|
|
85
|
+
def write_frame(self, payload: bytes) -> None:
|
|
86
|
+
self._inner.write_frame(payload)
|
|
87
|
+
|
|
88
|
+
def finish(self) -> None:
|
|
89
|
+
self._inner.finish()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class TrackProducer:
|
|
93
|
+
"""Track producer — write arbitrary byte payloads with no codec required.
|
|
94
|
+
|
|
95
|
+
Same pattern as moq-boy's status/command tracks.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
def __init__(self, inner: MoqTrackProducer) -> None:
|
|
99
|
+
self._inner = inner
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def name(self) -> str:
|
|
103
|
+
"""The track name."""
|
|
104
|
+
return self._inner.name()
|
|
105
|
+
|
|
106
|
+
async def used(self) -> None:
|
|
107
|
+
"""Wait until this track has at least one active subscriber."""
|
|
108
|
+
await self._inner.used()
|
|
109
|
+
|
|
110
|
+
async def unused(self) -> None:
|
|
111
|
+
"""Wait until this track has no active subscribers."""
|
|
112
|
+
await self._inner.unused()
|
|
113
|
+
|
|
114
|
+
def append_group(self) -> GroupProducer:
|
|
115
|
+
"""Start a new group; write frames into it, then finish()."""
|
|
116
|
+
return GroupProducer(self._inner.append_group())
|
|
117
|
+
|
|
118
|
+
def write_frame(self, payload: bytes) -> None:
|
|
119
|
+
"""Convenience: write a single-frame group in one call."""
|
|
120
|
+
self._inner.write_frame(payload)
|
|
121
|
+
|
|
122
|
+
def consume(self) -> TrackConsumer:
|
|
123
|
+
"""Create a consumer that reads directly from this producer's track."""
|
|
124
|
+
from .subscribe import TrackConsumer
|
|
125
|
+
|
|
126
|
+
return TrackConsumer(self._inner.consume())
|
|
127
|
+
|
|
128
|
+
def finish(self) -> None:
|
|
129
|
+
self._inner.finish()
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class AudioProducer:
|
|
133
|
+
"""Publish raw PCM and let libopus encode it on the way out.
|
|
134
|
+
|
|
135
|
+
Built via :meth:`BroadcastProducer.publish_audio`. PCM layout
|
|
136
|
+
(format / sample rate / channels / bitrate / frame duration) is
|
|
137
|
+
fixed at construction; each :meth:`write` call passes only bytes
|
|
138
|
+
and a presentation timestamp.
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
def __init__(self, inner: MoqAudioProducer) -> None:
|
|
142
|
+
self._inner = inner
|
|
143
|
+
|
|
144
|
+
def write(self, frame: AudioFrame) -> None:
|
|
145
|
+
"""Push one frame of PCM in the configured input format."""
|
|
146
|
+
self._inner.write(frame)
|
|
147
|
+
|
|
148
|
+
def finish(self) -> None:
|
|
149
|
+
"""Flush any pending samples and finalize the track."""
|
|
150
|
+
self._inner.finish()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class BroadcastProducer:
|
|
154
|
+
"""Wraps MoqBroadcastProducer with a cleaner interface."""
|
|
155
|
+
|
|
156
|
+
def __init__(self) -> None:
|
|
157
|
+
self._inner = MoqBroadcastProducer()
|
|
158
|
+
|
|
159
|
+
def publish_media(self, format: str, init: bytes) -> MediaProducer:
|
|
160
|
+
return MediaProducer(self._inner.publish_media(format, init))
|
|
161
|
+
|
|
162
|
+
def publish_media_stream(self, format: str) -> MediaStreamProducer:
|
|
163
|
+
"""Publish a media track fed by a raw byte stream (unknown frame
|
|
164
|
+
boundaries). `format` is a stream format (avc3, hev1, av01, fmp4, mkv)."""
|
|
165
|
+
return MediaStreamProducer(self._inner.publish_media_stream(format))
|
|
166
|
+
|
|
167
|
+
def publish_audio(
|
|
168
|
+
self,
|
|
169
|
+
name: str,
|
|
170
|
+
input: AudioEncoderInput,
|
|
171
|
+
output: AudioEncoderOutput,
|
|
172
|
+
) -> AudioProducer:
|
|
173
|
+
"""Publish a raw-audio track with an in-process Opus encoder."""
|
|
174
|
+
return AudioProducer(self._inner.publish_audio(name, input, output))
|
|
175
|
+
|
|
176
|
+
def publish_track(self, name: str) -> TrackProducer:
|
|
177
|
+
"""Create a track — send any bytes, no codec validation."""
|
|
178
|
+
return TrackProducer(self._inner.publish_track(name))
|
|
179
|
+
|
|
180
|
+
def consume(self) -> BroadcastConsumer:
|
|
181
|
+
"""Create a consumer that reads from this broadcast's tracks."""
|
|
182
|
+
from .subscribe import BroadcastConsumer
|
|
183
|
+
|
|
184
|
+
return BroadcastConsumer(self._inner.consume())
|
|
185
|
+
|
|
186
|
+
def finish(self) -> None:
|
|
187
|
+
self._inner.finish()
|
moq/py.typed
ADDED
|
File without changes
|
moq/server.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""Server wrapper — accept incoming sessions with automatic origin wiring."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from collections.abc import Awaitable, Callable, Sequence
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
from moq_ffi import MoqRequest, MoqServer, MoqSession
|
|
10
|
+
|
|
11
|
+
from .origin import OriginProducer
|
|
12
|
+
from .publish import BroadcastProducer
|
|
13
|
+
|
|
14
|
+
Transport = Literal["quic", "iroh", "websocket"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Request:
|
|
18
|
+
"""Wraps MoqRequest — an incoming session that can be accepted or rejected.
|
|
19
|
+
|
|
20
|
+
Use `await request.ok()` to complete the handshake, or
|
|
21
|
+
`await request.close(code)` to reject with an HTTP status code.
|
|
22
|
+
|
|
23
|
+
Dropping a Request without responding closes the underlying connection
|
|
24
|
+
silently; call `close(code)` to send an explicit HTTP status.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, inner: MoqRequest) -> None:
|
|
28
|
+
self._inner = inner
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def url(self) -> str | None:
|
|
32
|
+
return self._inner.url()
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def transport(self) -> Transport:
|
|
36
|
+
return self._inner.transport() # type: ignore[return-value]
|
|
37
|
+
|
|
38
|
+
def set_publish(self, origin: OriginProducer | None) -> None:
|
|
39
|
+
"""Override the publish origin for this session. Falls back to the
|
|
40
|
+
server's configured publish origin if unset."""
|
|
41
|
+
self._inner.set_publish(origin._inner if origin is not None else None)
|
|
42
|
+
|
|
43
|
+
def set_consume(self, origin: OriginProducer | None) -> None:
|
|
44
|
+
"""Override the consume origin for this session. Falls back to the
|
|
45
|
+
server's configured consume origin if unset."""
|
|
46
|
+
self._inner.set_consume(origin._inner if origin is not None else None)
|
|
47
|
+
|
|
48
|
+
async def ok(self) -> MoqSession:
|
|
49
|
+
"""Complete the MoQ handshake and return the established session.
|
|
50
|
+
|
|
51
|
+
The caller must hold the returned session to keep the connection
|
|
52
|
+
alive; dropping it closes the session. Raises `MoqError.AlreadyResponded`
|
|
53
|
+
if `ok()` or `close()` has already been called.
|
|
54
|
+
"""
|
|
55
|
+
return await self._inner.ok()
|
|
56
|
+
|
|
57
|
+
async def close(self, code: int = 404) -> None:
|
|
58
|
+
"""Reject the session with the given HTTP status code.
|
|
59
|
+
|
|
60
|
+
Raises `MoqError.AlreadyResponded` if `ok()` or `close()` has already
|
|
61
|
+
been called.
|
|
62
|
+
"""
|
|
63
|
+
await self._inner.close(code)
|
|
64
|
+
|
|
65
|
+
def cancel(self) -> None:
|
|
66
|
+
"""Cancel an in-flight `ok()` or `close()` call."""
|
|
67
|
+
self._inner.cancel()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class Server:
|
|
71
|
+
"""High-level MoQ server with automatic origin wiring.
|
|
72
|
+
|
|
73
|
+
In simple mode (no origin provided), creates an internal origin automatically:
|
|
74
|
+
|
|
75
|
+
async with Server("127.0.0.1:4443", tls_generate=["localhost"]) as server:
|
|
76
|
+
server.publish("live", broadcast)
|
|
77
|
+
await server.serve()
|
|
78
|
+
|
|
79
|
+
Or hand-roll the accept loop if you need per-request control:
|
|
80
|
+
|
|
81
|
+
async with Server("127.0.0.1:4443", tls_generate=["localhost"]) as server:
|
|
82
|
+
async for request in server:
|
|
83
|
+
if request.url and "/admin" in request.url:
|
|
84
|
+
await request.close(403)
|
|
85
|
+
continue
|
|
86
|
+
session = await request.ok() # hold to keep the connection alive
|
|
87
|
+
|
|
88
|
+
Exiting the context manager stops accepting new sessions but does not
|
|
89
|
+
close in-flight sessions; those stay alive until their handles are
|
|
90
|
+
dropped or `Session.cancel()` is called.
|
|
91
|
+
|
|
92
|
+
In advanced mode, provide your own origins for full control:
|
|
93
|
+
|
|
94
|
+
origin = OriginProducer()
|
|
95
|
+
server = Server(
|
|
96
|
+
"127.0.0.1:4443",
|
|
97
|
+
tls_generate=["localhost"],
|
|
98
|
+
publish=origin,
|
|
99
|
+
subscribe=origin,
|
|
100
|
+
)
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
def __init__(
|
|
104
|
+
self,
|
|
105
|
+
bind: str = "[::]:443",
|
|
106
|
+
*,
|
|
107
|
+
tls_cert: Sequence[str] = (),
|
|
108
|
+
tls_key: Sequence[str] = (),
|
|
109
|
+
tls_generate: Sequence[str] = (),
|
|
110
|
+
publish: OriginProducer | None = None,
|
|
111
|
+
subscribe: OriginProducer | None = None,
|
|
112
|
+
) -> None:
|
|
113
|
+
self._bind = bind
|
|
114
|
+
self._tls_cert = list(tls_cert)
|
|
115
|
+
self._tls_key = list(tls_key)
|
|
116
|
+
self._tls_generate = list(tls_generate)
|
|
117
|
+
|
|
118
|
+
# If neither origin is provided, create a shared internal one.
|
|
119
|
+
if publish is None and subscribe is None:
|
|
120
|
+
self._origin: OriginProducer | None = OriginProducer()
|
|
121
|
+
self._publish_origin: OriginProducer | None = self._origin
|
|
122
|
+
self._consume_origin: OriginProducer | None = self._origin
|
|
123
|
+
else:
|
|
124
|
+
self._origin = None
|
|
125
|
+
self._publish_origin = publish
|
|
126
|
+
self._consume_origin = subscribe
|
|
127
|
+
|
|
128
|
+
self._inner: MoqServer | None = None
|
|
129
|
+
self._local_addr: str | None = None
|
|
130
|
+
|
|
131
|
+
async def __aenter__(self):
|
|
132
|
+
self._inner = MoqServer()
|
|
133
|
+
self._inner.set_bind(self._bind)
|
|
134
|
+
if self._tls_cert:
|
|
135
|
+
self._inner.set_tls_cert(self._tls_cert)
|
|
136
|
+
if self._tls_key:
|
|
137
|
+
self._inner.set_tls_key(self._tls_key)
|
|
138
|
+
if self._tls_generate:
|
|
139
|
+
self._inner.set_tls_generate(self._tls_generate)
|
|
140
|
+
if self._publish_origin is not None:
|
|
141
|
+
self._inner.set_publish(self._publish_origin._inner)
|
|
142
|
+
if self._consume_origin is not None:
|
|
143
|
+
self._inner.set_consume(self._consume_origin._inner)
|
|
144
|
+
|
|
145
|
+
self._local_addr = await self._inner.listen()
|
|
146
|
+
return self
|
|
147
|
+
|
|
148
|
+
async def __aexit__(self, *exc) -> None:
|
|
149
|
+
if self._inner is not None:
|
|
150
|
+
self._inner.cancel()
|
|
151
|
+
self._inner = None
|
|
152
|
+
self._local_addr = None
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def local_addr(self) -> str:
|
|
156
|
+
"""The bound local address, available after entering the context manager."""
|
|
157
|
+
if self._local_addr is None:
|
|
158
|
+
raise RuntimeError("server not listening; use 'async with'")
|
|
159
|
+
return self._local_addr
|
|
160
|
+
|
|
161
|
+
def cert_fingerprints(self) -> list[str]:
|
|
162
|
+
"""SHA-256 fingerprints of the configured TLS certificates, hex-encoded.
|
|
163
|
+
|
|
164
|
+
Useful for pinning a generated self-signed certificate in a browser
|
|
165
|
+
via WebTransport's `serverCertificateHashes`.
|
|
166
|
+
"""
|
|
167
|
+
if self._inner is None:
|
|
168
|
+
raise RuntimeError("server not listening; use 'async with'")
|
|
169
|
+
return self._inner.cert_fingerprints()
|
|
170
|
+
|
|
171
|
+
def __aiter__(self):
|
|
172
|
+
return self
|
|
173
|
+
|
|
174
|
+
async def __anext__(self) -> Request:
|
|
175
|
+
if self._inner is None:
|
|
176
|
+
raise RuntimeError("server not listening; use 'async with'")
|
|
177
|
+
request = await self._inner.accept()
|
|
178
|
+
if request is None:
|
|
179
|
+
raise StopAsyncIteration
|
|
180
|
+
return Request(request)
|
|
181
|
+
|
|
182
|
+
def publish(self, path: str, broadcast: BroadcastProducer) -> None:
|
|
183
|
+
"""Publish a broadcast under the given path, served to incoming sessions."""
|
|
184
|
+
origin = self._publish_origin
|
|
185
|
+
if origin is None:
|
|
186
|
+
raise RuntimeError("no publish origin configured")
|
|
187
|
+
origin.publish(path, broadcast)
|
|
188
|
+
|
|
189
|
+
async def serve(
|
|
190
|
+
self,
|
|
191
|
+
on_request: Callable[[Request], Awaitable[bool | None]] | None = None,
|
|
192
|
+
) -> None:
|
|
193
|
+
"""Accept sessions in a loop, holding each one alive until it closes.
|
|
194
|
+
|
|
195
|
+
Each session is handled by its own task that awaits `session.closed()`,
|
|
196
|
+
so memory does not grow with the number of past connections.
|
|
197
|
+
|
|
198
|
+
Pass `on_request` to inspect a `Request` before accepting it; return
|
|
199
|
+
`False` (or raise) to reject the request with HTTP 403, `True` (or
|
|
200
|
+
`None`) to accept. For richer routing, hand-roll the accept loop
|
|
201
|
+
instead.
|
|
202
|
+
"""
|
|
203
|
+
session_tasks: set[asyncio.Task] = set()
|
|
204
|
+
|
|
205
|
+
async def serve_session(request: Request) -> None:
|
|
206
|
+
session = await request.ok()
|
|
207
|
+
await session.closed()
|
|
208
|
+
|
|
209
|
+
try:
|
|
210
|
+
async for request in self:
|
|
211
|
+
if on_request is not None:
|
|
212
|
+
try:
|
|
213
|
+
decision = await on_request(request)
|
|
214
|
+
except Exception:
|
|
215
|
+
await request.close(500)
|
|
216
|
+
raise
|
|
217
|
+
if decision is False:
|
|
218
|
+
await request.close(403)
|
|
219
|
+
continue
|
|
220
|
+
task = asyncio.create_task(serve_session(request))
|
|
221
|
+
session_tasks.add(task)
|
|
222
|
+
task.add_done_callback(session_tasks.discard)
|
|
223
|
+
finally:
|
|
224
|
+
# Cancel any session tasks still pending a handshake, but let
|
|
225
|
+
# established sessions wind down on their own.
|
|
226
|
+
for task in list(session_tasks):
|
|
227
|
+
if not task.done():
|
|
228
|
+
task.cancel()
|
moq/subscribe.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Consumer wrappers — subscribe to broadcasts, catalogs, and media tracks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from moq_ffi import (
|
|
6
|
+
Container,
|
|
7
|
+
MoqAudioConsumer,
|
|
8
|
+
MoqBroadcastConsumer,
|
|
9
|
+
MoqCatalogConsumer,
|
|
10
|
+
MoqGroupConsumer,
|
|
11
|
+
MoqMediaConsumer,
|
|
12
|
+
MoqTrackConsumer,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
from .types import Audio, AudioDecoderOutput, AudioFrame, Catalog, Frame
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class MediaConsumer:
|
|
19
|
+
"""Wraps MoqMediaConsumer as an async iterator of Frame."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, inner: MoqMediaConsumer) -> None:
|
|
22
|
+
self._inner = inner
|
|
23
|
+
|
|
24
|
+
def __aiter__(self):
|
|
25
|
+
return self
|
|
26
|
+
|
|
27
|
+
async def __anext__(self) -> Frame:
|
|
28
|
+
frame = await self._inner.next()
|
|
29
|
+
if frame is None:
|
|
30
|
+
raise StopAsyncIteration
|
|
31
|
+
return frame
|
|
32
|
+
|
|
33
|
+
def cancel(self) -> None:
|
|
34
|
+
self._inner.cancel()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class GroupConsumer:
|
|
38
|
+
"""Async iterator of byte payloads within a single group."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, inner: MoqGroupConsumer) -> None:
|
|
41
|
+
self._inner = inner
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def sequence(self) -> int:
|
|
45
|
+
"""The sequence number of this group within the track."""
|
|
46
|
+
return self._inner.sequence()
|
|
47
|
+
|
|
48
|
+
def __aiter__(self):
|
|
49
|
+
return self
|
|
50
|
+
|
|
51
|
+
async def __anext__(self) -> bytes:
|
|
52
|
+
frame = await self._inner.read_frame()
|
|
53
|
+
if frame is None:
|
|
54
|
+
raise StopAsyncIteration
|
|
55
|
+
return frame
|
|
56
|
+
|
|
57
|
+
def cancel(self) -> None:
|
|
58
|
+
self._inner.cancel()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class TrackConsumer:
|
|
62
|
+
"""Async iterator of groups from a track.
|
|
63
|
+
|
|
64
|
+
Each group is itself an async iterator of byte payloads. Same pattern as
|
|
65
|
+
moq-boy's status/command tracks (one frame per group), but multi-frame
|
|
66
|
+
groups are also supported.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def __init__(self, inner: MoqTrackConsumer) -> None:
|
|
70
|
+
self._inner = inner
|
|
71
|
+
|
|
72
|
+
def __aiter__(self):
|
|
73
|
+
return self
|
|
74
|
+
|
|
75
|
+
async def __anext__(self) -> GroupConsumer:
|
|
76
|
+
group = await self.recv_group()
|
|
77
|
+
if group is None:
|
|
78
|
+
raise StopAsyncIteration
|
|
79
|
+
return group
|
|
80
|
+
|
|
81
|
+
async def recv_group(self) -> GroupConsumer | None:
|
|
82
|
+
"""Return the next group in arrival order. Returns `None` when the track ends.
|
|
83
|
+
|
|
84
|
+
Groups are returned as they arrive on the wire, which may be out of sequence
|
|
85
|
+
order. Use this for live consumption where latency matters more than order.
|
|
86
|
+
"""
|
|
87
|
+
group = await self._inner.recv_group()
|
|
88
|
+
if group is None:
|
|
89
|
+
return None
|
|
90
|
+
return GroupConsumer(group)
|
|
91
|
+
|
|
92
|
+
async def next_group(self) -> GroupConsumer | None:
|
|
93
|
+
"""Return the next group in sequence order, skipping forward if behind.
|
|
94
|
+
|
|
95
|
+
Returns `None` when the track ends. Use this when order matters more than
|
|
96
|
+
latency; `recv_group` is preferred for live consumption.
|
|
97
|
+
"""
|
|
98
|
+
group = await self._inner.next_group()
|
|
99
|
+
if group is None:
|
|
100
|
+
return None
|
|
101
|
+
return GroupConsumer(group)
|
|
102
|
+
|
|
103
|
+
async def read_frame(self) -> bytes | None:
|
|
104
|
+
"""Read the first frame of the next group.
|
|
105
|
+
|
|
106
|
+
Convenience for tracks using one-frame-per-group (like moq-boy's
|
|
107
|
+
status/command tracks). Returns `None` when the track ends.
|
|
108
|
+
"""
|
|
109
|
+
return await self._inner.read_frame()
|
|
110
|
+
|
|
111
|
+
def cancel(self) -> None:
|
|
112
|
+
self._inner.cancel()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class AudioConsumer:
|
|
116
|
+
"""Async iterator of decoded audio frames.
|
|
117
|
+
|
|
118
|
+
Built via :meth:`BroadcastConsumer.subscribe_audio`. The PCM layout
|
|
119
|
+
is fixed by the :class:`AudioDecoderOutput` passed at subscribe
|
|
120
|
+
time; each frame's ``data`` is raw bytes in that format.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
def __init__(self, inner: MoqAudioConsumer) -> None:
|
|
124
|
+
self._inner = inner
|
|
125
|
+
|
|
126
|
+
def __aiter__(self):
|
|
127
|
+
return self
|
|
128
|
+
|
|
129
|
+
async def __anext__(self) -> AudioFrame:
|
|
130
|
+
frame = await self._inner.next()
|
|
131
|
+
if frame is None:
|
|
132
|
+
raise StopAsyncIteration
|
|
133
|
+
return frame
|
|
134
|
+
|
|
135
|
+
def cancel(self) -> None:
|
|
136
|
+
self._inner.cancel()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class CatalogConsumer:
|
|
140
|
+
"""Wraps MoqCatalogConsumer as an async iterator of Catalog."""
|
|
141
|
+
|
|
142
|
+
def __init__(self, inner: MoqCatalogConsumer) -> None:
|
|
143
|
+
self._inner = inner
|
|
144
|
+
|
|
145
|
+
def __aiter__(self):
|
|
146
|
+
return self
|
|
147
|
+
|
|
148
|
+
async def __anext__(self) -> Catalog:
|
|
149
|
+
catalog = await self._inner.next()
|
|
150
|
+
if catalog is None:
|
|
151
|
+
raise StopAsyncIteration
|
|
152
|
+
return catalog
|
|
153
|
+
|
|
154
|
+
def cancel(self) -> None:
|
|
155
|
+
self._inner.cancel()
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class BroadcastConsumer:
|
|
159
|
+
"""Wraps MoqBroadcastConsumer with convenience methods."""
|
|
160
|
+
|
|
161
|
+
def __init__(self, inner: MoqBroadcastConsumer) -> None:
|
|
162
|
+
self._inner = inner
|
|
163
|
+
|
|
164
|
+
def subscribe_catalog(self) -> CatalogConsumer:
|
|
165
|
+
return CatalogConsumer(self._inner.subscribe_catalog())
|
|
166
|
+
|
|
167
|
+
def subscribe_track(self, name: str) -> TrackConsumer:
|
|
168
|
+
"""Subscribe to a track — receive arbitrary byte payloads."""
|
|
169
|
+
return TrackConsumer(self._inner.subscribe_track(name))
|
|
170
|
+
|
|
171
|
+
def subscribe_media(self, name: str, container: Container, max_latency_ms: int) -> MediaConsumer:
|
|
172
|
+
return MediaConsumer(self._inner.subscribe_media(name, container, max_latency_ms))
|
|
173
|
+
|
|
174
|
+
def subscribe_audio(
|
|
175
|
+
self,
|
|
176
|
+
name: str,
|
|
177
|
+
catalog_audio: Audio,
|
|
178
|
+
output: AudioDecoderOutput,
|
|
179
|
+
) -> AudioConsumer:
|
|
180
|
+
"""Subscribe to a raw-audio track; samples come back in the format
|
|
181
|
+
declared by ``output``.
|
|
182
|
+
|
|
183
|
+
``catalog_audio`` comes from the catalog (e.g.
|
|
184
|
+
``await broadcast.catalog()`` followed by
|
|
185
|
+
``catalog.audio[name]``). Use ``output.latency_max_ms`` to
|
|
186
|
+
control how aggressively stalled groups get skipped — that's
|
|
187
|
+
the congestion-control knob. (Named ``_max`` to leave room for
|
|
188
|
+
a future ``latency_min_ms`` jitter-buffer floor.)
|
|
189
|
+
"""
|
|
190
|
+
return AudioConsumer(self._inner.subscribe_audio(name, catalog_audio, output))
|
|
191
|
+
|
|
192
|
+
async def catalog(self) -> Catalog:
|
|
193
|
+
"""Convenience: subscribe and return the first catalog."""
|
|
194
|
+
consumer = self.subscribe_catalog()
|
|
195
|
+
return await anext(consumer)
|
moq/types.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Re-export moq-ffi record types without the Moq prefix."""
|
|
2
|
+
|
|
3
|
+
from moq_ffi import (
|
|
4
|
+
MoqAudio as Audio,
|
|
5
|
+
)
|
|
6
|
+
from moq_ffi import (
|
|
7
|
+
MoqAudioCodec as AudioCodec,
|
|
8
|
+
)
|
|
9
|
+
from moq_ffi import (
|
|
10
|
+
MoqAudioDecoderOutput as AudioDecoderOutput,
|
|
11
|
+
)
|
|
12
|
+
from moq_ffi import (
|
|
13
|
+
MoqAudioEncoderInput as AudioEncoderInput,
|
|
14
|
+
)
|
|
15
|
+
from moq_ffi import (
|
|
16
|
+
MoqAudioEncoderOutput as AudioEncoderOutput,
|
|
17
|
+
)
|
|
18
|
+
from moq_ffi import (
|
|
19
|
+
MoqAudioFormat as AudioFormat,
|
|
20
|
+
)
|
|
21
|
+
from moq_ffi import (
|
|
22
|
+
MoqAudioFrame as AudioFrame,
|
|
23
|
+
)
|
|
24
|
+
from moq_ffi import (
|
|
25
|
+
MoqCatalog as Catalog,
|
|
26
|
+
)
|
|
27
|
+
from moq_ffi import (
|
|
28
|
+
MoqDimensions as Dimensions,
|
|
29
|
+
)
|
|
30
|
+
from moq_ffi import (
|
|
31
|
+
MoqFrame as Frame,
|
|
32
|
+
)
|
|
33
|
+
from moq_ffi import (
|
|
34
|
+
MoqVideo as Video,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"Audio",
|
|
39
|
+
"AudioCodec",
|
|
40
|
+
"AudioDecoderOutput",
|
|
41
|
+
"AudioEncoderInput",
|
|
42
|
+
"AudioEncoderOutput",
|
|
43
|
+
"AudioFormat",
|
|
44
|
+
"AudioFrame",
|
|
45
|
+
"Catalog",
|
|
46
|
+
"Dimensions",
|
|
47
|
+
"Frame",
|
|
48
|
+
"Video",
|
|
49
|
+
]
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: moq-rs
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Media over QUIC: real-time pub/sub with built-in caching, fan-out, and prioritization, with a Pythonic API. Import as `moq`.
|
|
5
|
+
Project-URL: Homepage, https://github.com/moq-dev/moq
|
|
6
|
+
Project-URL: Repository, https://github.com/moq-dev/moq
|
|
7
|
+
Author-email: Luke Curley <kixelated@gmail.com>
|
|
8
|
+
License-Expression: MIT OR Apache-2.0
|
|
9
|
+
Keywords: http3,live,media,quic,streaming,webtransport
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Multimedia :: Sound/Audio
|
|
14
|
+
Classifier: Topic :: Multimedia :: Video
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Requires-Dist: moq-ffi~=0.2.16
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# moq
|
|
20
|
+
|
|
21
|
+
Python bindings for [Media over QUIC](https://github.com/moq-dev/moq): real-time pub/sub with built-in caching, fan-out, and prioritization, on top of QUIC.
|
|
22
|
+
|
|
23
|
+
`moq` wraps the auto-generated [`moq-ffi`](https://pypi.org/project/moq-ffi/) UniFFI bindings with a Pythonic API: no `Moq` prefixes, async iterators, context managers, and simplified connection setup. At session setup it negotiates either the `moq-lite` or `moq-transport` wire protocol.
|
|
24
|
+
|
|
25
|
+
## Installation
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install moq
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
This pulls in the `moq-ffi` native bindings automatically. `moq` is pure Python and is versioned independently of `moq-ffi`; it floats to the latest compatible `moq-ffi` patch.
|
|
32
|
+
|
|
33
|
+
## Quick Start
|
|
34
|
+
|
|
35
|
+
### Subscribe to a stream
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
import asyncio
|
|
39
|
+
import moq
|
|
40
|
+
|
|
41
|
+
async def main():
|
|
42
|
+
async with moq.Client("https://relay.quic.video") as client:
|
|
43
|
+
async for announcement in client.announced():
|
|
44
|
+
catalog = await announcement.broadcast.catalog()
|
|
45
|
+
|
|
46
|
+
for name in catalog.audio:
|
|
47
|
+
async for frame in announcement.broadcast.subscribe_media(name):
|
|
48
|
+
print(f"Got frame: {len(frame.payload)} bytes, ts={frame.timestamp_us}")
|
|
49
|
+
|
|
50
|
+
asyncio.run(main())
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Publish a stream
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
import asyncio
|
|
57
|
+
import moq
|
|
58
|
+
|
|
59
|
+
async def main():
|
|
60
|
+
async with moq.Client("https://relay.quic.video") as client:
|
|
61
|
+
broadcast = moq.BroadcastProducer()
|
|
62
|
+
|
|
63
|
+
# Publish an Opus audio track (init bytes from your encoder)
|
|
64
|
+
audio = broadcast.publish_media("opus", opus_init_bytes)
|
|
65
|
+
client.publish("my-stream", broadcast)
|
|
66
|
+
|
|
67
|
+
# Write frames
|
|
68
|
+
audio.write_frame(payload, timestamp_us=0)
|
|
69
|
+
audio.write_frame(payload, timestamp_us=20000)
|
|
70
|
+
|
|
71
|
+
# Clean up
|
|
72
|
+
audio.finish()
|
|
73
|
+
broadcast.finish()
|
|
74
|
+
|
|
75
|
+
asyncio.run(main())
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### Host a server
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
import asyncio
|
|
82
|
+
import moq
|
|
83
|
+
|
|
84
|
+
async def main():
|
|
85
|
+
async with moq.Server("127.0.0.1:4443", tls_generate=["localhost"]) as server:
|
|
86
|
+
broadcast = moq.BroadcastProducer()
|
|
87
|
+
track = broadcast.publish_track("events")
|
|
88
|
+
server.publish("hello", broadcast)
|
|
89
|
+
print(f"listening on https://{server.local_addr}")
|
|
90
|
+
|
|
91
|
+
sessions = []
|
|
92
|
+
async for request in server:
|
|
93
|
+
print(f" + {request.transport} from {request.url}")
|
|
94
|
+
sessions.append(await request.ok())
|
|
95
|
+
|
|
96
|
+
asyncio.run(main())
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Reject a request instead of accepting it with `await request.close(403)`.
|
|
100
|
+
|
|
101
|
+
### Advanced: Manual origin wiring
|
|
102
|
+
|
|
103
|
+
For full control over the origin topology:
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
import moq
|
|
107
|
+
|
|
108
|
+
origin = moq.OriginProducer()
|
|
109
|
+
client = moq.Client(
|
|
110
|
+
"https://relay.quic.video",
|
|
111
|
+
publish=origin,
|
|
112
|
+
subscribe=origin,
|
|
113
|
+
)
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## API
|
|
117
|
+
|
|
118
|
+
### Connection
|
|
119
|
+
|
|
120
|
+
- **`Client(url, *, tls_verify=True, bind=None, publish=None, subscribe=None)`**. Async context manager for connecting to a relay.
|
|
121
|
+
- **`Server(bind="[::]:443", *, tls_cert=(), tls_key=(), tls_generate=(), publish=None, subscribe=None)`**. Async context manager + async iterator of incoming `Request`s.
|
|
122
|
+
- `.local_addr`. The bound address (useful when binding to port `0`).
|
|
123
|
+
- `.cert_fingerprints()`. SHA-256 fingerprints of the configured TLS certificates, for `serverCertificateHashes` browser cert pinning.
|
|
124
|
+
- `.publish(path, broadcast)`. Publish a broadcast to be served.
|
|
125
|
+
- **`Request`**. An incoming session, yielded by `async for request in server`.
|
|
126
|
+
- `.url`, `.transport`. Properties.
|
|
127
|
+
- `.set_publish(origin)`, `.set_consume(origin)`. Per-request overrides.
|
|
128
|
+
- `await .ok()`. Complete the handshake, returns a session (hold it to keep the connection alive).
|
|
129
|
+
- `await .close(code)`. Reject with an HTTP status code.
|
|
130
|
+
- `.cancel()`. Cancel an in-flight `ok()`/`close()` call.
|
|
131
|
+
|
|
132
|
+
### Publishing
|
|
133
|
+
|
|
134
|
+
- **`BroadcastProducer()`**. Create a broadcast to publish tracks into.
|
|
135
|
+
- `.publish_media(format, init) → MediaProducer`
|
|
136
|
+
- `.finish()`
|
|
137
|
+
- **`MediaProducer`**. Write frames to a track.
|
|
138
|
+
- `.write_frame(payload, timestamp_us)`
|
|
139
|
+
- `.finish()`
|
|
140
|
+
|
|
141
|
+
### Subscribing
|
|
142
|
+
|
|
143
|
+
- **`BroadcastConsumer`**. Subscribe to tracks within a broadcast.
|
|
144
|
+
- `.subscribe_catalog() → CatalogConsumer`
|
|
145
|
+
- `.subscribe_media(name, max_latency_ms=10000) → MediaConsumer`
|
|
146
|
+
- `await .catalog() → Catalog` (convenience)
|
|
147
|
+
- **`CatalogConsumer`**. Async iterator of `Catalog`.
|
|
148
|
+
- **`MediaConsumer`**. Async iterator of `Frame`.
|
|
149
|
+
|
|
150
|
+
### Origin (advanced)
|
|
151
|
+
|
|
152
|
+
- **`OriginProducer()`**. Manage broadcast announcements.
|
|
153
|
+
- `.consume() → OriginConsumer`
|
|
154
|
+
- `.publish(path, broadcast)`
|
|
155
|
+
- **`OriginConsumer`**. Discover broadcasts.
|
|
156
|
+
- `.announced(prefix) → Announced` (async iterator)
|
|
157
|
+
- `.announced_broadcast(path) → AnnouncedBroadcast` (awaitable)
|
|
158
|
+
|
|
159
|
+
### Types
|
|
160
|
+
|
|
161
|
+
- **`Catalog`**. `.audio: dict[str, Audio]`, `.video: dict[str, Video]`, `.display`, `.rotation`, `.flip`.
|
|
162
|
+
- **`Frame`**. `.payload: bytes`, `.timestamp_us: int`, `.keyframe: bool`.
|
|
163
|
+
- **`Audio`**. `.codec`, `.sample_rate`, `.channel_count`, `.bitrate`, `.description`.
|
|
164
|
+
- **`Video`**. `.codec`, `.coded: Dimensions`, `.display_ratio`, `.bitrate`, `.framerate`, `.description`.
|
|
165
|
+
- **`Dimensions`**. `.width: int`, `.height: int`.
|
|
166
|
+
|
|
167
|
+
## See Also
|
|
168
|
+
|
|
169
|
+
- [`moq-ffi`](https://pypi.org/project/moq-ffi/). The raw UniFFI bindings this package wraps. Use it directly only if you need the unwrapped `Moq`-prefixed API.
|
|
170
|
+
- [MoQ project](https://github.com/moq-dev/moq). Full monorepo with Rust server, TypeScript browser lib, and more.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
moq/__init__.py,sha256=Q0tqutMcHvQZPygRi-rT5YAEdNMlCiNHxlEgbDUBQ6c,1459
|
|
2
|
+
moq/client.py,sha256=QrUwQAyNs9yxGGN3MzybIm6xfDZo_LAkUV5Zs1QEooI,3291
|
|
3
|
+
moq/origin.py,sha256=8jXYhw6rpHoKoTR8t7YJngBu_smPtKs3b_rH_ZzgPec,2621
|
|
4
|
+
moq/publish.py,sha256=ZGCWoGoEX1J-2OmzJTlu_tGNsgm-IOitx2bMM4C9ehM,5894
|
|
5
|
+
moq/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
moq/server.py,sha256=NT07HT4IQFXVorcJQL_NdTlYwPztPnt_WKILxLDLVng,8421
|
|
7
|
+
moq/subscribe.py,sha256=Jl2kw9YUKi0NvBduTS82glLYz5pPrkuCFl_gFGKbcUM,5939
|
|
8
|
+
moq/types.py,sha256=o1K7iNY2spINxHlWPYKFEDXjnIxoAEyCQ4QMNyZ9kzI,920
|
|
9
|
+
moq_rs-0.3.0.dist-info/METADATA,sha256=D_I7yZhcQ0hLta5nQv4-4hyiOSsyrhaf2nlVRL_nVa0,6088
|
|
10
|
+
moq_rs-0.3.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
11
|
+
moq_rs-0.3.0.dist-info/RECORD,,
|