Cette mise à jour ajoute la gestion de la durée dans les métadonnées UPnP et OpenHome. Les changements incluent : - Ajout de la dépendance `pmoutils` dans `pmocontrol` - Mise à jour du modèle `Item` pour inclure le champ `duration` - Implémentation de la sérialisation DIDL-Lite avec `pmodidl` et `pmoutils` - Ajout de la gestion de la durée dans les renderers UPnP et OpenHome - Extraction de la durée depuis les métadonnées DIDL-Lite - Support du fallback vers la durée du DIDL lorsque l'ampli ne la retourne pas - Mise à jour des constructeurs d'items pour inclure la durée calculée pour les streams radio Ces modifications permettent une meilleure gestion des durées de pistes audio, notamment pour les streams radio avec des durées calculées à partir des timestamps de début/fin.
1712 lines
66 KiB
Rust
1712 lines
66 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, QueueBackend, QueueSnapshot};
|
|
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 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(®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"
|
|
);
|
|
|
|
if let Err(err) = refresh_attached_queue_for(
|
|
®istry_for_media_worker,
|
|
&renderer_id,
|
|
&event_bus_for_media_worker,
|
|
None,
|
|
) {
|
|
warn!(
|
|
renderer = renderer_id.0.as_str(),
|
|
server = server_id.0.as_str(),
|
|
error = %err,
|
|
"Failed to refresh queue from playlist container"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
MediaServerEvent::Online { server_id, info } => {
|
|
debug!(
|
|
server = server_id.0.as_str(),
|
|
friendly_name = info.friendly_name.as_str(),
|
|
"MediaServer came online"
|
|
);
|
|
}
|
|
MediaServerEvent::Offline { server_id } => {
|
|
debug!(server = server_id.0.as_str(), "MediaServer went offline");
|
|
}
|
|
}
|
|
}
|
|
})?;
|
|
|
|
// 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"
|
|
);
|
|
|
|
if let Err(err) = refresh_attached_queue_for(
|
|
®istry_for_periodic,
|
|
&renderer_id,
|
|
&event_bus_for_periodic,
|
|
None,
|
|
) {
|
|
warn!(
|
|
renderer = renderer_id.0.as_str(),
|
|
error = %err,
|
|
"Periodic refresh failed for bound playlist"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
})?;
|
|
|
|
// Thread de surveillance des sleep timers
|
|
// Vérifie toutes les secondes les timers actifs et émet des événements
|
|
let registry_for_timer = Arc::clone(®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,
|
|
};
|
|
|
|
Ok(FullRendererSnapshot {
|
|
state: state_view,
|
|
queue: queue_view,
|
|
binding,
|
|
})
|
|
}
|
|
|
|
/// Clears the renderer's queue.
|
|
///
|
|
/// Works for both internal queues and persistent backend queues (OpenHome).
|
|
pub fn clear_renderer_queue(&self, renderer_id: &DeviceId) -> Result<(), ControlPointError> {
|
|
let renderer = self.music_renderer_by_id(renderer_id).ok_or_else(|| {
|
|
ControlPointError::SnapshotError(format!("Renderer {} not found", renderer_id.0))
|
|
})?;
|
|
renderer.clear_queue()
|
|
}
|
|
|
|
/// Adds a track to the renderer's backend queue.
|
|
///
|
|
/// For renderers with persistent queues, this adds the track to the queue.
|
|
/// For other renderers, this returns an error.
|
|
///
|
|
/// Returns the backend-specific track ID if applicable.
|
|
pub fn add_track_to_renderer(
|
|
&self,
|
|
renderer_id: &DeviceId,
|
|
uri: &str,
|
|
metadata: &str,
|
|
after_id: Option<u32>,
|
|
play: bool,
|
|
) -> anyhow::Result<Option<u32>> {
|
|
let renderer = self
|
|
.music_renderer_by_id(renderer_id)
|
|
.ok_or_else(|| anyhow!("Renderer {} not found", renderer_id.0))?;
|
|
let track_id = renderer.add_track_to_queue(uri, metadata, after_id, play)?;
|
|
renderer.sync_queue_state()?;
|
|
Ok(track_id)
|
|
}
|
|
|
|
/// Selects and plays a specific track from the renderer's backend queue.
|
|
///
|
|
/// For renderers with persistent queues, this uses the track ID.
|
|
/// For other renderers, this returns an error.
|
|
pub fn select_renderer_track(
|
|
&self,
|
|
renderer_id: &DeviceId,
|
|
track_id: u32,
|
|
) -> Result<(), ControlPointError> {
|
|
let renderer = self.music_renderer_by_id(renderer_id).ok_or_else(|| {
|
|
ControlPointError::ControlPoint(format!("Renderer {} not found", renderer_id.0))
|
|
})?;
|
|
renderer.select_queue_track(track_id)?;
|
|
renderer.set_playback_source(PlaybackSource::FromQueue);
|
|
renderer.sync_queue_state()
|
|
}
|
|
|
|
/// Plays the current queue item without advancing the index.
|
|
///
|
|
/// 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);
|
|
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
|
|
if renderer.len()? == 0 {
|
|
debug!(
|
|
renderer = renderer_id.0.as_str(),
|
|
"play_next_from_queue: queue is empty"
|
|
);
|
|
renderer.set_playback_source(PlaybackSource::None);
|
|
return Ok(());
|
|
}
|
|
|
|
// Temporarily disable auto-advance to prevent race condition
|
|
renderer.set_playback_source(PlaybackSource::None);
|
|
|
|
// Use the backend's play_next which handles queue advancement correctly for each backend type
|
|
if let Err(err) = renderer.play_next_from_queue() {
|
|
error!(
|
|
renderer = renderer_id.0.as_str(),
|
|
error = %err,
|
|
"Failed to play next item from queue"
|
|
);
|
|
renderer.set_playback_source(PlaybackSource::None);
|
|
return Err(err);
|
|
}
|
|
|
|
// Get current item metadata for tracking
|
|
if let Some((item, _)) = renderer.peek_current()? {
|
|
let metadata = playback_item_track_metadata(&item);
|
|
renderer.set_last_metadata(Some(metadata));
|
|
}
|
|
|
|
renderer.set_playback_source(PlaybackSource::FromQueue);
|
|
debug!(
|
|
renderer = renderer_id.0.as_str(),
|
|
"Playing next item from queue"
|
|
);
|
|
|
|
// Prefetch next track if supported
|
|
self.prefetch_next_track(&renderer, renderer_id);
|
|
|
|
// 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"
|
|
),
|
|
}
|
|
}
|
|
|
|
/// 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);
|
|
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> {
|
|
// 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"
|
|
);
|
|
|
|
// Prepare the renderer for the new playlist (backend-agnostic)
|
|
let renderer = self.music_renderer_by_id(renderer_id).ok_or_else(|| {
|
|
ControlPointError::ControlPoint(format!("Renderer {} not found", renderer_id.0))
|
|
})?;
|
|
renderer.clear_for_playlist_attach()?;
|
|
|
|
// Sync backend state to local cache (backend-agnostic)
|
|
renderer.sync_queue_state()?;
|
|
|
|
// 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 mut auto_start_cb = |rid: &DeviceId| {
|
|
debug!(
|
|
renderer = rid.0.as_str(),
|
|
"Attach callback: forcing playback start (not checking if idle)"
|
|
);
|
|
self.play_current_from_queue(rid)
|
|
};
|
|
let callback: Option<&mut dyn FnMut(&DeviceId) -> Result<(), ControlPointError>> =
|
|
if auto_play {
|
|
Some(&mut auto_start_cb)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
refresh_attached_queue_for(&self.registry, renderer_id, &self.event_bus, callback)
|
|
}
|
|
|
|
/// Detach a renderer's queue from its associated playlist container.
|
|
///
|
|
/// Public mutation API paired with `attach_queue_to_playlist*`. After calling
|
|
/// this, the queue will no longer be automatically refreshed from the server.
|
|
/// If no binding existed, this is a no-op.
|
|
pub fn detach_queue_playlist(&self, renderer_id: &DeviceId) {
|
|
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();
|
|
|
|
// Step 2: Get server from registry
|
|
let music_server = {
|
|
let reg = registry.read().unwrap();
|
|
reg.get_server(&server_id)
|
|
};
|
|
|
|
let music_server = match music_server {
|
|
Some(s) => s,
|
|
None => {
|
|
warn!(
|
|
renderer = renderer_id.0.as_str(),
|
|
server = server_id.0.as_str(),
|
|
"refresh_attached_queue_for: server not found in registry"
|
|
);
|
|
return Ok(());
|
|
}
|
|
};
|
|
|
|
if !music_server.is_online() {
|
|
debug!(
|
|
renderer = renderer_id.0.as_str(),
|
|
server = server_id.0.as_str(),
|
|
"refresh_attached_queue_for: server offline, skipping refresh"
|
|
);
|
|
return Ok(());
|
|
}
|
|
|
|
// Step 3: Browse container
|
|
|
|
const MAX_BROWSE_ATTEMPTS: usize = 3;
|
|
const BROWSE_RETRY_DELAY_MS: u64 = 200;
|
|
let mut attempt = 1;
|
|
let entries = loop {
|
|
match music_server.browse_children(&container_id, 0, 64) {
|
|
Ok(e) => break e,
|
|
Err(err) => {
|
|
if attempt >= MAX_BROWSE_ATTEMPTS {
|
|
warn!(
|
|
renderer = renderer_id.0.as_str(),
|
|
server = server_id.0.as_str(),
|
|
container = container_id.as_str(),
|
|
attempts = attempt,
|
|
error = %err,
|
|
"Failed to browse playlist container for refresh"
|
|
);
|
|
return Err(err);
|
|
}
|
|
|
|
debug!(
|
|
renderer = renderer_id.0.as_str(),
|
|
server = server_id.0.as_str(),
|
|
container = container_id.as_str(),
|
|
attempt,
|
|
error = %err,
|
|
"Browse attempt failed, retrying"
|
|
);
|
|
thread::sleep(Duration::from_millis(
|
|
BROWSE_RETRY_DELAY_MS * attempt as u64,
|
|
));
|
|
attempt += 1;
|
|
}
|
|
}
|
|
};
|
|
|
|
debug!(
|
|
renderer = renderer_id.0.as_str(),
|
|
server = server_id.0.as_str(),
|
|
container = container_id.as_str(),
|
|
total_entries = entries.len(),
|
|
containers = entries.iter().filter(|e| e.is_container).count(),
|
|
items_count = entries.iter().filter(|e| !e.is_container).count(),
|
|
"Browse returned entries for playlist refresh"
|
|
);
|
|
|
|
// Step 4: Convert MediaEntry to PlaybackItem
|
|
let new_items: Vec<PlaybackItem> = entries
|
|
.iter()
|
|
.filter_map(|entry| playback_item_from_entry(music_server.clone(), entry))
|
|
.collect();
|
|
|
|
if new_items.is_empty() {
|
|
warn!(
|
|
renderer = renderer_id.0.as_str(),
|
|
server = server_id.0.as_str(),
|
|
container = container_id.as_str(),
|
|
total_entries = entries.len(),
|
|
"Refreshed playlist is empty, clearing queue"
|
|
);
|
|
renderer.clear_queue()?;
|
|
|
|
// Emit QueueUpdated event
|
|
event_bus.broadcast(RendererEvent::QueueUpdated {
|
|
id: renderer_id.clone(),
|
|
queue_length: 0,
|
|
});
|
|
|
|
return Ok(());
|
|
}
|
|
|
|
// Step 5: GENTLE SYNCHRONIZATION using sync_queue()
|
|
// 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)?;
|
|
|
|
let final_queue_len = {
|
|
let snapshot = renderer.queue_snapshot()?;
|
|
snapshot.items.len()
|
|
};
|
|
|
|
// Emit QueueUpdated event
|
|
event_bus.broadcast(RendererEvent::QueueUpdated {
|
|
id: renderer_id.clone(),
|
|
queue_length: final_queue_len,
|
|
});
|
|
|
|
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 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,
|
|
})
|
|
}
|
|
|
|
// 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()),
|
|
}
|
|
}
|