- Add `SyncCancelled` error variant for non-fatal cancellations - Refactor queue sync to async via `MusicQueue::schedule_sync()` - Extract browse+conversion into internal helper - Update all `QueueBackend::sync_queue()` signatures to accept cancel token and on_ready callback - Implement early-start logic (on pivot preservation or first insert) - Add `QueueReadyToPlay` and ‘ QueueSyncCancelled➔ SSE events - Replace blocking `refresh_attached_queue_for()` with non-blocking async dispatch in control_point.rs - Bump version to v0.3.41
541 lines
18 KiB
Rust
541 lines
18 KiB
Rust
use std::sync::{atomic::AtomicBool, Arc, Mutex};
|
|
|
|
use crate::errors::ControlPointError;
|
|
use crate::model::PlaybackState;
|
|
use crate::music_renderer::capabilities::{
|
|
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, QueueTransportControl, RendererBackend,
|
|
TransportControl, VolumeControl,
|
|
};
|
|
use crate::music_renderer::musicrenderer::{build_didl_lite_metadata, MusicRendererBackend};
|
|
use crate::music_renderer::RendererFromMediaRendererInfo;
|
|
use crate::queue::{EnqueueMode, MusicQueue, PlaybackItem, QueueBackend, QueueSnapshot};
|
|
use crate::upnp_clients::{
|
|
AvTransportClient, ConnectionInfo, ConnectionManagerClient, PositionInfo, ProtocolInfo,
|
|
RenderingControlClient,
|
|
};
|
|
use crate::{DeviceIdentity, RendererInfo};
|
|
|
|
/// High-level handle representing a renderer and its optional AVTransport client.
|
|
#[derive(Clone, Debug)]
|
|
pub struct UpnpRenderer {
|
|
avtransport: Option<AvTransportClient>,
|
|
rendering_control: Option<RenderingControlClient>,
|
|
connection_manager: Option<ConnectionManagerClient>,
|
|
has_avtransport_set_next: bool,
|
|
queue: Arc<Mutex<MusicQueue>>,
|
|
/// Durée extraite du DIDL-Lite (fallback si l'ampli ne la retourne pas)
|
|
cached_duration: Arc<Mutex<Option<String>>>,
|
|
/// Flag indicating if currently playing a continuous stream (radio without duration)
|
|
continuous_stream: Arc<Mutex<bool>>,
|
|
}
|
|
|
|
impl UpnpRenderer {
|
|
/// Retourne true si le renderer à un service de type AVTransport.
|
|
pub fn has_avtransport(&self) -> bool {
|
|
self.avtransport.is_some()
|
|
}
|
|
|
|
/// Retourne true si le renderer à un service de type RenderingControl.
|
|
pub fn has_rendering_control(&self) -> bool {
|
|
self.rendering_control.is_some()
|
|
}
|
|
|
|
/// Retourne true si le renderer à un service de type ConnectionManager.
|
|
pub fn has_connection_manager(&self) -> bool {
|
|
self.connection_manager.is_some()
|
|
}
|
|
|
|
/// Retourne le client de type AVTransport contenant les URL de controle et d'abonnement.
|
|
pub fn avtransport(&self) -> Result<&AvTransportClient, ControlPointError> {
|
|
self.avtransport.as_ref().ok_or_else(|| {
|
|
ControlPointError::upnp_operation_not_supported("AvTransport", "Renderer")
|
|
})
|
|
}
|
|
|
|
/// Retourne le client de type RenderingControl contenant les URL de controle et d'abonnement.
|
|
pub fn rendering_control(&self) -> Result<&RenderingControlClient, ControlPointError> {
|
|
self.rendering_control.as_ref().ok_or_else(|| {
|
|
ControlPointError::upnp_operation_not_supported("RenderingControl", "Renderer")
|
|
})
|
|
}
|
|
|
|
/// Retourne le client de type ConnectionManager contenant les URL de controle et d'abonnement.
|
|
pub fn connection_manager(&self) -> Result<&ConnectionManagerClient, ControlPointError> {
|
|
self.connection_manager.as_ref().ok_or_else(|| {
|
|
ControlPointError::upnp_operation_not_supported("ConnectionManager", "Renderer")
|
|
})
|
|
}
|
|
|
|
pub fn protocol_info(&self) -> Result<ProtocolInfo, ControlPointError> {
|
|
let cm = self.connection_manager()?;
|
|
cm.get_protocol_info()
|
|
}
|
|
|
|
pub fn connection_ids(&self) -> Result<Vec<i32>, ControlPointError> {
|
|
let cm = self.connection_manager()?;
|
|
cm.get_current_connection_ids()
|
|
}
|
|
|
|
pub fn connection_info(&self, connection_id: i32) -> Result<ConnectionInfo, ControlPointError> {
|
|
let cm = self.connection_manager()?;
|
|
cm.get_current_connection_info(connection_id)
|
|
}
|
|
|
|
pub fn set_next_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError> {
|
|
if !self.has_avtransport_set_next {
|
|
return Err(ControlPointError::upnp_operation_not_supported(
|
|
"SetNextAVTransportURI",
|
|
"Renderer",
|
|
));
|
|
}
|
|
let avt = self.avtransport()?;
|
|
avt.set_next_av_transport_uri(uri, meta)
|
|
}
|
|
}
|
|
|
|
impl UpnpRenderer {
|
|
pub fn new(
|
|
avtransport: Option<AvTransportClient>,
|
|
rendering_control: Option<RenderingControlClient>,
|
|
connection_manager: Option<ConnectionManagerClient>,
|
|
has_avtransport_set_next: bool,
|
|
queue: Arc<Mutex<MusicQueue>>,
|
|
) -> Self {
|
|
Self {
|
|
avtransport,
|
|
rendering_control,
|
|
connection_manager,
|
|
has_avtransport_set_next,
|
|
queue,
|
|
cached_duration: Arc::new(Mutex::new(None)),
|
|
continuous_stream: Arc::new(Mutex::new(false)),
|
|
}
|
|
}
|
|
|
|
/// Returns true if currently playing a continuous stream (radio without duration)
|
|
pub fn is_continuous_stream(&self) -> bool {
|
|
*self.continuous_stream.lock().unwrap()
|
|
}
|
|
}
|
|
|
|
impl RendererFromMediaRendererInfo for UpnpRenderer {
|
|
fn from_renderer_info(info: &RendererInfo) -> Result<Self, ControlPointError> {
|
|
// Prepare le service AVTTransport
|
|
let avtransport = match (
|
|
info.avtransport_control_url(),
|
|
info.avtransport_service_type(),
|
|
) {
|
|
(Some(url), Some(service)) => Some(AvTransportClient::new(url, service)),
|
|
_ => None,
|
|
};
|
|
|
|
// Prepare le service RenderingControl
|
|
let rendering_control = match (
|
|
info.rendering_control_control_url(),
|
|
info.rendering_control_service_type(),
|
|
) {
|
|
(Some(url), Some(service)) => Some(RenderingControlClient::new(url, service)),
|
|
_ => None,
|
|
};
|
|
|
|
// Prepare le service ConnectionManager
|
|
let connection_manager = match (
|
|
info.connection_manager_control_url(),
|
|
info.connection_manager_service_type(),
|
|
) {
|
|
(Some(url), Some(service)) => Some(ConnectionManagerClient::new(url, service)),
|
|
_ => None,
|
|
};
|
|
|
|
// Exige AVTTransport et RenderingControl au minimum
|
|
if avtransport.is_none() || rendering_control.is_none() {
|
|
return Err(ControlPointError::UpnpError(format!(
|
|
"Some mandatory services are missing on {:?}",
|
|
info.id(),
|
|
)));
|
|
}
|
|
|
|
// Create the internal queue
|
|
let queue = Arc::new(Mutex::new(MusicQueue::from_renderer_info(info)?));
|
|
|
|
Ok(Self {
|
|
avtransport,
|
|
rendering_control,
|
|
connection_manager,
|
|
has_avtransport_set_next: info.capabilities().has_avtransport_set_next(),
|
|
queue,
|
|
cached_duration: Arc::new(Mutex::new(None)),
|
|
continuous_stream: Arc::new(Mutex::new(false)),
|
|
})
|
|
}
|
|
|
|
fn to_backend(self) -> MusicRendererBackend {
|
|
MusicRendererBackend::Upnp(self)
|
|
}
|
|
}
|
|
|
|
impl RendererBackend for UpnpRenderer {
|
|
fn queue(&self) -> &Arc<Mutex<MusicQueue>> {
|
|
&self.queue
|
|
}
|
|
}
|
|
|
|
impl QueueTransportControl for UpnpRenderer {
|
|
fn play_from_queue(&self) -> Result<(), ControlPointError> {
|
|
let mut queue = self.queue.lock().unwrap();
|
|
|
|
// Get or initialize current index
|
|
let current_index = match queue.current_index()? {
|
|
Some(idx) => idx,
|
|
None => {
|
|
if queue.len()? > 0 {
|
|
queue.set_index(Some(0))?;
|
|
0
|
|
} else {
|
|
return Err(ControlPointError::QueueError("Queue is empty".into()));
|
|
}
|
|
}
|
|
};
|
|
|
|
// Get the item
|
|
let item = queue
|
|
.get_item(current_index)?
|
|
.ok_or_else(|| ControlPointError::QueueError("Current item not found".into()))?;
|
|
|
|
drop(queue);
|
|
|
|
// Build metadata - handle optional TrackMetadata
|
|
let metadata = if let Some(ref track_metadata) = item.metadata {
|
|
build_didl_lite_metadata(track_metadata, &item.uri, &item.protocol_info)
|
|
} else {
|
|
// Fallback to minimal DIDL-Lite if no metadata
|
|
format!(
|
|
r#"<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/"><item id="0" parentID="-1" restricted="1"><res protocolInfo="{}">{}</res></item></DIDL-Lite>"#,
|
|
item.protocol_info, item.uri
|
|
)
|
|
};
|
|
|
|
// Détecte si l'URL est un flux continu en interrogeant le serveur HTTP
|
|
let is_stream = crate::music_renderer::is_continuous_stream_url(&item.uri);
|
|
*self.continuous_stream.lock().unwrap() = is_stream;
|
|
tracing::debug!(
|
|
"UpnpRenderer play_from_queue: URI={}, continuous_stream={}",
|
|
item.uri,
|
|
is_stream
|
|
);
|
|
|
|
// Parse et cache la durée du DIDL (fallback pour certains amplis)
|
|
let duration = parse_didl_duration(&metadata);
|
|
if let Some(ref dur) = duration {
|
|
tracing::debug!("Caching duration from queue DIDL: {}", dur);
|
|
*self.cached_duration.lock().unwrap() = Some(dur.clone());
|
|
} else {
|
|
tracing::debug!("No duration to cache from queue DIDL");
|
|
*self.cached_duration.lock().unwrap() = None;
|
|
}
|
|
|
|
// UPNP: SetAVTransportURI + Play
|
|
let avt = self.avtransport()?;
|
|
avt.set_av_transport_uri(&item.uri, &metadata)?;
|
|
avt.play(0, "1")?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn play_next(&self) -> Result<(), ControlPointError> {
|
|
{
|
|
let mut queue = self.queue.lock().unwrap();
|
|
if !queue.advance()? {
|
|
return Err(ControlPointError::QueueError("No next track".into()));
|
|
}
|
|
}
|
|
|
|
self.play_from_queue()
|
|
}
|
|
|
|
fn play_previous(&self) -> Result<(), ControlPointError> {
|
|
{
|
|
let mut queue = self.queue.lock().unwrap();
|
|
if !queue.rewind()? {
|
|
return Err(ControlPointError::QueueError("No previous track".into()));
|
|
}
|
|
}
|
|
|
|
self.play_from_queue()
|
|
}
|
|
|
|
fn play_from_index(&self, index: usize) -> Result<(), ControlPointError> {
|
|
{
|
|
let mut queue = self.queue.lock().unwrap();
|
|
queue.set_index(Some(index))?;
|
|
}
|
|
|
|
self.play_from_queue()
|
|
}
|
|
}
|
|
|
|
impl QueueBackend for UpnpRenderer {
|
|
fn len(&self) -> Result<usize, ControlPointError> {
|
|
self.queue.lock().unwrap().len()
|
|
}
|
|
|
|
fn track_ids(&self) -> Result<Vec<u32>, ControlPointError> {
|
|
self.queue.lock().unwrap().track_ids()
|
|
}
|
|
|
|
fn id_to_position(&self, id: u32) -> Result<usize, ControlPointError> {
|
|
self.queue.lock().unwrap().id_to_position(id)
|
|
}
|
|
|
|
fn position_to_id(&self, id: usize) -> Result<u32, ControlPointError> {
|
|
self.queue.lock().unwrap().position_to_id(id)
|
|
}
|
|
|
|
fn current_track(&self) -> Result<Option<u32>, ControlPointError> {
|
|
self.queue.lock().unwrap().current_track()
|
|
}
|
|
|
|
fn current_index(&self) -> Result<Option<usize>, ControlPointError> {
|
|
self.queue.lock().unwrap().current_index()
|
|
}
|
|
|
|
fn queue_snapshot(&self) -> Result<QueueSnapshot, ControlPointError> {
|
|
self.queue.lock().unwrap().queue_snapshot()
|
|
}
|
|
|
|
fn set_index(&mut self, index: Option<usize>) -> Result<(), ControlPointError> {
|
|
self.queue.lock().unwrap().set_index(index)
|
|
}
|
|
|
|
fn replace_queue(
|
|
&mut self,
|
|
items: Vec<PlaybackItem>,
|
|
current_index: Option<usize>,
|
|
) -> Result<(), ControlPointError> {
|
|
self.queue
|
|
.lock()
|
|
.unwrap()
|
|
.replace_queue(items, current_index)
|
|
}
|
|
|
|
fn sync_queue(
|
|
&mut self,
|
|
items: Vec<PlaybackItem>,
|
|
_cancel_token: &Arc<AtomicBool>,
|
|
on_ready: Option<Box<dyn FnOnce() + Send>>,
|
|
) -> Result<(), ControlPointError> {
|
|
self.queue
|
|
.lock()
|
|
.unwrap()
|
|
.sync_queue(items, &Arc::new(AtomicBool::new(false)), on_ready)
|
|
}
|
|
|
|
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError> {
|
|
self.queue.lock().unwrap().get_item(index)
|
|
}
|
|
|
|
fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<(), ControlPointError> {
|
|
self.queue.lock().unwrap().replace_item(index, item)
|
|
}
|
|
|
|
fn enqueue_items(
|
|
&mut self,
|
|
items: Vec<PlaybackItem>,
|
|
mode: EnqueueMode,
|
|
) -> Result<(), ControlPointError> {
|
|
self.queue.lock().unwrap().enqueue_items(items, mode)
|
|
}
|
|
}
|
|
|
|
/// Parse le DIDL-Lite pour extraire la durée du premier élément <res>
|
|
fn parse_didl_duration(didl: &str) -> Option<String> {
|
|
// Recherche de l'élément <res> (avec ou sans espace après)
|
|
let res_start = didl
|
|
.find("<res ")
|
|
.or_else(|| didl.find("<res>"))
|
|
.or_else(|| didl.find("<res\n"))
|
|
.or_else(|| didl.find("<res\t"))?;
|
|
|
|
let after_res = &didl[res_start..];
|
|
|
|
// Recherche de l'attribut duration dans cet élément <res>
|
|
// Il doit être avant la fermeture du tag (avant '>')
|
|
if let Some(tag_close) = after_res.find('>') {
|
|
let tag_attrs = &after_res[..tag_close];
|
|
|
|
if let Some(duration_start) = tag_attrs.find("duration=\"") {
|
|
let duration_offset = duration_start + "duration=\"".len();
|
|
if let Some(duration_end) = tag_attrs[duration_offset..].find('"') {
|
|
let duration = &tag_attrs[duration_offset..duration_offset + duration_end];
|
|
return Some(duration.to_string());
|
|
}
|
|
}
|
|
}
|
|
|
|
tracing::warn!("No duration attribute found in DIDL <res> element");
|
|
None
|
|
}
|
|
|
|
/// Implémentation UPnP AV de `TransportControl` pour [`UpnpRenderer`].
|
|
///
|
|
/// Cette impl se base sur AVTransport (InstanceID = 0).
|
|
impl TransportControl for UpnpRenderer {
|
|
fn play_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError> {
|
|
// Log du DIDL complet pour déboguer
|
|
if !meta.is_empty() {
|
|
tracing::debug!(
|
|
"play_uri DIDL-Lite metadata: {}",
|
|
&meta[..meta.len().min(500)]
|
|
);
|
|
}
|
|
|
|
// Détecte si l'URL est un flux continu en interrogeant le serveur HTTP
|
|
let is_stream = crate::music_renderer::is_continuous_stream_url(uri);
|
|
*self.continuous_stream.lock().unwrap() = is_stream;
|
|
tracing::debug!(
|
|
"UpnpRenderer play_uri: URI={}, continuous_stream={}",
|
|
uri,
|
|
is_stream
|
|
);
|
|
|
|
// Parse le DIDL pour extraire la durée (fallback pour certains amplis)
|
|
let duration = parse_didl_duration(meta);
|
|
if let Some(ref dur) = duration {
|
|
tracing::debug!("Caching duration from DIDL: {}", dur);
|
|
*self.cached_duration.lock().unwrap() = Some(dur.clone());
|
|
} else {
|
|
tracing::debug!("No duration to cache from DIDL");
|
|
*self.cached_duration.lock().unwrap() = None;
|
|
}
|
|
|
|
let avt = self.avtransport()?;
|
|
avt.set_av_transport_uri(uri, meta)?;
|
|
avt.play(0, "1")
|
|
}
|
|
|
|
fn play(&self) -> Result<(), ControlPointError> {
|
|
let avt = self.avtransport()?;
|
|
avt.play(0, "1")
|
|
}
|
|
|
|
fn pause(&self) -> Result<(), ControlPointError> {
|
|
let avt = self.avtransport()?;
|
|
avt.pause(0)
|
|
}
|
|
|
|
fn stop(&self) -> Result<(), ControlPointError> {
|
|
let avt = self.avtransport()?;
|
|
avt.stop(0)
|
|
}
|
|
|
|
fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> {
|
|
let avt = self.avtransport()?;
|
|
avt.seek(0, "REL_TIME", hhmmss)
|
|
}
|
|
}
|
|
|
|
/// Implémentation UPnP RenderingControl de `VolumeControl` pour [`UpnpRenderer`].
|
|
///
|
|
/// Cette impl se base sur le channel "Master" (InstanceID = 0).
|
|
impl VolumeControl for UpnpRenderer {
|
|
fn volume(&self) -> Result<u16, ControlPointError> {
|
|
let rc = self.rendering_control()?;
|
|
rc.get_volume(0, "Master")
|
|
}
|
|
|
|
fn set_volume(&self, v: u16) -> Result<(), ControlPointError> {
|
|
let rc = self.rendering_control()?;
|
|
rc.set_volume(0, "Master", v)
|
|
}
|
|
|
|
fn mute(&self) -> Result<bool, ControlPointError> {
|
|
let rc = self.rendering_control()?;
|
|
rc.get_mute(0, "Master")
|
|
}
|
|
|
|
fn set_mute(&self, m: bool) -> Result<(), ControlPointError> {
|
|
let rc = self.rendering_control()?;
|
|
rc.set_mute(0, "Master", m)
|
|
}
|
|
}
|
|
|
|
/// Implémentation UPnP AV de `PlaybackStatus` pour [`UpnpRenderer`].
|
|
///
|
|
/// Utilise AVTransport::GetTransportInfo(InstanceID=0).
|
|
impl PlaybackStatus for UpnpRenderer {
|
|
fn playback_state(&self) -> Result<PlaybackState, ControlPointError> {
|
|
let avt = self.avtransport()?;
|
|
let info = avt.get_transport_info(0)?;
|
|
Ok(PlaybackState::from_upnp_state(
|
|
&info.current_transport_state,
|
|
))
|
|
}
|
|
}
|
|
|
|
impl PlaybackPosition for UpnpRenderer {
|
|
fn playback_position(&self) -> Result<PlaybackPositionInfo, ControlPointError> {
|
|
let avt = self.avtransport()?;
|
|
let raw: PositionInfo = avt.get_position_info(0)?;
|
|
|
|
tracing::trace!(
|
|
"UPnP GetPositionInfo returned: track_duration={:?}, rel_time={:?}",
|
|
raw.track_duration,
|
|
raw.rel_time
|
|
);
|
|
|
|
// Normalize "00:00:00" or "0:00:00" to None (some renderers return this for unknown duration)
|
|
let normalized_duration = raw.track_duration.as_ref().and_then(|d| {
|
|
if d == "00:00:00" || d == "0:00:00" {
|
|
None
|
|
} else {
|
|
Some(d.clone())
|
|
}
|
|
});
|
|
|
|
let track_duration = normalized_duration;
|
|
|
|
// Récupérer les métadonnées depuis la queue (avec protection contre diminution de durée)
|
|
// plutôt que depuis GetPositionInfo qui peut retourner des métadonnées obsolètes
|
|
let mut track_metadata_xml = None;
|
|
let mut track_uri = raw.track_uri.clone();
|
|
|
|
let mut queue_guard = self.queue.lock().unwrap();
|
|
|
|
// Récupérer l'item courant de la queue
|
|
// Normalement current_index est toujours Some() si la queue n'est pas vide (règle métier)
|
|
let queue_item = queue_guard.peek_current().ok().flatten();
|
|
|
|
if let Some((current_item, _)) = queue_item {
|
|
track_uri = Some(current_item.uri.clone());
|
|
|
|
// Build DIDL metadata XML from cached/protected TrackMetadata
|
|
if let Some(ref metadata) = current_item.metadata {
|
|
track_metadata_xml = Some(
|
|
crate::music_renderer::musicrenderer::build_didl_lite_metadata(
|
|
metadata,
|
|
¤t_item.uri,
|
|
¤t_item.protocol_info,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
drop(queue_guard);
|
|
|
|
tracing::trace!(
|
|
"UPnP playback_position: track_duration={:?}, rel_time={:?}, using_queue_metadata={}",
|
|
track_duration,
|
|
raw.rel_time,
|
|
track_metadata_xml.is_some()
|
|
);
|
|
|
|
Ok(PlaybackPositionInfo {
|
|
track: Some(raw.track),
|
|
rel_time: raw.rel_time,
|
|
abs_time: raw.abs_time,
|
|
track_duration,
|
|
track_metadata: track_metadata_xml,
|
|
track_uri,
|
|
})
|
|
}
|
|
}
|