use serde::{Deserialize, Serialize};
pub trait CatalogExt: moq_msf::CatalogExt {}
impl CatalogExt for () {}
#[derive(Serialize, Clone, Default, Debug, PartialEq)]
#[serde(transparent)]
pub struct Extra(serde_json::Map<String, serde_json::Value>);
impl CatalogExt for Extra {}
impl<'de> Deserialize<'de> for Extra {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let sections = serde_json::Map::<String, serde_json::Value>::deserialize(deserializer)?;
if let Some(name) = sections.keys().find(|name| reserved(name)) {
return Err(serde::de::Error::custom(format!("reserved catalog section: {name}")));
}
Ok(Self(sections))
}
}
fn reserved(name: &str) -> bool {
matches!(
name,
"video" | "audio" | "text" | "archive" | "clock" | "json" | "binary" | "timeline"
) || moq_msf::reserved_root(name)
}
impl Extra {
pub fn get(&self, name: &str) -> Option<&serde_json::Value> {
self.0.get(name)
}
pub fn iter(&self) -> impl Iterator<Item = (&String, &serde_json::Value)> {
self.0.iter()
}
pub fn len(&self) -> usize {
self.0.len()
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub fn set(&mut self, name: impl Into<String>, value: serde_json::Value) -> crate::Result<()> {
let name = name.into();
if reserved(&name) {
return Err(crate::Error::ReservedSection(name));
}
self.0.insert(name, value);
Ok(())
}
pub fn remove(&mut self, name: &str) -> Option<serde_json::Value> {
self.0.remove(name)
}
}
#[cfg(test)]
mod test {
use std::task::Poll;
use hang::Catalog;
use serde::{Deserialize, Serialize};
use super::*;
#[derive(Serialize, Deserialize, Clone, Default, PartialEq, Debug)]
struct Scte35Ext {
#[serde(skip_serializing_if = "Option::is_none")]
scte35: Option<Scte35>,
}
#[derive(Serialize, Deserialize, Clone, Default, PartialEq, Debug)]
struct Scte35 {
splice_id: u32,
}
impl CatalogExt for Scte35Ext {}
#[test]
fn legacy_text_section_keeps_the_catalog() {
let json =
r#"{"video":{"renditions":{}},"audio":{"renditions":{}},"text":{"overlay":"hi"},"scte35":{"spliceId":7}}"#;
let catalog: Catalog<Extra> = serde_json::from_str(json).expect("legacy text section broke the catalog");
assert!(catalog.text.is_empty());
assert!(catalog.ext.get("scte35").is_some(), "unrelated sections still decode");
}
#[test]
fn retired_timeline_section_is_rejected() {
let json = r#"{"timeline":{"track":"timeline.json"}}"#;
let error = serde_json::from_str::<Catalog<Extra>>(json).expect_err("retired timeline section was accepted");
assert!(error.to_string().contains("reserved catalog section: timeline"));
}
#[test]
fn extension_roundtrip() {
let mut broadcast = moq_net::broadcast::Info::new().produce();
let config = crate::catalog::Config::default().with_catalog(Catalog::<Scte35Ext>::default());
let mut producer = crate::catalog::Producer::new(&mut broadcast, config).unwrap();
let mut consumer = producer.consume().unwrap();
producer.modify().unwrap().audio.renditions.insert(
"audio0".to_string(),
hang::catalog::AudioConfig::new(hang::catalog::AudioCodec::Opus, 48_000, 2),
);
producer.modify().unwrap().ext.scte35 = Some(Scte35 { splice_id: 42 });
let waiter = kio::Waiter::noop();
let mut latest = None;
while let Poll::Ready(Ok(Some(catalog))) = consumer.poll_next(&waiter) {
latest = Some(catalog);
}
let catalog = latest.expect("catalog published");
assert!(catalog.audio.renditions.contains_key("audio0"));
assert_eq!(catalog.ext.scte35, Some(Scte35 { splice_id: 42 }));
}
#[test]
fn untyped_extra_roundtrip() {
let mut broadcast = moq_net::broadcast::Info::new().produce();
let config = crate::catalog::Config::default().with_catalog(Catalog::<Extra>::default());
let mut producer = crate::catalog::Producer::<Extra>::new(&mut broadcast, config).unwrap();
let mut consumer = producer.consume().unwrap();
producer.modify().unwrap().audio.renditions.insert(
"audio0".to_string(),
hang::catalog::AudioConfig::new(hang::catalog::AudioCodec::Opus, 48_000, 2),
);
producer
.modify()
.unwrap()
.set_section("transcript", serde_json::json!({ "track": "transcript.json" }))
.unwrap();
assert!(matches!(
producer.modify().unwrap().set_section("video", serde_json::json!({})),
Err(crate::Error::ReservedSection(_))
));
assert!(matches!(
producer
.modify()
.unwrap()
.set_section("timeline", serde_json::json!({})),
Err(crate::Error::ReservedSection(_))
));
assert!(matches!(
producer.modify().unwrap().set_section("clock", serde_json::json!({})),
Err(crate::Error::ReservedSection(_))
));
for name in ["version", "generatedAt", "isComplete", "tracks", "initDataList"] {
assert!(
matches!(
producer.modify().unwrap().set_section(name, serde_json::json!("nope")),
Err(crate::Error::ReservedSection(_))
),
"{name} must be refused",
);
}
let waiter = kio::Waiter::noop();
let mut latest = None;
while let Poll::Ready(Ok(Some(catalog))) = consumer.poll_next(&waiter) {
latest = Some(catalog);
}
let catalog = latest.expect("catalog published");
assert!(catalog.audio.renditions.contains_key("audio0"));
assert_eq!(
catalog.ext.get("transcript"),
Some(&serde_json::json!({ "track": "transcript.json" }))
);
assert_eq!(catalog.ext.iter().count(), 1);
}
}