- Replace play_current_from_queue with new `playCurrentFromQueueWithRetry` method in control_point.rs - Replace play_next_from_queue with `playNextFromQueueWithRetry` and add panic safety in musicrenderer.rs - Implement retry logic (3 attempts, 200ms delay) for transient renderer errors like JBL Authentics 300 - Deprecate old methods without retry support
2058 lines
78 KiB
Rust
2058 lines
78 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::{playback_item_from_entry, MediaBrowser, MusicServer};
|
|
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(®istry);
|
|
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(®istry);
|
|
|
|
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(®istry);
|
|
let udn_cache_for_mdns = Arc::clone(&udn_cache);
|
|
thread::spawn(move || {
|
|
use crate::discovery::ChromecastDiscoveryManager;
|
|
use mdns_sd::{ServiceDaemon, ServiceEvent};
|
|
|
|
let mut discovery_manager =
|
|
ChromecastDiscoveryManager::new(registry_for_mdns, udn_cache_for_mdns);
|
|
|
|
debug!("Starting mDNS discovery thread for Chromecast devices");
|
|
|
|
const SERVICE_TYPE: &str = "_googlecast._tcp.local.";
|
|
|
|
let daemon = match ServiceDaemon::new() {
|
|
Ok(d) => d,
|
|
Err(e) => {
|
|
error!("Failed to create mDNS daemon: {}", e);
|
|
return;
|
|
}
|
|
};
|
|
|
|
let receiver = match daemon.browse(SERVICE_TYPE) {
|
|
Ok(r) => r,
|
|
Err(e) => {
|
|
error!("Failed to start mDNS browse for {}: {}", SERVICE_TYPE, e);
|
|
return;
|
|
}
|
|
};
|
|
|
|
debug!("mDNS discovery started for Chromecast devices");
|
|
|
|
while let Ok(event) = receiver.recv() {
|
|
match event {
|
|
ServiceEvent::ServiceResolved(info) => {
|
|
debug!(
|
|
fullname = info.get_fullname(),
|
|
host = info.get_hostname(),
|
|
port = info.get_port(),
|
|
"mDNS Chromecast service resolved"
|
|
);
|
|
discovery_manager.handle_service_resolved(&info);
|
|
}
|
|
ServiceEvent::ServiceRemoved(_service_type, fullname) => {
|
|
debug!(fullname = %fullname, "mDNS Chromecast service removed");
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
warn!("mDNS discovery receiver closed");
|
|
});
|
|
|
|
// 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(®istry),
|
|
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(®istry);
|
|
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(
|
|
®istry_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(®istry);
|
|
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(
|
|
®istry_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(®istry);
|
|
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"
|
|
);
|
|
|
|
// Note: We set playback_source to FromQueue BEFORE calling play_from_queue().
|
|
// This prevents a race condition where:
|
|
// 1. play_from_queue() is called
|
|
// 2. watcher detects STOPPED from track transition (before playback_source is set)
|
|
// 3. with source=None, is_playing_from_queue() returns false, breaking auto-advance
|
|
// By setting it BEFORE, even if STOPPED is detected, source will be FromQueue.
|
|
// Note: We no longer temporarily set playback_source to None (that caused Bug #2).
|
|
|
|
// Start playback using play_from_queue which preserves the queue
|
|
// Set source to FromQueue BEFORE calling to prevent race condition
|
|
renderer.set_playback_source(PlaybackSource::FromQueue);
|
|
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));
|
|
|
|
// Note: playback_source already set to FromQueue above
|
|
|
|
// 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(());
|
|
}
|
|
|
|
// Note: Same fix as play_current_from_queue - set source BEFORE calling.
|
|
// Set source to FromQueue BEFORE calling to prevent race condition
|
|
renderer.set_playback_source(PlaybackSource::FromQueue);
|
|
|
|
// 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(());
|
|
}
|
|
|
|
// Note: Same fix as play_current_from_queue - set source BEFORE calling.
|
|
// Set source to FromQueue BEFORE calling to prevent race condition
|
|
renderer.set_playback_source(PlaybackSource::FromQueue);
|
|
|
|
// 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))
|
|
})?;
|
|
// Use retry version to handle transient renderer errors (like JBL Authentics 300)
|
|
renderer.play_current_from_queue_with_retry()
|
|
}))
|
|
} 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))
|
|
})?;
|
|
// Use retry version to handle transient renderer errors (like JBL Authentics 300)
|
|
renderer.play_current_from_queue_with_retry()
|
|
}))
|
|
} 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()),
|
|
}
|
|
}
|