use std::marker::PhantomData;
use std::time::Instant;
use moq_net::Timed;
use serde::Serialize;
use serde::de::DeserializeOwned;
use hang::catalog::{Compression, JsonConfig, Mode};
use crate::catalog::hang::CatalogExt;
use crate::catalog::{IntoRendition, Listing, RenditionConfig};
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
pub struct Config {
pub compression: bool,
pub schema: Option<String>,
pub delta_ratio: Option<u32>,
}
impl Config {
pub fn with_compression(mut self, compression: bool) -> Self {
self.compression = compression;
self
}
pub fn with_schema(mut self, schema: impl Into<String>) -> Self {
self.schema = Some(schema.into());
self
}
pub fn with_delta_ratio(mut self, delta_ratio: u32) -> Self {
self.delta_ratio = Some(delta_ratio);
self
}
}
impl<E: CatalogExt> IntoRendition<E, JsonConfig> for Config {
type Config = JsonConfig;
fn into_rendition(self) -> JsonConfig {
let mut entry = JsonConfig::new(Mode::Snapshot);
entry.compression = self.compression.then_some(Compression::Deflate);
entry.schema = self.schema;
entry
}
}
fn prepare(config: &mut impl AsMut<JsonConfig>, mode: Mode) -> crate::Result<bool> {
let json = config.as_mut();
if json.broadcast.is_some() {
return Err(crate::Error::ForeignBroadcast);
}
json.mode = mode;
crate::compression(json.compression.as_ref())
}
fn delta_ratio_of<C: std::any::Any>(config: &C) -> Option<u32> {
(config as &dyn std::any::Any)
.downcast_ref::<Config>()
.and_then(|config| config.delta_ratio)
}
pub struct Snapshot<T, E: CatalogExt = ()> {
inner: moq_json::snapshot::Producer<T>,
listing: Listing,
clock: crate::Clock,
_catalog: PhantomData<fn() -> E>,
}
impl<T: Serialize, E: CatalogExt> Snapshot<T, E> {
pub(crate) fn new<C>(
track: moq_net::track::Producer,
rendition: crate::catalog::Rendition<E, C::Config>,
config: C,
) -> crate::Result<Self>
where
C: IntoRendition<E, JsonConfig> + std::any::Any,
{
let delta_ratio = delta_ratio_of(&config);
let mut config = config.into_rendition();
let mut json = moq_json::snapshot::Config::default();
if prepare(&mut config, Mode::Snapshot)? {
json.compression = moq_json::Compression::Deflate;
}
if let Some(delta_ratio) = delta_ratio {
json.delta_ratio = delta_ratio;
}
let inner = moq_json::snapshot::Producer::new(track, json);
let clock = rendition.clock();
let listing = Listing::new(rendition, config)?;
Ok(Self {
inner,
listing,
clock,
_catalog: PhantomData,
})
}
pub fn name(&self) -> &str {
self.listing.name()
}
pub fn consume(&self) -> moq_net::track::Subscriber {
self.inner.consume()
}
pub fn demand(&self) -> moq_net::track::Demand {
self.inner.demand()
}
pub fn update<'a>(&mut self, value: impl Into<Timed<&'a T, Instant>>) -> crate::Result<()>
where
T: 'a,
{
let (value, captured) = self.clock.stamp(value.into())?;
match self.inner.update(value)? {
Some(size) => self.listing.record(size, captured),
None => Ok(()),
}
}
pub fn finish(mut self) -> crate::Result<()> {
Ok(self.inner.finish()?)
}
}
pub struct Stream<T, E: CatalogExt = ()> {
inner: moq_json::stream::Producer<T>,
name: String,
listing: Option<Listing>,
clock: crate::Clock,
_catalog: PhantomData<fn() -> E>,
}
impl<T: Serialize, E: CatalogExt> Stream<T, E> {
pub(crate) fn new<C: RenditionConfig<E> + AsMut<JsonConfig>>(
track: moq_net::track::Producer,
rendition: crate::catalog::Rendition<E, C>,
mut config: C,
) -> crate::Result<Self> {
let mut json = moq_json::stream::Config::default();
if prepare(&mut config, Mode::Stream)? {
json.compression = moq_json::Compression::Deflate;
}
let inner = moq_json::stream::Producer::new(track, json);
let clock = rendition.clock();
let listing = Listing::new(rendition, config)?;
Ok(Self {
inner,
name: listing.name().to_string(),
listing: Some(listing),
clock,
_catalog: PhantomData,
})
}
pub fn name(&self) -> &str {
&self.name
}
pub fn consume(&self) -> moq_net::track::Subscriber {
self.inner.consume()
}
pub fn demand(&self) -> moq_net::track::Demand {
self.inner.demand()
}
pub fn append<'a>(&mut self, value: impl Into<Timed<&'a T, Instant>>) -> crate::Result<()>
where
T: 'a,
{
let (value, captured) = self.clock.stamp(value.into())?;
let size = match self.inner.append(value) {
Ok(size) => size,
Err(err) => {
self.listing = None;
return Err(err.into());
}
};
match &mut self.listing {
Some(listing) => listing.record(size, captured),
None => Ok(()),
}
}
pub fn finish(mut self) -> crate::Result<()> {
Ok(self.inner.finish()?)
}
}
pub struct Consumer<T> {
inner: Inner<T>,
mode: Mode,
}
enum Inner<T> {
Snapshot(moq_json::snapshot::Consumer<T>),
Stream(moq_json::stream::Consumer<T>),
}
impl<T: DeserializeOwned> Consumer<T> {
pub fn from_track(track: moq_net::track::Subscriber, config: &JsonConfig) -> crate::Result<Self> {
let compression = crate::compression(config.compression.as_ref())?;
let inner = match &config.mode {
Mode::Snapshot => {
let mut json = moq_json::snapshot::consumer::Config::default();
if compression {
json.compression = moq_json::Compression::Deflate;
}
Inner::Snapshot(moq_json::snapshot::Consumer::new(track, json))
}
Mode::Stream => {
let mut json = moq_json::stream::Config::default();
if compression {
json.compression = moq_json::Compression::Deflate;
}
Inner::Stream(moq_json::stream::Consumer::new(track, json))
}
other => return Err(crate::Error::UnsupportedMode(other.to_string())),
};
Ok(Self {
inner,
mode: config.mode.clone(),
})
}
pub fn mode(&self) -> &Mode {
&self.mode
}
pub async fn next(&mut self) -> crate::Result<Option<T>> {
kio::wait(|waiter| self.poll_next(waiter)).await
}
pub fn poll_next(&mut self, waiter: &kio::Waiter) -> std::task::Poll<crate::Result<Option<T>>> {
let result = match &mut self.inner {
Inner::Snapshot(consumer) => consumer.poll_next(waiter),
Inner::Stream(consumer) => consumer.poll_next(waiter),
};
match result {
std::task::Poll::Ready(value) => std::task::Poll::Ready(value.map_err(Into::into)),
std::task::Poll::Pending => std::task::Poll::Pending,
}
}
}
impl crate::catalog::Entry<'_, JsonConfig> {
pub async fn subscribe<T: DeserializeOwned>(&self, source: &crate::Source) -> crate::Result<Consumer<T>> {
let track = source
.subscribe_track(self.config().broadcast.as_ref(), self.name())
.await?;
Consumer::from_track(track, self.config())
}
}
#[cfg(test)]
mod test {
use std::task::Poll;
use serde_json::{Value, json};
use super::*;
fn catalog() -> (moq_net::broadcast::Producer, crate::catalog::Producer) {
let mut broadcast = moq_net::broadcast::Info::new().produce();
let catalog = crate::catalog::Producer::new(&mut broadcast, crate::catalog::Config::default()).unwrap();
(broadcast, catalog)
}
fn track(broadcast: &mut moq_net::broadcast::Producer, name: &str) -> moq_net::track::Producer {
broadcast.create_track(name, None).unwrap()
}
fn entry(catalog: &crate::catalog::Producer, name: &str) -> JsonConfig {
catalog
.snapshot()
.json
.tracks
.get(name)
.expect("missing catalog entry")
.clone()
}
fn drain(mut consumer: Consumer<Value>) -> Vec<Value> {
let waiter = kio::Waiter::noop();
let mut out = Vec::new();
while let Poll::Ready(Ok(Some(value))) = consumer.poll_next(&waiter) {
out.push(value);
}
out
}
#[test]
fn a_stream_track_roundtrips() {
let (mut broadcast, catalog) = catalog();
let mut chat = catalog
.json_stream::<Value>(track(&mut broadcast, "chat"), Config::default().with_compression(true))
.unwrap();
let track = chat.consume();
let expected: Vec<Value> = (0..3).map(|n| json!({ "n": n })).collect();
for value in &expected {
chat.append(value).unwrap();
}
let entry = entry(&catalog, "chat");
chat.finish().unwrap();
let consumer = Consumer::from_track(track, &entry).unwrap();
assert_eq!(consumer.mode(), &Mode::Stream);
assert_eq!(drain(consumer), expected);
}
#[test]
fn a_snapshot_track_roundtrips() {
let (mut broadcast, catalog) = catalog();
let mut status = catalog
.json_snapshot::<Value>(track(&mut broadcast, "status"), Config::default())
.unwrap();
let track = status.consume();
status.update(&json!({ "live": false })).unwrap();
status.update(&json!({ "live": true })).unwrap();
let entry = entry(&catalog, "status");
status.finish().unwrap();
let consumer = Consumer::from_track(track, &entry).unwrap();
assert_eq!(consumer.mode(), &Mode::Snapshot);
assert_eq!(drain(consumer), vec![json!({ "live": true })]);
}
#[test]
fn a_config_delta_ratio_reaches_the_encoder() {
let (mut broadcast, catalog) = catalog();
let mut full = catalog
.json_snapshot::<Value>(track(&mut broadcast, "full"), Config::default().with_delta_ratio(0))
.unwrap();
let mut delta = catalog
.json_snapshot::<Value>(track(&mut broadcast, "delta"), Config::default().with_delta_ratio(100))
.unwrap();
let mut full_track = full.consume();
let mut delta_track = delta.consume();
for value in [json!({ "n": 1 }), json!({ "n": 2 })] {
full.update(&value).unwrap();
delta.update(&value).unwrap();
}
full.finish().unwrap();
delta.finish().unwrap();
assert_eq!(ready_groups(&mut full_track), vec![1]);
assert_eq!(ready_groups(&mut delta_track), vec![2]);
}
fn ready_groups(subscriber: &mut moq_net::track::Subscriber) -> Vec<usize> {
let waiter = kio::Waiter::noop();
let mut counts = Vec::new();
loop {
match subscriber.poll_recv_group(&waiter) {
Poll::Ready(Ok(Some(group))) => counts.push(group.frame_count()),
Poll::Ready(Ok(None)) | Poll::Pending => return counts,
Poll::Ready(Err(err)) => panic!("group ended in error: {err}"),
}
}
}
#[test]
fn the_entry_describes_how_to_read_the_track() {
let (mut broadcast, catalog) = catalog();
let _chat = catalog
.json_stream::<Value>(
track(&mut broadcast, "chat"),
Config::default()
.with_compression(true)
.with_schema("https://example.com/chat.schema.json"),
)
.unwrap();
let entry = entry(&catalog, "chat");
assert_eq!(entry.mode, Mode::Stream);
assert_eq!(entry.compression, Some(Compression::Deflate));
assert_eq!(entry.schema.as_deref(), Some("https://example.com/chat.schema.json"));
assert_eq!(_chat.name(), "chat");
}
#[test]
fn dropping_the_producer_retires_the_entry() {
let (mut broadcast, catalog) = catalog();
let chat = catalog
.json_stream::<Value>(track(&mut broadcast, "chat"), Config::default())
.unwrap();
assert!(catalog.snapshot().json.tracks.contains_key("chat"));
drop(chat);
assert!(
!catalog.snapshot().json.tracks.contains_key("chat"),
"a track with no publisher must not stay advertised"
);
}
#[test]
fn finishing_retires_the_entry_too() {
let (mut broadcast, catalog) = catalog();
let chat = catalog
.json_stream::<Value>(track(&mut broadcast, "chat"), Config::default())
.unwrap();
assert!(catalog.snapshot().json.tracks.contains_key("chat"));
chat.finish().unwrap();
assert!(!catalog.snapshot().json.tracks.contains_key("chat"));
}
#[test]
fn an_existing_catalog_entry_is_not_replaced() {
let mut broadcast = moq_net::broadcast::Info::new().produce();
let mut existing = JsonConfig::new(Mode::Snapshot);
existing.broadcast = Some(moq_net::path::RelativeOwned::new("source"));
let mut seed = crate::catalog::hang::Catalog::<()>::default();
seed.json.tracks.insert("chat".to_string(), existing.clone());
let config = crate::catalog::Config::default().with_catalog(seed);
let catalog = crate::catalog::Producer::new(&mut broadcast, config).unwrap();
assert!(matches!(
catalog.json_stream::<Value>(track(&mut broadcast, "chat"), Config::default()),
Err(crate::Error::Hang(hang::Error::Duplicate(_)))
));
assert_eq!(catalog.snapshot().json.tracks.get("chat"), Some(&existing));
}
#[test]
fn writes_fill_an_absent_bitrate() {
let (mut broadcast, catalog) = catalog();
let mut gps = catalog
.json_stream::<Value>(track(&mut broadcast, "gps"), Config::default())
.unwrap();
let mut supplied = JsonConfig::new(Mode::Stream);
supplied.bitrate = Some(4_200);
let mut status = catalog
.json_snapshot::<Value>(track(&mut broadcast, "status"), supplied)
.unwrap();
for i in 0..60u64 {
let now = moq_net::Timestamp::from_micros(i * 40_000).unwrap();
gps.listing.as_mut().unwrap().record_at(now, 500, None).unwrap();
status.listing.record_at(now, 500, None).unwrap();
}
assert_eq!(entry(&catalog, "gps").bitrate, Some(100_000));
assert_eq!(entry(&catalog, "status").bitrate, Some(4_200));
assert_eq!(
entry(&catalog, "gps").jitter,
None,
"write spacing is not a flush delay"
);
}
#[test]
fn an_unchanged_snapshot_measures_nothing() {
let (mut broadcast, catalog) = catalog();
let mut status = catalog
.json_snapshot::<Value>(track(&mut broadcast, "status"), Config::default())
.unwrap();
let now = std::time::Instant::now();
let value = serde_json::json!({ "armed": true });
status.update(Timed::from(&value).at(now)).unwrap();
let stale = now - std::time::Duration::from_secs(1);
status.update(Timed::from(&value).at(stale)).unwrap();
assert_eq!(entry(&catalog, "status").jitter, None);
let changed = serde_json::json!({ "armed": false });
status.update(Timed::from(&changed).at(stale)).unwrap();
let jitter = entry(&catalog, "status").jitter.expect("a late capture is jitter");
assert!(jitter >= std::time::Duration::from_secs(1), "{jitter:?}");
}
#[test]
fn the_catalog_enumerates_its_tracks() {
let (mut broadcast, catalog) = catalog();
let _chat = catalog
.json_stream::<Value>(track(&mut broadcast, "chat"), Config::default())
.unwrap();
let _status = catalog
.json_snapshot::<Value>(track(&mut broadcast, "status"), Config::default())
.unwrap();
let snapshot = catalog.snapshot();
let found: Vec<(&str, &Mode)> = snapshot
.json
.tracks
.iter()
.map(|(name, config)| (name.as_str(), &config.mode))
.collect();
assert_eq!(found, vec![("chat", &Mode::Stream), ("status", &Mode::Snapshot)]);
assert!(!snapshot.json.tracks.contains_key("nope"));
let chat = crate::catalog::Entry::new("chat", snapshot.json.tracks.get("chat").expect("missing entry"));
assert_eq!(chat.name(), "chat");
assert_eq!(chat.mode, Mode::Stream);
}
#[tokio::test]
async fn an_entry_subscribes_and_reads() {
let mut broadcast = moq_net::broadcast::Info::new().produce();
let catalog = crate::catalog::Producer::new(&mut broadcast, crate::catalog::Config::default()).unwrap();
let source = crate::source::announced(&broadcast.consume());
let mut chat = catalog
.json_stream::<Value>(track(&mut broadcast, "chat"), Config::default())
.unwrap();
chat.append(&json!({ "text": "hello" })).unwrap();
let snapshot = catalog.snapshot();
let entry = crate::catalog::Entry::new("chat", snapshot.json.tracks.get("chat").expect("missing entry"));
let mut consumer = entry.subscribe::<Value>(&source).await.unwrap();
chat.finish().unwrap();
assert_eq!(consumer.next().await.unwrap(), Some(json!({ "text": "hello" })));
assert_eq!(consumer.next().await.unwrap(), None);
}
#[test]
fn an_unserializable_record_ends_the_track_and_the_entry() {
struct Record(bool);
impl Serialize for Record {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
match self.0 {
true => Err(serde::ser::Error::custom("cannot serialize")),
false => serializer.serialize_u8(0),
}
}
}
let (mut broadcast, catalog) = catalog();
let mut chat = catalog
.json_stream::<Record>(track(&mut broadcast, "chat"), Config::default())
.unwrap();
let mut track = chat.consume();
assert!(chat.append(&Record(true)).is_err());
assert!(!catalog.snapshot().json.tracks.contains_key("chat"));
assert!(
chat.append(&Record(false)).is_err(),
"a record after the failure would land on a track the retired entry no longer advertises"
);
let waiter = kio::Waiter::noop();
assert!(matches!(track.poll_recv_group(&waiter), Poll::Ready(Err(_))));
}
#[test]
fn an_unrecognized_mode_or_compression_is_refused() {
let track = moq_net::broadcast::Info::new()
.produce()
.create_track("chat", None)
.unwrap();
let mut config = JsonConfig::new(Mode::Unknown("windowed".to_string()));
assert!(matches!(
Consumer::<Value>::from_track(track.subscribe(None), &config),
Err(crate::Error::UnsupportedMode(_))
));
config.mode = Mode::Stream;
config.compression = Some(Compression::Unknown("zstd".to_string()));
assert!(matches!(
Consumer::<Value>::from_track(track.subscribe(None), &config),
Err(crate::Error::UnsupportedCompression(_))
));
}
}