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