Files
pmomusic/pmocontrol/src/control_point.rs
Eric Coissac 9dee193947 ⬆️ version to v0.3.41 — async queue sync refactoring
- 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
2026-04-09 11:21:11 +02:00

2045 lines
77 KiB
Rust

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};
#[cfg(feature = "pmoserver")]
use crate::openapi::{
CurrentTrackMetadata, FullRendererSnapshot, QueueItem, QueueSnapshotView, RendererBindingView,
RendererStateView,
};
use crate::queue::{EnqueueMode, PlaybackItem, QueueSnapshot, SyncScheduleOutcome};
use crate::registry::DeviceRegistry;
/// Control point minimal :
/// - lance un SsdpClient dans un thread,
/// - passe les SsdpEvent au DiscoveryManager,
/// - applique les DeviceUpdate dans le DeviceRegistry.
///
/// 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>>,
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> {
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
// 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);
});
});
// 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 10 secondes avant le prochain cycle pour découverte rapide
thread::sleep(Duration::from_secs(10));
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);
thread::spawn(move || {
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();
}
}
});
// Thread de découverte mDNS pour Chromecast
let registry_for_mdns = Arc::clone(&registry);
let udn_cache_for_mdns = Arc::clone(&udn_cache);
thread::spawn(move || {
use crate::discovery::ChromecastDiscoveryManager;
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);
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 {
// 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)) {
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()
);
discovery_manager.handle_mdns_response(response);
}
Err(e) => {
warn!("mDNS discovery error: {}", e);
}
}
}
warn!("mDNS discovery stream ended unexpectedly");
}
Err(e) => {
error!("Failed to start mDNS discovery: {}", e);
}
}
});
});
// Note: Renderer polling is now handled by each MusicRenderer's own watcher thread.
// The central polling loop has been removed in favor of per-renderer watchers
// that are started/stopped by the Registry when devices come online/offline.
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!(
server = server_id.0.as_str(),
system_update_id = system_update_id,
"MediaServer global update"
);
}
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()
}
}
};
// 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"
);
let _ = schedule_queue_refresh_for(
&registry_for_media_worker,
&renderer_id,
&event_bus_for_media_worker,
None,
);
}
}
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");
}
}
}
})?;
// 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"
);
let _ = schedule_queue_refresh_for(
&registry_for_periodic,
&renderer_id,
&event_bus_for_periodic,
None,
);
}
}
})?;
// 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"
);
// Mark this as a user-requested stop to prevent auto-advance
renderer.mark_user_stop_requested();
// 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);
}
}
}
}
});
Ok(Self {
registry,
// udn_cache,
event_bus,
media_event_bus,
})
}
/// Accès au DeviceRegistry partagé.
pub fn registry(&self) -> Arc<RwLock<DeviceRegistry>> {
Arc::clone(&self.registry)
}
/// 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![])
}
/// 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()
}
/// 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)
}
/// 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
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()?;
debug!(
renderer = renderer_id.0.as_str(),
items_removed = removed,
queue_len = 0,
"Cleared playback queue"
);
// Note: QueueUpdated event is emitted automatically by MusicRenderer::clear_queue()
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
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"
);
// Note: QueueUpdated event is emitted automatically by MusicRenderer::enqueue_items()
Ok(())
}
/// Shuffles the queue of a renderer and restarts playback from the first track.
///
/// This method:
/// 1. Detaches the queue from any attached playlist
/// 2. Stops playback
/// 3. Randomizes the order of tracks in the queue
/// 4. Starts playback from the first track
///
/// Note: QueueUpdated event is emitted automatically by MusicRenderer::shuffle_queue()
/// via its internal call to replace_queue().
pub fn shuffle_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))
})?;
// Perform the shuffle (this also detaches playlist and restarts playback)
renderer.shuffle_queue()?;
debug!(
renderer = renderer_id.0.as_str(),
queue_len = renderer.len().unwrap_or(0),
"Shuffled playback queue"
);
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())
}
/// Build a fully consistent snapshot for UI consumers (state + queue + binding).
#[cfg(feature = "pmoserver")]
pub fn renderer_full_snapshot(
&self,
renderer_id: &DeviceId,
) -> 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();
// 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;
let playback_source = renderer.playback_source();
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() {
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);
}
}
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()),
})
.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());
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(),
})
});
let state_view = RendererStateView {
id: renderer_id.0.clone(),
friendly_name: info.friendly_name().to_string(),
transport_state: current_state
.as_ref()
.map(|state| state.as_str().to_string())
.unwrap_or_else(|| "UNKNOWN".to_string()),
position_ms,
duration_ms,
volume: current_volume.and_then(|value| u8::try_from(value).ok()),
mute: current_mute,
queue_len,
attached_playlist: binding.clone(),
current_track,
};
let is_stream = renderer.is_playing_a_stream();
Ok(FullRendererSnapshot {
state: state_view,
queue: queue_view,
binding,
is_stream,
})
}
/// 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.
///
/// 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 {
debug!(
renderer = renderer_id.0.as_str(),
"play_current_from_queue: queue is empty or no current item"
);
renderer.set_playback_source(PlaybackSource::None);
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);
}
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);
// Prefetch next track if supported (gapless playback)
self.prefetch_next_track(&renderer, renderer_id);
Ok(())
}
/// 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
let queue_len = renderer.len()?;
tracing::trace!(
renderer = renderer_id.0.as_str(),
queue_len,
"play_next_from_queue: checking queue length"
);
if queue_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);
// Note: QueueUpdated event is emitted automatically by MusicRenderer::play_next_from_queue()
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"
),
}
}
/// Prefetch the next track for a renderer (gapless playback support).
///
/// Called by the WebRenderer backend after a TrackEnded event to trigger
/// SetNextAVTransportURI for the next-next track (N+2).
pub fn prefetch_next_for_renderer(&self, renderer_id: &DeviceId) {
if let Some(renderer) = self.music_renderer_by_id(renderer_id) {
self.prefetch_next_track(&renderer, renderer_id);
}
}
/// Advances the queue index by one for a gapless transition, then prefetches the next-next track.
///
/// Called by the WebRenderer backend after a gapless TrackEnded event. In the gapless case,
/// the browser has already advanced to the next track autonomously, so the backend queue
/// index must be updated to stay in sync. Then we prefetch track N+2 for the next gapless
/// transition.
pub fn advance_queue_and_prefetch(&self, renderer_id: &DeviceId) {
if let Some(renderer) = self.music_renderer_by_id(renderer_id) {
// Advance the queue index to match the browser's gapless transition
match renderer.advance_queue_index() {
Ok(true) => {
debug!(
renderer = renderer_id.0.as_str(),
"advance_queue_and_prefetch: queue index advanced for gapless transition"
);
// Now prefetch the next-next track (N+2)
self.prefetch_next_track(&renderer, renderer_id);
}
Ok(false) => {
debug!(
renderer = renderer_id.0.as_str(),
"advance_queue_and_prefetch: no next track in queue, skipping prefetch"
);
}
Err(err) => {
debug!(
renderer = renderer_id.0.as_str(),
error = %err,
"advance_queue_and_prefetch: failed to advance queue index"
);
}
}
}
}
/// 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))
})?;
// Check if index is valid
if index >= renderer.len()? {
debug!(
renderer = renderer_id.0.as_str(),
index, "play_queue_index: index out of bounds"
);
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_from_index which handles everything correctly
if let Err(err) = renderer.play_from_index(index) {
error!(
renderer = renderer_id.0.as_str(),
index,
error = %err,
"Failed to play from queue index"
);
renderer.set_playback_source(PlaybackSource::None);
return Err(err);
}
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);
// Prefetch next track if supported (gapless playback)
self.prefetch_next_track(&renderer, renderer_id);
Ok(())
}
/// 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> {
// 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))
})?;
// Mark that user requested stop before actually stopping
renderer.mark_user_stop_requested();
debug!(renderer = renderer_id.0.as_str(), "User-requested stop");
renderer.stop()
}
/// 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.
/// 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> {
self.attach_queue_to_playlist_with_options(renderer_id, server_id, container_id, false)
}
/// 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: &DeviceId,
server_id: DeviceId,
container_id: String,
auto_play: bool,
) -> Result<(), ControlPointError> {
self.attach_queue_to_playlist_internal(renderer_id, &server_id, &container_id, auto_play)
}
/// Internal implementation shared by every attach wrapper.
fn attach_queue_to_playlist_internal(
&self,
renderer_id: &DeviceId,
server_id: &DeviceId,
container_id: &str,
auto_play: bool,
) -> Result<(), ControlPointError> {
// If already bound to the same container on the same server, don't clear the
// renderer queue — that would interrupt active playback. Instead, just trigger
// a gentle refresh (which uses LCS and preserves the currently playing track).
let renderer = self.music_renderer_by_id(renderer_id).ok_or_else(|| {
ControlPointError::ControlPoint(format!("Renderer {} not found", renderer_id.0))
})?;
let already_bound = renderer
.get_playlist_binding()
.map(|b| b.server_id == *server_id && b.container_id == container_id)
.unwrap_or(false);
if already_bound {
debug!(
renderer = renderer_id.0.as_str(),
server = server_id.0.as_str(),
container = container_id,
auto_play,
"Re-attach to same container: skipping clear, triggering gentle refresh"
);
let mut binding = renderer.get_playlist_binding().unwrap();
binding.pending_refresh = true;
binding.auto_play_on_refresh = auto_play;
renderer.set_playlist_binding(Some(binding));
let callback: Option<Box<dyn FnOnce(&DeviceId) -> Result<(), ControlPointError> + Send + 'static>> =
if auto_play {
let reg = Arc::clone(&self.registry);
Some(Box::new(move |rid: &DeviceId| {
let renderer = reg.read().unwrap().get_renderer(rid).ok_or_else(||
ControlPointError::ControlPoint(format!("Renderer {} not found", rid.0))
)?;
renderer.play_current_from_queue()
}))
} else {
None
};
let _ = schedule_queue_refresh_for(
&self.registry,
renderer_id,
&self.event_bus,
callback,
);
return Ok(());
}
// CRITICAL: When attaching a new playlist to a renderer, we must UNCONDITIONALLY
// clear the RENDERER queue first (but NOT the local queue cache, which will be
// replaced by refresh_attached_queue_for() using replace_entire_playlist()).
//
// Attach workflow: Clear renderer → Fill with new playlist
// 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,
"Attaching new playlist: clearing renderer queue"
);
renderer.clear_for_playlist_attach()?;
// Sync backend state to local cache (backend-agnostic)
renderer.sync_queue_state()?;
// Detach any existing binding (local queue will be replaced by sync_queue later)
self.detach_playlist_binding(renderer_id, "attach_new_playlist");
debug!(
renderer = renderer_id.0.as_str(),
"Prepared renderer for new playlist attachment"
);
let binding = PlaylistBinding {
server_id: server_id.clone(),
container_id: container_id.to_string(),
has_seen_update: false,
pending_refresh: true,
auto_play_on_refresh: auto_play,
};
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));
info!(
renderer = renderer_id.0.as_str(),
server = server_id.0.as_str(),
container = container_id,
auto_play,
"Queue attached to playlist container"
);
// Note: BindingChanged event is emitted automatically by MusicRenderer::set_playlist_binding()
// For initial attach with auto_play, force playback start (don't check if idle)
let callback: Option<Box<dyn FnOnce(&DeviceId) -> Result<(), ControlPointError> + Send + 'static>> =
if auto_play {
let reg = Arc::clone(&self.registry);
Some(Box::new(move |rid: &DeviceId| {
debug!(
renderer = rid.0.as_str(),
"Attach callback: forcing playback start (not checking if idle)"
);
let renderer = reg.read().unwrap().get_renderer(rid).ok_or_else(||
ControlPointError::ControlPoint(format!("Renderer {} not found", rid.0))
)?;
renderer.play_current_from_queue()
}))
} else {
None
};
let _ = schedule_queue_refresh_for(&self.registry, renderer_id, &self.event_bus, callback);
Ok(())
}
/// 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) {
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. Transfer playlist binding to destination if source had one
// IMPORTANT: We transfer the binding directly WITHOUT triggering a refresh.
// The queue items have already been copied in step 3, so we just need to
// set the binding so future server updates will sync to the destination.
// Using attach_queue_to_playlist() would clear the queue and re-fetch from server,
// losing the current playback position.
if let Some((server_id, container_id, has_seen_update)) = source_binding.clone() {
tracing::debug!(
dest = dest_renderer_id.0.as_str(),
server = server_id.0.as_str(),
container = container_id.as_str(),
"Transferring playlist binding to destination renderer (without refresh)"
);
// Create binding with pending_refresh=false to avoid re-fetching
let binding = PlaylistBinding {
server_id,
container_id,
has_seen_update,
pending_refresh: false,
auto_play_on_refresh: false,
};
dest_renderer.set_playlist_binding(Some(binding));
}
// 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,
)
})
}
/// Internal helper to detach any playlist binding and notify observers.
///
/// 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,
};
let had_binding = renderer.get_playlist_binding();
renderer.clear_playlist_binding();
// Note: BindingChanged event is emitted automatically by MusicRenderer::clear_playlist_binding()
// only if there was a binding to remove
if let Some(binding) = had_binding {
info!(
renderer = renderer_id.0.as_str(),
server = binding.server_id.0.as_str(),
container = binding.container_id.as_str(),
reason = reason,
"Playlist binding detached"
);
} else {
debug!(
renderer = renderer_id.0.as_str(),
reason = reason,
"detach_playlist_binding: no binding to remove"
);
}
}
// Note: emit_renderer_event and handle_renderer_event have been removed.
// Renderer events are now emitted directly by each MusicRenderer's watcher thread,
// and auto-advance logic is handled internally by MusicRenderer::handle_state_change().
}
#[cfg(feature = "pmoserver")]
fn convert_runtime_position(position: Option<&PlaybackPositionInfo>) -> (Option<u64>, Option<u64>) {
match position {
Some(info) => {
let position_ms = parse_hms_to_ms(info.rel_time.as_deref());
let duration_ms = parse_hms_to_ms(info.track_duration.as_deref());
// Validate that position doesn't exceed duration
// If position > duration, the renderer is reporting invalid data
// (common during track initialization on some UPNP renderers)
match (position_ms, duration_ms) {
(Some(pos), Some(dur)) if pos > dur => {
// Position exceeds duration - invalid state during initialization
// Return None for position to avoid showing bogus timestamps
(None, duration_ms)
}
_ => (position_ms, duration_ms),
}
}
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)
}
// Note: RendererRuntimeSnapshot has been removed.
// Each MusicRenderer now maintains its own WatchedState for change detection.
/// 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();
tracing::trace!(
renderer = renderer_id.0.as_str(),
auto_play,
"refresh_attached_queue_for: auto_play flag consumed"
);
// 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: Notify UI that the queue is being refreshed (dedicated event, no state change)
event_bus.broadcast(RendererEvent::QueueRefreshing {
id: renderer_id.clone(),
});
// Step 4: Browse container (renamed from Step 3 for clarity)
const MAX_BROWSE_ATTEMPTS: usize = 3;
const BROWSE_RETRY_DELAY_MS: u64 = 200;
const BROWSE_PAGE_SIZE: u32 = 64;
// DIAGNOSTIC: Log the queue state before browsing
let pre_snapshot = renderer.queue_snapshot()?;
debug!(
renderer = renderer_id.0.as_str(),
server = server_id.0.as_str(),
container = container_id.as_str(),
pre_queue_items = pre_snapshot.items.len(),
pre_current_index = pre_snapshot.current_index,
"refresh_attached_queue_for: queue state BEFORE browse"
);
// Paginated browse — une playlist peut dépasser BROWSE_PAGE_SIZE items
let entries = {
let mut all_entries = Vec::new();
let mut offset = 0u32;
loop {
let mut attempt = 1;
let page = loop {
match music_server.browse_children(&container_id, offset, BROWSE_PAGE_SIZE) {
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;
}
}
};
let fetched = page.len() as u32;
all_entries.extend(page);
if fetched < BROWSE_PAGE_SIZE {
break;
}
offset += fetched;
}
all_entries
};
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()
// 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"
);
renderer.sync_queue(new_items)?;
// DIAGNOSTIC: Log the queue state after sync_queue
let post_snapshot = renderer.queue_snapshot()?;
debug!(
renderer = renderer_id.0.as_str(),
server = server_id.0.as_str(),
container = container_id.as_str(),
post_queue_items = post_snapshot.items.len(),
post_current_index = post_snapshot.current_index,
"refresh_attached_queue_for: queue state AFTER sync_queue"
);
let final_queue_len = post_snapshot.items.len();
// Emit QueueUpdated event
event_bus.broadcast(RendererEvent::QueueUpdated {
id: renderer_id.clone(),
queue_length: final_queue_len,
});
if auto_play {
if let Some(callback) = after_refresh.as_deref_mut() {
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"
);
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"
);
} else {
info!(
renderer = renderer_id.0.as_str(),
server = server_id.0.as_str(),
container = container_id.as_str(),
"Auto-play callback completed successfully"
);
}
} else {
debug!(
renderer = renderer_id.0.as_str(),
"Auto-play enabled but no callback provided"
);
}
} else {
debug!(
renderer = renderer_id.0.as_str(),
"Auto-play disabled, skipping playback start"
);
}
Ok(())
}
fn fetch_queue_items_for(
music_server: &Arc<crate::media_server::MusicServer>,
container_id: &str,
) -> Result<Vec<PlaybackItem>, ControlPointError> {
const MAX_BROWSE_ATTEMPTS: usize = 3;
const BROWSE_RETRY_DELAY_MS: u64 = 200;
const BROWSE_PAGE_SIZE: u32 = 64;
let entries = {
let mut all_entries = Vec::new();
let mut offset = 0u32;
loop {
let mut attempt = 1;
let page = loop {
match music_server.browse_children(container_id, offset, BROWSE_PAGE_SIZE) {
Ok(e) => break e,
Err(err) => {
if attempt >= MAX_BROWSE_ATTEMPTS {
return Err(err);
}
thread::sleep(Duration::from_millis(
BROWSE_RETRY_DELAY_MS * attempt as u64,
));
attempt += 1;
}
}
};
let fetched = page.len() as u32;
all_entries.extend(page);
if fetched < BROWSE_PAGE_SIZE {
break;
}
offset += fetched;
}
all_entries
};
let new_items: Vec<PlaybackItem> = entries
.iter()
.filter_map(|entry| playback_item_from_entry(music_server.clone(), entry))
.collect();
Ok(new_items)
}
fn schedule_queue_refresh_for(
registry: &Arc<RwLock<DeviceRegistry>>,
renderer_id: &DeviceId,
event_bus: &RendererEventBus,
after_refresh: Option<Box<dyn FnOnce(&DeviceId) -> Result<(), ControlPointError> + Send + 'static>>,
) -> SyncScheduleOutcome {
// 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(),
"schedule_queue_refresh_for: renderer not found"
);
return SyncScheduleOutcome::Scheduled;
}
};
// Check if there's a binding
let (server_id, container_id) = {
let binding = match renderer.get_playlist_binding() {
Some(b) => b,
None => {
debug!(
renderer = renderer_id.0.as_str(),
"schedule_queue_refresh_for: no binding present"
);
return SyncScheduleOutcome::Scheduled;
}
};
(binding.server_id.clone(), binding.container_id.clone())
};
// 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(),
"schedule_queue_refresh_for: server not found in registry"
);
return SyncScheduleOutcome::Scheduled;
}
};
if !music_server.is_online() {
debug!(
renderer = renderer_id.0.as_str(),
server = server_id.0.as_str(),
"schedule_queue_refresh_for: server offline, skipping refresh"
);
return SyncScheduleOutcome::Scheduled;
}
// Emit QueueRefreshing
event_bus.broadcast(RendererEvent::QueueRefreshing {
id: renderer_id.clone(),
});
// Get the queue Arc
let queue_arc = {
let reg = registry.read().unwrap();
match reg.get_renderer_queue_arc(renderer_id) {
Some(q) => q,
None => {
warn!("schedule_queue_refresh_for: could not get queue arc");
return SyncScheduleOutcome::Scheduled;
}
}
};
// Build the pending_items_fn for re-fetching
let registry_clone = Arc::clone(registry);
let server_id_clone = server_id.clone();
let container_id_clone = container_id.clone();
let pending_fn = Box::new(move || {
let music_server = {
let reg = registry_clone.read().unwrap();
reg.get_server(&server_id_clone)
};
match music_server {
Some(s) => fetch_queue_items_for(&s, &container_id_clone),
None => Err(ControlPointError::MediaServerError("Server not found".to_string())),
}
});
// Build on_ready callback
let rid = renderer_id.clone();
let bus = event_bus.clone();
let after_cb = after_refresh;
let on_ready: Option<Box<dyn FnOnce() + Send + 'static>> = Some(Box::new(move || {
bus.broadcast(RendererEvent::QueueReadyToPlay { id: rid.clone() });
if let Some(cb) = after_cb {
if let Err(e) = cb(&rid) {
warn!("auto-play callback failed: {}", e);
}
}
}));
// Build on_complete callback
let rid2 = renderer_id.clone();
let bus2 = event_bus.clone();
let on_complete = Box::new(move |queue_len: usize| {
bus2.broadcast(RendererEvent::QueueUpdated {
id: rid2.clone(),
queue_length: queue_len,
});
});
// First fetch
let items = match fetch_queue_items_for(&music_server, &container_id) {
Ok(items) => items,
Err(e) => {
warn!("schedule_queue_refresh_for: failed to fetch items: {}", e);
return SyncScheduleOutcome::Scheduled;
}
};
// Call schedule_sync
let outcome = crate::queue::MusicQueue::schedule_sync(
&queue_arc,
&renderer_id.0,
items,
pending_fn,
on_ready,
on_complete,
);
if matches!(outcome, SyncScheduleOutcome::AlreadyRunning) {
event_bus.broadcast(RendererEvent::QueueSyncCancelled {
id: renderer_id.clone(),
});
}
outcome
}
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: item.protocol_info.clone(),
bits_per_sample: None,
sample_frequency: None,
nr_audio_channels: None,
duration: metadata.and_then(|m| m.duration.clone()),
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,
duration: None,
is_continuous_stream: crate::music_renderer::is_continuous_stream_url(&item.uri),
})
}
// Note: The following helper functions have been moved to music_renderer/watcher.rs:
// - parse_optional_hms_to_secs
// - compute_logical_playback_state
// - playback_state_equal
// - playback_position_equal
// - extract_track_metadata
// - parse_hms_to_secs
// They are now used by each MusicRenderer's watcher thread for change detection.
#[cfg(feature = "pmoserver")]
fn current_track_from_playback_item(item: &PlaybackItem) -> CurrentTrackMetadata {
let meta = item.metadata.as_ref();
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()),
}
}