=============== pmodidl/Cargo.toml ============ [package] name = "pmodidl" version = "0.1.0" edition = "2024" [dependencies] serde = "1.0.228" utoipa = { version = "5.4.0", features = ["axum_extras"] } utoipa-swagger-ui = { version = "9.0.2", features = ["axum"] } quick-xml = { version = "0.38.3", features = ["serialize"] } bevy_reflect = "0.17.1" bevy_reflect_derive = "0.17.1" pmoutils = { path = "../pmoutils" } xmltree = "0.10" ========= End of pmodidl/Cargo.toml =========== =============== pmodidl/examples/test_serialization.rs ============ use pmodidl::{DIDLLite, Item, Resource}; fn main() { let item1 = Item { id: "test1".to_string(), parent_id: "root".to_string(), restricted: Some("1".to_string()), title: "Test Song".to_string(), creator: Some("Test Artist".to_string()), class: "object.item.audioItem.musicTrack".to_string(), artist: Some("Test Artist".to_string()), album: None, // Pas d'album genre: None, album_art: None, // Pas d'albumArtURI album_art_pk: None, date: None, original_track_number: None, resources: vec![Resource { protocol_info: "http-get:*:audio/flac:*".to_string(), bits_per_sample: Some("16".to_string()), sample_frequency: Some("44100".to_string()), nr_audio_channels: Some("2".to_string()), duration: Some("0:03:00".to_string()), url: "http://example.com/test.flac".to_string(), }], descriptions: vec![], }; let didl = DIDLLite { xmlns: "urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/".to_string(), xmlns_upnp: Some("urn:schemas-upnp-org:metadata-1-0/upnp/".to_string()), xmlns_dc: Some("http://purl.org/dc/elements/1.1/".to_string()), xmlns_dlna: Some("urn:schemas-dlna-org:metadata-1-0/".to_string()), xmlns_pv: None, xmlns_sec: None, containers: vec![], items: vec![item1], }; let xml = quick_xml::se::to_string(&didl).expect("Serialization failed"); println!("=== Output from quick_xml::se::to_string() ==="); println!("{}", xml); println!("\n=== Length: {} bytes ===", xml.len()); println!( "\n=== Starts with '{}", xml); println!("{}", with_decl); } ========= End of pmodidl/examples/test_serialization.rs =========== =============== pmodidl/src/lib.rs ============ //! # pmodidl - DIDL-Lite Parser //! //! Parser et utilitaires pour le format DIDL-Lite utilisé dans UPnP/DLNA. use bevy_reflect::Reflect; use pmoutils::ToXmlElement; use serde::{Deserialize, Serialize}; use std::borrow::Cow; use std::collections::HashSet; use std::fmt::Write; use std::io::Cursor; use xmltree::{Element, XMLNode}; // ============= Couche d'abstraction générique ============= /// Trait pour tout parser de métadonnées média pub trait MediaMetadataParser: Sized { type Error: std::error::Error + Send + Sync + 'static; /// Parse une chaîne de métadonnées fn parse(input: &str) -> Result; /// Retourne le format du parser fn format_name() -> &'static str; } /// Enveloppe générique pour tout type de métadonnées parsées #[derive(Debug, Clone, Serialize, Deserialize, Reflect)] pub struct ParsedMetadata { /// Format du document (ex: "DIDL-Lite", "RSS", etc.) pub format: String, /// Données parsées pub data: T, /// Timestamp du parsing (exclu de la réflexion car SystemTime n'implémente pas Reflect) #[reflect(ignore)] #[serde(skip_serializing_if = "Option::is_none")] pub parsed_at: Option, } impl ParsedMetadata { pub fn new(format: impl Into, data: T) -> Self { Self { format: format.into(), data, parsed_at: Some(std::time::SystemTime::now()), } } /// Transforme les données avec une fonction pub fn map(self, f: F) -> ParsedMetadata where F: FnOnce(T) -> U, { ParsedMetadata { format: self.format, data: f(self.data), parsed_at: self.parsed_at, } } } /// Fonction helper pour parser et envelopper automatiquement pub fn parse_metadata(input: &str) -> Result, P::Error> { let data = P::parse(input)?; Ok(ParsedMetadata::new(P::format_name(), data)) } // ============= Implémentation pour DIDLLite ============= impl MediaMetadataParser for DIDLLite { type Error = quick_xml::de::DeError; fn parse(input: &str) -> Result { let sanitized = sanitize_singleton_elements(input); quick_xml::de::from_str(sanitized.as_ref()) } fn format_name() -> &'static str { "DIDL-Lite" } } /// Type alias pour faciliter l'utilisation pub type DidlMetadata = ParsedMetadata; // ============= Structures DIDL-Lite ============= /// Racine d'un document DIDL-Lite #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema, Reflect)] #[serde(rename = "DIDL-Lite")] pub struct DIDLLite { #[serde(rename = "@xmlns")] pub xmlns: String, #[serde(rename = "@xmlns:upnp", skip_serializing_if = "Option::is_none")] pub xmlns_upnp: Option, #[serde(rename = "@xmlns:dc", skip_serializing_if = "Option::is_none")] pub xmlns_dc: Option, #[serde(rename = "@xmlns:dlna", skip_serializing_if = "Option::is_none")] pub xmlns_dlna: Option, #[serde(rename = "@xmlns:sec", skip_serializing_if = "Option::is_none")] pub xmlns_sec: Option, #[serde(rename = "@xmlns:pv", skip_serializing_if = "Option::is_none")] pub xmlns_pv: Option, #[serde(rename = "container", default)] pub containers: Vec, #[serde(rename = "item", default)] pub items: Vec, } /// Container pouvant contenir d'autres containers ou items #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema, Reflect)] pub struct Container { #[serde(rename = "@id")] pub id: String, #[serde(rename = "@parentID", default)] pub parent_id: String, #[serde(rename = "@restricted", skip_serializing_if = "Option::is_none")] pub restricted: Option, #[serde(rename = "@childCount", skip_serializing_if = "Option::is_none")] pub child_count: Option, #[serde(rename = "@searchable", skip_serializing_if = "Option::is_none")] pub searchable: Option, #[serde(rename = "dc:title", alias = "title")] pub title: String, #[serde(rename = "upnp:class", alias = "class", default)] pub class: String, #[serde(rename = "container", default)] pub containers: Vec, #[serde(rename = "item", default)] pub items: Vec, } /// Item représentant un objet audio #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema, Reflect)] pub struct Item { #[serde(rename = "@id")] pub id: String, #[serde(rename = "@parentID", default)] pub parent_id: String, #[serde(rename = "@restricted", skip_serializing_if = "Option::is_none")] pub restricted: Option, #[serde(rename = "dc:title", alias = "title")] pub title: String, #[serde( rename = "dc:creator", alias = "creator", skip_serializing_if = "Option::is_none" )] pub creator: Option, #[serde(rename = "upnp:class", alias = "class", default)] pub class: String, #[serde( rename = "upnp:artist", alias = "artist", skip_serializing_if = "Option::is_none" )] pub artist: Option, #[serde( rename = "upnp:album", alias = "album", skip_serializing_if = "Option::is_none" )] pub album: Option, #[serde( rename = "upnp:genre", alias = "genre", skip_serializing_if = "Option::is_none" )] pub genre: Option, #[serde( rename = "upnp:albumArtURI", alias = "albumArtURI", skip_serializing_if = "Option::is_none" )] pub album_art: Option, #[serde(skip)] pub album_art_pk: Option, #[serde( rename = "dc:date", alias = "date", skip_serializing_if = "Option::is_none" )] pub date: Option, #[serde( rename = "upnp:originalTrackNumber", alias = "originalTrackNumber", skip_serializing_if = "Option::is_none" )] pub original_track_number: Option, #[serde(rename = "res", default)] pub resources: Vec, #[serde(rename = "desc", default)] pub descriptions: Vec, } /// Ressource média (fichier audio) #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema, Reflect)] pub struct Resource { #[serde(rename = "@protocolInfo", default)] pub protocol_info: String, #[serde(rename = "@bitsPerSample", skip_serializing_if = "Option::is_none")] pub bits_per_sample: Option, #[serde(rename = "@sampleFrequency", skip_serializing_if = "Option::is_none")] pub sample_frequency: Option, #[serde(rename = "@nrAudioChannels", skip_serializing_if = "Option::is_none")] pub nr_audio_channels: Option, #[serde(rename = "@duration", skip_serializing_if = "Option::is_none")] pub duration: Option, #[serde(rename = "$text", default)] pub url: String, } /// Description avec métadonnées additionnelles (replaygain, etc.) #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema, Reflect)] pub struct Description { #[serde(rename = "@id", skip_serializing_if = "Option::is_none")] pub id: Option, #[serde(rename = "@nameSpace", skip_serializing_if = "Option::is_none")] pub namespace: Option, #[serde(rename = "track_gain", skip_serializing_if = "Option::is_none")] pub track_gain: Option, #[serde(rename = "track_peak", skip_serializing_if = "Option::is_none")] pub track_peak: Option, } // ============= Implémentation des méthodes ============= impl Default for DIDLLite { fn default() -> Self { Self { xmlns: "urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/".to_string(), xmlns_upnp: Some("urn:schemas-upnp-org:metadata-1-0/upnp/".to_string()), xmlns_dc: Some("http://purl.org/dc/elements/1.1/".to_string()), xmlns_dlna: None, xmlns_sec: None, xmlns_pv: None, containers: Vec::new(), items: Vec::new(), } } } impl DIDLLite { /// Applique les namespaces sur un élément xmltree. fn set_namespaces(&self, elem: &mut Element) { elem.attributes.insert("xmlns".into(), self.xmlns.clone()); if let Some(ref upnp) = self.xmlns_upnp { elem.attributes.insert("xmlns:upnp".into(), upnp.clone()); } if let Some(ref dc) = self.xmlns_dc { elem.attributes.insert("xmlns:dc".into(), dc.clone()); } if let Some(ref dlna) = self.xmlns_dlna { elem.attributes.insert("xmlns:dlna".into(), dlna.clone()); } if let Some(ref sec) = self.xmlns_sec { elem.attributes.insert("xmlns:sec".into(), sec.clone()); } if let Some(ref pv) = self.xmlns_pv { elem.attributes.insert("xmlns:pv".into(), pv.clone()); } } /// Itère sur tous les containers de manière récursive pub fn all_containers(&self) -> impl Iterator { AllContainersIter::new(&self.containers) } /// Itère sur tous les items de manière récursive pub fn all_items(&self) -> impl Iterator { AllItemsIter::new(&self.containers, &self.items) } /// Trouve un container par ID pub fn get_container_by_id(&self, id: &str) -> Option<&Container> { self.all_containers().find(|c| c.id == id) } /// Trouve un item par ID pub fn get_item_by_id(&self, id: &str) -> Option<&Item> { self.all_items().find(|i| i.id == id) } /// Filtre les containers pub fn filter_containers(&self, predicate: F) -> impl Iterator where F: Fn(&Container) -> bool, { self.all_containers().filter(move |c| predicate(c)) } /// Filtre les items pub fn filter_items(&self, predicate: F) -> impl Iterator where F: Fn(&Item) -> bool, { self.all_items().filter(move |i| predicate(i)) } /// Génère une représentation Markdown pub fn to_markdown(&self) -> String { let mut buf = String::new(); buf.push_str("### DIDL-Lite Document\n\n"); if !self.containers.is_empty() { buf.push_str("#### Containers\n\n"); for container in &self.containers { container.write_markdown(&mut buf, 0); } } if !self.items.is_empty() { buf.push_str("#### Items\n\n"); for item in &self.items { item.write_markdown(&mut buf, 0); } } buf } } impl Container { /// Itère sur tous les containers enfants récursivement pub fn all_containers(&self) -> impl Iterator { AllContainersIter::new(&self.containers) } /// Itère sur tous les items de ce container et ses enfants pub fn all_items(&self) -> impl Iterator { AllItemsIter::new(&self.containers, &self.items) } fn write_markdown(&self, buf: &mut String, depth: usize) { let indent = " ".repeat(depth); writeln!(buf, "{}- **Container**: {}", indent, self.title).unwrap(); writeln!(buf, "{} - ID: `{}`", indent, self.id).unwrap(); writeln!(buf, "{} - ParentID: `{}`", indent, self.parent_id).unwrap(); writeln!(buf, "{} - Class: `{}`", indent, self.class).unwrap(); if let Some(ref restricted) = self.restricted { writeln!(buf, "{} - Restricted: `{}`", indent, restricted).unwrap(); } if let Some(ref count) = self.child_count { writeln!(buf, "{} - ChildCount: `{}`", indent, count).unwrap(); } if !self.containers.is_empty() { writeln!(buf, "{} - Subcontainers:", indent).unwrap(); for sub in &self.containers { sub.write_markdown(buf, depth + 2); } } if !self.items.is_empty() { writeln!(buf, "{} - Items:", indent).unwrap(); for item in &self.items { item.write_markdown(buf, depth + 2); } } buf.push('\n'); } } impl Item { /// Formate la date pour satisfaire les clients stricts (YYYY-MM-DD). Si seule /// l'année est fournie, on complète avec "-01-01". fn normalized_date(&self) -> Option { self.date.as_ref().map(|d| { let trimmed = d.trim(); if trimmed.len() == 4 && trimmed.chars().all(|c| c.is_ascii_digit()) { format!("{}-01-01", trimmed) } else { trimmed.to_string() } }) } /// Itère sur les ressources audio uniquement pub fn audio_resources(&self) -> impl Iterator { self.resources .iter() .filter(|r| r.protocol_info.contains("audio/")) } /// Retourne la ressource principale (première disponible) pub fn primary_resource(&self) -> Option<&Resource> { self.resources.first() } /// Itère sur les métadonnées sous forme de paires clé-valeur pub fn metadata(&self) -> impl Iterator { let mut pairs = Vec::new(); pairs.push(("title", self.title.as_str())); if let Some(ref artist) = self.artist { pairs.push(("artist", artist.as_str())); } if let Some(ref album) = self.album { pairs.push(("album", album.as_str())); } if let Some(ref genre) = self.genre { pairs.push(("genre", genre.as_str())); } if let Some(ref date) = self.date { pairs.push(("date", date.as_str())); } if let Some(ref track) = self.original_track_number { pairs.push(("trackNumber", track.as_str())); } for desc in &self.descriptions { if let Some(ref gain) = desc.track_gain { pairs.push(("replayGain", gain.as_str())); } if let Some(ref peak) = desc.track_peak { pairs.push(("replayPeak", peak.as_str())); } } pairs.into_iter() } fn write_markdown(&self, buf: &mut String, depth: usize) { let indent = " ".repeat(depth); writeln!(buf, "{}- **Item**: {}", indent, self.title).unwrap(); writeln!(buf, "{} - ID: `{}`", indent, self.id).unwrap(); writeln!(buf, "{} - ParentID: `{}`", indent, self.parent_id).unwrap(); writeln!(buf, "{} - Class: `{}`", indent, self.class).unwrap(); if let Some(ref creator) = self.creator { writeln!(buf, "{} - Creator: {}", indent, creator).unwrap(); } if let Some(ref artist) = self.artist { writeln!(buf, "{} - Artist: {}", indent, artist).unwrap(); } if let Some(ref album) = self.album { writeln!(buf, "{} - Album: {}", indent, album).unwrap(); } if let Some(ref genre) = self.genre { writeln!(buf, "{} - Genre: {}", indent, genre).unwrap(); } if let Some(ref art) = self.album_art { writeln!(buf, "{} - Album Art: ![Cover]({})", indent, art).unwrap(); } if let Some(ref date) = self.date { writeln!(buf, "{} - Date: {}", indent, date).unwrap(); } if let Some(ref track) = self.original_track_number { writeln!(buf, "{} - Track: {}", indent, track).unwrap(); } if !self.resources.is_empty() { writeln!(buf, "{} - Resources:", indent).unwrap(); for res in &self.resources { writeln!(buf, "{} - URL: {}", indent, res.url).unwrap(); writeln!(buf, "{} - Protocol: `{}`", indent, res.protocol_info).unwrap(); if let Some(ref dur) = res.duration { writeln!(buf, "{} - Duration: `{}`", indent, dur).unwrap(); } if let Some(ref bits) = res.bits_per_sample { writeln!(buf, "{} - BitsPerSample: `{}`", indent, bits).unwrap(); } if let Some(ref freq) = res.sample_frequency { writeln!(buf, "{} - SampleFrequency: `{}`", indent, freq).unwrap(); } if let Some(ref channels) = res.nr_audio_channels { writeln!(buf, "{} - Channels: `{}`", indent, channels).unwrap(); } } } if !self.descriptions.is_empty() { writeln!(buf, "{} - Descriptions:", indent).unwrap(); for desc in &self.descriptions { if let Some(ref ns) = desc.namespace { writeln!(buf, "{} - Namespace: `{}`", indent, ns).unwrap(); } if let Some(ref gain) = desc.track_gain { writeln!(buf, "{} - Track Gain: `{}`", indent, gain).unwrap(); } if let Some(ref peak) = desc.track_peak { writeln!(buf, "{} - Track Peak: `{}`", indent, peak).unwrap(); } } } buf.push('\n'); } } // ============= Implémentation ToXmlElement ============= fn text_element(name: &str, value: &str) -> Element { let mut e = Element::new(name); e.children.push(XMLNode::Text(value.to_string())); e } const SINGLETON_ELEMENTS: &[&str] = &[ "dc:title", "title", "dc:creator", "creator", "upnp:class", "class", "upnp:artist", "artist", "upnp:album", "album", "upnp:genre", "genre", "upnp:albumArtURI", "albumArtURI", "dc:date", "date", "upnp:originalTrackNumber", "originalTrackNumber", ]; fn sanitize_singleton_elements(input: &str) -> Cow<'_, str> { if !SINGLETON_ELEMENTS.iter().any(|tag| input.contains(tag)) { return Cow::Borrowed(input); } let mut cursor = Cursor::new(input.as_bytes()); let mut root = match Element::parse(&mut cursor) { Ok(elem) => elem, Err(_) => return Cow::Borrowed(input), }; if !dedup_singleton_children(&mut root) { return Cow::Borrowed(input); } let mut buf = Vec::new(); if root.write(&mut buf).is_err() { return Cow::Borrowed(input); } String::from_utf8(buf) .map(Cow::Owned) .unwrap_or_else(|_| Cow::Borrowed(input)) } fn dedup_singleton_children(element: &mut Element) -> bool { let mut changed = false; let mut seen: HashSet = HashSet::new(); let mut idx = 0; while idx < element.children.len() { let mut remove_current = false; if let XMLNode::Element(child_elem) = &mut element.children[idx] { if SINGLETON_ELEMENTS.contains(&child_elem.name.as_str()) && !seen.insert(child_elem.name.clone()) { remove_current = true; changed = true; } else if dedup_singleton_children(child_elem) { changed = true; } } if remove_current { element.children.remove(idx); } else { idx += 1; } } changed } impl ToXmlElement for DIDLLite { fn to_xml_element(&self) -> Element { let mut root = Element::new("DIDL-Lite"); self.set_namespaces(&mut root); for c in &self.containers { root.children.push(XMLNode::Element(c.to_xml_element())); } for i in &self.items { root.children.push(XMLNode::Element(i.to_xml_element())); } root } } impl ToXmlElement for Container { fn to_xml_element(&self) -> Element { let mut elem = Element::new("container"); elem.attributes.insert("id".into(), self.id.clone()); elem.attributes .insert("parentID".into(), self.parent_id.clone()); if let Some(ref r) = self.restricted { elem.attributes.insert("restricted".into(), r.clone()); } if let Some(ref cc) = self.child_count { elem.attributes.insert("childCount".into(), cc.clone()); } if let Some(ref searchable) = self.searchable { elem.attributes .insert("searchable".into(), searchable.clone()); } elem.children .push(XMLNode::Element(text_element("dc:title", &self.title))); elem.children .push(XMLNode::Element(text_element("upnp:class", &self.class))); for c in &self.containers { elem.children.push(XMLNode::Element(c.to_xml_element())); } for i in &self.items { elem.children.push(XMLNode::Element(i.to_xml_element())); } elem } } impl ToXmlElement for Item { fn to_xml_element(&self) -> Element { let mut elem = Element::new("item"); elem.attributes.insert("id".into(), self.id.clone()); elem.attributes .insert("parentID".into(), self.parent_id.clone()); if let Some(ref r) = self.restricted { elem.attributes.insert("restricted".into(), r.clone()); } elem.children .push(XMLNode::Element(text_element("dc:title", &self.title))); if let Some(ref c) = self.creator { elem.children .push(XMLNode::Element(text_element("dc:creator", c))); } elem.children .push(XMLNode::Element(text_element("upnp:class", &self.class))); if let Some(ref artist) = self.artist { elem.children .push(XMLNode::Element(text_element("upnp:artist", artist))); } if let Some(ref album) = self.album { elem.children .push(XMLNode::Element(text_element("upnp:album", album))); } if let Some(ref genre) = self.genre { elem.children .push(XMLNode::Element(text_element("upnp:genre", genre))); } if let Some(ref art) = self.album_art { elem.children .push(XMLNode::Element(text_element("upnp:albumArtURI", art))); } if let Some(date) = self.normalized_date() { elem.children .push(XMLNode::Element(text_element("dc:date", &date))); } if let Some(ref track) = self.original_track_number { elem.children.push(XMLNode::Element(text_element( "upnp:originalTrackNumber", track, ))); } for res in &self.resources { elem.children.push(XMLNode::Element(res.to_xml_element())); } for desc in &self.descriptions { elem.children.push(XMLNode::Element(desc.to_xml_element())); } elem } } impl ToXmlElement for Resource { fn to_xml_element(&self) -> Element { let mut elem = Element::new("res"); elem.attributes .insert("protocolInfo".into(), self.protocol_info.clone()); if let Some(ref bps) = self.bits_per_sample { elem.attributes.insert("bitsPerSample".into(), bps.clone()); } if let Some(ref freq) = self.sample_frequency { elem.attributes .insert("sampleFrequency".into(), freq.clone()); } if let Some(ref ch) = self.nr_audio_channels { elem.attributes.insert("nrAudioChannels".into(), ch.clone()); } if let Some(ref dur) = self.duration { elem.attributes.insert("duration".into(), dur.clone()); } elem.children.push(XMLNode::Text(self.url.clone())); elem } } impl ToXmlElement for Description { fn to_xml_element(&self) -> Element { let mut elem = Element::new("desc"); if let Some(ref id) = self.id { elem.attributes.insert("id".into(), id.clone()); } if let Some(ref ns) = self.namespace { elem.attributes.insert("nameSpace".into(), ns.clone()); } if let Some(ref gain) = self.track_gain { elem.children .push(XMLNode::Element(text_element("track_gain", gain))); } if let Some(ref peak) = self.track_peak { elem.children .push(XMLNode::Element(text_element("track_peak", peak))); } elem } } // ============= Itérateurs personnalisés ============= struct AllContainersIter<'a> { stack: Vec<&'a Container>, } impl<'a> AllContainersIter<'a> { fn new(containers: &'a [Container]) -> Self { Self { stack: containers.iter().collect(), } } } impl<'a> Iterator for AllContainersIter<'a> { type Item = &'a Container; fn next(&mut self) -> Option { self.stack.pop().map(|container| { // Ajouter les enfants à la pile self.stack.extend(container.containers.iter()); container }) } } struct AllItemsIter<'a> { containers: Vec<&'a Container>, current_items: std::slice::Iter<'a, Item>, } impl<'a> AllItemsIter<'a> { fn new(containers: &'a [Container], items: &'a [Item]) -> Self { Self { containers: containers.iter().collect(), current_items: items.iter(), } } } impl<'a> Iterator for AllItemsIter<'a> { type Item = &'a Item; fn next(&mut self) -> Option { loop { if let Some(item) = self.current_items.next() { return Some(item); } let container = self.containers.pop()?; self.containers.extend(container.containers.iter()); self.current_items = container.items.iter(); } } } #[cfg(test)] mod tests { use super::*; #[test] fn test_parse_simple_didl() { let xml = r#" Test Song object.item.audioItem.musicTrack http://example.com/song.mp3 "#; let didl = DIDLLite::parse(xml).unwrap(); assert_eq!(didl.items.len(), 1); assert_eq!(didl.items[0].title, "Test Song"); } #[test] fn test_parse_without_namespaces() { // Teste un XML sans namespaces explicites (devices UPnP laxistes) let xml = r#" Test Song object.item.audioItem.musicTrack http://example.com/song.mp3 "#; let didl = DIDLLite::parse(xml).unwrap(); assert_eq!(didl.items.len(), 1); assert_eq!(didl.items[0].title, "Test Song"); } #[test] fn test_generic_parser() { let xml = r#" "#; // Utiliser le parser générique let metadata: DidlMetadata = parse_metadata(xml).unwrap(); assert_eq!(metadata.format, "DIDL-Lite"); assert!(metadata.parsed_at.is_some()); } #[test] fn test_metadata_map() { let xml = r#" "#; let metadata: DidlMetadata = parse_metadata(xml).unwrap(); // Transformer les données let item_count = metadata.map(|didl| didl.items.len()); assert_eq!(item_count.format, "DIDL-Lite"); assert_eq!(item_count.data, 0); } } ========= End of pmodidl/src/lib.rs ===========