2025-12-05 19:01:33 +01:00
|
|
|
use std::collections::{HashMap, HashSet};
|
|
|
|
|
use std::hash::{DefaultHasher, Hash, Hasher};
|
|
|
|
|
use std::io::{self, BufRead, BufReader, Read, Write};
|
2025-12-06 13:37:42 +01:00
|
|
|
use std::marker::PhantomData;
|
2025-12-15 11:18:58 +01:00
|
|
|
use std::net::{IpAddr, TcpListener, TcpStream, UdpSocket};
|
2025-12-06 13:37:42 +01:00
|
|
|
use std::ops::{Deref, DerefMut};
|
|
|
|
|
use std::sync::{Arc, Mutex, MutexGuard, RwLock};
|
2025-11-29 18:56:11 +01:00
|
|
|
use std::thread;
|
2025-12-05 19:01:33 +01:00
|
|
|
use std::time::{Duration, Instant};
|
2025-11-29 18:56:11 +01:00
|
|
|
|
2025-12-17 21:41:24 +01:00
|
|
|
use anyhow::{anyhow, Context};
|
|
|
|
|
use crossbeam_channel::{unbounded, Receiver, Sender};
|
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-05 21:59:43 +01:00
|
|
|
use thiserror::Error;
|
2025-12-17 20:45:51 +01:00
|
|
|
use tracing::{debug, error, info, trace, warn};
|
2025-12-17 21:41:24 +01:00
|
|
|
use ureq::{http, Agent};
|
2025-12-05 19:01:33 +01:00
|
|
|
use xmltree::{Element, XMLNode};
|
2025-11-29 18:56:11 +01:00
|
|
|
|
2025-12-06 13:37:42 +01:00
|
|
|
pub mod music_queue;
|
|
|
|
|
pub mod openhome_queue;
|
|
|
|
|
|
2025-12-17 21:41:24 +01:00
|
|
|
pub const OPENHOME_SNAPSHOT_CACHE_TTL: Duration = Duration::from_secs(2);
|
|
|
|
|
|
2025-12-01 19:06:07 +01:00
|
|
|
use crate::capabilities::{
|
2025-12-01 19:55:55 +01:00
|
|
|
PlaybackPosition, PlaybackPositionInfo, PlaybackState, PlaybackStatus, TransportControl,
|
|
|
|
|
VolumeControl,
|
2025-12-01 19:06:07 +01:00
|
|
|
};
|
2025-12-15 11:18:58 +01:00
|
|
|
use crate::control_point::music_queue::MusicQueue;
|
|
|
|
|
use crate::control_point::openhome_queue::OpenHomeQueue;
|
2025-11-29 18:56:11 +01:00
|
|
|
use crate::discovery::DiscoveryManager;
|
2025-12-01 23:10:16 +01:00
|
|
|
use crate::events::{MediaServerEventBus, RendererEventBus};
|
2025-12-06 13:37:42 +01:00
|
|
|
use crate::media_server::{MediaBrowser, MediaEntry, MediaServerInfo, MusicServer, ServerId};
|
2025-12-01 23:10:16 +01:00
|
|
|
use crate::media_server_events::spawn_media_server_event_runtime;
|
2025-12-03 18:59:41 +01:00
|
|
|
use crate::model::TrackMetadata;
|
2025-12-05 19:01:33 +01:00
|
|
|
use crate::model::{MediaServerEvent, RendererEvent, RendererId, RendererInfo};
|
2025-12-15 11:18:58 +01:00
|
|
|
use crate::music_renderer::{
|
2025-12-17 21:41:24 +01:00
|
|
|
set_openhome_queue_provider, OpenHomeQueueProvider, RendererRuntimeState,
|
2025-12-15 11:18:58 +01:00
|
|
|
};
|
2025-12-06 00:38:06 +01:00
|
|
|
#[cfg(feature = "pmoserver")]
|
|
|
|
|
use crate::openapi::{
|
|
|
|
|
CurrentTrackMetadata, FullRendererSnapshot, QueueItem, QueueSnapshotView, RendererBindingView,
|
|
|
|
|
RendererStateView,
|
|
|
|
|
};
|
2025-12-17 21:41:24 +01:00
|
|
|
use crate::openhome::{
|
|
|
|
|
build_info_client, build_playlist_client, build_product_client, OhServiceKind,
|
|
|
|
|
};
|
|
|
|
|
use crate::openhome_client::parse_track_metadata_from_didl;
|
2025-12-05 21:59:43 +01:00
|
|
|
use crate::openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack};
|
2025-12-05 19:01:33 +01:00
|
|
|
use crate::openhome_renderer::{format_seconds, map_openhome_state};
|
2025-11-29 18:56:11 +01:00
|
|
|
use crate::provider::HttpXmlDescriptionProvider;
|
2025-12-15 11:18:58 +01:00
|
|
|
use crate::queue_backend::{EnqueueMode, PlaybackItem, QueueBackend};
|
|
|
|
|
use crate::queue_interne::InternalQueue;
|
2025-11-30 09:26:33 +01:00
|
|
|
use crate::registry::{DeviceRegistry, DeviceRegistryRead, DeviceUpdate};
|
2025-11-30 10:54:01 +01:00
|
|
|
use crate::upnp_renderer::UpnpRenderer;
|
2025-12-17 21:41:24 +01:00
|
|
|
use crate::MusicRenderer;
|
2025-11-29 18:56:11 +01:00
|
|
|
|
2025-12-02 14:41:53 +01:00
|
|
|
/// Optional attachment between a renderer playback queue and a server-side
|
|
|
|
|
/// DIDL-Lite playlist container.
|
|
|
|
|
///
|
|
|
|
|
/// When a queue is bound to a playlist, the control point will automatically
|
|
|
|
|
/// refresh it whenever the server notifies us of changes to that container.
|
|
|
|
|
/// User-driven mutations (clear, enqueue, etc.) break the binding automatically.
|
|
|
|
|
#[derive(Clone, Debug)]
|
2025-12-05 07:08:03 +01:00
|
|
|
pub struct PlaylistBinding {
|
2025-12-02 14:41:53 +01:00
|
|
|
/// MediaServer that owns the playlist container.
|
2025-12-05 07:08:03 +01:00
|
|
|
pub server_id: ServerId,
|
2025-12-02 14:41:53 +01:00
|
|
|
/// DIDL-Lite object id of the playlist container.
|
2025-12-05 07:08:03 +01:00
|
|
|
pub container_id: String,
|
2025-12-02 14:41:53 +01:00
|
|
|
/// True once at least one ContainerUpdateIDs notification has been seen.
|
2025-12-05 07:08:03 +01:00
|
|
|
pub(crate) has_seen_update: bool,
|
2025-12-02 14:41:53 +01:00
|
|
|
/// Flag used internally to signal that the queue should be refreshed
|
|
|
|
|
/// from the server container.
|
2025-12-05 07:08:03 +01:00
|
|
|
pub(crate) pending_refresh: bool,
|
2025-12-03 23:17:41 +01:00
|
|
|
/// Whether the next refresh should auto-start playback if the renderer is idle.
|
2025-12-05 07:08:03 +01:00
|
|
|
pub(crate) auto_play_on_refresh: bool,
|
2025-12-02 14:41:53 +01:00
|
|
|
}
|
|
|
|
|
|
2025-12-05 21:59:43 +01:00
|
|
|
#[derive(Debug, Error)]
|
|
|
|
|
pub enum OpenHomeAccessError {
|
|
|
|
|
#[error("Renderer {0} not found")]
|
|
|
|
|
RendererNotFound(String),
|
|
|
|
|
#[error("Renderer {0} has no OpenHome playlist service")]
|
|
|
|
|
PlaylistNotSupported(String),
|
|
|
|
|
}
|
|
|
|
|
|
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>>,
|
2025-11-30 10:54:01 +01:00
|
|
|
event_bus: RendererEventBus,
|
2025-12-01 23:10:16 +01:00
|
|
|
media_event_bus: MediaServerEventBus,
|
2025-12-01 19:55:55 +01:00
|
|
|
runtime: Arc<RuntimeState>,
|
2025-12-02 14:41:53 +01:00
|
|
|
/// Optional attachment between a renderer playback queue and a
|
|
|
|
|
/// server-side DIDL-Lite playlist container.
|
|
|
|
|
///
|
|
|
|
|
/// Key : RendererId
|
|
|
|
|
/// Value : PlaylistBinding
|
|
|
|
|
playlist_bindings: Arc<Mutex<HashMap<RendererId, PlaylistBinding>>>,
|
2025-12-22 10:32:48 +01:00
|
|
|
/// Cache of MusicRenderer instances to avoid recreating them.
|
|
|
|
|
/// This is critical for Chromecast which maintains a persistent TLS connection.
|
|
|
|
|
///
|
|
|
|
|
/// Key : RendererId
|
|
|
|
|
/// Value : MusicRenderer
|
|
|
|
|
renderer_cache: Arc<Mutex<HashMap<RendererId, MusicRenderer>>>,
|
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> {
|
|
|
|
|
let registry = Arc::new(RwLock::new(DeviceRegistry::new()));
|
2025-11-30 10:54:01 +01:00
|
|
|
let event_bus = RendererEventBus::new();
|
2025-12-01 23:10:16 +01:00
|
|
|
let media_event_bus = MediaServerEventBus::new();
|
2025-12-01 19:55:55 +01:00
|
|
|
let runtime = Arc::new(RuntimeState::new());
|
2025-12-06 13:37:42 +01:00
|
|
|
set_openhome_queue_provider(Arc::new(RuntimeOpenHomeQueueProvider {
|
|
|
|
|
runtime: Arc::clone(&runtime),
|
|
|
|
|
}));
|
2025-12-02 14:41:53 +01:00
|
|
|
let playlist_bindings = Arc::new(Mutex::new(HashMap::new()));
|
2025-12-22 10:32:48 +01:00
|
|
|
let renderer_cache = Arc::new(Mutex::new(HashMap::new()));
|
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);
|
2025-12-26 19:44:19 +01:00
|
|
|
let event_bus_for_discovery = event_bus.clone();
|
|
|
|
|
let media_event_bus_for_discovery = media_event_bus.clone();
|
2025-11-29 18:56:11 +01:00
|
|
|
|
|
|
|
|
// Thread de découverte
|
|
|
|
|
thread::spawn(move || {
|
|
|
|
|
// Provider HTTP+XML et DiscoveryManager VIVENT dans le thread
|
|
|
|
|
let provider = HttpXmlDescriptionProvider::new(timeout_secs);
|
|
|
|
|
let mut discovery = DiscoveryManager::new(provider);
|
|
|
|
|
|
|
|
|
|
// 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));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// La closure passée à run_event_loop capture discovery par mutable borrow
|
|
|
|
|
// => FnMut, ce que SsdpClient::run_event_loop accepte.
|
|
|
|
|
client.run_event_loop(move |event| {
|
|
|
|
|
let updates: Vec<DeviceUpdate> = discovery.handle_ssdp_event(event);
|
|
|
|
|
|
|
|
|
|
if updates.is_empty() {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if let Ok(mut reg) = registry_for_thread.write() {
|
|
|
|
|
for update in updates {
|
2025-12-26 19:44:19 +01:00
|
|
|
// Émettre les événements Online/Offline avant d'appliquer l'update
|
|
|
|
|
match &update {
|
|
|
|
|
DeviceUpdate::RendererOnline(info) => {
|
|
|
|
|
event_bus_for_discovery.broadcast(RendererEvent::Online {
|
|
|
|
|
id: info.id.clone(),
|
|
|
|
|
info: info.clone(),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
DeviceUpdate::RendererOfflineById(id) => {
|
|
|
|
|
event_bus_for_discovery.broadcast(RendererEvent::Offline {
|
|
|
|
|
id: id.clone(),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
DeviceUpdate::RendererOfflineByUdn(udn) => {
|
|
|
|
|
// Trouver l'ID avant de marquer offline
|
|
|
|
|
if let Some(renderer) = reg.get_renderer_by_udn(udn) {
|
|
|
|
|
event_bus_for_discovery.broadcast(RendererEvent::Offline {
|
|
|
|
|
id: renderer.id.clone(),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
DeviceUpdate::ServerOnline(info) => {
|
|
|
|
|
media_event_bus_for_discovery.broadcast(MediaServerEvent::Online {
|
|
|
|
|
server_id: info.id.clone(),
|
|
|
|
|
info: info.clone(),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
DeviceUpdate::ServerOfflineById(id) => {
|
|
|
|
|
media_event_bus_for_discovery.broadcast(MediaServerEvent::Offline {
|
|
|
|
|
server_id: id.clone(),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
DeviceUpdate::ServerOfflineByUdn(udn) => {
|
|
|
|
|
// Trouver l'ID avant de marquer offline
|
|
|
|
|
if let Some(server) = reg.get_server_by_udn(udn) {
|
|
|
|
|
media_event_bus_for_discovery.broadcast(MediaServerEvent::Offline {
|
|
|
|
|
server_id: server.id.clone(),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-29 18:56:11 +01:00
|
|
|
reg.apply_update(update);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2025-12-26 19:44:19 +01:00
|
|
|
// Thread de renouvellement périodique des M-SEARCH
|
|
|
|
|
// Envoie des requêtes de découverte toutes les 60 secondes pour forcer
|
|
|
|
|
// les nouveaux appareils à se présenter
|
|
|
|
|
thread::spawn(move || {
|
|
|
|
|
let search_targets = [
|
|
|
|
|
"ssdp:all",
|
|
|
|
|
"urn:schemas-upnp-org:device:MediaRenderer:1",
|
|
|
|
|
"urn:av-openhome-org:device:MediaRenderer:1",
|
|
|
|
|
"urn:schemas-upnp-org:device:MediaServer:1",
|
|
|
|
|
"urn:schemas-wiimu-com:service:PlayQueue:1",
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
loop {
|
|
|
|
|
// Attendre 60 secondes avant le prochain cycle
|
|
|
|
|
thread::sleep(Duration::from_secs(60));
|
|
|
|
|
|
|
|
|
|
debug!("Sending periodic M-SEARCH for device discovery");
|
|
|
|
|
|
|
|
|
|
// Envoyer les M-SEARCH
|
|
|
|
|
for st in &search_targets {
|
|
|
|
|
if let Err(e) = client_for_renewal.send_msearch(st, 3) {
|
|
|
|
|
warn!("Failed to send periodic M-SEARCH for {}: {}", st, e);
|
|
|
|
|
}
|
|
|
|
|
thread::sleep(Duration::from_millis(200));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Thread de vérification de présence périodique
|
|
|
|
|
// Vérifie toutes les 60 secondes que les devices connus sont toujours accessibles
|
|
|
|
|
let registry_for_presence = Arc::clone(®istry);
|
|
|
|
|
let event_bus_for_presence = event_bus.clone();
|
|
|
|
|
let media_event_bus_for_presence = media_event_bus.clone();
|
|
|
|
|
thread::spawn(move || {
|
|
|
|
|
use ureq::Agent;
|
|
|
|
|
|
|
|
|
|
// HTTP client avec timeout court pour les vérifications de présence
|
|
|
|
|
let config = Agent::config_builder()
|
|
|
|
|
.timeout_global(Some(Duration::from_secs(5)))
|
|
|
|
|
.build();
|
|
|
|
|
let agent: Agent = config.into();
|
|
|
|
|
|
|
|
|
|
loop {
|
|
|
|
|
// Attendre 60 secondes avant le prochain cycle
|
|
|
|
|
thread::sleep(Duration::from_secs(60));
|
|
|
|
|
|
|
|
|
|
debug!("Starting periodic presence check for devices");
|
|
|
|
|
|
|
|
|
|
let mut updates = Vec::new();
|
|
|
|
|
|
|
|
|
|
// Lire la liste des devices
|
|
|
|
|
if let Ok(reg) = registry_for_presence.read() {
|
|
|
|
|
// Vérifier les renderers
|
|
|
|
|
for renderer in reg.list_renderers() {
|
|
|
|
|
if !renderer.online {
|
|
|
|
|
continue; // Skip déjà offline
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Faire un HTTP HEAD pour vérifier la présence
|
|
|
|
|
match agent.head(&renderer.location).call() {
|
|
|
|
|
Ok(_) => {
|
|
|
|
|
// Device répond toujours
|
|
|
|
|
debug!("Renderer {} ({:?}) is still online",
|
|
|
|
|
renderer.friendly_name, renderer.id);
|
|
|
|
|
}
|
|
|
|
|
Err(e) => {
|
|
|
|
|
// Device ne répond plus
|
|
|
|
|
warn!("Renderer {} ({:?}) is no longer responding: {} - marking offline",
|
|
|
|
|
renderer.friendly_name, renderer.id, e);
|
|
|
|
|
updates.push(DeviceUpdate::RendererOfflineById(renderer.id));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Vérifier les servers
|
|
|
|
|
for server in reg.list_servers() {
|
|
|
|
|
if !server.online {
|
|
|
|
|
continue; // Skip déjà offline
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Faire un HTTP HEAD pour vérifier la présence
|
|
|
|
|
match agent.head(&server.location).call() {
|
|
|
|
|
Ok(_) => {
|
|
|
|
|
// Device répond toujours
|
|
|
|
|
debug!("Server {} ({:?}) is still online",
|
|
|
|
|
server.friendly_name, server.id);
|
|
|
|
|
}
|
|
|
|
|
Err(e) => {
|
|
|
|
|
// Device ne répond plus
|
|
|
|
|
warn!("Server {} ({:?}) is no longer responding: {} - marking offline",
|
|
|
|
|
server.friendly_name, server.id, e);
|
|
|
|
|
updates.push(DeviceUpdate::ServerOfflineById(server.id));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Appliquer les updates et émettre les événements
|
|
|
|
|
if !updates.is_empty() {
|
|
|
|
|
if let Ok(mut reg) = registry_for_presence.write() {
|
|
|
|
|
for update in updates {
|
|
|
|
|
// Émettre les événements Offline
|
|
|
|
|
match &update {
|
|
|
|
|
DeviceUpdate::RendererOfflineById(id) => {
|
|
|
|
|
event_bus_for_presence.broadcast(RendererEvent::Offline {
|
|
|
|
|
id: id.clone(),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
DeviceUpdate::ServerOfflineById(id) => {
|
|
|
|
|
media_event_bus_for_presence.broadcast(MediaServerEvent::Offline {
|
|
|
|
|
server_id: id.clone(),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
reg.apply_update(update);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2025-12-21 21:24:55 +01:00
|
|
|
// Thread de découverte mDNS pour Chromecast
|
|
|
|
|
let registry_for_mdns = Arc::clone(®istry);
|
|
|
|
|
thread::spawn(move || {
|
|
|
|
|
use crate::chromecast_discovery;
|
|
|
|
|
use futures_util::StreamExt;
|
|
|
|
|
|
|
|
|
|
debug!("Starting mDNS discovery thread for Chromecast devices");
|
|
|
|
|
|
|
|
|
|
const SERVICE_NAME: &str = "_googlecast._tcp.local";
|
|
|
|
|
|
|
|
|
|
// Run async discovery in a blocking task
|
|
|
|
|
async_std::task::block_on(async {
|
2025-12-22 10:32:48 +01:00
|
|
|
// Create mDNS discovery stream with 15 second query interval
|
|
|
|
|
// (shorter interval for faster initial discovery)
|
|
|
|
|
match mdns::discover::all(SERVICE_NAME, Duration::from_secs(15)) {
|
2025-12-21 21:24:55 +01:00
|
|
|
Ok(discovery) => {
|
|
|
|
|
let stream = discovery.listen();
|
|
|
|
|
futures_util::pin_mut!(stream);
|
|
|
|
|
|
|
|
|
|
debug!("mDNS discovery stream started for Chromecast devices");
|
|
|
|
|
|
|
|
|
|
// Listen to mDNS responses
|
|
|
|
|
while let Some(result) = stream.next().await {
|
|
|
|
|
match result {
|
|
|
|
|
Ok(response) => {
|
|
|
|
|
debug!("Received mDNS response with {} records",
|
|
|
|
|
response.records().count());
|
|
|
|
|
|
|
|
|
|
// Process the mDNS response
|
|
|
|
|
if let Some(update) = chromecast_discovery::process_mdns_response(response) {
|
|
|
|
|
debug!("Processed Chromecast device update: {:?}", update);
|
|
|
|
|
|
|
|
|
|
// Update the registry
|
|
|
|
|
let mut registry = registry_for_mdns.write().unwrap();
|
|
|
|
|
registry.apply_update(update);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Err(e) => {
|
|
|
|
|
warn!("mDNS discovery error: {}", e);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
warn!("mDNS discovery stream ended unexpectedly");
|
|
|
|
|
}
|
|
|
|
|
Err(e) => {
|
|
|
|
|
error!("Failed to start mDNS discovery: {}", e);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2025-11-30 10:54:01 +01:00
|
|
|
let runtime_cp = ControlPoint {
|
|
|
|
|
registry: Arc::clone(®istry),
|
|
|
|
|
event_bus: event_bus.clone(),
|
2025-12-01 23:10:16 +01:00
|
|
|
media_event_bus: media_event_bus.clone(),
|
2025-12-01 19:55:55 +01:00
|
|
|
runtime: Arc::clone(&runtime),
|
2025-12-02 14:41:53 +01:00
|
|
|
playlist_bindings: Arc::clone(&playlist_bindings),
|
2025-12-22 10:32:48 +01:00
|
|
|
renderer_cache: Arc::clone(&renderer_cache),
|
2025-11-30 10:54:01 +01:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
thread::spawn(move || {
|
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 {
|
2025-12-01 20:40:24 +01:00
|
|
|
let infos = {
|
2025-11-30 10:54:01 +01:00
|
|
|
let reg = runtime_cp.registry.read().unwrap();
|
|
|
|
|
reg.list_renderers()
|
|
|
|
|
};
|
2025-12-22 10:32:48 +01:00
|
|
|
|
|
|
|
|
// Build a map of current renderer IDs for cleanup
|
|
|
|
|
let current_ids: HashSet<RendererId> = infos.iter().map(|i| i.id.clone()).collect();
|
|
|
|
|
|
|
|
|
|
// Remove offline renderers from shared cache
|
|
|
|
|
{
|
|
|
|
|
let mut cache = runtime_cp.renderer_cache.lock().unwrap();
|
|
|
|
|
cache.retain(|id, _| current_ids.contains(id));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Get or create renderers from shared cache
|
|
|
|
|
let renderers: Vec<MusicRenderer> = infos
|
2025-12-01 20:40:24 +01:00
|
|
|
.into_iter()
|
2025-12-03 18:59:41 +01:00
|
|
|
.filter_map(|info| {
|
2025-12-22 10:32:48 +01:00
|
|
|
let id = info.id.clone();
|
|
|
|
|
|
|
|
|
|
// Try to get from cache first
|
|
|
|
|
{
|
|
|
|
|
let cache = runtime_cp.renderer_cache.lock().unwrap();
|
|
|
|
|
if let Some(renderer) = cache.get(&id) {
|
|
|
|
|
return Some(renderer.clone());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Create new renderer and add to cache
|
|
|
|
|
if let Some(renderer) = MusicRenderer::from_registry_info(info, &runtime_cp.registry) {
|
|
|
|
|
let mut cache = runtime_cp.renderer_cache.lock().unwrap();
|
|
|
|
|
cache.insert(id, renderer.clone());
|
|
|
|
|
Some(renderer)
|
|
|
|
|
} else {
|
|
|
|
|
None
|
|
|
|
|
}
|
2025-12-03 18:59:41 +01:00
|
|
|
})
|
2025-12-22 10:32:48 +01:00
|
|
|
.collect();
|
2025-11-30 10:54:01 +01:00
|
|
|
|
|
|
|
|
for renderer in renderers {
|
2025-11-30 14:43:29 +01:00
|
|
|
let info = renderer.info();
|
2025-11-30 10:54:01 +01:00
|
|
|
|
|
|
|
|
if !info.online {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-05 19:01:33 +01:00
|
|
|
let backend = if info.capabilities.has_oh_playlist {
|
|
|
|
|
PlaylistBackend::OpenHome
|
|
|
|
|
} else {
|
|
|
|
|
PlaylistBackend::PMOQueue
|
|
|
|
|
};
|
|
|
|
|
let previous_backend = runtime_cp.runtime.playlist_backend(&info.id);
|
2025-12-22 10:32:48 +01:00
|
|
|
let runtime_entry_exists = runtime_cp.runtime.has_entry(&info.id);
|
|
|
|
|
|
|
|
|
|
// Initialize queue if: backend changed OR runtime entry doesn't exist yet
|
|
|
|
|
if previous_backend != backend || !runtime_entry_exists {
|
2025-12-05 21:59:43 +01:00
|
|
|
runtime_cp.runtime.set_playlist_backend(&info.id, backend);
|
2025-12-06 13:37:42 +01:00
|
|
|
match backend {
|
|
|
|
|
PlaylistBackend::OpenHome => {
|
|
|
|
|
if let Some(queue) = build_openhome_queue(info) {
|
|
|
|
|
runtime_cp
|
|
|
|
|
.runtime
|
|
|
|
|
.set_music_queue(&info.id, MusicQueue::OpenHome(queue));
|
|
|
|
|
} else {
|
|
|
|
|
runtime_cp.runtime.set_music_queue(
|
|
|
|
|
&info.id,
|
|
|
|
|
MusicQueue::Internal(InternalQueue::new()),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
PlaylistBackend::PMOQueue => {
|
2025-12-15 11:18:58 +01:00
|
|
|
runtime_cp.runtime.set_music_queue(
|
|
|
|
|
&info.id,
|
|
|
|
|
MusicQueue::Internal(InternalQueue::new()),
|
|
|
|
|
);
|
2025-12-06 13:37:42 +01:00
|
|
|
}
|
|
|
|
|
}
|
2025-12-05 19:01:33 +01:00
|
|
|
if matches!(backend, PlaylistBackend::OpenHome) {
|
|
|
|
|
if let Err(err) = sync_openhome_playlist(
|
|
|
|
|
&runtime_cp.registry,
|
|
|
|
|
&runtime_cp.runtime,
|
|
|
|
|
&runtime_cp.event_bus,
|
|
|
|
|
&info.id,
|
|
|
|
|
) {
|
|
|
|
|
debug!(
|
|
|
|
|
renderer = info.id.0.as_str(),
|
|
|
|
|
error = %err,
|
|
|
|
|
"Initial OpenHome playlist sync failed"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-11-30 10:54:01 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let renderer_id = info.id.clone();
|
2025-12-01 19:55:55 +01:00
|
|
|
let prev_snapshot = runtime_cp.runtime.snapshot_for(&renderer_id);
|
|
|
|
|
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 {
|
|
|
|
|
runtime_cp.emit_renderer_event(RendererEvent::PositionChanged {
|
|
|
|
|
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"
|
|
|
|
|
);
|
2025-12-05 21:59:43 +01:00
|
|
|
runtime_cp.emit_renderer_event(
|
|
|
|
|
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) {
|
2025-11-30 10:54:01 +01:00
|
|
|
runtime_cp.emit_renderer_event(RendererEvent::StateChanged {
|
|
|
|
|
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) {
|
|
|
|
|
runtime_cp.emit_renderer_event(RendererEvent::VolumeChanged {
|
|
|
|
|
id: renderer_id.clone(),
|
|
|
|
|
volume,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
new_snapshot.last_volume = Some(volume);
|
2025-12-01 19:06:07 +01:00
|
|
|
}
|
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) {
|
|
|
|
|
runtime_cp.emit_renderer_event(RendererEvent::MuteChanged {
|
|
|
|
|
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
|
|
|
|
|
|
|
|
runtime_cp
|
|
|
|
|
.runtime
|
|
|
|
|
.update_snapshot(&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-05 19:01:33 +01:00
|
|
|
let (oh_event_tx, oh_event_rx) = unbounded::<RendererEvent>();
|
|
|
|
|
let event_forwarder_cp = ControlPoint {
|
|
|
|
|
registry: Arc::clone(®istry),
|
|
|
|
|
event_bus: event_bus.clone(),
|
|
|
|
|
media_event_bus: media_event_bus.clone(),
|
|
|
|
|
runtime: Arc::clone(&runtime),
|
|
|
|
|
playlist_bindings: Arc::clone(&playlist_bindings),
|
2025-12-22 10:32:48 +01:00
|
|
|
renderer_cache: Arc::clone(&renderer_cache),
|
2025-12-05 19:01:33 +01:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
thread::Builder::new()
|
|
|
|
|
.name("cp-openhome-event-forwarder".into())
|
|
|
|
|
.spawn(move || {
|
|
|
|
|
while let Ok(event) = oh_event_rx.recv() {
|
|
|
|
|
event_forwarder_cp.emit_renderer_event(event);
|
|
|
|
|
}
|
|
|
|
|
})?;
|
|
|
|
|
|
|
|
|
|
spawn_openhome_event_runtime(
|
|
|
|
|
Arc::clone(®istry),
|
|
|
|
|
Arc::clone(&runtime),
|
|
|
|
|
event_bus.clone(),
|
|
|
|
|
oh_event_tx,
|
2025-12-22 10:26:34 +01:00
|
|
|
Arc::clone(&playlist_bindings),
|
2025-12-05 19:01:33 +01:00
|
|
|
)?;
|
|
|
|
|
|
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);
|
|
|
|
|
let runtime_for_media_worker = Arc::clone(&runtime);
|
|
|
|
|
let bindings_for_media_worker = Arc::clone(&playlist_bindings);
|
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())
|
2025-12-17 21:41:24 +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
|
|
|
|
2025-12-17 21:41:24 +01:00
|
|
|
match event {
|
|
|
|
|
MediaServerEvent::GlobalUpdated {
|
|
|
|
|
server_id,
|
|
|
|
|
system_update_id,
|
|
|
|
|
} => {
|
|
|
|
|
info!(
|
|
|
|
|
server = server_id.0.as_str(),
|
|
|
|
|
system_update_id = system_update_id,
|
|
|
|
|
"MediaServer global update"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
MediaServerEvent::ContainersUpdated {
|
|
|
|
|
server_id,
|
|
|
|
|
container_ids,
|
|
|
|
|
} => {
|
|
|
|
|
let renderers_to_refresh: Vec<RendererId> = {
|
|
|
|
|
let mut bindings = bindings_for_media_worker.lock().unwrap();
|
|
|
|
|
let mut to_refresh = Vec::new();
|
|
|
|
|
|
|
|
|
|
for (renderer_id, binding) in bindings.iter_mut() {
|
|
|
|
|
if binding.server_id == server_id
|
|
|
|
|
&& container_ids.contains(&binding.container_id)
|
|
|
|
|
{
|
|
|
|
|
binding.pending_refresh = true;
|
|
|
|
|
binding.has_seen_update = true;
|
|
|
|
|
to_refresh.push(renderer_id.clone());
|
2025-12-02 14:41:53 +01:00
|
|
|
}
|
2025-12-17 21:41:24 +01:00
|
|
|
}
|
2025-12-02 14:41:53 +01:00
|
|
|
|
2025-12-17 21:41:24 +01:00
|
|
|
to_refresh
|
|
|
|
|
};
|
2025-12-02 14:41:53 +01:00
|
|
|
|
2025-12-17 21:41:24 +01:00
|
|
|
for renderer_id in renderers_to_refresh {
|
|
|
|
|
debug!(
|
|
|
|
|
renderer = renderer_id.0.as_str(),
|
|
|
|
|
server = server_id.0.as_str(),
|
|
|
|
|
"Triggering queue refresh for bound playlist"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if let Err(err) = refresh_attached_queue_for(
|
|
|
|
|
®istry_for_media_worker,
|
|
|
|
|
&runtime_for_media_worker,
|
|
|
|
|
&bindings_for_media_worker,
|
|
|
|
|
&renderer_id,
|
|
|
|
|
&event_bus_for_media_worker,
|
|
|
|
|
None,
|
|
|
|
|
) {
|
|
|
|
|
warn!(
|
2025-12-02 14:41:53 +01:00
|
|
|
renderer = renderer_id.0.as_str(),
|
|
|
|
|
server = server_id.0.as_str(),
|
2025-12-17 21:41:24 +01:00
|
|
|
error = %err,
|
|
|
|
|
"Failed to refresh queue from playlist container"
|
2025-12-02 14:41:53 +01:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-12-26 19:44:19 +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-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);
|
|
|
|
|
let runtime_for_periodic = Arc::clone(&runtime);
|
|
|
|
|
let bindings_for_periodic = Arc::clone(&playlist_bindings);
|
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
|
|
|
|
|
let renderers_to_refresh: Vec<RendererId> = {
|
|
|
|
|
let mut bindings = bindings_for_periodic.lock().unwrap();
|
|
|
|
|
let mut to_refresh = Vec::new();
|
|
|
|
|
|
|
|
|
|
for (renderer_id, binding) in bindings.iter_mut() {
|
|
|
|
|
binding.pending_refresh = true;
|
|
|
|
|
to_refresh.push(renderer_id.clone());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
to_refresh
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 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,
|
|
|
|
|
&runtime_for_periodic,
|
|
|
|
|
&bindings_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"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})?;
|
|
|
|
|
|
2025-11-30 10:54:01 +01:00
|
|
|
Ok(Self {
|
|
|
|
|
registry,
|
|
|
|
|
event_bus,
|
2025-12-01 23:10:16 +01:00
|
|
|
media_event_bus,
|
2025-12-01 19:55:55 +01:00
|
|
|
runtime,
|
2025-12-02 14:41:53 +01:00
|
|
|
playlist_bindings,
|
2025-12-22 10:32:48 +01:00
|
|
|
renderer_cache,
|
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
|
|
|
|
|
|
|
|
/// Snapshot list of renderers currently known by the registry.
|
2025-11-30 10:54:01 +01:00
|
|
|
pub fn list_upnp_renderers(&self) -> Vec<UpnpRenderer> {
|
2025-12-01 20:40:24 +01:00
|
|
|
let infos = {
|
|
|
|
|
let reg = self.registry.read().unwrap();
|
|
|
|
|
reg.list_renderers()
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
infos
|
2025-11-30 09:26:33 +01:00
|
|
|
.into_iter()
|
2025-12-01 20:40:24 +01:00
|
|
|
.map(|info| UpnpRenderer::from_registry(info, &self.registry))
|
2025-11-30 09:26:33 +01:00
|
|
|
.collect()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Return the first renderer in the registry, if any.
|
2025-11-30 10:54:01 +01:00
|
|
|
pub fn default_upnp_renderer(&self) -> Option<UpnpRenderer> {
|
2025-12-01 20:40:24 +01:00
|
|
|
let info = {
|
|
|
|
|
let reg = self.registry.read().unwrap();
|
|
|
|
|
reg.list_renderers().into_iter().next()
|
|
|
|
|
}?;
|
|
|
|
|
|
|
|
|
|
Some(UpnpRenderer::from_registry(info, &self.registry))
|
2025-11-30 09:26:33 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Lookup a renderer by id.
|
2025-11-30 10:54:01 +01:00
|
|
|
pub fn upnp_renderer_by_id(&self, id: &RendererId) -> Option<UpnpRenderer> {
|
2025-12-01 20:40:24 +01:00
|
|
|
let info = {
|
|
|
|
|
let reg = self.registry.read().unwrap();
|
|
|
|
|
reg.get_renderer(id)
|
|
|
|
|
}?;
|
|
|
|
|
|
|
|
|
|
Some(UpnpRenderer::from_registry(info, &self.registry))
|
2025-11-30 09:26:33 +01:00
|
|
|
}
|
2025-11-30 10:54:01 +01:00
|
|
|
|
2025-12-22 10:32:48 +01:00
|
|
|
/// Internal helper to get or create a renderer from the cache.
|
|
|
|
|
/// This ensures that Chromecast renderers maintain their persistent connections.
|
|
|
|
|
fn get_or_create_renderer(&self, info: RendererInfo) -> Option<MusicRenderer> {
|
|
|
|
|
let id = info.id.clone();
|
|
|
|
|
|
|
|
|
|
// Try to get from cache first
|
|
|
|
|
{
|
|
|
|
|
let cache = self.renderer_cache.lock().unwrap();
|
|
|
|
|
if let Some(renderer) = cache.get(&id) {
|
|
|
|
|
return Some(renderer.clone());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Not in cache, create new renderer
|
|
|
|
|
if let Some(renderer) = MusicRenderer::from_registry_info(info, &self.registry) {
|
|
|
|
|
// Add to cache
|
|
|
|
|
let mut cache = self.renderer_cache.lock().unwrap();
|
|
|
|
|
cache.insert(id, renderer.clone());
|
|
|
|
|
Some(renderer)
|
|
|
|
|
} else {
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-30 10:54:01 +01:00
|
|
|
/// Snapshot list of music renderers (protocol-agnostic view).
|
|
|
|
|
pub fn list_music_renderers(&self) -> Vec<MusicRenderer> {
|
2025-12-01 20:40:24 +01:00
|
|
|
let infos = {
|
|
|
|
|
let reg = self.registry.read().unwrap();
|
|
|
|
|
reg.list_renderers()
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-22 10:32:48 +01:00
|
|
|
// Clean up cache - remove renderers that are no longer in the registry
|
|
|
|
|
{
|
|
|
|
|
let current_ids: HashSet<RendererId> = infos.iter().map(|i| i.id.clone()).collect();
|
|
|
|
|
let mut cache = self.renderer_cache.lock().unwrap();
|
|
|
|
|
cache.retain(|id, _| current_ids.contains(id));
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-01 20:40:24 +01:00
|
|
|
infos
|
2025-11-30 10:54:01 +01:00
|
|
|
.into_iter()
|
2025-12-22 10:32:48 +01:00
|
|
|
.filter_map(|info| self.get_or_create_renderer(info))
|
2025-11-30 10:54:01 +01:00
|
|
|
.collect()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Return the first music renderer in the registry, if any.
|
|
|
|
|
pub fn default_music_renderer(&self) -> Option<MusicRenderer> {
|
2025-12-01 20:40:24 +01:00
|
|
|
let infos = {
|
|
|
|
|
let reg = self.registry.read().unwrap();
|
|
|
|
|
reg.list_renderers()
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
infos
|
2025-11-30 10:54:01 +01:00
|
|
|
.into_iter()
|
2025-12-22 10:32:48 +01:00
|
|
|
.find_map(|info| self.get_or_create_renderer(info))
|
2025-11-30 10:54:01 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Lookup a music renderer by id.
|
|
|
|
|
pub fn music_renderer_by_id(&self, id: &RendererId) -> Option<MusicRenderer> {
|
2025-12-01 20:40:24 +01:00
|
|
|
let info = {
|
|
|
|
|
let reg = self.registry.read().unwrap();
|
|
|
|
|
reg.get_renderer(id)
|
|
|
|
|
}?;
|
|
|
|
|
|
2025-12-22 10:32:48 +01:00
|
|
|
self.get_or_create_renderer(info)
|
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.
|
|
|
|
|
pub fn list_media_servers(&self) -> Vec<MediaServerInfo> {
|
|
|
|
|
let reg = self.registry.read().unwrap();
|
|
|
|
|
reg.list_servers()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Lookup a media server by id.
|
|
|
|
|
pub fn media_server(&self, id: &ServerId) -> Option<MediaServerInfo> {
|
|
|
|
|
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
|
|
|
|
|
/// (`QueueBackend::clear_queue` via `RuntimeState::with_music_queue_mut`).
|
2025-12-01 19:55:55 +01:00
|
|
|
pub fn clear_queue(&self, renderer_id: &RendererId) -> anyhow::Result<()> {
|
|
|
|
|
if !self.runtime.has_entry(renderer_id) {
|
|
|
|
|
let err = Self::runtime_entry_missing(renderer_id);
|
|
|
|
|
warn!(
|
|
|
|
|
renderer = renderer_id.0.as_str(),
|
|
|
|
|
"Cannot clear queue: renderer not registered in runtime"
|
|
|
|
|
);
|
|
|
|
|
return Err(err);
|
|
|
|
|
}
|
|
|
|
|
|
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-05 19:01:33 +01:00
|
|
|
if self.runtime.uses_openhome_playlist(renderer_id) {
|
2025-12-05 21:59:43 +01:00
|
|
|
let renderer = self.openhome_renderer(renderer_id)?;
|
|
|
|
|
renderer.openhome_playlist_clear()?;
|
2025-12-05 19:01:33 +01:00
|
|
|
self.sync_openhome_playlist_for(renderer_id)?;
|
|
|
|
|
debug!(
|
|
|
|
|
renderer = renderer_id.0.as_str(),
|
|
|
|
|
"Cleared OpenHome playlist"
|
|
|
|
|
);
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-06 13:37:42 +01:00
|
|
|
let removed = self.runtime.with_music_queue_mut(renderer_id, |queue| {
|
|
|
|
|
let removed = queue.upcoming_len()?;
|
|
|
|
|
queue.clear_queue()?;
|
|
|
|
|
Ok(removed)
|
|
|
|
|
})?;
|
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`
|
|
|
|
|
/// inside `RuntimeState::with_music_queue_mut`).
|
2025-12-01 19:55:55 +01:00
|
|
|
pub fn enqueue_items(
|
|
|
|
|
&self,
|
|
|
|
|
renderer_id: &RendererId,
|
|
|
|
|
items: Vec<PlaybackItem>,
|
|
|
|
|
) -> anyhow::Result<()> {
|
|
|
|
|
if !self.runtime.has_entry(renderer_id) {
|
|
|
|
|
let err = Self::runtime_entry_missing(renderer_id);
|
|
|
|
|
warn!(
|
|
|
|
|
renderer = renderer_id.0.as_str(),
|
|
|
|
|
"Cannot enqueue items: renderer not registered in runtime"
|
|
|
|
|
);
|
|
|
|
|
return Err(err);
|
|
|
|
|
}
|
|
|
|
|
|
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-05 19:01:33 +01:00
|
|
|
if self.runtime.uses_openhome_playlist(renderer_id) {
|
|
|
|
|
self.enqueue_items_openhome(renderer_id, items)?;
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-01 19:55:55 +01:00
|
|
|
let item_count = items.len();
|
2025-12-06 13:37:42 +01:00
|
|
|
let new_len = self.runtime.with_music_queue_mut(renderer_id, |queue| {
|
|
|
|
|
queue.enqueue_items(items, EnqueueMode::AppendToEnd)?;
|
|
|
|
|
queue.upcoming_len()
|
|
|
|
|
})?;
|
2025-12-01 19:55:55 +01:00
|
|
|
|
|
|
|
|
debug!(
|
|
|
|
|
renderer = renderer_id.0.as_str(),
|
|
|
|
|
added = item_count,
|
|
|
|
|
queue_len = new_len,
|
|
|
|
|
"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(())
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-06 13:37:42 +01:00
|
|
|
/// Read-only snapshot of the upcoming queue items for a renderer.
|
|
|
|
|
///
|
|
|
|
|
/// This helper never mutates the runtime. It simply exposes the pending
|
|
|
|
|
/// items as seen by the local `QueueBackend`. For a full `(items, index)`
|
|
|
|
|
/// view, prefer [`get_full_queue_snapshot`].
|
2025-12-01 19:55:55 +01:00
|
|
|
pub fn get_queue_snapshot(
|
|
|
|
|
&self,
|
|
|
|
|
renderer_id: &RendererId,
|
|
|
|
|
) -> anyhow::Result<Vec<PlaybackItem>> {
|
|
|
|
|
if !self.runtime.has_entry(renderer_id) {
|
|
|
|
|
let err = Self::runtime_entry_missing(renderer_id);
|
|
|
|
|
warn!(
|
|
|
|
|
renderer = renderer_id.0.as_str(),
|
|
|
|
|
"Cannot snapshot queue: renderer not registered in runtime"
|
|
|
|
|
);
|
|
|
|
|
return Err(err);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
self.runtime
|
|
|
|
|
.queue_snapshot(renderer_id)
|
|
|
|
|
.ok_or_else(|| Self::runtime_entry_missing(renderer_id))
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-06 13:37:42 +01:00
|
|
|
/// Read-only helper returning both queue items and the current index.
|
|
|
|
|
///
|
|
|
|
|
/// This is the most detailed queue view exposed publicly and is meant
|
|
|
|
|
/// for UI/REST layers that need an authoritative snapshot without
|
|
|
|
|
/// mutating the runtime.
|
2025-12-03 21:49:41 +01:00
|
|
|
pub fn get_full_queue_snapshot(
|
|
|
|
|
&self,
|
|
|
|
|
renderer_id: &RendererId,
|
|
|
|
|
) -> anyhow::Result<(Vec<PlaybackItem>, Option<usize>)> {
|
|
|
|
|
if !self.runtime.has_entry(renderer_id) {
|
2025-12-05 07:08:03 +01:00
|
|
|
// Renderer not yet initialized in runtime (just discovered via SSDP)
|
|
|
|
|
// This is normal and will be fixed on first polling cycle
|
|
|
|
|
debug!(
|
2025-12-03 21:49:41 +01:00
|
|
|
renderer = renderer_id.0.as_str(),
|
2025-12-05 07:08:03 +01:00
|
|
|
"Renderer not yet initialized in runtime, returning empty queue"
|
2025-12-03 21:49:41 +01:00
|
|
|
);
|
2025-12-05 07:08:03 +01:00
|
|
|
return Err(Self::runtime_entry_missing(renderer_id));
|
2025-12-03 21:49:41 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
self.runtime
|
|
|
|
|
.queue_full_snapshot(renderer_id)
|
|
|
|
|
.ok_or_else(|| Self::runtime_entry_missing(renderer_id))
|
|
|
|
|
}
|
|
|
|
|
|
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.
|
2025-12-06 00:38:06 +01:00
|
|
|
pub fn get_current_track_metadata(&self, renderer_id: &RendererId) -> Option<TrackMetadata> {
|
2025-12-05 22:31:35 +01:00
|
|
|
self.runtime.current_track_metadata(renderer_id)
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-05 19:01:33 +01:00
|
|
|
/// Force a resynchronization of the OpenHome playlist cache for a renderer.
|
|
|
|
|
///
|
|
|
|
|
/// This is used by external APIs after mutating the native playlist so that
|
|
|
|
|
/// the local queue mirrors the renderer state.
|
|
|
|
|
pub fn refresh_openhome_playlist(&self, renderer_id: &RendererId) -> anyhow::Result<()> {
|
|
|
|
|
self.sync_openhome_playlist_for(renderer_id)
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-05 21:59:43 +01:00
|
|
|
pub fn get_openhome_playlist_snapshot(
|
|
|
|
|
&self,
|
|
|
|
|
renderer_id: &RendererId,
|
|
|
|
|
) -> anyhow::Result<OpenHomePlaylistSnapshot> {
|
|
|
|
|
let renderer = self.openhome_renderer(renderer_id)?;
|
|
|
|
|
renderer.openhome_playlist_snapshot()
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-17 21:41:24 +01:00
|
|
|
pub fn get_cached_openhome_playlist_snapshot(
|
|
|
|
|
&self,
|
|
|
|
|
renderer_id: &RendererId,
|
|
|
|
|
ttl: Duration,
|
|
|
|
|
) -> anyhow::Result<OpenHomePlaylistSnapshot> {
|
|
|
|
|
let renderer = self.openhome_renderer(renderer_id)?;
|
|
|
|
|
self.runtime.openhome_snapshot_cached(&renderer, ttl)
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-05 22:31:35 +01:00
|
|
|
pub fn get_openhome_playlist_len(&self, renderer_id: &RendererId) -> anyhow::Result<usize> {
|
|
|
|
|
let renderer = self.openhome_renderer(renderer_id)?;
|
|
|
|
|
renderer.openhome_playlist_len()
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-06 00:38:06 +01:00
|
|
|
/// Build a fully consistent snapshot for UI consumers (state + queue + binding).
|
|
|
|
|
#[cfg(feature = "pmoserver")]
|
|
|
|
|
pub fn renderer_full_snapshot(
|
|
|
|
|
&self,
|
|
|
|
|
renderer_id: &RendererId,
|
|
|
|
|
) -> 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();
|
|
|
|
|
|
2025-12-17 21:41:24 +01:00
|
|
|
// MICRO-PATCH 5: Chemin OpenHome complètement découplé du miroir local
|
|
|
|
|
if self.runtime.uses_openhome_playlist(renderer_id) {
|
|
|
|
|
// Pour OpenHome: récupérer UNIQUEMENT runtime_snapshot pour volume/state/position
|
|
|
|
|
// Ne PAS utiliser queue_items/current_index du runtime (miroir local)
|
|
|
|
|
let (runtime_snapshot, _, _) = self.runtime.renderer_snapshot_bundle(renderer_id);
|
|
|
|
|
|
|
|
|
|
// OpenHome est la source de vérité pour la queue - pas de fallback au runtime
|
|
|
|
|
let snapshot = self.get_cached_openhome_playlist_snapshot(
|
|
|
|
|
renderer_id,
|
|
|
|
|
OPENHOME_SNAPSHOT_CACHE_TTL,
|
|
|
|
|
)?;
|
|
|
|
|
|
|
|
|
|
let queue_items: Vec<PlaybackItem> = snapshot
|
|
|
|
|
.tracks
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|track| playback_item_from_openhome_track(renderer_id, track))
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
let queue_len = snapshot.tracks.len();
|
|
|
|
|
|
|
|
|
|
// Pour OpenHome: current_index vient UNIQUEMENT d'OpenHome, pas d'heuristiques runtime
|
|
|
|
|
let queue_current_index = snapshot.current_index.or_else(|| {
|
|
|
|
|
snapshot.current_id.and_then(|id| {
|
|
|
|
|
snapshot.tracks.iter().position(|track| track.id == id)
|
|
|
|
|
})
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
debug!(
|
|
|
|
|
renderer = renderer_id.0.as_str(),
|
|
|
|
|
current_id = ?snapshot.current_id,
|
|
|
|
|
current_index = ?queue_current_index,
|
|
|
|
|
track_count = snapshot.tracks.len(),
|
|
|
|
|
"renderer_full_snapshot: OpenHome snapshot retrieved"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let queue_view_items: Vec<QueueItem> = queue_items
|
|
|
|
|
.iter()
|
|
|
|
|
.enumerate()
|
|
|
|
|
.map(|(index, item)| QueueItem {
|
|
|
|
|
index,
|
|
|
|
|
uri: item.uri.clone(),
|
|
|
|
|
title: item.metadata.as_ref().and_then(|m| m.title.clone()),
|
|
|
|
|
artist: item.metadata.as_ref().and_then(|m| m.artist.clone()),
|
|
|
|
|
album: item.metadata.as_ref().and_then(|m| m.album.clone()),
|
|
|
|
|
album_art_uri: item.metadata.as_ref().and_then(|m| m.album_art_uri.clone()),
|
|
|
|
|
server_id: Some(item.media_server_id.0.clone()),
|
|
|
|
|
object_id: Some(item.didl_id.clone()),
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
let queue_view = QueueSnapshotView {
|
|
|
|
|
renderer_id: renderer_id.0.clone(),
|
|
|
|
|
items: queue_view_items,
|
|
|
|
|
current_index: queue_current_index,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let binding = self.current_queue_playlist_binding(renderer_id).map(
|
|
|
|
|
|(server_id, container_id, has_seen_update)| RendererBindingView {
|
|
|
|
|
server_id: server_id.0,
|
|
|
|
|
container_id,
|
|
|
|
|
has_seen_update,
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let (position_ms, duration_ms) =
|
|
|
|
|
convert_runtime_position(runtime_snapshot.position.as_ref());
|
|
|
|
|
let queue_current_metadata = queue_current_index
|
|
|
|
|
.and_then(|idx| queue_items.get(idx))
|
|
|
|
|
.map(current_track_from_playback_item);
|
|
|
|
|
|
|
|
|
|
// MICRO-PATCH 5: Pour OpenHome, préférer les métadonnées depuis le snapshot OpenHome
|
|
|
|
|
// car runtime_snapshot.last_metadata n'est jamais mis à jour pour OpenHome
|
|
|
|
|
let current_track = queue_current_metadata.or_else(|| {
|
|
|
|
|
runtime_snapshot
|
|
|
|
|
.last_metadata
|
|
|
|
|
.as_ref()
|
|
|
|
|
.map(|meta| CurrentTrackMetadata {
|
|
|
|
|
title: meta.title.clone(),
|
|
|
|
|
artist: meta.artist.clone(),
|
|
|
|
|
album: meta.album.clone(),
|
|
|
|
|
album_art_uri: meta.album_art_uri.clone(),
|
|
|
|
|
})
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let state_view = RendererStateView {
|
|
|
|
|
id: renderer_id.0.clone(),
|
|
|
|
|
friendly_name: info.friendly_name.clone(),
|
|
|
|
|
transport_state: runtime_snapshot
|
|
|
|
|
.state
|
|
|
|
|
.as_ref()
|
|
|
|
|
.map(|state| state.as_str().to_string())
|
|
|
|
|
.unwrap_or_else(|| "UNKNOWN".to_string()),
|
|
|
|
|
position_ms,
|
|
|
|
|
duration_ms,
|
|
|
|
|
volume: runtime_snapshot
|
|
|
|
|
.last_volume
|
|
|
|
|
.and_then(|value| u8::try_from(value).ok()),
|
|
|
|
|
mute: runtime_snapshot.last_mute,
|
|
|
|
|
queue_len,
|
|
|
|
|
attached_playlist: binding.clone(),
|
|
|
|
|
current_track,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return Ok(FullRendererSnapshot {
|
|
|
|
|
state: state_view,
|
|
|
|
|
queue: queue_view,
|
|
|
|
|
binding,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Chemin non-OpenHome (UPnP AV): continue d'utiliser le runtime
|
|
|
|
|
let (runtime_snapshot, queue_items, mut queue_current_index) =
|
2025-12-06 00:38:06 +01:00
|
|
|
self.runtime.renderer_snapshot_bundle(renderer_id);
|
|
|
|
|
let playback_source = self.runtime.playback_source(renderer_id);
|
|
|
|
|
let queue_len = queue_items.len();
|
|
|
|
|
|
|
|
|
|
if queue_current_index.is_none() {
|
|
|
|
|
if let Some(position) = runtime_snapshot.position.as_ref() {
|
|
|
|
|
if let Some(uri) = position.track_uri.as_ref() {
|
|
|
|
|
if let Some(idx) = queue_items.iter().position(|item| item.uri == *uri) {
|
|
|
|
|
queue_current_index = Some(idx);
|
|
|
|
|
}
|
|
|
|
|
} else if let Some(track_no) = position.track {
|
|
|
|
|
let zero_based = track_no.saturating_sub(1) as usize;
|
|
|
|
|
if zero_based < queue_items.len() {
|
|
|
|
|
queue_current_index = Some(zero_based);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if queue_current_index.is_none()
|
|
|
|
|
&& matches!(playback_source, PlaybackSource::FromQueue)
|
|
|
|
|
&& runtime_snapshot
|
|
|
|
|
.state
|
|
|
|
|
.as_ref()
|
|
|
|
|
.map(|state| matches!(state, PlaybackState::Playing | PlaybackState::Paused))
|
|
|
|
|
.unwrap_or(false)
|
|
|
|
|
&& !queue_items.is_empty()
|
|
|
|
|
{
|
|
|
|
|
queue_current_index = Some(0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let queue_view_items: Vec<QueueItem> = queue_items
|
|
|
|
|
.iter()
|
|
|
|
|
.enumerate()
|
|
|
|
|
.map(|(index, item)| QueueItem {
|
|
|
|
|
index,
|
|
|
|
|
uri: item.uri.clone(),
|
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,
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let (position_ms, duration_ms) =
|
|
|
|
|
convert_runtime_position(runtime_snapshot.position.as_ref());
|
|
|
|
|
let queue_current_metadata = queue_current_index
|
|
|
|
|
.and_then(|idx| queue_items.get(idx))
|
|
|
|
|
.map(current_track_from_playback_item);
|
|
|
|
|
|
|
|
|
|
let current_track = runtime_snapshot
|
|
|
|
|
.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(),
|
|
|
|
|
})
|
|
|
|
|
.or(queue_current_metadata);
|
|
|
|
|
|
|
|
|
|
let state_view = RendererStateView {
|
|
|
|
|
id: renderer_id.0.clone(),
|
|
|
|
|
friendly_name: info.friendly_name.clone(),
|
|
|
|
|
transport_state: runtime_snapshot
|
|
|
|
|
.state
|
|
|
|
|
.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,
|
|
|
|
|
volume: runtime_snapshot
|
|
|
|
|
.last_volume
|
|
|
|
|
.and_then(|value| u8::try_from(value).ok()),
|
|
|
|
|
mute: runtime_snapshot.last_mute,
|
|
|
|
|
queue_len,
|
|
|
|
|
attached_playlist: binding.clone(),
|
|
|
|
|
current_track,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
Ok(FullRendererSnapshot {
|
|
|
|
|
state: state_view,
|
|
|
|
|
queue: queue_view,
|
|
|
|
|
binding,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-05 21:59:43 +01:00
|
|
|
pub fn clear_openhome_playlist(&self, renderer_id: &RendererId) -> anyhow::Result<()> {
|
|
|
|
|
let renderer = self.openhome_renderer(renderer_id)?;
|
|
|
|
|
renderer.openhome_playlist_clear()?;
|
|
|
|
|
self.sync_openhome_playlist_for(renderer_id)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn add_openhome_track(
|
|
|
|
|
&self,
|
|
|
|
|
renderer_id: &RendererId,
|
|
|
|
|
uri: &str,
|
|
|
|
|
metadata: &str,
|
|
|
|
|
after_id: Option<u32>,
|
|
|
|
|
play: bool,
|
|
|
|
|
) -> anyhow::Result<()> {
|
|
|
|
|
let renderer = self.openhome_renderer(renderer_id)?;
|
|
|
|
|
renderer.openhome_playlist_add_track(uri, metadata, after_id, play)?;
|
|
|
|
|
self.sync_openhome_playlist_for(renderer_id)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn play_openhome_track_id(
|
|
|
|
|
&self,
|
|
|
|
|
renderer_id: &RendererId,
|
|
|
|
|
track_id: u32,
|
|
|
|
|
) -> anyhow::Result<()> {
|
|
|
|
|
let renderer = self.openhome_renderer(renderer_id)?;
|
|
|
|
|
renderer.openhome_playlist_play_id(track_id)?;
|
|
|
|
|
self.runtime
|
|
|
|
|
.set_playback_source(renderer_id, PlaybackSource::FromQueue);
|
|
|
|
|
self.sync_openhome_playlist_for(renderer_id)
|
|
|
|
|
}
|
|
|
|
|
|
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).
|
2025-12-05 07:08:03 +01:00
|
|
|
pub fn play_current_from_queue(&self, renderer_id: &RendererId) -> anyhow::Result<()> {
|
|
|
|
|
if !self.runtime.has_entry(renderer_id) {
|
|
|
|
|
let err = Self::runtime_entry_missing(renderer_id);
|
|
|
|
|
warn!(
|
|
|
|
|
renderer = renderer_id.0.as_str(),
|
|
|
|
|
"Cannot play current: renderer not registered in runtime"
|
|
|
|
|
);
|
|
|
|
|
return Err(err);
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-05 19:01:33 +01:00
|
|
|
if self.runtime.uses_openhome_playlist(renderer_id) {
|
|
|
|
|
return self.play_current_openhome(renderer_id);
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-05 07:08:03 +01:00
|
|
|
let Some((item, remaining)) = self.runtime.peek_current(renderer_id) else {
|
|
|
|
|
debug!(
|
|
|
|
|
renderer = renderer_id.0.as_str(),
|
|
|
|
|
"play_current_from_queue: queue is empty or no current item"
|
|
|
|
|
);
|
|
|
|
|
self.runtime
|
|
|
|
|
.set_playback_source(renderer_id, PlaybackSource::None);
|
|
|
|
|
return Ok(());
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
debug!(
|
|
|
|
|
renderer = renderer_id.0.as_str(),
|
|
|
|
|
queue_len = remaining + 1,
|
|
|
|
|
uri = item.uri.as_str(),
|
|
|
|
|
"Playing current playback item from queue"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let renderer = self.music_renderer_by_id(renderer_id).ok_or_else(|| {
|
|
|
|
|
warn!(
|
|
|
|
|
renderer = renderer_id.0.as_str(),
|
|
|
|
|
"Renderer disappeared before queue playback could start"
|
|
|
|
|
);
|
|
|
|
|
anyhow!("Renderer {} not found", renderer_id.0)
|
|
|
|
|
})?;
|
|
|
|
|
|
|
|
|
|
let playback = (|| -> anyhow::Result<()> {
|
2025-12-06 13:37:42 +01:00
|
|
|
let didl_metadata = playback_item_to_didl(&item);
|
2025-12-05 07:08:03 +01:00
|
|
|
renderer.play_uri(&item.uri, &didl_metadata)?;
|
|
|
|
|
Ok(())
|
|
|
|
|
})();
|
|
|
|
|
|
|
|
|
|
match playback {
|
|
|
|
|
Ok(()) => {
|
|
|
|
|
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
|
|
|
|
|
|
|
|
// Sauvegarder les métadonnées dans le snapshot pour que current_track soit disponible
|
|
|
|
|
// même si le renderer UPnP ne retourne pas de métadonnées dans GetPositionInfo
|
2025-12-06 13:37:42 +01:00
|
|
|
let metadata = playback_item_track_metadata(&item);
|
2025-12-05 23:37:21 +01:00
|
|
|
self.runtime.update_snapshot_with(renderer_id, |snapshot| {
|
|
|
|
|
snapshot.last_metadata = Some(metadata);
|
|
|
|
|
});
|
|
|
|
|
|
2025-12-05 07:08:03 +01:00
|
|
|
self.runtime
|
|
|
|
|
.set_playback_source(renderer_id, PlaybackSource::FromQueue);
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
Err(e) => {
|
|
|
|
|
error!(
|
|
|
|
|
renderer = renderer_id.0.as_str(),
|
|
|
|
|
error = %e,
|
|
|
|
|
"Failed to play current item from queue"
|
|
|
|
|
);
|
|
|
|
|
self.runtime
|
|
|
|
|
.set_playback_source(renderer_id, PlaybackSource::None);
|
|
|
|
|
Err(e)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-06 13:37:42 +01:00
|
|
|
/// Advances the queue by one item, starts playback and updates the snapshot.
|
|
|
|
|
///
|
|
|
|
|
/// The structural mutation uses the `QueueBackend::dequeue_next` helper
|
|
|
|
|
/// (through `RuntimeState`) so that all pointer updates are consistent.
|
2025-12-01 19:55:55 +01:00
|
|
|
pub fn play_next_from_queue(&self, renderer_id: &RendererId) -> anyhow::Result<()> {
|
|
|
|
|
if !self.runtime.has_entry(renderer_id) {
|
|
|
|
|
let err = Self::runtime_entry_missing(renderer_id);
|
|
|
|
|
warn!(
|
|
|
|
|
renderer = renderer_id.0.as_str(),
|
|
|
|
|
"Cannot advance queue: renderer not registered in runtime"
|
|
|
|
|
);
|
|
|
|
|
return Err(err);
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-05 19:01:33 +01:00
|
|
|
if self.runtime.uses_openhome_playlist(renderer_id) {
|
|
|
|
|
self.play_next_openhome(renderer_id)?;
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-01 19:55:55 +01:00
|
|
|
let Some((item, remaining_after)) = self.runtime.dequeue_next(renderer_id) else {
|
|
|
|
|
debug!(
|
|
|
|
|
renderer = renderer_id.0.as_str(),
|
|
|
|
|
"play_next_from_queue: queue is empty"
|
|
|
|
|
);
|
|
|
|
|
self.runtime
|
|
|
|
|
.set_playback_source(renderer_id, PlaybackSource::None);
|
|
|
|
|
return Ok(());
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let queue_before = remaining_after + 1;
|
|
|
|
|
debug!(
|
|
|
|
|
renderer = renderer_id.0.as_str(),
|
|
|
|
|
queue_before,
|
|
|
|
|
queue_after = remaining_after,
|
|
|
|
|
uri = item.uri.as_str(),
|
|
|
|
|
"Dequeued next playback item"
|
|
|
|
|
);
|
|
|
|
|
|
2025-12-01 20:40:24 +01:00
|
|
|
let renderer = self.music_renderer_by_id(renderer_id).ok_or_else(|| {
|
|
|
|
|
warn!(
|
|
|
|
|
renderer = renderer_id.0.as_str(),
|
|
|
|
|
"Renderer disappeared before queue playback could start"
|
|
|
|
|
);
|
|
|
|
|
anyhow!("Renderer {} not found", renderer_id.0)
|
|
|
|
|
})?;
|
2025-12-01 19:55:55 +01:00
|
|
|
|
|
|
|
|
let playback = (|| -> anyhow::Result<()> {
|
2025-12-06 13:37:42 +01:00
|
|
|
let didl_metadata = playback_item_to_didl(&item);
|
2025-12-05 07:08:03 +01:00
|
|
|
renderer.play_uri(&item.uri, &didl_metadata)?;
|
2025-12-01 19:55:55 +01:00
|
|
|
Ok(())
|
|
|
|
|
})();
|
|
|
|
|
|
|
|
|
|
if let Err(err) = playback {
|
|
|
|
|
error!(
|
|
|
|
|
renderer = renderer_id.0.as_str(),
|
|
|
|
|
error = %err,
|
|
|
|
|
"Failed to start playback for queued item"
|
|
|
|
|
);
|
|
|
|
|
if self
|
|
|
|
|
.runtime
|
2025-12-06 13:37:42 +01:00
|
|
|
.with_music_queue_mut(renderer_id, |queue| {
|
|
|
|
|
queue.enqueue_items(vec![item.clone()], EnqueueMode::InsertAfterCurrent)?;
|
|
|
|
|
Ok(())
|
|
|
|
|
})
|
|
|
|
|
.is_err()
|
2025-12-01 19:55:55 +01:00
|
|
|
{
|
|
|
|
|
warn!(
|
|
|
|
|
renderer = renderer_id.0.as_str(),
|
|
|
|
|
"Failed to requeue item after playback error"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
self.runtime
|
|
|
|
|
.set_playback_source(renderer_id, PlaybackSource::None);
|
|
|
|
|
return Err(err);
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-05 23:37:21 +01:00
|
|
|
// Sauvegarder les métadonnées dans le snapshot pour que current_track soit disponible
|
|
|
|
|
// même si le renderer UPnP ne retourne pas de métadonnées dans GetPositionInfo
|
2025-12-06 13:37:42 +01:00
|
|
|
let metadata = playback_item_track_metadata(&item);
|
2025-12-05 23:37:21 +01:00
|
|
|
self.runtime.update_snapshot_with(renderer_id, |snapshot| {
|
|
|
|
|
snapshot.last_metadata = Some(metadata);
|
|
|
|
|
});
|
|
|
|
|
|
2025-12-01 19:55:55 +01:00
|
|
|
self.runtime
|
|
|
|
|
.set_playback_source(renderer_id, PlaybackSource::FromQueue);
|
|
|
|
|
debug!(
|
|
|
|
|
renderer = renderer_id.0.as_str(),
|
|
|
|
|
queue_len = remaining_after,
|
|
|
|
|
"Started playback from queue"
|
|
|
|
|
);
|
2025-12-01 20:40:24 +01:00
|
|
|
|
|
|
|
|
if let Some(snapshot) = self.runtime.queue_snapshot(renderer_id) {
|
|
|
|
|
if let Some(next_item) = snapshot.first() {
|
|
|
|
|
if let Some(upnp) = renderer.as_upnp() {
|
|
|
|
|
let known_supported = upnp.supports_set_next();
|
|
|
|
|
if known_supported || upnp.has_avtransport() {
|
2025-12-06 13:37:42 +01:00
|
|
|
let next_didl_metadata = playback_item_to_didl(next_item);
|
2025-12-05 07:08:03 +01:00
|
|
|
match upnp.set_next_uri(&next_item.uri, &next_didl_metadata) {
|
2025-12-01 20:40:24 +01:00
|
|
|
Ok(_) => debug!(
|
|
|
|
|
renderer = renderer_id.0.as_str(),
|
|
|
|
|
"Prefetched next track via SetNextAVTransportURI"
|
|
|
|
|
),
|
|
|
|
|
Err(err) => debug!(
|
|
|
|
|
renderer = renderer_id.0.as_str(),
|
|
|
|
|
error = %err,
|
|
|
|
|
"SetNextAVTransportURI failed for next queue item; continuing without prefetch"
|
|
|
|
|
),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-03 18:59:41 +01:00
|
|
|
// Emit QueueUpdated event
|
|
|
|
|
self.emit_renderer_event(RendererEvent::QueueUpdated {
|
|
|
|
|
id: renderer_id.clone(),
|
|
|
|
|
queue_length: remaining_after,
|
|
|
|
|
});
|
|
|
|
|
|
2025-12-01 19:55:55 +01:00
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-27 14:56:45 +01:00
|
|
|
/// Jumps to a specific index in the queue and starts playback.
|
|
|
|
|
///
|
|
|
|
|
/// For OpenHome renderers, this uses the playlist's native SeekId capability.
|
|
|
|
|
/// For internal queues, this updates the current index and starts playback.
|
|
|
|
|
pub fn play_queue_index(&self, renderer_id: &RendererId, index: usize) -> anyhow::Result<()> {
|
|
|
|
|
if !self.runtime.has_entry(renderer_id) {
|
|
|
|
|
let err = Self::runtime_entry_missing(renderer_id);
|
|
|
|
|
warn!(
|
|
|
|
|
renderer = renderer_id.0.as_str(),
|
|
|
|
|
"Cannot jump to index: renderer not registered in runtime"
|
|
|
|
|
);
|
|
|
|
|
return Err(err);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// For OpenHome, use select_track_index which handles the track_id lookup
|
|
|
|
|
if self.runtime.uses_openhome_playlist(renderer_id) {
|
|
|
|
|
info!(
|
|
|
|
|
renderer = renderer_id.0.as_str(),
|
|
|
|
|
index,
|
|
|
|
|
"Seeking to OpenHome queue index"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Use the queue to select the track by index
|
|
|
|
|
self.runtime.with_music_queue_mut(renderer_id, |queue| {
|
|
|
|
|
if let crate::control_point::music_queue::MusicQueue::OpenHome(oh_queue) = queue {
|
|
|
|
|
oh_queue.select_track_index(index)
|
|
|
|
|
} else {
|
|
|
|
|
Err(anyhow!("Expected OpenHome queue"))
|
|
|
|
|
}
|
|
|
|
|
})?;
|
|
|
|
|
|
|
|
|
|
info!(
|
|
|
|
|
renderer = renderer_id.0.as_str(),
|
|
|
|
|
index,
|
|
|
|
|
"Successfully seeked to OpenHome queue index"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
self.runtime
|
|
|
|
|
.set_playback_source(renderer_id, PlaybackSource::FromQueue);
|
|
|
|
|
return self.sync_openhome_playlist_for(renderer_id);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// For internal queue, set the index and play
|
|
|
|
|
self.runtime.with_music_queue_mut(renderer_id, |queue| {
|
|
|
|
|
queue.set_index(Some(index))
|
|
|
|
|
})?;
|
|
|
|
|
|
|
|
|
|
let Some((item, remaining)) = self.runtime.peek_current(renderer_id) else {
|
|
|
|
|
debug!(
|
|
|
|
|
renderer = renderer_id.0.as_str(),
|
|
|
|
|
index,
|
|
|
|
|
"play_queue_index: no item at index"
|
|
|
|
|
);
|
|
|
|
|
self.runtime
|
|
|
|
|
.set_playback_source(renderer_id, PlaybackSource::None);
|
|
|
|
|
return Ok(());
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
debug!(
|
|
|
|
|
renderer = renderer_id.0.as_str(),
|
|
|
|
|
index,
|
|
|
|
|
queue_len = remaining + 1,
|
|
|
|
|
uri = item.uri.as_str(),
|
|
|
|
|
"Playing item at index from queue"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let renderer = self.music_renderer_by_id(renderer_id).ok_or_else(|| {
|
|
|
|
|
warn!(
|
|
|
|
|
renderer = renderer_id.0.as_str(),
|
|
|
|
|
"Renderer disappeared before queue playback could start"
|
|
|
|
|
);
|
|
|
|
|
anyhow!("Renderer {} not found", renderer_id.0)
|
|
|
|
|
})?;
|
|
|
|
|
|
|
|
|
|
let playback = (|| -> anyhow::Result<()> {
|
|
|
|
|
let didl_metadata = playback_item_to_didl(&item);
|
|
|
|
|
renderer.play_uri(&item.uri, &didl_metadata)?;
|
|
|
|
|
Ok(())
|
|
|
|
|
})();
|
|
|
|
|
|
|
|
|
|
match playback {
|
|
|
|
|
Ok(()) => {
|
|
|
|
|
info!(
|
|
|
|
|
renderer = renderer_id.0.as_str(),
|
|
|
|
|
index,
|
|
|
|
|
uri = item.uri.as_str(),
|
|
|
|
|
"Queue playback started at index"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let metadata = playback_item_track_metadata(&item);
|
|
|
|
|
self.runtime.update_snapshot_with(renderer_id, |snapshot| {
|
|
|
|
|
snapshot.last_metadata = Some(metadata);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
self.runtime
|
|
|
|
|
.set_playback_source(renderer_id, PlaybackSource::FromQueue);
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
Err(err) => {
|
|
|
|
|
error!(
|
|
|
|
|
renderer = renderer_id.0.as_str(),
|
|
|
|
|
index,
|
|
|
|
|
error = %err,
|
|
|
|
|
"Failed to start playback for queued item at index"
|
|
|
|
|
);
|
|
|
|
|
self.runtime
|
|
|
|
|
.set_playback_source(renderer_id, PlaybackSource::None);
|
|
|
|
|
Err(err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-03 23:17:41 +01:00
|
|
|
fn start_queue_playback_if_idle(&self, renderer_id: &RendererId) -> anyhow::Result<()> {
|
|
|
|
|
let snapshot = self.runtime.snapshot_for(renderer_id);
|
|
|
|
|
let renderer_playing = matches!(snapshot.state, Some(PlaybackState::Playing));
|
2025-12-22 10:32:48 +01:00
|
|
|
let from_queue = self.runtime.is_playing_from_queue(renderer_id);
|
|
|
|
|
|
|
|
|
|
debug!(
|
|
|
|
|
renderer = renderer_id.0.as_str(),
|
|
|
|
|
renderer_playing,
|
|
|
|
|
from_queue,
|
|
|
|
|
state = ?snapshot.state,
|
|
|
|
|
"start_queue_playback_if_idle: checking if should start playback"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Only skip if the renderer is actually playing
|
|
|
|
|
// Don't skip just because playback_source is FromQueue - the renderer might have stopped
|
|
|
|
|
if renderer_playing {
|
|
|
|
|
debug!(
|
|
|
|
|
renderer = renderer_id.0.as_str(),
|
|
|
|
|
"start_queue_playback_if_idle: skipping because renderer is already playing"
|
|
|
|
|
);
|
2025-12-03 23:17:41 +01:00
|
|
|
return Ok(());
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-22 10:32:48 +01:00
|
|
|
// Check if queue has ANY items (not just upcoming items after current)
|
|
|
|
|
// This is important for newly attached playlists with current_index set
|
2025-12-03 23:17:41 +01:00
|
|
|
let has_items = self
|
|
|
|
|
.runtime
|
2025-12-22 10:32:48 +01:00
|
|
|
.queue_full_snapshot(renderer_id)
|
|
|
|
|
.map(|(items, _)| !items.is_empty())
|
2025-12-03 23:17:41 +01:00
|
|
|
.unwrap_or(false);
|
|
|
|
|
if !has_items {
|
|
|
|
|
debug!(
|
|
|
|
|
renderer = renderer_id.0.as_str(),
|
|
|
|
|
"start_queue_playback_if_idle: queue is empty"
|
|
|
|
|
);
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-17 21:41:24 +01:00
|
|
|
if self.runtime.uses_openhome_playlist(renderer_id) {
|
|
|
|
|
self.play_current_from_queue(renderer_id)
|
|
|
|
|
} else {
|
|
|
|
|
self.play_next_from_queue(renderer_id)
|
|
|
|
|
}
|
2025-12-03 23:17:41 +01:00
|
|
|
}
|
|
|
|
|
|
2025-12-05 07:08:03 +01:00
|
|
|
/// Stop playback in response to user action (e.g., Stop button in UI).
|
|
|
|
|
///
|
|
|
|
|
/// This method marks the stop as user-requested to prevent automatic
|
|
|
|
|
/// advancement to the next track in the queue when the STOPPED event
|
|
|
|
|
/// is received from the renderer.
|
|
|
|
|
pub fn user_stop(&self, renderer_id: &RendererId) -> anyhow::Result<()> {
|
|
|
|
|
// Mark that user requested stop before actually stopping
|
|
|
|
|
self.runtime.mark_user_stop_requested(renderer_id);
|
|
|
|
|
|
|
|
|
|
// Get renderer and call stop
|
|
|
|
|
let renderer = self.music_renderer_by_id(renderer_id).ok_or_else(|| {
|
|
|
|
|
warn!(
|
|
|
|
|
renderer = renderer_id.0.as_str(),
|
|
|
|
|
"Cannot stop: renderer not found in registry"
|
|
|
|
|
);
|
|
|
|
|
anyhow!("Renderer {} not found", renderer_id.0)
|
|
|
|
|
})?;
|
|
|
|
|
|
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,
|
|
|
|
|
renderer_id: &RendererId,
|
|
|
|
|
server_id: ServerId,
|
|
|
|
|
container_id: String,
|
2025-12-06 00:38:06 +01:00
|
|
|
) -> anyhow::Result<()> {
|
|
|
|
|
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,
|
|
|
|
|
renderer_id: &RendererId,
|
|
|
|
|
server_id: ServerId,
|
|
|
|
|
container_id: String,
|
2025-12-06 00:38:06 +01:00
|
|
|
auto_play: bool,
|
|
|
|
|
) -> anyhow::Result<()> {
|
|
|
|
|
self.attach_queue_to_playlist_internal(renderer_id, &server_id, &container_id, auto_play)
|
2025-12-05 07:08:03 +01:00
|
|
|
}
|
|
|
|
|
|
2025-12-06 00:38:06 +01:00
|
|
|
/// Internal implementation shared by every attach wrapper.
|
2025-12-05 07:08:03 +01:00
|
|
|
fn attach_queue_to_playlist_internal(
|
|
|
|
|
&self,
|
|
|
|
|
renderer_id: &RendererId,
|
2025-12-06 00:38:06 +01:00
|
|
|
server_id: &ServerId,
|
|
|
|
|
container_id: &str,
|
|
|
|
|
auto_play: bool,
|
|
|
|
|
) -> anyhow::Result<()> {
|
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
|
|
|
|
|
|
|
|
// Clear the renderer's queue for OpenHome renderers
|
|
|
|
|
// We also sync the local cache to reflect the empty state, which will trigger
|
|
|
|
|
// refresh_attached_queue_for() to use replace_entire_playlist() instead of gentle sync
|
|
|
|
|
if self.runtime.uses_openhome_playlist(renderer_id) {
|
|
|
|
|
let renderer = self.openhome_renderer(renderer_id)?;
|
|
|
|
|
renderer.openhome_playlist_clear()?;
|
|
|
|
|
// Sync local cache to reflect the empty renderer state
|
|
|
|
|
self.sync_openhome_playlist_for(renderer_id)?;
|
|
|
|
|
debug!(
|
|
|
|
|
renderer = renderer_id.0.as_str(),
|
|
|
|
|
"Cleared OpenHome renderer playlist and synced local cache"
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
// For non-OpenHome renderers, use the standard clear_queue
|
|
|
|
|
self.clear_queue(renderer_id)?;
|
|
|
|
|
}
|
2025-12-26 18:35:56 +01:00
|
|
|
|
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
|
|
|
};
|
|
|
|
|
|
2025-12-03 23:17:41 +01:00
|
|
|
{
|
|
|
|
|
let mut bindings = self.playlist_bindings.lock().unwrap();
|
2025-12-05 07:08:03 +01:00
|
|
|
bindings.insert(renderer_id.clone(), binding.clone());
|
2025-12-03 23:17:41 +01:00
|
|
|
info!(
|
|
|
|
|
renderer = renderer_id.0.as_str(),
|
|
|
|
|
server = server_id.0.as_str(),
|
2025-12-06 00:38:06 +01:00
|
|
|
container = container_id,
|
|
|
|
|
auto_play,
|
2025-12-03 23:17:41 +01:00
|
|
|
"Queue attached to playlist container"
|
|
|
|
|
);
|
2025-12-06 00:38:06 +01:00
|
|
|
}
|
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-06 00:38:06 +01:00
|
|
|
let mut auto_start_cb = |rid: &RendererId| self.start_queue_playback_if_idle(rid);
|
|
|
|
|
let callback: Option<&mut dyn FnMut(&RendererId) -> anyhow::Result<()>> = if auto_play {
|
|
|
|
|
Some(&mut auto_start_cb)
|
|
|
|
|
} else {
|
|
|
|
|
None
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
refresh_attached_queue_for(
|
|
|
|
|
&self.registry,
|
|
|
|
|
&self.runtime,
|
|
|
|
|
&self.playlist_bindings,
|
|
|
|
|
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.
|
2025-12-02 14:41:53 +01:00
|
|
|
pub fn detach_queue_playlist(&self, renderer_id: &RendererId) {
|
2025-12-06 00:38:06 +01:00
|
|
|
self.detach_playlist_binding(renderer_id, "api_detach");
|
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,
|
|
|
|
|
renderer_id: &RendererId,
|
|
|
|
|
) -> Option<(ServerId, String, bool)> {
|
|
|
|
|
let bindings = self.playlist_bindings.lock().unwrap();
|
|
|
|
|
bindings.get(renderer_id).map(|binding| {
|
|
|
|
|
(
|
|
|
|
|
binding.server_id.clone(),
|
|
|
|
|
binding.container_id.clone(),
|
|
|
|
|
binding.has_seen_update,
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-06 00:38:06 +01:00
|
|
|
/// Internal helper to detach any playlist binding and notify observers.
|
2025-12-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.
|
|
|
|
|
fn detach_playlist_binding(&self, renderer_id: &RendererId, reason: &str) {
|
|
|
|
|
let removed = {
|
|
|
|
|
let mut bindings = self.playlist_bindings.lock().unwrap();
|
|
|
|
|
bindings.remove(renderer_id)
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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 {
|
|
|
|
|
match state {
|
|
|
|
|
PlaybackState::Stopped => {
|
2025-12-05 07:08:03 +01:00
|
|
|
// Check if user requested stop (via Stop button in UI)
|
|
|
|
|
if self.runtime.check_and_clear_user_stop_requested(id) {
|
|
|
|
|
debug!(
|
|
|
|
|
renderer = id.0.as_str(),
|
|
|
|
|
"Renderer stopped by user request; not auto-advancing"
|
|
|
|
|
);
|
|
|
|
|
self.runtime.set_playback_source(id, PlaybackSource::None);
|
|
|
|
|
} else if self.runtime.is_playing_from_queue(id) {
|
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"
|
|
|
|
|
);
|
2025-12-01 20:40:24 +01:00
|
|
|
self.runtime.set_playback_source(id, PlaybackSource::None);
|
2025-12-01 19:55:55 +01:00
|
|
|
}
|
|
|
|
|
} else {
|
2025-12-01 20:40:24 +01:00
|
|
|
self.runtime.set_playback_source(id, PlaybackSource::None);
|
2025-12-01 19:55:55 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
PlaybackState::Playing => {
|
|
|
|
|
self.runtime.mark_external_if_idle(id);
|
|
|
|
|
}
|
|
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn runtime_entry_missing(renderer_id: &RendererId) -> anyhow::Error {
|
|
|
|
|
anyhow!(
|
|
|
|
|
"Renderer {} not registered in control point runtime",
|
|
|
|
|
renderer_id.0
|
|
|
|
|
)
|
|
|
|
|
}
|
2025-12-05 19:01:33 +01:00
|
|
|
|
2025-12-05 21:59:43 +01:00
|
|
|
fn openhome_renderer(&self, renderer_id: &RendererId) -> anyhow::Result<MusicRenderer> {
|
|
|
|
|
let renderer = self
|
|
|
|
|
.music_renderer_by_id(renderer_id)
|
|
|
|
|
.ok_or_else(|| OpenHomeAccessError::RendererNotFound(renderer_id.0.clone()))?;
|
|
|
|
|
if !renderer.info().capabilities.has_oh_playlist {
|
|
|
|
|
return Err(OpenHomeAccessError::PlaylistNotSupported(renderer_id.0.clone()).into());
|
|
|
|
|
}
|
|
|
|
|
Ok(renderer)
|
2025-12-05 19:01:33 +01:00
|
|
|
}
|
|
|
|
|
|
2025-12-05 21:59:43 +01:00
|
|
|
fn sync_openhome_playlist_for(&self, renderer_id: &RendererId) -> anyhow::Result<()> {
|
|
|
|
|
sync_openhome_playlist(&self.registry, &self.runtime, &self.event_bus, renderer_id)
|
2025-12-05 19:01:33 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn enqueue_items_openhome(
|
|
|
|
|
&self,
|
|
|
|
|
renderer_id: &RendererId,
|
|
|
|
|
items: Vec<PlaybackItem>,
|
|
|
|
|
) -> anyhow::Result<()> {
|
|
|
|
|
if items.is_empty() {
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-05 21:59:43 +01:00
|
|
|
let renderer = self.openhome_renderer(renderer_id)?;
|
2025-12-05 22:31:35 +01:00
|
|
|
|
|
|
|
|
// Get the last track ID from the OpenHome native playlist
|
|
|
|
|
// This ensures we append to the end of the actual playlist, not just our local queue
|
|
|
|
|
let mut after_id = renderer
|
|
|
|
|
.openhome_playlist_ids()
|
|
|
|
|
.ok()
|
|
|
|
|
.and_then(|ids| ids.last().copied());
|
2025-12-05 19:01:33 +01:00
|
|
|
|
|
|
|
|
for item in items.iter() {
|
2025-12-06 13:37:42 +01:00
|
|
|
let metadata = playback_item_to_didl(item);
|
2025-12-17 20:45:51 +01:00
|
|
|
debug!(
|
|
|
|
|
uri = item.uri.as_str(),
|
|
|
|
|
protocol_info = item.protocol_info.as_str(),
|
|
|
|
|
metadata_len = metadata.len(),
|
|
|
|
|
"Inserting track to OpenHome playlist"
|
|
|
|
|
);
|
|
|
|
|
trace!(metadata = metadata.as_str(), "DIDL-Lite metadata");
|
2025-12-05 21:59:43 +01:00
|
|
|
after_id =
|
|
|
|
|
Some(renderer.openhome_playlist_add_track(&item.uri, &metadata, after_id, false)?);
|
2025-12-05 19:01:33 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
self.sync_openhome_playlist_for(renderer_id)?;
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn play_current_openhome(&self, renderer_id: &RendererId) -> anyhow::Result<()> {
|
2025-12-05 22:31:35 +01:00
|
|
|
let renderer = self.openhome_renderer(renderer_id)?;
|
|
|
|
|
|
2025-12-17 21:41:24 +01:00
|
|
|
// MICRO-PATCH 5: Pour OpenHome, récupérer les données directement depuis la playlist native
|
|
|
|
|
// au lieu du miroir local (qui peut être vide ou obsolète)
|
|
|
|
|
match self.get_cached_openhome_playlist_snapshot(
|
|
|
|
|
renderer_id,
|
|
|
|
|
OPENHOME_SNAPSHOT_CACHE_TTL,
|
|
|
|
|
) {
|
|
|
|
|
Ok(snapshot) => {
|
|
|
|
|
if snapshot.tracks.is_empty() {
|
|
|
|
|
debug!(
|
|
|
|
|
renderer = renderer_id.0.as_str(),
|
|
|
|
|
"OpenHome playlist is empty"
|
|
|
|
|
);
|
|
|
|
|
self.runtime
|
|
|
|
|
.set_playback_source(renderer_id, PlaybackSource::None);
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Trouver le track_id courant depuis le snapshot OpenHome
|
|
|
|
|
let target_track_id = if let Some(current_id) = snapshot.current_id {
|
|
|
|
|
Some(current_id)
|
|
|
|
|
} else if let Some(current_idx) = snapshot.current_index {
|
|
|
|
|
snapshot.tracks.get(current_idx).map(|track| track.id)
|
|
|
|
|
} else {
|
|
|
|
|
snapshot.tracks.first().map(|track| track.id)
|
|
|
|
|
};
|
2025-12-05 22:31:35 +01:00
|
|
|
|
2025-12-17 21:41:24 +01:00
|
|
|
if let Some(track_id) = target_track_id {
|
|
|
|
|
match renderer.openhome_playlist_play_id(track_id) {
|
|
|
|
|
Ok(()) => {
|
|
|
|
|
self.runtime
|
|
|
|
|
.set_playback_source(renderer_id, PlaybackSource::FromQueue);
|
|
|
|
|
self.sync_openhome_playlist_for(renderer_id)?;
|
|
|
|
|
info!(
|
|
|
|
|
renderer = renderer_id.0.as_str(),
|
|
|
|
|
track_id,
|
|
|
|
|
playlist_len = snapshot.tracks.len(),
|
|
|
|
|
"Started OpenHome playlist playback (current item)"
|
|
|
|
|
);
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
|
|
|
|
Err(err) => {
|
|
|
|
|
warn!(
|
|
|
|
|
renderer = renderer_id.0.as_str(),
|
|
|
|
|
track_id,
|
|
|
|
|
error = %err,
|
|
|
|
|
"PlayId failed, falling back to Play()"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fallback: appeler Play() sans spécifier de track_id
|
|
|
|
|
renderer.play()?;
|
|
|
|
|
self.runtime
|
|
|
|
|
.set_playback_source(renderer_id, PlaybackSource::FromQueue);
|
|
|
|
|
self.sync_openhome_playlist_for(renderer_id)?;
|
|
|
|
|
info!(
|
|
|
|
|
renderer = renderer_id.0.as_str(),
|
|
|
|
|
playlist_len = snapshot.tracks.len(),
|
|
|
|
|
"Started OpenHome native playlist playback"
|
|
|
|
|
);
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
|
|
|
|
Err(err) => {
|
|
|
|
|
warn!(
|
|
|
|
|
renderer = renderer_id.0.as_str(),
|
|
|
|
|
error = %err,
|
|
|
|
|
"Failed to fetch OpenHome playlist snapshot, playlist might be empty"
|
|
|
|
|
);
|
|
|
|
|
debug!(
|
|
|
|
|
renderer = renderer_id.0.as_str(),
|
|
|
|
|
"OpenHome playlist is empty or unavailable"
|
|
|
|
|
);
|
|
|
|
|
self.runtime
|
|
|
|
|
.set_playback_source(renderer_id, PlaybackSource::None);
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
2025-12-05 22:31:35 +01:00
|
|
|
}
|
2025-12-17 21:41:24 +01:00
|
|
|
}
|
2025-12-05 22:31:35 +01:00
|
|
|
|
2025-12-17 21:41:24 +01:00
|
|
|
fn play_next_openhome(&self, renderer_id: &RendererId) -> anyhow::Result<()> {
|
|
|
|
|
let renderer = self.openhome_renderer(renderer_id)?;
|
|
|
|
|
|
|
|
|
|
// MICRO-PATCH 5: Récupérer les données directement depuis OpenHome au lieu du miroir local
|
|
|
|
|
let snapshot = self.get_cached_openhome_playlist_snapshot(
|
|
|
|
|
renderer_id,
|
|
|
|
|
OPENHOME_SNAPSHOT_CACHE_TTL,
|
|
|
|
|
)?;
|
|
|
|
|
|
|
|
|
|
if snapshot.tracks.is_empty() {
|
2025-12-05 19:01:33 +01:00
|
|
|
debug!(
|
|
|
|
|
renderer = renderer_id.0.as_str(),
|
2025-12-17 21:41:24 +01:00
|
|
|
"OpenHome playlist is empty, cannot play next"
|
2025-12-05 19:01:33 +01:00
|
|
|
);
|
|
|
|
|
self.runtime
|
|
|
|
|
.set_playback_source(renderer_id, PlaybackSource::None);
|
|
|
|
|
return Ok(());
|
2025-12-17 21:41:24 +01:00
|
|
|
}
|
2025-12-05 21:59:43 +01:00
|
|
|
|
2025-12-17 21:41:24 +01:00
|
|
|
// Déterminer le prochain track_id
|
|
|
|
|
let next_track_id = match snapshot.current_index {
|
|
|
|
|
Some(idx) => {
|
|
|
|
|
// Prendre la piste suivante si elle existe
|
|
|
|
|
snapshot
|
|
|
|
|
.tracks
|
|
|
|
|
.get(idx + 1)
|
|
|
|
|
.map(|track| track.id)
|
|
|
|
|
.or_else(|| snapshot.tracks.first().map(|track| track.id))
|
|
|
|
|
}
|
|
|
|
|
None => snapshot.tracks.first().map(|track| track.id),
|
2025-12-05 21:59:43 +01:00
|
|
|
};
|
|
|
|
|
|
2025-12-17 21:41:24 +01:00
|
|
|
let Some(track_id) = next_track_id else {
|
2025-12-05 21:59:43 +01:00
|
|
|
debug!(
|
|
|
|
|
renderer = renderer_id.0.as_str(),
|
|
|
|
|
"No OpenHome track available to advance to"
|
|
|
|
|
);
|
|
|
|
|
self.runtime
|
|
|
|
|
.set_playback_source(renderer_id, PlaybackSource::None);
|
|
|
|
|
return Ok(());
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
renderer.openhome_playlist_play_id(track_id)?;
|
2025-12-05 19:01:33 +01:00
|
|
|
self.runtime
|
|
|
|
|
.set_playback_source(renderer_id, PlaybackSource::FromQueue);
|
|
|
|
|
self.sync_openhome_playlist_for(renderer_id)?;
|
|
|
|
|
info!(
|
|
|
|
|
renderer = renderer_id.0.as_str(),
|
2025-12-05 21:59:43 +01:00
|
|
|
track_id, "Advanced OpenHome playlist to next track"
|
2025-12-05 19:01:33 +01:00
|
|
|
);
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
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),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-06 13:37:42 +01:00
|
|
|
fn build_openhome_queue(info: &RendererInfo) -> Option<OpenHomeQueue> {
|
2025-12-17 21:41:24 +01:00
|
|
|
let playlist = build_playlist_client(info)?;
|
|
|
|
|
let info_client = build_info_client(info);
|
|
|
|
|
let product_client = build_product_client(info);
|
|
|
|
|
Some(OpenHomeQueue::new(
|
|
|
|
|
info.id.clone(),
|
|
|
|
|
playlist,
|
|
|
|
|
info_client,
|
|
|
|
|
product_client,
|
|
|
|
|
))
|
2025-12-06 00:38:06 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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)
|
|
|
|
|
}
|
|
|
|
|
|
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-01 19:55:55 +01:00
|
|
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
|
|
|
|
enum PlaybackSource {
|
|
|
|
|
#[default]
|
|
|
|
|
None,
|
|
|
|
|
FromQueue,
|
|
|
|
|
External,
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-05 19:01:33 +01:00
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
|
|
|
enum PlaylistBackend {
|
|
|
|
|
PMOQueue,
|
|
|
|
|
OpenHome,
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-01 19:55:55 +01:00
|
|
|
struct RendererRuntimeEntry {
|
|
|
|
|
snapshot: RendererRuntimeSnapshot,
|
2025-12-06 13:37:42 +01:00
|
|
|
pub queue: crate::control_point::music_queue::MusicQueue,
|
2025-12-17 21:41:24 +01:00
|
|
|
openhome_cache: OpenHomePlaylistCache,
|
2025-12-01 19:55:55 +01:00
|
|
|
playback_source: PlaybackSource,
|
2025-12-05 07:08:03 +01:00
|
|
|
user_stop_requested: bool,
|
2025-12-05 19:01:33 +01:00
|
|
|
playlist_backend: PlaylistBackend,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Default for RendererRuntimeEntry {
|
|
|
|
|
fn default() -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
snapshot: RendererRuntimeSnapshot::default(),
|
2025-12-06 13:37:42 +01:00
|
|
|
queue: MusicQueue::Internal(InternalQueue::new()),
|
2025-12-17 21:41:24 +01:00
|
|
|
openhome_cache: OpenHomePlaylistCache::default(),
|
2025-12-05 19:01:33 +01:00
|
|
|
playback_source: PlaybackSource::None,
|
|
|
|
|
user_stop_requested: false,
|
|
|
|
|
playlist_backend: PlaylistBackend::PMOQueue,
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-12-01 19:55:55 +01:00
|
|
|
}
|
|
|
|
|
|
2025-12-17 21:41:24 +01:00
|
|
|
#[derive(Debug, Default, Clone)]
|
|
|
|
|
struct OpenHomePlaylistCache {
|
|
|
|
|
ids: Option<Vec<u32>>,
|
|
|
|
|
snapshot: Option<OpenHomePlaylistSnapshot>,
|
|
|
|
|
last_refresh: Option<Instant>,
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-01 19:55:55 +01:00
|
|
|
struct RuntimeState {
|
|
|
|
|
entries: Mutex<HashMap<RendererId, RendererRuntimeEntry>>,
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-06 13:37:42 +01:00
|
|
|
pub struct RendererRuntimeStateMut<'a> {
|
|
|
|
|
pub queue: MusicQueueGuard<'a>,
|
|
|
|
|
_guard: MutexGuard<'a, HashMap<RendererId, RendererRuntimeEntry>>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl<'a> RendererRuntimeStateMut<'a> {
|
|
|
|
|
fn new(
|
|
|
|
|
guard: MutexGuard<'a, HashMap<RendererId, RendererRuntimeEntry>>,
|
|
|
|
|
queue_ptr: *mut MusicQueue,
|
|
|
|
|
) -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
queue: MusicQueueGuard::new(queue_ptr),
|
|
|
|
|
_guard: guard,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub struct MusicQueueGuard<'a> {
|
|
|
|
|
ptr: *mut MusicQueue,
|
|
|
|
|
_marker: PhantomData<&'a mut MusicQueue>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl<'a> MusicQueueGuard<'a> {
|
|
|
|
|
fn new(ptr: *mut MusicQueue) -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
ptr,
|
|
|
|
|
_marker: PhantomData,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl<'a> Deref for MusicQueueGuard<'a> {
|
|
|
|
|
type Target = MusicQueue;
|
|
|
|
|
|
|
|
|
|
fn deref(&self) -> &Self::Target {
|
|
|
|
|
unsafe { &*self.ptr }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl<'a> DerefMut for MusicQueueGuard<'a> {
|
|
|
|
|
fn deref_mut(&mut self) -> &mut Self::Target {
|
|
|
|
|
unsafe { &mut *self.ptr }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct RuntimeOpenHomeQueueProvider {
|
|
|
|
|
runtime: Arc<RuntimeState>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl OpenHomeQueueProvider for RuntimeOpenHomeQueueProvider {
|
|
|
|
|
fn renderer_state(&self, renderer_id: &RendererId) -> anyhow::Result<RendererRuntimeState> {
|
|
|
|
|
self.runtime.renderer_state(renderer_id)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn renderer_state_mut<'a>(
|
|
|
|
|
&'a self,
|
|
|
|
|
renderer_id: &RendererId,
|
|
|
|
|
) -> anyhow::Result<RendererRuntimeStateMut<'a>> {
|
|
|
|
|
self.runtime.renderer_state_mut(renderer_id)
|
|
|
|
|
}
|
2025-12-17 21:41:24 +01:00
|
|
|
|
|
|
|
|
fn invalidate_openhome_cache(&self, renderer_id: &RendererId) -> anyhow::Result<()> {
|
|
|
|
|
self.runtime.invalidate_openhome_cache(renderer_id);
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
2025-12-06 13:37:42 +01:00
|
|
|
}
|
|
|
|
|
|
2025-12-01 19:55:55 +01:00
|
|
|
impl RuntimeState {
|
|
|
|
|
fn new() -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
entries: Mutex::new(HashMap::new()),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn snapshot_for(&self, id: &RendererId) -> RendererRuntimeSnapshot {
|
|
|
|
|
let entries = self.entries.lock().unwrap();
|
|
|
|
|
entries
|
|
|
|
|
.get(id)
|
|
|
|
|
.map(|entry| entry.snapshot.clone())
|
|
|
|
|
.unwrap_or_default()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn update_snapshot(&self, id: &RendererId, snapshot: RendererRuntimeSnapshot) {
|
|
|
|
|
self.with_entry(id, |entry| {
|
|
|
|
|
entry.snapshot = snapshot;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-05 19:01:33 +01:00
|
|
|
fn update_snapshot_with<F>(&self, id: &RendererId, f: F)
|
|
|
|
|
where
|
|
|
|
|
F: FnOnce(&mut RendererRuntimeSnapshot),
|
|
|
|
|
{
|
|
|
|
|
self.with_entry(id, |entry| {
|
|
|
|
|
f(&mut entry.snapshot);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-01 19:55:55 +01:00
|
|
|
fn has_entry(&self, id: &RendererId) -> bool {
|
|
|
|
|
let entries = self.entries.lock().unwrap();
|
|
|
|
|
entries.contains_key(id)
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-06 13:37:42 +01:00
|
|
|
fn with_music_queue_mut<F, R>(&self, id: &RendererId, f: F) -> anyhow::Result<R>
|
2025-12-01 19:55:55 +01:00
|
|
|
where
|
2025-12-06 13:37:42 +01:00
|
|
|
F: FnOnce(&mut MusicQueue) -> anyhow::Result<R>,
|
2025-12-01 19:55:55 +01:00
|
|
|
{
|
|
|
|
|
let mut entries = self.entries.lock().unwrap();
|
2025-12-06 13:37:42 +01:00
|
|
|
let entry = entries
|
|
|
|
|
.get_mut(id)
|
|
|
|
|
.ok_or_else(|| anyhow!("Renderer {} not registered in runtime", id.0))?;
|
|
|
|
|
f(&mut entry.queue)
|
2025-12-01 19:55:55 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn queue_snapshot(&self, id: &RendererId) -> Option<Vec<PlaybackItem>> {
|
|
|
|
|
let entries = self.entries.lock().unwrap();
|
2025-12-06 13:37:42 +01:00
|
|
|
entries
|
|
|
|
|
.get(id)
|
|
|
|
|
.and_then(|entry| entry.queue.upcoming_items().ok())
|
2025-12-01 19:55:55 +01:00
|
|
|
}
|
|
|
|
|
|
2025-12-03 21:49:41 +01:00
|
|
|
fn queue_full_snapshot(&self, id: &RendererId) -> Option<(Vec<PlaybackItem>, Option<usize>)> {
|
|
|
|
|
let entries = self.entries.lock().unwrap();
|
2025-12-06 13:37:42 +01:00
|
|
|
entries.get(id).and_then(|entry| {
|
|
|
|
|
entry
|
|
|
|
|
.queue
|
|
|
|
|
.queue_snapshot()
|
|
|
|
|
.ok()
|
|
|
|
|
.map(|snapshot| (snapshot.items, snapshot.current_index))
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn renderer_state(&self, id: &RendererId) -> anyhow::Result<RendererRuntimeState> {
|
|
|
|
|
let entries = self.entries.lock().unwrap();
|
|
|
|
|
let entry = entries
|
|
|
|
|
.get(id)
|
|
|
|
|
.ok_or_else(|| anyhow!("Renderer {} not registered in runtime", id.0))?;
|
|
|
|
|
Ok(RendererRuntimeState {
|
|
|
|
|
queue: entry.queue.clone(),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-15 11:18:58 +01:00
|
|
|
fn renderer_state_mut(&self, id: &RendererId) -> anyhow::Result<RendererRuntimeStateMut<'_>> {
|
2025-12-06 13:37:42 +01:00
|
|
|
let mut entries = self.entries.lock().unwrap();
|
|
|
|
|
let queue_ptr = {
|
|
|
|
|
let entry = entries
|
|
|
|
|
.get_mut(id)
|
|
|
|
|
.ok_or_else(|| anyhow!("Renderer {} not registered in runtime", id.0))?;
|
|
|
|
|
let entry_ptr: *mut RendererRuntimeEntry = entry;
|
|
|
|
|
unsafe { &mut (*entry_ptr).queue as *mut MusicQueue }
|
|
|
|
|
};
|
|
|
|
|
Ok(RendererRuntimeStateMut::new(entries, queue_ptr))
|
2025-12-03 21:49:41 +01:00
|
|
|
}
|
|
|
|
|
|
2025-12-05 22:31:35 +01:00
|
|
|
fn current_track_metadata(&self, id: &RendererId) -> Option<TrackMetadata> {
|
|
|
|
|
let entries = self.entries.lock().unwrap();
|
|
|
|
|
entries
|
|
|
|
|
.get(id)
|
|
|
|
|
.and_then(|entry| entry.snapshot.last_metadata.clone())
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-06 00:38:06 +01:00
|
|
|
#[cfg(feature = "pmoserver")]
|
|
|
|
|
fn renderer_snapshot_bundle(
|
|
|
|
|
&self,
|
|
|
|
|
id: &RendererId,
|
|
|
|
|
) -> (RendererRuntimeSnapshot, Vec<PlaybackItem>, Option<usize>) {
|
|
|
|
|
let entries = self.entries.lock().unwrap();
|
|
|
|
|
if let Some(entry) = entries.get(id) {
|
2025-12-06 13:37:42 +01:00
|
|
|
match entry.queue.queue_snapshot() {
|
|
|
|
|
Ok(snapshot) => (
|
|
|
|
|
entry.snapshot.clone(),
|
|
|
|
|
snapshot.items,
|
|
|
|
|
snapshot.current_index,
|
|
|
|
|
),
|
|
|
|
|
Err(err) => {
|
|
|
|
|
warn!(
|
|
|
|
|
renderer = id.0.as_str(),
|
|
|
|
|
error = %err,
|
|
|
|
|
"Failed to build queue snapshot for renderer"
|
|
|
|
|
);
|
|
|
|
|
(entry.snapshot.clone(), Vec::new(), None)
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-12-06 00:38:06 +01:00
|
|
|
} else {
|
|
|
|
|
(RendererRuntimeSnapshot::default(), Vec::new(), None)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-01 19:55:55 +01:00
|
|
|
fn dequeue_next(&self, id: &RendererId) -> Option<(PlaybackItem, usize)> {
|
|
|
|
|
let mut entries = self.entries.lock().unwrap();
|
|
|
|
|
let entry = entries.get_mut(id)?;
|
2025-12-06 13:37:42 +01:00
|
|
|
entry.queue.dequeue_next().ok().flatten()
|
2025-12-01 19:55:55 +01:00
|
|
|
}
|
|
|
|
|
|
2025-12-05 07:08:03 +01:00
|
|
|
fn peek_current(&self, id: &RendererId) -> Option<(PlaybackItem, usize)> {
|
|
|
|
|
let entries = self.entries.lock().unwrap();
|
|
|
|
|
let entry = entries.get(id)?;
|
2025-12-06 13:37:42 +01:00
|
|
|
entry.queue.peek_current().ok().flatten()
|
2025-12-05 07:08:03 +01:00
|
|
|
}
|
|
|
|
|
|
2025-12-01 19:55:55 +01:00
|
|
|
fn set_playback_source(&self, id: &RendererId, source: PlaybackSource) {
|
|
|
|
|
let mut entries = self.entries.lock().unwrap();
|
|
|
|
|
if let Some(entry) = entries.get_mut(id) {
|
|
|
|
|
entry.playback_source = source;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn playback_source(&self, id: &RendererId) -> PlaybackSource {
|
|
|
|
|
let entries = self.entries.lock().unwrap();
|
|
|
|
|
entries
|
|
|
|
|
.get(id)
|
|
|
|
|
.map(|entry| entry.playback_source)
|
|
|
|
|
.unwrap_or(PlaybackSource::None)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn is_playing_from_queue(&self, id: &RendererId) -> bool {
|
2025-12-01 20:40:24 +01:00
|
|
|
matches!(self.playback_source(id), PlaybackSource::FromQueue)
|
2025-12-01 19:55:55 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn mark_external_if_idle(&self, id: &RendererId) {
|
|
|
|
|
let mut entries = self.entries.lock().unwrap();
|
|
|
|
|
if let Some(entry) = entries.get_mut(id) {
|
|
|
|
|
if matches!(entry.playback_source, PlaybackSource::None) {
|
|
|
|
|
entry.playback_source = PlaybackSource::External;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn with_entry<F, R>(&self, id: &RendererId, f: F) -> R
|
|
|
|
|
where
|
|
|
|
|
F: FnOnce(&mut RendererRuntimeEntry) -> R,
|
|
|
|
|
{
|
|
|
|
|
let mut entries = self.entries.lock().unwrap();
|
|
|
|
|
let entry = entries
|
|
|
|
|
.entry(id.clone())
|
|
|
|
|
.or_insert_with(RendererRuntimeEntry::default);
|
|
|
|
|
f(entry)
|
|
|
|
|
}
|
2025-12-05 07:08:03 +01:00
|
|
|
|
2025-12-06 13:37:42 +01:00
|
|
|
fn set_music_queue(&self, id: &RendererId, queue: MusicQueue) {
|
|
|
|
|
self.with_entry(id, |entry| {
|
|
|
|
|
entry.queue = queue;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-17 21:41:24 +01:00
|
|
|
fn invalidate_openhome_cache(&self, id: &RendererId) {
|
|
|
|
|
self.with_entry(id, |entry| {
|
|
|
|
|
entry.openhome_cache = OpenHomePlaylistCache::default();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn get_openhome_cache(&self, id: &RendererId) -> Option<OpenHomePlaylistCache> {
|
|
|
|
|
let entries = self.entries.lock().unwrap();
|
|
|
|
|
entries.get(id).map(|entry| entry.openhome_cache.clone())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn set_openhome_cache(&self, id: &RendererId, cache: OpenHomePlaylistCache) {
|
|
|
|
|
self.with_entry(id, |entry| {
|
|
|
|
|
entry.openhome_cache = cache;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn openhome_snapshot_cached(
|
|
|
|
|
&self,
|
|
|
|
|
renderer: &MusicRenderer,
|
|
|
|
|
ttl: Duration,
|
|
|
|
|
) -> anyhow::Result<OpenHomePlaylistSnapshot> {
|
|
|
|
|
let ids = renderer.openhome_playlist_ids()?;
|
|
|
|
|
let now = Instant::now();
|
|
|
|
|
|
|
|
|
|
if let Some(cache) = self.get_openhome_cache(renderer.id()) {
|
|
|
|
|
if let (Some(cached_ids), Some(snapshot), Some(last_refresh)) =
|
|
|
|
|
(cache.ids, cache.snapshot, cache.last_refresh)
|
|
|
|
|
{
|
|
|
|
|
if cached_ids == ids && now.saturating_duration_since(last_refresh) <= ttl {
|
|
|
|
|
return Ok(snapshot);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let snapshot = renderer.fetch_openhome_playlist_snapshot()?;
|
|
|
|
|
let cache = OpenHomePlaylistCache {
|
|
|
|
|
ids: Some(ids),
|
|
|
|
|
snapshot: Some(snapshot.clone()),
|
|
|
|
|
last_refresh: Some(now),
|
|
|
|
|
};
|
|
|
|
|
self.set_openhome_cache(renderer.id(), cache);
|
|
|
|
|
Ok(snapshot)
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-05 19:01:33 +01:00
|
|
|
fn set_playlist_backend(&self, id: &RendererId, backend: PlaylistBackend) {
|
|
|
|
|
self.with_entry(id, |entry| {
|
|
|
|
|
entry.playlist_backend = backend;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn playlist_backend(&self, id: &RendererId) -> PlaylistBackend {
|
|
|
|
|
let entries = self.entries.lock().unwrap();
|
|
|
|
|
entries
|
|
|
|
|
.get(id)
|
|
|
|
|
.map(|entry| entry.playlist_backend)
|
|
|
|
|
.unwrap_or(PlaylistBackend::PMOQueue)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn uses_openhome_playlist(&self, id: &RendererId) -> bool {
|
2025-12-05 21:59:43 +01:00
|
|
|
matches!(self.playlist_backend(id), PlaylistBackend::OpenHome)
|
2025-12-05 19:01:33 +01:00
|
|
|
}
|
|
|
|
|
|
2025-12-05 07:08:03 +01:00
|
|
|
fn mark_user_stop_requested(&self, id: &RendererId) {
|
|
|
|
|
let mut entries = self.entries.lock().unwrap();
|
|
|
|
|
if let Some(entry) = entries.get_mut(id) {
|
|
|
|
|
entry.user_stop_requested = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn check_and_clear_user_stop_requested(&self, id: &RendererId) -> bool {
|
|
|
|
|
let mut entries = self.entries.lock().unwrap();
|
|
|
|
|
if let Some(entry) = entries.get_mut(id) {
|
|
|
|
|
let was_requested = entry.user_stop_requested;
|
|
|
|
|
entry.user_stop_requested = false;
|
|
|
|
|
was_requested
|
|
|
|
|
} else {
|
|
|
|
|
false
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-12-01 19:55:55 +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>>,
|
|
|
|
|
runtime: &Arc<RuntimeState>,
|
|
|
|
|
bindings: &Arc<Mutex<HashMap<RendererId, PlaylistBinding>>>,
|
|
|
|
|
renderer_id: &RendererId,
|
2025-12-03 18:59:41 +01:00
|
|
|
event_bus: &RendererEventBus,
|
2025-12-03 23:17:41 +01:00
|
|
|
mut after_refresh: Option<&mut dyn FnMut(&RendererId) -> anyhow::Result<()>>,
|
2025-12-02 14:41:53 +01:00
|
|
|
) -> anyhow::Result<()> {
|
|
|
|
|
// Step 1: Check binding and mark refresh as in-progress
|
2025-12-03 23:17:41 +01:00
|
|
|
let (server_id, container_id, auto_play) = {
|
2025-12-02 14:41:53 +01:00
|
|
|
let mut bindings_lock = bindings.lock().unwrap();
|
|
|
|
|
let binding = match bindings_lock.get_mut(renderer_id) {
|
|
|
|
|
Some(b) => b,
|
|
|
|
|
None => {
|
|
|
|
|
debug!(
|
|
|
|
|
renderer = renderer_id.0.as_str(),
|
|
|
|
|
"refresh_attached_queue_for: no binding present"
|
|
|
|
|
);
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if !binding.pending_refresh {
|
|
|
|
|
debug!(
|
|
|
|
|
renderer = renderer_id.0.as_str(),
|
|
|
|
|
"refresh_attached_queue_for: pending_refresh is false, nothing to do"
|
|
|
|
|
);
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Mark as processed
|
|
|
|
|
binding.pending_refresh = false;
|
2025-12-03 23:17:41 +01:00
|
|
|
let auto_play = binding.auto_play_on_refresh;
|
|
|
|
|
binding.auto_play_on_refresh = false;
|
|
|
|
|
(
|
|
|
|
|
binding.server_id.clone(),
|
|
|
|
|
binding.container_id.clone(),
|
|
|
|
|
auto_play,
|
|
|
|
|
)
|
2025-12-02 14:41:53 +01:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Step 2: Fetch MediaServerInfo from registry
|
|
|
|
|
let server_info = {
|
|
|
|
|
let reg = registry.read().unwrap();
|
|
|
|
|
reg.get_server(&server_id)
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let server_info = match server_info {
|
|
|
|
|
Some(info) => info,
|
|
|
|
|
None => {
|
|
|
|
|
warn!(
|
|
|
|
|
renderer = renderer_id.0.as_str(),
|
|
|
|
|
server = server_id.0.as_str(),
|
|
|
|
|
"refresh_attached_queue_for: server not found in registry"
|
|
|
|
|
);
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if !server_info.online {
|
|
|
|
|
debug!(
|
|
|
|
|
renderer = renderer_id.0.as_str(),
|
|
|
|
|
server = server_id.0.as_str(),
|
|
|
|
|
"refresh_attached_queue_for: server offline, skipping refresh"
|
|
|
|
|
);
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if !server_info.has_content_directory {
|
|
|
|
|
debug!(
|
|
|
|
|
renderer = renderer_id.0.as_str(),
|
|
|
|
|
server = server_id.0.as_str(),
|
|
|
|
|
"refresh_attached_queue_for: server has no ContentDirectory"
|
|
|
|
|
);
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Step 3: Create MusicServer and browse container
|
|
|
|
|
let music_server = MusicServer::from_info(&server_info, Duration::from_secs(5))?;
|
|
|
|
|
|
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
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Step 4: Convert MediaEntry to PlaybackItem
|
|
|
|
|
let new_items: Vec<PlaybackItem> = entries
|
|
|
|
|
.iter()
|
|
|
|
|
.filter_map(|entry| playback_item_from_entry(&music_server, entry))
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
if new_items.is_empty() {
|
|
|
|
|
debug!(
|
|
|
|
|
renderer = renderer_id.0.as_str(),
|
|
|
|
|
server = server_id.0.as_str(),
|
|
|
|
|
container = container_id.as_str(),
|
|
|
|
|
"Refreshed playlist is empty, clearing queue"
|
|
|
|
|
);
|
2025-12-15 11:18:58 +01:00
|
|
|
runtime.with_music_queue_mut(renderer_id, |queue| queue.clear_queue())?;
|
2025-12-17 21:41:24 +01:00
|
|
|
runtime.invalidate_openhome_cache(renderer_id);
|
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(());
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-17 21:41:24 +01:00
|
|
|
// Step 5: GENTLE SYNCHRONIZATION
|
|
|
|
|
// Use incremental replace_queue() instead of replace_with_attached_playlist()
|
|
|
|
|
// This uses LCS algorithm to minimize playlist operations and avoid interrupting playback
|
|
|
|
|
|
2025-12-03 23:17:41 +01:00
|
|
|
// Get the full queue snapshot to access the item currently being played
|
|
|
|
|
let (full_queue, current_idx) = runtime
|
|
|
|
|
.queue_full_snapshot(renderer_id)
|
|
|
|
|
.unwrap_or((vec![], None));
|
|
|
|
|
|
|
|
|
|
// Get the item currently being played (at current_index), not the next one in queue
|
|
|
|
|
let current_item = current_idx.and_then(|idx| full_queue.get(idx).cloned());
|
2025-12-02 14:41:53 +01:00
|
|
|
|
2025-12-17 21:41:24 +01:00
|
|
|
// Check if renderer is playing - if so, we MUST preserve the current track
|
2025-12-26 20:12:11 +01:00
|
|
|
// We use multiple signals to determine playback state:
|
|
|
|
|
// 1. Direct playback_state() query (may fail on some renderers like upmpdcli)
|
|
|
|
|
// 2. Presence of current_idx (if we have a current track, likely playing)
|
2025-12-17 21:41:24 +01:00
|
|
|
let is_playing = {
|
|
|
|
|
let renderer_info = {
|
|
|
|
|
let reg = registry.read().unwrap();
|
|
|
|
|
reg.get_renderer(renderer_id)
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if let Some(info) = renderer_info {
|
|
|
|
|
if let Some(renderer) = MusicRenderer::from_registry_info(info, registry) {
|
2025-12-26 20:12:11 +01:00
|
|
|
// Try direct query first
|
|
|
|
|
if matches!(renderer.playback_state(), Ok(PlaybackState::Playing)) {
|
|
|
|
|
true
|
|
|
|
|
} else if current_idx.is_some() && !full_queue.is_empty() {
|
|
|
|
|
// Fallback: if we have a current index and non-empty queue,
|
|
|
|
|
// assume playback is happening (handles renderers where playback_state() fails)
|
|
|
|
|
debug!(
|
|
|
|
|
renderer = renderer_id.0.as_str(),
|
|
|
|
|
"playback_state() failed or not Playing, but current_idx is set - assuming playback"
|
|
|
|
|
);
|
|
|
|
|
true
|
|
|
|
|
} else {
|
|
|
|
|
false
|
|
|
|
|
}
|
2025-12-17 21:41:24 +01:00
|
|
|
} else {
|
|
|
|
|
false
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
false
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-02 14:41:53 +01:00
|
|
|
let item_found_at = current_item.as_ref().and_then(|current| {
|
2025-12-06 13:37:42 +01:00
|
|
|
let current_uid = current.unique_id();
|
|
|
|
|
new_items
|
|
|
|
|
.iter()
|
|
|
|
|
.position(|new_item| new_item.unique_id() == current_uid)
|
2025-12-15 11:18:58 +01:00
|
|
|
.or_else(|| {
|
|
|
|
|
new_items
|
|
|
|
|
.iter()
|
|
|
|
|
.position(|new_item| new_item.uri == current.uri)
|
|
|
|
|
})
|
2025-12-02 14:41:53 +01:00
|
|
|
});
|
|
|
|
|
|
2025-12-15 11:18:58 +01:00
|
|
|
let final_queue_len = runtime.with_music_queue_mut(renderer_id, |queue| {
|
|
|
|
|
if let Some(idx) = item_found_at {
|
2025-12-17 21:41:24 +01:00
|
|
|
// Current item is in new playlist - use gentle incremental update
|
2025-12-15 11:18:58 +01:00
|
|
|
queue.replace_queue(new_items.clone(), Some(idx))?;
|
|
|
|
|
info!(
|
|
|
|
|
renderer = renderer_id.0.as_str(),
|
|
|
|
|
server = server_id.0.as_str(),
|
|
|
|
|
container = container_id.as_str(),
|
|
|
|
|
total_items = new_items.len(),
|
|
|
|
|
current_index = idx,
|
|
|
|
|
upcoming = new_items.len().saturating_sub(idx + 1),
|
|
|
|
|
current_preserved = true,
|
2025-12-17 21:41:24 +01:00
|
|
|
is_playing,
|
|
|
|
|
"Gentle refresh: current item found in new playlist"
|
2025-12-15 11:18:58 +01:00
|
|
|
);
|
|
|
|
|
Ok(new_items.len())
|
|
|
|
|
} else if let Some(ref current) = current_item {
|
2025-12-17 21:41:24 +01:00
|
|
|
// Current item NOT in new playlist
|
|
|
|
|
if is_playing {
|
|
|
|
|
// CRITICAL: Preserve current playing track by inserting it at the beginning
|
|
|
|
|
let mut combined = Vec::with_capacity(new_items.len() + 1);
|
|
|
|
|
combined.push(current.clone());
|
|
|
|
|
combined.extend(new_items.clone());
|
|
|
|
|
queue.replace_queue(combined, Some(0))?;
|
|
|
|
|
info!(
|
|
|
|
|
renderer = renderer_id.0.as_str(),
|
|
|
|
|
server = server_id.0.as_str(),
|
|
|
|
|
container = container_id.as_str(),
|
|
|
|
|
total_items = new_items.len() + 1,
|
|
|
|
|
current_index = 0,
|
|
|
|
|
upcoming = new_items.len(),
|
|
|
|
|
current_preserved = true,
|
|
|
|
|
current_reinserted = true,
|
|
|
|
|
is_playing = true,
|
|
|
|
|
"Gentle refresh: preserved playing track not in new playlist"
|
|
|
|
|
);
|
|
|
|
|
Ok(new_items.len() + 1)
|
|
|
|
|
} else {
|
|
|
|
|
// Not playing, use new playlist as-is
|
|
|
|
|
queue.replace_queue(new_items.clone(), None)?;
|
|
|
|
|
info!(
|
|
|
|
|
renderer = renderer_id.0.as_str(),
|
|
|
|
|
server = server_id.0.as_str(),
|
|
|
|
|
container = container_id.as_str(),
|
|
|
|
|
total_items = new_items.len(),
|
|
|
|
|
current_preserved = false,
|
|
|
|
|
is_playing = false,
|
|
|
|
|
"Gentle refresh: replaced queue (not playing)"
|
|
|
|
|
);
|
|
|
|
|
Ok(new_items.len())
|
|
|
|
|
}
|
2025-12-15 11:18:58 +01:00
|
|
|
} else {
|
2025-12-17 21:41:24 +01:00
|
|
|
// No current item, use new playlist as-is
|
2025-12-15 11:18:58 +01:00
|
|
|
queue.replace_queue(new_items.clone(), None)?;
|
|
|
|
|
info!(
|
|
|
|
|
renderer = renderer_id.0.as_str(),
|
|
|
|
|
server = server_id.0.as_str(),
|
|
|
|
|
container = container_id.as_str(),
|
|
|
|
|
total_items = new_items.len(),
|
|
|
|
|
current_preserved = false,
|
2025-12-17 21:41:24 +01:00
|
|
|
is_playing,
|
|
|
|
|
"Gentle refresh: no current item"
|
2025-12-15 11:18:58 +01:00
|
|
|
);
|
|
|
|
|
Ok(new_items.len())
|
|
|
|
|
}
|
|
|
|
|
})?;
|
2025-12-17 21:41:24 +01:00
|
|
|
runtime.invalidate_openhome_cache(renderer_id);
|
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() {
|
|
|
|
|
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-02 14:41:53 +01:00
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Helper to convert a MediaEntry to a PlaybackItem.
|
|
|
|
|
fn playback_item_from_entry(server: &MusicServer, entry: &MediaEntry) -> Option<PlaybackItem> {
|
|
|
|
|
// Ignore containers
|
|
|
|
|
if entry.is_container {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Skip "live stream" entries (heuristic from example)
|
|
|
|
|
if entry.title.to_ascii_lowercase().contains("live stream") {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Find an audio resource
|
2025-12-06 13:37:42 +01:00
|
|
|
let resource = entry.resources.iter().find(|res| res.is_audio())?;
|
|
|
|
|
|
|
|
|
|
let metadata = TrackMetadata {
|
|
|
|
|
title: Some(entry.title.clone()),
|
|
|
|
|
artist: entry.artist.clone(),
|
|
|
|
|
album: entry.album.clone(),
|
|
|
|
|
genre: entry.genre.clone(),
|
|
|
|
|
album_art_uri: entry.album_art_uri.clone(),
|
|
|
|
|
date: entry.date.clone(),
|
|
|
|
|
track_number: entry.track_number.clone(),
|
|
|
|
|
creator: entry.creator.clone(),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
Some(PlaybackItem {
|
|
|
|
|
media_server_id: server.id().clone(),
|
|
|
|
|
didl_id: entry.id.clone(),
|
|
|
|
|
uri: resource.uri.clone(),
|
2025-12-17 20:45:51 +01:00
|
|
|
protocol_info: resource.protocol_info.clone(),
|
2025-12-06 13:37:42 +01:00
|
|
|
metadata: Some(metadata),
|
|
|
|
|
})
|
2025-12-02 14:41:53 +01:00
|
|
|
}
|
|
|
|
|
|
2025-12-05 19:01:33 +01:00
|
|
|
const OPENHOME_TRACK_PREFIX: &str = "openhome:";
|
|
|
|
|
|
2025-12-06 13:37:42 +01:00
|
|
|
fn playback_item_from_openhome_track(
|
|
|
|
|
renderer_id: &RendererId,
|
|
|
|
|
track: &OpenHomePlaylistTrack,
|
|
|
|
|
) -> PlaybackItem {
|
|
|
|
|
let metadata = TrackMetadata {
|
|
|
|
|
title: track.title.clone(),
|
|
|
|
|
artist: track.artist.clone(),
|
|
|
|
|
album: track.album.clone(),
|
|
|
|
|
genre: None,
|
|
|
|
|
album_art_uri: track.album_art_uri.clone(),
|
|
|
|
|
date: None,
|
|
|
|
|
track_number: None,
|
|
|
|
|
creator: None,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
PlaybackItem {
|
|
|
|
|
media_server_id: ServerId(format!("openhome:{}", renderer_id.0)),
|
|
|
|
|
didl_id: format!("{}{}", OPENHOME_TRACK_PREFIX, track.id),
|
|
|
|
|
uri: track.uri.clone(),
|
2025-12-17 20:45:51 +01:00
|
|
|
// OpenHome tracks don't provide protocolInfo, use generic default
|
|
|
|
|
protocol_info: "http-get:*:audio/*:*".to_string(),
|
2025-12-06 13:37:42 +01:00
|
|
|
metadata: Some(metadata),
|
|
|
|
|
}
|
2025-12-05 19:01:33 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn openhome_track_id_from_item(item: &PlaybackItem) -> Option<u32> {
|
2025-12-06 13:37:42 +01:00
|
|
|
let raw = item.didl_id.strip_prefix(OPENHOME_TRACK_PREFIX)?;
|
2025-12-05 19:01:33 +01:00
|
|
|
raw.parse::<u32>().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,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-17 21:41:24 +01:00
|
|
|
fn openhome_renderer_from_registry(
|
|
|
|
|
registry: &Arc<RwLock<DeviceRegistry>>,
|
|
|
|
|
renderer_id: &RendererId,
|
|
|
|
|
) -> anyhow::Result<MusicRenderer> {
|
|
|
|
|
let info = {
|
|
|
|
|
let reg = registry.read().unwrap();
|
|
|
|
|
reg.get_renderer(renderer_id)
|
|
|
|
|
.ok_or_else(|| OpenHomeAccessError::RendererNotFound(renderer_id.0.clone()))?
|
|
|
|
|
};
|
|
|
|
|
let renderer = MusicRenderer::from_registry_info(info, registry)
|
|
|
|
|
.and_then(|r| match r {
|
|
|
|
|
MusicRenderer::OpenHome(_) => Some(r),
|
|
|
|
|
_ => None,
|
|
|
|
|
})
|
|
|
|
|
.ok_or_else(|| OpenHomeAccessError::PlaylistNotSupported(renderer_id.0.clone()))?;
|
|
|
|
|
Ok(renderer)
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-05 19:01:33 +01:00
|
|
|
fn sync_openhome_playlist(
|
|
|
|
|
registry: &Arc<RwLock<DeviceRegistry>>,
|
|
|
|
|
runtime: &Arc<RuntimeState>,
|
|
|
|
|
event_bus: &RendererEventBus,
|
|
|
|
|
renderer_id: &RendererId,
|
|
|
|
|
) -> anyhow::Result<()> {
|
2025-12-17 21:41:24 +01:00
|
|
|
let renderer = openhome_renderer_from_registry(registry, renderer_id)?;
|
2025-12-05 19:01:33 +01:00
|
|
|
|
2025-12-06 13:37:42 +01:00
|
|
|
let snapshot = renderer.fetch_openhome_playlist_snapshot()?;
|
2025-12-05 21:59:43 +01:00
|
|
|
let playback_items: Vec<PlaybackItem> = snapshot
|
|
|
|
|
.tracks
|
2025-12-05 19:01:33 +01:00
|
|
|
.iter()
|
2025-12-06 13:37:42 +01:00
|
|
|
.map(|track| playback_item_from_openhome_track(renderer_id, track))
|
2025-12-05 19:01:33 +01:00
|
|
|
.collect();
|
|
|
|
|
|
2025-12-17 21:41:24 +01:00
|
|
|
debug!(
|
|
|
|
|
renderer = renderer_id.0.as_str(),
|
|
|
|
|
fetched_items = playback_items.len(),
|
|
|
|
|
current_id = ?snapshot.current_id,
|
|
|
|
|
"sync_openhome_playlist: fetched snapshot from renderer"
|
|
|
|
|
);
|
|
|
|
|
|
2025-12-05 21:59:43 +01:00
|
|
|
let current_id = snapshot.current_id;
|
2025-12-05 19:01:33 +01:00
|
|
|
|
|
|
|
|
let current_index = current_id.and_then(|id| {
|
|
|
|
|
playback_items
|
|
|
|
|
.iter()
|
|
|
|
|
.position(|item| openhome_track_id_from_item(item) == Some(id))
|
|
|
|
|
});
|
|
|
|
|
|
2025-12-06 13:37:42 +01:00
|
|
|
let queue_items = playback_items;
|
2025-12-05 19:01:33 +01:00
|
|
|
let queue_len = runtime
|
2025-12-06 13:37:42 +01:00
|
|
|
.with_music_queue_mut(renderer_id, move |queue| {
|
|
|
|
|
let len = queue_items.len();
|
|
|
|
|
queue.replace_queue(queue_items, current_index)?;
|
|
|
|
|
Ok(len)
|
2025-12-05 19:01:33 +01:00
|
|
|
})
|
|
|
|
|
.unwrap_or(0);
|
|
|
|
|
|
2025-12-17 21:41:24 +01:00
|
|
|
debug!(
|
|
|
|
|
renderer = renderer_id.0.as_str(),
|
|
|
|
|
queue_len,
|
|
|
|
|
"sync_openhome_playlist: updated local queue"
|
|
|
|
|
);
|
|
|
|
|
|
2025-12-05 19:01:33 +01:00
|
|
|
event_bus.broadcast(RendererEvent::QueueUpdated {
|
|
|
|
|
id: renderer_id.clone(),
|
|
|
|
|
queue_length: queue_len,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const OPENHOME_SUBSCRIPTION_TIMEOUT_SECS: u64 = 300;
|
|
|
|
|
const OPENHOME_RENEWAL_MARGIN_SECS: u64 = 60;
|
|
|
|
|
|
|
|
|
|
fn spawn_openhome_event_runtime(
|
|
|
|
|
registry: Arc<RwLock<DeviceRegistry>>,
|
|
|
|
|
runtime: Arc<RuntimeState>,
|
|
|
|
|
event_bus: RendererEventBus,
|
|
|
|
|
event_tx: Sender<RendererEvent>,
|
2025-12-22 10:26:34 +01:00
|
|
|
playlist_bindings: Arc<Mutex<HashMap<RendererId, PlaylistBinding>>>,
|
2025-12-05 19:01:33 +01:00
|
|
|
) -> io::Result<()> {
|
|
|
|
|
let listener = TcpListener::bind("0.0.0.0:0")?;
|
|
|
|
|
let listener_addr = listener
|
|
|
|
|
.local_addr()
|
|
|
|
|
.map_err(|err| io::Error::new(io::ErrorKind::Other, err))?;
|
|
|
|
|
|
|
|
|
|
info!("OpenHome event listener bound on {}", listener_addr);
|
|
|
|
|
|
|
|
|
|
let (notify_tx, notify_rx) = unbounded::<OpenHomeIncomingNotify>();
|
|
|
|
|
thread::Builder::new()
|
|
|
|
|
.name("openhome-event-http".into())
|
|
|
|
|
.spawn(move || run_openhome_http_listener(listener, notify_tx))?;
|
|
|
|
|
|
|
|
|
|
let worker = OpenHomeEventRuntime::new(
|
|
|
|
|
registry,
|
|
|
|
|
runtime,
|
|
|
|
|
event_bus,
|
|
|
|
|
notify_rx,
|
|
|
|
|
event_tx,
|
|
|
|
|
listener_addr.port(),
|
2025-12-22 10:26:34 +01:00
|
|
|
playlist_bindings,
|
2025-12-05 19:01:33 +01:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
thread::Builder::new()
|
|
|
|
|
.name("openhome-event-worker".into())
|
|
|
|
|
.spawn(move || worker.run())
|
|
|
|
|
.map(|_| ())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct OpenHomeEventRuntime {
|
|
|
|
|
registry: Arc<RwLock<DeviceRegistry>>,
|
|
|
|
|
runtime: Arc<RuntimeState>,
|
|
|
|
|
event_bus: RendererEventBus,
|
|
|
|
|
notify_rx: Receiver<OpenHomeIncomingNotify>,
|
|
|
|
|
event_tx: Sender<RendererEvent>,
|
|
|
|
|
listener_port: u16,
|
|
|
|
|
http_timeout: Duration,
|
|
|
|
|
subscriptions: HashMap<OpenHomeSubscriptionKey, OpenHomeSubscriptionState>,
|
|
|
|
|
path_index: HashMap<String, OpenHomeSubscriptionKey>,
|
2025-12-22 10:26:34 +01:00
|
|
|
playlist_bindings: Arc<Mutex<HashMap<RendererId, PlaylistBinding>>>,
|
2025-12-05 19:01:33 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl OpenHomeEventRuntime {
|
|
|
|
|
fn new(
|
|
|
|
|
registry: Arc<RwLock<DeviceRegistry>>,
|
|
|
|
|
runtime: Arc<RuntimeState>,
|
|
|
|
|
event_bus: RendererEventBus,
|
|
|
|
|
notify_rx: Receiver<OpenHomeIncomingNotify>,
|
|
|
|
|
event_tx: Sender<RendererEvent>,
|
|
|
|
|
listener_port: u16,
|
2025-12-22 10:26:34 +01:00
|
|
|
playlist_bindings: Arc<Mutex<HashMap<RendererId, PlaylistBinding>>>,
|
2025-12-05 19:01:33 +01:00
|
|
|
) -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
registry,
|
|
|
|
|
runtime,
|
|
|
|
|
event_bus,
|
|
|
|
|
notify_rx,
|
|
|
|
|
event_tx,
|
|
|
|
|
listener_port,
|
|
|
|
|
http_timeout: Duration::from_secs(5),
|
|
|
|
|
subscriptions: HashMap::new(),
|
|
|
|
|
path_index: HashMap::new(),
|
2025-12-22 10:26:34 +01:00
|
|
|
playlist_bindings,
|
2025-12-05 19:01:33 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn run(mut self) {
|
|
|
|
|
loop {
|
|
|
|
|
self.drain_notifications();
|
|
|
|
|
self.refresh_renderers();
|
|
|
|
|
self.renew_expiring();
|
|
|
|
|
thread::sleep(Duration::from_millis(250));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn drain_notifications(&mut self) {
|
|
|
|
|
while let Ok(notify) = self.notify_rx.try_recv() {
|
|
|
|
|
self.handle_notification(notify);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn refresh_renderers(&mut self) {
|
|
|
|
|
let renderer_infos = {
|
|
|
|
|
let reg = self.registry.read().unwrap();
|
|
|
|
|
reg.list_renderers()
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let mut active: HashSet<OpenHomeSubscriptionKey> = HashSet::new();
|
|
|
|
|
|
|
|
|
|
for info in renderer_infos {
|
|
|
|
|
if !info.online {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if let Some(url) = info.oh_playlist_event_sub_url.clone() {
|
2025-12-17 21:41:24 +01:00
|
|
|
let key = OpenHomeSubscriptionKey::new(&info.id, OhServiceKind::Playlist);
|
2025-12-05 19:01:33 +01:00
|
|
|
active.insert(key.clone());
|
|
|
|
|
self.ensure_subscription(key, info.clone(), url);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if let Some(url) = info.oh_info_event_sub_url.clone() {
|
2025-12-17 21:41:24 +01:00
|
|
|
let key = OpenHomeSubscriptionKey::new(&info.id, OhServiceKind::Info);
|
2025-12-05 19:01:33 +01:00
|
|
|
active.insert(key.clone());
|
|
|
|
|
self.ensure_subscription(key, info.clone(), url);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if let Some(url) = info.oh_time_event_sub_url.clone() {
|
2025-12-17 21:41:24 +01:00
|
|
|
let key = OpenHomeSubscriptionKey::new(&info.id, OhServiceKind::Time);
|
2025-12-05 19:01:33 +01:00
|
|
|
active.insert(key.clone());
|
|
|
|
|
self.ensure_subscription(key, info.clone(), url);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let stale: Vec<OpenHomeSubscriptionKey> = self
|
|
|
|
|
.subscriptions
|
|
|
|
|
.keys()
|
|
|
|
|
.filter(|key| !active.contains(*key))
|
|
|
|
|
.cloned()
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
for key in stale {
|
|
|
|
|
if let Some(mut entry) = self.subscriptions.remove(&key) {
|
|
|
|
|
self.path_index.remove(&entry.callback_path);
|
|
|
|
|
if let Err(err) = Self::unsubscribe_entry(self.http_timeout, &mut entry) {
|
|
|
|
|
warn!(
|
|
|
|
|
renderer = entry.renderer.friendly_name.as_str(),
|
|
|
|
|
service = entry.service.as_str(),
|
|
|
|
|
error = %err,
|
|
|
|
|
"Failed to unsubscribe from OpenHome events"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn ensure_subscription(
|
|
|
|
|
&mut self,
|
|
|
|
|
key: OpenHomeSubscriptionKey,
|
|
|
|
|
info: RendererInfo,
|
|
|
|
|
event_url: String,
|
|
|
|
|
) {
|
2025-12-05 21:59:43 +01:00
|
|
|
let entry = self.subscriptions.entry(key.clone()).or_insert_with(|| {
|
|
|
|
|
OpenHomeSubscriptionState::new(info.clone(), key.service, event_url.clone())
|
|
|
|
|
});
|
2025-12-05 19:01:33 +01:00
|
|
|
|
|
|
|
|
entry.update(info, event_url);
|
|
|
|
|
self.path_index
|
|
|
|
|
.insert(entry.callback_path.clone(), key.clone());
|
|
|
|
|
|
|
|
|
|
if entry.sid.is_none() && entry.should_retry() {
|
2025-12-05 21:59:43 +01:00
|
|
|
if let Err(err) = Self::subscribe_entry(self.listener_port, self.http_timeout, entry) {
|
2025-12-05 19:01:33 +01:00
|
|
|
warn!(
|
|
|
|
|
renderer = entry.renderer.friendly_name.as_str(),
|
|
|
|
|
service = entry.service.as_str(),
|
|
|
|
|
error = %err,
|
|
|
|
|
"OpenHome SUBSCRIBE failed"
|
|
|
|
|
);
|
|
|
|
|
entry.defer_retry();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn renew_expiring(&mut self) {
|
|
|
|
|
let now = Instant::now();
|
|
|
|
|
let mut to_renew = Vec::new();
|
|
|
|
|
for (key, entry) in self.subscriptions.iter() {
|
|
|
|
|
if let Some(exp) = entry.expires_at {
|
|
|
|
|
if exp <= now + Duration::from_secs(OPENHOME_RENEWAL_MARGIN_SECS) {
|
|
|
|
|
to_renew.push(key.clone());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for key in to_renew {
|
|
|
|
|
if let Some(entry) = self.subscriptions.get_mut(&key) {
|
|
|
|
|
if let Err(err) = Self::renew_entry(self.http_timeout, entry) {
|
|
|
|
|
warn!(
|
|
|
|
|
renderer = entry.renderer.friendly_name.as_str(),
|
|
|
|
|
service = entry.service.as_str(),
|
|
|
|
|
error = %err,
|
|
|
|
|
"Failed to renew OpenHome subscription"
|
|
|
|
|
);
|
|
|
|
|
entry.reset_subscription();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn handle_notification(&mut self, notify: OpenHomeIncomingNotify) {
|
|
|
|
|
let Some(key) = self.path_index.get(¬ify.path).cloned() else {
|
|
|
|
|
debug!("Dropping OpenHome notify for unknown path {}", notify.path);
|
|
|
|
|
return;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let Some(entry) = self.subscriptions.get(&key) else {
|
|
|
|
|
return;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if let (Some(expected), Some(received)) = (&entry.sid, ¬ify.sid) {
|
|
|
|
|
if !expected.eq_ignore_ascii_case(received) {
|
|
|
|
|
debug!(
|
|
|
|
|
renderer = entry.renderer.friendly_name.as_str(),
|
|
|
|
|
service = entry.service.as_str(),
|
|
|
|
|
expected_sid = expected.as_str(),
|
|
|
|
|
received_sid = received.as_str(),
|
|
|
|
|
"Ignoring OpenHome notify with mismatched SID"
|
|
|
|
|
);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-05 21:59:43 +01:00
|
|
|
let properties =
|
|
|
|
|
parse_openhome_propertyset(&entry.renderer.id, &entry.service, ¬ify.body);
|
2025-12-05 19:01:33 +01:00
|
|
|
if properties.is_empty() {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
match entry.service {
|
2025-12-17 21:41:24 +01:00
|
|
|
OhServiceKind::Playlist => {
|
2025-12-05 21:59:43 +01:00
|
|
|
if properties
|
|
|
|
|
.iter()
|
|
|
|
|
.any(|(name, _)| is_id_array_property(name))
|
|
|
|
|
{
|
|
|
|
|
if self.runtime.uses_openhome_playlist(&entry.renderer.id) {
|
2025-12-22 10:26:34 +01:00
|
|
|
// Check if this renderer has an active playlist binding
|
|
|
|
|
// If it does, skip sync because refresh_attached_queue_for() handles it
|
|
|
|
|
let has_active_binding = {
|
|
|
|
|
let bindings = self.playlist_bindings.lock().unwrap();
|
|
|
|
|
bindings.contains_key(&entry.renderer.id)
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if !has_active_binding {
|
|
|
|
|
// Synchronize the local queue with the renderer's playlist state
|
|
|
|
|
// This ensures our local mirror stays in sync when the renderer
|
|
|
|
|
// playlist changes from other control points or manual edits
|
|
|
|
|
if let Err(err) = sync_openhome_playlist(
|
|
|
|
|
&self.registry,
|
|
|
|
|
&self.runtime,
|
|
|
|
|
&self.event_bus,
|
|
|
|
|
&entry.renderer.id,
|
|
|
|
|
) {
|
2025-12-17 21:41:24 +01:00
|
|
|
warn!(
|
|
|
|
|
renderer = entry.renderer.friendly_name.as_str(),
|
|
|
|
|
error = %err,
|
2025-12-22 10:26:34 +01:00
|
|
|
"Failed to sync OpenHome playlist after IdArray event"
|
2025-12-17 21:41:24 +01:00
|
|
|
);
|
|
|
|
|
}
|
2025-12-05 19:01:33 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-12-17 21:41:24 +01:00
|
|
|
OhServiceKind::Info => {
|
2025-12-05 19:01:33 +01:00
|
|
|
self.handle_info_properties(&entry.renderer.id, properties);
|
|
|
|
|
}
|
2025-12-17 21:41:24 +01:00
|
|
|
OhServiceKind::Time => {
|
2025-12-05 19:01:33 +01:00
|
|
|
self.handle_time_properties(&entry.renderer.id, properties);
|
|
|
|
|
}
|
2025-12-17 21:41:24 +01:00
|
|
|
OhServiceKind::Volume | OhServiceKind::Product => {}
|
2025-12-05 19:01:33 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-05 21:59:43 +01:00
|
|
|
fn handle_info_properties(&self, renderer_id: &RendererId, properties: Vec<(String, String)>) {
|
2025-12-05 19:01:33 +01:00
|
|
|
let mut metadata_xml: Option<String> = None;
|
|
|
|
|
let mut transport_state: Option<String> = None;
|
|
|
|
|
let mut track_id: Option<u32> = None;
|
|
|
|
|
let mut track_uri: Option<String> = None;
|
|
|
|
|
|
|
|
|
|
for (name, value) in properties {
|
|
|
|
|
match name.as_str() {
|
|
|
|
|
"Metadata" | "TrackMetadata" => {
|
|
|
|
|
if !value.trim().is_empty() {
|
|
|
|
|
metadata_xml = Some(value);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
"TransportState" => {
|
|
|
|
|
transport_state = Some(value);
|
|
|
|
|
}
|
|
|
|
|
"Id" | "TrackId" => {
|
|
|
|
|
if let Ok(id) = value.trim().parse::<u32>() {
|
|
|
|
|
track_id = Some(id);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
"Uri" | "TrackUri" => {
|
|
|
|
|
if !value.trim().is_empty() {
|
|
|
|
|
track_uri = Some(value);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if let Some(xml) = metadata_xml {
|
|
|
|
|
if let Some(metadata) = parse_track_metadata_from_didl(&xml) {
|
|
|
|
|
self.runtime.update_snapshot_with(renderer_id, |snapshot| {
|
|
|
|
|
snapshot.last_metadata = Some(metadata.clone());
|
|
|
|
|
let mut position = snapshot
|
|
|
|
|
.position
|
|
|
|
|
.clone()
|
|
|
|
|
.unwrap_or_else(|| empty_playback_position());
|
|
|
|
|
position.track_metadata = Some(xml.clone());
|
|
|
|
|
snapshot.position = Some(position);
|
|
|
|
|
});
|
|
|
|
|
let _ = self.event_tx.send(RendererEvent::MetadataChanged {
|
|
|
|
|
id: renderer_id.clone(),
|
|
|
|
|
metadata,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if track_id.is_some() || track_uri.is_some() {
|
|
|
|
|
self.runtime.update_snapshot_with(renderer_id, |snapshot| {
|
|
|
|
|
let mut position = snapshot
|
|
|
|
|
.position
|
|
|
|
|
.clone()
|
|
|
|
|
.unwrap_or_else(|| empty_playback_position());
|
|
|
|
|
if let Some(id) = track_id {
|
|
|
|
|
position.track = Some(id);
|
|
|
|
|
}
|
|
|
|
|
if let Some(uri) = track_uri.clone() {
|
|
|
|
|
position.track_uri = Some(uri);
|
|
|
|
|
}
|
|
|
|
|
snapshot.position = Some(position);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if let Some(state_str) = transport_state {
|
|
|
|
|
let playback_state = map_openhome_state(&state_str);
|
|
|
|
|
let _ = self.event_tx.send(RendererEvent::StateChanged {
|
|
|
|
|
id: renderer_id.clone(),
|
|
|
|
|
state: playback_state,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-05 21:59:43 +01:00
|
|
|
fn handle_time_properties(&self, renderer_id: &RendererId, properties: Vec<(String, String)>) {
|
2025-12-05 19:01:33 +01:00
|
|
|
let mut duration: Option<u32> = None;
|
|
|
|
|
let mut seconds: Option<u32> = None;
|
|
|
|
|
|
|
|
|
|
for (name, value) in properties {
|
|
|
|
|
match name.as_str() {
|
|
|
|
|
"Duration" => {
|
|
|
|
|
duration = value.trim().parse::<u32>().ok();
|
|
|
|
|
}
|
|
|
|
|
"Seconds" => {
|
|
|
|
|
seconds = value.trim().parse::<u32>().ok();
|
|
|
|
|
}
|
|
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if duration.is_none() && seconds.is_none() {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let position = self.runtime.snapshot_for(renderer_id).position;
|
|
|
|
|
let mut new_position = position.unwrap_or_else(|| empty_playback_position());
|
|
|
|
|
if let Some(d) = duration {
|
|
|
|
|
new_position.track_duration = Some(format_seconds(d));
|
|
|
|
|
}
|
|
|
|
|
if let Some(s) = seconds {
|
|
|
|
|
new_position.rel_time = Some(format_seconds(s));
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-05 21:59:43 +01:00
|
|
|
self.runtime.update_snapshot_with(renderer_id, |snapshot| {
|
|
|
|
|
snapshot.position = Some(new_position.clone());
|
|
|
|
|
});
|
2025-12-05 19:01:33 +01:00
|
|
|
|
|
|
|
|
let _ = self.event_tx.send(RendererEvent::PositionChanged {
|
|
|
|
|
id: renderer_id.clone(),
|
|
|
|
|
position: new_position,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn subscribe_entry(
|
|
|
|
|
listener_port: u16,
|
|
|
|
|
http_timeout: Duration,
|
|
|
|
|
entry: &mut OpenHomeSubscriptionState,
|
|
|
|
|
) -> anyhow::Result<()> {
|
|
|
|
|
let event_url = entry.event_sub_url.clone();
|
|
|
|
|
let (remote_host, remote_port) =
|
|
|
|
|
parse_host_port(&event_url).context("Cannot extract host for SUBSCRIBE")?;
|
|
|
|
|
let local_ip = determine_local_ip(&remote_host, remote_port)
|
|
|
|
|
.context("Cannot determine local IP for callback")?;
|
|
|
|
|
|
|
|
|
|
let callback_url = format!(
|
|
|
|
|
"http://{}:{}{}",
|
|
|
|
|
format_ip(&local_ip),
|
|
|
|
|
listener_port,
|
|
|
|
|
entry.callback_path
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
debug!(
|
|
|
|
|
renderer = entry.renderer.friendly_name.as_str(),
|
|
|
|
|
service = entry.service.as_str(),
|
|
|
|
|
callback = callback_url.as_str(),
|
|
|
|
|
"Subscribing to OpenHome events"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let host_header = format!("{}:{}", remote_host, remote_port);
|
|
|
|
|
let timeout_header = format!("Second-{}", OPENHOME_SUBSCRIPTION_TIMEOUT_SECS);
|
|
|
|
|
let callback_header = format!("<{}>", callback_url);
|
|
|
|
|
|
|
|
|
|
let request = http::Request::builder()
|
|
|
|
|
.method("SUBSCRIBE")
|
|
|
|
|
.uri(&event_url)
|
|
|
|
|
.header("HOST", host_header)
|
|
|
|
|
.header("CALLBACK", callback_header)
|
|
|
|
|
.header("NT", "upnp:event")
|
|
|
|
|
.header("TIMEOUT", timeout_header)
|
|
|
|
|
.body(())
|
|
|
|
|
.map_err(anyhow::Error::new)?;
|
|
|
|
|
|
|
|
|
|
let response = build_agent(http_timeout).run(request)?;
|
|
|
|
|
if !response.status().is_success() {
|
2025-12-05 21:59:43 +01:00
|
|
|
anyhow::bail!("SUBSCRIBE returned HTTP {}", response.status());
|
2025-12-05 19:01:33 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let sid = response
|
|
|
|
|
.headers()
|
|
|
|
|
.get("SID")
|
|
|
|
|
.and_then(|value| value.to_str().ok())
|
|
|
|
|
.map(|s| s.to_string())
|
|
|
|
|
.ok_or_else(|| anyhow::anyhow!("SUBSCRIBE response missing SID"))?;
|
|
|
|
|
let timeout = parse_timeout(
|
|
|
|
|
response
|
|
|
|
|
.headers()
|
|
|
|
|
.get("TIMEOUT")
|
|
|
|
|
.and_then(|value| value.to_str().ok()),
|
|
|
|
|
)
|
|
|
|
|
.unwrap_or(Duration::from_secs(OPENHOME_SUBSCRIPTION_TIMEOUT_SECS));
|
|
|
|
|
|
|
|
|
|
entry.sid = Some(sid);
|
|
|
|
|
entry.expires_at = Some(Instant::now() + timeout);
|
|
|
|
|
entry.retry_after = Instant::now() + Duration::from_secs(5);
|
|
|
|
|
|
|
|
|
|
info!(
|
|
|
|
|
renderer = entry.renderer.friendly_name.as_str(),
|
|
|
|
|
service = entry.service.as_str(),
|
|
|
|
|
"Subscribed to OpenHome events (timeout {}s)",
|
|
|
|
|
timeout.as_secs()
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn renew_entry(
|
|
|
|
|
http_timeout: Duration,
|
|
|
|
|
entry: &mut OpenHomeSubscriptionState,
|
|
|
|
|
) -> anyhow::Result<()> {
|
|
|
|
|
let sid = entry.sid.clone().context("Cannot renew without SID")?;
|
|
|
|
|
let request = http::Request::builder()
|
|
|
|
|
.method("SUBSCRIBE")
|
|
|
|
|
.uri(&entry.event_sub_url)
|
|
|
|
|
.header("SID", sid)
|
|
|
|
|
.header(
|
|
|
|
|
"TIMEOUT",
|
|
|
|
|
format!("Second-{}", OPENHOME_SUBSCRIPTION_TIMEOUT_SECS),
|
|
|
|
|
)
|
|
|
|
|
.body(())
|
|
|
|
|
.map_err(anyhow::Error::new)?;
|
|
|
|
|
let response = build_agent(http_timeout).run(request)?;
|
|
|
|
|
if !response.status().is_success() {
|
2025-12-05 21:59:43 +01:00
|
|
|
anyhow::bail!("SUBSCRIBE renewal failed with {}", response.status());
|
2025-12-05 19:01:33 +01:00
|
|
|
}
|
|
|
|
|
let timeout = parse_timeout(
|
|
|
|
|
response
|
|
|
|
|
.headers()
|
|
|
|
|
.get("TIMEOUT")
|
|
|
|
|
.and_then(|value| value.to_str().ok()),
|
|
|
|
|
)
|
|
|
|
|
.unwrap_or(Duration::from_secs(OPENHOME_SUBSCRIPTION_TIMEOUT_SECS));
|
|
|
|
|
entry.expires_at = Some(Instant::now() + timeout);
|
|
|
|
|
info!(
|
|
|
|
|
renderer = entry.renderer.friendly_name.as_str(),
|
|
|
|
|
service = entry.service.as_str(),
|
|
|
|
|
"Renewed OpenHome subscription (timeout {}s)",
|
|
|
|
|
timeout.as_secs()
|
|
|
|
|
);
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn unsubscribe_entry(
|
|
|
|
|
http_timeout: Duration,
|
|
|
|
|
entry: &mut OpenHomeSubscriptionState,
|
|
|
|
|
) -> anyhow::Result<()> {
|
|
|
|
|
let sid = match entry.sid.take() {
|
|
|
|
|
Some(sid) => sid,
|
|
|
|
|
None => return Ok(()),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let request = http::Request::builder()
|
|
|
|
|
.method("UNSUBSCRIBE")
|
|
|
|
|
.uri(&entry.event_sub_url)
|
|
|
|
|
.header("SID", sid)
|
|
|
|
|
.body(())
|
|
|
|
|
.map_err(anyhow::Error::new)?;
|
|
|
|
|
let response = build_agent(http_timeout).run(request)?;
|
|
|
|
|
if !response.status().is_success() {
|
|
|
|
|
warn!(
|
|
|
|
|
renderer = entry.renderer.friendly_name.as_str(),
|
|
|
|
|
service = entry.service.as_str(),
|
|
|
|
|
status = response.status().as_u16(),
|
|
|
|
|
"UNSUBSCRIBE returned non-success status"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
|
|
|
|
struct OpenHomeSubscriptionKey {
|
|
|
|
|
renderer_id: RendererId,
|
2025-12-17 21:41:24 +01:00
|
|
|
service: OhServiceKind,
|
2025-12-05 19:01:33 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl OpenHomeSubscriptionKey {
|
2025-12-17 21:41:24 +01:00
|
|
|
fn new(renderer_id: &RendererId, service: OhServiceKind) -> Self {
|
2025-12-05 19:01:33 +01:00
|
|
|
Self {
|
|
|
|
|
renderer_id: renderer_id.clone(),
|
|
|
|
|
service,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct OpenHomeSubscriptionState {
|
|
|
|
|
renderer: RendererInfo,
|
2025-12-17 21:41:24 +01:00
|
|
|
service: OhServiceKind,
|
2025-12-05 19:01:33 +01:00
|
|
|
event_sub_url: String,
|
|
|
|
|
callback_path: String,
|
|
|
|
|
sid: Option<String>,
|
|
|
|
|
expires_at: Option<Instant>,
|
|
|
|
|
retry_after: Instant,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl OpenHomeSubscriptionState {
|
2025-12-17 21:41:24 +01:00
|
|
|
fn new(renderer: RendererInfo, service: OhServiceKind, event_sub_url: String) -> Self {
|
2025-12-05 19:01:33 +01:00
|
|
|
Self {
|
|
|
|
|
callback_path: build_openhome_callback_path(&renderer.id, service),
|
|
|
|
|
renderer,
|
|
|
|
|
service,
|
|
|
|
|
event_sub_url,
|
|
|
|
|
sid: None,
|
|
|
|
|
expires_at: None,
|
|
|
|
|
retry_after: Instant::now(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn update(&mut self, renderer: RendererInfo, event_url: String) {
|
2025-12-05 21:59:43 +01:00
|
|
|
if self.renderer.location != renderer.location || self.event_sub_url != event_url {
|
2025-12-05 19:01:33 +01:00
|
|
|
self.event_sub_url = event_url;
|
|
|
|
|
self.sid = None;
|
|
|
|
|
self.expires_at = None;
|
|
|
|
|
self.retry_after = Instant::now();
|
|
|
|
|
}
|
|
|
|
|
self.renderer = renderer;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn should_retry(&self) -> bool {
|
|
|
|
|
Instant::now() >= self.retry_after
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn defer_retry(&mut self) {
|
|
|
|
|
self.retry_after = Instant::now() + Duration::from_secs(15);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn reset_subscription(&mut self) {
|
|
|
|
|
self.sid = None;
|
|
|
|
|
self.expires_at = None;
|
|
|
|
|
self.retry_after = Instant::now() + Duration::from_secs(5);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct OpenHomeIncomingNotify {
|
|
|
|
|
path: String,
|
|
|
|
|
sid: Option<String>,
|
|
|
|
|
body: Vec<u8>,
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-05 21:59:43 +01:00
|
|
|
fn run_openhome_http_listener(listener: TcpListener, notify_tx: Sender<OpenHomeIncomingNotify>) {
|
2025-12-05 19:01:33 +01:00
|
|
|
for stream in listener.incoming() {
|
|
|
|
|
match stream {
|
|
|
|
|
Ok(mut stream) => {
|
|
|
|
|
if let Err(err) = stream.set_read_timeout(Some(Duration::from_secs(5))) {
|
2025-12-05 21:59:43 +01:00
|
|
|
warn!(
|
|
|
|
|
"Failed to set read timeout on OpenHome notify connection: {}",
|
|
|
|
|
err
|
|
|
|
|
);
|
2025-12-05 19:01:33 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
match read_openhome_http_request(&mut stream) {
|
|
|
|
|
Ok(request) => {
|
|
|
|
|
if request.method != "NOTIFY" {
|
|
|
|
|
let _ = write_openhome_http_response(
|
|
|
|
|
&mut stream,
|
|
|
|
|
405,
|
|
|
|
|
"Method Not Allowed",
|
|
|
|
|
);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let notify = OpenHomeIncomingNotify {
|
|
|
|
|
path: request.path,
|
|
|
|
|
sid: request.headers.get("sid").cloned(),
|
|
|
|
|
body: request.body,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if notify_tx.send(notify).is_err() {
|
|
|
|
|
warn!("Dropping OpenHome notify because worker channel is closed");
|
|
|
|
|
}
|
|
|
|
|
let _ = write_openhome_http_response(&mut stream, 200, "OK");
|
|
|
|
|
}
|
|
|
|
|
Err(err) => {
|
|
|
|
|
warn!("Failed to parse OpenHome notify request: {}", err);
|
|
|
|
|
let _ = write_openhome_http_response(&mut stream, 400, "Bad Request");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Err(err) => {
|
|
|
|
|
warn!("Incoming OpenHome notify connection failed: {}", err);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct OpenHomeHttpRequest {
|
|
|
|
|
method: String,
|
|
|
|
|
path: String,
|
|
|
|
|
headers: HashMap<String, String>,
|
|
|
|
|
body: Vec<u8>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn read_openhome_http_request(stream: &mut TcpStream) -> io::Result<OpenHomeHttpRequest> {
|
|
|
|
|
let mut reader = BufReader::new(stream.try_clone()?);
|
|
|
|
|
let mut request_line = String::new();
|
|
|
|
|
if reader.read_line(&mut request_line)? == 0 {
|
|
|
|
|
return Err(io::Error::new(
|
|
|
|
|
io::ErrorKind::UnexpectedEof,
|
|
|
|
|
"missing request line",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let request_line = request_line.trim_end_matches(&['\r', '\n'][..]);
|
|
|
|
|
let mut parts = request_line.split_whitespace();
|
|
|
|
|
let method = parts
|
|
|
|
|
.next()
|
|
|
|
|
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing method"))?
|
|
|
|
|
.to_ascii_uppercase();
|
|
|
|
|
let path = parts
|
|
|
|
|
.next()
|
|
|
|
|
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing path"))?
|
|
|
|
|
.to_string();
|
|
|
|
|
|
|
|
|
|
let mut headers = HashMap::new();
|
|
|
|
|
loop {
|
|
|
|
|
let mut line = String::new();
|
|
|
|
|
let len = reader.read_line(&mut line)?;
|
|
|
|
|
if len == 0 {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
let trimmed = line.trim_end_matches(&['\r', '\n'][..]);
|
|
|
|
|
if trimmed.is_empty() {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
if let Some((name, value)) = trimmed.split_once(':') {
|
|
|
|
|
headers.insert(name.trim().to_ascii_lowercase(), value.trim().to_string());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let content_length: usize = headers
|
|
|
|
|
.get("content-length")
|
|
|
|
|
.and_then(|v| v.parse().ok())
|
|
|
|
|
.unwrap_or(0);
|
|
|
|
|
|
|
|
|
|
let mut body = vec![0u8; content_length];
|
|
|
|
|
reader.read_exact(&mut body)?;
|
|
|
|
|
|
|
|
|
|
Ok(OpenHomeHttpRequest {
|
|
|
|
|
method,
|
|
|
|
|
path,
|
|
|
|
|
headers,
|
|
|
|
|
body,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn write_openhome_http_response(
|
|
|
|
|
stream: &mut TcpStream,
|
|
|
|
|
status: u16,
|
|
|
|
|
message: &str,
|
|
|
|
|
) -> io::Result<()> {
|
|
|
|
|
let response = format!(
|
|
|
|
|
"HTTP/1.1 {} {}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
|
|
|
|
|
status, message
|
|
|
|
|
);
|
|
|
|
|
stream.write_all(response.as_bytes())
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-17 21:41:24 +01:00
|
|
|
fn build_openhome_callback_path(id: &RendererId, service: OhServiceKind) -> String {
|
2025-12-05 19:01:33 +01:00
|
|
|
let mut sanitized = String::new();
|
|
|
|
|
for ch in id.0.chars() {
|
|
|
|
|
if ch.is_ascii_alphanumeric() {
|
|
|
|
|
sanitized.push(ch);
|
|
|
|
|
} else {
|
|
|
|
|
sanitized.push('_');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
let mut hasher = DefaultHasher::new();
|
|
|
|
|
id.hash(&mut hasher);
|
|
|
|
|
service.hash(&mut hasher);
|
|
|
|
|
let suffix = hasher.finish();
|
2025-12-05 21:59:43 +01:00
|
|
|
format!("/openhome-events/{}/{:x}", service.as_str(), suffix)
|
2025-12-05 19:01:33 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn parse_openhome_propertyset(
|
|
|
|
|
renderer_id: &RendererId,
|
2025-12-17 21:41:24 +01:00
|
|
|
service: &OhServiceKind,
|
2025-12-05 19:01:33 +01:00
|
|
|
body: &[u8],
|
|
|
|
|
) -> Vec<(String, String)> {
|
|
|
|
|
let mut properties = Vec::new();
|
|
|
|
|
let reader = std::io::Cursor::new(body);
|
|
|
|
|
let Ok(root) = Element::parse(reader) else {
|
|
|
|
|
warn!(
|
|
|
|
|
renderer = renderer_id.0.as_str(),
|
|
|
|
|
service = service.as_str(),
|
|
|
|
|
"Failed to parse OpenHome notify payload"
|
|
|
|
|
);
|
|
|
|
|
return properties;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
for property in root.children.iter().filter_map(|node| match node {
|
|
|
|
|
XMLNode::Element(elem) => Some(elem),
|
|
|
|
|
_ => None,
|
|
|
|
|
}) {
|
|
|
|
|
for child in property.children.iter().filter_map(|node| match node {
|
|
|
|
|
XMLNode::Element(elem) => Some(elem),
|
|
|
|
|
_ => None,
|
|
|
|
|
}) {
|
|
|
|
|
if let Some(text) = child.get_text() {
|
|
|
|
|
properties.push((child.name.clone(), text.into_owned()));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
properties
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn is_id_array_property(name: &str) -> bool {
|
2025-12-05 21:59:43 +01:00
|
|
|
name.trim().to_ascii_lowercase().ends_with("idarray")
|
2025-12-05 19:01:33 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn empty_playback_position() -> PlaybackPositionInfo {
|
|
|
|
|
PlaybackPositionInfo {
|
|
|
|
|
track: None,
|
|
|
|
|
rel_time: None,
|
|
|
|
|
abs_time: None,
|
|
|
|
|
track_duration: None,
|
|
|
|
|
track_metadata: None,
|
|
|
|
|
track_uri: None,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn parse_timeout(raw: Option<&str>) -> Option<Duration> {
|
|
|
|
|
let value = raw?;
|
|
|
|
|
let lower = value.trim().to_ascii_lowercase();
|
|
|
|
|
if lower == "second-infinite" {
|
|
|
|
|
return Some(Duration::from_secs(OPENHOME_SUBSCRIPTION_TIMEOUT_SECS));
|
|
|
|
|
}
|
|
|
|
|
if let Some(idx) = lower.find("second-") {
|
|
|
|
|
let number = &lower[idx + 7..];
|
|
|
|
|
if let Ok(seconds) = number.parse::<u64>() {
|
|
|
|
|
return Some(Duration::from_secs(seconds));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn parse_host_port(url: &str) -> Option<(String, u16)> {
|
|
|
|
|
let default_port = if url.to_ascii_lowercase().starts_with("https://") {
|
|
|
|
|
443
|
|
|
|
|
} else {
|
|
|
|
|
80
|
|
|
|
|
};
|
|
|
|
|
let (_, rest) = url.split_once("://")?;
|
|
|
|
|
let mut parts = rest.splitn(2, '/');
|
|
|
|
|
let authority = parts.next()?.trim();
|
|
|
|
|
if authority.starts_with('[') {
|
|
|
|
|
let end = authority.find(']')?;
|
|
|
|
|
let host = &authority[1..end];
|
|
|
|
|
let remainder = authority.get(end + 1..).unwrap_or("");
|
|
|
|
|
let port = if let Some(stripped) = remainder.strip_prefix(':') {
|
|
|
|
|
stripped.parse().unwrap_or(default_port)
|
|
|
|
|
} else {
|
|
|
|
|
default_port
|
|
|
|
|
};
|
|
|
|
|
Some((host.to_string(), port))
|
|
|
|
|
} else if let Some((host, port)) = authority.split_once(':') {
|
|
|
|
|
Some((host.to_string(), port.parse().ok()?))
|
|
|
|
|
} else {
|
|
|
|
|
Some((authority.to_string(), default_port))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn determine_local_ip(remote_host: &str, remote_port: u16) -> io::Result<IpAddr> {
|
|
|
|
|
let is_ipv6 = remote_host.contains(':') && !remote_host.contains('.');
|
|
|
|
|
let target = if is_ipv6 {
|
|
|
|
|
format!(
|
|
|
|
|
"[{}]:{}",
|
|
|
|
|
remote_host.trim_matches(|c| c == '[' || c == ']'),
|
|
|
|
|
remote_port
|
|
|
|
|
)
|
|
|
|
|
} else {
|
|
|
|
|
format!("{}:{}", remote_host, remote_port)
|
|
|
|
|
};
|
|
|
|
|
let bind_addr = if is_ipv6 { "[::]:0" } else { "0.0.0.0:0" };
|
|
|
|
|
let socket = UdpSocket::bind(bind_addr)?;
|
|
|
|
|
socket.connect(&target)?;
|
|
|
|
|
Ok(socket.local_addr()?.ip())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn format_ip(ip: &IpAddr) -> String {
|
|
|
|
|
match ip {
|
|
|
|
|
IpAddr::V4(v4) => v4.to_string(),
|
|
|
|
|
IpAddr::V6(v6) => format!("[{}]", v6),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn build_agent(timeout: Duration) -> Agent {
|
|
|
|
|
Agent::config_builder()
|
|
|
|
|
.timeout_global(Some(timeout))
|
|
|
|
|
.http_status_as_error(false)
|
|
|
|
|
.allow_non_standard_methods(true)
|
|
|
|
|
.build()
|
|
|
|
|
.into()
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-30 10:54:01 +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)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn parse_optional_hms_to_secs(value: &Option<String>) -> Option<u64> {
|
|
|
|
|
value.as_ref().and_then(|s| parse_hms_to_secs(s))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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 {
|
|
|
|
|
use PlaybackState::*;
|
|
|
|
|
|
|
|
|
|
// Rule 1: Arylic / LinkPlay sometimes report STOPPED while the stream is
|
|
|
|
|
// actually playing. If we detect that the relative time advances between
|
|
|
|
|
// two polls, we treat this as Playing.
|
|
|
|
|
if let Stopped = raw {
|
|
|
|
|
if let (Some(prev), Some(curr)) = (prev_position, current_position) {
|
|
|
|
|
if let (Some(prev_rel), Some(curr_rel)) = (
|
|
|
|
|
parse_optional_hms_to_secs(&prev.rel_time),
|
|
|
|
|
parse_optional_hms_to_secs(&curr.rel_time),
|
|
|
|
|
) {
|
|
|
|
|
if curr_rel > prev_rel {
|
|
|
|
|
let delta = curr_rel - prev_rel;
|
|
|
|
|
// Our poll loop runs every 1s; accept small jitter in the delta.
|
|
|
|
|
if delta <= 5 {
|
|
|
|
|
return Playing;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Rule 2: Some devices report NO_MEDIA_PRESENT while exposing a non-zero
|
|
|
|
|
// track duration. In practice this behaves like a stopped transport with
|
|
|
|
|
// a loaded track.
|
|
|
|
|
if let NoMedia = raw {
|
|
|
|
|
let duration_secs = current_position
|
|
|
|
|
.and_then(|p| parse_optional_hms_to_secs(&p.track_duration))
|
|
|
|
|
.or_else(|| prev_position.and_then(|p| parse_optional_hms_to_secs(&p.track_duration)));
|
|
|
|
|
|
|
|
|
|
if matches!(duration_secs, Some(d) if d > 0) {
|
|
|
|
|
return Stopped;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
}
|