Files
pmomusic/pmocontrol/src/control_point.rs

1996 lines
76 KiB
Rust
Raw Normal View History

use std::io;
use std::sync::{Arc, Mutex, RwLock};
use std::thread;
use std::time::Duration;
use anyhow::anyhow;
use crossbeam_channel::Receiver;
use pmodidl::{DIDLLite, Item as DidlItem, Resource as DidlResource};
use pmoupnp::ssdp::SsdpClient;
use quick_xml::se::to_string as to_didl_string;
use tracing::{debug, error, info, warn};
use crate::discovery::manager::UDNRegistry;
use crate::errors::ControlPointError;
use crate::events::{MediaServerEventBus, RendererEventBus};
use crate::media_server::{MediaBrowser, MusicServer, playback_item_from_entry};
use crate::media_server_events::spawn_media_server_event_runtime;
use crate::model::{MediaServerEvent, RendererEvent};
use crate::model::{PlaybackState, TrackMetadata};
use crate::music_renderer::{MusicRenderer, PlaybackPositionInfo, PlaylistBinding};
use crate::{DeviceId, DeviceIdentity, DeviceOnline, PlaybackSource};
2025-12-06 00:38:06 +01:00
#[cfg(feature = "pmoserver")]
use crate::openapi::{
CurrentTrackMetadata, FullRendererSnapshot, QueueItem, QueueSnapshotView, RendererBindingView,
RendererStateView,
};
use crate::queue::{EnqueueMode, PlaybackItem, QueueBackend, QueueSnapshot};
use crate::registry::DeviceRegistry;
2025-12-05 21:59:43 +01:00
/// Control point minimal :
/// - lance un SsdpClient dans un thread,
/// - passe les SsdpEvent au DiscoveryManager,
/// - applique les DeviceUpdate dans le DeviceRegistry.
2025-12-06 00:38:06 +01:00
///
/// Le runtime est **l'unique source de vérité** pour l'état des renderers :
/// les clients doivent toujours consommer des snapshots consolidés côté serveur
/// et n'utiliser les événements SSE que comme signaux de rafraîchissement.
pub struct ControlPoint {
registry: Arc<RwLock<DeviceRegistry>>,
// udn_cache: Arc<Mutex<UDNRegistry>>,
2025-11-30 10:54:01 +01:00
event_bus: RendererEventBus,
media_event_bus: MediaServerEventBus,
}
impl ControlPoint {
/// Crée un ControlPoint et lance le thread de découverte SSDP.
///
/// `timeout_secs` : timeout HTTP pour la récupération des descriptions UPnP.
pub fn spawn(timeout_secs: u64) -> io::Result<Self> {
2025-11-30 10:54:01 +01:00
let event_bus = RendererEventBus::new();
let udn_cache = Arc::new(Mutex::new(UDNRegistry::new()));
let media_event_bus = MediaServerEventBus::new();
let registry = Arc::new(RwLock::new(DeviceRegistry::new(
&event_bus,
&media_event_bus,
)));
// SsdpClient
let client = SsdpClient::new()?; // pmoupnp::ssdp::SsdpClient
2025-12-26 19:44:19 +01:00
// Clone pour le thread de renouvellement périodique
let client_for_renewal = client.clone();
// Arc utilisé dans le thread
let registry_for_thread = Arc::clone(&registry);
let udn_cache_for_thread = Arc::clone(&udn_cache);
// Thread de découverte
thread::spawn(move || {
use crate::discovery::UpnpDiscoveryManager;
// Créer le gestionnaire de découverte UPNP
let mut discovery =
UpnpDiscoveryManager::new(registry_for_thread, udn_cache_for_thread);
// ACTIVE DISCOVERY : envoyer quelques M-SEARCH au démarrage
// pour forcer les devices à répondre rapidement.
let search_targets = [
"ssdp:all",
"urn:schemas-upnp-org:device:MediaRenderer:1",
"urn:av-openhome-org:device:MediaRenderer:1",
"urn:schemas-upnp-org:device:MediaServer:1",
"urn:schemas-wiimu-com:service:PlayQueue:1", // <-- AJOUTER
];
for st in &search_targets {
if let Err(e) = client.send_msearch(st, 3) {
eprintln!("Failed to send M-SEARCH for {}: {}", st, e);
}
std::thread::sleep(Duration::from_millis(200));
}
// La closure passe les événements SSDP au gestionnaire de découverte
// Le registry émet automatiquement les événements Online/Offline
client.run_event_loop(move |event| {
discovery.handle_ssdp_event(event);
});
});
2025-12-26 19:44:19 +01:00
// Thread de renouvellement périodique des M-SEARCH
// Envoie des requêtes de découverte toutes les 60 secondes pour forcer
// les nouveaux appareils à se présenter
thread::spawn(move || {
let search_targets = [
"ssdp:all",
"urn:schemas-upnp-org:device:MediaRenderer:1",
"urn:av-openhome-org:device:MediaRenderer:1",
"urn:schemas-upnp-org:device:MediaServer:1",
"urn:schemas-wiimu-com:service:PlayQueue:1",
];
loop {
// Attendre 60 secondes avant le prochain cycle
thread::sleep(Duration::from_secs(60));
debug!("Sending periodic M-SEARCH for device discovery");
// Envoyer les M-SEARCH
for st in &search_targets {
if let Err(e) = client_for_renewal.send_msearch(st, 3) {
warn!("Failed to send periodic M-SEARCH for {}: {}", st, e);
}
thread::sleep(Duration::from_millis(200));
}
}
});
// Thread de vérification de présence périodique
// Vérifie toutes les 60 secondes les timeouts des devices
let registry_for_timeout = Arc::clone(&registry);
2025-12-26 19:44:19 +01:00
thread::spawn(move || {
2025-12-26 19:44:19 +01:00
loop {
thread::sleep(Duration::from_secs(60));
// Le registry vérifie les timeouts et émet automatiquement les événements Offline
if let Ok(mut reg) = registry_for_timeout.write() {
reg.check_timeouts();
2025-12-26 19:44:19 +01:00
}
}
});
2025-12-21 21:24:55 +01:00
// Thread de découverte mDNS pour Chromecast
let registry_for_mdns = Arc::clone(&registry);
let udn_cache_for_mdns = Arc::clone(&udn_cache);
2025-12-21 21:24:55 +01:00
thread::spawn(move || {
use crate::discovery::ChromecastDiscoveryManager;
2025-12-21 21:24:55 +01:00
use futures_util::StreamExt;
// Créer le gestionnaire de découverte UPNP
let mut discovery_manager =
ChromecastDiscoveryManager::new(registry_for_mdns, udn_cache_for_mdns);
2025-12-21 21:24:55 +01:00
debug!("Starting mDNS discovery thread for Chromecast devices");
const SERVICE_NAME: &str = "_googlecast._tcp.local";
// Run async discovery in a blocking task
async_std::task::block_on(async {
2025-12-22 10:32:48 +01:00
// Create mDNS discovery stream with 15 second query interval
// (shorter interval for faster initial discovery)
match mdns::discover::all(SERVICE_NAME, Duration::from_secs(15)) {
2025-12-21 21:24:55 +01:00
Ok(discovery) => {
let stream = discovery.listen();
futures_util::pin_mut!(stream);
debug!("mDNS discovery stream started for Chromecast devices");
// Listen to mDNS responses
while let Some(result) = stream.next().await {
match result {
Ok(response) => {
debug!(
"Received mDNS response with {} records",
response.records().count()
);
2025-12-21 21:24:55 +01:00
discovery_manager.handle_mdns_response(response);
2025-12-21 21:24:55 +01:00
}
Err(e) => {
warn!("mDNS discovery error: {}", e);
}
}
}
warn!("mDNS discovery stream ended unexpectedly");
}
Err(e) => {
error!("Failed to start mDNS discovery: {}", e);
}
}
});
});
let polling_cp = ControlPoint {
2025-11-30 10:54:01 +01:00
registry: Arc::clone(&registry),
// udn_cache: udn_cache.clone(),
2025-11-30 10:54:01 +01:00
event_bus: event_bus.clone(),
media_event_bus: media_event_bus.clone(),
2025-11-30 10:54:01 +01:00
};
thread::spawn(move || {
use std::collections::HashMap;
// Local cache for change detection (not a source of truth)
let mut polling_cache: HashMap<DeviceId, RendererRuntimeSnapshot> = HashMap::new();
let mut tick: u32 = 0;
2025-12-22 10:32:48 +01:00
2025-11-30 10:54:01 +01:00
loop {
// Get renderers directly from registry - they already contain backends
let renderers = {
let reg = polling_cp.registry.read().unwrap();
reg.list_renderers().unwrap_or_else(|_| vec![])
2025-11-30 10:54:01 +01:00
};
2025-12-22 10:32:48 +01:00
2025-11-30 10:54:01 +01:00
for renderer in renderers {
if !renderer.is_online() {
2025-11-30 10:54:01 +01:00
continue;
}
let renderer_id = renderer.id();
// Get previous snapshot from local cache
let prev_snapshot =
polling_cache.get(&renderer_id).cloned().unwrap_or_default();
let mut new_snapshot = prev_snapshot.clone();
let prev_position = prev_snapshot.position.clone();
2025-11-30 10:54:01 +01:00
// Poll position every tick (1s) for smooth UI progress
2025-11-30 10:54:01 +01:00
if let Ok(position) = renderer.playback_position() {
let has_changed = match prev_snapshot.position.as_ref() {
2025-11-30 10:54:01 +01:00
Some(prev) => !playback_position_equal(prev, &position),
None => true,
};
if has_changed {
polling_cp.emit_renderer_event(RendererEvent::PositionChanged {
2025-11-30 10:54:01 +01:00
id: renderer_id.clone(),
position: position.clone(),
});
}
// Extract and emit metadata changes
2025-12-05 07:08:03 +01:00
match extract_track_metadata(&position) {
Some(metadata) => {
let metadata_changed = match prev_snapshot.last_metadata.as_ref() {
Some(prev) => prev != &metadata,
None => true,
};
if metadata_changed {
debug!(
renderer = renderer_id.0.as_str(),
title = metadata.title.as_deref(),
artist = metadata.artist.as_deref(),
"Emitting metadata changed event"
);
polling_cp.emit_renderer_event(
2025-12-05 21:59:43 +01:00
RendererEvent::MetadataChanged {
id: renderer_id.clone(),
metadata: metadata.clone(),
},
);
2025-12-05 07:08:03 +01:00
new_snapshot.last_metadata = Some(metadata);
}
}
None => {
debug!(
renderer = renderer_id.0.as_str(),
has_track_metadata = position.track_metadata.is_some(),
"No metadata extracted from position info"
);
}
}
new_snapshot.position = Some(position);
2025-11-30 10:54:01 +01:00
}
// Poll state every tick to ensure responsive playback control
2025-11-30 10:54:01 +01:00
if let Ok(raw_state) = renderer.playback_state() {
let logical_state = compute_logical_playback_state(
&raw_state,
prev_position.as_ref(),
new_snapshot.position.as_ref(),
2025-11-30 10:54:01 +01:00
);
let has_changed = match prev_snapshot.state.as_ref() {
2025-11-30 10:54:01 +01:00
Some(prev) => !playback_state_equal(prev, &logical_state),
None => true,
};
// Emit event only for non-transient states to reduce noise
// and avoid overwhelming the renderer during track changes
if has_changed && !matches!(logical_state, PlaybackState::Transitioning) {
polling_cp.emit_renderer_event(RendererEvent::StateChanged {
2025-11-30 10:54:01 +01:00
id: renderer_id.clone(),
state: logical_state.clone(),
});
}
new_snapshot.state = Some(logical_state);
2025-11-30 10:54:01 +01:00
}
2025-12-01 19:06:07 +01:00
// Poll volume and mute less frequently (every 3 seconds)
// to reduce SOAP overhead without impacting UI responsiveness
if tick % 3 == 0 {
if let Ok(volume) = renderer.volume() {
if prev_snapshot.last_volume != Some(volume) {
polling_cp.emit_renderer_event(RendererEvent::VolumeChanged {
id: renderer_id.clone(),
volume,
});
}
new_snapshot.last_volume = Some(volume);
2025-12-01 19:06:07 +01:00
}
if let Ok(mute) = renderer.mute() {
if prev_snapshot.last_mute != Some(mute) {
polling_cp.emit_renderer_event(RendererEvent::MuteChanged {
id: renderer_id.clone(),
mute,
});
}
2025-12-01 19:06:07 +01:00
new_snapshot.last_mute = Some(mute);
2025-12-01 19:06:07 +01:00
}
}
// Update local cache
polling_cache.insert(renderer_id, new_snapshot);
2025-11-30 10:54:01 +01:00
}
tick = tick.wrapping_add(1);
// Keep 1 second polling for smooth position updates
2025-11-30 10:54:01 +01:00
thread::sleep(Duration::from_secs(1));
}
});
spawn_media_server_event_runtime(
Arc::clone(&registry),
media_event_bus.clone(),
timeout_secs,
)?;
// Worker thread to process MediaServerEvent and trigger queue refreshes
// for renderers bound to updated playlist containers
let registry_for_media_worker = Arc::clone(&registry);
let event_bus_for_media_worker = event_bus.clone();
let media_rx = media_event_bus.subscribe();
thread::Builder::new()
.name("cp-media-server-event-worker".into())
.spawn(move || {
loop {
let event = match media_rx.recv() {
Ok(e) => e,
Err(_) => {
warn!("MediaServerEvent channel closed, worker exiting");
break;
}
};
match event {
MediaServerEvent::GlobalUpdated {
server_id,
system_update_id,
} => {
info!(
2025-12-17 21:41:24 +01:00
server = server_id.0.as_str(),
system_update_id = system_update_id,
"MediaServer global update"
2025-12-17 21:41:24 +01:00
);
}
MediaServerEvent::ContainersUpdated {
server_id,
container_ids,
} => {
// Find all renderers bound to the updated containers
let renderers_to_refresh: Vec<(DeviceId, Arc<MusicRenderer>)> = {
let reg = registry_for_media_worker.read().unwrap();
match reg.list_renderers() {
Ok(renderers) => renderers
.into_iter()
.filter_map(|renderer| {
// Mark binding for refresh if it matches
if renderer.mark_binding_for_refresh(&server_id, &container_ids) {
Some((renderer.id(), renderer))
} else {
None
}
})
.collect(),
Err(e) => {
warn!(error = %e, "Failed to list renderers for container update");
Vec::new()
}
}
};
2025-12-17 21:41:24 +01:00
// Trigger refresh for each affected renderer (outside of registry lock)
for (renderer_id, _renderer) in renderers_to_refresh {
debug!(
renderer = renderer_id.0.as_str(),
server = server_id.0.as_str(),
"Triggering queue refresh for bound playlist"
);
if let Err(err) = refresh_attached_queue_for(
&registry_for_media_worker,
&renderer_id,
&event_bus_for_media_worker,
None,
) {
warn!(
renderer = renderer_id.0.as_str(),
server = server_id.0.as_str(),
error = %err,
"Failed to refresh queue from playlist container"
);
}
}
}
MediaServerEvent::Online { server_id, info } => {
debug!(
server = server_id.0.as_str(),
friendly_name = info.friendly_name.as_str(),
"MediaServer came online"
);
}
MediaServerEvent::Offline { server_id } => {
debug!(server = server_id.0.as_str(), "MediaServer went offline");
}
2025-12-26 19:44:19 +01:00
}
}
})?;
// Periodic refresh worker for bound playlists
// Every 60 seconds, trigger a refresh for all renderers with active bindings
let registry_for_periodic = Arc::clone(&registry);
let event_bus_for_periodic = event_bus.clone();
thread::Builder::new()
.name("cp-playlist-periodic-refresh".into())
.spawn(move || {
loop {
// Sleep for 60 seconds between refresh cycles
thread::sleep(Duration::from_secs(60));
// Collect all renderers with active bindings and mark them for refresh
let renderers_to_refresh: Vec<DeviceId> = {
let reg = registry_for_periodic.read().unwrap();
match reg.list_renderers() {
Ok(renderers) => renderers
.into_iter()
.filter_map(|renderer| {
// Mark binding for refresh if it exists
if renderer.mark_pending_refresh() {
Some(renderer.id())
} else {
None
}
})
.collect(),
Err(e) => {
warn!(error = %e, "Failed to list renderers for periodic refresh");
Vec::new()
}
}
};
// Trigger refresh for each bound renderer (outside of lock)
for renderer_id in renderers_to_refresh {
debug!(
renderer = renderer_id.0.as_str(),
"Periodic refresh triggered for bound playlist"
);
if let Err(err) = refresh_attached_queue_for(
&registry_for_periodic,
&renderer_id,
&event_bus_for_periodic,
2025-12-03 23:17:41 +01:00
None,
) {
warn!(
renderer = renderer_id.0.as_str(),
error = %err,
"Periodic refresh failed for bound playlist"
);
}
}
}
})?;
// Thread de surveillance des sleep timers
// Vérifie toutes les secondes les timers actifs et émet des événements
let registry_for_timer = Arc::clone(&registry);
let event_bus_for_timer = event_bus.clone();
thread::spawn(move || {
use std::collections::HashMap;
// Track last emitted tick for each renderer to avoid spamming events
let mut last_tick: HashMap<DeviceId, u32> = HashMap::new();
loop {
thread::sleep(Duration::from_secs(1));
// Get all renderers with active timers
let renderers = {
let reg = registry_for_timer.read().unwrap();
match reg.list_renderers() {
Ok(renderers) => renderers,
Err(err) => {
warn!(error = %err, "Failed to list renderers in timer watchdog");
continue;
}
}
};
for renderer in &renderers {
// Skip renderers without active timers
if !renderer.is_sleep_timer_active() {
continue;
}
let renderer_id = renderer.id();
let (is_active, duration, remaining) = renderer.sleep_timer_state();
if !is_active {
continue;
}
let remaining_seconds = remaining.unwrap_or(0);
// Check if timer has expired
if renderer.is_sleep_timer_expired() {
debug!(
renderer = renderer_id.0.as_str(),
"Sleep timer expired, stopping playback"
);
// Stop playback
if let Err(err) = renderer.stop() {
warn!(
renderer = renderer_id.0.as_str(),
error = %err,
"Failed to stop renderer when timer expired"
);
}
// Cancel the timer
renderer.cancel_sleep_timer();
// Emit TimerExpired event
event_bus_for_timer.broadcast(RendererEvent::TimerExpired {
id: renderer_id.clone(),
});
// Remove from tick tracking
last_tick.remove(&renderer_id);
} else {
// Emit tick event every second
let should_emit_tick = last_tick
.get(&renderer_id)
.map(|&last| remaining_seconds != last)
.unwrap_or(true);
if should_emit_tick {
event_bus_for_timer.broadcast(RendererEvent::TimerTick {
id: renderer_id.clone(),
remaining_seconds,
});
last_tick.insert(renderer_id, remaining_seconds);
}
}
}
}
});
2025-11-30 10:54:01 +01:00
Ok(Self {
registry,
// udn_cache,
2025-11-30 10:54:01 +01:00
event_bus,
media_event_bus,
2025-11-30 10:54:01 +01:00
})
}
/// Accès au DeviceRegistry partagé.
pub fn registry(&self) -> Arc<RwLock<DeviceRegistry>> {
Arc::clone(&self.registry)
}
2025-11-30 09:26:33 +01:00
2025-11-30 10:54:01 +01:00
/// Snapshot list of music renderers (protocol-agnostic view).
pub fn list_music_renderers(&self) -> Vec<Arc<MusicRenderer>> {
let reg = self.registry.read().unwrap();
reg.list_renderers().unwrap_or_else(|_| vec![])
2025-11-30 10:54:01 +01:00
}
/// Return the first music renderer in the registry, if any.
pub fn default_music_renderer(&self) -> Option<Arc<MusicRenderer>> {
let reg = self.registry.read().unwrap();
reg.list_renderers().ok()?.into_iter().next()
2025-11-30 10:54:01 +01:00
}
/// Lookup a music renderer by id.
pub fn music_renderer_by_id(&self, id: &DeviceId) -> Option<Arc<MusicRenderer>> {
let reg = self.registry.read().unwrap();
reg.get_renderer(id)
2025-11-30 10:54:01 +01:00
}
/// Snapshot list of media servers currently known by the registry.
pub fn list_media_servers(&self) -> Result<Vec<Arc<MusicServer>>, ControlPointError> {
let reg = self.registry.read().unwrap();
reg.list_servers()
}
/// Lookup a media server by id.
pub fn media_server(&self, id: &DeviceId) -> Option<Arc<MusicServer>> {
let reg = self.registry.read().unwrap();
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 `MusicRenderer::get_queue_mut()`).
pub fn clear_queue(&self, renderer_id: &DeviceId) -> Result<(), ControlPointError> {
// User-driven mutation: detach any playlist binding
2025-12-06 00:38:06 +01:00
self.detach_playlist_binding(renderer_id, "clear_queue");
// Clear the queue on the backend (backend-agnostic)
let renderer = self.music_renderer_by_id(renderer_id).ok_or_else(|| {
ControlPointError::SnapshotError(format!("Renderer {} not found", renderer_id.0))
})?;
// Get queue length before clearing
let removed = renderer.upcoming_len().unwrap_or(0);
renderer.clear_queue()?;
// Sync backend state to local cache
renderer.sync_queue_state()?;
2025-12-05 19:01:33 +01:00
debug!(
renderer = renderer_id.0.as_str(),
items_removed = removed,
queue_len = 0,
"Cleared playback queue"
);
// Emit QueueUpdated event
self.emit_renderer_event(RendererEvent::QueueUpdated {
id: renderer_id.clone(),
queue_length: 0,
});
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`
/// accessed via `MusicRenderer::get_queue_mut()`).
pub fn enqueue_items(
&self,
renderer_id: &DeviceId,
items: Vec<PlaybackItem>,
) -> Result<(), ControlPointError> {
self.enqueue_items_with_mode(renderer_id, items, EnqueueMode::AppendToEnd)
}
/// Enqueue items to a renderer's queue with a specific enqueue mode.
///
/// This is the low-level version that allows specifying the enqueue mode.
/// User-driven operations should detach any playlist binding.
pub fn enqueue_items_with_mode(
&self,
renderer_id: &DeviceId,
items: Vec<PlaybackItem>,
mode: EnqueueMode,
) -> Result<(), ControlPointError> {
// User-driven mutation: detach any playlist binding
2025-12-06 00:38:06 +01:00
self.detach_playlist_binding(renderer_id, "enqueue_items");
// Enqueue items using QueueBackend abstraction (works for both backends)
let item_count = items.len();
let renderer = self.music_renderer_by_id(renderer_id).ok_or_else(|| {
ControlPointError::SnapshotError(format!("Renderer {} not found", renderer_id.0))
})?;
renderer.enqueue_items(items, mode)?;
let new_len = renderer.upcoming_len()?;
debug!(
renderer = renderer_id.0.as_str(),
added = item_count,
queue_len = new_len,
mode = ?mode,
"Enqueued playback items"
);
// Emit QueueUpdated event
self.emit_renderer_event(RendererEvent::QueueUpdated {
id: renderer_id.clone(),
queue_length: new_len,
});
Ok(())
}
/// Read-only snapshot of the queue items and current index for a renderer.
///
/// Returns both the queue items and the current playing index.
/// This is the authoritative queue view for UI/REST layers.
pub fn get_full_queue_snapshot(
&self,
renderer_id: &DeviceId,
) -> Result<(Vec<PlaybackItem>, Option<usize>), ControlPointError> {
let renderer = self.music_renderer_by_id(renderer_id).ok_or_else(|| {
ControlPointError::SnapshotError(format!("Renderer {} not found", renderer_id.0))
})?;
let snapshot = renderer.queue_snapshot()?;
Ok((snapshot.items, snapshot.current_index))
}
/// 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: &DeviceId) -> Option<TrackMetadata> {
self.music_renderer_by_id(renderer_id)
.and_then(|r| r.last_metadata())
}
/// Gets the backend queue snapshot for renderers with persistent queues.
///
/// Returns the queue snapshot if the renderer has a backend queue,
/// or None if it doesn't.
pub fn get_renderer_queue_snapshot(
&self,
renderer_id: &DeviceId,
) -> Result<QueueSnapshot, ControlPointError> {
let renderer = self.music_renderer_by_id(renderer_id).ok_or_else(|| {
ControlPointError::SnapshotError(format!("Renderer {} not found", renderer_id.0))
})?;
renderer.queue_snapshot()
}
/// Gets the length of the backend queue for renderers with persistent queues.
///
/// Returns the queue length.
pub fn get_renderer_queue_length(
&self,
renderer_id: &DeviceId,
) -> Result<usize, ControlPointError> {
Ok(self.get_renderer_queue_snapshot(renderer_id)?.len())
}
2025-12-06 00:38:06 +01:00
/// Build a fully consistent snapshot for UI consumers (state + queue + binding).
#[cfg(feature = "pmoserver")]
pub fn renderer_full_snapshot(
&self,
renderer_id: &DeviceId,
2025-12-06 00:38:06 +01:00
) -> anyhow::Result<FullRendererSnapshot> {
let renderer = self
.music_renderer_by_id(renderer_id)
.ok_or_else(|| anyhow!("Renderer {} not found", renderer_id.0))?;
let info = renderer.info();
// Query current state directly from renderer
let current_state = renderer.playback_state().ok();
let current_position = renderer.playback_position().ok();
let current_volume = renderer.volume().ok();
let current_mute = renderer.mute().ok();
let last_metadata = renderer.last_metadata();
2025-12-17 21:41:24 +01:00
// Get queue from renderer (works for all backends)
let queue_snapshot = renderer
.queue_snapshot()
.map_err(|e| anyhow!("Failed to get queue snapshot: {}", e))?;
let queue_items = queue_snapshot.items;
let mut queue_current_index = queue_snapshot.current_index;
2025-12-17 21:41:24 +01:00
let playback_source = renderer.playback_source();
2025-12-06 00:38:06 +01:00
let queue_len = queue_items.len();
// Try heuristics to determine current_index if not set
if queue_current_index.is_none() {
if let Some(position) = current_position.as_ref() {
2025-12-06 00:38:06 +01:00
if let Some(uri) = position.track_uri.as_ref() {
if let Some(idx) = queue_items.iter().position(|item| item.uri == *uri) {
queue_current_index = Some(idx);
}
} else if let Some(track_no) = position.track {
let zero_based = track_no.saturating_sub(1) as usize;
if zero_based < queue_items.len() {
queue_current_index = Some(zero_based);
}
}
}
// Final fallback: if playing from queue and no index, assume first track
if queue_current_index.is_none()
&& matches!(playback_source, PlaybackSource::FromQueue)
&& current_state
.as_ref()
.map(|state| matches!(state, PlaybackState::Playing | PlaybackState::Paused))
.unwrap_or(false)
&& !queue_items.is_empty()
{
queue_current_index = Some(0);
}
2025-12-06 00:38:06 +01:00
}
let queue_view_items: Vec<QueueItem> = queue_items
.iter()
.enumerate()
.map(|(index, item)| QueueItem {
index,
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()),
server_id: Some(item.media_server_id.0.clone()),
object_id: Some(item.didl_id.clone()),
2025-12-06 00:38:06 +01:00
})
.collect();
let queue_view = QueueSnapshotView {
renderer_id: renderer_id.0.clone(),
items: queue_view_items,
current_index: queue_current_index,
};
let binding = self.current_queue_playlist_binding(renderer_id).map(
|(server_id, container_id, has_seen_update)| RendererBindingView {
server_id: server_id.0,
container_id,
has_seen_update,
},
);
let (position_ms, duration_ms) = convert_runtime_position(current_position.as_ref());
2025-12-06 00:38:06 +01:00
let queue_current_metadata = queue_current_index
.and_then(|idx| queue_items.get(idx))
.map(current_track_from_playback_item);
// Prefer queue metadata, fallback to cached metadata
let current_track = queue_current_metadata.or_else(|| {
last_metadata.as_ref().map(|meta| CurrentTrackMetadata {
title: meta.title.clone(),
artist: meta.artist.clone(),
album: meta.album.clone(),
album_art_uri: meta.album_art_uri.clone(),
})
});
2025-12-06 00:38:06 +01:00
let state_view = RendererStateView {
id: renderer_id.0.clone(),
friendly_name: info.friendly_name().to_string(),
transport_state: current_state
2025-12-06 00:38:06 +01:00
.as_ref()
.map(|state| state.as_str().to_string())
2025-12-06 00:38:06 +01:00
.unwrap_or_else(|| "UNKNOWN".to_string()),
position_ms,
duration_ms,
volume: current_volume.and_then(|value| u8::try_from(value).ok()),
mute: current_mute,
2025-12-06 00:38:06 +01:00
queue_len,
attached_playlist: binding.clone(),
current_track,
};
Ok(FullRendererSnapshot {
state: state_view,
queue: queue_view,
binding,
})
}
/// Clears the renderer's queue.
///
/// Works for both internal queues and persistent backend queues (OpenHome).
pub fn clear_renderer_queue(&self, renderer_id: &DeviceId) -> Result<(), ControlPointError> {
let renderer = self.music_renderer_by_id(renderer_id).ok_or_else(|| {
ControlPointError::SnapshotError(format!("Renderer {} not found", renderer_id.0))
})?;
renderer.clear_queue()
}
/// Adds a track to the renderer's backend queue.
///
/// For renderers with persistent queues, this adds the track to the queue.
/// For other renderers, this returns an error.
///
/// Returns the backend-specific track ID if applicable.
pub fn add_track_to_renderer(
&self,
renderer_id: &DeviceId,
uri: &str,
metadata: &str,
after_id: Option<u32>,
play: bool,
) -> anyhow::Result<Option<u32>> {
let renderer = self
.music_renderer_by_id(renderer_id)
.ok_or_else(|| anyhow!("Renderer {} not found", renderer_id.0))?;
let track_id = renderer.add_track_to_queue(uri, metadata, after_id, play)?;
renderer.sync_queue_state()?;
Ok(track_id)
}
/// Selects and plays a specific track from the renderer's backend queue.
///
/// For renderers with persistent queues, this uses the track ID.
/// For other renderers, this returns an error.
pub fn select_renderer_track(
&self,
renderer_id: &DeviceId,
track_id: u32,
) -> Result<(), ControlPointError> {
let renderer = self.music_renderer_by_id(renderer_id).ok_or_else(|| {
ControlPointError::ControlPoint(format!("Renderer {} not found", renderer_id.0))
})?;
renderer.select_queue_track(track_id)?;
renderer.set_playback_source(PlaybackSource::FromQueue);
renderer.sync_queue_state()
}
/// Plays the current queue item without advancing the index.
2025-12-05 07:08:03 +01:00
///
/// 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: &DeviceId) -> Result<(), ControlPointError> {
let renderer = self.music_renderer_by_id(renderer_id).ok_or_else(|| {
ControlPointError::SnapshotError(format!("Renderer {} not found", renderer_id.0))
})?;
// Use generic queue access (works for all backends)
let Some((item, remaining)) = renderer.peek_current()? else {
2025-12-05 07:08:03 +01:00
debug!(
renderer = renderer_id.0.as_str(),
"play_current_from_queue: queue is empty or no current item"
);
renderer.set_playback_source(PlaybackSource::None);
2025-12-05 07:08:03 +01:00
return Ok(());
};
debug!(
renderer = renderer_id.0.as_str(),
queue_len = remaining + 1,
uri = item.uri.as_str(),
"Playing current playback item from queue"
);
// Temporarily disable auto-advance to prevent race condition
// when renderer sends Stopped event during SetAVTransportURI
renderer.set_playback_source(PlaybackSource::None);
// Start playback using play_from_queue which preserves the queue
if let Err(err) = renderer.play_from_queue() {
error!(
renderer = renderer_id.0.as_str(),
error = %err,
"Failed to play current item from queue"
);
renderer.set_playback_source(PlaybackSource::None);
return Err(err);
}
2025-12-05 07:08:03 +01:00
info!(
renderer = renderer_id.0.as_str(),
uri = item.uri.as_str(),
"Queue playback started (current item)"
);
// Save metadata in renderer for current track availability
// even if the renderer doesn't return metadata in GetPositionInfo
let metadata = playback_item_track_metadata(&item);
renderer.set_last_metadata(Some(metadata));
renderer.set_playback_source(PlaybackSource::FromQueue);
Ok(())
2025-12-05 07:08:03 +01:00
}
/// Advances the queue by one item, starts playback and updates the snapshot.
pub fn play_next_from_queue(&self, renderer_id: &DeviceId) -> Result<(), ControlPointError> {
let renderer = self.music_renderer_by_id(renderer_id).ok_or_else(|| {
ControlPointError::SnapshotError(format!("Renderer {} not found", renderer_id.0))
})?;
// Check if queue is empty before trying to play next
if renderer.len()? == 0 {
debug!(
renderer = renderer_id.0.as_str(),
"play_next_from_queue: queue is empty"
);
renderer.set_playback_source(PlaybackSource::None);
return Ok(());
}
// Temporarily disable auto-advance to prevent race condition
renderer.set_playback_source(PlaybackSource::None);
// Use the backend's play_next which handles queue advancement correctly for each backend type
if let Err(err) = renderer.play_next_from_queue() {
error!(
renderer = renderer_id.0.as_str(),
error = %err,
"Failed to play next item from queue"
);
renderer.set_playback_source(PlaybackSource::None);
return Err(err);
}
// Get current item metadata for tracking
if let Some((item, _)) = renderer.peek_current()? {
let metadata = playback_item_track_metadata(&item);
renderer.set_last_metadata(Some(metadata));
}
renderer.set_playback_source(PlaybackSource::FromQueue);
debug!(
renderer = renderer_id.0.as_str(),
"Playing next item from queue"
);
// Prefetch next track if supported
self.prefetch_next_track(&renderer, renderer_id);
// Emit QueueUpdated event
let queue_length = renderer.len().unwrap_or(0);
self.emit_renderer_event(RendererEvent::QueueUpdated {
id: renderer_id.clone(),
queue_length,
});
Ok(())
}
/// Prefetches the next track in the queue if the renderer supports it.
fn prefetch_next_track(&self, renderer: &Arc<MusicRenderer>, renderer_id: &DeviceId) {
// Only attempt prefetch if the renderer supports it
if !renderer.supports_set_next() {
return;
}
// Get the next item from the queue using peek_current
let Ok(Some((_, remaining))) = renderer.peek_current() else {
return;
};
if remaining == 0 {
return;
}
let queue_snapshot = match renderer.queue_snapshot() {
Ok(snapshot) => snapshot,
Err(_) => return,
};
// Get next item (current + 1)
let next_index = queue_snapshot.current_index.map(|i| i + 1).unwrap_or(0);
let Some(next_item) = queue_snapshot.items.get(next_index) else {
return;
};
let next_didl_metadata = playback_item_to_didl(next_item);
match renderer.set_next_uri(&next_item.uri, &next_didl_metadata) {
Ok(_) => debug!(
renderer = renderer_id.0.as_str(),
"Prefetched next track via SetNextAVTransportURI"
),
Err(err) => debug!(
renderer = renderer_id.0.as_str(),
error = %err,
"SetNextAVTransportURI failed; continuing without prefetch"
),
}
}
/// Jumps to a specific index in the queue and starts playback.
pub fn play_queue_index(
&self,
renderer_id: &DeviceId,
index: usize,
) -> Result<(), ControlPointError> {
let renderer = self.music_renderer_by_id(renderer_id).ok_or_else(|| {
ControlPointError::SnapshotError(format!("Renderer {} not found", renderer_id.0))
})?;
2025-12-22 10:32:48 +01:00
// Check if index is valid
if index >= renderer.len()? {
2025-12-22 10:32:48 +01:00
debug!(
renderer = renderer_id.0.as_str(),
index, "play_queue_index: index out of bounds"
2025-12-22 10:32:48 +01:00
);
renderer.set_playback_source(PlaybackSource::None);
2025-12-03 23:17:41 +01:00
return Ok(());
}
// Temporarily disable auto-advance to prevent race condition
renderer.set_playback_source(PlaybackSource::None);
// Use the backend's play_from_index which handles everything correctly
if let Err(err) = renderer.play_from_index(index) {
error!(
2025-12-03 23:17:41 +01:00
renderer = renderer_id.0.as_str(),
index,
error = %err,
"Failed to play from queue index"
2025-12-03 23:17:41 +01:00
);
renderer.set_playback_source(PlaybackSource::None);
return Err(err);
2025-12-03 23:17:41 +01:00
}
info!(
renderer = renderer_id.0.as_str(),
index, "Queue playback started at index"
);
// Get current item metadata for tracking
if let Some((item, _)) = renderer.peek_current()? {
let metadata = playback_item_track_metadata(&item);
renderer.set_last_metadata(Some(metadata));
}
renderer.set_playback_source(PlaybackSource::FromQueue);
Ok(())
2025-12-03 23:17:41 +01:00
}
2025-12-05 07:08:03 +01:00
/// Stop playback in response to user action (e.g., Stop button in UI).
///
/// This method marks the stop as user-requested to prevent automatic
/// advancement to the next track in the queue when the STOPPED event
/// is received from the renderer.
pub fn user_stop(&self, renderer_id: &DeviceId) -> Result<(), ControlPointError> {
2025-12-05 07:08:03 +01:00
// Get renderer and call stop
let renderer = self.music_renderer_by_id(renderer_id).ok_or_else(|| {
ControlPointError::SnapshotError(format!("Renderer {} not found", renderer_id.0))
2025-12-05 07:08:03 +01:00
})?;
// Mark that user requested stop before actually stopping
renderer.mark_user_stop_requested();
2025-12-05 21:59:43 +01:00
debug!(renderer = renderer_id.0.as_str(), "User-requested stop");
2025-12-05 07:08:03 +01:00
renderer.stop()
}
2025-11-30 10:54:01 +01:00
/// Subscribe to renderer events emitted by the control point runtime.
///
/// Each subscriber receives all future events independently.
pub fn subscribe_events(&self) -> Receiver<RendererEvent> {
self.event_bus.subscribe()
}
/// Access the media server event bus for ContentDirectory notifications.
pub fn media_server_events(&self) -> MediaServerEventBus {
self.media_event_bus.clone()
}
/// Subscribe directly to media server events emitted by the control point.
pub fn subscribe_media_server_events(&self) -> Receiver<MediaServerEvent> {
self.media_event_bus.subscribe()
}
/// Attach a renderer's playback queue to a server-side playlist container.
///
/// 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`, so this method is
/// part of the queue-mutation surface area.
2025-12-05 07:08:03 +01:00
/// Attach a renderer's queue to a playlist container.
///
/// The queue will be automatically refreshed when the playlist changes on the server.
pub fn attach_queue_to_playlist(
&self,
renderer_id: &DeviceId,
server_id: DeviceId,
container_id: String,
) -> Result<(), ControlPointError> {
2025-12-06 00:38:06 +01:00
self.attach_queue_to_playlist_with_options(renderer_id, server_id, container_id, false)
2025-12-05 07:08:03 +01:00
}
2025-12-06 00:38:06 +01:00
/// Attach a renderer queue to a playlist with explicit `auto_play` behaviour.
///
/// Same queue-mutation guarantees as [`attach_queue_to_playlist`].
2025-12-06 00:38:06 +01:00
pub fn attach_queue_to_playlist_with_options(
2025-12-05 07:08:03 +01:00
&self,
renderer_id: &DeviceId,
server_id: DeviceId,
2025-12-05 07:08:03 +01:00
container_id: String,
2025-12-06 00:38:06 +01:00
auto_play: bool,
) -> Result<(), ControlPointError> {
2025-12-06 00:38:06 +01:00
self.attach_queue_to_playlist_internal(renderer_id, &server_id, &container_id, auto_play)
2025-12-05 07:08:03 +01:00
}
2025-12-06 00:38:06 +01:00
/// Internal implementation shared by every attach wrapper.
2025-12-05 07:08:03 +01:00
fn attach_queue_to_playlist_internal(
&self,
renderer_id: &DeviceId,
server_id: &DeviceId,
2025-12-06 00:38:06 +01:00
container_id: &str,
auto_play: bool,
) -> Result<(), ControlPointError> {
2025-12-26 18:35:56 +01:00
// CRITICAL: When attaching a new playlist to a renderer, we must UNCONDITIONALLY
2025-12-26 20:12:11 +01:00
// clear the RENDERER queue first (but NOT the local queue cache, which will be
// replaced by refresh_attached_queue_for() using replace_entire_playlist()).
2025-12-26 18:35:56 +01:00
//
2025-12-26 20:12:11 +01:00
// Attach workflow: Clear renderer → Fill with new playlist
2025-12-26 18:35:56 +01:00
// Update workflow: Gentle sync (preserve current item, use LCS)
info!(
renderer = renderer_id.0.as_str(),
server = server_id.0.as_str(),
container = container_id,
2025-12-26 20:12:11 +01:00
"Attaching new playlist: clearing renderer queue"
2025-12-26 18:35:56 +01:00
);
2025-12-26 20:12:11 +01:00
// Prepare the renderer for the new playlist (backend-agnostic)
let renderer = self.music_renderer_by_id(renderer_id).ok_or_else(|| {
ControlPointError::ControlPoint(format!("Renderer {} not found", renderer_id.0))
})?;
renderer.clear_for_playlist_attach()?;
// Sync backend state to local cache (backend-agnostic)
renderer.sync_queue_state()?;
2025-12-26 18:35:56 +01:00
// Clear the local queue (detach binding + clear runtime queue structure)
self.detach_playlist_binding(renderer_id, "attach_new_playlist");
renderer.clear_queue()?;
debug!(
renderer = renderer_id.0.as_str(),
"Cleared renderer and local queue for new playlist"
);
2025-12-05 07:08:03 +01:00
let binding = PlaylistBinding {
server_id: server_id.clone(),
2025-12-06 00:38:06 +01:00
container_id: container_id.to_string(),
2025-12-05 07:08:03 +01:00
has_seen_update: false,
2025-12-06 00:38:06 +01:00
pending_refresh: true,
auto_play_on_refresh: auto_play,
2025-12-05 07:08:03 +01:00
};
let renderer = self.music_renderer_by_id(renderer_id).ok_or_else(|| {
ControlPointError::ControlPoint(format!("Renderer {} not found", renderer_id.0))
})?;
renderer.set_playlist_binding(Some(binding.clone()));
info!(
renderer = renderer_id.0.as_str(),
server = server_id.0.as_str(),
container = container_id,
auto_play,
"Queue attached to playlist container"
);
2025-12-03 23:17:41 +01:00
2025-12-05 07:08:03 +01:00
self.emit_renderer_event(RendererEvent::BindingChanged {
id: renderer_id.clone(),
binding: Some(binding),
});
2025-12-28 15:27:48 +01:00
// For initial attach with auto_play, force playback start (don't check if idle)
let mut auto_start_cb = |rid: &DeviceId| {
2025-12-28 15:27:48 +01:00
debug!(
renderer = rid.0.as_str(),
"Attach callback: forcing playback start (not checking if idle)"
);
self.play_current_from_queue(rid)
};
let callback: Option<&mut dyn FnMut(&DeviceId) -> Result<(), ControlPointError>> =
if auto_play {
Some(&mut auto_start_cb)
} else {
None
};
2025-12-06 00:38:06 +01:00
refresh_attached_queue_for(&self.registry, renderer_id, &self.event_bus, callback)
}
/// Detach a renderer's queue from its associated playlist container.
///
/// 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: &DeviceId) {
2025-12-06 00:38:06 +01:00
self.detach_playlist_binding(renderer_id, "api_detach");
}
/// Transfers the queue and playlist binding from one renderer to another.
///
/// This method performs a complete transfer:
/// 1. Takes a snapshot of the source renderer's queue (including playlist binding)
/// 2. Clears the destination renderer's queue
/// 3. Fills the destination renderer's queue with the source snapshot
/// 4. If the source had a playlist binding, recreates it on the destination
/// 5. Stops playback on the source renderer
/// 6. Starts playback on the destination renderer at the same position
/// 7. Clears the source renderer's queue
///
/// This is useful for seamlessly moving playback from one device to another
/// while preserving the queue state and playlist synchronization.
pub fn transfer_queue(
&self,
source_renderer_id: &DeviceId,
dest_renderer_id: &DeviceId,
) -> Result<(), ControlPointError> {
// 1. Get snapshot from source renderer
let source_snapshot = self.get_renderer_queue_snapshot(source_renderer_id)?;
let source_binding = self.current_queue_playlist_binding(source_renderer_id);
tracing::info!(
source = source_renderer_id.0.as_str(),
dest = dest_renderer_id.0.as_str(),
items = source_snapshot.items.len(),
current_index = ?source_snapshot.current_index,
has_binding = source_binding.is_some(),
"Transferring queue between renderers"
);
// 2. Clear destination queue
self.clear_renderer_queue(dest_renderer_id)?;
// 3. Fill destination queue with source items
let dest_renderer = self.music_renderer_by_id(dest_renderer_id).ok_or_else(|| {
ControlPointError::SnapshotError(format!(
"Destination renderer {} not found",
dest_renderer_id.0
))
})?;
dest_renderer
.replace_queue(source_snapshot.items.clone(), source_snapshot.current_index)?;
// 4. Recreate playlist binding on destination if source had one
if let Some((server_id, container_id, _)) = source_binding {
tracing::debug!(
dest = dest_renderer_id.0.as_str(),
server = server_id.0.as_str(),
container = container_id.as_str(),
"Recreating playlist binding on destination renderer"
);
self.attach_queue_to_playlist(dest_renderer_id, server_id, container_id)?;
}
// 5. Stop playback on source renderer
let source_renderer = self
.music_renderer_by_id(source_renderer_id)
.ok_or_else(|| {
ControlPointError::SnapshotError(format!(
"Source renderer {} not found",
source_renderer_id.0
))
})?;
if let Err(e) = source_renderer.stop() {
tracing::warn!(
source = source_renderer_id.0.as_str(),
error = ?e,
"Failed to stop source renderer (continuing transfer)"
);
}
// 5b. Detach playlist binding from source renderer
self.detach_queue_playlist(source_renderer_id);
tracing::debug!(
source = source_renderer_id.0.as_str(),
"Detached playlist binding from source renderer"
);
// 6. Start playback on destination renderer (if there was a current item)
if source_snapshot.current_index.is_some() && !source_snapshot.items.is_empty() {
// play() détecte automatiquement la queue et joue le track courant
// (comportement unifié pour tous les backends)
if let Err(e) = dest_renderer.play() {
tracing::warn!(
dest = dest_renderer_id.0.as_str(),
error = ?e,
"Failed to start playback on destination renderer"
);
}
}
// 7. Clear source queue
if let Err(e) = self.clear_renderer_queue(source_renderer_id) {
tracing::warn!(
source = source_renderer_id.0.as_str(),
error = ?e,
"Failed to clear source renderer queue after transfer"
);
}
tracing::info!(
source = source_renderer_id.0.as_str(),
dest = dest_renderer_id.0.as_str(),
"Queue transfer completed successfully"
);
Ok(())
}
/// Query the current playlist binding for a renderer's queue, if any.
///
/// Returns `(server_id, container_id, has_seen_update)` if the queue is
/// bound to a server playlist container, or `None` otherwise.
pub fn current_queue_playlist_binding(
&self,
renderer_id: &DeviceId,
) -> Option<(DeviceId, String, bool)> {
let renderer = self.music_renderer_by_id(renderer_id)?;
renderer.get_playlist_binding().map(|binding| {
(
binding.server_id.clone(),
binding.container_id.clone(),
binding.has_seen_update,
)
})
}
2025-12-06 00:38:06 +01:00
/// Internal helper to detach any playlist binding and notify observers.
///
2025-12-06 00:38:06 +01:00
/// Invariant: every user-driven queue mutation **must** call this method so
/// that bindings never become out of sync with the local queue snapshot.
fn detach_playlist_binding(&self, renderer_id: &DeviceId, reason: &str) {
let renderer = match self.music_renderer_by_id(renderer_id) {
Some(r) => r,
None => return,
2025-12-06 00:38:06 +01:00
};
let removed = renderer.get_playlist_binding();
renderer.set_playlist_binding(None);
2025-12-06 00:38:06 +01:00
if let Some(binding) = removed {
info!(
renderer = renderer_id.0.as_str(),
server = binding.server_id.0.as_str(),
container = binding.container_id.as_str(),
reason = reason,
2025-12-06 00:38:06 +01:00
"Playlist binding detached"
);
self.emit_renderer_event(RendererEvent::BindingChanged {
id: renderer_id.clone(),
binding: None,
});
} else {
debug!(
renderer = renderer_id.0.as_str(),
reason = reason,
"detach_playlist_binding: no binding to remove"
);
}
}
2025-11-30 10:54:01 +01:00
pub(crate) fn emit_renderer_event(&self, event: RendererEvent) {
self.handle_renderer_event(&event);
2025-11-30 10:54:01 +01:00
self.event_bus.broadcast(event);
}
fn handle_renderer_event(&self, event: &RendererEvent) {
if let RendererEvent::StateChanged { id, state } = event {
let Some(renderer) = self.music_renderer_by_id(id) else {
return;
};
match state {
PlaybackState::Stopped => {
2025-12-05 07:08:03 +01:00
// Check if user requested stop (via Stop button in UI)
if renderer.check_and_clear_user_stop_requested() {
2025-12-05 07:08:03 +01:00
debug!(
renderer = id.0.as_str(),
"Renderer stopped by user request; not auto-advancing"
);
renderer.set_playback_source(PlaybackSource::None);
} else if renderer.is_playing_from_queue() {
debug!(
renderer = id.0.as_str(),
"Renderer stopped after queue-driven playback; advancing"
);
if let Err(err) = self.play_next_from_queue(id) {
error!(
renderer = id.0.as_str(),
error = %err,
"Auto-advance failed; clearing queue playback state"
);
renderer.set_playback_source(PlaybackSource::None);
}
} else {
renderer.set_playback_source(PlaybackSource::None);
}
}
PlaybackState::Playing => {
renderer.mark_external_if_idle();
}
_ => {}
}
}
}
2025-11-30 10:54:01 +01:00
}
2025-12-06 00:38:06 +01:00
#[cfg(feature = "pmoserver")]
fn convert_runtime_position(position: Option<&PlaybackPositionInfo>) -> (Option<u64>, Option<u64>) {
match position {
Some(info) => (
parse_hms_to_ms(info.rel_time.as_deref()),
parse_hms_to_ms(info.track_duration.as_deref()),
),
None => (None, None),
}
}
#[cfg(feature = "pmoserver")]
fn parse_hms_to_ms(hms: Option<&str>) -> Option<u64> {
let value = hms?;
let parts: Vec<&str> = value.split(':').collect();
if parts.len() != 3 {
return None;
}
let hours: u64 = parts[0].parse().ok()?;
let minutes: u64 = parts[1].parse().ok()?;
let seconds: u64 = parts[2].parse().ok()?;
Some((hours * 3600 + minutes * 60 + seconds) * 1000)
}
/// Snapshot of renderer state used for change detection in the polling thread.
/// This is a local cache, not a source of truth.
#[derive(Clone, Default)]
2025-11-30 10:54:01 +01:00
struct RendererRuntimeSnapshot {
state: Option<PlaybackState>,
position: Option<PlaybackPositionInfo>,
2025-12-01 19:06:07 +01:00
last_volume: Option<u16>,
last_mute: Option<bool>,
last_metadata: Option<TrackMetadata>,
2025-11-30 10:54:01 +01:00
}
/// Internal helper to refresh a renderer's playback queue from its bound
/// playlist container.
///
/// This function is called automatically when a ContentDirectory event indicates
/// that the bound container has been updated. It attempts to preserve the
/// currently playing item when possible.
fn refresh_attached_queue_for(
registry: &Arc<RwLock<DeviceRegistry>>,
renderer_id: &DeviceId,
event_bus: &RendererEventBus,
mut after_refresh: Option<&mut dyn FnMut(&DeviceId) -> Result<(), ControlPointError>>,
) -> Result<(), ControlPointError> {
// Step 1: Get renderer from registry
let renderer = {
let reg = registry.read().unwrap();
reg.get_renderer(renderer_id)
};
let renderer = match renderer {
Some(r) => r,
None => {
debug!(
renderer = renderer_id.0.as_str(),
"refresh_attached_queue_for: renderer not found"
);
return Ok(());
}
};
// Check if there's a binding and if it needs refresh
if !renderer.has_pending_refresh() {
debug!(
renderer = renderer_id.0.as_str(),
"refresh_attached_queue_for: no pending refresh needed"
);
return Ok(());
}
let (server_id, container_id) = {
let binding = match renderer.get_playlist_binding() {
Some(b) => b,
None => {
debug!(
renderer = renderer_id.0.as_str(),
"refresh_attached_queue_for: no binding present"
);
return Ok(());
}
};
(binding.server_id.clone(), binding.container_id.clone())
};
// Reset the pending_refresh flag and consume auto_play
renderer.reset_pending_refresh();
let auto_play = renderer.consume_auto_play();
// Step 2: Get server from registry
let music_server = {
let reg = registry.read().unwrap();
reg.get_server(&server_id)
};
let music_server = match music_server {
Some(s) => s,
None => {
warn!(
renderer = renderer_id.0.as_str(),
server = server_id.0.as_str(),
"refresh_attached_queue_for: server not found in registry"
);
return Ok(());
}
};
if !music_server.is_online() {
debug!(
renderer = renderer_id.0.as_str(),
server = server_id.0.as_str(),
"refresh_attached_queue_for: server offline, skipping refresh"
);
return Ok(());
}
// Step 3: Browse container
2025-12-17 21:41:24 +01:00
const MAX_BROWSE_ATTEMPTS: usize = 3;
const BROWSE_RETRY_DELAY_MS: u64 = 200;
let mut attempt = 1;
let entries = loop {
match music_server.browse_children(&container_id, 0, 64) {
Ok(e) => break e,
Err(err) => {
if attempt >= MAX_BROWSE_ATTEMPTS {
warn!(
renderer = renderer_id.0.as_str(),
server = server_id.0.as_str(),
container = container_id.as_str(),
attempts = attempt,
error = %err,
"Failed to browse playlist container for refresh"
);
return Err(err);
}
debug!(
renderer = renderer_id.0.as_str(),
server = server_id.0.as_str(),
container = container_id.as_str(),
attempt,
error = %err,
"Browse attempt failed, retrying"
);
thread::sleep(Duration::from_millis(
BROWSE_RETRY_DELAY_MS * attempt as u64,
));
attempt += 1;
}
}
};
debug!(
renderer = renderer_id.0.as_str(),
server = server_id.0.as_str(),
container = container_id.as_str(),
total_entries = entries.len(),
containers = entries.iter().filter(|e| e.is_container).count(),
items_count = entries.iter().filter(|e| !e.is_container).count(),
"Browse returned entries for playlist refresh"
);
// Step 4: Convert MediaEntry to PlaybackItem
let new_items: Vec<PlaybackItem> = entries
.iter()
.filter_map(|entry| playback_item_from_entry(music_server.clone(), entry))
.collect();
if new_items.is_empty() {
warn!(
renderer = renderer_id.0.as_str(),
server = server_id.0.as_str(),
container = container_id.as_str(),
total_entries = entries.len(),
"Refreshed playlist is empty, clearing queue"
);
renderer.clear_queue()?;
// Emit QueueUpdated event
event_bus.broadcast(RendererEvent::QueueUpdated {
id: renderer_id.clone(),
queue_length: 0,
});
return Ok(());
}
// Step 5: GENTLE SYNCHRONIZATION using sync_queue()
2025-12-17 21:41:24 +01:00
// This uses LCS algorithm to minimize playlist operations and avoid interrupting playback
info!(
renderer = renderer_id.0.as_str(),
server = server_id.0.as_str(),
container = container_id.as_str(),
total_items = new_items.len(),
"Refreshing playlist with sync_queue"
);
2025-12-17 21:41:24 +01:00
renderer.sync_queue(new_items)?;
let final_queue_len = {
let snapshot = renderer.queue_snapshot()?;
snapshot.items.len()
};
// Emit QueueUpdated event
event_bus.broadcast(RendererEvent::QueueUpdated {
id: renderer_id.clone(),
queue_length: final_queue_len,
});
2025-12-03 23:17:41 +01:00
if auto_play {
if let Some(callback) = after_refresh.as_deref_mut() {
2025-12-28 15:27:48 +01:00
debug!(
renderer = renderer_id.0.as_str(),
server = server_id.0.as_str(),
container = container_id.as_str(),
"Auto-play enabled: calling callback to start playback"
);
2025-12-03 23:17:41 +01:00
if let Err(err) = callback(renderer_id) {
warn!(
renderer = renderer_id.0.as_str(),
server = server_id.0.as_str(),
container = container_id.as_str(),
error = %err,
"Failed to auto-start playback after playlist refresh"
);
2025-12-28 15:27:48 +01:00
} else {
info!(
renderer = renderer_id.0.as_str(),
server = server_id.0.as_str(),
container = container_id.as_str(),
"Auto-play callback completed successfully"
);
2025-12-03 23:17:41 +01:00
}
2025-12-28 15:27:48 +01:00
} else {
debug!(
renderer = renderer_id.0.as_str(),
"Auto-play enabled but no callback provided"
);
2025-12-03 23:17:41 +01:00
}
2025-12-28 15:27:48 +01:00
} else {
debug!(
renderer = renderer_id.0.as_str(),
"Auto-play disabled, skipping playback start"
);
2025-12-03 23:17:41 +01:00
}
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 {
2025-12-17 20:45:51 +01:00
protocol_info: item.protocol_info.clone(),
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 parse_optional_hms_to_secs(value: &Option<String>) -> Option<u64> {
value.as_ref().and_then(|s| parse_hms_to_secs(s))
2025-11-30 10:54:01 +01:00
}
/// Compute a logical playback state by combining the raw AVTransport state
/// with previous and current position information.
///
/// This is designed to compensate for buggy LinkPlay/Arylic devices that
/// report:
/// - STOPPED while the time actually advances,
/// - NO_MEDIA_PRESENT while track duration is known.
fn compute_logical_playback_state(
raw: &PlaybackState,
prev_position: Option<&PlaybackPositionInfo>,
current_position: Option<&PlaybackPositionInfo>,
) -> PlaybackState {
// Rule 1: Arylic / LinkPlay sometimes report STOPPED while the stream is
// actually playing. If we detect that the relative time advances between
// two polls, we treat this as Playing.
if let PlaybackState::Stopped = raw {
2025-11-30 10:54:01 +01:00
if let (Some(prev), Some(curr)) = (prev_position, current_position) {
if let (Some(prev_rel), Some(curr_rel)) = (
parse_optional_hms_to_secs(&prev.rel_time),
parse_optional_hms_to_secs(&curr.rel_time),
) {
if curr_rel > prev_rel {
let delta = curr_rel - prev_rel;
// Our poll loop runs every 1s; accept small jitter in the delta.
if delta <= 5 {
return PlaybackState::Playing;
2025-11-30 10:54:01 +01:00
}
}
}
}
}
// Rule 2: Some devices report NO_MEDIA_PRESENT while exposing a non-zero
// track duration. In practice this behaves like a stopped transport with
// a loaded track.
if let PlaybackState::NoMedia = raw {
2025-11-30 10:54:01 +01:00
let duration_secs = current_position
.and_then(|p| parse_optional_hms_to_secs(&p.track_duration))
.or_else(|| prev_position.and_then(|p| parse_optional_hms_to_secs(&p.track_duration)));
if matches!(duration_secs, Some(d) if d > 0) {
return PlaybackState::Stopped;
2025-11-30 10:54:01 +01:00
}
}
// Fallback: keep the raw (already normalized) state.
raw.clone()
}
fn playback_state_equal(a: &PlaybackState, b: &PlaybackState) -> bool {
match (a, b) {
(PlaybackState::Unknown(lhs), PlaybackState::Unknown(rhs)) => lhs == rhs,
_ => std::mem::discriminant(a) == std::mem::discriminant(b),
}
}
fn playback_position_equal(a: &PlaybackPositionInfo, b: &PlaybackPositionInfo) -> bool {
a.track == b.track
&& a.rel_time == b.rel_time
&& a.abs_time == b.abs_time
&& a.track_duration == b.track_duration
&& a.track_metadata == b.track_metadata
&& a.track_uri == b.track_uri
}
2025-12-06 00:38:06 +01:00
#[cfg(feature = "pmoserver")]
fn current_track_from_playback_item(item: &PlaybackItem) -> CurrentTrackMetadata {
let meta = item.metadata.as_ref();
2025-12-06 00:38:06 +01:00
CurrentTrackMetadata {
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()),
2025-12-06 00:38:06 +01:00
}
}
/// Extract TrackMetadata from DIDL-Lite XML in PlaybackPositionInfo.
fn extract_track_metadata(position: &PlaybackPositionInfo) -> Option<TrackMetadata> {
2025-12-05 07:08:03 +01:00
let didl_xml = match position.track_metadata.as_ref() {
Some(xml) => xml,
None => {
debug!("Position info has no track_metadata (DIDL-Lite XML)");
return None;
}
};
// Parse DIDL-Lite XML
let didl = match pmodidl::parse_metadata::<pmodidl::DIDLLite>(didl_xml) {
Ok(parsed) => parsed.data,
Err(err) => {
debug!(error = %err, "Failed to parse DIDL-Lite metadata from GetPositionInfo");
return None;
}
};
// Extract first item metadata
2025-12-05 07:08:03 +01:00
let item = match didl.items.first() {
Some(item) => item,
None => {
debug!("DIDL-Lite has no items");
return None;
}
};
debug!(
title = item.title.as_str(),
has_album_art = item.album_art.is_some(),
album_art_uri = item.album_art.as_deref(),
"Extracted metadata from position info"
);
Some(TrackMetadata {
title: Some(item.title.clone()),
artist: item.artist.clone(),
album: item.album.clone(),
genre: item.genre.clone(),
album_art_uri: item.album_art.clone(),
date: item.date.clone(),
track_number: item.original_track_number.clone(),
creator: item.creator.clone(),
})
}
/// Parse "HH:MM:SS" style time strings to seconds.
///
/// Returns None for empty or sentinel values such as "NOT_IMPLEMENTED" or "-:--:--".
fn parse_hms_to_secs(s: &str) -> Option<u64> {
let s = s.trim();
if s.is_empty() {
return None;
}
// Common sentinel values for "no information" in UPnP implementations.
if s == "NOT_IMPLEMENTED" || s == "-:--:--" {
return None;
}
let parts: Vec<_> = s.split(':').collect();
if parts.len() != 3 {
return None;
}
let hours: u64 = parts[0].parse().ok()?;
let minutes: u64 = parts[1].parse().ok()?;
let seconds: u64 = parts[2].parse().ok()?;
Some(hours * 3600 + minutes * 60 + seconds)
}