1pub mod codes;
11pub mod volindex;
12pub mod volindex_codes;
13
14pub use volindex::{parse_volindex, VolindexError, VolumeIndex};
16
17use base64::Engine;
18use serde::{Deserialize, Serialize};
19use thiserror::Error;
20use uuid::Uuid;
21
22#[derive(Debug, Error, PartialEq)]
25pub enum ImfTypeError {
26 #[error("Invalid UUID '{0}': expected urn:uuid:<uuid> or bare UUID")]
27 InvalidUuid(String),
28 #[error("Invalid edit rate '{0}': expected 'numerator denominator'")]
29 InvalidEditRate(String),
30 #[error("Invalid hash: {0}")]
31 InvalidHash(String),
32 #[error("Invalid language tag '{0}': must be non-empty")]
33 InvalidLanguageTag(String),
34 #[error("Invalid SMPTE UL '{0}': expected 16 hex bytes in dotted groups")]
35 InvalidUl(String),
36}
37
38#[derive(Debug, Clone, Copy)]
55pub struct SmpteUl(pub [u8; 16]);
56
57impl SmpteUl {
58 pub fn parse(s: &str) -> Result<Self, ImfTypeError> {
65 let hex_part = s.strip_prefix("urn:smpte:ul:").unwrap_or(s).trim();
66
67 let hex_str: String = hex_part.chars().filter(|c| *c != '.').collect();
69
70 if hex_str.len() != 32 {
71 return Err(ImfTypeError::InvalidUl(s.to_string()));
72 }
73
74 let mut bytes = [0u8; 16];
75 for i in 0..16 {
76 bytes[i] = u8::from_str_radix(&hex_str[i * 2..i * 2 + 2], 16)
77 .map_err(|_| ImfTypeError::InvalidUl(s.to_string()))?;
78 }
79
80 Ok(SmpteUl(bytes))
81 }
82
83 pub fn matches_ignoring_version(&self, other: &SmpteUl) -> bool {
87 for i in 0..16 {
88 if i == 7 {
89 continue; }
91 if self.0[i] != other.0[i] {
92 return false;
93 }
94 }
95 true
96 }
97
98 pub fn normalized(&self) -> Self {
100 let mut bytes = self.0;
101 bytes[7] = 0;
102 SmpteUl(bytes)
103 }
104
105 pub fn item_bytes(&self) -> &[u8] {
107 &self.0[8..]
108 }
109}
110
111impl PartialEq for SmpteUl {
112 fn eq(&self, other: &Self) -> bool {
114 self.matches_ignoring_version(other)
115 }
116}
117
118impl Eq for SmpteUl {}
119
120impl std::hash::Hash for SmpteUl {
121 fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
122 let norm = self.normalized();
124 norm.0.hash(state);
125 }
126}
127
128impl std::fmt::Display for SmpteUl {
129 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
130 write!(
131 f,
132 "urn:smpte:ul:{:02x}{:02x}{:02x}{:02x}.{:02x}{:02x}{:02x}{:02x}.{:02x}{:02x}{:02x}{:02x}.{:02x}{:02x}{:02x}{:02x}",
133 self.0[0], self.0[1], self.0[2], self.0[3],
134 self.0[4], self.0[5], self.0[6], self.0[7],
135 self.0[8], self.0[9], self.0[10], self.0[11],
136 self.0[12], self.0[13], self.0[14], self.0[15],
137 )
138 }
139}
140
141#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
149#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
150#[cfg_attr(feature = "typescript", ts(type = "string"))]
151pub struct ImfUuid(pub Uuid);
152
153impl ImfUuid {
154 pub fn parse(s: &str) -> Result<Self, ImfTypeError> {
164 let bare = s.strip_prefix("urn:uuid:").unwrap_or(s);
165 Uuid::parse_str(bare)
166 .map(ImfUuid)
167 .map_err(|_| ImfTypeError::InvalidUuid(s.to_string()))
168 }
169
170 pub fn parse_urn(s: &str) -> Result<Self, ImfTypeError> {
178 let bare = s
179 .strip_prefix("urn:uuid:")
180 .ok_or_else(|| ImfTypeError::InvalidUuid(s.to_string()))?;
181 Uuid::parse_str(bare)
182 .map(ImfUuid)
183 .map_err(|_| ImfTypeError::InvalidUuid(s.to_string()))
184 }
185
186 pub fn to_urn(&self) -> String {
188 format!("urn:uuid:{}", self.0)
189 }
190}
191
192impl std::fmt::Display for ImfUuid {
193 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
194 self.0.fmt(f)
195 }
196}
197
198impl Serialize for ImfUuid {
199 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
200 s.serialize_str(&self.0.to_string())
201 }
202}
203
204impl<'de> Deserialize<'de> for ImfUuid {
205 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
206 let s = String::deserialize(d)?;
207 ImfUuid::parse(&s).map_err(serde::de::Error::custom)
220 }
221}
222
223#[cfg(feature = "jsonschema")]
224impl schemars::JsonSchema for ImfUuid {
225 fn schema_name() -> String {
226 "ImfUuid".to_owned()
227 }
228
229 fn json_schema(gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema {
230 let mut schema = gen.subschema_for::<String>().into_object();
231 schema.metadata().description = Some(
232 "A SMPTE IMF UUID, serialised as a bare UUID string (e.g. \"0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85\")".to_owned()
233 );
234 schema.format = Some("uuid".to_owned());
235 schema.into()
236 }
237}
238
239#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
245#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
246pub struct AssetHash {
247 algorithm: HashAlgorithm,
248 bytes: Vec<u8>,
249}
250
251#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
257#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
258pub enum HashAlgorithm {
259 Sha1,
262 Sha256,
265}
266
267impl HashAlgorithm {
268 pub fn from_uri(uri: &str) -> Option<Self> {
273 match uri.trim() {
274 "http://www.w3.org/2000/09/xmldsig#sha1" => Some(Self::Sha1),
275 "http://www.w3.org/2001/04/xmlenc#sha256" => Some(Self::Sha256),
276 _ => None,
277 }
278 }
279
280 pub fn digest_len(&self) -> usize {
282 match self {
283 Self::Sha1 => 20,
284 Self::Sha256 => 32,
285 }
286 }
287}
288
289impl std::fmt::Display for HashAlgorithm {
290 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
291 match self {
292 Self::Sha1 => write!(f, "SHA-1"),
293 Self::Sha256 => write!(f, "SHA-256"),
294 }
295 }
296}
297
298impl AssetHash {
299 pub fn algorithm(&self) -> HashAlgorithm {
301 self.algorithm
302 }
303
304 pub fn bytes(&self) -> &[u8] {
306 &self.bytes
307 }
308
309 pub fn from_base64_sha1(b64: &str) -> Result<Self, ImfTypeError> {
313 let bytes = base64::engine::general_purpose::STANDARD
314 .decode(b64)
315 .map_err(|e| ImfTypeError::InvalidHash(e.to_string()))?;
316 if bytes.len() != 20 {
317 return Err(ImfTypeError::InvalidHash(format!(
318 "SHA-1 digest must be 20 bytes, got {}",
319 bytes.len()
320 )));
321 }
322 Ok(Self {
323 algorithm: HashAlgorithm::Sha1,
324 bytes,
325 })
326 }
327
328 pub fn from_base64_sha256(b64: &str) -> Result<Self, ImfTypeError> {
332 let bytes = base64::engine::general_purpose::STANDARD
333 .decode(b64)
334 .map_err(|e| ImfTypeError::InvalidHash(e.to_string()))?;
335 if bytes.len() != 32 {
336 return Err(ImfTypeError::InvalidHash(format!(
337 "SHA-256 digest must be 32 bytes, got {}",
338 bytes.len()
339 )));
340 }
341 Ok(Self {
342 algorithm: HashAlgorithm::Sha256,
343 bytes,
344 })
345 }
346
347 pub fn from_base64(b64: &str, algorithm: HashAlgorithm) -> Result<Self, ImfTypeError> {
349 match algorithm {
350 HashAlgorithm::Sha1 => Self::from_base64_sha1(b64),
351 HashAlgorithm::Sha256 => Self::from_base64_sha256(b64),
352 }
353 }
354
355 pub fn to_base64(&self) -> String {
357 base64::engine::general_purpose::STANDARD.encode(&self.bytes)
358 }
359}
360
361#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
365#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
366pub enum MimeType {
367 TextXml,
369 ApplicationXml,
371 ApplicationMxf,
373 Other(String),
375}
376
377impl MimeType {
378 pub fn parse(s: &str) -> Self {
379 match s.trim() {
380 "text/xml" => Self::TextXml,
381 "application/xml" => Self::ApplicationXml,
382 "application/mxf" => Self::ApplicationMxf,
383 other => Self::Other(other.to_string()),
384 }
385 }
386
387 pub fn is_xml(&self) -> bool {
388 matches!(self, Self::TextXml | Self::ApplicationXml)
389 }
390
391 pub fn is_mxf(&self) -> bool {
392 matches!(self, Self::ApplicationMxf)
393 }
394}
395
396impl std::fmt::Display for MimeType {
397 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
398 match self {
399 Self::TextXml => write!(f, "text/xml"),
400 Self::ApplicationXml => write!(f, "application/xml"),
401 Self::ApplicationMxf => write!(f, "application/mxf"),
402 Self::Other(s) => write!(f, "{}", s),
403 }
404 }
405}
406
407#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
411#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
412pub enum AssetMapNamespace {
413 #[default]
415 Dci429_9,
416 Smpte2067_9_2016,
422 Unknown(String),
424}
425
426impl AssetMapNamespace {
427 pub fn from_uri(uri: &str) -> Self {
429 match uri.trim() {
430 "http://www.smpte-ra.org/schemas/429-9/2007/AM" => Self::Dci429_9,
431 "http://www.smpte-ra.org/schemas/2067-9/2016" => Self::Smpte2067_9_2016,
432 other => Self::Unknown(other.to_string()),
433 }
434 }
435
436 pub fn spec_id(&self) -> &str {
438 match self {
439 Self::Dci429_9 => "ST 429-9:2007",
440 Self::Smpte2067_9_2016 => "ST 2067-9:2016",
441 Self::Unknown(_) => "Unknown",
442 }
443 }
444}
445
446impl std::fmt::Display for AssetMapNamespace {
447 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
448 match self {
449 Self::Dci429_9 => write!(f, "http://www.smpte-ra.org/schemas/429-9/2007/AM"),
450 Self::Smpte2067_9_2016 => write!(f, "http://www.smpte-ra.org/schemas/2067-9/2016"),
451 Self::Unknown(s) => write!(f, "{}", s),
452 }
453 }
454}
455
456#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
462#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
463pub enum PklNamespace {
464 #[default]
466 Dci429_8,
467 Smpte2067_2_2013,
469 Smpte2067_2_2016,
471 Smpte2067_2_2016Pkl,
473 Smpte2067_2_2020,
475 Unknown(String),
477}
478
479impl PklNamespace {
480 pub fn from_uri(uri: &str) -> Self {
482 match uri.trim() {
483 "http://www.smpte-ra.org/schemas/429-8/2007/PKL" => Self::Dci429_8,
484 "http://www.smpte-ra.org/schemas/2067-2/2013" => Self::Smpte2067_2_2013,
485 "http://www.smpte-ra.org/schemas/2067-2/2016" => Self::Smpte2067_2_2016,
486 "http://www.smpte-ra.org/schemas/2067-2/2016/PKL" => Self::Smpte2067_2_2016Pkl,
487 "http://www.smpte-ra.org/ns/2067-2/2020" => Self::Smpte2067_2_2020,
488 other => Self::Unknown(other.to_string()),
489 }
490 }
491
492 pub fn spec_id(&self) -> &str {
494 match self {
495 Self::Dci429_8 => "ST 429-8:2007",
496 Self::Smpte2067_2_2013 => "ST 2067-2:2013",
497 Self::Smpte2067_2_2016 | Self::Smpte2067_2_2016Pkl => "ST 2067-2:2016",
498 Self::Smpte2067_2_2020 => "ST 2067-2:2020",
499 Self::Unknown(_) => "Unknown",
500 }
501 }
502}
503
504impl std::fmt::Display for PklNamespace {
505 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
506 match self {
507 Self::Dci429_8 => write!(f, "http://www.smpte-ra.org/schemas/429-8/2007/PKL"),
508 Self::Smpte2067_2_2013 => write!(f, "http://www.smpte-ra.org/schemas/2067-2/2013"),
509 Self::Smpte2067_2_2016 => write!(f, "http://www.smpte-ra.org/schemas/2067-2/2016"),
510 Self::Smpte2067_2_2016Pkl => {
511 write!(f, "http://www.smpte-ra.org/schemas/2067-2/2016/PKL")
512 }
513 Self::Smpte2067_2_2020 => write!(f, "http://www.smpte-ra.org/ns/2067-2/2020"),
514 Self::Unknown(s) => write!(f, "{}", s),
515 }
516 }
517}
518
519#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
527#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
528pub enum CoreConstraintsNamespace {
529 Smpte2067_2_2013,
531 #[default]
533 Smpte2067_2_2016,
534 Smpte2067_2_2020,
536 Unknown(String),
538}
539
540impl CoreConstraintsNamespace {
541 pub fn from_uri(uri: &str) -> Self {
543 match uri.trim() {
544 "http://www.smpte-ra.org/schemas/2067-2/2013" => Self::Smpte2067_2_2013,
545 "http://www.smpte-ra.org/schemas/2067-2/2016" => Self::Smpte2067_2_2016,
546 "http://www.smpte-ra.org/ns/2067-2/2020" => Self::Smpte2067_2_2020,
547 other => Self::Unknown(other.to_string()),
548 }
549 }
550
551 pub fn spec_id(&self) -> &str {
553 match self {
554 Self::Smpte2067_2_2013 => "ST 2067-2:2013",
555 Self::Smpte2067_2_2016 => "ST 2067-2:2016",
556 Self::Smpte2067_2_2020 => "ST 2067-2:2020",
557 Self::Unknown(_) => "Unknown",
558 }
559 }
560}
561
562impl std::fmt::Display for CoreConstraintsNamespace {
563 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
564 match self {
565 Self::Smpte2067_2_2013 => write!(f, "http://www.smpte-ra.org/schemas/2067-2/2013"),
566 Self::Smpte2067_2_2016 => write!(f, "http://www.smpte-ra.org/schemas/2067-2/2016"),
567 Self::Smpte2067_2_2020 => write!(f, "http://www.smpte-ra.org/ns/2067-2/2020"),
568 Self::Unknown(s) => write!(f, "{}", s),
569 }
570 }
571}
572
573pub fn detect_root_namespace(xml: &str) -> Option<String> {
580 use std::sync::LazyLock;
581 static RE_XMLNS: LazyLock<regex::Regex> =
584 LazyLock::new(|| regex::Regex::new(r#"(?:^|[\s<])xmlns="([^"]*)""#).unwrap());
585 RE_XMLNS.captures(xml).map(|cap| cap[1].to_string())
586}
587
588#[derive(Debug, Error)]
592pub enum AssetMapParseError {
593 #[error("XML parse error: {0}")]
595 Xml(#[from] quick_xml::DeError),
596 #[error("Invalid field '{field}': {source}")]
598 Field {
599 field: &'static str,
600 #[source]
601 source: ImfTypeError,
602 },
603}
604
605mod raw {
608 use serde::Deserialize;
609
610 fn default_volume_index() -> u32 {
611 1
612 }
613
614 #[derive(Deserialize)]
615 pub struct AssetMap {
616 #[serde(rename = "Id")]
617 pub id: String,
618 #[serde(rename = "AnnotationText", default)]
619 pub annotation_text: Option<String>,
620 #[serde(rename = "Creator", default)]
621 pub creator: Option<String>,
622 #[serde(rename = "VolumeCount")]
623 pub volume_count: u32,
624 #[serde(rename = "IssueDate")]
625 pub issue_date: String,
626 #[serde(rename = "Issuer", default)]
627 pub issuer: Option<String>,
628 #[serde(rename = "AssetList")]
629 pub asset_list: AssetList,
630 }
631
632 #[derive(Deserialize)]
633 pub struct AssetList {
634 #[serde(rename = "Asset")]
635 pub assets: Vec<Asset>,
636 }
637
638 #[derive(Deserialize)]
639 pub struct Asset {
640 #[serde(rename = "Id")]
641 pub id: String,
642 #[serde(rename = "PackingList", default)]
643 pub packing_list: Option<bool>,
644 #[serde(rename = "ChunkList")]
645 pub chunk_list: ChunkList,
646 }
647
648 #[derive(Deserialize)]
649 pub struct ChunkList {
650 #[serde(rename = "Chunk")]
651 pub chunks: Vec<Chunk>,
652 }
653
654 #[derive(Deserialize)]
655 pub struct Chunk {
656 #[serde(rename = "Path")]
657 pub path: String,
658 #[serde(rename = "VolumeIndex", default = "default_volume_index")]
659 pub volume_index: u32,
660 }
661
662 #[derive(Deserialize)]
665 pub struct OutputProfileList {
666 #[serde(rename = "Id")]
667 pub id: String,
668 #[serde(rename = "Annotation", default)]
669 pub annotation: Option<String>,
670 #[serde(rename = "IssueDate")]
671 pub issue_date: String,
672 #[serde(rename = "Issuer", default)]
673 pub issuer: Option<String>,
674 #[serde(rename = "Creator", default)]
675 pub creator: Option<String>,
676 #[serde(rename = "CompositionPlaylistId")]
677 pub composition_playlist_id: String,
678 }
679
680 #[derive(Deserialize)]
683 pub struct PackingList {
684 #[serde(rename = "Id")]
685 pub id: String,
686 #[serde(rename = "AnnotationText", default)]
687 pub annotation_text: Option<String>,
688 #[serde(rename = "IssueDate")]
689 pub issue_date: String,
690 #[serde(rename = "Issuer", default)]
691 pub issuer: Option<String>,
692 #[serde(rename = "Creator", default)]
693 pub creator: Option<String>,
694 #[serde(rename = "GroupId", default)]
696 pub group_id: Option<String>,
697 #[serde(rename = "AssetList")]
698 pub asset_list: PklAssetList,
699 }
700
701 #[derive(Deserialize)]
702 pub struct PklAssetList {
703 #[serde(rename = "Asset")]
704 pub assets: Vec<PklAsset>,
705 }
706
707 #[derive(Deserialize)]
710 pub struct DigestMethod {
711 #[serde(rename = "@Algorithm")]
712 pub algorithm: String,
713 }
714
715 #[derive(Deserialize)]
716 pub struct PklAsset {
717 #[serde(rename = "Id")]
718 pub id: String,
719 #[serde(rename = "AnnotationText", default)]
720 pub annotation_text: Option<String>,
721 #[serde(rename = "Hash")]
722 pub hash: String,
723 #[serde(rename = "Size")]
724 pub size: u64,
725 #[serde(rename = "Type")]
726 pub mime_type: String,
727 #[serde(rename = "OriginalFileName", default)]
728 pub original_file_name: Option<String>,
729 #[serde(rename = "HashAlgorithm", default)]
732 pub hash_algorithm: Option<DigestMethod>,
733 }
734}
735
736#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
742#[derive(Debug, Serialize, Deserialize, PartialEq)]
743#[serde(rename_all = "camelCase")]
744#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
745#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
746#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
747#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
748pub struct AssetMap {
749 #[serde(skip)]
751 pub namespace: AssetMapNamespace,
752 pub id: ImfUuid,
754 pub annotation_text: Option<String>,
755 pub creator: Option<String>,
756 pub volume_count: u32,
757 pub issue_date: String,
759 pub issuer: Option<String>,
760 pub asset_list: AssetList,
761}
762
763impl AssetMap {
764 fn from_raw(
765 raw: raw::AssetMap,
766 namespace: AssetMapNamespace,
767 ) -> Result<Self, AssetMapParseError> {
768 Ok(Self {
769 namespace,
770 id: ImfUuid::parse(&raw.id).map_err(|source| AssetMapParseError::Field {
771 field: "Id",
772 source,
773 })?,
774 annotation_text: raw.annotation_text,
775 creator: raw.creator,
776 volume_count: raw.volume_count,
777 issue_date: raw.issue_date,
778 issuer: raw.issuer,
779 asset_list: AssetList::from_raw(raw.asset_list)?,
780 })
781 }
782}
783
784#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
786#[derive(Debug, Serialize, Deserialize, PartialEq)]
787#[serde(rename_all = "camelCase")]
788#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
789#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
790#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
791#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
792pub struct AssetList {
793 pub assets: Vec<Asset>,
794}
795
796impl AssetList {
797 fn from_raw(raw: raw::AssetList) -> Result<Self, AssetMapParseError> {
798 let assets = raw
799 .assets
800 .into_iter()
801 .map(Asset::from_raw)
802 .collect::<Result<Vec<_>, _>>()?;
803 Ok(Self { assets })
804 }
805}
806
807#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
809#[derive(Debug, Serialize, Deserialize, PartialEq)]
810#[serde(rename_all = "camelCase")]
811#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
812#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
813#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
814#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
815pub struct Asset {
816 pub id: ImfUuid,
818 pub packing_list: Option<bool>,
820 pub chunk_list: ChunkList,
821}
822
823impl Asset {
824 fn from_raw(raw: raw::Asset) -> Result<Self, AssetMapParseError> {
825 Ok(Self {
826 id: ImfUuid::parse(&raw.id).map_err(|source| AssetMapParseError::Field {
827 field: "Id",
828 source,
829 })?,
830 packing_list: raw.packing_list,
831 chunk_list: ChunkList::from_raw(raw.chunk_list),
832 })
833 }
834}
835
836#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
838#[derive(Debug, Serialize, Deserialize, PartialEq)]
839#[serde(rename_all = "camelCase")]
840#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
841#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
842#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
843#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
844pub struct ChunkList {
845 pub chunks: Vec<Chunk>,
846}
847
848impl ChunkList {
849 fn from_raw(raw: raw::ChunkList) -> Self {
850 Self {
851 chunks: raw
852 .chunks
853 .into_iter()
854 .map(|c| Chunk {
855 path: c.path,
856 volume_index: c.volume_index,
857 })
858 .collect(),
859 }
860 }
861}
862
863#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
865#[derive(Debug, Serialize, Deserialize, PartialEq)]
866#[serde(rename_all = "camelCase")]
867#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
868#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
869#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
870#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
871pub struct Chunk {
872 pub path: String,
874 pub volume_index: u32,
875}
876
877#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
891#[derive(Debug, Serialize, Deserialize, PartialEq)]
892#[serde(rename_all = "camelCase")]
893pub struct OutputProfileList {
894 pub id: ImfUuid,
895 pub annotation: Option<String>,
896 pub issue_date: String,
898 pub issuer: Option<String>,
899 pub creator: Option<String>,
900 pub composition_playlist_id: ImfUuid,
902 #[serde(default)]
905 pub macros: Vec<OplMacro>,
906}
907
908#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
916#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
917#[serde(rename_all = "camelCase")]
918pub struct OplMacro {
919 pub xsi_type: Option<String>,
923 pub name: String,
925 pub annotation: Option<String>,
927 pub extra_fields: Vec<(String, String)>,
933}
934
935impl OutputProfileList {
936 fn from_raw(
937 raw: raw::OutputProfileList,
938 macros: Vec<OplMacro>,
939 ) -> Result<Self, AssetMapParseError> {
940 Ok(Self {
941 id: ImfUuid::parse(&raw.id).map_err(|source| AssetMapParseError::Field {
942 field: "Id",
943 source,
944 })?,
945 annotation: raw.annotation,
946 issue_date: raw.issue_date,
947 issuer: raw.issuer,
948 creator: raw.creator,
949 composition_playlist_id: ImfUuid::parse(&raw.composition_playlist_id).map_err(
950 |source| AssetMapParseError::Field {
951 field: "CompositionPlaylistId",
952 source,
953 },
954 )?,
955 macros,
956 })
957 }
958}
959
960#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
965#[derive(Debug, Serialize, Deserialize, PartialEq)]
966#[serde(rename_all = "camelCase")]
967pub struct PackingList {
968 #[serde(skip)]
970 pub namespace: PklNamespace,
971 pub id: ImfUuid,
972 pub annotation_text: Option<String>,
973 pub issue_date: String,
975 pub issuer: Option<String>,
976 pub creator: Option<String>,
977 pub group_id: Option<ImfUuid>,
979 pub asset_list: PklAssetList,
980}
981
982impl PackingList {
983 fn from_raw(
984 raw: raw::PackingList,
985 namespace: PklNamespace,
986 ) -> Result<Self, AssetMapParseError> {
987 let group_id = raw
988 .group_id
989 .map(|s| ImfUuid::parse(&s))
990 .transpose()
991 .map_err(|source| AssetMapParseError::Field {
992 field: "GroupId",
993 source,
994 })?;
995
996 Ok(Self {
997 namespace,
998 id: ImfUuid::parse(&raw.id).map_err(|source| AssetMapParseError::Field {
999 field: "Id",
1000 source,
1001 })?,
1002 annotation_text: raw.annotation_text,
1003 issue_date: raw.issue_date,
1004 issuer: raw.issuer,
1005 creator: raw.creator,
1006 group_id,
1007 asset_list: PklAssetList::from_raw(raw.asset_list)?,
1008 })
1009 }
1010}
1011
1012#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
1014#[derive(Debug, Serialize, Deserialize, PartialEq)]
1015#[serde(rename_all = "camelCase")]
1016pub struct PklAssetList {
1017 pub assets: Vec<PklAsset>,
1018}
1019
1020impl PklAssetList {
1021 fn from_raw(raw: raw::PklAssetList) -> Result<Self, AssetMapParseError> {
1022 let assets = raw
1023 .assets
1024 .into_iter()
1025 .map(PklAsset::from_raw)
1026 .collect::<Result<Vec<_>, _>>()?;
1027 Ok(Self { assets })
1028 }
1029}
1030
1031#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
1033#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
1034#[serde(rename_all = "camelCase")]
1035pub struct PklAsset {
1036 pub id: ImfUuid,
1037 pub annotation_text: Option<String>,
1038 pub hash: AssetHash,
1040 pub size: u64,
1042 pub mime_type: MimeType,
1044 pub original_file_name: Option<String>,
1045}
1046
1047impl PklAsset {
1048 fn from_raw(raw: raw::PklAsset) -> Result<Self, AssetMapParseError> {
1049 let algorithm = match &raw.hash_algorithm {
1052 Some(dm) => {
1053 HashAlgorithm::from_uri(&dm.algorithm).ok_or_else(|| AssetMapParseError::Field {
1054 field: "HashAlgorithm",
1055 source: ImfTypeError::InvalidHash(format!(
1056 "unsupported hash algorithm URI: {}",
1057 dm.algorithm
1058 )),
1059 })?
1060 }
1061 None => HashAlgorithm::Sha1,
1062 };
1063
1064 Ok(Self {
1065 id: ImfUuid::parse(&raw.id).map_err(|source| AssetMapParseError::Field {
1066 field: "Id",
1067 source,
1068 })?,
1069 annotation_text: raw.annotation_text,
1070 hash: AssetHash::from_base64(&raw.hash, algorithm).map_err(|source| {
1071 AssetMapParseError::Field {
1072 field: "Hash",
1073 source,
1074 }
1075 })?,
1076 size: raw.size,
1077 mime_type: MimeType::parse(&raw.mime_type),
1078 original_file_name: raw.original_file_name,
1079 })
1080 }
1081}
1082
1083pub fn parse_assetmap(xml_content: &str) -> Result<AssetMap, AssetMapParseError> {
1092 let namespace = detect_root_namespace(xml_content)
1096 .map(|uri| AssetMapNamespace::from_uri(&uri))
1097 .unwrap_or_else(|| AssetMapNamespace::Unknown(String::new()));
1098 let raw: raw::AssetMap = quick_xml::de::from_str(xml_content)?;
1099 AssetMap::from_raw(raw, namespace)
1100}
1101
1102pub fn parse_pkl(xml_content: &str) -> Result<PackingList, AssetMapParseError> {
1107 let namespace = detect_root_namespace(xml_content)
1111 .map(|uri| PklNamespace::from_uri(&uri))
1112 .unwrap_or_else(|| PklNamespace::Unknown(String::new()));
1113 let raw: raw::PackingList = quick_xml::de::from_str(xml_content)?;
1114 PackingList::from_raw(raw, namespace)
1115}
1116
1117pub fn parse_opl(xml_content: &str) -> Result<OutputProfileList, AssetMapParseError> {
1132 let raw: raw::OutputProfileList = quick_xml::de::from_str(xml_content)?;
1133 let macros = parse_opl_macros(xml_content).unwrap_or_default();
1134 OutputProfileList::from_raw(raw, macros)
1135}
1136
1137fn parse_opl_macros(xml_content: &str) -> Result<Vec<OplMacro>, quick_xml::Error> {
1145 use quick_xml::events::Event;
1146 use quick_xml::reader::Reader;
1147
1148 let mut reader = Reader::from_str(xml_content);
1149 reader.trim_text(true);
1150
1151 let mut macros: Vec<OplMacro> = Vec::new();
1152 let mut buf = Vec::new();
1153 let mut in_macro_list = 0u32;
1154 let mut current: Option<OplMacroBuilder> = None;
1155 let mut text_target: Option<String> = None;
1158
1159 loop {
1160 match reader.read_event_into(&mut buf) {
1161 Ok(Event::Start(e)) => {
1162 let local = local_name(e.name().as_ref());
1163 if local == "MacroList" {
1164 in_macro_list += 1;
1165 continue;
1166 }
1167 if in_macro_list == 0 {
1168 continue;
1169 }
1170 if local == "Macro" {
1171 let xsi_type = e
1173 .attributes()
1174 .with_checks(false)
1175 .filter_map(|a| a.ok())
1176 .find_map(|a| {
1177 if local_name(a.key.as_ref()) == "type" {
1178 std::str::from_utf8(&a.value).ok().map(str::to_string)
1179 } else {
1180 None
1181 }
1182 });
1183 current = Some(OplMacroBuilder {
1184 xsi_type,
1185 name: String::new(),
1186 annotation: None,
1187 extra_fields: Vec::new(),
1188 });
1189 text_target = None;
1190 } else if current.is_some() {
1191 if text_target.is_none() {
1195 text_target = Some(local);
1196 }
1197 }
1198 }
1199 Ok(Event::End(e)) => {
1200 let local = local_name(e.name().as_ref());
1201 if local == "MacroList" {
1202 in_macro_list = in_macro_list.saturating_sub(1);
1203 continue;
1204 }
1205 if local == "Macro" {
1206 if let Some(builder) = current.take() {
1207 macros.push(OplMacro {
1208 xsi_type: builder.xsi_type,
1209 name: builder.name,
1210 annotation: builder.annotation,
1211 extra_fields: builder.extra_fields,
1212 });
1213 }
1214 text_target = None;
1215 } else if let Some(target) = &text_target {
1216 if target == &local {
1217 text_target = None;
1218 }
1219 }
1220 }
1221 Ok(Event::Empty(e)) => {
1222 if let (true, Some(builder)) = (in_macro_list > 0, current.as_mut()) {
1224 let local = local_name(e.name().as_ref());
1225 match local.as_str() {
1226 "Name" => {} "Annotation" => builder.annotation = Some(String::new()),
1228 _ => builder.extra_fields.push((local, String::new())),
1229 }
1230 }
1231 }
1232 Ok(Event::Text(t)) => {
1233 if let (Some(builder), Some(target)) = (current.as_mut(), text_target.as_ref()) {
1234 let text = t.unescape().unwrap_or_default().into_owned();
1235 match target.as_str() {
1236 "Name" => builder.name.push_str(&text),
1237 "Annotation" => {
1238 builder.annotation =
1239 Some(builder.annotation.take().map(|a| a + &text).unwrap_or(text))
1240 }
1241 other => {
1242 if let Some(entry) =
1245 builder.extra_fields.iter_mut().rfind(|(n, _)| n == other)
1246 {
1247 entry.1.push_str(&text);
1248 } else {
1249 builder.extra_fields.push((other.to_string(), text));
1250 }
1251 }
1252 }
1253 }
1254 }
1255 Ok(Event::Eof) => break,
1256 Err(e) => return Err(e),
1257 _ => {}
1258 }
1259 buf.clear();
1260 }
1261
1262 Ok(macros)
1263}
1264
1265fn local_name(tag: &[u8]) -> String {
1267 let s = std::str::from_utf8(tag).unwrap_or("");
1268 match s.rsplit_once(':') {
1269 Some((_, local)) => local.to_string(),
1270 None => s.to_string(),
1271 }
1272}
1273
1274struct OplMacroBuilder {
1276 xsi_type: Option<String>,
1277 name: String,
1278 annotation: Option<String>,
1279 extra_fields: Vec<(String, String)>,
1280}
1281
1282#[cfg(test)]
1285mod tests {
1286 use super::*;
1287 use pretty_assertions::assert_eq;
1288 use std::path::PathBuf;
1289
1290 fn test_data(name: &str) -> PathBuf {
1291 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1292 .join("../../test-data")
1293 .join(name)
1294 }
1295
1296 #[test]
1300 fn uuid_parse_urn_form() {
1301 let id = ImfUuid::parse("urn:uuid:0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85").unwrap();
1302 assert_eq!(id.to_string(), "0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85");
1303 assert_eq!(id.to_urn(), "urn:uuid:0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85");
1304 }
1305
1306 #[test]
1307 fn uuid_parse_bare_form() {
1308 let id = ImfUuid::parse("0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85").unwrap();
1309 assert_eq!(id.to_string(), "0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85");
1310 }
1311
1312 #[test]
1313 fn uuid_parse_invalid() {
1314 assert!(ImfUuid::parse("not-a-uuid").is_err());
1315 assert!(ImfUuid::parse("").is_err());
1316 assert!(ImfUuid::parse("urn:uuid:not-valid").is_err());
1317 }
1318
1319 #[test]
1320 fn uuid_roundtrip_serde() {
1321 let id = ImfUuid::parse("urn:uuid:0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85").unwrap();
1322 let json = serde_json::to_string(&id).unwrap();
1323 assert_eq!(json, r#""0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85""#);
1325 let back: ImfUuid = serde_json::from_str(&json).unwrap();
1326 assert_eq!(id, back);
1327 }
1328
1329 #[test]
1330 fn uuid_deserialize_urn_from_json() {
1331 let back: ImfUuid =
1333 serde_json::from_str(r#""urn:uuid:0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85""#).unwrap();
1334 assert_eq!(back.to_string(), "0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85");
1335 }
1336
1337 #[test]
1341 fn smpte_ul_parse_4group() {
1342 let ul = SmpteUl::parse("060e2b34.04010106.04010101.03030000").unwrap();
1343 assert_eq!(ul.0[0], 0x06);
1344 assert_eq!(ul.0[7], 0x06); assert_eq!(ul.0[12], 0x03);
1346 }
1347
1348 #[test]
1349 fn smpte_ul_parse_urn_form() {
1350 let ul = SmpteUl::parse("urn:smpte:ul:060e2b34.04010106.04010101.03030000").unwrap();
1351 assert_eq!(ul.0[0], 0x06);
1352 }
1353
1354 #[test]
1355 fn smpte_ul_parse_5group_variant() {
1356 let ul = SmpteUl::parse("urn:smpte:ul:060e2b34.0401.0101.04010101.01020000").unwrap();
1358 assert_eq!(ul.0[4], 0x04);
1359 assert_eq!(ul.0[5], 0x01);
1360 }
1361
1362 #[test]
1363 fn smpte_ul_version_agnostic_equality() {
1364 let v1 = SmpteUl::parse("060e2b34.04010101.04010101.03030000").unwrap();
1366 let v6 = SmpteUl::parse("060e2b34.04010106.04010101.03030000").unwrap();
1367 let vd = SmpteUl::parse("060e2b34.0401010d.04010101.03030000").unwrap();
1368 assert_eq!(v1, v6, "version 01 == version 06");
1369 assert_eq!(v6, vd, "version 06 == version 0d");
1370 }
1371
1372 #[test]
1373 fn smpte_ul_different_items_not_equal() {
1374 let a = SmpteUl::parse("060e2b34.04010106.04010101.03030000").unwrap();
1375 let b = SmpteUl::parse("060e2b34.04010106.04010101.03040000").unwrap();
1376 assert_ne!(a, b);
1377 }
1378
1379 #[test]
1380 fn smpte_ul_display_roundtrip() {
1381 let ul = SmpteUl::parse("urn:smpte:ul:060e2b34.04010106.04010101.03030000").unwrap();
1382 let s = ul.to_string();
1383 assert!(s.starts_with("urn:smpte:ul:"));
1384 let ul2 = SmpteUl::parse(&s).unwrap();
1385 assert_eq!(ul, ul2);
1386 }
1387
1388 #[test]
1389 fn smpte_ul_parse_invalid() {
1390 assert!(SmpteUl::parse("not-a-ul").is_err());
1391 assert!(SmpteUl::parse("060e2b34.04010106").is_err()); }
1393
1394 #[test]
1398 fn asset_hash_sha1_roundtrip() {
1399 let b64 = "2jmj7l5rSw0yVb/vlWAYkK/YBwk=";
1401 let h = AssetHash::from_base64_sha1(b64).unwrap();
1402 assert_eq!(h.algorithm, HashAlgorithm::Sha1);
1403 assert_eq!(h.bytes.len(), 20);
1404 assert_eq!(h.to_base64(), b64);
1405 }
1406
1407 #[test]
1409 fn asset_hash_sha1_wrong_length_rejected() {
1410 let err = AssetHash::from_base64_sha1("AAAA").unwrap_err();
1411 assert!(err.to_string().contains("20 bytes"));
1412 }
1413
1414 #[test]
1415 fn asset_hash_sha256_roundtrip() {
1416 let b64 = "47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=";
1417 let h = AssetHash::from_base64_sha256(b64).unwrap();
1418 assert_eq!(h.algorithm, HashAlgorithm::Sha256);
1419 assert_eq!(h.bytes.len(), 32);
1420 assert_eq!(h.to_base64(), b64);
1421 }
1422
1423 #[test]
1424 fn asset_hash_sha256_wrong_length_rejected() {
1425 let err = AssetHash::from_base64_sha256("2jmj7l5rSw0yVb/vlWAYkK/YBwk=").unwrap_err();
1426 assert!(err.to_string().contains("32 bytes"));
1427 }
1428
1429 #[test]
1430 fn asset_hash_from_base64_routes_correctly() {
1431 let sha1_b64 = "2jmj7l5rSw0yVb/vlWAYkK/YBwk=";
1432 let sha256_b64 = "47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=";
1433
1434 let h1 = AssetHash::from_base64(sha1_b64, HashAlgorithm::Sha1).unwrap();
1435 assert_eq!(h1.algorithm, HashAlgorithm::Sha1);
1436
1437 let h2 = AssetHash::from_base64(sha256_b64, HashAlgorithm::Sha256).unwrap();
1438 assert_eq!(h2.algorithm, HashAlgorithm::Sha256);
1439 }
1440
1441 #[test]
1442 fn asset_hash_invalid_base64() {
1443 assert!(AssetHash::from_base64_sha1("not-valid-base64!!!").is_err());
1444 }
1445
1446 #[test]
1450 fn hash_algorithm_from_uri() {
1451 assert_eq!(
1452 HashAlgorithm::from_uri("http://www.w3.org/2000/09/xmldsig#sha1"),
1453 Some(HashAlgorithm::Sha1)
1454 );
1455 assert_eq!(
1456 HashAlgorithm::from_uri("http://www.w3.org/2001/04/xmlenc#sha256"),
1457 Some(HashAlgorithm::Sha256)
1458 );
1459 assert_eq!(HashAlgorithm::from_uri("http://example.com/unknown"), None);
1460 }
1461
1462 #[test]
1463 fn hash_algorithm_digest_len() {
1464 assert_eq!(HashAlgorithm::Sha1.digest_len(), 20);
1465 assert_eq!(HashAlgorithm::Sha256.digest_len(), 32);
1466 }
1467
1468 #[test]
1469 fn hash_algorithm_display() {
1470 assert_eq!(HashAlgorithm::Sha1.to_string(), "SHA-1");
1471 assert_eq!(HashAlgorithm::Sha256.to_string(), "SHA-256");
1472 }
1473
1474 #[test]
1478 fn volindex_parses_index_element() {
1479 let xml = r#"<?xml version="1.0" encoding="UTF-8" standalone="no" ?>
1480<VolumeIndex xmlns="http://www.smpte-ra.org/schemas/429-9/2007/AM">
1481 <Index>1</Index>
1482</VolumeIndex>"#;
1483 let result = parse_volindex(xml).unwrap();
1484 assert_eq!(result.index, 1);
1485 }
1486
1487 #[test]
1491 fn assetmap_id_is_imf_uuid() {
1492 let xml = r#"<?xml version="1.0" encoding="UTF-8" standalone="no" ?>
1493<AssetMap xmlns="http://www.smpte-ra.org/schemas/429-9/2007/AM">
1494 <Id>urn:uuid:75864667-c65e-4aae-a5b2-fa5ea5fe31b7</Id>
1495 <AnnotationText>MERIDIAN</AnnotationText>
1496 <Creator>Clipster 6.1.0.0 Beta (build 111500)</Creator>
1497 <VolumeCount>1</VolumeCount>
1498 <IssueDate>2016-10-06T08:35:02-00:00</IssueDate>
1499 <Issuer>R&S</Issuer>
1500 <AssetList>
1501 <Asset>
1502 <Id>urn:uuid:0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85</Id>
1503 <ChunkList>
1504 <Chunk>
1505 <Path>CPL_0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85.xml</Path>
1506 <VolumeIndex>1</VolumeIndex>
1507 </Chunk>
1508 </ChunkList>
1509 </Asset>
1510 <Asset>
1511 <Id>urn:uuid:f5e93462-aed2-44ad-a4ba-2adb65823e7c</Id>
1512 <PackingList>true</PackingList>
1513 <ChunkList>
1514 <Chunk>
1515 <Path>PKL_f5e93462-aed2-44ad-a4ba-2adb65823e7c.xml</Path>
1516 <VolumeIndex>1</VolumeIndex>
1517 </Chunk>
1518 </ChunkList>
1519 </Asset>
1520 </AssetList>
1521</AssetMap>"#;
1522
1523 let result = parse_assetmap(xml).unwrap();
1524 assert_eq!(
1525 result.id,
1526 ImfUuid::parse("urn:uuid:75864667-c65e-4aae-a5b2-fa5ea5fe31b7").unwrap()
1527 );
1528 assert_eq!(result.annotation_text, Some("MERIDIAN".to_string()));
1529 assert_eq!(result.volume_count, 1);
1530 assert_eq!(result.asset_list.assets.len(), 2);
1531
1532 let cpl_asset = &result.asset_list.assets[0];
1534 assert_eq!(
1535 cpl_asset.id,
1536 ImfUuid::parse("urn:uuid:0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85").unwrap()
1537 );
1538 assert_eq!(cpl_asset.packing_list, None);
1539 assert_eq!(
1540 cpl_asset.chunk_list.chunks[0].path,
1541 "CPL_0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85.xml"
1542 );
1543
1544 let pkl_asset = &result.asset_list.assets[1];
1546 assert_eq!(pkl_asset.packing_list, Some(true));
1547 assert_eq!(
1548 pkl_asset.chunk_list.chunks[0].path,
1549 "PKL_f5e93462-aed2-44ad-a4ba-2adb65823e7c.xml"
1550 );
1551 }
1552
1553 #[test]
1555 fn assetmap_invalid_uuid_returns_field_error() {
1556 let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1557<AssetMap xmlns="http://www.smpte-ra.org/schemas/429-9/2007/AM">
1558 <Id>not-a-valid-uuid</Id>
1559 <VolumeCount>1</VolumeCount>
1560 <IssueDate>2024-01-01T00:00:00Z</IssueDate>
1561 <AssetList><Asset>
1562 <Id>urn:uuid:00000000-0000-0000-0000-000000000001</Id>
1563 <ChunkList><Chunk><Path>foo.xml</Path></Chunk></ChunkList>
1564 </Asset></AssetList>
1565</AssetMap>"#;
1566 let err = parse_assetmap(xml).unwrap_err();
1567 assert!(
1568 matches!(err, AssetMapParseError::Field { field: "Id", .. }),
1569 "expected Field error for Id, got: {err}"
1570 );
1571 }
1572
1573 #[test]
1577 fn pkl_parses_assets_with_strong_types() {
1578 let xml = r#"<?xml version="1.0" encoding="UTF-8" standalone="no" ?>
1579<PackingList xmlns="http://www.smpte-ra.org/schemas/429-8/2007/PKL">
1580 <Id>urn:uuid:f5e93462-aed2-44ad-a4ba-2adb65823e7c</Id>
1581 <AnnotationText>MERIDIAN</AnnotationText>
1582 <IssueDate>2016-10-06T08:35:02-00:00</IssueDate>
1583 <Issuer>R&S</Issuer>
1584 <Creator>Clipster 6.1.0.0 Beta (build 111500)</Creator>
1585 <AssetList>
1586 <Asset>
1587 <Id>urn:uuid:0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85</Id>
1588 <AnnotationText>Meridian UHD 5994P</AnnotationText>
1589 <Hash>IW0J5IZBsAxLMCCmWtHvfHhjVUw=</Hash>
1590 <Size>15214</Size>
1591 <Type>text/xml</Type>
1592 <OriginalFileName>CPL_0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85.xml</OriginalFileName>
1593 </Asset>
1594 <Asset>
1595 <Id>urn:uuid:61d91654-2650-4abf-abbc-ad2c7f640bf8</Id>
1596 <Hash>fL7SnTeNskm71I4otXqr/T0D5LQ=</Hash>
1597 <Size>79486353</Size>
1598 <Type>application/mxf</Type>
1599 <OriginalFileName>MERIDIAN_Netflix_Photon_161006_00.mxf</OriginalFileName>
1600 </Asset>
1601 </AssetList>
1602</PackingList>"#;
1603
1604 let result = parse_pkl(xml).unwrap();
1605 assert_eq!(
1606 result.id,
1607 ImfUuid::parse("urn:uuid:f5e93462-aed2-44ad-a4ba-2adb65823e7c").unwrap()
1608 );
1609 assert_eq!(result.annotation_text, Some("MERIDIAN".to_string()));
1610 assert_eq!(result.issuer, Some("R&S".to_string()));
1611 assert_eq!(result.asset_list.assets.len(), 2);
1612
1613 let cpl_asset = &result.asset_list.assets[0];
1614 assert_eq!(cpl_asset.hash.algorithm, HashAlgorithm::Sha1);
1615 assert_eq!(cpl_asset.hash.bytes.len(), 20);
1616 assert_eq!(cpl_asset.hash.to_base64(), "IW0J5IZBsAxLMCCmWtHvfHhjVUw=");
1617 assert_eq!(cpl_asset.size, 15214);
1618 assert_eq!(cpl_asset.mime_type, MimeType::TextXml);
1619 assert!(cpl_asset.mime_type.is_xml());
1620
1621 let mxf_asset = &result.asset_list.assets[1];
1622 assert_eq!(mxf_asset.mime_type, MimeType::ApplicationMxf);
1623 assert!(mxf_asset.mime_type.is_mxf());
1624 }
1625
1626 #[test]
1628 fn pkl_explicit_sha1_hash_algorithm() {
1629 let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1630<PackingList xmlns="http://www.smpte-ra.org/schemas/429-8/2007/PKL">
1631 <Id>urn:uuid:f5e93462-aed2-44ad-a4ba-2adb65823e7c</Id>
1632 <IssueDate>2024-01-01T00:00:00Z</IssueDate>
1633 <AssetList><Asset>
1634 <Id>urn:uuid:00000000-0000-0000-0000-000000000001</Id>
1635 <Hash>2jmj7l5rSw0yVb/vlWAYkK/YBwk=</Hash>
1636 <Size>1024</Size>
1637 <Type>application/mxf</Type>
1638 <HashAlgorithm Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/>
1639 </Asset></AssetList>
1640</PackingList>"#;
1641 let result = parse_pkl(xml).unwrap();
1642 assert_eq!(
1643 result.asset_list.assets[0].hash.algorithm,
1644 HashAlgorithm::Sha1
1645 );
1646 }
1647
1648 #[test]
1650 fn pkl_sha256_hash_algorithm() {
1651 let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1652<PackingList xmlns="http://www.smpte-ra.org/schemas/429-8/2007/PKL">
1653 <Id>urn:uuid:f5e93462-aed2-44ad-a4ba-2adb65823e7c</Id>
1654 <IssueDate>2024-01-01T00:00:00Z</IssueDate>
1655 <AssetList><Asset>
1656 <Id>urn:uuid:00000000-0000-0000-0000-000000000001</Id>
1657 <Hash>47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=</Hash>
1658 <Size>1024</Size>
1659 <Type>application/mxf</Type>
1660 <HashAlgorithm Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
1661 </Asset></AssetList>
1662</PackingList>"#;
1663 let result = parse_pkl(xml).unwrap();
1664 assert_eq!(
1665 result.asset_list.assets[0].hash.algorithm,
1666 HashAlgorithm::Sha256
1667 );
1668 assert_eq!(result.asset_list.assets[0].hash.bytes.len(), 32);
1669 }
1670
1671 #[test]
1673 fn pkl_missing_hash_algorithm_defaults_to_sha1() {
1674 let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1675<PackingList xmlns="http://www.smpte-ra.org/schemas/429-8/2007/PKL">
1676 <Id>urn:uuid:f5e93462-aed2-44ad-a4ba-2adb65823e7c</Id>
1677 <IssueDate>2024-01-01T00:00:00Z</IssueDate>
1678 <AssetList><Asset>
1679 <Id>urn:uuid:00000000-0000-0000-0000-000000000001</Id>
1680 <Hash>2jmj7l5rSw0yVb/vlWAYkK/YBwk=</Hash>
1681 <Size>1024</Size>
1682 <Type>application/mxf</Type>
1683 </Asset></AssetList>
1684</PackingList>"#;
1685 let result = parse_pkl(xml).unwrap();
1686 assert_eq!(
1687 result.asset_list.assets[0].hash.algorithm,
1688 HashAlgorithm::Sha1
1689 );
1690 }
1691
1692 #[test]
1694 fn pkl_with_group_id() {
1695 let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1696<PackingList xmlns="http://www.smpte-ra.org/schemas/429-8/2007/PKL">
1697 <Id>urn:uuid:f5e93462-aed2-44ad-a4ba-2adb65823e7c</Id>
1698 <IssueDate>2024-01-01T00:00:00Z</IssueDate>
1699 <GroupId>urn:uuid:aabbccdd-1122-3344-5566-778899aabbcc</GroupId>
1700 <AssetList><Asset>
1701 <Id>urn:uuid:00000000-0000-0000-0000-000000000001</Id>
1702 <Hash>2jmj7l5rSw0yVb/vlWAYkK/YBwk=</Hash>
1703 <Size>1024</Size>
1704 <Type>application/mxf</Type>
1705 </Asset></AssetList>
1706</PackingList>"#;
1707 let result = parse_pkl(xml).unwrap();
1708 assert_eq!(
1709 result.group_id,
1710 Some(ImfUuid::parse("urn:uuid:aabbccdd-1122-3344-5566-778899aabbcc").unwrap())
1711 );
1712 }
1713
1714 #[test]
1716 fn pkl_unknown_mime_type_preserved() {
1717 let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1718<PackingList xmlns="http://www.smpte-ra.org/schemas/429-8/2007/PKL">
1719 <Id>urn:uuid:f5e93462-aed2-44ad-a4ba-2adb65823e7c</Id>
1720 <IssueDate>2024-01-01T00:00:00Z</IssueDate>
1721 <AssetList><Asset>
1722 <Id>urn:uuid:00000000-0000-0000-0000-000000000001</Id>
1723 <Hash>2jmj7l5rSw0yVb/vlWAYkK/YBwk=</Hash>
1724 <Size>512</Size>
1725 <Type>application/octet-stream</Type>
1726 </Asset></AssetList>
1727</PackingList>"#;
1728 let result = parse_pkl(xml).unwrap();
1729 assert_eq!(
1730 result.asset_list.assets[0].mime_type,
1731 MimeType::Other("application/octet-stream".to_string())
1732 );
1733 }
1734
1735 #[test]
1739 fn pkl_parses_with_2067_2_2016_namespace() {
1740 let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1741<PackingList xmlns="http://www.smpte-ra.org/schemas/2067-2/2016">
1742 <Id>urn:uuid:f5e93462-aed2-44ad-a4ba-2adb65823e7c</Id>
1743 <IssueDate>2024-01-01T00:00:00Z</IssueDate>
1744 <AssetList><Asset>
1745 <Id>urn:uuid:00000000-0000-0000-0000-000000000001</Id>
1746 <Hash>2jmj7l5rSw0yVb/vlWAYkK/YBwk=</Hash>
1747 <Size>1024</Size>
1748 <Type>application/mxf</Type>
1749 </Asset></AssetList>
1750</PackingList>"#;
1751 let result = parse_pkl(xml).unwrap();
1752 assert_eq!(result.asset_list.assets.len(), 1);
1753 assert_eq!(result.namespace, PklNamespace::Smpte2067_2_2016);
1754 assert_eq!(result.namespace.spec_id(), "ST 2067-2:2016");
1755 }
1756
1757 #[test]
1758 fn pkl_parses_with_2067_2_2020_namespace() {
1759 let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1760<PackingList xmlns="http://www.smpte-ra.org/ns/2067-2/2020">
1761 <Id>urn:uuid:f5e93462-aed2-44ad-a4ba-2adb65823e7c</Id>
1762 <IssueDate>2024-01-01T00:00:00Z</IssueDate>
1763 <AssetList><Asset>
1764 <Id>urn:uuid:00000000-0000-0000-0000-000000000001</Id>
1765 <Hash>2jmj7l5rSw0yVb/vlWAYkK/YBwk=</Hash>
1766 <Size>1024</Size>
1767 <Type>application/mxf</Type>
1768 </Asset></AssetList>
1769</PackingList>"#;
1770 let result = parse_pkl(xml).unwrap();
1771 assert_eq!(result.namespace, PklNamespace::Smpte2067_2_2020);
1772 }
1773
1774 #[test]
1775 fn pkl_detects_dci_429_8_namespace() {
1776 let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1777<PackingList xmlns="http://www.smpte-ra.org/schemas/429-8/2007/PKL">
1778 <Id>urn:uuid:f5e93462-aed2-44ad-a4ba-2adb65823e7c</Id>
1779 <IssueDate>2024-01-01T00:00:00Z</IssueDate>
1780 <AssetList><Asset>
1781 <Id>urn:uuid:00000000-0000-0000-0000-000000000001</Id>
1782 <Hash>2jmj7l5rSw0yVb/vlWAYkK/YBwk=</Hash>
1783 <Size>1024</Size>
1784 <Type>application/mxf</Type>
1785 </Asset></AssetList>
1786</PackingList>"#;
1787 let result = parse_pkl(xml).unwrap();
1788 assert_eq!(result.namespace, PklNamespace::Dci429_8);
1789 }
1790
1791 #[test]
1792 fn assetmap_parses_with_2067_9_namespace() {
1793 let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1794<AssetMap xmlns="http://www.smpte-ra.org/schemas/2067-9/2016">
1795 <Id>urn:uuid:75864667-c65e-4aae-a5b2-fa5ea5fe31b7</Id>
1796 <VolumeCount>1</VolumeCount>
1797 <IssueDate>2024-01-01T00:00:00Z</IssueDate>
1798 <AssetList>
1799 <Asset>
1800 <Id>urn:uuid:0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85</Id>
1801 <ChunkList><Chunk><Path>test.xml</Path></Chunk></ChunkList>
1802 </Asset>
1803 </AssetList>
1804</AssetMap>"#;
1805 let result = parse_assetmap(xml).unwrap();
1806 assert_eq!(result.asset_list.assets.len(), 1);
1807 assert_eq!(result.namespace, AssetMapNamespace::Smpte2067_9_2016);
1808 }
1809
1810 #[test]
1814 fn assetmap_with_fake_2020_namespace_lands_in_unknown() {
1815 let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1816<AssetMap xmlns="http://www.smpte-ra.org/ns/2067-9/2020">
1817 <Id>urn:uuid:75864667-c65e-4aae-a5b2-fa5ea5fe31b7</Id>
1818 <VolumeCount>1</VolumeCount>
1819 <IssueDate>2024-01-01T00:00:00Z</IssueDate>
1820 <AssetList>
1821 <Asset>
1822 <Id>urn:uuid:0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85</Id>
1823 <ChunkList><Chunk><Path>test.xml</Path></Chunk></ChunkList>
1824 </Asset>
1825 </AssetList>
1826</AssetMap>"#;
1827 let result = parse_assetmap(xml).unwrap();
1828 assert!(matches!(result.namespace, AssetMapNamespace::Unknown(_)));
1829 }
1830
1831 #[test]
1832 fn assetmap_detects_dci_429_9_namespace() {
1833 let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1834<AssetMap xmlns="http://www.smpte-ra.org/schemas/429-9/2007/AM">
1835 <Id>urn:uuid:75864667-c65e-4aae-a5b2-fa5ea5fe31b7</Id>
1836 <VolumeCount>1</VolumeCount>
1837 <IssueDate>2024-01-01T00:00:00Z</IssueDate>
1838 <AssetList>
1839 <Asset>
1840 <Id>urn:uuid:0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85</Id>
1841 <ChunkList><Chunk><Path>test.xml</Path></Chunk></ChunkList>
1842 </Asset>
1843 </AssetList>
1844</AssetMap>"#;
1845 let result = parse_assetmap(xml).unwrap();
1846 assert_eq!(result.namespace, AssetMapNamespace::Dci429_9);
1847 }
1848
1849 #[test]
1852 fn assetmap_without_root_xmlns_lands_in_unknown_not_dci() {
1853 let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1854<AssetMap>
1855 <Id>urn:uuid:75864667-c65e-4aae-a5b2-fa5ea5fe31b7</Id>
1856 <VolumeCount>1</VolumeCount>
1857 <IssueDate>2024-01-01T00:00:00Z</IssueDate>
1858 <AssetList>
1859 <Asset>
1860 <Id>urn:uuid:0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85</Id>
1861 <ChunkList><Chunk><Path>test.xml</Path></Chunk></ChunkList>
1862 </Asset>
1863 </AssetList>
1864</AssetMap>"#;
1865 let result = parse_assetmap(xml).expect("AssetMap should parse without xmlns");
1866 assert!(
1867 matches!(result.namespace, AssetMapNamespace::Unknown(ref s) if s.is_empty()),
1868 "expected Unknown(\"\") for missing xmlns, got {:?}",
1869 result.namespace
1870 );
1871 }
1872
1873 #[test]
1876 fn pkl_without_root_xmlns_lands_in_unknown_not_dci() {
1877 let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1878<PackingList>
1879 <Id>urn:uuid:75864667-c65e-4aae-a5b2-fa5ea5fe31b7</Id>
1880 <IssueDate>2024-01-01T00:00:00Z</IssueDate>
1881 <AssetList><Asset>
1882 <Id>urn:uuid:00000000-0000-0000-0000-000000000001</Id>
1883 <Hash>2jmj7l5rSw0yVb/vlWAYkK/YBwk=</Hash>
1884 <Size>1024</Size>
1885 <Type>application/xml</Type>
1886 </Asset></AssetList>
1887</PackingList>"#;
1888 let result = parse_pkl(xml).expect("PKL should parse without xmlns");
1889 assert!(
1890 matches!(result.namespace, PklNamespace::Unknown(ref s) if s.is_empty()),
1891 "expected Unknown(\"\") for missing xmlns, got {:?}",
1892 result.namespace
1893 );
1894 }
1895
1896 #[test]
1900 fn opl_parses_core_metadata() {
1901 let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1902<OutputProfileList xmlns="http://www.smpte-ra.org/schemas/2067-100/2014">
1903 <Id>urn:uuid:8cf83c32-4949-4f00-b081-01e12b18932f</Id>
1904 <Annotation>OPL Example</Annotation>
1905 <IssueDate>2016-06-14T19:22:37-00:00</IssueDate>
1906 <Issuer>Clipster</Issuer>
1907 <Creator>Clipster 5.9.3.7</Creator>
1908 <CompositionPlaylistId>urn:uuid:0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85</CompositionPlaylistId>
1909 <AliasList/>
1910 <MacroList/>
1911</OutputProfileList>"#;
1912 let result = parse_opl(xml).unwrap();
1913 assert_eq!(
1914 result.id.to_string(),
1915 "8cf83c32-4949-4f00-b081-01e12b18932f"
1916 );
1917 assert_eq!(result.annotation.as_deref(), Some("OPL Example"));
1918 assert_eq!(result.issuer.as_deref(), Some("Clipster"));
1919 assert_eq!(result.creator.as_deref(), Some("Clipster 5.9.3.7"));
1920 assert_eq!(
1921 result.composition_playlist_id.to_string(),
1922 "0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85"
1923 );
1924 }
1925
1926 #[test]
1928 fn opl_parses_real_test_file() {
1929 let xml = std::fs::read_to_string(test_data(
1930 "OPL/OPL_8cf83c32-4949-4f00-b081-01e12b18932f.xml",
1931 ))
1932 .unwrap();
1933 let result = parse_opl(&xml).unwrap();
1934 assert_eq!(
1935 result.id.to_string(),
1936 "8cf83c32-4949-4f00-b081-01e12b18932f"
1937 );
1938 assert_eq!(
1939 result.composition_playlist_id.to_string(),
1940 "0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85"
1941 );
1942 }
1943
1944 #[test]
1948 fn opl_with_empty_macro_list_yields_no_macros() {
1949 let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1950<OutputProfileList xmlns="http://www.smpte-ra.org/schemas/2067-100/2014">
1951 <Id>urn:uuid:8cf83c32-4949-4f00-b081-01e12b18932f</Id>
1952 <IssueDate>2016-06-14T19:22:37Z</IssueDate>
1953 <Issuer>x</Issuer>
1954 <Creator>x</Creator>
1955 <CompositionPlaylistId>urn:uuid:0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85</CompositionPlaylistId>
1956 <MacroList/>
1957</OutputProfileList>"#;
1958 let result = parse_opl(xml).unwrap();
1959 assert!(result.macros.is_empty());
1960 }
1961
1962 #[test]
1965 fn opl_with_preset_macro_captures_all_fields() {
1966 let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1967<OutputProfileList xmlns="http://www.smpte-ra.org/schemas/2067-100/2014"
1968 xmlns:opl="http://www.smpte-ra.org/schemas/2067-100/2014"
1969 xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
1970 <Id>urn:uuid:8cf83c32-4949-4f00-b081-01e12b18932f</Id>
1971 <IssueDate>2016-06-14T19:22:37Z</IssueDate>
1972 <Issuer>x</Issuer>
1973 <Creator>x</Creator>
1974 <CompositionPlaylistId>urn:uuid:0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85</CompositionPlaylistId>
1975 <MacroList>
1976 <Macro xsi:type="opl:PresetMacroType">
1977 <Name>HD1080p</Name>
1978 <Annotation>Preset for 1080p HD</Annotation>
1979 <Preset>urn:smpte:opl:preset:hd1080p</Preset>
1980 </Macro>
1981 </MacroList>
1982</OutputProfileList>"#;
1983 let result = parse_opl(xml).unwrap();
1984 assert_eq!(result.macros.len(), 1);
1985 let m = &result.macros[0];
1986 assert_eq!(m.xsi_type.as_deref(), Some("opl:PresetMacroType"));
1987 assert_eq!(m.name, "HD1080p");
1988 assert_eq!(m.annotation.as_deref(), Some("Preset for 1080p HD"));
1989 assert!(
1990 m.extra_fields
1991 .iter()
1992 .any(|(k, v)| k == "Preset" && v == "urn:smpte:opl:preset:hd1080p"),
1993 "expected Preset URI in extra_fields, got {:?}",
1994 m.extra_fields
1995 );
1996 }
1997
1998 #[test]
2000 fn opl_with_multiple_macros_captures_all_in_order() {
2001 let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
2002<OutputProfileList xmlns="http://www.smpte-ra.org/schemas/2067-100/2014"
2003 xmlns:opl="http://www.smpte-ra.org/schemas/2067-100/2014"
2004 xmlns:arm="http://www.smpte-ra.org/schemas/2067-103/2014"
2005 xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
2006 <Id>urn:uuid:8cf83c32-4949-4f00-b081-01e12b18932f</Id>
2007 <IssueDate>2016-06-14T19:22:37Z</IssueDate>
2008 <Issuer>x</Issuer>
2009 <Creator>x</Creator>
2010 <CompositionPlaylistId>urn:uuid:0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85</CompositionPlaylistId>
2011 <MacroList>
2012 <Macro xsi:type="opl:PresetMacroType">
2013 <Name>P1</Name>
2014 <Preset>urn:p1</Preset>
2015 </Macro>
2016 <Macro xsi:type="arm:AudioRoutingMixingMacroType">
2017 <Name>AudioMix</Name>
2018 <Annotation>5.1 downmix</Annotation>
2019 </Macro>
2020 </MacroList>
2021</OutputProfileList>"#;
2022 let result = parse_opl(xml).unwrap();
2023 assert_eq!(result.macros.len(), 2);
2024 assert_eq!(result.macros[0].name, "P1");
2025 assert_eq!(
2026 result.macros[0].xsi_type.as_deref(),
2027 Some("opl:PresetMacroType")
2028 );
2029 assert_eq!(result.macros[1].name, "AudioMix");
2030 assert_eq!(
2031 result.macros[1].xsi_type.as_deref(),
2032 Some("arm:AudioRoutingMixingMacroType")
2033 );
2034 assert_eq!(result.macros[1].annotation.as_deref(), Some("5.1 downmix"));
2035 }
2036
2037 #[test]
2039 fn opl_parses_isxd_test_file() {
2040 let xml = std::fs::read_to_string(test_data(
2041 "ISXD/CompleteIMP/OPL_af6b288d-27e8-441f-9a36-2c4ab9025d19.xml",
2042 ))
2043 .unwrap();
2044 let result = parse_opl(&xml).unwrap();
2045 assert_eq!(
2046 result.id.to_string(),
2047 "af6b288d-27e8-441f-9a36-2c4ab9025d19"
2048 );
2049 assert_eq!(
2050 result.composition_playlist_id.to_string(),
2051 "b2d74f92-1990-41e0-869f-2179a50f7090"
2052 );
2053 }
2054}