use std::collections::BTreeMap;
use std::time::Duration;
use bytes::Bytes;
use serde::{Deserialize, Serialize};
use serde_with::base64::Base64;
use serde_with::{DurationMilliSeconds, serde_as};
use crate::catalog::hang::CatalogExt;
pub(super) const SI_PIDS: &[u16] = &[
0x0010, 0x0011, 0x0012, 0x0014, ];
pub(super) fn si_interval(table_id: u8) -> Option<Duration> {
match table_id {
0x40..=0x41 => Some(Duration::from_secs(10)),
0x42 => Some(Duration::from_secs(2)),
0x46 => Some(Duration::from_secs(10)),
0x4A => Some(Duration::from_secs(10)),
0x4E => Some(Duration::from_secs(2)),
0x4F => Some(Duration::from_secs(10)),
0x50..=0x5F => Some(Duration::from_secs(10)),
0x60..=0x6F => Some(Duration::from_secs(30)),
0x70 | 0x73 => Some(Duration::from_secs(30)),
_ => None,
}
}
#[serde_as]
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct Mpegts {
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub tracks: BTreeMap<String, Track>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub program_descriptors: Vec<Descriptor>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub program: Option<Program>,
#[serde(
default,
skip_serializing_if = "BTreeMap::is_empty",
serialize_with = "serialize_si",
deserialize_with = "deserialize_si"
)]
pub si: BTreeMap<u16, BTreeMap<u8, SiEntry>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mux_rate: Option<u64>,
}
impl Mpegts {
pub fn is_empty(&self) -> bool {
self.tracks.is_empty()
&& self.program_descriptors.is_empty()
&& self.program.is_none()
&& self.si.is_empty()
&& self.mux_rate.is_none()
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct Program {
pub transport_stream_id: u16,
pub program_number: u16,
pub pmt_pid: u16,
}
#[serde_as]
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct SiEntry {
pub track: String,
#[serde_as(as = "Option<DurationMilliSeconds<u64>>")]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub interval: Option<Duration>,
#[serde(skip)]
pub(crate) sections: Vec<Bytes>,
}
fn serialize_si<S: serde::Serializer>(
si: &BTreeMap<u16, BTreeMap<u8, SiEntry>>,
serializer: S,
) -> Result<S::Ok, S::Error> {
use serde::ser::{Error, SerializeMap};
let mut map = serializer.serialize_map(Some(si.len()))?;
for (pid, tables) in si {
for entry in tables.values() {
if !entry.sections.is_empty() {
return Err(S::Error::custom(format!(
"inline SI sections on PID {pid} name no track and cannot be serialized"
)));
}
}
let tables: BTreeMap<String, &SiEntry> = tables.iter().map(|(id, entry)| (id.to_string(), entry)).collect();
map.serialize_entry(&pid.to_string(), &tables)?;
}
map.end()
}
fn deserialize_si<'de, D>(deserializer: D) -> Result<BTreeMap<u16, BTreeMap<u8, SiEntry>>, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::Error;
#[serde_as]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct Inline {
#[serde_as(as = "Vec<Base64>")]
sections: Vec<Bytes>,
#[serde_as(as = "Option<DurationMilliSeconds<u64>>")]
#[serde(default)]
interval: Option<Duration>,
}
let raw: BTreeMap<String, serde_json::Value> = Deserialize::deserialize(deserializer)?;
let mut si = BTreeMap::new();
for (pid, value) in raw {
let pid: u16 = pid.parse().map_err(D::Error::custom)?;
let tables = if value.get("sections").is_some() {
let inline: Inline = serde_json::from_value(value).map_err(D::Error::custom)?;
let mut tables: BTreeMap<u8, SiEntry> = BTreeMap::new();
for section in inline.sections {
let Some(&table_id) = section.first() else {
return Err(D::Error::custom(format!("empty SI section on PID {pid}")));
};
tables
.entry(table_id)
.or_insert_with(|| SiEntry {
interval: inline.interval.or_else(|| si_interval(table_id)),
..Default::default()
})
.sections
.push(section);
}
tables
} else {
let tables: BTreeMap<String, SiEntry> = serde_json::from_value(value).map_err(D::Error::custom)?;
tables
.into_iter()
.map(|(table_id, entry)| Ok((table_id.parse().map_err(D::Error::custom)?, entry)))
.collect::<Result<_, D::Error>>()?
};
si.insert(pid, tables);
}
Ok(si)
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct Track {
pub pid: u16,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub descriptors: Vec<Descriptor>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub verbatim: Option<Verbatim>,
}
impl Track {
pub fn new(pid: u16) -> Self {
Self {
pid,
descriptors: Vec::new(),
verbatim: None,
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct Verbatim {
pub stream_type: u8,
#[serde(default)]
pub framing: Framing,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stream_id: Option<u8>,
}
impl Verbatim {
pub fn new(stream_type: u8, framing: Framing) -> Self {
Self {
stream_type,
framing,
stream_id: None,
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub enum Framing {
#[default]
Pes,
Section,
}
#[serde_as]
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct Descriptor {
pub tag: u8,
#[serde_as(as = "Base64")]
pub data: Bytes,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
#[non_exhaustive]
pub struct Ext {
#[serde(default, skip_serializing_if = "Mpegts::is_empty")]
pub mpegts: Mpegts,
}
impl CatalogExt for Ext {}
pub trait Catalog: CatalogExt {
fn mpegts_mut(&mut self) -> Option<&mut Mpegts>;
}
impl Catalog for () {
fn mpegts_mut(&mut self) -> Option<&mut Mpegts> {
None
}
}
impl Catalog for crate::catalog::hang::Extra {
fn mpegts_mut(&mut self) -> Option<&mut Mpegts> {
None
}
}
impl Catalog for Ext {
fn mpegts_mut(&mut self) -> Option<&mut Mpegts> {
Some(&mut self.mpegts)
}
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn empty_section_omitted() {
let ext = Ext::default();
assert_eq!(serde_json::to_string(&ext).unwrap(), "{}");
}
#[test]
fn mux_rate_alone_is_not_empty() {
let mpegts = Mpegts {
mux_rate: Some(2_500_000),
..Default::default()
};
assert!(!mpegts.is_empty());
let json = serde_json::to_string(&Ext { mpegts }).unwrap();
assert_eq!(json, r#"{"mpegts":{"muxRate":2500000}}"#);
assert_eq!(serde_json::to_string(&Ext::default()).unwrap(), "{}");
}
#[test]
fn section_roundtrip() {
let mut mpegts = Mpegts::default();
mpegts.tracks.insert(
"audio".to_string(),
Track {
pid: 0x101,
descriptors: vec![Descriptor {
tag: 0x0a,
data: Bytes::from_static(b"eng\x00"),
}],
verbatim: None,
},
);
mpegts.tracks.insert(
".scte35".to_string(),
Track {
pid: 0x102,
descriptors: Vec::new(),
verbatim: Some(Verbatim::new(0x86, Framing::Section)),
},
);
mpegts.program_descriptors.push(Descriptor {
tag: 0x05,
data: Bytes::from_static(b"CUEI"),
});
mpegts.mux_rate = Some(2_500_000);
let json = serde_json::to_string(&Ext { mpegts: mpegts.clone() }).unwrap();
assert!(json.contains("\"Q1VFSQ==\""), "descriptor data is base64: {json}");
assert!(
json.contains("\"muxRate\":2500000"),
"mux rate is a bare integer: {json}"
);
let parsed: Ext = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.mpegts, mpegts, "mpegts section round-trips");
}
#[test]
fn program_and_si_roundtrip() {
let entry = SiEntry {
track: "0x0011-0x42.si".to_string(),
interval: Some(Duration::from_secs(2)),
..Default::default()
};
let mpegts = Mpegts {
program: Some(Program {
transport_stream_id: 0x1234,
program_number: 1,
pmt_pid: 0x0064,
..Default::default()
}),
si: BTreeMap::from([(0x0011, BTreeMap::from([(0x42u8, entry)]))]),
..Default::default()
};
assert!(!mpegts.is_empty(), "a program record is not empty");
let json = serde_json::to_string(&Ext { mpegts: mpegts.clone() }).unwrap();
assert!(json.contains("\"pmtPid\":100"), "identity stays structured: {json}");
assert!(json.contains("\"17\":"), "PID key written as a string: {json}");
assert!(json.contains("\"66\":"), "table_id key written as a string: {json}");
assert!(
json.contains("\"track\":\"0x0011-0x42.si\""),
"the entry names its track: {json}"
);
assert!(json.contains("\"interval\":2000"), "interval is bare millis: {json}");
let parsed: Ext = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.mpegts, mpegts, "program and SI round-trip");
}
#[test]
fn inline_si_form_is_read() {
let json = r#"{
"program": {"pmtPid": 4096, "programNumber": 1, "transportStreamId": 1},
"si": {
"17": {
"interval": 2000,
"sections": ["QvAlAAHBAAD/Af8AAfyAFEgSAQZGRm1wZWcJU2VydmljZTAxd3xDyg=="]
}
},
"tracks": {"0.avc3": {"pid": 256}}
}"#;
let mpegts: Mpegts = serde_json::from_str(json).expect("the 0.11.0 form must decode");
let sdt = &mpegts.si[&0x0011][&0x42];
assert_eq!(sdt.track, "");
assert_eq!(sdt.interval, Some(Duration::from_secs(2)));
assert_eq!(sdt.sections.len(), 1);
assert_eq!(sdt.sections[0][0], 0x42);
assert_eq!(mpegts.si[&0x0011].len(), 1);
let json = r#"{"si": {"18": {"sections": ["TgAB", "UAAB", "TgAC"]}}}"#;
let mpegts: Mpegts = serde_json::from_str(json).unwrap();
let eit = &mpegts.si[&0x0012];
assert_eq!(eit.len(), 2);
assert_eq!(eit[&0x4e].sections.len(), 2);
assert_eq!(eit[&0x4e].interval, Some(Duration::from_secs(2)));
assert_eq!(eit[&0x50].sections.len(), 1);
assert_eq!(eit[&0x50].interval, Some(Duration::from_secs(10)));
serde_json::to_string(&mpegts).expect_err("inline sections must not serialize");
}
#[test]
fn track_backed_si_roundtrip_after_legacy_read() {
let json = r#"{"si": {
"17": {"sections": ["QvAlAAHBAAD/Af8AAfyAFEgSAQZGRm1wZWcJU2VydmljZTAxd3xDyg=="]},
"18": {"78": {"track": "si/18/78", "interval": 2000}}
}}"#;
let mut mpegts: Mpegts = serde_json::from_str(json).unwrap();
serde_json::to_string(&mpegts).expect_err("the inline entry must not serialize");
mpegts.si.remove(&0x0011);
let json = serde_json::to_string(&mpegts).unwrap();
assert!(json.contains("\"si/18/78\""), "track-backed entry still writes: {json}");
let parsed: Mpegts = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, mpegts);
}
#[test]
fn unknown_framing_is_refused() {
let json = r#"{
"tracks": {
"x.ts": {
"pid": 500,
"verbatim": { "streamType": 6, "framing": "future" }
}
}
}"#;
let err = serde_json::from_str::<Mpegts>(json).expect_err("unknown framing must fail");
assert!(
err.to_string().contains("future"),
"error should name the unknown variant: {err}"
);
}
#[test]
fn invalid_si_pid_key_is_refused() {
let json = r#"{ "si": { "not-a-pid": { "66": { "track": "si" } } } }"#;
serde_json::from_str::<Mpegts>(json).expect_err("a non-integer SI PID key must fail");
}
#[test]
fn mixed_si_forms_are_refused() {
let json = r#"{ "si": { "17": {
"sections": ["QvAlAAHBAAD/Af8AAfyAFEgSAQZGRm1wZWcJU2VydmljZTAxd3xDyg=="],
"66": { "track": "si/17/66" }
} } }"#;
serde_json::from_str::<Mpegts>(json).expect_err("sections with a table_id entry must fail");
}
#[tokio::test]
async fn section_rides_the_msf_catalog() {
use crate::catalog::Stream as _;
let mut broadcast = moq_net::broadcast::Info::new().produce();
let mut producer = crate::catalog::Producer::new(
&mut broadcast,
crate::catalog::Config::default().with_catalog(crate::catalog::hang::Catalog::<Ext>::default()),
)
.unwrap();
let mut guard = producer.modify().unwrap();
guard.ext.mpegts.program = Some(Program {
transport_stream_id: 0x1234,
program_number: 1,
pmt_pid: 0x0064,
..Default::default()
});
guard.ext.mpegts.tracks.insert(
".ts".to_string(),
Track {
pid: 0x0102,
descriptors: vec![Descriptor {
tag: 0x05,
data: Bytes::from_static(b"CUEI"),
}],
verbatim: Some(Verbatim::new(0x86, Framing::Section)),
},
);
let expected = guard.ext.mpegts.clone();
drop(guard);
let mut consumer =
crate::catalog::Consumer::<Ext>::new(&broadcast.consume(), crate::catalog::CatalogFormat::Msf)
.await
.unwrap();
let catalog = consumer.next().await.unwrap().expect("catalog published");
assert_eq!(
catalog.ext.mpegts, expected,
"the mpegts section survives the MSF track"
);
}
}