diff --git a/pmocontrol/examples/full_control_point_demo.rs b/pmocontrol/examples/full_control_point_demo.rs index 6b6eeccd..cb91ca13 100644 --- a/pmocontrol/examples/full_control_point_demo.rs +++ b/pmocontrol/examples/full_control_point_demo.rs @@ -356,6 +356,13 @@ fn playback_item_from_entry(server: &MusicServer, entry: &MediaEntry) -> Option< item.title = Some(entry.title.clone()); item.server_id = Some(server.id().clone()); item.object_id = Some(entry.id.clone()); + item.artist = entry.artist.clone(); + item.album = entry.album.clone(); + item.genre = entry.genre.clone(); + item.album_art_uri = entry.album_art_uri.clone(); + item.date = entry.date.clone(); + item.track_number = entry.track_number.clone(); + item.creator = entry.creator.clone(); Some(item) } @@ -431,6 +438,34 @@ fn spawn_renderer_event_thread( io::stdout().flush().ok(); } } + RendererEvent::MetadataChanged { id, metadata } => { + if id == renderer_id { + println!("\n[EVENT] Metadata changed:"); + if let Some(title) = &metadata.title { + println!(" Title: {}", title); + } + if let Some(artist) = &metadata.artist { + println!(" Artist: {}", artist); + } + if let Some(album) = &metadata.album { + println!(" Album: {}", album); + } + if let Some(genre) = &metadata.genre { + println!(" Genre: {}", genre); + } + if let Some(date) = &metadata.date { + println!(" Date: {}", date); + } + if let Some(track_number) = &metadata.track_number { + println!(" Track: {}", track_number); + } + if let Some(album_art_uri) = &metadata.album_art_uri { + println!(" Album Art: {}", album_art_uri); + } + print!("> "); + io::stdout().flush().ok(); + } + } } } Err(_) => { @@ -722,7 +757,16 @@ fn show_queue( } else { for (idx, item) in queue.iter().enumerate() { let title = item.title.as_deref().unwrap_or(""); - println!(" [{}] {}", idx, title); + let artist = item.artist.as_deref().unwrap_or(""); + let album = item.album.as_deref().unwrap_or(""); + + if !artist.is_empty() && !album.is_empty() { + println!(" [{}] {} - {} ({})", idx, artist, title, album); + } else if !artist.is_empty() { + println!(" [{}] {} - {}", idx, artist, title); + } else { + println!(" [{}] {}", idx, title); + } } } diff --git a/pmocontrol/src/arylic_tcp.rs b/pmocontrol/src/arylic_tcp.rs index 8ff41791..caf2351b 100644 --- a/pmocontrol/src/arylic_tcp.rs +++ b/pmocontrol/src/arylic_tcp.rs @@ -301,6 +301,8 @@ impl ArylicPlaybackInfo { } else { None }, + track_metadata: None, + track_uri: None, } } } diff --git a/pmocontrol/src/avtransport_client.rs b/pmocontrol/src/avtransport_client.rs index fe949515..89e8f546 100644 --- a/pmocontrol/src/avtransport_client.rs +++ b/pmocontrol/src/avtransport_client.rs @@ -391,6 +391,8 @@ pub struct PositionInfo { pub track_duration: Option, // HH:MM:SS or None pub rel_time: Option, // HH:MM:SS or None pub abs_time: Option, // HH:MM:SS or None + pub track_metadata: Option, // DIDL-Lite XML or None + pub track_uri: Option, // Current track URI } impl AvTransportClient { @@ -442,11 +444,15 @@ fn parse_position_info(envelope: &SoapEnvelope) -> Result { let track_duration = opt(response, "TrackDuration"); let rel_time = opt(response, "RelTime"); let abs_time = opt(response, "AbsTime"); + let track_metadata = opt(response, "TrackMetaData"); + let track_uri = opt(response, "TrackURI"); Ok(PositionInfo { track, track_duration, rel_time, abs_time, + track_metadata, + track_uri, }) } diff --git a/pmocontrol/src/capabilities.rs b/pmocontrol/src/capabilities.rs index 29101670..f974ad5e 100644 --- a/pmocontrol/src/capabilities.rs +++ b/pmocontrol/src/capabilities.rs @@ -12,6 +12,8 @@ pub struct PlaybackPositionInfo { pub rel_time: Option, // position courante pub abs_time: Option, // si pertinent pub track_duration: Option, // durée totale + pub track_metadata: Option, // DIDL-Lite XML from GetPositionInfo + pub track_uri: Option, // Current track URI } pub trait PlaybackPosition { fn playback_position(&self) -> Result; diff --git a/pmocontrol/src/control_point.rs b/pmocontrol/src/control_point.rs index e371406c..25a9fb77 100644 --- a/pmocontrol/src/control_point.rs +++ b/pmocontrol/src/control_point.rs @@ -14,6 +14,7 @@ use crate::capabilities::{ PlaybackPosition, PlaybackPositionInfo, PlaybackState, PlaybackStatus, TransportControl, VolumeControl, }; +use crate::model::TrackMetadata; use crate::discovery::DiscoveryManager; use crate::events::{MediaServerEventBus, RendererEventBus}; use crate::media_server::{ @@ -171,6 +172,22 @@ impl ControlPoint { }); } + // Extract and emit metadata changes + if let Some(metadata) = extract_track_metadata(&position) { + let metadata_changed = match prev_snapshot.last_metadata.as_ref() { + Some(prev) => prev != &metadata, + None => true, + }; + + if metadata_changed { + runtime_cp.emit_renderer_event(RendererEvent::MetadataChanged { + id: renderer_id.clone(), + metadata: metadata.clone(), + }); + new_snapshot.last_metadata = Some(metadata); + } + } + new_snapshot.position = Some(position); } @@ -601,7 +618,6 @@ impl ControlPoint { let playback = (|| -> anyhow::Result<()> { renderer.play_uri(&item.uri, "")?; - renderer.play()?; Ok(()) })(); @@ -809,6 +825,7 @@ struct RendererRuntimeSnapshot { position: Option, last_volume: Option, last_mute: Option, + last_metadata: Option, } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] @@ -1109,6 +1126,13 @@ fn playback_item_from_entry(server: &MusicServer, entry: &MediaEntry) -> Option< item.title = Some(entry.title.clone()); item.server_id = Some(server.id().clone()); item.object_id = Some(entry.id.clone()); + item.artist = entry.artist.clone(); + item.album = entry.album.clone(); + item.genre = entry.genre.clone(); + item.album_art_uri = entry.album_art_uri.clone(); + item.date = entry.date.clone(); + item.track_number = entry.track_number.clone(); + item.creator = entry.creator.clone(); Some(item) } @@ -1206,4 +1230,34 @@ fn playback_position_equal(a: &PlaybackPositionInfo, b: &PlaybackPositionInfo) - && a.rel_time == b.rel_time && a.abs_time == b.abs_time && a.track_duration == b.track_duration + && a.track_metadata == b.track_metadata + && a.track_uri == b.track_uri +} + +/// Extract TrackMetadata from DIDL-Lite XML in PlaybackPositionInfo. +fn extract_track_metadata(position: &PlaybackPositionInfo) -> Option { + let didl_xml = position.track_metadata.as_ref()?; + + // Parse DIDL-Lite XML + let didl = match pmodidl::parse_metadata::(didl_xml) { + Ok(parsed) => parsed.data, + Err(err) => { + debug!(error = %err, "Failed to parse DIDL-Lite metadata from GetPositionInfo"); + return None; + } + }; + + // Extract first item metadata + let item = didl.items.first()?; + + Some(TrackMetadata { + title: Some(item.title.clone()), + artist: item.artist.clone(), + album: item.album.clone(), + genre: item.genre.clone(), + album_art_uri: item.album_art.clone(), + date: item.date.clone(), + track_number: item.original_track_number.clone(), + creator: item.creator.clone(), + }) } diff --git a/pmocontrol/src/linkplay.rs b/pmocontrol/src/linkplay.rs index 561d30ad..01959c18 100644 --- a/pmocontrol/src/linkplay.rs +++ b/pmocontrol/src/linkplay.rs @@ -227,6 +227,8 @@ impl LinkPlayStatus { } else { None }, + track_metadata: None, + track_uri: None, } } } diff --git a/pmocontrol/src/media_server.rs b/pmocontrol/src/media_server.rs index a6494371..ea1b338b 100644 --- a/pmocontrol/src/media_server.rs +++ b/pmocontrol/src/media_server.rs @@ -47,6 +47,13 @@ pub struct MediaEntry { pub is_container: bool, pub class: String, pub resources: Vec, + pub artist: Option, + pub album: Option, + pub genre: Option, + pub album_art_uri: Option, + pub date: Option, + pub track_number: Option, + pub creator: Option, } /// Backend-agnostic media browsing contract. @@ -323,6 +330,13 @@ fn map_didl_entries(xml: &str) -> Result> { is_container: true, class: container.class, resources: Vec::new(), + artist: None, + album: None, + genre: None, + album_art_uri: None, + date: None, + track_number: None, + creator: None, }); } @@ -344,6 +358,13 @@ fn map_didl_entries(xml: &str) -> Result> { is_container: false, class: item.class, resources, + artist: item.artist, + album: item.album, + genre: item.genre, + album_art_uri: item.album_art, + date: item.date, + track_number: item.original_track_number, + creator: item.creator, }); } diff --git a/pmocontrol/src/model.rs b/pmocontrol/src/model.rs index 95de1e0b..98b362bd 100644 --- a/pmocontrol/src/model.rs +++ b/pmocontrol/src/model.rs @@ -4,6 +4,18 @@ use crate::media_server::ServerId; #[derive(Clone, Debug, PartialEq, Eq, Hash)] pub struct RendererId(pub String); +#[derive(Clone, Debug, PartialEq)] +pub struct TrackMetadata { + pub title: Option, + pub artist: Option, + pub album: Option, + pub genre: Option, + pub album_art_uri: Option, + pub date: Option, + pub track_number: Option, + pub creator: Option, +} + #[derive(Clone, Debug)] pub enum RendererProtocol { UpnpAvOnly, @@ -79,6 +91,10 @@ pub enum RendererEvent { id: RendererId, mute: bool, }, + MetadataChanged { + id: RendererId, + metadata: TrackMetadata, + }, } #[derive(Clone, Debug)] diff --git a/pmocontrol/src/playback_queue.rs b/pmocontrol/src/playback_queue.rs index df1ae0b2..3062a95c 100644 --- a/pmocontrol/src/playback_queue.rs +++ b/pmocontrol/src/playback_queue.rs @@ -8,6 +8,13 @@ pub struct PlaybackItem { pub title: Option, pub server_id: Option, pub object_id: Option, + pub artist: Option, + pub album: Option, + pub genre: Option, + pub album_art_uri: Option, + pub date: Option, + pub track_number: Option, + pub creator: Option, } impl PlaybackItem { @@ -17,6 +24,13 @@ impl PlaybackItem { title: None, server_id: None, object_id: None, + artist: None, + album: None, + genre: None, + album_art_uri: None, + date: None, + track_number: None, + creator: None, } } } diff --git a/pmocontrol/src/upnp_renderer.rs b/pmocontrol/src/upnp_renderer.rs index 41d96c08..d7f4a85d 100644 --- a/pmocontrol/src/upnp_renderer.rs +++ b/pmocontrol/src/upnp_renderer.rs @@ -290,6 +290,8 @@ impl PlaybackPosition for UpnpRenderer { rel_time: raw.rel_time, abs_time: raw.abs_time, track_duration: raw.track_duration, + track_metadata: raw.track_metadata, + track_uri: raw.track_uri, }) } }