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