1use std::fmt;
20use std::str::FromStr;
21use std::time::Duration;
22
23use serde::{Deserialize, Serialize};
24use serde_with::DurationMilliSeconds;
25
26pub const DEFAULT_NAME: &str = "catalog";
28
29#[derive(Debug, Clone, PartialEq, Default)]
37pub struct Catalog {
38 pub tracks: Vec<Track>,
40}
41
42#[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 pub name: String,
57
58 pub packaging: Packaging,
60
61 #[serde(default)]
67 pub is_live: bool,
68
69 pub role: Option<Role>,
71
72 pub codec: Option<String>,
74
75 pub width: Option<u32>,
77
78 pub height: Option<u32>,
80
81 pub framerate: Option<f64>,
83
84 pub samplerate: Option<u32>,
86
87 pub channel_config: Option<String>,
89
90 pub bitrate: Option<u64>,
92
93 pub init_data: Option<String>,
99
100 init_ref: Option<String>,
104
105 pub render_group: Option<u32>,
107
108 pub alt_group: Option<u32>,
110
111 #[serde(rename = "maxGrpSapStartingType")]
115 pub max_grp_sap_starting_type: Option<u8>,
116
117 #[serde(rename = "maxObjSapStartingType")]
121 pub max_obj_sap_starting_type: Option<u8>,
122
123 #[serde_as(as = "Option<DurationMilliSeconds<u64>>")]
128 pub jitter: Option<Duration>,
129}
130
131impl Catalog {
132 pub fn to_string(&self) -> Result<String, serde_json::Error> {
134 serde_json::to_string(self)
135 }
136
137 #[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
144const 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 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 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 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#[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
233struct 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 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 Ok(WireVersion)
284 }
285 }
286
287 deserializer.deserialize_any(VersionVisitor)
288 }
289}
290
291#[derive(Serialize, Deserialize)]
293#[serde(rename_all = "camelCase")]
294struct InitData {
295 id: String,
297 #[serde(rename = "type")]
299 kind: String,
300 data: String,
302}
303
304impl Track {
305 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#[derive(Debug, Clone, PartialEq, Eq)]
337pub enum Packaging {
338 Loc,
340 Cmaf,
342 Legacy,
344 MediaTimeline,
346 EventTimeline,
348 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 Ok(Packaging::from_str(&s).unwrap())
391 }
392}
393
394#[derive(Debug, Clone, PartialEq, Eq)]
396pub enum Role {
397 Video,
399 Audio,
401 AudioDescription,
403 Caption,
405 Subtitle,
407 SignLanguage,
409 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 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 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 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 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 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 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 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 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 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 assert!(Catalog::from_str(r#"{"version":"draft-99","tracks":[]}"#).is_ok());
722 }
723
724 #[test]
725 fn unsupported_numeric_version_errors() {
726 assert!(Catalog::from_str(r#"{"version":2,"tracks":[]}"#).is_err());
728 }
729
730 #[test]
731 fn float_numeric_version_is_accepted() {
732 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 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 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 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 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 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 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 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 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}