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