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