Abstraction de la gestion des queues de lecture

This commit is contained in:
2025-12-06 13:37:42 +01:00
parent 380e105873
commit 31ee25c7f2
21 changed files with 2680 additions and 976 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@@ -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<RendererInfo> = {
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<String> = env::args().collect();
let selected_id: Option<RendererId> = if args.len() >= 2 {
match args[1].parse::<usize>() {
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 : <error {err}>"),
}
}
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 : <error {err}>"),
}
}
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 : <error {err}>"),
}
}
}
/// 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("<no title>");
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()
}
}

View File

@@ -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(" <vide>"));
} else {
for (idx, item) in self.queue_snapshot.iter().enumerate() {
let title = item.title.as_deref().unwrap_or("<titre>");
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("<titre>");
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<PlaybackItem> {
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<TrackMetadata> {
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<Line<'static>> {

View File

@@ -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("<no title>")
);
if let Some(first) = fresh_snapshot.first() {
let label = first
.metadata
.as_ref()
.and_then(|meta| meta.title.as_deref())
.unwrap_or("<no title>");
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 => "<unknown>".to_string(),

View File

@@ -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<dyn std::error::Error>> {
// 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(())
}

View File

@@ -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 => "<unknown>".to_string(),

View File

@@ -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).

View File

@@ -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);
let new_len = self.runtime.with_music_queue_mut(renderer_id, |queue| {
queue.enqueue_items(items, EnqueueMode::AppendToEnd)?;
queue.upcoming_len()
})
.ok_or_else(|| Self::runtime_entry_missing(renderer_id))?;
})?;
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<TrackMetadata> {
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<OpenHomeQueue> {
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<HashMap<RendererId, RendererRuntimeEntry>>,
}
pub struct RendererRuntimeStateMut<'a> {
pub queue: MusicQueueGuard<'a>,
_guard: MutexGuard<'a, HashMap<RendererId, RendererRuntimeEntry>>,
}
impl<'a> RendererRuntimeStateMut<'a> {
fn new(
guard: MutexGuard<'a, HashMap<RendererId, RendererRuntimeEntry>>,
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<RuntimeState>,
}
impl OpenHomeQueueProvider for RuntimeOpenHomeQueueProvider {
fn renderer_state(&self, renderer_id: &RendererId) -> anyhow::Result<RendererRuntimeState> {
self.runtime.renderer_state(renderer_id)
}
fn renderer_state_mut<'a>(
&'a self,
renderer_id: &RendererId,
) -> anyhow::Result<RendererRuntimeStateMut<'a>> {
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<F, R>(&self, id: &RendererId, f: F) -> Option<R>
fn with_music_queue_mut<F, R>(&self, id: &RendererId, f: F) -> anyhow::Result<R>
where
F: FnOnce(&mut PlaybackQueue) -> R,
F: FnOnce(&mut MusicQueue) -> anyhow::Result<R>,
{
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<Vec<PlaybackItem>> {
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<PlaybackItem>, Option<usize>)> {
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<RendererRuntimeState> {
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<RendererRuntimeStateMut<'_>> {
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<TrackMetadata> {
@@ -1617,8 +1770,21 @@ impl RuntimeState {
) -> (RendererRuntimeSnapshot, Vec<PlaybackItem>, Option<usize>) {
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)) = (&current.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<PlaybackItem> {
// 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<u32> {
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::<u32>().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<RwLock<DeviceRegistry>>,
runtime: &Arc<RuntimeState>,
@@ -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<PlaybackItem> = 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()),
}
}

View File

@@ -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<OpenHomePlaylistSnapshot> {
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<QueueSnapshot> {
match self {
MusicQueue::Internal(q) => q.queue_snapshot(),
MusicQueue::OpenHome(q) => q.queue_snapshot(),
}
}
fn set_index(&mut self, index: Option<usize>) -> Result<()> {
match self {
MusicQueue::Internal(q) => q.set_index(index),
MusicQueue::OpenHome(q) => q.set_index(index),
}
}
fn replace_queue(
&mut self,
items: Vec<PlaybackItem>,
current_index: Option<usize>,
) -> 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<Option<PlaybackItem>> {
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),
}
}
}

View File

@@ -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<OhInfoClient>,
pub items: Vec<PlaybackItem>,
pub current_index: Option<usize>,
track_ids: Vec<u32>,
}
impl OpenHomeQueue {
pub fn new(
renderer_id: RendererId,
playlist: OhPlaylistClient,
info_client: Option<OhInfoClient>,
) -> 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<OpenHomePlaylistSnapshot> {
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<u32> {
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<u32>,
play: bool,
) -> Result<u32> {
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<u32> {
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<String> {
if xml.trim().is_empty() {
return None;
}
let parsed = pmodidl::parse_metadata::<DIDLLite>(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#"<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/">"#,
);
xml.push_str(&format!(
r#"<item id="{}" parentID="-1" restricted="1">"#,
escaped_id
));
xml.push_str(&format!("<dc:title>{}</dc:title>", escaped_title));
if let Some(meta) = &item.metadata {
if let Some(artist) = meta.artist.as_deref() {
let escaped = escape(artist);
xml.push_str(&format!("<upnp:artist>{}</upnp:artist>", escaped));
xml.push_str(&format!("<dc:creator>{}</dc:creator>", escaped));
}
if let Some(album) = meta.album.as_deref() {
let escaped = escape(album);
xml.push_str(&format!("<upnp:album>{}</upnp:album>", escaped));
}
if let Some(genre) = meta.genre.as_deref() {
let escaped = escape(genre);
xml.push_str(&format!("<upnp:genre>{}</upnp:genre>", escaped));
}
if let Some(uri) = meta.album_art_uri.as_deref() {
let escaped = escape(uri);
xml.push_str(&format!(
"<upnp:albumArtURI>{}</upnp:albumArtURI>",
escaped
));
}
if let Some(date) = meta.date.as_deref() {
let escaped = escape(date);
xml.push_str(&format!("<dc:date>{}</dc:date>", escaped));
}
if let Some(track_no) = meta.track_number.as_deref() {
let escaped = escape(track_no);
xml.push_str(&format!(
"<upnp:originalTrackNumber>{}</upnp:originalTrackNumber>",
escaped
));
}
}
xml.push_str(&format!(
r#"<res protocolInfo="http-get:*:audio/*:*">{}</res>"#,
escaped_uri
));
xml.push_str(r#"<upnp:class>object.item.audioItem.musicTrack</upnp:class></item></DIDL-Lite>"#);
xml
}
impl QueueBackend for OpenHomeQueue {
fn queue_snapshot(&self) -> Result<QueueSnapshot> {
Ok(QueueSnapshot {
items: self.items.clone(),
current_index: self.current_index,
})
}
fn set_index(&mut self, index: Option<usize>) -> 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<PlaybackItem>,
current_index: Option<usize>,
) -> 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<Option<PlaybackItem>> {
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(())
}
}

View File

@@ -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;

View File

@@ -38,6 +38,22 @@ pub struct MediaResource {
pub duration: Option<String>,
}
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 {

View File

@@ -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<RendererRuntimeState>;
fn renderer_state_mut<'a>(
&'a self,
renderer_id: &RendererId,
) -> Result<RendererRuntimeStateMut<'a>>;
}
static OPENHOME_QUEUE_PROVIDER: OnceLock<Arc<dyn OpenHomeQueueProvider>> = OnceLock::new();
pub fn set_openhome_queue_provider(
provider: Arc<dyn OpenHomeQueueProvider>,
) {
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<OpenHomePlaylistSnapshot> {
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<OpenHomePlaylistSnapshot> {
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<usize> {
match self {
MusicRenderer::OpenHome(renderer) => renderer.openhome_playlist_len(),
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<Vec<u32>> {
match self {
MusicRenderer::OpenHome(renderer) => renderer.openhome_playlist_ids(),
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<u32>,
play: bool,
) -> Result<u32> {
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<u32>,
play: bool,
) -> Result<u32> {
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<PlaybackItem> {
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,
})
}
}

View File

@@ -1,241 +0,0 @@
use crate::media_server::ServerId;
#[derive(Clone, Debug)]
pub struct PlaybackItem {
pub uri: String,
pub title: Option<String>,
pub server_id: Option<ServerId>,
pub object_id: Option<String>,
pub artist: Option<String>,
pub album: Option<String>,
pub genre: Option<String>,
pub album_art_uri: Option<String>,
pub date: Option<String>,
pub track_number: Option<String>,
pub creator: Option<String>,
pub protocol_info: Option<String>,
}
impl PlaybackItem {
pub fn new(uri: impl Into<String>) -> 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#"<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/">"#,
);
// 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#"<item id="{}" parentID="-1" restricted="1">"#,
escaped_item_id
));
didl.push_str(&format!("<dc:title>{}</dc:title>", escaped_title));
if let Some(artist) = &self.artist {
let escaped_artist = escape(artist);
didl.push_str(&format!("<upnp:artist>{}</upnp:artist>", escaped_artist));
didl.push_str(&format!("<dc:creator>{}</dc:creator>", escaped_artist));
}
if let Some(album) = &self.album {
let escaped_album = escape(album);
didl.push_str(&format!("<upnp:album>{}</upnp:album>", escaped_album));
}
if let Some(genre) = &self.genre {
let escaped_genre = escape(genre);
didl.push_str(&format!("<upnp:genre>{}</upnp:genre>", 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!(
"<upnp:albumArtURI>{}</upnp:albumArtURI>",
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!("<dc:date>{}</dc:date>", escaped_date));
}
if let Some(track_num) = &self.track_number {
let escaped_track = escape(track_num);
didl.push_str(&format!(
"<upnp:originalTrackNumber>{}</upnp:originalTrackNumber>",
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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;");
didl.push_str(&format!(
r#"<res protocolInfo="{}">{}</res>"#,
safe_protocol_info, escaped_uri
));
didl.push_str(r#"<upnp:class>object.item.audioItem.musicTrack</upnp:class>"#);
didl.push_str("</item>");
didl.push_str("</DIDL-Lite>");
didl
}
}
#[derive(Clone, Debug, Default)]
pub struct PlaybackQueue {
items: Vec<PlaybackItem>,
current_index: Option<usize>,
}
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<I: IntoIterator<Item = PlaybackItem>>(&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<PlaybackItem> {
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<PlaybackItem> {
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<PlaybackItem>, Option<usize>) {
(self.items.clone(), self.current_index)
}
pub fn set_current_index(&mut self, index: Option<usize>) {
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;
}
}
}

View File

@@ -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
// ============================================================================

View File

@@ -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
//! higherlevel 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 `<res>` 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<TrackMetadata>,
}
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<PlaybackItem>,
/// Index (0-based) of the current item in `items`, or `None` if
/// no item is currently selected.
pub current_index: Option<usize>,
}
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<QueueSnapshot>;
/// 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<usize>) -> Result<()>;
/// Replaces the entire queue with a new list of items and a new
/// current index.
fn replace_queue(
&mut self,
items: Vec<PlaybackItem>,
current_index: Option<usize>,
) -> Result<()>;
/// Returns the item at `index`, if it exists.
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>>;
/// 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<Option<usize>> {
Ok(self.queue_snapshot()?.current_index)
}
/// Returns the number of items in the queue.
fn len(&self) -> Result<usize> {
Ok(self.queue_snapshot()?.len())
}
/// Returns `true` if the queue is empty.
fn is_empty(&self) -> Result<bool> {
Ok(self.queue_snapshot()?.is_empty())
}
/// Returns a full snapshot of the queue.
fn full_snapshot(&self) -> Result<QueueSnapshot> {
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<Box<dyn Iterator<Item = PlaybackItem>>> {
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<Vec<PlaybackItem>> {
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<usize> {
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<Option<(PlaybackItem, usize)>> {
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<Option<(PlaybackItem, usize)>> {
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<PlaybackItem>, 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<PlaybackItem>) -> 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<PlaybackItem>) -> 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<Option<usize>> {
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<Option<usize>> {
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<bool> {
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<bool> {
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<PlaybackItem>) -> 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))
}
}
}

View File

@@ -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<PlaybackItem>`
/// - 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<PlaybackItem>,
current_index: Option<usize>,
}
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<PlaybackItem>, 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<usize> {
self.current_index
}
}
impl QueueBackend for InternalQueue {
fn queue_snapshot(&self) -> Result<QueueSnapshot> {
Ok(QueueSnapshot {
items: self.items.clone(),
current_index: self.current_index,
})
}
fn set_index(&mut self, index: Option<usize>) -> 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<PlaybackItem>,
current_index: Option<usize>,
) -> Result<()> {
self.items = items;
self.current_index = current_index.filter(|&i| i < self.items.len());
Ok(())
}
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>> {
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(())
}
}

View File

@@ -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,
}
}
@@ -329,7 +327,7 @@ pub async fn all_events_sse(State(control_point): State<Arc<ControlPoint>>) -> i
RendererEvent::StateChanged { id, state } => {
RendererEventPayload::StateChanged {
renderer_id: id.0,
state: state_to_string(state),
state: state.as_str().to_string(),
timestamp,
}
}
@@ -438,19 +436,3 @@ pub fn create_sse_router(control_point: Arc<ControlPoint>) -> 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,
}
}

View File

@@ -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)
}
}

972
pmodidl_026.txt Normal file
View File

@@ -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' ? {} ===",
xml.starts_with("<?xml")
);
println!("\n=== With manual XML declaration ===");
let with_decl = format!("<?xml version=\"1.0\" encoding=\"UTF-8\"?>{}", 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<Self, Self::Error>;
/// 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<T> {
/// 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<std::time::SystemTime>,
}
impl<T> ParsedMetadata<T> {
pub fn new(format: impl Into<String>, 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<U, F>(self, f: F) -> ParsedMetadata<U>
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<P: MediaMetadataParser>(input: &str) -> Result<ParsedMetadata<P>, 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<Self, Self::Error> {
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<DIDLLite>;
// ============= 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<String>,
#[serde(rename = "@xmlns:dc", skip_serializing_if = "Option::is_none")]
pub xmlns_dc: Option<String>,
#[serde(rename = "@xmlns:dlna", skip_serializing_if = "Option::is_none")]
pub xmlns_dlna: Option<String>,
#[serde(rename = "@xmlns:sec", skip_serializing_if = "Option::is_none")]
pub xmlns_sec: Option<String>,
#[serde(rename = "@xmlns:pv", skip_serializing_if = "Option::is_none")]
pub xmlns_pv: Option<String>,
#[serde(rename = "container", default)]
pub containers: Vec<Container>,
#[serde(rename = "item", default)]
pub items: Vec<Item>,
}
/// 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<String>,
#[serde(rename = "@childCount", skip_serializing_if = "Option::is_none")]
pub child_count: Option<String>,
#[serde(rename = "@searchable", skip_serializing_if = "Option::is_none")]
pub searchable: Option<String>,
#[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<Container>,
#[serde(rename = "item", default)]
pub items: Vec<Item>,
}
/// 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<String>,
#[serde(rename = "dc:title", alias = "title")]
pub title: String,
#[serde(
rename = "dc:creator",
alias = "creator",
skip_serializing_if = "Option::is_none"
)]
pub creator: Option<String>,
#[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<String>,
#[serde(
rename = "upnp:album",
alias = "album",
skip_serializing_if = "Option::is_none"
)]
pub album: Option<String>,
#[serde(
rename = "upnp:genre",
alias = "genre",
skip_serializing_if = "Option::is_none"
)]
pub genre: Option<String>,
#[serde(
rename = "upnp:albumArtURI",
alias = "albumArtURI",
skip_serializing_if = "Option::is_none"
)]
pub album_art: Option<String>,
#[serde(skip)]
pub album_art_pk: Option<String>,
#[serde(
rename = "dc:date",
alias = "date",
skip_serializing_if = "Option::is_none"
)]
pub date: Option<String>,
#[serde(
rename = "upnp:originalTrackNumber",
alias = "originalTrackNumber",
skip_serializing_if = "Option::is_none"
)]
pub original_track_number: Option<String>,
#[serde(rename = "res", default)]
pub resources: Vec<Resource>,
#[serde(rename = "desc", default)]
pub descriptions: Vec<Description>,
}
/// 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<String>,
#[serde(rename = "@sampleFrequency", skip_serializing_if = "Option::is_none")]
pub sample_frequency: Option<String>,
#[serde(rename = "@nrAudioChannels", skip_serializing_if = "Option::is_none")]
pub nr_audio_channels: Option<String>,
#[serde(rename = "@duration", skip_serializing_if = "Option::is_none")]
pub duration: Option<String>,
#[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<String>,
#[serde(rename = "@nameSpace", skip_serializing_if = "Option::is_none")]
pub namespace: Option<String>,
#[serde(rename = "track_gain", skip_serializing_if = "Option::is_none")]
pub track_gain: Option<String>,
#[serde(rename = "track_peak", skip_serializing_if = "Option::is_none")]
pub track_peak: Option<String>,
}
// ============= 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<Item = &Container> {
AllContainersIter::new(&self.containers)
}
/// Itère sur tous les items de manière récursive
pub fn all_items(&self) -> impl Iterator<Item = &Item> {
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<F>(&self, predicate: F) -> impl Iterator<Item = &Container>
where
F: Fn(&Container) -> bool,
{
self.all_containers().filter(move |c| predicate(c))
}
/// Filtre les items
pub fn filter_items<F>(&self, predicate: F) -> impl Iterator<Item = &Item>
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<Item = &Container> {
AllContainersIter::new(&self.containers)
}
/// Itère sur tous les items de ce container et ses enfants
pub fn all_items(&self) -> impl Iterator<Item = &Item> {
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<String> {
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<Item = &Resource> {
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<Item = (&str, &str)> {
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<String> = 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::Item> {
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<Self::Item> {
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#"
<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/"
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/">
<item id="1" parentID="0">
<dc:title>Test Song</dc:title>
<upnp:class>object.item.audioItem.musicTrack</upnp:class>
<res protocolInfo="http-get:*:audio/mpeg:*">http://example.com/song.mp3</res>
</item>
</DIDL-Lite>
"#;
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#"
<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/">
<item id="1" parentID="0">
<title>Test Song</title>
<class>object.item.audioItem.musicTrack</class>
<res protocolInfo="http-get:*:audio/mpeg:*">http://example.com/song.mp3</res>
</item>
</DIDL-Lite>
"#;
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#"
<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/"
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/">
</DIDL-Lite>
"#;
// 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#"
<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/"
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/">
</DIDL-Lite>
"#;
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 ===========

13
tools/build_package_resume Executable file
View File

@@ -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)