Skip to main content

moq_msf/
lib.rs

1//! MSF (MOQT Streaming Format) catalog types.
2//!
3//! This crate provides types for the MSF catalog format as defined in
4//! draft-ietf-moq-msf-01, with additional support for CMAF packaging
5//! from draft-ietf-moq-cmsf-00.
6//!
7//! [`Catalog`] is a version-agnostic snapshot of tracks. The wire details are
8//! hidden behind (de)serialization: parsing accepts both draft-00 (numeric
9//! `version`, inline `initData`) and draft-01 (string `version`, with init data
10//! held in a root `initDataList` and referenced per-track by `initRef`).
11//! Serializing always emits the newest draft, and init data is resolved to
12//! inline [`Track::init_data`] either way, so callers never touch the version
13//! or the init-data indirection. draft-00's `generatedAt` and `isComplete`
14//! fields stay available as version-neutral catalog fields.
15//!
16//! References:
17//! - <https://www.ietf.org/archive/id/draft-ietf-moq-msf-01.txt>
18//! - <https://www.ietf.org/archive/id/draft-ietf-moq-cmsf-00.txt>
19
20use std::fmt;
21use std::str::FromStr;
22use std::time::Duration;
23
24use serde::{Deserialize, Serialize};
25use serde_with::DurationMilliSecondsWithFrac;
26
27/// The default track name for the MSF catalog.
28pub const DEFAULT_NAME: &str = "catalog";
29
30/// A snapshot of an MSF catalog: the tracks currently in a broadcast.
31///
32/// This is a version-agnostic view. The on-wire details (the catalog `version`
33/// field, and draft-01's `initDataList`/`initRef` indirection for initialization
34/// data) are handled during (de)serialization, so callers only ever see
35/// resolved tracks with inline [`Track::init_data`]. Parsing accepts both
36/// draft-00 and draft-01 catalogs; serializing always emits the newest draft.
37///
38/// Generic over an application extension `E` (defaulting to `()` for none), which is
39/// serialized as a flat union with the members MSF defines: `ext`'s own members and
40/// `tracks` share one JSON object on the wire. See [`CatalogExt`].
41///
42/// Marked `#[non_exhaustive]` because the MSF drafts continue to grow optional
43/// root fields. External callers build a catalog with [`Catalog::new`] or
44/// [`Catalog::default`] and then assign whichever optional fields they need.
45#[derive(Debug, Clone, PartialEq, Default)]
46#[non_exhaustive]
47pub struct Catalog<E: CatalogExt = ()> {
48	/// Catalog generation time as Unix epoch milliseconds.
49	pub generated_at: Option<u64>,
50
51	/// Whether the broadcast has finished and no more tracks will appear.
52	pub is_complete: bool,
53
54	/// The tracks in this catalog snapshot.
55	pub tracks: Vec<Track>,
56
57	/// The application extension: root members beyond the ones MSF defines.
58	pub ext: E,
59}
60
61/// An application's catalog extension: a plain serde struct of extra root members,
62/// serialized as a flat union with the members MSF itself defines.
63///
64/// Any type that serializes to a JSON object qualifies, so a caller names its own
65/// struct and never touches `serde_json`:
66///
67/// ```
68/// use serde::{Deserialize, Serialize};
69///
70/// #[derive(Serialize, Deserialize, Clone, Default)]
71/// struct Mpegts {
72///     program_number: u16,
73/// }
74///
75/// #[derive(Serialize, Deserialize, Clone, Default)]
76/// struct MpegtsExt {
77///     #[serde(skip_serializing_if = "Option::is_none")]
78///     mpegts: Option<Mpegts>,
79/// }
80///
81/// let mut catalog = moq_msf::Catalog::<MpegtsExt>::default();
82/// catalog.ext.mpegts = Some(Mpegts { program_number: 1 });
83/// ```
84///
85/// `()` is the no-extension case, so [`Catalog<()>`] carries only what MSF defines and
86/// drops the rest. For passthrough of members that aren't known at compile time, use
87/// `serde_json::Map<String, serde_json::Value>`, which keeps them verbatim.
88///
89/// An extension MUST NOT name a member after one MSF defines (`version`, `generatedAt`,
90/// `isComplete`, `tracks`, `initDataList`); the wire JSON would carry a duplicate key.
91pub trait CatalogExt: Serialize + serde::de::DeserializeOwned + Default + Clone + Send + Unpin + 'static {}
92
93// Blanket, so a caller writes no impl: naming the trait is only about readable bounds.
94impl<T: Serialize + serde::de::DeserializeOwned + Default + Clone + Send + Unpin + 'static> CatalogExt for T {}
95
96/// A single track in the MSF catalog.
97///
98/// Marked `#[non_exhaustive]` because the CMSF/MSF drafts continue to grow
99/// optional fields. External callers build a track with [`Track::new`] and
100/// then assign whichever optional fields they need; struct-literal
101/// construction (with or without `..base`) is not available outside this
102/// crate.
103#[serde_with::serde_as]
104#[serde_with::skip_serializing_none]
105#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
106#[serde(rename_all = "camelCase")]
107#[non_exhaustive]
108pub struct Track {
109	/// Unique track name (case-sensitive).
110	pub name: String,
111
112	/// Packaging mode.
113	pub packaging: Packaging,
114
115	/// Whether new objects will be appended.
116	///
117	/// draft-00 marks this required, but its own examples omit it on
118	/// `mediatimeline`/`eventtimeline` tracks, so we default to `false` when
119	/// absent rather than reject the whole catalog.
120	#[serde(default)]
121	pub is_live: bool,
122
123	/// Content role.
124	pub role: Option<Role>,
125
126	/// WebCodecs codec string.
127	pub codec: Option<String>,
128
129	/// Video frame width in pixels.
130	pub width: Option<u32>,
131
132	/// Video frame height in pixels.
133	pub height: Option<u32>,
134
135	/// Video frame rate.
136	pub framerate: Option<f64>,
137
138	/// Audio sample rate in Hz.
139	pub samplerate: Option<u32>,
140
141	/// Audio channel configuration.
142	pub channel_config: Option<String>,
143
144	/// Bitrate in bits per second.
145	pub bitrate: Option<u64>,
146
147	/// Whether the publisher recommends temporarily avoiding this track.
148	///
149	/// This is a non-standard extension shared with the hang catalog.
150	pub stalled: Option<bool>,
151
152	/// Resolved base64 initialization data.
153	///
154	/// On the wire this is carried indirectly through draft-01's `initDataList` +
155	/// `initRef`; [`Catalog`] (de)serialization resolves it so callers always see
156	/// the inline payload here. draft-00's inline `initData` is also accepted.
157	pub init_data: Option<String>,
158
159	/// Wire-only pointer into the catalog's `initDataList` (draft-01). Populated
160	/// only while (de)serializing; resolved into `init_data` on parse and never
161	/// surfaced to callers.
162	init_ref: Option<String>,
163
164	/// Render group for synchronized playback.
165	pub render_group: Option<u32>,
166
167	/// Alternate group for quality switching.
168	pub alt_group: Option<u32>,
169
170	/// Maximum SAP starting type for groups (CMSF 3.5.2).
171	/// A value of 1 means every group starts with a closed-GOP IDR.
172	// Explicit rename to lock the wire name independent of rename_all.
173	#[serde(rename = "maxGrpSapStartingType")]
174	pub max_grp_sap_starting_type: Option<u8>,
175
176	/// Maximum SAP starting type for objects (CMSF 3.5.2).
177	/// A value of 1 means every object starts with a closed-GOP IDR.
178	// Explicit rename to lock the wire name independent of rename_all.
179	#[serde(rename = "maxObjSapStartingType")]
180	pub max_obj_sap_starting_type: Option<u8>,
181
182	/// Jitter (non-standard extension; not in the MSF/CMSF drafts).
183	///
184	/// Serialized as a JSON number of milliseconds, matching the hang catalog.
185	#[serde_as(as = "Option<DurationMilliSecondsWithFrac>")]
186	pub jitter: Option<Duration>,
187}
188
189impl Catalog<()> {
190	/// Construct a catalog with tracks and no root-level optional fields.
191	///
192	/// This is the no-extension case. Use [`with_ext`](Catalog::with_ext) to carry one.
193	pub fn new(tracks: Vec<Track>) -> Self {
194		Self::with_ext(tracks, ())
195	}
196}
197
198impl<E: CatalogExt> Catalog<E> {
199	/// Construct a catalog with tracks and an application extension ([`CatalogExt`]).
200	pub fn with_ext(tracks: Vec<Track>, ext: E) -> Self {
201		Self {
202			generated_at: None,
203			is_complete: false,
204			tracks,
205			ext,
206		}
207	}
208
209	/// Serialize the MSF catalog to a JSON string.
210	pub fn to_json(&self) -> Result<String, serde_json::Error> {
211		serde_json::to_string(self)
212	}
213
214	/// Deserialize an MSF catalog from a JSON string.
215	#[allow(clippy::should_implement_trait)]
216	pub fn from_str(s: &str) -> Result<Self, serde_json::Error> {
217		serde_json::from_str(s)
218	}
219}
220
221/// The newest MSF draft string this crate emits.
222const CURRENT_VERSION: &str = "draft-01";
223
224/// The extension's first member that collides with one MSF defines, if any.
225///
226/// Serializes the extension to inspect its member names, so it is only called under
227/// `debug_assertions`.
228#[cfg(debug_assertions)]
229fn colliding_member<E: CatalogExt>(ext: &E) -> Option<String> {
230	match serde_json::to_value(ext) {
231		Ok(serde_json::Value::Object(members)) => members.into_iter().map(|(name, _)| name).find(|n| reserved_root(n)),
232		// Not an object (the `()` no-extension case) or unserializable: nothing to collide.
233		_ => None,
234	}
235}
236
237/// Whether `name` is a root member MSF itself defines, which an extension MUST NOT reuse.
238///
239/// An extension is serialized flat, so a member by one of these names emits a duplicate JSON
240/// key: serde does not reject it, and a reader keeps whichever came last. A typed extension
241/// fixes its member names at compile time, but one built at runtime (a `serde_json::Map`)
242/// needs this check wherever it accepts a name.
243pub fn reserved_root(name: &str) -> bool {
244	matches!(
245		name,
246		"version" | "generatedAt" | "isComplete" | "tracks" | "initDataList"
247	)
248}
249
250impl<E: CatalogExt> Serialize for Catalog<E> {
251	fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
252		use std::collections::HashMap;
253
254		// An extension member named after one MSF defines emits a duplicate JSON key, which serde
255		// writes without complaint and a reader resolves by keeping the last. A typed extension
256		// fixes its member names at compile time, so this is a bug in the extension rather than
257		// something a peer can trigger: check it where it costs nothing to ship. A `cfg` block
258		// rather than `debug_assert!`, whose arguments a release build still compiles.
259		#[cfg(debug_assertions)]
260		if let Some(name) = colliding_member(&self.ext) {
261			panic!("MSF catalog extension member {name:?} collides with a reserved root member");
262		}
263
264		// Hoist inline init payloads into a shared, deduplicated initDataList and
265		// point each track at its entry via initRef. That's the draft-01 wire
266		// shape; identical payloads across tracks collapse to one entry.
267		let mut init_data_list: Vec<InitData> = Vec::new();
268		let mut ids: HashMap<String, String> = HashMap::new();
269		let mut tracks = Vec::with_capacity(self.tracks.len());
270
271		for track in &self.tracks {
272			let mut track = track.clone();
273			if let Some(payload) = track.init_data.take() {
274				let id = if let Some(id) = ids.get(&payload) {
275					id.clone()
276				} else {
277					let id = format!("init{}", init_data_list.len());
278					init_data_list.push(InitData {
279						id: id.clone(),
280						kind: "inline".to_string(),
281						data: payload.clone(),
282					});
283					ids.insert(payload, id.clone());
284					id
285				};
286				track.init_ref = Some(id);
287			}
288			tracks.push(track);
289		}
290
291		Wire {
292			version: WireVersion,
293			generated_at: self.generated_at,
294			is_complete: self.is_complete,
295			tracks,
296			init_data_list: (!init_data_list.is_empty()).then_some(init_data_list),
297			ext: self.ext.clone(),
298		}
299		.serialize(serializer)
300	}
301}
302
303impl<'de, E: CatalogExt> Deserialize<'de> for Catalog<E> {
304	fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
305		use std::collections::HashMap;
306
307		let wire = Wire::<E>::deserialize(deserializer)?;
308		let init_data_list = wire.init_data_list.unwrap_or_default();
309
310		// id -> init data entry, built once so resolution is linear in the number
311		// of tracks rather than tracks x entries.
312		let by_id: HashMap<&str, &InitData> = init_data_list.iter().map(|e| (e.id.as_str(), e)).collect();
313
314		let tracks = wire
315			.tracks
316			.into_iter()
317			.map(|mut track| -> Result<Track, D::Error> {
318				// Resolve draft-01 initRef into inline init_data so callers never
319				// see the indirection. Inline init_data (draft-00) is kept as-is,
320				// but a catalog that carries both forms must not disagree.
321				if let Some(id) = track.init_ref.take() {
322					let entry = by_id.get(id.as_str()).ok_or_else(|| {
323						serde::de::Error::custom(format!(
324							"MSF track {:?} references missing initData {:?}",
325							track.name, id
326						))
327					})?;
328
329					if entry.kind != "inline" {
330						return Err(serde::de::Error::custom(format!(
331							"MSF track {:?} references initData {:?} with unsupported type {:?}",
332							track.name, id, entry.kind
333						)));
334					}
335
336					match &track.init_data {
337						Some(inline) if inline != &entry.data => {
338							return Err(serde::de::Error::custom(format!(
339								"MSF track {:?} carries conflicting initData and initRef {:?}",
340								track.name, id
341							)));
342						}
343						Some(_) => {}
344						None => track.init_data = Some(entry.data.clone()),
345					}
346				}
347				track.init_ref = None;
348				Ok(track)
349			})
350			.collect::<Result<Vec<_>, _>>()?;
351
352		Ok(Catalog {
353			generated_at: wire.generated_at,
354			is_complete: wire.is_complete,
355			tracks,
356			ext: wire.ext,
357		})
358	}
359}
360
361fn is_false(value: &bool) -> bool {
362	!*value
363}
364
365/// The on-wire catalog shape, carrying the bits [`Catalog`] hides from callers.
366#[serde_with::skip_serializing_none]
367#[derive(Serialize, Deserialize)]
368#[serde(rename_all = "camelCase")]
369#[serde(bound(serialize = "E: Serialize", deserialize = "E: serde::de::DeserializeOwned"))]
370struct Wire<E> {
371	version: WireVersion,
372	generated_at: Option<u64>,
373	#[serde(default, skip_serializing_if = "is_false")]
374	is_complete: bool,
375	#[serde(default)]
376	tracks: Vec<Track>,
377	init_data_list: Option<Vec<InitData>>,
378
379	/// The extension's own root members, written flat alongside the ones above.
380	#[serde(flatten)]
381	ext: E,
382}
383
384/// Wire encoding of the catalog version. Deserialization accepts draft-00's
385/// number `1` or any draft-01 `"draft-XX"` string; serialization always emits
386/// [`CURRENT_VERSION`], so callers never deal with the version on the wire.
387struct WireVersion;
388
389impl Serialize for WireVersion {
390	fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
391		serializer.serialize_str(CURRENT_VERSION)
392	}
393}
394
395impl<'de> Deserialize<'de> for WireVersion {
396	fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
397		struct VersionVisitor;
398
399		impl serde::de::Visitor<'_> for VersionVisitor {
400			type Value = WireVersion;
401
402			fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
403				f.write_str("the JSON number 1 (draft-00) or a \"draft-XX\" version string")
404			}
405
406			// draft-00's only defined numeric version is 1. Accept it from any JSON
407			// number type (serde_json picks u64/i64/f64 by shape, and `1.0` is a
408			// valid spelling), and reject everything else.
409			fn visit_u64<E: serde::de::Error>(self, v: u64) -> Result<WireVersion, E> {
410				match v {
411					1 => Ok(WireVersion),
412					other => Err(E::custom(format!("unsupported MSF catalog version: {other}"))),
413				}
414			}
415
416			fn visit_i64<E: serde::de::Error>(self, v: i64) -> Result<WireVersion, E> {
417				if v == 1 {
418					Ok(WireVersion)
419				} else {
420					Err(E::custom(format!("unsupported MSF catalog version: {v}")))
421				}
422			}
423
424			fn visit_f64<E: serde::de::Error>(self, v: f64) -> Result<WireVersion, E> {
425				if v == 1.0 {
426					Ok(WireVersion)
427				} else {
428					Err(E::custom(format!("unsupported MSF catalog version: {v}")))
429				}
430			}
431
432			fn visit_str<E: serde::de::Error>(self, _v: &str) -> Result<WireVersion, E> {
433				// Any draft string is accepted; we always re-emit the current draft.
434				Ok(WireVersion)
435			}
436		}
437
438		deserializer.deserialize_any(VersionVisitor)
439	}
440}
441
442/// An entry in the wire `initDataList`, referenced by a track's `initRef`.
443#[derive(Serialize, Deserialize)]
444#[serde(rename_all = "camelCase")]
445struct InitData {
446	/// Identifier, unique within the catalog, that a track's `initRef` points at.
447	id: String,
448	/// Reference type. draft-01 defines only `"inline"` (base64 payload in `data`).
449	#[serde(rename = "type")]
450	kind: String,
451	/// The init payload, interpreted per `kind`. For `"inline"`, base64.
452	data: String,
453}
454
455impl Track {
456	/// Construct a track with the required identity fields set and every
457	/// optional field cleared. Fields are `pub`, so callers set whatever they
458	/// need by assignment afterwards.
459	///
460	/// This is the only path external crates have to build a `Track` since the
461	/// type is `#[non_exhaustive]`.
462	pub fn new(name: impl Into<String>, packaging: Packaging) -> Self {
463		Self {
464			name: name.into(),
465			packaging,
466			is_live: false,
467			role: None,
468			codec: None,
469			width: None,
470			height: None,
471			framerate: None,
472			samplerate: None,
473			channel_config: None,
474			bitrate: None,
475			stalled: None,
476			init_data: None,
477			init_ref: None,
478			render_group: None,
479			alt_group: None,
480			max_grp_sap_starting_type: None,
481			max_obj_sap_starting_type: None,
482			jitter: None,
483		}
484	}
485}
486
487/// Packaging mode for an MSF track.
488#[derive(Debug, Clone, PartialEq, Eq)]
489pub enum Packaging {
490	/// Low Overhead Container (MSF).
491	Loc,
492	/// CMAF fragmented MP4 (CMSF).
493	Cmaf,
494	/// Legacy container format (timestamp + raw codec payload).
495	Legacy,
496	/// Media timeline.
497	MediaTimeline,
498	/// Event timeline.
499	EventTimeline,
500	/// Unknown packaging type.
501	Unknown(String),
502}
503
504impl fmt::Display for Packaging {
505	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
506		match self {
507			Packaging::Loc => write!(f, "loc"),
508			Packaging::Cmaf => write!(f, "cmaf"),
509			Packaging::Legacy => write!(f, "legacy"),
510			Packaging::MediaTimeline => write!(f, "mediatimeline"),
511			Packaging::EventTimeline => write!(f, "eventtimeline"),
512			Packaging::Unknown(s) => write!(f, "{s}"),
513		}
514	}
515}
516
517impl FromStr for Packaging {
518	type Err = std::convert::Infallible;
519
520	fn from_str(s: &str) -> Result<Self, Self::Err> {
521		Ok(match s {
522			"loc" => Packaging::Loc,
523			"cmaf" => Packaging::Cmaf,
524			"legacy" => Packaging::Legacy,
525			"mediatimeline" => Packaging::MediaTimeline,
526			"eventtimeline" => Packaging::EventTimeline,
527			other => Packaging::Unknown(other.to_string()),
528		})
529	}
530}
531
532impl Serialize for Packaging {
533	fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
534		serializer.serialize_str(&self.to_string())
535	}
536}
537
538impl<'de> Deserialize<'de> for Packaging {
539	fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
540		let s = String::deserialize(deserializer)?;
541		// FromStr is infallible so unwrap is safe.
542		Ok(Packaging::from_str(&s).unwrap())
543	}
544}
545
546/// Content role for an MSF track.
547#[derive(Debug, Clone, PartialEq, Eq)]
548pub enum Role {
549	/// Visual content.
550	Video,
551	/// Audio content.
552	Audio,
553	/// Audio description for visually impaired.
554	AudioDescription,
555	/// Textual representation of audio.
556	Caption,
557	/// Transcription of spoken dialogue.
558	Subtitle,
559	/// Visual track for hearing impaired.
560	SignLanguage,
561	/// Unknown role.
562	Unknown(String),
563}
564
565impl fmt::Display for Role {
566	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
567		match self {
568			Role::Video => write!(f, "video"),
569			Role::Audio => write!(f, "audio"),
570			Role::AudioDescription => write!(f, "audiodescription"),
571			Role::Caption => write!(f, "caption"),
572			Role::Subtitle => write!(f, "subtitle"),
573			Role::SignLanguage => write!(f, "signlanguage"),
574			Role::Unknown(s) => write!(f, "{s}"),
575		}
576	}
577}
578
579impl FromStr for Role {
580	type Err = std::convert::Infallible;
581
582	fn from_str(s: &str) -> Result<Self, Self::Err> {
583		Ok(match s {
584			"video" => Role::Video,
585			"audio" => Role::Audio,
586			"audiodescription" => Role::AudioDescription,
587			"caption" => Role::Caption,
588			"subtitle" => Role::Subtitle,
589			"signlanguage" => Role::SignLanguage,
590			other => Role::Unknown(other.to_string()),
591		})
592	}
593}
594
595impl Serialize for Role {
596	fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
597		serializer.serialize_str(&self.to_string())
598	}
599}
600
601impl<'de> Deserialize<'de> for Role {
602	fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
603		let s = String::deserialize(deserializer)?;
604		// FromStr is infallible so unwrap is safe.
605		Ok(Role::from_str(&s).unwrap())
606	}
607}
608
609#[cfg(test)]
610mod test {
611	use super::*;
612
613	fn video_track() -> Track {
614		Track {
615			name: "video0".to_string(),
616			packaging: Packaging::Legacy,
617			is_live: true,
618			role: Some(Role::Video),
619			codec: Some("avc3.64001f".to_string()),
620			width: Some(1280),
621			height: Some(720),
622			framerate: Some(30.0),
623			samplerate: None,
624			channel_config: None,
625			bitrate: Some(6_000_000),
626			stalled: Some(true),
627			init_data: None,
628			init_ref: None,
629			render_group: Some(1),
630			alt_group: None,
631			max_grp_sap_starting_type: None,
632			max_obj_sap_starting_type: None,
633			jitter: None,
634		}
635	}
636
637	fn audio_track() -> Track {
638		Track {
639			name: "audio0".to_string(),
640			packaging: Packaging::Legacy,
641			is_live: true,
642			role: Some(Role::Audio),
643			codec: Some("opus".to_string()),
644			width: None,
645			height: None,
646			framerate: None,
647			samplerate: Some(48_000),
648			channel_config: Some("2".to_string()),
649			bitrate: Some(128_000),
650			stalled: None,
651			init_data: None,
652			init_ref: None,
653			render_group: Some(1),
654			alt_group: None,
655			max_grp_sap_starting_type: None,
656			max_obj_sap_starting_type: None,
657			jitter: None,
658		}
659	}
660
661	fn track_with_sap_and_jitter() -> Track {
662		Track {
663			name: "video0".to_string(),
664			packaging: Packaging::Cmaf,
665			is_live: true,
666			role: Some(Role::Video),
667			codec: Some("avc1.640028".to_string()),
668			width: Some(1920),
669			height: Some(1080),
670			framerate: Some(30.0),
671			samplerate: None,
672			channel_config: None,
673			bitrate: Some(5_000_000),
674			stalled: None,
675			init_data: None,
676			init_ref: None,
677			render_group: Some(1),
678			alt_group: None,
679			max_grp_sap_starting_type: Some(1),
680			max_obj_sap_starting_type: Some(2),
681			jitter: Some(Duration::from_millis(15)),
682		}
683	}
684
685	#[test]
686	fn serialize_video_track() {
687		let catalog = Catalog::new(vec![video_track()]);
688
689		let json = catalog.to_json().unwrap();
690		let parsed = Catalog::<()>::from_str(&json).unwrap();
691		assert_eq!(catalog, parsed);
692
693		// Verify audio fields are not present in JSON.
694		let value: serde_json::Value = serde_json::from_str(&json).unwrap();
695		let track = &value["tracks"][0];
696		assert!(track.get("samplerate").is_none());
697		assert!(track.get("channelConfig").is_none());
698
699		// Verify skip_serializing_none omits the new optional fields when None.
700		assert!(track.get("maxGrpSapStartingType").is_none());
701		assert!(track.get("maxObjSapStartingType").is_none());
702		assert!(track.get("jitter").is_none());
703		assert_eq!(track["stalled"], true);
704	}
705
706	#[test]
707	fn serialize_audio_track() {
708		let catalog = Catalog::new(vec![audio_track()]);
709
710		let json = catalog.to_json().unwrap();
711		let parsed = Catalog::<()>::from_str(&json).unwrap();
712		assert_eq!(catalog, parsed);
713
714		// Verify video fields are not present in JSON.
715		let value: serde_json::Value = serde_json::from_str(&json).unwrap();
716		let track = &value["tracks"][0];
717		assert!(track.get("width").is_none());
718		assert!(track.get("height").is_none());
719		assert!(track.get("framerate").is_none());
720	}
721
722	#[test]
723	fn packaging_roundtrip() {
724		for (s, expected) in [
725			("loc", Packaging::Loc),
726			("cmaf", Packaging::Cmaf),
727			("legacy", Packaging::Legacy),
728			("mediatimeline", Packaging::MediaTimeline),
729			("eventtimeline", Packaging::EventTimeline),
730			("custom", Packaging::Unknown("custom".to_string())),
731		] {
732			let packaging: Packaging = s.parse().unwrap();
733			assert_eq!(packaging, expected);
734			assert_eq!(packaging.to_string(), s);
735		}
736	}
737
738	#[test]
739	fn role_roundtrip() {
740		for (s, expected) in [
741			("video", Role::Video),
742			("audio", Role::Audio),
743			("audiodescription", Role::AudioDescription),
744			("caption", Role::Caption),
745			("subtitle", Role::Subtitle),
746			("signlanguage", Role::SignLanguage),
747			("custom", Role::Unknown("custom".to_string())),
748		] {
749			let role: Role = s.parse().unwrap();
750			assert_eq!(role, expected);
751			assert_eq!(role.to_string(), s);
752		}
753	}
754
755	#[test]
756	fn roundtrip_empty() {
757		let catalog = Catalog::new(vec![]);
758		let json = catalog.to_json().unwrap();
759		let parsed = Catalog::<()>::from_str(&json).unwrap();
760		assert_eq!(catalog, parsed);
761	}
762
763	#[test]
764	fn cmaf_packaging() {
765		let mut track = track_with_sap_and_jitter();
766		track.name = "hd".to_string();
767		track.alt_group = Some(1);
768		track.max_grp_sap_starting_type = None;
769		track.max_obj_sap_starting_type = None;
770		track.jitter = None;
771		track.init_data = Some("AQID".to_string());
772
773		let catalog = Catalog::new(vec![track]);
774
775		let json = catalog.to_json().unwrap();
776		assert!(json.contains("\"packaging\":\"cmaf\""));
777		let parsed = Catalog::<()>::from_str(&json).unwrap();
778		assert_eq!(catalog, parsed);
779		assert_eq!(parsed.tracks[0].init_data.as_deref(), Some("AQID"));
780	}
781
782	#[test]
783	fn serialize_sap_fields() {
784		let catalog = Catalog::new(vec![track_with_sap_and_jitter()]);
785
786		let json = catalog.to_json().unwrap();
787
788		// Verify wire-format field names use the explicit camelCase renames and the
789		// auto-renamed jitter field.
790		let value: serde_json::Value = serde_json::from_str(&json).unwrap();
791		let track = &value["tracks"][0];
792		assert_eq!(track.get("maxGrpSapStartingType"), Some(&serde_json::json!(1)));
793		assert_eq!(track.get("maxObjSapStartingType"), Some(&serde_json::json!(2)));
794		assert_eq!(track.get("jitter").and_then(serde_json::Value::as_f64), Some(15.0));
795
796		// Snake-case names must NOT appear on the wire.
797		assert!(track.get("max_grp_sap_starting_type").is_none());
798		assert!(track.get("max_obj_sap_starting_type").is_none());
799	}
800
801	#[test]
802	fn deserialize_without_sap_fields() {
803		// Backward compatibility: catalogs produced before SAP/jitter were added
804		// must still deserialize, with the new fields defaulting to None.
805		let json = r#"{
806			"version": 1,
807			"tracks": [{
808				"name": "video0",
809				"packaging": "cmaf",
810				"isLive": true,
811				"role": "video",
812				"codec": "avc1.640028",
813				"width": 1920,
814				"height": 1080,
815				"framerate": 30.0,
816				"bitrate": 5000000,
817				"renderGroup": 1
818			}]
819		}"#;
820
821		let catalog = Catalog::<()>::from_str(json).unwrap();
822		let track = &catalog.tracks[0];
823		assert_eq!(track.max_grp_sap_starting_type, None);
824		assert_eq!(track.max_obj_sap_starting_type, None);
825		assert_eq!(track.jitter, None);
826	}
827
828	#[test]
829	fn sap_and_jitter_roundtrip() {
830		let original = Catalog::new(vec![track_with_sap_and_jitter()]);
831
832		let json = original.to_json().unwrap();
833		let parsed = Catalog::<()>::from_str(&json).unwrap();
834		assert_eq!(original, parsed);
835		assert_eq!(parsed.tracks[0].max_grp_sap_starting_type, Some(1));
836		assert_eq!(parsed.tracks[0].max_obj_sap_starting_type, Some(2));
837		assert_eq!(parsed.tracks[0].jitter, Some(Duration::from_millis(15)));
838	}
839
840	#[test]
841	fn fractional_jitter_roundtrips() {
842		let json = r#"{
843			"version": "draft-01",
844			"tracks": [{
845				"name": "video0",
846				"packaging": "cmaf",
847				"isLive": true,
848				"role": "video",
849				"codec": "avc1.640028",
850				"jitter": 15.0
851			}]
852		}"#;
853
854		let catalog = Catalog::<()>::from_str(json).expect("fractional jitter must decode");
855		assert_eq!(catalog.tracks[0].jitter, Some(Duration::from_millis(15)));
856
857		let value: serde_json::Value = serde_json::from_str(&catalog.to_json().unwrap()).unwrap();
858		assert_eq!(value["tracks"][0]["jitter"].as_f64(), Some(15.0));
859	}
860
861	#[test]
862	fn serialize_emits_draft01_version() {
863		// Callers never set a version; we always emit the newest draft string.
864		let json = Catalog::<()>::default().to_json().unwrap();
865		let value: serde_json::Value = serde_json::from_str(&json).unwrap();
866		assert_eq!(value["version"], serde_json::json!("draft-01"));
867	}
868
869	#[test]
870	fn draft00_numeric_version_decodes_and_normalizes() {
871		// draft-00 put the JSON number 1 in `version`. It must decode, and on
872		// re-serialize we normalize to the current draft string.
873		let catalog = Catalog::<()>::from_str(r#"{"version":1,"tracks":[]}"#).unwrap();
874		assert!(catalog.tracks.is_empty());
875
876		let value: serde_json::Value = serde_json::from_str(&catalog.to_json().unwrap()).unwrap();
877		assert_eq!(value["version"], serde_json::json!("draft-01"));
878	}
879
880	#[test]
881	fn draft01_string_version_decodes() {
882		let catalog = Catalog::<()>::from_str(r#"{"version":"draft-01","tracks":[]}"#).unwrap();
883		assert!(catalog.tracks.is_empty());
884	}
885
886	#[test]
887	fn unknown_version_string_is_accepted() {
888		// A future draft we don't specifically recognize still decodes; we don't
889		// expose the version, so callers are unaffected.
890		assert!(Catalog::<()>::from_str(r#"{"version":"draft-99","tracks":[]}"#).is_ok());
891	}
892
893	#[test]
894	fn unsupported_numeric_version_errors() {
895		// Numbers other than 1 never had a defined meaning, so reject them.
896		assert!(Catalog::<()>::from_str(r#"{"version":2,"tracks":[]}"#).is_err());
897	}
898
899	#[test]
900	fn float_numeric_version_is_accepted() {
901		// `1.0` is a valid JSON spelling of the draft-00 version; accept it so we
902		// don't reject a catalog the JS decoder would happily parse.
903		assert!(Catalog::<()>::from_str(r#"{"version":1.0,"tracks":[]}"#).is_ok());
904		assert!(Catalog::<()>::from_str(r#"{"version":2.0,"tracks":[]}"#).is_err());
905	}
906
907	#[test]
908	fn dangling_init_ref_errors() {
909		// A dangling initRef would otherwise look like a track with no init_data.
910		// Reject it so publishers do not silently lose decoder setup bytes.
911		let json = r#"{
912			"version": "draft-01",
913			"initDataList": [
914				{ "id": "v0", "type": "inline", "data": "AQID" }
915			],
916			"tracks": [
917				{ "name": "a", "packaging": "cmaf", "isLive": true, "role": "video",
918				  "codec": "avc1.640028", "initRef": "missing" }
919			]
920		}"#;
921
922		let err = Catalog::<()>::from_str(json).expect_err("dangling initRef must fail");
923		assert!(err.to_string().contains("missing initData"), "unexpected error: {err}");
924	}
925
926	#[test]
927	fn unsupported_init_ref_type_errors() {
928		// Only inline init data can be resolved into Track::init_data. Other
929		// reference types must not become indistinguishable from absent init data.
930		let json = r#"{
931			"version": "draft-01",
932			"initDataList": [
933				{ "id": "v0", "type": "url", "data": "https://example.com/init" }
934			],
935			"tracks": [
936				{ "name": "video0", "packaging": "cmaf", "isLive": true, "role": "video",
937				  "codec": "avc1.640028", "initRef": "v0" }
938			]
939		}"#;
940
941		let err = Catalog::<()>::from_str(json).expect_err("unsupported initRef type must fail");
942		assert!(err.to_string().contains("unsupported type"), "unexpected error: {err}");
943	}
944
945	#[test]
946	fn inline_init_data_and_init_ref_must_agree() {
947		let json = r#"{
948			"version": "draft-01",
949			"initDataList": [
950				{ "id": "v0", "type": "inline", "data": "AQID" }
951			],
952			"tracks": [
953				{ "name": "video0", "packaging": "cmaf", "isLive": true, "role": "video",
954				  "codec": "avc1.640028", "initData": "BAUG", "initRef": "v0" }
955			]
956		}"#;
957
958		let err = Catalog::<()>::from_str(json).expect_err("conflicting initData/initRef must fail");
959		assert!(err.to_string().contains("conflicting"), "unexpected error: {err}");
960	}
961
962	#[test]
963	fn matching_inline_init_data_and_init_ref_decodes() {
964		let json = r#"{
965			"version": "draft-01",
966			"initDataList": [
967				{ "id": "v0", "type": "inline", "data": "AQID" }
968			],
969			"tracks": [
970				{ "name": "video0", "packaging": "cmaf", "isLive": true, "role": "video",
971				  "codec": "avc1.640028", "initData": "AQID", "initRef": "v0" }
972			]
973		}"#;
974
975		let catalog = Catalog::<()>::from_str(json).unwrap();
976		assert_eq!(catalog.tracks[0].init_data.as_deref(), Some("AQID"));
977	}
978
979	#[test]
980	fn draft01_init_ref_resolves_to_inline() {
981		// draft-01 carries init data in a root initDataList; tracks reference it by
982		// id via initRef. Parsing must resolve that into inline init_data.
983		let json = r#"{
984			"version": "draft-01",
985			"initDataList": [
986				{ "id": "v0", "type": "inline", "data": "AQID" }
987			],
988			"tracks": [
989				{ "name": "video0", "packaging": "cmaf", "isLive": true, "role": "video",
990				  "codec": "avc1.640028", "initRef": "v0" }
991			]
992		}"#;
993
994		let catalog = Catalog::<()>::from_str(json).unwrap();
995		assert_eq!(catalog.tracks[0].init_data.as_deref(), Some("AQID"));
996	}
997
998	#[test]
999	fn serialize_hoists_and_dedups_init_data() {
1000		// Two tracks sharing the same init payload must collapse to a single
1001		// initDataList entry, with both tracks referencing it via initRef and no
1002		// inline initData left on the tracks.
1003		let mut a = video_track();
1004		a.name = "a".to_string();
1005		a.init_data = Some("AQID".to_string());
1006		let mut b = video_track();
1007		b.name = "b".to_string();
1008		b.init_data = Some("AQID".to_string());
1009
1010		let catalog = Catalog::new(vec![a, b]);
1011		let value: serde_json::Value = serde_json::from_str(&catalog.to_json().unwrap()).unwrap();
1012
1013		let list = value["initDataList"].as_array().expect("initDataList present");
1014		assert_eq!(list.len(), 1, "identical payloads should dedup to one entry");
1015		assert_eq!(list[0]["data"], serde_json::json!("AQID"));
1016		assert_eq!(list[0]["type"], serde_json::json!("inline"));
1017
1018		let id = list[0]["id"].as_str().unwrap();
1019		for t in value["tracks"].as_array().unwrap() {
1020			assert_eq!(t["initRef"], serde_json::json!(id));
1021			assert!(t.get("initData").is_none(), "no inline initData on the wire");
1022		}
1023
1024		// And it round-trips back to inline init_data for both tracks.
1025		let parsed = Catalog::<()>::from_str(&catalog.to_json().unwrap()).unwrap();
1026		assert_eq!(parsed.tracks[0].init_data.as_deref(), Some("AQID"));
1027		assert_eq!(parsed.tracks[1].init_data.as_deref(), Some("AQID"));
1028	}
1029
1030	#[test]
1031	fn draft00_example_av_decodes() {
1032		// Example 1 from draft-ietf-moq-msf-00: time-aligned audio/video. Exercises the
1033		// numeric version, integer framerate into an f64 field, preserved generatedAt,
1034		// and unmodeled fields (namespace, targetLatency) which must be ignored.
1035		let json = r#"{
1036			"version": 1,
1037			"generatedAt": 1746104606044,
1038			"tracks": [
1039				{
1040					"name": "1080p-video",
1041					"namespace": "conference.example.com/conference123/alice",
1042					"packaging": "loc",
1043					"isLive": true,
1044					"targetLatency": 2000,
1045					"role": "video",
1046					"renderGroup": 1,
1047					"codec": "av01.0.08M.10.0.110.09",
1048					"width": 1920,
1049					"height": 1080,
1050					"framerate": 30,
1051					"bitrate": 1500000
1052				},
1053				{
1054					"name": "audio",
1055					"namespace": "conference.example.com/conference123/alice",
1056					"packaging": "loc",
1057					"isLive": true,
1058					"targetLatency": 2000,
1059					"role": "audio",
1060					"codec": "opus",
1061					"samplerate": 48000,
1062					"channelConfig": "2",
1063					"bitrate": 32000
1064				}
1065			]
1066		}"#;
1067
1068		let catalog = Catalog::<()>::from_str(json).expect("draft-00 AV catalog must decode");
1069		assert_eq!(catalog.generated_at, Some(1746104606044));
1070		assert_eq!(catalog.tracks.len(), 2);
1071		assert_eq!(catalog.tracks[0].framerate, Some(30.0));
1072		assert_eq!(catalog.tracks[1].channel_config.as_deref(), Some("2"));
1073	}
1074
1075	#[test]
1076	fn draft00_example_timeline_tracks_decode() {
1077		// Example 8 from draft-ietf-moq-msf-00: mediatimeline/eventtimeline tracks omit
1078		// isLive/role/codec entirely. The whole catalog must still decode.
1079		let json = r#"{
1080			"version": 1,
1081			"generatedAt": 1746104606044,
1082			"tracks": [
1083				{
1084					"name": "history",
1085					"namespace": "conference.example.com/conference123/alice",
1086					"packaging": "mediatimeline",
1087					"mimetype": "application/json",
1088					"depends": ["1080p-video", "audio"]
1089				},
1090				{
1091					"name": "1080p-video",
1092					"namespace": "conference.example.com/conference123/alice",
1093					"packaging": "loc",
1094					"isLive": true,
1095					"role": "video",
1096					"codec": "av01.0.08M.10.0.110.09",
1097					"width": 1920,
1098					"height": 1080,
1099					"framerate": 30,
1100					"bitrate": 1500000
1101				}
1102			]
1103		}"#;
1104
1105		let catalog = Catalog::<()>::from_str(json).expect("draft-00 timeline catalog must decode");
1106		assert_eq!(catalog.tracks.len(), 2);
1107		// The timeline track had no isLive; it must default rather than fail the parse.
1108		assert!(!catalog.tracks[0].is_live);
1109		assert_eq!(catalog.tracks[0].packaging, Packaging::MediaTimeline);
1110	}
1111
1112	#[test]
1113	fn draft00_example_complete_decodes() {
1114		// Example 9: terminating a live broadcast (isComplete, empty tracks).
1115		let json = r#"{
1116			"version": 1,
1117			"generatedAt": 1746104606044,
1118			"isComplete": true,
1119			"tracks": []
1120		}"#;
1121		let catalog = Catalog::<()>::from_str(json).expect("draft-00 completion catalog must decode");
1122		assert_eq!(catalog.generated_at, Some(1746104606044));
1123		assert!(catalog.is_complete);
1124		assert!(catalog.tracks.is_empty());
1125
1126		let value: serde_json::Value = serde_json::from_str(&catalog.to_json().unwrap()).unwrap();
1127		assert_eq!(value["generatedAt"], serde_json::json!(1746104606044u64));
1128		assert_eq!(value["isComplete"], serde_json::json!(true));
1129	}
1130
1131	#[test]
1132	fn default_catalog_omits_completion_fields() {
1133		let value: serde_json::Value = serde_json::from_str(&Catalog::<()>::default().to_json().unwrap()).unwrap();
1134		assert!(value.get("generatedAt").is_none());
1135		assert!(value.get("isComplete").is_none());
1136	}
1137
1138	/// A typed extension rides the catalog root without the caller touching serde_json.
1139	#[test]
1140	fn typed_extension_roundtrip() {
1141		#[derive(Serialize, Deserialize, Clone, Default, Debug, PartialEq)]
1142		#[serde(rename_all = "camelCase")]
1143		struct Program {
1144			program_number: u16,
1145			pmt_pid: u16,
1146		}
1147
1148		#[derive(Serialize, Deserialize, Clone, Default, Debug, PartialEq)]
1149		struct MpegtsExt {
1150			#[serde(default, skip_serializing_if = "Option::is_none")]
1151			mpegts: Option<Program>,
1152		}
1153
1154		let ext = MpegtsExt {
1155			mpegts: Some(Program {
1156				program_number: 1,
1157				pmt_pid: 100,
1158			}),
1159		};
1160		let catalog = Catalog::with_ext(vec![video_track()], ext.clone());
1161
1162		let json = catalog.to_json().unwrap();
1163		let value: serde_json::Value = serde_json::from_str(&json).unwrap();
1164		assert_eq!(
1165			value["mpegts"]["pmtPid"],
1166			serde_json::json!(100),
1167			"written flat: {json}"
1168		);
1169
1170		let parsed = Catalog::<MpegtsExt>::from_str(&json).expect("typed extension must decode");
1171		assert_eq!(parsed.ext, ext);
1172		assert_eq!(parsed.tracks.len(), 1);
1173	}
1174
1175	/// A typed extension whose member name collides with one MSF defines trips the debug
1176	/// assertion: nothing else catches it, since serde writes the duplicate key happily.
1177	#[test]
1178	#[should_panic(expected = "collides with a reserved root member")]
1179	fn colliding_typed_extension_trips_the_debug_assertion() {
1180		#[derive(Serialize, Deserialize, Clone, Default)]
1181		struct BadExt {
1182			tracks: Vec<String>,
1183		}
1184
1185		let _ = Catalog::with_ext(Vec::new(), BadExt::default()).to_json();
1186	}
1187
1188	/// Root members the extension does not model are dropped, exactly as the hang catalog
1189	/// drops sections its extension does not declare.
1190	#[test]
1191	fn unmodeled_root_members_are_dropped() {
1192		let json = r#"{ "version": "draft-01", "tracks": [], "somethingElse": [1, 2, 3] }"#;
1193		let catalog = Catalog::<()>::from_str(json).expect("an unknown member must not fail the parse");
1194		let value: serde_json::Value = serde_json::from_str(&catalog.to_json().unwrap()).unwrap();
1195		assert!(value.get("somethingElse").is_none());
1196	}
1197
1198	/// The untyped extension: a serde_json map keeps every unmodeled member verbatim, for a
1199	/// caller that does not know the sections at compile time.
1200	#[test]
1201	fn untyped_extension_is_passthrough() {
1202		type Extra = serde_json::Map<String, serde_json::Value>;
1203
1204		let json = r#"{
1205			"version": "draft-01",
1206			"tracks": [],
1207			"mpegts": { "program": { "pmtPid": 100 } },
1208			"somethingElse": [1, 2, 3]
1209		}"#;
1210		let catalog = Catalog::<Extra>::from_str(json).expect("passthrough must decode");
1211		assert_eq!(catalog.ext.len(), 2);
1212
1213		let value: serde_json::Value = serde_json::from_str(&catalog.to_json().unwrap()).unwrap();
1214		assert_eq!(value["mpegts"]["program"]["pmtPid"], serde_json::json!(100));
1215		assert_eq!(value["somethingElse"], serde_json::json!([1, 2, 3]));
1216	}
1217}