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