From 31ee25c7f23789556d54d4b4ad4678385fb5e989 Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Sat, 6 Dec 2025 13:37:42 +0100 Subject: [PATCH] Abstraction de la gestion des queues de lecture --- .DS_Store | Bin 14340 -> 18436 bytes pmocontrol/examples/event_demo.rs | 276 ----- .../examples/full_control_point_demo.rs | 90 +- pmocontrol/examples/live_pmomusic_demo.rs | 64 +- .../examples/pmomusic_integration_example.rs | 81 -- pmocontrol/examples/queue_pmomusic_demo.rs | 52 +- pmocontrol/src/capabilities.rs | 12 + pmocontrol/src/control_point.rs | 566 +++++++--- pmocontrol/src/control_point/music_queue.rs | 74 ++ .../src/control_point/openhome_queue.rs | 360 +++++++ pmocontrol/src/lib.rs | 5 +- pmocontrol/src/media_server.rs | 16 + pmocontrol/src/music_renderer.rs | 171 ++- pmocontrol/src/playback_queue.rs | 241 ----- pmocontrol/src/pmoserver_ext.rs | 65 +- pmocontrol/src/queue_backend.rs | 442 ++++++++ pmocontrol/src/queue_interne.rs | 113 ++ pmocontrol/src/sse.rs | 30 +- pmocontrol/src/upnp_renderer.rs | 13 +- pmodidl_026.txt | 972 ++++++++++++++++++ tools/build_package_resume | 13 + 21 files changed, 2680 insertions(+), 976 deletions(-) delete mode 100644 pmocontrol/examples/event_demo.rs delete mode 100644 pmocontrol/examples/pmomusic_integration_example.rs create mode 100644 pmocontrol/src/control_point/music_queue.rs create mode 100644 pmocontrol/src/control_point/openhome_queue.rs delete mode 100644 pmocontrol/src/playback_queue.rs create mode 100644 pmocontrol/src/queue_backend.rs create mode 100644 pmocontrol/src/queue_interne.rs create mode 100644 pmodidl_026.txt create mode 100755 tools/build_package_resume diff --git a/.DS_Store b/.DS_Store index 8a655fcc4113a688d3ba6a76de261bf3bcade2fa..941815d89bb3fe7d326477d0cdbab7be5591e11c 100644 GIT binary patch literal 18436 zcmeHOYit}>6~1Tf*t6@zo+M7Y4RLA_3T^5*_WBi*Quz_5b)Hu2)J|Hu-N(e4dcEuJ z?Al4ALQsJp&{jyi1QHSA@q-@(71Rn9gg^qR0@NQUAc{o)Ab|v^;voc;K;=94&N_2v zXO<16G_`hCvv(f%oO|!N=bU@axpRaNG*Qm>3o#~yFmQ5dZWf|BCPX*NKAe?<-}6~@ zG~o&1d=FP3!=0-Tg>MQ z`9;)WS>J@S)vbF@uKYdiG!zs3waR{Z}yoh_X|lX5avK5OR}=T~jVPUkGM zbu0Bm4O#p4?pT+($;`U9@45rGE#^`8_O88MU1r35&QO(m_jkq2CNpJ3CG5an+>(1U zav&Bnn@uOG-re&)+={Jq%)K9<04BqH!7z+HV|x`{v_VDpjP<$XGQ@477i+@(;vq1> zig;2yCB7i8h-bwM;``!7aZS7|ek=YU{v=)}gW9Q+cGJ7*PP&_VXdeyJ0eUYTq5M%kc1 zg8~f-xD;Sz+l`aUF}J*|N~wqO$L0gSXA?pmxP}^)&$``m%q=ggQi1|Tf}&KQ{GC#EZ9?7{PR^&x1b_F2gWNN3#z>e-jR^2)v!&IKG-%7YzZxAaiY{)6yJaz zdf3xq2vmB_as3qWLd8${h3luZwzjslzJC_Z$ZRQ9DmxRY;)8rQS19DtmFrac0k|kh zvvabLFQx3fRg?gB)C(RbzP^#3?nGkriJtCD zqlvzr?va6^C!UBJJNE29cq+M+Th14rQd2~(niE^p{rdmYxC$AcZ{7?sBaVxcILpGp zyk)AXM(rM|RXo7uN_|+BIath#KDcpE`y~%tr8g^*GkA_{v}fs1Y@2IbvE7|nAC$Dt z`@^hr=B?)4p0;aa39-+)FB+EXBVd2_B1X+4@OcK@wnncIs13(uE7Hqfr%DxL!vdRMcsK1)dB2*g+LU5zbG<6y!D?THq5MCr{6j?d^1wf=4DViFIM#V zAo!*ltqgZQ%FwGeX4>)9haaldzg0YjS+L}pbYZV+jHFx@IF1ykJGgaC@}lAtaIwzd z)uk8zS-pMsVYT+RiCHlvCdFw$PK!Bl1{z}$TJD56jQjh7aL6&g>d_ioSU+LBOrd?U z$18Z!i}QRy>t4v)P{#&U7eOftEoa1e^gfTDg(pdgwFvz%!?F*O)9lPuAT!SGTadTG<)gD;_V?nNLc z@Jdf!Xr2@9aTzq_nc=?kjI0K&KlB05Y__02+a!yC@ab%(i&ob_-Ab>pviWf{r)V_k zSg)T1+zMujXPkK@U}a>AXwXp)bg(qCba|<;2@nPRs^yTiqIcA{LIP&M^)ukQag^iY z7-V4wy?j#)fAj^8-2oC(4rImnBpU95ph zY01qiz`#+q5kLmT*+6NHV}oE1guwb^V?nT^!LU^^v1-@^`W@J38DG5Wp;{1ZNL&$A z)HPoaY>2%=7_X)^gJ47Ah&)~mwiPi?{`G>(g8AP&qw8j8C8NLp40*kEz)R^^~!~1dYsRE#HncYE-c1 z?H9v>R~**<{bB&u`{epHEC%s@1hocHV+g-KTm@PH^ify?A-^Ah5scpU3=Qn>rKfUmNAdA$<7o{_WxvhrmI+r8NL zg(c+k^4S8a2P*4ia%n_Xcm{Wf2^bhB#k@4^kHc`8L*s{Va+#E7{Q|mJ=iMj9996L$vAFVc4`?7C9Y;jYtjV7R6ZjU&8ErnT{P# zW_TM0<;5VTtzy5sfQ8%a)i{G4u5-ZA3v3+5f>#I87s|6FYiQv zC+!;{DST5G1P&u$Mp zRxG+qL8h+4;B$G|1rk?0(!lL8by&>=IX&#qdQHa7vc4lJU&9e*1*16tO@M=YrPGe- ztBqze#&H4j#&WHs+>ui43mY(Y-zaoAf0zSYsF}&_;E;2&4Gst<(K8s!!4#0;1+Qxs zL(Q`6A;K@gFj9AUsn(&vuxc)2d?7SvM?|B!AG&5pK~mn!vu(0%87#t*7vg}*5Q7c{ z^^ciQ+W^c<0rJ5;)!f!?;PeyVbk^<1@Jr&H#tJZt6<`7DK!^lrILs;>CjdPcCP6aP z;GLg#@N*vAufh!+c*^5Ckh_^ynuFdXdwpEzQl^cM+m3@WOTc^%ZGlhq`cmgz@yJ*U zxTUhWlGN)m?&fDab$5B|ViddyqvVW36nw>_aX?v~nb1%iQ$^!=13aDs3~Pu2!|MHz zGx@5gA9sM}Gobo3LIl&|EGWg-9EeNeIR_vn5I{JL-xThWczaU5Ifj~N_Ot87Jgnth5^omC@K77)n_mJgKqf`(2!2$g|+U0TCQYKkzROXyFllW6ruAxE~ zW!x(YFcm8w3~}Ixd4{>9N<%Ycw03u*T{TKIaerR|pjkFT}s& zMME>kYCvw7$*eI(pFmUuCX9MmC^YI-)!#5Qs~VY)Qk7jy^<^#_NxrWPq~Y&1kzy{7 zYTxqeiiQ_r%~fiA|Es%LwdgSfGbF%(m+t3PkJ(oX$8azX)*70tR|`kTevzEpdixQw zZ^R3cX7bkRN67w>G{<+>MUVcleWa@WobY~xe+lE?*QtdgWM4_r!|Phq!Z8p$x9rMj z#$_!WgCRH&vZxzJ7@smd>K#iBKCK%^2%j=N>g08fJHJtx3*W1(U*-~sJqPy#G_6yg zCS(LXK!4nTyd)5F5A4Sc@M%KE&I9@(Wv)K{;3#}xKW>266WA#T#KG&D#$(=9*CmyJf$-K`lKO<=cXWMDn`T>V{RJ;CRIk@@U~%C}8! zNK#+_nNO(Qqnj(W-F*(dHqWLOX7JVa(Dd!??E1!qY`sa9@6^;>+1c}E>&53z$L4Q} zY`v?SckH~K)AUB}89#jF#5K;{i*1W_Rz{}y=}h433|mS=e@}Uy=!KB#};Je z#TUwH`?0cJx@hh}GM&V=E5195)H*E-PQHonioeq^yg7gh;EwovqDa1z34!X#-dIl* zDR(j&jHy0+{_m3mzb>8^-xEI+FNv4Muf(s#E8;iekK%9Q@8TcgHHuIZ zZK4+1PP?cJ>4JM{fCiB&I3m*o57Yff6g)xmND@3t4^oOAr6pRXyi52vgX`Wk(czD3W`^YjDyG5v&oO0UrG=+E>Q`YZjD{zb1xL}YuUGtw2=8@XFK;t^6M zg3Q_x8B7)b_aA6$P29XUEvjo{Z78S0^Z#$&0W^Bnpg@BHZ#r3JbA&Xc=zMvdNXeM2$y`|*=1EqP~0of|63xUG!VH5BHUN) f3-irxz@KUO|9Q_A8h;Di@c*xGDKb6YQvd(o%|XLr delta 342 zcmZpfz}Qlt!oa}5=v10w$iQF#WO4v8h!)@h5^W3&3Lu(cqKdX?D3E3bN-!|wGo&%( zFeEZ0d*)0mRGw_$!otc3lw_Iw#7d8ios*rLJ$ADohZxJo23=-OkPHxTaD#<5tMNQ# zW>niOWc`bIvkl91rp-J80m~^%{&URjFZ1A z+D-0J@?vD2?4TsE*<5)e##R4P%0+Z8udM7V55#5}tp^RefWNw`+00HGnlK=n! diff --git a/pmocontrol/examples/event_demo.rs b/pmocontrol/examples/event_demo.rs deleted file mode 100644 index 471577a7..00000000 --- a/pmocontrol/examples/event_demo.rs +++ /dev/null @@ -1,276 +0,0 @@ -// examples/events_demo.rs -// -// Demo temps réel des RendererEvent émis par le runtime de ControlPoint : -// - SSDP discovery via `ControlPoint` -// - sélection d'un renderer (facultatif) -// - abonnement à `subscribe_events()` -// - affichage continu des événements avec horodatage HH:MM:SS -// -// Build et run (depuis la racine du crate pmocontrol) : -// cargo run --example events_demo -- # écoute tous les renderers -// cargo run --example events_demo -- 0 # filtre sur renderer index 0 -// cargo run --example events_demo -- 1 # filtre sur renderer index 1, etc. -// -// Ctrl-C pour quitter. - -use std::env; -use std::io; -use std::thread; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; - -use pmocontrol::model::TrackMetadata; -use pmocontrol::openhome_renderer::{format_seconds, map_openhome_state}; -use pmocontrol::{ - ControlPoint, DeviceRegistryRead, PlaybackPositionInfo, PlaybackState, RendererEvent, - RendererId, RendererInfo, -}; - -fn main() -> io::Result<()> { - // Logging simple (tracing_subscriber est déjà utilisé dans les autres exemples) - let _ = tracing_subscriber::fmt::try_init(); - println!("Starting PMOMusic renderer events demo..."); - - // 1. Lance le ControlPoint (timeout HTTP pour les descriptions UPnP) - let cp = ControlPoint::spawn(5)?; - - // 2. Laisse la découverte tourner un peu avant de lister les renderers - println!("Waiting 5 seconds for SSDP discovery..."); - thread::sleep(Duration::from_secs(5)); - - let registry = cp.registry(); - let renderers: Vec = { - let reg = registry.read().unwrap(); - reg.list_renderers() - }; - - if renderers.is_empty() { - println!("No renderers discovered. Make sure your devices are on and reachable."); - return Ok(()); - } - - println!("\nDiscovered renderers:"); - for (idx, info) in renderers.iter().enumerate() { - println!( - " [{}] {} | model={} | udn={} | location={} | online={}", - idx, info.friendly_name, info.model_name, info.udn, info.location, info.online - ); - print_openhome_summary(" ", info, &cp); - } - - // 3. Optionnel : sélection d'un renderer par index (filtrage des événements) - let args: Vec = env::args().collect(); - let selected_id: Option = if args.len() >= 2 { - match args[1].parse::() { - Ok(idx) if idx < renderers.len() => { - let info = &renderers[idx]; - println!( - "\nFiltering events on renderer [{}] {} (id={})", - idx, info.friendly_name, info.id.0 - ); - Some(info.id.clone()) - } - Ok(idx) => { - eprintln!( - "\nRenderer index {} is out of range (0..{}), listening to all renderers.", - idx, - renderers.len().saturating_sub(1) - ); - None - } - Err(e) => { - eprintln!( - "\nArgument '{}' is not a valid index (error: {}), listening to all renderers.", - args[1], e - ); - None - } - } - } else { - println!("\nNo renderer index provided, listening to events from all renderers."); - None - }; - - // 4. Abonnement aux événements du runtime - let rx = cp.subscribe_events(); - - println!("\nSubscribed to renderer events."); - println!("Press Ctrl-C to quit.\n"); - - // 5. Boucle bloquante sur les événements - loop { - match rx.recv() { - Ok(event) => { - if let Some(ref id) = selected_id { - // Filtre : on ignore les événements des autres renderers - if !event_matches_id(&event, id) { - continue; - } - } - - print_event(&event); - } - Err(err) => { - eprintln!("Event channel closed: {}. Exiting.", err); - break; - } - } - } - - Ok(()) -} - -/// Vérifie si un événement concerne un RendererId donné. -fn event_matches_id(event: &RendererEvent, id: &RendererId) -> bool { - match event { - RendererEvent::StateChanged { id: eid, .. } => eid == id, - RendererEvent::PositionChanged { id: eid, .. } => eid == id, - RendererEvent::VolumeChanged { id: eid, .. } => eid == id, - RendererEvent::MuteChanged { id: eid, .. } => eid == id, - RendererEvent::MetadataChanged { id: eid, .. } => eid == id, - RendererEvent::QueueUpdated { id: eid, .. } => eid == id, - RendererEvent::BindingChanged { id: eid, .. } => eid == id, - } -} - -/// Format HH:MM:SS basé sur l'heure système (UTC mod 24h). -fn now_hms() -> String { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_else(|_| Duration::from_secs(0)); - let total = now.as_secs() % 86_400; - let h = total / 3600; - let m = (total % 3600) / 60; - let s = total % 60; - format!("{:02}:{:02}:{:02}", h, m, s) -} - -/// Affichage lisible d'un PlaybackState. -fn format_playback_state(state: &PlaybackState) -> String { - match state { - PlaybackState::Stopped => "Stopped".to_string(), - PlaybackState::Playing => "Playing".to_string(), - PlaybackState::Paused => "Paused".to_string(), - PlaybackState::Transitioning => "Transitioning".to_string(), - PlaybackState::NoMedia => "NoMedia".to_string(), - PlaybackState::Unknown(s) => format!("Unknown({})", s), - } -} - -/// Affichage lisible d'un PlaybackPositionInfo. -fn format_position(pos: &PlaybackPositionInfo) -> String { - let track = pos - .track - .map(|t| t.to_string()) - .unwrap_or_else(|| "-".to_string()); - let rel = pos.rel_time.as_deref().unwrap_or("-").to_string(); - let dur = pos.track_duration.as_deref().unwrap_or("-").to_string(); - - format!("track={} rel_time={} duration={}", track, rel, dur) -} - -fn print_openhome_summary(prefix: &str, info: &RendererInfo, cp: &ControlPoint) { - if !info.capabilities.has_oh_playlist - && !info.capabilities.has_oh_info - && !info.capabilities.has_oh_time - { - return; - } - - let registry = cp.registry(); - let reg = registry.read().unwrap(); - let playlist_client = reg.oh_playlist_client_for_renderer(&info.id); - let info_client = reg.oh_info_client_for_renderer(&info.id); - let time_client = reg.oh_time_client_for_renderer(&info.id); - drop(reg); - - if playlist_client.is_none() && info_client.is_none() && time_client.is_none() { - return; - } - - println!("{prefix}OpenHome:"); - - if let Some(client) = playlist_client { - match client.id_array() { - Ok(ids) => println!("{prefix} Playlist tracks : {}", ids.len()), - Err(err) => println!("{prefix} Playlist tracks : "), - } - } - - if let Some(client) = info_client { - match client.transport_state() { - Ok(state) => { - let logical = map_openhome_state(&state); - println!("{prefix} Transport state : {} ({:?})", state, logical); - } - Err(err) => println!("{prefix} Transport state : "), - } - } - - if let Some(client) = time_client { - match client.position() { - Ok(pos) => println!( - "{prefix} Position : {}/{} (tracks={})", - format_seconds(pos.elapsed_secs), - format_seconds(pos.duration_secs), - pos.track_count - ), - Err(err) => println!("{prefix} Position : "), - } - } -} - -/// Affiche un RendererEvent avec horodatage. -fn print_event(event: &RendererEvent) { - let ts = now_hms(); - match event { - RendererEvent::StateChanged { id, state } => { - println!( - "[{}] [{}] StateChanged: {}", - ts, - id.0, - format_playback_state(state) - ); - } - RendererEvent::PositionChanged { id, position } => { - println!( - "[{}] [{}] PositionChanged: {}", - ts, - id.0, - format_position(position) - ); - } - RendererEvent::VolumeChanged { id, volume } => { - println!("[{}] [{}] VolumeChanged: {}", ts, id.0, volume); - } - RendererEvent::MuteChanged { id, mute } => { - println!("[{}] [{}] MuteChanged: {}", ts, id.0, mute); - } - RendererEvent::MetadataChanged { id, metadata } => { - println!( - "[{}] [{}] MetadataChanged: {}", - ts, - id.0, - format_metadata(metadata) - ); - } - RendererEvent::QueueUpdated { id, queue_length } => { - println!( - "[{}] [{}] QueueUpdated: queue_length={}", - ts, id.0, queue_length - ); - } - } -} - -fn format_metadata(meta: &TrackMetadata) -> String { - let title = meta.title.as_deref().unwrap_or(""); - let artist = meta.artist.as_deref().unwrap_or(""); - let album = meta.album.as_deref().unwrap_or(""); - if !artist.is_empty() && !album.is_empty() { - format!("{} - {} ({})", artist, title, album) - } else if !artist.is_empty() { - format!("{} - {}", artist, title) - } else { - title.to_string() - } -} diff --git a/pmocontrol/examples/full_control_point_demo.rs b/pmocontrol/examples/full_control_point_demo.rs index ed4182cb..be1c81ff 100644 --- a/pmocontrol/examples/full_control_point_demo.rs +++ b/pmocontrol/examples/full_control_point_demo.rs @@ -19,9 +19,9 @@ use crossterm::terminal::{ }; use pmocontrol::model::TrackMetadata; use pmocontrol::{ - ControlPoint, DeviceRegistryRead, MediaBrowser, MediaEntry, MediaResource, MediaServerEvent, - MediaServerInfo, MusicServer, PlaybackItem, PlaybackPosition, PlaybackPositionInfo, - PlaybackStatus, RendererEvent, RendererInfo, TransportControl, VolumeControl, + ControlPoint, DeviceRegistryRead, MediaBrowser, MediaEntry, MediaServerEvent, MediaServerInfo, + MusicServer, PlaybackItem, PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, + RendererEvent, RendererInfo, TransportControl, VolumeControl, }; use ratatui::Terminal; use ratatui::backend::CrosstermBackend; @@ -432,8 +432,11 @@ impl App { lines.push(Line::from(" ")); } else { for (idx, item) in self.queue_snapshot.iter().enumerate() { - let title = item.title.as_deref().unwrap_or(""); - let artist = item.artist.as_deref().unwrap_or(""); + let meta = item.metadata.as_ref(); + let title = meta + .and_then(|m| m.title.as_deref()) + .unwrap_or(""); + let artist = meta.and_then(|m| m.artist.as_deref()).unwrap_or(""); let prefix = match self.queue_current_index { Some(current) if current == idx => "▶", _ => " ", @@ -924,8 +927,9 @@ impl App { self.ui_state.set_status(format_track_status(&meta)); } else { let label = item - .title - .as_deref() + .metadata + .as_ref() + .and_then(|meta| meta.title.as_deref()) .map(|t| t.to_string()) .unwrap_or_else(|| item.uri.clone()); self.ui_state.metadata = None; @@ -1047,6 +1051,10 @@ impl App { .set_status(format!("File mise à jour ({queue_length})")); } } + RendererEvent::BindingChanged { .. } => { + // Binding events are surfaced via the REST API; the demo UI does not + // expose binding info yet, so we ignore them. + } } } @@ -1250,59 +1258,27 @@ fn collect_playable_items( /// Convert MediaEntry to PlaybackItem. fn playback_item_from_entry(server: &MusicServer, entry: &MediaEntry) -> Option { - let resource = entry.resources.iter().find(|res| is_audio_resource(res))?; - let mut item = PlaybackItem::new(resource.uri.clone()); - 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) + let resource = entry.resources.iter().find(|res| res.is_audio())?; + let metadata = TrackMetadata { + title: Some(entry.title.clone()), + artist: entry.artist.clone(), + album: entry.album.clone(), + genre: entry.genre.clone(), + album_art_uri: entry.album_art_uri.clone(), + date: entry.date.clone(), + track_number: entry.track_number.clone(), + creator: entry.creator.clone(), + }; + Some(PlaybackItem { + media_server_id: server.id().clone(), + didl_id: entry.id.clone(), + uri: resource.uri.clone(), + metadata: Some(metadata), + }) } fn playback_metadata_from_item(item: &PlaybackItem) -> Option { - let metadata = TrackMetadata { - title: item.title.clone(), - artist: item.artist.clone(), - album: item.album.clone(), - genre: item.genre.clone(), - album_art_uri: item.album_art_uri.clone(), - date: item.date.clone(), - track_number: item.track_number.clone(), - creator: item.creator.clone(), - }; - - if metadata.title.is_none() - && metadata.artist.is_none() - && metadata.album.is_none() - && metadata.genre.is_none() - && metadata.album_art_uri.is_none() - && metadata.date.is_none() - && metadata.track_number.is_none() - && metadata.creator.is_none() - { - return None; - } - - Some(metadata) -} - -/// Check if MediaResource is audio. -fn is_audio_resource(res: &MediaResource) -> bool { - let lower = res.protocol_info.to_ascii_lowercase(); - if lower.contains("audio/") { - return true; - } - lower - .split(':') - .nth(2) - .map(|mime| mime.starts_with("audio/")) - .unwrap_or(false) + item.metadata.clone() } fn render_metadata_block(metadata: Option<&TrackMetadata>) -> Vec> { diff --git a/pmocontrol/examples/live_pmomusic_demo.rs b/pmocontrol/examples/live_pmomusic_demo.rs index 7829d908..127870ae 100644 --- a/pmocontrol/examples/live_pmomusic_demo.rs +++ b/pmocontrol/examples/live_pmomusic_demo.rs @@ -8,10 +8,10 @@ use std::thread; use std::time::Duration; use anyhow::{Context, Result}; +use pmocontrol::model::TrackMetadata; use pmocontrol::{ - ControlPoint, DeviceRegistryRead, MediaBrowser, MediaEntry, MediaResource, MediaServerEvent, - MediaServerInfo, MusicRenderer, MusicServer, PlaybackItem, PlaybackPosition, - PlaybackPositionInfo, RendererInfo, RendererProtocol, + ControlPoint, DeviceRegistryRead, MediaBrowser, MediaEntry, MediaServerEvent, MediaServerInfo, + MusicRenderer, MusicServer, PlaybackItem, PlaybackPosition, PlaybackPositionInfo, RendererInfo, }; const DEFAULT_TIMEOUT_SECS: u64 = 5; @@ -217,11 +217,13 @@ fn main() -> Result<()> { " → Queue length after refresh: {} items", fresh_snapshot.len() ); - if !fresh_snapshot.is_empty() { - println!( - " → First item: {}", - fresh_snapshot[0].title.as_deref().unwrap_or("") - ); + if let Some(first) = fresh_snapshot.first() { + let label = first + .metadata + .as_ref() + .and_then(|meta| meta.title.as_deref()) + .unwrap_or(""); + println!(" → First item: {label}"); } } } @@ -535,30 +537,33 @@ fn playback_item_from_entry(server: &MusicServer, entry: &MediaEntry) -> Option< if entry.title.to_ascii_lowercase().contains("live stream") { return None; } - let resource = entry.resources.iter().find(|res| is_audio_resource(res))?; - let mut item = PlaybackItem::new(resource.uri.clone()); - item.title = Some(entry.title.clone()); - item.server_id = Some(server.id().clone()); - item.object_id = Some(entry.id.clone()); - Some(item) -} - -fn is_audio_resource(res: &MediaResource) -> bool { - let lower = res.protocol_info.to_ascii_lowercase(); - if lower.contains("audio/") { - return true; - } - lower - .split(':') - .nth(2) - .map(|mime| mime.starts_with("audio/")) - .unwrap_or(false) + let resource = entry.resources.iter().find(|res| res.is_audio())?; + let metadata = TrackMetadata { + title: Some(entry.title.clone()), + artist: entry.artist.clone(), + album: entry.album.clone(), + genre: entry.genre.clone(), + album_art_uri: entry.album_art_uri.clone(), + date: entry.date.clone(), + track_number: entry.track_number.clone(), + creator: entry.creator.clone(), + }; + Some(PlaybackItem { + media_server_id: server.id().clone(), + didl_id: entry.id.clone(), + uri: resource.uri.clone(), + metadata: Some(metadata), + }) } fn print_queue_snapshot(items: &[PlaybackItem]) { println!("Current queue snapshot ({} items):", items.len()); for (idx, item) in items.iter().take(10).enumerate() { - let label = item.title.as_deref().unwrap_or_else(|| item.uri.as_str()); + let label = item + .metadata + .as_ref() + .and_then(|meta| meta.title.as_deref()) + .unwrap_or_else(|| item.uri.as_str()); println!(" [{}] {}", idx, label); } if items.len() > 10 { @@ -572,8 +577,9 @@ fn print_queue_snapshot(items: &[PlaybackItem]) { fn current_track_title(item: Option<&PlaybackItem>) -> String { match item { Some(track) => track - .title - .as_deref() + .metadata + .as_ref() + .and_then(|meta| meta.title.as_deref()) .unwrap_or_else(|| track.uri.as_str()) .to_string(), None => "".to_string(), diff --git a/pmocontrol/examples/pmomusic_integration_example.rs b/pmocontrol/examples/pmomusic_integration_example.rs deleted file mode 100644 index abcf96c0..00000000 --- a/pmocontrol/examples/pmomusic_integration_example.rs +++ /dev/null @@ -1,81 +0,0 @@ -//! Exemple d'intégration du Control Point dans PMOMusic -//! -//! Cet exemple montre comment enregistrer le Control Point dans une application -//! PMOMusic complète, en suivant le même pattern que les autres composants. - -#[cfg(not(feature = "pmoserver"))] -fn main() { - eprintln!("This example requires the 'pmoserver' feature. Re-run with `--features pmoserver`."); -} - -#[cfg(feature = "pmoserver")] -use pmocontrol::ControlPointExt; -#[cfg(feature = "pmoserver")] -use pmoserver::Server; -#[cfg(feature = "pmoserver")] -use tracing::info; - -#[cfg(feature = "pmoserver")] -#[tokio::main] -async fn main() -> Result<(), Box> { - // Initialiser le logging - tracing_subscriber::fmt::init(); - - // ========== PHASE 1 : Infrastructure ========== - - let server = Server::create_upnp_server().await?; - - // ========== PHASE 2 : Enregistrement des composants ========== - - // Enregistrer les devices UPnP, sources musicales, etc. - // (code existant de PMOMusic...) - - // ========== Enregistrer le Control Point ========== - // - // Cette ligne unique : - // 1. Lance le runtime SSDP et la découverte des devices - // 2. Démarre le polling des renderers (état, position, volume, etc.) - // 3. S'abonne aux événements UPnP des serveurs de médias - // 4. Enregistre toutes les routes REST (/api/control/*) - // 5. Enregistre tous les endpoints SSE (/api/control/events/*) - // 6. Génère la documentation OpenAPI - - info!("🎛️ Registering Control Point..."); - let control_point = server - .write() - .await - .register_control_point(5) // timeout de 5 secondes pour les requêtes HTTP - .await?; - - // Le Control Point est maintenant actif ! - // On peut l'utiliser directement si besoin - info!("✅ Control Point ready!"); - - // Exemple : lister les renderers découverts (optionnel) - tokio::time::sleep(tokio::time::Duration::from_secs(3)).await; - let renderers = control_point.list_music_renderers(); - info!("📻 Discovered {} renderer(s)", renderers.len()); - for renderer in renderers { - info!(" - {} ({})", renderer.friendly_name, renderer.id.0); - } - - // ========== PHASE 3 : Démarrage du serveur ========== - - info!("🌐 Starting HTTP server..."); - server.write().await.start().await; - - info!("✅ PMOMusic is ready!"); - info!("📡 Control Point API available at:"); - info!(" - GET /api/control/renderers"); - info!(" - GET /api/control/servers"); - info!(" - GET /api/control/events (SSE)"); - info!(" - GET /api/control/events/renderers (SSE)"); - info!(" - GET /api/control/events/servers (SSE)"); - info!(" - Docs: /swagger-ui/control"); - info!(""); - info!("Press Ctrl+C to stop..."); - - server.write().await.wait().await; - - Ok(()) -} diff --git a/pmocontrol/examples/queue_pmomusic_demo.rs b/pmocontrol/examples/queue_pmomusic_demo.rs index a61740f5..0beb5df1 100644 --- a/pmocontrol/examples/queue_pmomusic_demo.rs +++ b/pmocontrol/examples/queue_pmomusic_demo.rs @@ -8,10 +8,10 @@ use std::thread; use std::time::Duration; use anyhow::{Context, Result}; +use pmocontrol::model::TrackMetadata; use pmocontrol::{ - ControlPoint, DeviceRegistryRead, MediaBrowser, MediaEntry, MediaResource, MediaServerEvent, - MediaServerInfo, MusicRenderer, MusicServer, PlaybackItem, PlaybackPosition, - PlaybackPositionInfo, RendererInfo, RendererProtocol, + ControlPoint, DeviceRegistryRead, MediaBrowser, MediaEntry, MediaServerEvent, MediaServerInfo, + MusicRenderer, MusicServer, PlaybackItem, PlaybackPosition, PlaybackPositionInfo, RendererInfo, }; const DEFAULT_TIMEOUT_SECS: u64 = 5; @@ -468,30 +468,33 @@ fn playback_item_from_entry(server: &MusicServer, entry: &MediaEntry) -> Option< if entry.title.to_ascii_lowercase().contains("live stream") { return None; } - let resource = entry.resources.iter().find(|res| is_audio_resource(res))?; - let mut item = PlaybackItem::new(resource.uri.clone()); - item.title = Some(entry.title.clone()); - item.server_id = Some(server.id().clone()); - item.object_id = Some(entry.id.clone()); - Some(item) -} - -fn is_audio_resource(res: &MediaResource) -> bool { - let lower = res.protocol_info.to_ascii_lowercase(); - if lower.contains("audio/") { - return true; - } - lower - .split(':') - .nth(2) - .map(|mime| mime.starts_with("audio/")) - .unwrap_or(false) + let resource = entry.resources.iter().find(|res| res.is_audio())?; + let metadata = TrackMetadata { + title: Some(entry.title.clone()), + artist: entry.artist.clone(), + album: entry.album.clone(), + genre: entry.genre.clone(), + album_art_uri: entry.album_art_uri.clone(), + date: entry.date.clone(), + track_number: entry.track_number.clone(), + creator: entry.creator.clone(), + }; + Some(PlaybackItem { + media_server_id: server.id().clone(), + didl_id: entry.id.clone(), + uri: resource.uri.clone(), + metadata: Some(metadata), + }) } fn print_queue_snapshot(items: &[PlaybackItem]) { println!("Current queue snapshot ({} items):", items.len()); for (idx, item) in items.iter().enumerate() { - let label = item.title.as_deref().unwrap_or_else(|| item.uri.as_str()); + let label = item + .metadata + .as_ref() + .and_then(|meta| meta.title.as_deref()) + .unwrap_or_else(|| item.uri.as_str()); println!(" [{}] {} -> {}", idx, label, item.uri); } if items.is_empty() { @@ -502,8 +505,9 @@ fn print_queue_snapshot(items: &[PlaybackItem]) { fn current_track_title(item: Option<&PlaybackItem>) -> String { match item { Some(track) => track - .title - .as_deref() + .metadata + .as_ref() + .and_then(|meta| meta.title.as_deref()) .unwrap_or_else(|| track.uri.as_str()) .to_string(), None => "".to_string(), diff --git a/pmocontrol/src/capabilities.rs b/pmocontrol/src/capabilities.rs index f974ad5e..e5461a35 100644 --- a/pmocontrol/src/capabilities.rs +++ b/pmocontrol/src/capabilities.rs @@ -50,6 +50,18 @@ impl PlaybackState { _ => PlaybackState::Unknown(raw.to_string()), } } + + /// Returns a human-readable label for the playback state. + pub fn as_str(&self) -> &str { + match self { + PlaybackState::Stopped => "STOPPED", + PlaybackState::Playing => "PLAYING", + PlaybackState::Paused => "PAUSED", + PlaybackState::Transitioning => "TRANSITIONING", + PlaybackState::NoMedia => "NO_MEDIA", + PlaybackState::Unknown(s) => s.as_str(), + } + } } /// Generic abstraction for playback status (transport state). diff --git a/pmocontrol/src/control_point.rs b/pmocontrol/src/control_point.rs index 98408b30..b8bf5369 100644 --- a/pmocontrol/src/control_point.rs +++ b/pmocontrol/src/control_point.rs @@ -2,18 +2,31 @@ use std::collections::{HashMap, HashSet}; use std::hash::{DefaultHasher, Hash, Hasher}; use std::io::{self, BufRead, BufReader, Read, Write}; use std::net::{IpAddr, TcpListener, TcpStream, UdpSocket}; -use std::sync::{Arc, Mutex, RwLock}; +use std::marker::PhantomData; +use std::ops::{Deref, DerefMut}; +use std::sync::{Arc, Mutex, MutexGuard, RwLock}; use std::thread; use std::time::{Duration, Instant}; use anyhow::{Context, anyhow}; use crossbeam_channel::{Receiver, Sender, unbounded}; +use quick_xml::se::to_string as to_didl_string; +use pmodidl::{DIDLLite, Item as DidlItem, Resource as DidlResource}; use pmoupnp::ssdp::SsdpClient; use thiserror::Error; use tracing::{debug, error, info, warn}; use ureq::{Agent, http}; use xmltree::{Element, XMLNode}; +pub mod music_queue; +pub mod openhome_queue; + +use crate::control_point::music_queue::MusicQueue; +use crate::control_point::openhome_queue::OpenHomeQueue; +use crate::music_renderer::{ + OpenHomeQueueProvider, RendererRuntimeState, set_openhome_queue_provider, +}; +use crate::queue_interne::InternalQueue; use crate::MusicRenderer; use crate::capabilities::{ PlaybackPosition, PlaybackPositionInfo, PlaybackState, PlaybackStatus, TransportControl, @@ -21,9 +34,7 @@ use crate::capabilities::{ }; use crate::discovery::DiscoveryManager; use crate::events::{MediaServerEventBus, RendererEventBus}; -use crate::media_server::{ - MediaBrowser, MediaEntry, MediaResource, MediaServerInfo, MusicServer, ServerId, -}; +use crate::media_server::{MediaBrowser, MediaEntry, MediaServerInfo, MusicServer, ServerId}; use crate::media_server_events::spawn_media_server_event_runtime; use crate::model::TrackMetadata; use crate::model::{MediaServerEvent, RendererEvent, RendererId, RendererInfo}; @@ -32,10 +43,10 @@ use crate::openapi::{ CurrentTrackMetadata, FullRendererSnapshot, QueueItem, QueueSnapshotView, RendererBindingView, RendererStateView, }; -use crate::openhome_client::parse_track_metadata_from_didl; +use crate::openhome_client::{OhInfoClient, OhPlaylistClient, parse_track_metadata_from_didl}; use crate::openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack}; use crate::openhome_renderer::{format_seconds, map_openhome_state}; -use crate::playback_queue::{PlaybackItem, PlaybackQueue}; +use crate::queue_backend::{EnqueueMode, PlaybackItem, QueueBackend}; use crate::provider::HttpXmlDescriptionProvider; use crate::registry::{DeviceRegistry, DeviceRegistryRead, DeviceUpdate}; use crate::upnp_renderer::UpnpRenderer; @@ -99,6 +110,9 @@ impl ControlPoint { let event_bus = RendererEventBus::new(); let media_event_bus = MediaServerEventBus::new(); let runtime = Arc::new(RuntimeState::new()); + set_openhome_queue_provider(Arc::new(RuntimeOpenHomeQueueProvider { + runtime: Arc::clone(&runtime), + })); let playlist_bindings = Arc::new(Mutex::new(HashMap::new())); // SsdpClient @@ -184,6 +198,28 @@ impl ControlPoint { let previous_backend = runtime_cp.runtime.playlist_backend(&info.id); if previous_backend != backend { runtime_cp.runtime.set_playlist_backend(&info.id, backend); + match backend { + PlaylistBackend::OpenHome => { + if let Some(queue) = build_openhome_queue(info) { + runtime_cp + .runtime + .set_music_queue(&info.id, MusicQueue::OpenHome(queue)); + } else { + runtime_cp.runtime.set_music_queue( + &info.id, + MusicQueue::Internal(InternalQueue::new()), + ); + } + } + PlaylistBackend::PMOQueue => { + runtime_cp + .runtime + .set_music_queue( + &info.id, + MusicQueue::Internal(InternalQueue::new()), + ); + } + } if matches!(backend, PlaylistBackend::OpenHome) { if let Err(err) = sync_openhome_playlist( &runtime_cp.registry, @@ -573,6 +609,13 @@ impl ControlPoint { reg.get_server(id) } + /// Clears the renderer queue while preserving the playlist binding invariant. + /// + /// Invariant reminder: every user-driven queue mutation must call + /// `detach_playlist_binding` beforehand so that any server-side playlist + /// attachment stays consistent with the local `QueueBackend` snapshot. + /// The actual structural change then goes through the backend helpers + /// (`QueueBackend::clear_queue` via `RuntimeState::with_music_queue_mut`). pub fn clear_queue(&self, renderer_id: &RendererId) -> anyhow::Result<()> { if !self.runtime.has_entry(renderer_id) { let err = Self::runtime_entry_missing(renderer_id); @@ -597,14 +640,11 @@ impl ControlPoint { return Ok(()); } - let removed = self - .runtime - .with_queue_mut(renderer_id, |queue| { - let removed = queue.upcoming_len(); - queue.clear(); - removed - }) - .ok_or_else(|| Self::runtime_entry_missing(renderer_id))?; + let removed = self.runtime.with_music_queue_mut(renderer_id, |queue| { + let removed = queue.upcoming_len()?; + queue.clear_queue()?; + Ok(removed) + })?; debug!( renderer = renderer_id.0.as_str(), @@ -622,6 +662,13 @@ impl ControlPoint { Ok(()) } + /// Appends playback items to the renderer queue and enforces the playlist + /// binding invariant for user-driven mutations. + /// + /// Each caller-triggered queue mutation must first detach any playlist binding + /// to avoid diverging from the server container, then manipulate the queue + /// strictly through the `QueueBackend` helpers (here `QueueBackend::enqueue_items` + /// inside `RuntimeState::with_music_queue_mut`). pub fn enqueue_items( &self, renderer_id: &RendererId, @@ -645,13 +692,10 @@ impl ControlPoint { } let item_count = items.len(); - let new_len = self - .runtime - .with_queue_mut(renderer_id, |queue| { - queue.enqueue_many(items); - queue.upcoming_len() - }) - .ok_or_else(|| Self::runtime_entry_missing(renderer_id))?; + let new_len = self.runtime.with_music_queue_mut(renderer_id, |queue| { + queue.enqueue_items(items, EnqueueMode::AppendToEnd)?; + queue.upcoming_len() + })?; debug!( renderer = renderer_id.0.as_str(), @@ -669,6 +713,11 @@ impl ControlPoint { Ok(()) } + /// Read-only snapshot of the upcoming queue items for a renderer. + /// + /// This helper never mutates the runtime. It simply exposes the pending + /// items as seen by the local `QueueBackend`. For a full `(items, index)` + /// view, prefer [`get_full_queue_snapshot`]. pub fn get_queue_snapshot( &self, renderer_id: &RendererId, @@ -687,6 +736,11 @@ impl ControlPoint { .ok_or_else(|| Self::runtime_entry_missing(renderer_id)) } + /// Read-only helper returning both queue items and the current index. + /// + /// This is the most detailed queue view exposed publicly and is meant + /// for UI/REST layers that need an authoritative snapshot without + /// mutating the runtime. pub fn get_full_queue_snapshot( &self, renderer_id: &RendererId, @@ -706,7 +760,10 @@ impl ControlPoint { .ok_or_else(|| Self::runtime_entry_missing(renderer_id)) } - /// Retourne les métadonnées courantes depuis le snapshot en mémoire + /// Read-only accessor to the last known metadata for the renderer. + /// + /// Useful for UI layers that want to display the currently playing + /// track even when the renderer is not returning metadata via UPnP. pub fn get_current_track_metadata(&self, renderer_id: &RendererId) -> Option { self.runtime.current_track_metadata(renderer_id) } @@ -782,12 +839,15 @@ impl ControlPoint { .map(|(index, item)| QueueItem { index, uri: item.uri.clone(), - title: item.title.clone(), - artist: item.artist.clone(), - album: item.album.clone(), - album_art_uri: item.album_art_uri.clone(), - server_id: item.server_id.as_ref().map(|s| s.0.clone()), - object_id: item.object_id.clone(), + title: item.metadata.as_ref().and_then(|m| m.title.clone()), + artist: item.metadata.as_ref().and_then(|m| m.artist.clone()), + album: item.metadata.as_ref().and_then(|m| m.album.clone()), + album_art_uri: item + .metadata + .as_ref() + .and_then(|m| m.album_art_uri.clone()), + server_id: Some(item.media_server_id.0.clone()), + object_id: Some(item.didl_id.clone()), }) .collect(); @@ -828,7 +888,7 @@ impl ControlPoint { transport_state: runtime_snapshot .state .as_ref() - .map(playback_state_label) + .map(|state| state.as_str().to_string()) .unwrap_or_else(|| "UNKNOWN".to_string()), position_ms, duration_ms, @@ -879,10 +939,12 @@ impl ControlPoint { self.sync_openhome_playlist_for(renderer_id) } - /// Play the current item from the queue without advancing the index. + /// Plays the current queue item without advancing the index. /// - /// This is useful after a Stop operation to resume playback from the current - /// position rather than skipping to the next track. + /// Useful after a Stop operation to resume playback from the same track. + /// The method only reads queue content via the runtime helpers and + /// delegates potential structural mutations to `QueueBackend` (when an item + /// needs to be restored after a playback error). pub fn play_current_from_queue(&self, renderer_id: &RendererId) -> anyhow::Result<()> { if !self.runtime.has_entry(renderer_id) { let err = Self::runtime_entry_missing(renderer_id); @@ -923,7 +985,7 @@ impl ControlPoint { })?; let playback = (|| -> anyhow::Result<()> { - let didl_metadata = item.to_didl_metadata(); + let didl_metadata = playback_item_to_didl(&item); renderer.play_uri(&item.uri, &didl_metadata)?; Ok(()) })(); @@ -938,16 +1000,7 @@ impl ControlPoint { // Sauvegarder les métadonnées dans le snapshot pour que current_track soit disponible // même si le renderer UPnP ne retourne pas de métadonnées dans GetPositionInfo - let metadata = TrackMetadata { - title: item.title.clone(), - artist: item.artist.clone(), - album: item.album.clone(), - genre: item.genre.clone(), - album_art_uri: item.album_art_uri.clone(), - date: item.date.clone(), - track_number: item.track_number.clone(), - creator: item.creator.clone(), - }; + let metadata = playback_item_track_metadata(&item); self.runtime.update_snapshot_with(renderer_id, |snapshot| { snapshot.last_metadata = Some(metadata); }); @@ -969,6 +1022,10 @@ impl ControlPoint { } } + /// Advances the queue by one item, starts playback and updates the snapshot. + /// + /// The structural mutation uses the `QueueBackend::dequeue_next` helper + /// (through `RuntimeState`) so that all pointer updates are consistent. pub fn play_next_from_queue(&self, renderer_id: &RendererId) -> anyhow::Result<()> { if !self.runtime.has_entry(renderer_id) { let err = Self::runtime_entry_missing(renderer_id); @@ -1012,7 +1069,7 @@ impl ControlPoint { })?; let playback = (|| -> anyhow::Result<()> { - let didl_metadata = item.to_didl_metadata(); + let didl_metadata = playback_item_to_didl(&item); renderer.play_uri(&item.uri, &didl_metadata)?; Ok(()) })(); @@ -1025,8 +1082,11 @@ impl ControlPoint { ); if self .runtime - .with_queue_mut(renderer_id, |queue| queue.enqueue_front(item)) - .is_none() + .with_music_queue_mut(renderer_id, |queue| { + queue.enqueue_items(vec![item.clone()], EnqueueMode::InsertAfterCurrent)?; + Ok(()) + }) + .is_err() { warn!( renderer = renderer_id.0.as_str(), @@ -1040,16 +1100,7 @@ impl ControlPoint { // Sauvegarder les métadonnées dans le snapshot pour que current_track soit disponible // même si le renderer UPnP ne retourne pas de métadonnées dans GetPositionInfo - let metadata = TrackMetadata { - title: item.title.clone(), - artist: item.artist.clone(), - album: item.album.clone(), - genre: item.genre.clone(), - album_art_uri: item.album_art_uri.clone(), - date: item.date.clone(), - track_number: item.track_number.clone(), - creator: item.creator.clone(), - }; + let metadata = playback_item_track_metadata(&item); self.runtime.update_snapshot_with(renderer_id, |snapshot| { snapshot.last_metadata = Some(metadata); }); @@ -1067,7 +1118,7 @@ impl ControlPoint { if let Some(upnp) = renderer.as_upnp() { let known_supported = upnp.supports_set_next(); if known_supported || upnp.has_avtransport() { - let next_didl_metadata = next_item.to_didl_metadata(); + let next_didl_metadata = playback_item_to_didl(next_item); match upnp.set_next_uri(&next_item.uri, &next_didl_metadata) { Ok(_) => debug!( renderer = renderer_id.0.as_str(), @@ -1161,7 +1212,8 @@ impl ControlPoint { /// When attached, the queue will be automatically refreshed from the /// container whenever the server notifies us of changes via ContentDirectory /// events. The binding is broken if the user explicitly mutates the queue - /// through methods like `clear_queue` or `enqueue_items`. + /// through methods like `clear_queue` or `enqueue_items`, so this method is + /// part of the queue-mutation surface area. /// Attach a renderer's queue to a playlist container. /// /// The queue will be automatically refreshed when the playlist changes on the server. @@ -1175,6 +1227,8 @@ impl ControlPoint { } /// Attach a renderer queue to a playlist with explicit `auto_play` behaviour. + /// + /// Same queue-mutation guarantees as [`attach_queue_to_playlist`]. pub fn attach_queue_to_playlist_with_options( &self, renderer_id: &RendererId, @@ -1237,8 +1291,9 @@ impl ControlPoint { /// Detach a renderer's queue from its associated playlist container. /// - /// After calling this, the queue will no longer be automatically refreshed - /// from the server. If no binding existed, this is a no-op. + /// Public mutation API paired with `attach_queue_to_playlist*`. After calling + /// this, the queue will no longer be automatically refreshed from the server. + /// If no binding existed, this is a no-op. pub fn detach_queue_playlist(&self, renderer_id: &RendererId) { self.detach_playlist_binding(renderer_id, "api_detach"); } @@ -1373,7 +1428,7 @@ impl ControlPoint { .and_then(|ids| ids.last().copied()); for item in items.iter() { - let metadata = item.to_didl_metadata(); + let metadata = playback_item_to_didl(item); after_id = Some(renderer.openhome_playlist_add_track(&item.uri, &metadata, after_id, false)?); } @@ -1404,7 +1459,7 @@ impl ControlPoint { return Ok(()); } - // Fallback: utiliser la PlaybackQueue locale si la playlist native est vide + // Fallback: utiliser la queue locale du runtime si la playlist native est vide // (ce cas se produit quand on a enqueue des items via le control point) let Some((item, _)) = self.runtime.peek_current(renderer_id) else { debug!( @@ -1476,16 +1531,16 @@ fn convert_runtime_position(position: Option<&PlaybackPositionInfo>) -> (Option< } } -#[cfg(feature = "pmoserver")] -fn playback_state_label(state: &PlaybackState) -> String { - match state { - PlaybackState::Stopped => "STOPPED".to_string(), - PlaybackState::Playing => "PLAYING".to_string(), - PlaybackState::Paused => "PAUSED".to_string(), - PlaybackState::Transitioning => "TRANSITIONING".to_string(), - PlaybackState::NoMedia => "NO_MEDIA".to_string(), - PlaybackState::Unknown(custom) => custom.clone(), - } +fn build_openhome_queue(info: &RendererInfo) -> Option { + let control_url = info.oh_playlist_control_url.as_ref()?.clone(); + let service_type = info.oh_playlist_service_type.as_ref()?.clone(); + let playlist = OhPlaylistClient::new(control_url, service_type); + let info_client = info + .oh_info_control_url + .as_ref() + .zip(info.oh_info_service_type.as_ref()) + .map(|(url, ty)| OhInfoClient::new(url.clone(), ty.clone())); + Some(OpenHomeQueue::new(info.id.clone(), playlist, info_client)) } #[cfg(feature = "pmoserver")] @@ -1528,7 +1583,7 @@ enum PlaylistBackend { struct RendererRuntimeEntry { snapshot: RendererRuntimeSnapshot, - queue: PlaybackQueue, + pub queue: crate::control_point::music_queue::MusicQueue, playback_source: PlaybackSource, user_stop_requested: bool, playlist_backend: PlaylistBackend, @@ -1538,7 +1593,7 @@ impl Default for RendererRuntimeEntry { fn default() -> Self { Self { snapshot: RendererRuntimeSnapshot::default(), - queue: PlaybackQueue::default(), + queue: MusicQueue::Internal(InternalQueue::new()), playback_source: PlaybackSource::None, user_stop_requested: false, playlist_backend: PlaylistBackend::PMOQueue, @@ -1550,6 +1605,68 @@ struct RuntimeState { entries: Mutex>, } +pub struct RendererRuntimeStateMut<'a> { + pub queue: MusicQueueGuard<'a>, + _guard: MutexGuard<'a, HashMap>, +} + +impl<'a> RendererRuntimeStateMut<'a> { + fn new( + guard: MutexGuard<'a, HashMap>, + queue_ptr: *mut MusicQueue, + ) -> Self { + Self { + queue: MusicQueueGuard::new(queue_ptr), + _guard: guard, + } + } +} + +pub struct MusicQueueGuard<'a> { + ptr: *mut MusicQueue, + _marker: PhantomData<&'a mut MusicQueue>, +} + +impl<'a> MusicQueueGuard<'a> { + fn new(ptr: *mut MusicQueue) -> Self { + Self { + ptr, + _marker: PhantomData, + } + } +} + +impl<'a> Deref for MusicQueueGuard<'a> { + type Target = MusicQueue; + + fn deref(&self) -> &Self::Target { + unsafe { &*self.ptr } + } +} + +impl<'a> DerefMut for MusicQueueGuard<'a> { + fn deref_mut(&mut self) -> &mut Self::Target { + unsafe { &mut *self.ptr } + } +} + +struct RuntimeOpenHomeQueueProvider { + runtime: Arc, +} + +impl OpenHomeQueueProvider for RuntimeOpenHomeQueueProvider { + fn renderer_state(&self, renderer_id: &RendererId) -> anyhow::Result { + self.runtime.renderer_state(renderer_id) + } + + fn renderer_state_mut<'a>( + &'a self, + renderer_id: &RendererId, + ) -> anyhow::Result> { + self.runtime.renderer_state_mut(renderer_id) + } +} + impl RuntimeState { fn new() -> Self { Self { @@ -1585,22 +1702,58 @@ impl RuntimeState { entries.contains_key(id) } - fn with_queue_mut(&self, id: &RendererId, f: F) -> Option + fn with_music_queue_mut(&self, id: &RendererId, f: F) -> anyhow::Result where - F: FnOnce(&mut PlaybackQueue) -> R, + F: FnOnce(&mut MusicQueue) -> anyhow::Result, { let mut entries = self.entries.lock().unwrap(); - entries.get_mut(id).map(|entry| f(&mut entry.queue)) + let entry = entries + .get_mut(id) + .ok_or_else(|| anyhow!("Renderer {} not registered in runtime", id.0))?; + f(&mut entry.queue) } fn queue_snapshot(&self, id: &RendererId) -> Option> { let entries = self.entries.lock().unwrap(); - entries.get(id).map(|entry| entry.queue.snapshot()) + entries + .get(id) + .and_then(|entry| entry.queue.upcoming_items().ok()) } fn queue_full_snapshot(&self, id: &RendererId) -> Option<(Vec, Option)> { let entries = self.entries.lock().unwrap(); - entries.get(id).map(|entry| entry.queue.full_snapshot()) + entries.get(id).and_then(|entry| { + entry + .queue + .queue_snapshot() + .ok() + .map(|snapshot| (snapshot.items, snapshot.current_index)) + }) + } + + fn renderer_state(&self, id: &RendererId) -> anyhow::Result { + let entries = self.entries.lock().unwrap(); + let entry = entries + .get(id) + .ok_or_else(|| anyhow!("Renderer {} not registered in runtime", id.0))?; + Ok(RendererRuntimeState { + queue: entry.queue.clone(), + }) + } + + fn renderer_state_mut( + &self, + id: &RendererId, + ) -> anyhow::Result> { + let mut entries = self.entries.lock().unwrap(); + let queue_ptr = { + let entry = entries + .get_mut(id) + .ok_or_else(|| anyhow!("Renderer {} not registered in runtime", id.0))?; + let entry_ptr: *mut RendererRuntimeEntry = entry; + unsafe { &mut (*entry_ptr).queue as *mut MusicQueue } + }; + Ok(RendererRuntimeStateMut::new(entries, queue_ptr)) } fn current_track_metadata(&self, id: &RendererId) -> Option { @@ -1617,8 +1770,21 @@ impl RuntimeState { ) -> (RendererRuntimeSnapshot, Vec, Option) { let entries = self.entries.lock().unwrap(); if let Some(entry) = entries.get(id) { - let (items, current_index) = entry.queue.full_snapshot(); - (entry.snapshot.clone(), items, current_index) + match entry.queue.queue_snapshot() { + Ok(snapshot) => ( + entry.snapshot.clone(), + snapshot.items, + snapshot.current_index, + ), + Err(err) => { + warn!( + renderer = id.0.as_str(), + error = %err, + "Failed to build queue snapshot for renderer" + ); + (entry.snapshot.clone(), Vec::new(), None) + } + } } else { (RendererRuntimeSnapshot::default(), Vec::new(), None) } @@ -1627,17 +1793,13 @@ impl RuntimeState { fn dequeue_next(&self, id: &RendererId) -> Option<(PlaybackItem, usize)> { let mut entries = self.entries.lock().unwrap(); let entry = entries.get_mut(id)?; - let item = entry.queue.dequeue()?; - let remaining = entry.queue.upcoming_len(); - Some((item, remaining)) + entry.queue.dequeue_next().ok().flatten() } fn peek_current(&self, id: &RendererId) -> Option<(PlaybackItem, usize)> { let entries = self.entries.lock().unwrap(); let entry = entries.get(id)?; - let item = entry.queue.peek()?.clone(); - let remaining = entry.queue.upcoming_len(); - Some((item, remaining)) + entry.queue.peek_current().ok().flatten() } fn set_playback_source(&self, id: &RendererId, source: PlaybackSource) { @@ -1679,6 +1841,12 @@ impl RuntimeState { f(entry) } + fn set_music_queue(&self, id: &RendererId, queue: MusicQueue) { + self.with_entry(id, |entry| { + entry.queue = queue; + }); + } + fn set_playlist_backend(&self, id: &RendererId, backend: PlaylistBackend) { self.with_entry(id, |entry| { entry.playlist_backend = backend; @@ -1829,7 +1997,8 @@ fn refresh_attached_queue_for( container = container_id.as_str(), "Refreshed playlist is empty, clearing queue" ); - runtime.with_queue_mut(renderer_id, |queue| queue.clear()); + runtime + .with_music_queue_mut(renderer_id, |queue| queue.clear_queue())?; // Emit QueueUpdated event event_bus.broadcast(RendererEvent::QueueUpdated { @@ -1850,27 +2019,17 @@ fn refresh_attached_queue_for( let current_item = current_idx.and_then(|idx| full_queue.get(idx).cloned()); let item_found_at = current_item.as_ref().and_then(|current| { - new_items.iter().position(|new_item| { - // Match by object_id if both have it - if let (Some(current_obj), Some(new_obj)) = (¤t.object_id, &new_item.object_id) { - return current_obj == new_obj; - } - // Fallback: match by URI - current.uri == new_item.uri - }) + let current_uid = current.unique_id(); + new_items + .iter() + .position(|new_item| new_item.unique_id() == current_uid) + .or_else(|| new_items.iter().position(|new_item| new_item.uri == current.uri)) }); let final_queue_len = runtime - .with_queue_mut(renderer_id, |queue| { - queue.clear(); - + .with_music_queue_mut(renderer_id, |queue| { if let Some(idx) = item_found_at { - // Current item found: load the ENTIRE new playlist and position at that item - // This preserves items before the current track (as "already played") - for item in new_items.iter() { - queue.enqueue(item.clone()); - } - queue.set_current_index(Some(idx)); + queue.replace_queue(new_items.clone(), Some(idx))?; info!( renderer = renderer_id.0.as_str(), server = server_id.0.as_str(), @@ -1881,15 +2040,12 @@ fn refresh_attached_queue_for( current_preserved = true, "Refreshed queue from playlist container" ); - new_items.len() + Ok(new_items.len()) } else if let Some(ref current) = current_item { - // Current item NOT found: insert it at the beginning, then add new items - // This preserves the currently playing track and prevents it from being lost - queue.enqueue(current.clone()); - for item in new_items.iter() { - queue.enqueue(item.clone()); - } - queue.set_current_index(Some(0)); + let mut combined = Vec::with_capacity(new_items.len() + 1); + combined.push(current.clone()); + combined.extend(new_items.clone()); + queue.replace_queue(combined, Some(0))?; info!( renderer = renderer_id.0.as_str(), server = server_id.0.as_str(), @@ -1901,13 +2057,9 @@ fn refresh_attached_queue_for( current_reinserted = true, "Refreshed queue from playlist container (current item reinserted at start)" ); - new_items.len() + 1 + Ok(new_items.len() + 1) } else { - // No current item: replace with full new list - for item in new_items.iter() { - queue.enqueue(item.clone()); - } - queue.set_current_index(None); + queue.replace_queue(new_items.clone(), None)?; info!( renderer = renderer_id.0.as_str(), server = server_id.0.as_str(), @@ -1916,10 +2068,9 @@ fn refresh_attached_queue_for( current_preserved = false, "Refreshed queue from playlist container (no current item)" ); - new_items.len() + Ok(new_items.len()) } - }) - .unwrap_or(0); + })?; // Emit QueueUpdated event event_bus.broadcast(RendererEvent::QueueUpdated { @@ -1944,20 +2095,6 @@ fn refresh_attached_queue_for( Ok(()) } -/// Helper to detect if a MediaResource is audio content. -fn is_audio_resource(res: &MediaResource) -> bool { - let lower = res.protocol_info.to_ascii_lowercase(); - if lower.contains("audio/") { - return true; - } - // Check MIME type in protocolInfo (format: protocol:network:contentFormat:additionalInfo) - lower - .split(':') - .nth(2) - .map(|mime| mime.starts_with("audio/")) - .unwrap_or(false) -} - /// Helper to convert a MediaEntry to a PlaybackItem. fn playback_item_from_entry(server: &MusicServer, entry: &MediaEntry) -> Option { // Ignore containers @@ -1971,42 +2108,128 @@ fn playback_item_from_entry(server: &MusicServer, entry: &MediaEntry) -> Option< } // Find an audio resource - let resource = entry.resources.iter().find(|res| is_audio_resource(res))?; + let resource = entry.resources.iter().find(|res| res.is_audio())?; - let mut item = PlaybackItem::new(resource.uri.clone()); - 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(); - item.protocol_info = Some(resource.protocol_info.clone()); + let metadata = TrackMetadata { + title: Some(entry.title.clone()), + artist: entry.artist.clone(), + album: entry.album.clone(), + genre: entry.genre.clone(), + album_art_uri: entry.album_art_uri.clone(), + date: entry.date.clone(), + track_number: entry.track_number.clone(), + creator: entry.creator.clone(), + }; - Some(item) + Some(PlaybackItem { + media_server_id: server.id().clone(), + didl_id: entry.id.clone(), + uri: resource.uri.clone(), + metadata: Some(metadata), + }) } const OPENHOME_TRACK_PREFIX: &str = "openhome:"; -fn playback_item_from_openhome_track(track: &OpenHomePlaylistTrack) -> PlaybackItem { - let mut item = PlaybackItem::new(track.uri.clone()); - item.object_id = Some(format!("{}{}", OPENHOME_TRACK_PREFIX, track.id)); - item.title = track.title.clone(); - item.artist = track.artist.clone(); - item.album = track.album.clone(); - item.album_art_uri = track.album_art_uri.clone(); - item +fn playback_item_from_openhome_track( + renderer_id: &RendererId, + track: &OpenHomePlaylistTrack, +) -> PlaybackItem { + let metadata = TrackMetadata { + title: track.title.clone(), + artist: track.artist.clone(), + album: track.album.clone(), + genre: None, + album_art_uri: track.album_art_uri.clone(), + date: None, + track_number: None, + creator: None, + }; + + PlaybackItem { + media_server_id: ServerId(format!("openhome:{}", renderer_id.0)), + didl_id: format!("{}{}", OPENHOME_TRACK_PREFIX, track.id), + uri: track.uri.clone(), + metadata: Some(metadata), + } } fn openhome_track_id_from_item(item: &PlaybackItem) -> Option { - let object_id = item.object_id.as_ref()?; - let raw = object_id.strip_prefix(OPENHOME_TRACK_PREFIX)?; + let raw = item.didl_id.strip_prefix(OPENHOME_TRACK_PREFIX)?; raw.parse::().ok() } +fn didl_item_from_playback_item(item: &PlaybackItem) -> DidlItem { + let metadata = item.metadata.as_ref(); + let title = metadata + .and_then(|m| m.title.as_deref()) + .unwrap_or("Unknown") + .to_string(); + let creator = metadata + .and_then(|m| m.creator.clone()) + .or_else(|| metadata.and_then(|m| m.artist.clone())); + + DidlItem { + id: item.didl_id.clone(), + parent_id: "-1".to_string(), + restricted: Some("1".to_string()), + title, + creator, + class: "object.item.audioItem.musicTrack".to_string(), + artist: metadata.and_then(|m| m.artist.clone()), + album: metadata.and_then(|m| m.album.clone()), + genre: metadata.and_then(|m| m.genre.clone()), + album_art: metadata.and_then(|m| m.album_art_uri.clone()), + album_art_pk: None, + date: metadata.and_then(|m| m.date.clone()), + original_track_number: metadata.and_then(|m| m.track_number.clone()), + resources: vec![DidlResource { + protocol_info: "http-get:*:audio/*:*".to_string(), + bits_per_sample: None, + sample_frequency: None, + nr_audio_channels: None, + duration: None, + url: item.uri.clone(), + }], + descriptions: Vec::new(), + } +} + +fn playback_item_to_didl(item: &PlaybackItem) -> String { + let didl_item = didl_item_from_playback_item(item); + 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: None, + xmlns_sec: None, + xmlns_pv: None, + containers: Vec::new(), + items: vec![didl_item], + }; + + match to_didl_string(&didl) { + Ok(xml) => xml, + Err(err) => { + warn!(error = %err, "Failed to serialize DIDL-Lite metadata"); + String::new() + } + } +} + +fn playback_item_track_metadata(item: &PlaybackItem) -> TrackMetadata { + item.metadata.clone().unwrap_or_else(|| TrackMetadata { + title: None, + artist: None, + album: None, + genre: None, + album_art_uri: None, + date: None, + track_number: None, + creator: None, + }) +} + fn sync_openhome_playlist( registry: &Arc>, runtime: &Arc, @@ -2027,11 +2250,11 @@ fn sync_openhome_playlist( .ok_or_else(|| OpenHomeAccessError::PlaylistNotSupported(renderer_id.0.clone()))? }; - let snapshot = renderer.openhome_playlist_snapshot()?; + let snapshot = renderer.fetch_openhome_playlist_snapshot()?; let playback_items: Vec = snapshot .tracks .iter() - .map(playback_item_from_openhome_track) + .map(|track| playback_item_from_openhome_track(renderer_id, track)) .collect(); let current_id = snapshot.current_id; @@ -2042,12 +2265,12 @@ fn sync_openhome_playlist( .position(|item| openhome_track_id_from_item(item) == Some(id)) }); + let queue_items = playback_items; let queue_len = runtime - .with_queue_mut(renderer_id, |queue| { - queue.clear(); - queue.enqueue_many(playback_items.iter().cloned()); - queue.set_current_index(current_index); - queue.len() + .with_music_queue_mut(renderer_id, move |queue| { + let len = queue_items.len(); + queue.replace_queue(queue_items, current_index)?; + Ok(len) }) .unwrap_or(0); @@ -3000,11 +3223,12 @@ fn playback_position_equal(a: &PlaybackPositionInfo, b: &PlaybackPositionInfo) - #[cfg(feature = "pmoserver")] fn current_track_from_playback_item(item: &PlaybackItem) -> CurrentTrackMetadata { + let meta = item.metadata.as_ref(); CurrentTrackMetadata { - title: item.title.clone(), - artist: item.artist.clone(), - album: item.album.clone(), - album_art_uri: item.album_art_uri.clone(), + title: meta.and_then(|m| m.title.clone()), + artist: meta.and_then(|m| m.artist.clone()), + album: meta.and_then(|m| m.album.clone()), + album_art_uri: meta.and_then(|m| m.album_art_uri.clone()), } } diff --git a/pmocontrol/src/control_point/music_queue.rs b/pmocontrol/src/control_point/music_queue.rs new file mode 100644 index 00000000..db365044 --- /dev/null +++ b/pmocontrol/src/control_point/music_queue.rs @@ -0,0 +1,74 @@ +use anyhow::{Result, anyhow}; + +use crate::control_point::openhome_queue::OpenHomeQueue; +use crate::openhome_playlist::OpenHomePlaylistSnapshot; +use crate::queue_backend::{PlaybackItem, QueueBackend, QueueSnapshot}; +use crate::queue_interne::InternalQueue; + +#[derive(Debug, Clone)] +pub enum MusicQueue { + Internal(InternalQueue), + OpenHome(OpenHomeQueue), +} + +impl MusicQueue { + pub fn new_internal() -> Self { + MusicQueue::Internal(InternalQueue::default()) + } + + pub fn openhome_playlist_snapshot(&self) -> Result { + match self { + MusicQueue::OpenHome(queue) => queue.openhome_playlist_snapshot(), + _ => Err(anyhow!( + "OpenHome playlist snapshot is only available for OpenHome queues" + )), + } + } +} + +impl Default for MusicQueue { + fn default() -> Self { + MusicQueue::new_internal() + } +} + +impl QueueBackend for MusicQueue { + fn queue_snapshot(&self) -> Result { + match self { + MusicQueue::Internal(q) => q.queue_snapshot(), + MusicQueue::OpenHome(q) => q.queue_snapshot(), + } + } + + fn set_index(&mut self, index: Option) -> Result<()> { + match self { + MusicQueue::Internal(q) => q.set_index(index), + MusicQueue::OpenHome(q) => q.set_index(index), + } + } + + fn replace_queue( + &mut self, + items: Vec, + current_index: Option, + ) -> Result<()> { + match self { + MusicQueue::Internal(q) => q.replace_queue(items, current_index), + MusicQueue::OpenHome(q) => q.replace_queue(items, current_index), + } + } + + fn get_item(&self, index: usize) -> Result> { + match self { + MusicQueue::Internal(q) => q.get_item(index), + MusicQueue::OpenHome(q) => q.get_item(index), + } + } + + fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<()> { + match self { + MusicQueue::Internal(q) => q.replace_item(index, item), + MusicQueue::OpenHome(q) => q.replace_item(index, item), + } + } +} diff --git a/pmocontrol/src/control_point/openhome_queue.rs b/pmocontrol/src/control_point/openhome_queue.rs new file mode 100644 index 00000000..73c4ca63 --- /dev/null +++ b/pmocontrol/src/control_point/openhome_queue.rs @@ -0,0 +1,360 @@ +use anyhow::{Result, anyhow}; +use pmodidl::DIDLLite; +use quick_xml::escape::escape; + +use crate::media_server::ServerId; +use crate::model::RendererId; +use crate::openhome_client::{ + OhInfoClient, OhPlaylistClient, OhTrackEntry, parse_track_metadata_from_didl, +}; +use crate::openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack}; +use crate::queue_backend::{PlaybackItem, QueueBackend, QueueSnapshot}; + +/// Local mirror of an OpenHome playlist for a single renderer. +#[derive(Clone, Debug)] +pub struct OpenHomeQueue { + pub renderer_id: RendererId, + pub playlist: OhPlaylistClient, + pub info_client: Option, + pub items: Vec, + pub current_index: Option, + track_ids: Vec, +} + +impl OpenHomeQueue { + pub fn new( + renderer_id: RendererId, + playlist: OhPlaylistClient, + info_client: Option, + ) -> Self { + Self { + renderer_id, + playlist, + info_client, + items: Vec::new(), + current_index: None, + track_ids: Vec::new(), + } + } + + /// Reload the full OpenHome playlist snapshot into local playback items. + /// + /// This mirrors the logic previously implemented by + /// `OpenHomeRenderer::snapshot_openhome_playlist` but converts entries + /// directly into `PlaybackItem`s. + pub fn refresh_from_openhome(&mut self) -> Result<()> { + let entries = self.playlist.read_all_tracks()?; + let mut items = Vec::with_capacity(entries.len()); + let mut track_ids = Vec::with_capacity(entries.len()); + + for entry in &entries { + items.push(self.playback_item_from_entry(entry)); + track_ids.push(entry.id); + } + + let current_id = self + .info_client + .as_ref() + .and_then(|client| client.id().ok()); + let current_index = current_id + .and_then(|id| track_ids.iter().position(|entry_id| *entry_id == id)); + + self.items = items; + self.track_ids = track_ids; + self.current_index = current_index; + Ok(()) + } + + pub fn openhome_playlist_snapshot(&self) -> Result { + let tracks = self + .items + .iter() + .zip(self.track_ids.iter()) + .map(|(item, track_id)| OpenHomePlaylistTrack { + id: *track_id, + uri: item.uri.clone(), + title: item.metadata.as_ref().and_then(|m| m.title.clone()), + artist: item.metadata.as_ref().and_then(|m| m.artist.clone()), + album: item.metadata.as_ref().and_then(|m| m.album.clone()), + album_art_uri: item + .metadata + .as_ref() + .and_then(|m| m.album_art_uri.clone()), + }) + .collect(); + + Ok(OpenHomePlaylistSnapshot { + renderer_id: self.renderer_id.0.clone(), + current_id: self + .current_index + .and_then(|idx| self.track_ids.get(idx).copied()), + tracks, + }) + } + + pub fn len(&self) -> usize { + self.items.len() + } + + /// Return the list of OpenHome track IDs in order. + pub fn openhome_track_ids(&self) -> Vec { + self.track_ids.clone() + } + + pub fn select_track_id(&mut self, id: u32) -> Result<()> { + let index = match self.track_ids.iter().position(|&tid| tid == id) { + Some(pos) => pos, + None => { + self.refresh_from_openhome()?; + self.track_ids + .iter() + .position(|&tid| tid == id) + .ok_or_else(|| anyhow!("Unknown OpenHome track id {}", id))? + } + }; + + self.playlist.play_id(id)?; + self.current_index = Some(index); + Ok(()) + } + + pub fn clear(&mut self) -> Result<()> { + self.playlist.delete_all()?; + self.items.clear(); + self.track_ids.clear(); + self.current_index = None; + Ok(()) + } + + pub fn add_playback_item( + &mut self, + item: PlaybackItem, + after_id: Option, + play: bool, + ) -> Result { + let metadata_xml = build_metadata_xml(&item); + let insert_after = match after_id { + Some(id) => id, + None => self.track_ids.last().copied().unwrap_or(0), + }; + + let new_id = self + .playlist + .insert(insert_after, &item.uri, &metadata_xml)?; + + if play { + self.playlist.play_id(new_id)?; + } + + let mut insert_index = after_id + .and_then(|id| { + if id == 0 { + Some(0) + } else { + self.track_ids.iter().position(|tid| *tid == id).map(|pos| pos + 1) + } + }) + .unwrap_or_else(|| self.track_ids.len()); + + if insert_index > self.track_ids.len() { + insert_index = self.track_ids.len(); + } + + self.track_ids.insert(insert_index, new_id); + self.items.insert(insert_index, item); + + self.current_index = if play { + Some(insert_index) + } else { + self.current_index.map(|idx| { + if insert_index <= idx { + idx + 1 + } else { + idx + } + }) + }; + + Ok(new_id) + } + + fn playback_item_from_entry(&self, entry: &OhTrackEntry) -> PlaybackItem { + let metadata = parse_track_metadata_from_didl(&entry.metadata_xml); + let didl_id = didl_id_from_metadata(&entry.metadata_xml) + .unwrap_or_else(|| format!("openhome:{}", entry.id)); + PlaybackItem { + media_server_id: ServerId(format!("openhome:{}", self.renderer_id.0)), + didl_id, + uri: entry.uri.clone(), + metadata, + } + } + + fn ensure_track_id(&mut self, index: usize) -> Result { + if index >= self.items.len() { + return Err(anyhow!("Index out of bounds in OpenHomeQueue: {}", index)); + } + + if let Some(id) = self.track_ids.get(index).copied() { + return Ok(id); + } + + self.refresh_from_openhome()?; + self.track_ids + .get(index) + .copied() + .ok_or_else(|| anyhow!("Failed to resolve OpenHome track id at index {}", index)) + } +} + +pub fn didl_id_from_metadata(xml: &str) -> Option { + if xml.trim().is_empty() { + return None; + } + + let parsed = pmodidl::parse_metadata::(xml).ok()?; + parsed + .data + .items + .first() + .map(|item| item.id.clone()) +} + +fn build_metadata_xml(item: &PlaybackItem) -> String { + let title = item + .metadata + .as_ref() + .and_then(|m| m.title.as_deref()) + .unwrap_or("Unknown"); + let escaped_title = escape(title); + let escaped_uri = escape(item.uri.as_str()); + let escaped_id = escape(item.didl_id.as_str()); + + let mut xml = String::from( + r#""#, + ); + xml.push_str(&format!( + r#""#, + escaped_id + )); + xml.push_str(&format!("{}", escaped_title)); + + if let Some(meta) = &item.metadata { + if let Some(artist) = meta.artist.as_deref() { + let escaped = escape(artist); + xml.push_str(&format!("{}", escaped)); + xml.push_str(&format!("{}", escaped)); + } + if let Some(album) = meta.album.as_deref() { + let escaped = escape(album); + xml.push_str(&format!("{}", escaped)); + } + if let Some(genre) = meta.genre.as_deref() { + let escaped = escape(genre); + xml.push_str(&format!("{}", escaped)); + } + if let Some(uri) = meta.album_art_uri.as_deref() { + let escaped = escape(uri); + xml.push_str(&format!( + "{}", + escaped + )); + } + if let Some(date) = meta.date.as_deref() { + let escaped = escape(date); + xml.push_str(&format!("{}", escaped)); + } + if let Some(track_no) = meta.track_number.as_deref() { + let escaped = escape(track_no); + xml.push_str(&format!( + "{}", + escaped + )); + } + } + + xml.push_str(&format!( + r#"{}"#, + escaped_uri + )); + xml.push_str(r#"object.item.audioItem.musicTrack"#); + xml +} + +impl QueueBackend for OpenHomeQueue { + fn queue_snapshot(&self) -> Result { + Ok(QueueSnapshot { + items: self.items.clone(), + current_index: self.current_index, + }) + } + + fn set_index(&mut self, index: Option) -> Result<()> { + let normalized = index.filter(|&i| i < self.items.len()); + if let Some(idx) = normalized { + let track_id = self.ensure_track_id(idx)?; + self.playlist.play_id(track_id)?; + } + self.current_index = normalized; + Ok(()) + } + + fn replace_queue( + &mut self, + items: Vec, + current_index: Option, + ) -> Result<()> { + self.playlist.delete_all()?; + + let mut previous_id = 0u32; + let mut inserted_ids = Vec::with_capacity(items.len()); + + for item in &items { + let metadata = build_metadata_xml(item); + let new_id = self.playlist.insert(previous_id, &item.uri, &metadata)?; + previous_id = new_id; + inserted_ids.push(new_id); + } + + let normalized = current_index.filter(|&i| i < inserted_ids.len()); + if let Some(idx) = normalized { + if let Some(track_id) = inserted_ids.get(idx).copied() { + self.playlist.play_id(track_id)?; + } + } + + self.items = items; + self.track_ids = inserted_ids; + self.current_index = normalized; + Ok(()) + } + + fn get_item(&self, index: usize) -> Result> { + Ok(self.items.get(index).cloned()) + } + + fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<()> { + if index >= self.items.len() { + return Ok(()); + } + + let track_id = self.ensure_track_id(index)?; + let before_id = if index == 0 { + 0 + } else { + self.ensure_track_id(index - 1)? + }; + + self.playlist.delete_id(track_id)?; + let metadata = build_metadata_xml(&item); + let new_id = self.playlist.insert(before_id, &item.uri, &metadata)?; + + if self.current_index == Some(index) { + self.playlist.play_id(new_id)?; + } + + self.items[index] = item; + self.track_ids[index] = new_id; + Ok(()) + } +} diff --git a/pmocontrol/src/lib.rs b/pmocontrol/src/lib.rs index 3715d8cb..b11524bb 100644 --- a/pmocontrol/src/lib.rs +++ b/pmocontrol/src/lib.rs @@ -14,7 +14,8 @@ pub mod music_renderer; pub mod openhome_client; pub mod openhome_playlist; pub mod openhome_renderer; -pub mod playback_queue; +pub mod queue_backend; +pub mod queue_interne; pub mod provider; pub mod registry; pub mod rendering_control_client; @@ -48,7 +49,7 @@ pub use media_server::{ pub use music_renderer::MusicRenderer; pub use openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack}; pub use openhome_renderer::OpenHomeRenderer; -pub use playback_queue::{PlaybackItem, PlaybackQueue}; +pub use queue_backend::{EnqueueMode, PlaybackItem, QueueSnapshot}; pub use rendering_control_client::RenderingControlClient; pub use upnp_renderer::UpnpRenderer; diff --git a/pmocontrol/src/media_server.rs b/pmocontrol/src/media_server.rs index edb6c63d..6410e57e 100644 --- a/pmocontrol/src/media_server.rs +++ b/pmocontrol/src/media_server.rs @@ -38,6 +38,22 @@ pub struct MediaResource { pub duration: Option, } +impl MediaResource { + /// Returns true if this resource represents audio content. + pub fn is_audio(&self) -> bool { + let lower = self.protocol_info.to_ascii_lowercase(); + if lower.contains("audio/") { + return true; + } + // protocolInfo format: protocol:network:contentFormat:additionalInfo + lower + .split(':') + .nth(2) + .map(|mime| mime.starts_with("audio/")) + .unwrap_or(false) + } +} + /// Representation of either a container or an item returned by ContentDirectory. #[derive(Clone, Debug)] pub struct MediaEntry { diff --git a/pmocontrol/src/music_renderer.rs b/pmocontrol/src/music_renderer.rs index 85c4a50f..be6cee6c 100644 --- a/pmocontrol/src/music_renderer.rs +++ b/pmocontrol/src/music_renderer.rs @@ -6,11 +6,17 @@ //! renderers through this type so that transport, volume, and state queries //! stay backend-neutral. -use std::sync::{Arc, RwLock}; +use std::sync::{Arc, OnceLock, RwLock}; use crate::capabilities::{PlaybackPositionInfo, PlaybackStatus}; +use crate::control_point::music_queue::MusicQueue; +use crate::control_point::openhome_queue::didl_id_from_metadata; +use crate::control_point::RendererRuntimeStateMut; +use crate::media_server::ServerId; use crate::model::{RendererId, RendererInfo, RendererProtocol}; +use crate::openhome_client::parse_track_metadata_from_didl; use crate::openhome_playlist::OpenHomePlaylistSnapshot; +use crate::queue_backend::PlaybackItem; use crate::{ ArylicTcpRenderer, DeviceRegistry, LinkPlayRenderer, OpenHomeRenderer, PlaybackPosition, PlaybackState, TransportControl, UpnpRenderer, VolumeControl, @@ -46,6 +52,27 @@ pub(crate) fn op_not_supported(op: &str, backend: &str) -> anyhow::Error { ) } +#[derive(Clone, Debug)] +pub struct RendererRuntimeState { + pub queue: MusicQueue, +} + +pub trait OpenHomeQueueProvider: Send + Sync + 'static { + fn renderer_state(&self, renderer_id: &RendererId) -> Result; + fn renderer_state_mut<'a>( + &'a self, + renderer_id: &RendererId, + ) -> Result>; +} + +static OPENHOME_QUEUE_PROVIDER: OnceLock> = OnceLock::new(); + +pub fn set_openhome_queue_provider( + provider: Arc, +) { + let _ = OPENHOME_QUEUE_PROVIDER.set(provider); +} + impl MusicRenderer { /// Renderer identifier (stable within the registry). pub fn id(&self) -> &RendererId { @@ -162,6 +189,21 @@ impl MusicRenderer { } pub fn openhome_playlist_snapshot(&self) -> Result { + if let Some(provider) = OPENHOME_QUEUE_PROVIDER.get() { + let state = provider.renderer_state(self.id())?; + match &state.queue { + MusicQueue::OpenHome(queue) => queue.openhome_playlist_snapshot(), + _ => Err(op_not_supported( + "openhome_playlist_snapshot", + self.unsupported_backend_name(), + )), + } + } else { + self.fetch_openhome_playlist_snapshot() + } + } + + pub(crate) fn fetch_openhome_playlist_snapshot(&self) -> Result { match self { MusicRenderer::OpenHome(renderer) => renderer.snapshot_openhome_playlist(), _ => Err(op_not_supported( @@ -172,26 +214,56 @@ impl MusicRenderer { } pub fn openhome_playlist_len(&self) -> Result { - match self { - MusicRenderer::OpenHome(renderer) => renderer.openhome_playlist_len(), - _ => Err(op_not_supported( - "openhome_playlist_len", - self.unsupported_backend_name(), - )), + if let Some(provider) = OPENHOME_QUEUE_PROVIDER.get() { + let state = provider.renderer_state(self.id())?; + match &state.queue { + MusicQueue::OpenHome(queue) => Ok(queue.len()), + _ => Err(op_not_supported( + "openhome_playlist_len", + self.unsupported_backend_name(), + )), + } + } else { + Ok(self.fetch_openhome_playlist_snapshot()?.tracks.len()) } } pub fn openhome_playlist_ids(&self) -> Result> { - match self { - MusicRenderer::OpenHome(renderer) => renderer.openhome_playlist_ids(), - _ => Err(op_not_supported( - "openhome_playlist_ids", - self.unsupported_backend_name(), - )), + if let Some(provider) = OPENHOME_QUEUE_PROVIDER.get() { + let state = provider.renderer_state(self.id())?; + match &state.queue { + MusicQueue::OpenHome(queue) => Ok(queue.openhome_track_ids()), + _ => Err(op_not_supported( + "openhome_playlist_ids", + self.unsupported_backend_name(), + )), + } + } else { + let snapshot = self.fetch_openhome_playlist_snapshot()?; + Ok(snapshot + .tracks + .into_iter() + .map(|track| track.id) + .collect()) } } pub fn openhome_playlist_clear(&self) -> Result<()> { + if let Some(provider) = OPENHOME_QUEUE_PROVIDER.get() { + let mut state = provider.renderer_state_mut(self.id())?; + match &mut *state.queue { + MusicQueue::OpenHome(queue) => queue.clear(), + _ => Err(op_not_supported( + "openhome_playlist_clear", + self.unsupported_backend_name(), + )), + } + } else { + self.fetch_openhome_playlist_clear() + } + } + + pub(crate) fn fetch_openhome_playlist_clear(&self) -> Result<()> { match self { MusicRenderer::OpenHome(renderer) => renderer.clear_openhome_playlist(), _ => Err(op_not_supported( @@ -207,6 +279,55 @@ impl MusicRenderer { metadata: &str, after_id: Option, play: bool, + ) -> Result { + if let Some(provider) = OPENHOME_QUEUE_PROVIDER.get() { + let mut state = provider.renderer_state_mut(self.id())?; + match &mut *state.queue { + MusicQueue::OpenHome(queue) => { + let playback_item = Self::playback_item_from_params(self.id(), uri, metadata)?; + queue.add_playback_item(playback_item, after_id, play) + } + _ => Err(op_not_supported( + "openhome_playlist_add_track", + self.unsupported_backend_name(), + )), + } + } else { + self.fetch_openhome_playlist_add_track(uri, metadata, after_id, play) + } + } + + pub fn openhome_playlist_play_id(&self, id: u32) -> Result<()> { + if let Some(provider) = OPENHOME_QUEUE_PROVIDER.get() { + let mut state = provider.renderer_state_mut(self.id())?; + match &mut *state.queue { + MusicQueue::OpenHome(queue) => queue.select_track_id(id), + _ => Err(op_not_supported( + "openhome_playlist_play_id", + self.unsupported_backend_name(), + )), + } + } else { + self.fetch_openhome_playlist_play_id(id) + } + } + + fn unsupported_backend_name(&self) -> &'static str { + match self { + MusicRenderer::Upnp(_) => "UPnP", + MusicRenderer::OpenHome(_) => "OpenHome", + MusicRenderer::LinkPlay(_) => "LinkPlay", + MusicRenderer::ArylicTcp(_) => "ArylicTcp", + MusicRenderer::HybridUpnpArylic { .. } => "HybridUpnpArylic", + } + } + + fn fetch_openhome_playlist_add_track( + &self, + uri: &str, + metadata: &str, + after_id: Option, + play: bool, ) -> Result { match self { MusicRenderer::OpenHome(renderer) => { @@ -219,7 +340,7 @@ impl MusicRenderer { } } - pub fn openhome_playlist_play_id(&self, id: u32) -> Result<()> { + fn fetch_openhome_playlist_play_id(&self, id: u32) -> Result<()> { match self { MusicRenderer::OpenHome(renderer) => renderer.play_openhome_track_id(id), _ => Err(op_not_supported( @@ -229,14 +350,20 @@ impl MusicRenderer { } } - fn unsupported_backend_name(&self) -> &'static str { - match self { - MusicRenderer::Upnp(_) => "UPnP", - MusicRenderer::OpenHome(_) => "OpenHome", - MusicRenderer::LinkPlay(_) => "LinkPlay", - MusicRenderer::ArylicTcp(_) => "ArylicTcp", - MusicRenderer::HybridUpnpArylic { .. } => "HybridUpnpArylic", - } + fn playback_item_from_params( + renderer_id: &RendererId, + uri: &str, + metadata_xml: &str, + ) -> Result { + let metadata = parse_track_metadata_from_didl(metadata_xml); + let didl_id = didl_id_from_metadata(metadata_xml) + .unwrap_or_else(|| format!("openhome:{}", renderer_id.0)); + Ok(PlaybackItem { + media_server_id: ServerId(format!("openhome:{}", renderer_id.0)), + didl_id, + uri: uri.to_string(), + metadata, + }) } } diff --git a/pmocontrol/src/playback_queue.rs b/pmocontrol/src/playback_queue.rs deleted file mode 100644 index 6e47b5dd..00000000 --- a/pmocontrol/src/playback_queue.rs +++ /dev/null @@ -1,241 +0,0 @@ -use crate::media_server::ServerId; - -#[derive(Clone, Debug)] -pub struct PlaybackItem { - pub uri: String, - 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, - pub protocol_info: Option, -} - -impl PlaybackItem { - pub fn new(uri: impl Into) -> Self { - Self { - uri: uri.into(), - 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, - protocol_info: None, - } - } - - /// Convert PlaybackItem to DIDL-Lite XML metadata for SetAVTransportURI - pub fn to_didl_metadata(&self) -> String { - use quick_xml::escape::escape; - use tracing::debug; - - let title = self.title.as_deref().unwrap_or("Unknown"); - let escaped_uri = escape(&self.uri); - let escaped_title = escape(title); - - let mut didl = String::from( - r#""#, - ); - // Use proper ID from object_id if available, otherwise use "0" - let item_id = self.object_id.as_deref().unwrap_or("0"); - let escaped_item_id = escape(item_id); - didl.push_str(&format!( - r#""#, - escaped_item_id - )); - didl.push_str(&format!("{}", escaped_title)); - - if let Some(artist) = &self.artist { - let escaped_artist = escape(artist); - didl.push_str(&format!("{}", escaped_artist)); - didl.push_str(&format!("{}", escaped_artist)); - } - - if let Some(album) = &self.album { - let escaped_album = escape(album); - didl.push_str(&format!("{}", escaped_album)); - } - - if let Some(genre) = &self.genre { - let escaped_genre = escape(genre); - didl.push_str(&format!("{}", escaped_genre)); - } - - if let Some(album_art) = &self.album_art_uri { - let escaped_art = escape(album_art); - debug!( - title = title, - album_art_uri = album_art.as_str(), - "Including albumArtURI in DIDL metadata" - ); - didl.push_str(&format!( - "{}", - escaped_art - )); - } else { - debug!( - title = title, - "No album_art_uri in PlaybackItem - skipping albumArtURI in DIDL" - ); - } - - if let Some(date) = &self.date { - let escaped_date = escape(date); - didl.push_str(&format!("{}", escaped_date)); - } - - if let Some(track_num) = &self.track_number { - let escaped_track = escape(track_num); - didl.push_str(&format!( - "{}", - escaped_track - )); - } - - // Add resource with URI - // Use the original protocolInfo if available, otherwise use a generic one - let protocol_info = self - .protocol_info - .as_deref() - .unwrap_or("http-get:*:audio/*:*"); - - // For protocolInfo, we only need to escape XML special chars, not ':' - // We manually escape only the necessary characters to preserve the protocolInfo format - let safe_protocol_info = protocol_info - .replace('&', "&") - .replace('<', "<") - .replace('>', ">") - .replace('"', """); - - didl.push_str(&format!( - r#"{}"#, - safe_protocol_info, escaped_uri - )); - - didl.push_str(r#"object.item.audioItem.musicTrack"#); - didl.push_str(""); - didl.push_str(""); - - didl - } -} - -#[derive(Clone, Debug, Default)] -pub struct PlaybackQueue { - items: Vec, - current_index: Option, -} - -impl PlaybackQueue { - pub fn new() -> Self { - Self { - items: Vec::new(), - current_index: None, - } - } - - pub fn len(&self) -> usize { - self.items.len() - } - - pub fn is_empty(&self) -> bool { - self.items.is_empty() - } - - pub fn clear(&mut self) { - self.items.clear(); - self.current_index = None; - } - - pub fn enqueue(&mut self, item: PlaybackItem) { - self.items.push(item); - } - - pub fn enqueue_many>(&mut self, items: I) { - for item in items { - self.items.push(item); - } - } - - pub fn enqueue_front(&mut self, item: PlaybackItem) { - let insert_at = match self.current_index { - Some(idx) => { - let next = idx.saturating_add(1); - next.min(self.items.len()) - } - None => 0, - }; - self.items.insert(insert_at, item); - // current_index remains unchanged; insertion happens after the cursor. - } - - pub fn dequeue(&mut self) -> Option { - if self.items.is_empty() { - return None; - } - - match self.current_index { - None => { - self.current_index = Some(0); - self.items.get(0).cloned() - } - Some(idx) => { - let next_idx = idx + 1; - if next_idx >= self.items.len() { - None - } else { - self.current_index = Some(next_idx); - self.items.get(next_idx).cloned() - } - } - } - } - - pub fn peek(&self) -> Option<&PlaybackItem> { - if let Some(idx) = self.current_index { - self.items.get(idx) - } else { - self.items.first() - } - } - - pub fn snapshot(&self) -> Vec { - match self.current_index { - None => self.items.clone(), - Some(idx) => self.items.iter().skip(idx + 1).cloned().collect(), - } - } - - pub fn upcoming_len(&self) -> usize { - match self.current_index { - None => self.items.len(), - Some(idx) => self.items.len().saturating_sub(idx + 1), - } - } - - pub fn full_snapshot(&self) -> (Vec, Option) { - (self.items.clone(), self.current_index) - } - - pub fn set_current_index(&mut self, index: Option) { - if let Some(idx) = index { - if idx < self.items.len() { - self.current_index = Some(idx); - } else { - self.current_index = None; - } - } else { - self.current_index = None; - } - } -} diff --git a/pmocontrol/src/pmoserver_ext.rs b/pmocontrol/src/pmoserver_ext.rs index b043fcb8..a2abb583 100644 --- a/pmocontrol/src/pmoserver_ext.rs +++ b/pmocontrol/src/pmoserver_ext.rs @@ -6,9 +6,9 @@ #[cfg(feature = "pmoserver")] use crate::control_point::{ControlPoint, OpenHomeAccessError}; #[cfg(feature = "pmoserver")] -use crate::media_server::{MediaBrowser, MediaEntry, MediaResource, MusicServer, ServerId}; +use crate::media_server::{MediaBrowser, MediaEntry, MusicServer, ServerId}; #[cfg(feature = "pmoserver")] -use crate::model::{RendererCapabilities, RendererId, RendererProtocol}; +use crate::model::{RendererCapabilities, RendererId, RendererProtocol, TrackMetadata}; #[cfg(feature = "pmoserver")] use crate::openapi::{ AttachPlaylistRequest, AttachedPlaylistInfo, BrowseResponse, ContainerEntry, ErrorResponse, @@ -17,7 +17,7 @@ use crate::openapi::{ RendererProtocolSummary, RendererState, RendererSummary, SuccessResponse, VolumeSetRequest, }; #[cfg(feature = "pmoserver")] -use crate::playback_queue::PlaybackItem; +use crate::queue_backend::PlaybackItem; #[cfg(feature = "pmoserver")] use crate::{PlaybackPosition, PlaybackStatus, TransportControl, VolumeControl}; @@ -1858,37 +1858,25 @@ fn playback_item_from_entry(server: &MusicServer, entry: &MediaEntry) -> Option< } // Find an audio resource - let resource = entry.resources.iter().find(|res| is_audio_resource(res))?; + let resource = entry.resources.iter().find(|res| res.is_audio())?; - let mut item = PlaybackItem::new(resource.uri.clone()); - 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(); - item.protocol_info = Some(resource.protocol_info.clone()); + let metadata = TrackMetadata { + title: Some(entry.title.clone()), + artist: entry.artist.clone(), + album: entry.album.clone(), + genre: entry.genre.clone(), + album_art_uri: entry.album_art_uri.clone(), + date: entry.date.clone(), + track_number: entry.track_number.clone(), + creator: entry.creator.clone(), + }; - Some(item) -} - -/// Helper to detect if a MediaResource is audio content. -#[cfg(feature = "pmoserver")] -fn is_audio_resource(res: &MediaResource) -> bool { - let lower = res.protocol_info.to_ascii_lowercase(); - if lower.contains("audio/") { - return true; - } - // Check MIME type in protocolInfo (format: protocol:network:contentFormat:additionalInfo) - lower - .split(':') - .nth(2) - .map(|mime| mime.starts_with("audio/")) - .unwrap_or(false) + Some(PlaybackItem { + media_server_id: server.id().clone(), + didl_id: entry.id.clone(), + uri: resource.uri.clone(), + metadata: Some(metadata), + }) } #[cfg(feature = "pmoserver")] @@ -1917,19 +1905,6 @@ fn capability_summary(caps: &RendererCapabilities) -> RendererCapabilitiesSummar } } -#[cfg(feature = "pmoserver")] -fn state_to_string(state: crate::PlaybackState) -> String { - use crate::PlaybackState; - match state { - PlaybackState::Stopped => "STOPPED".to_string(), - PlaybackState::Playing => "PLAYING".to_string(), - PlaybackState::Paused => "PAUSED".to_string(), - PlaybackState::Transitioning => "TRANSITIONING".to_string(), - PlaybackState::NoMedia => "NO_MEDIA".to_string(), - PlaybackState::Unknown(s) => s, - } -} - // ============================================================================ // ROUTER & TRAIT // ============================================================================ diff --git a/pmocontrol/src/queue_backend.rs b/pmocontrol/src/queue_backend.rs new file mode 100644 index 00000000..61ae5b2b --- /dev/null +++ b/pmocontrol/src/queue_backend.rs @@ -0,0 +1,442 @@ +//! Generic queue abstraction for PMOControl. +//! +//! This module defines: +//! - the canonical `PlaybackItem` structure used by the ControlPoint queues, +//! - a generic `QueueSnapshot` view, +//! - the `EnqueueMode` enum, +//! - the `QueueBackend` trait, which abstracts queue manipulation for +//! different backends (internal/local queue, OpenHome playlist, …). +//! +//! Design goals: +//! - All queue manipulation logic (length, current index, enqueue, replace, +//! navigation, sync with MediaServer, …) is centralized here. +//! - Backends only implement a extremely small set of primitives; all +//! higher–level operations are provided as default methods. +//! - This trait NEVER starts playback. It only manipulates the queue +//! structure. Transport/renderer logic (play/pause/seek/…) is handled +//! elsewhere (e.g. `TransportControl` / `MusicRenderer`). +//! +//! Identity model: +//! - We are in a UPnP Control Point context. +//! - Every `PlaybackItem` comes from a UPnP MediaServer (ContentDirectory) +//! and is a projection of a DIDL-Lite `item`. +//! - The logical identity of a track is the pair +//! (media_server_id, didl_id) +//! where: +//! * `media_server_id` identifies the UPnP MediaServer, +//! * `didl_id` is the DIDL-Lite `id` attribute for the item. +//! - This identity is used by the sync helpers to preserve the current +//! track across queue rebuilds when the MediaServer content changes. + +use anyhow::Result; + +// ADAPTE ces imports aux modules existants dans pmocontrol. +// Exemple probable : +// use crate::model::MediaServerId; +// use crate::model::TrackMetadata; +use crate::media_server::ServerId as MediaServerId; +use crate::model::TrackMetadata; + +/// Canonical representation of a track in a renderer queue. +/// +/// This type is the bridge between: +/// - the UPnP MediaServer (DIDL-Lite items), +/// - the ControlPoint runtime, +/// - and the different queue backends (internal / OpenHome). +/// +/// It is intentionally DIDL-centric: every item in a queue comes from +/// a UPnP ContentDirectory and carries its MediaServer identity. +#[derive(Clone, Debug)] +pub struct PlaybackItem { + /// Identifier of the UPnP MediaServer that owns this content. + /// + /// Typically this is the UDN of the MediaServer device, or an + /// equivalent logical identifier. + pub media_server_id: MediaServerId, + + /// DIDL-Lite `id` attribute of the `item` in the ContentDirectory. + /// + /// This, combined with `media_server_id`, is the logical identity + /// of the track across refreshes of the MediaServer state. + pub didl_id: String, + + /// Main resource URI to be used for playback. + /// + /// This is usually the first `` element (or a selected one) + /// from the DIDL-Lite item. + pub uri: String, + + /// Optional rich metadata for the track (title, artist, album, cover, + /// duration, …). + /// + /// The exact structure is defined in `TrackMetadata` and may + /// aggregate information from DIDL, tags, or additional sources. + pub metadata: Option, +} + +impl PlaybackItem { + /// Returns a stable, backend-agnostic logical identifier for this item. + /// + /// By default this is the concatenation of the MediaServer identifier + /// and the DIDL `id`. Backends and higher-level logic should use this + /// when they need to match items across queue rebuilds. + pub fn unique_id(&self) -> String { + // ADAPTE si MediaServerId n'implémente pas Display : utilise + // un champ string interne ou une méthode as_str(). + format!("{}::{}", self.media_server_id.0, self.didl_id) + } +} + +/// Logical snapshot of a renderer queue. +/// +/// This is the canonical view used by the ControlPoint and the REST/API +/// layer. It is independent of how the queue is actually stored (local +/// in-memory queue, OpenHome playlist, …). +#[derive(Clone, Debug)] +pub struct QueueSnapshot { + /// All items currently in the queue, in play order. + pub items: Vec, + /// Index (0-based) of the current item in `items`, or `None` if + /// no item is currently selected. + pub current_index: Option, +} + +impl QueueSnapshot { + /// Returns the number of items in the snapshot. + pub fn len(&self) -> usize { + self.items.len() + } + + /// Returns `true` if the snapshot contains no items. + pub fn is_empty(&self) -> bool { + self.items.is_empty() + } +} + +/// High-level enqueue mode. +/// +/// This enum specifies how new items should be inserted relative to the +/// existing queue when using `QueueBackend::enqueue_items`. +#[derive(Clone, Copy, Debug)] +pub enum EnqueueMode { + /// Append new items at the end of the queue. + AppendToEnd, + /// Insert new items immediately after the current index + /// (or at the beginning if there is no current index). + InsertAfterCurrent, + /// Replace the whole queue with the new items. + ReplaceAll, +} + +/// Backend abstraction for a renderer queue. +/// +/// A `QueueBackend` exposes and manipulates the structural state of a queue +/// for a given renderer instance: +/// +/// - list of items, +/// - current index, +/// - replacement and mutation of items. +/// +/// It does **not** control playback. Transport actions (“play current item”, +/// “seek”, …) are handled by other components (e.g. `TransportControl`). +/// +/// Each queue instance is bound to a single renderer by construction. The +/// trait therefore does not take a `RendererId` parameter; all methods +/// operate directly on `self`. +/// +/// Implementors must provide a small set of primitives. All other methods +/// are default helpers that can usually be reused as-is. +pub trait QueueBackend { + // ===================================================================== + // BACKEND PRIMITIVES (must be implemented) + // ===================================================================== + + /// Returns the full snapshot (items + current index) of this queue. + fn queue_snapshot(&self) -> Result; + + /// Sets the current index for this queue. + /// + /// This method only updates the queue structure (pointer to the current + /// item). It MUST NOT start playback. + fn set_index(&mut self, index: Option) -> Result<()>; + + /// Replaces the entire queue with a new list of items and a new + /// current index. + fn replace_queue( + &mut self, + items: Vec, + current_index: Option, + ) -> Result<()>; + + /// Returns the item at `index`, if it exists. + fn get_item(&self, index: usize) -> Result>; + + /// Replaces the item at `index` with `item`. + fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<()>; + + // ===================================================================== + // DEFAULT HELPERS (backend-agnostic logic) + // ===================================================================== + + /// Clears the queue. + fn clear_queue(&mut self) -> Result<()> { + self.replace_queue(Vec::new(), None) + } + + /// Alias for `clear_queue`, semantic name for “empty before rebuild”. + fn empty_queue(&mut self) -> Result<()> { + self.clear_queue() + } + + /// Returns the current index, if any. + fn current_index(&self) -> Result> { + Ok(self.queue_snapshot()?.current_index) + } + + /// Returns the number of items in the queue. + fn len(&self) -> Result { + Ok(self.queue_snapshot()?.len()) + } + + /// Returns `true` if the queue is empty. + fn is_empty(&self) -> Result { + Ok(self.queue_snapshot()?.is_empty()) + } + + /// Returns a full snapshot of the queue. + fn full_snapshot(&self) -> Result { + self.queue_snapshot() + } + + /// Returns an iterator over all items in the queue. + /// + /// The default implementation: + /// - takes a snapshot, + /// - returns a boxed iterator owning the underlying `Vec`. + fn iter_items(&self) -> Result>> { + let snapshot = self.queue_snapshot()?; + Ok(Box::new(snapshot.items.into_iter())) + } + + /// Returns the list of items that come strictly after the current index. + fn upcoming_items(&self) -> Result> { + let snapshot = self.queue_snapshot()?; + let items = match snapshot.current_index { + None => snapshot.items, + Some(idx) => snapshot.items.into_iter().skip(idx + 1).collect(), + }; + Ok(items) + } + + /// Returns how many items remain in the queue after the current index. + fn upcoming_len(&self) -> Result { + Ok(self.upcoming_items()?.len()) + } + + /// Returns the current item (or the first pending item if no index is set) + /// along with the count of remaining items. + fn peek_current(&self) -> Result> { + let snapshot = self.queue_snapshot()?; + let QueueSnapshot { + items, + current_index, + } = snapshot; + + if items.is_empty() { + return Ok(None); + } + + let len = items.len(); + let (item, resolved_index) = match current_index { + Some(idx) if idx < len => (items.get(idx).cloned(), Some(idx)), + _ => (items.first().cloned(), None), + }; + + let item = match item { + Some(item) => item, + None => return Ok(None), + }; + + let remaining = match resolved_index { + Some(idx) => len.saturating_sub(idx + 1), + None => len, + }; + + Ok(Some((item, remaining))) + } + + /// Advances the queue to the next item (respecting the current index) and + /// returns it with the number of remaining items. + fn dequeue_next(&mut self) -> Result> { + let snapshot = self.queue_snapshot()?; + let QueueSnapshot { + items, + current_index, + } = snapshot; + + if items.is_empty() { + return Ok(None); + } + + let len = items.len(); + let next_index = match current_index { + None => 0, + Some(idx) => { + let candidate = idx + 1; + if candidate >= len { + return Ok(None); + } + candidate + } + }; + + let Some(item) = items.get(next_index).cloned() else { + return Ok(None); + }; + + let remaining = len.saturating_sub(next_index + 1); + self.set_index(Some(next_index))?; + Ok(Some((item, remaining))) + } + + /// Enqueues items according to the selected `EnqueueMode`. + /// + /// This method only manipulates the queue structure; it does not + /// start playback. + fn enqueue_items(&mut self, items: Vec, mode: EnqueueMode) -> Result<()> { + let mut snapshot = self.queue_snapshot()?; + + match mode { + EnqueueMode::AppendToEnd => { + snapshot.items.extend(items); + } + EnqueueMode::InsertAfterCurrent => { + let insert_pos = snapshot + .current_index + .map(|i| (i + 1).min(snapshot.items.len())) + .unwrap_or(0); + + for (offset, it) in items.into_iter().enumerate() { + snapshot + .items + .insert(insert_pos + offset, it); + } + } + EnqueueMode::ReplaceAll => { + snapshot.items = items; + snapshot.current_index = None; + } + } + + self.replace_queue(snapshot.items, snapshot.current_index) + } + + /// Replaces the queue with `items` and sets a default index. + fn replace_all(&mut self, items: Vec) -> Result<()> { + if items.is_empty() { + self.replace_queue(Vec::new(), None) + } else { + self.replace_queue(items, Some(0)) + } + } + + /// Appends items and, if the queue was previously empty, initializes + /// the current index to `0`. + fn append_or_init_index(&mut self, items: Vec) -> Result<()> { + let was_empty = self.is_empty()?; + let mut snapshot = self.queue_snapshot()?; + snapshot.items.extend(items); + + let new_index = if was_empty && !snapshot.items.is_empty() { + Some(0) + } else { + snapshot.current_index + }; + + self.replace_queue(snapshot.items, new_index) + } + + /// Computes the “next” index. + fn next_index(&self) -> Result> { + let len = self.len()?; + if len == 0 { + return Ok(None); + } + + match self.current_index()? { + None => Ok(Some(0)), + Some(i) if i + 1 < len => Ok(Some(i + 1)), + _ => Ok(None), + } + } + + /// Computes the “previous” index. + fn previous_index(&self) -> Result> { + match self.current_index()? { + None => Ok(None), + Some(0) => Ok(None), + Some(i) => Ok(Some(i - 1)), + } + } + + /// Advances the current index to the next item, if any. + fn advance(&mut self) -> Result { + if let Some(next) = self.next_index()? { + self.set_index(Some(next))?; + Ok(true) + } else { + Ok(false) + } + } + + /// Rewinds the current index to the previous item, if any. + fn rewind(&mut self) -> Result { + if let Some(prev) = self.previous_index()? { + self.set_index(Some(prev))?; + Ok(true) + } else { + Ok(false) + } + } + + /// Convenience helper to update an item “in place” at the given index. + fn update_item( + &mut self, + index: usize, + update: impl FnOnce(PlaybackItem) -> PlaybackItem, + ) -> Result<()> { + if let Some(item) = self.get_item(index)? { + let new_item = update(item); + self.replace_item(index, new_item) + } else { + anyhow::bail!("Queue index {} out of range", index); + } + } + + /// Synchronizes the queue with a new list of items coming from an + /// external MediaServer, trying to preserve the current track. + fn sync_from_external_preserve_current(&mut self, new_items: Vec) -> Result<()> { + let snapshot = self.queue_snapshot()?; + let current = snapshot + .current_index + .and_then(|i| snapshot.items.get(i).cloned()); + + let Some(current) = current else { + return self.replace_all(new_items); + }; + + let current_uid = current.unique_id(); + + if let Some(new_idx) = new_items + .iter() + .position(|it| it.unique_id() == current_uid) + { + self.replace_queue(new_items, Some(new_idx)) + } else { + let mut items = Vec::with_capacity(new_items.len() + 1); + items.push(current); + items.extend(new_items); + self.replace_queue(items, Some(0)) + } + } +} diff --git a/pmocontrol/src/queue_interne.rs b/pmocontrol/src/queue_interne.rs new file mode 100644 index 00000000..5d67485f --- /dev/null +++ b/pmocontrol/src/queue_interne.rs @@ -0,0 +1,113 @@ +//! Internal (local) queue implementation for PMOControl. +//! +//! This module provides a concrete implementation of the generic +//! `QueueBackend` trait for queues that are fully managed inside the +//! ControlPoint, without delegating playlist management to a remote +//! backend (like OpenHome). +//! +//! In this design, each queue instance is associated to exactly one +//! renderer. The queue does not need to know the renderer identifier: +//! it is "bound" to the renderer by construction, and will be stored +//! directly in the runtime (inside a higher-level `MusicQueue` enum). +//! +//! This internal queue: +//! - owns its list of `PlaybackItem`s, +//! - maintains a `current_index`, +//! - never starts playback (transport control is handled elsewhere). + +use anyhow::Result; + +use crate::queue_backend::{PlaybackItem, QueueBackend, QueueSnapshot}; + +/// Internal/local queue implementation. +/// +/// This is the simplest possible queue backend: +/// - a `Vec` +/// - plus an optional `current_index`. +/// +/// It does not talk to any remote service. All operations are pure +/// structural mutations on in-memory data. +#[derive(Clone, Debug, Default)] +pub struct InternalQueue { + items: Vec, + current_index: Option, +} + +impl InternalQueue { + /// Creates an empty internal queue. + pub fn new() -> Self { + Self { + items: Vec::new(), + current_index: None, + } + } + + /// Creates an internal queue from an initial list of items. + /// + /// If `set_current_to_first` is `true` and the list is non-empty, + /// the current index is set to `Some(0)`. Otherwise, it is `None`. + pub fn from_items(items: Vec, set_current_to_first: bool) -> Self { + let current_index = if set_current_to_first && !items.is_empty() { + Some(0) + } else { + None + }; + Self { items, current_index } + } + + /// Exposes a read-only view of the underlying items. + pub fn items(&self) -> &[PlaybackItem] { + &self.items + } + + /// Exposes the current index (read-only). + pub fn current_index(&self) -> Option { + self.current_index + } +} + +impl QueueBackend for InternalQueue { + fn queue_snapshot(&self) -> Result { + Ok(QueueSnapshot { + items: self.items.clone(), + current_index: self.current_index, + }) + } + + fn set_index(&mut self, index: Option) -> Result<()> { + match index { + None => { + self.current_index = None; + } + Some(i) => { + if i < self.items.len() { + self.current_index = Some(i); + } else { + self.current_index = None; + } + } + } + Ok(()) + } + + fn replace_queue( + &mut self, + items: Vec, + current_index: Option, + ) -> Result<()> { + self.items = items; + self.current_index = current_index.filter(|&i| i < self.items.len()); + Ok(()) + } + + fn get_item(&self, index: usize) -> Result> { + Ok(self.items.get(index).cloned()) + } + + fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<()> { + if index < self.items.len() { + self.items[index] = item; + } + Ok(()) + } +} diff --git a/pmocontrol/src/sse.rs b/pmocontrol/src/sse.rs index c6bf21ec..ddb103dd 100644 --- a/pmocontrol/src/sse.rs +++ b/pmocontrol/src/sse.rs @@ -14,8 +14,6 @@ //! l'UI doit toujours refetch l'instantané complet auprès du ControlPoint, //! seule source de vérité de l'état renderer. -#[cfg(feature = "pmoserver")] -use crate::PlaybackState; #[cfg(feature = "pmoserver")] use crate::control_point::ControlPoint; #[cfg(feature = "pmoserver")] @@ -155,7 +153,7 @@ pub async fn renderer_events_sse( RendererEvent::StateChanged { id, state } => { RendererEventPayload::StateChanged { renderer_id: id.0, - state: state_to_string(state), + state: state.as_str().to_string(), timestamp, } } @@ -327,11 +325,11 @@ pub async fn all_events_sse(State(control_point): State>) -> i let renderer_payload = match event { RendererEvent::StateChanged { id, state } => { - RendererEventPayload::StateChanged { - renderer_id: id.0, - state: state_to_string(state), - timestamp, - } + RendererEventPayload::StateChanged { + renderer_id: id.0, + state: state.as_str().to_string(), + timestamp, + } } RendererEvent::PositionChanged { id, position } => { RendererEventPayload::PositionChanged { @@ -438,19 +436,3 @@ pub fn create_sse_router(control_point: Arc) -> Router { .route("/events/servers", get(media_server_events_sse)) .with_state(control_point) } - -// ============================================================================ -// HELPERS -// ============================================================================ - -#[cfg(feature = "pmoserver")] -fn state_to_string(state: PlaybackState) -> String { - match state { - PlaybackState::Stopped => "STOPPED".to_string(), - PlaybackState::Playing => "PLAYING".to_string(), - PlaybackState::Paused => "PAUSED".to_string(), - PlaybackState::Transitioning => "TRANSITIONING".to_string(), - PlaybackState::NoMedia => "NO_MEDIA".to_string(), - PlaybackState::Unknown(s) => s, - } -} diff --git a/pmocontrol/src/upnp_renderer.rs b/pmocontrol/src/upnp_renderer.rs index f50aa702..7d87bffc 100644 --- a/pmocontrol/src/upnp_renderer.rs +++ b/pmocontrol/src/upnp_renderer.rs @@ -238,7 +238,9 @@ mod tests { /// Cette impl se base sur AVTransport (InstanceID = 0). impl TransportControl for UpnpRenderer { fn play_uri(&self, uri: &str, meta: &str) -> Result<()> { - self.play_uri(uri, meta) + let avt = self.avtransport()?; + avt.set_av_transport_uri(uri, meta)?; + avt.play(0, "1") } fn play(&self) -> Result<()> { @@ -247,15 +249,18 @@ impl TransportControl for UpnpRenderer { } fn pause(&self) -> Result<()> { - self.pause() + let avt = self.avtransport()?; + avt.pause(0) } fn stop(&self) -> Result<()> { - self.stop() + let avt = self.avtransport()?; + avt.stop(0) } fn seek_rel_time(&self, hhmmss: &str) -> Result<()> { - self.seek_rel_time(hhmmss) + let avt = self.avtransport()?; + avt.seek(0, "REL_TIME", hhmmss) } } diff --git a/pmodidl_026.txt b/pmodidl_026.txt new file mode 100644 index 00000000..bbbd048a --- /dev/null +++ b/pmodidl_026.txt @@ -0,0 +1,972 @@ +=============== 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 =========== + diff --git a/tools/build_package_resume b/tools/build_package_resume new file mode 100755 index 00000000..a1eb0a9d --- /dev/null +++ b/tools/build_package_resume @@ -0,0 +1,13 @@ +#!/bin/bash + +TARGET=$1 +DEST=$(basename $TARGET) +VERSION=$2 + +find $TARGET -type f -not -name '.*' \ + | while read filename ; do + echo "=============== $filename ============" + cat $filename + echo "========= End of $filename ===========" + echo "" +done > $(printf "%s_%03d.txt" $DEST $VERSION)