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 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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any