Files
pmomusic/pmocontrol/src/music_renderer/openhome_renderer.rs
Eric Coissac e8e33414f0 (refactor) Replace tokio::spawn_blocking with std thread for metadata preloading
(refactor) Replace tokio::spawn_blocking with std thread for metadata preloading
- Use `std::thread` instead of Tokio's blocking pool to avoid potential thread starvation in async context
- Ensures long-running metadata loading does not block Tokio workers
2026-04-10 00:05:16 +02:00

760 lines
26 KiB
Rust

use std::sync::{atomic::AtomicBool, Arc, Mutex};
use std::time::SystemTime;
use crate::music_renderer::capabilities::{
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, QueueTransportControl, RendererBackend,
TransportControl, VolumeControl,
};
use crate::music_renderer::time_utils::{format_hhmmss_u32, parse_time_flexible};
use crate::DeviceIdentity;
use crate::errors::ControlPointError;
use crate::model::{PlaybackState, RendererInfo};
use crate::music_renderer::musicrenderer::MusicRendererBackend;
use crate::music_renderer::openhome::{
build_info_client, build_playlist_client, build_product_client, build_radio_client,
build_time_client, build_volume_client,
};
use crate::music_renderer::RendererFromMediaRendererInfo;
use crate::queue::{EnqueueMode, MusicQueue, PlaybackItem, QueueBackend, QueueSnapshot};
use crate::upnp_clients::{
OhInfoClient, OhPlaylistClient, OhProductClient, OhRadioClient, OhTimeClient, OhVolumeClient,
OPENHOME_PLAYLIST_HEAD_ID,
};
use tracing::debug;
/// Cache for playback position to avoid redundant SOAP calls
#[derive(Debug)]
struct PositionCache {
/// Last cached position info
last_position: Option<PlaybackPositionInfo>,
/// Timestamp of last cache update
last_update: Option<SystemTime>,
/// Number of calls in the last second (for warning detection)
calls_in_last_second: Vec<SystemTime>,
}
impl PositionCache {
fn new() -> Self {
Self {
last_position: None,
last_update: None,
calls_in_last_second: Vec::new(),
}
}
}
#[derive(Clone, Debug)]
pub struct OpenHomeRenderer {
playlist: Option<OhPlaylistClient>,
info_client: Option<OhInfoClient>,
time_client: Option<OhTimeClient>,
volume_client: Option<OhVolumeClient>,
product_client: Option<OhProductClient>,
#[allow(dead_code)]
radio_client: Option<OhRadioClient>,
queue: Arc<Mutex<MusicQueue>>,
/// Flag indicating if currently playing a continuous stream (radio without duration)
continuous_stream: Arc<Mutex<bool>>,
/// Cached current track URI to detect track changes
current_track_uri: Arc<Mutex<Option<String>>>,
/// Position cache to avoid redundant SOAP calls (OpenHome has second-precision only)
position_cache: Arc<Mutex<PositionCache>>,
}
impl OpenHomeRenderer {
pub fn new(
playlist: Option<OhPlaylistClient>,
info_client: Option<OhInfoClient>,
time_client: Option<OhTimeClient>,
volume_client: Option<OhVolumeClient>,
product_client: Option<OhProductClient>,
radio_client: Option<OhRadioClient>,
queue: Arc<Mutex<MusicQueue>>,
) -> Self {
Self {
playlist,
info_client,
time_client,
volume_client,
product_client,
radio_client,
queue,
continuous_stream: Arc::new(Mutex::new(false)),
current_track_uri: Arc::new(Mutex::new(None)),
position_cache: Arc::new(Mutex::new(PositionCache::new())),
}
}
/// Returns true if currently playing a continuous stream (radio without duration)
pub fn is_continuous_stream(&self) -> bool {
*self.continuous_stream.lock().unwrap()
}
pub fn has_playlist(&self) -> bool {
self.playlist.is_some()
}
pub fn has_info(&self) -> bool {
self.info_client.is_some()
}
pub fn has_time(&self) -> bool {
self.time_client.is_some()
}
pub fn has_volume(&self) -> bool {
self.volume_client.is_some()
}
pub fn has_any_openhome_service(&self) -> bool {
self.has_playlist() || self.has_info() || self.has_time() || self.has_volume()
}
fn playlist_client_for(&self, op: &str) -> Result<&OhPlaylistClient, ControlPointError> {
let playlist = self.playlist.as_ref().ok_or_else(|| {
ControlPointError::upnp_operation_not_supported(op, "OpenHome Playlist")
})?;
self.ensure_playlist_source_selected()?;
Ok(playlist)
}
fn info_client_for(&self, op: &str) -> Result<&OhInfoClient, ControlPointError> {
self.info_client
.as_ref()
.ok_or_else(|| ControlPointError::upnp_operation_not_supported(op, "OpenHome Info"))
}
fn time_client_for(&self, op: &str) -> Result<&OhTimeClient, ControlPointError> {
self.time_client
.as_ref()
.ok_or_else(|| ControlPointError::upnp_operation_not_supported(op, "OpenHome Time"))
}
fn volume_client_for(&self, op: &str) -> Result<&OhVolumeClient, ControlPointError> {
self.volume_client
.as_ref()
.ok_or_else(|| ControlPointError::upnp_operation_not_supported(op, "OpenHome Volume"))
}
fn ensure_playlist_source_selected(&self) -> Result<(), ControlPointError> {
if let Some(product) = &self.product_client {
product.ensure_playlist_source_selected()
} else {
Ok(())
}
}
// pub(crate) fn snapshot_openhome_playlist(&self) -> Result<OpenHomePlaylistSnapshot> {
// let playlist = self.playlist_client_for("snapshot_openhome_playlist")?;
// let entries = playlist.read_all_tracks()?;
// // Get current track ID from the playlist service
// let current_id = playlist.id().ok();
// let current_index =
// current_id.and_then(|id| entries.iter().position(|entry| entry.id == id));
// debug!(
// current_id = ?current_id,
// current_index = ?current_index,
// track_count = entries.len(),
// "snapshot_openhome_playlist completed"
// );
// let tracks = entries.iter().map(convert_oh_track_entry).collect();
// Ok(OpenHomePlaylistSnapshot {
// current_id,
// current_index,
// tracks,
// })
// }
/// Retourne la longueur de la playlist OpenHome sans récupérer toutes les métadonnées.
/// Plus rapide que snapshot_openhome_playlist() pour juste connaître le nombre de pistes.
pub(crate) fn openhome_playlist_len(&self) -> Result<usize, ControlPointError> {
// Use queue.len() which uses cached track_ids() internally
let queue = self.queue.lock().unwrap();
queue.len()
}
/// Retourne les IDs des pistes de la playlist OpenHome.
/// Plus rapide que snapshot_openhome_playlist() car ne récupère pas les métadonnées.
pub(crate) fn openhome_playlist_ids(&self) -> Result<Vec<u32>, ControlPointError> {
let queue = self.queue.lock().unwrap();
if let Some(oh_queue) = queue.as_openhome() {
oh_queue.track_ids()
} else {
Err(ControlPointError::QueueError(
"Not an OpenHome queue".to_string(),
))
}
}
pub(crate) fn clear_openhome_playlist(&self) -> Result<(), ControlPointError> {
let playlist = self.playlist_client_for("clear_openhome_playlist")?;
playlist.delete_all()
}
pub(crate) fn add_track_openhome(
&self,
uri: &str,
metadata: &str,
after_id: Option<u32>,
play: bool,
) -> Result<u32, ControlPointError> {
let playlist = self.playlist_client_for("add_track_openhome")?;
let insert_after = match after_id {
Some(id) => id,
None => {
let queue = self.queue.lock().unwrap();
if let Some(oh_queue) = queue.as_openhome() {
oh_queue
.track_ids()?
.last()
.copied()
.unwrap_or(OPENHOME_PLAYLIST_HEAD_ID)
} else {
return Err(ControlPointError::QueueError(
"Not an OpenHome queue".to_string(),
));
}
}
};
let new_id = playlist.insert(insert_after, uri, metadata)?;
if play {
playlist.seek_id(new_id)?;
}
Ok(new_id)
}
pub(crate) fn play_openhome_track_id(&self, id: u32) -> Result<(), ControlPointError> {
let playlist = self.playlist_client_for("play_openhome_track_id")?;
playlist.seek_id(id)
}
}
impl RendererFromMediaRendererInfo for OpenHomeRenderer {
fn from_renderer_info(info: &RendererInfo) -> Result<Self, ControlPointError> {
// Create the OpenHome queue
let queue = Arc::new(Mutex::new(MusicQueue::from_renderer_info(info)?));
let renderer = OpenHomeRenderer::new(
build_playlist_client(&info),
build_info_client(&info),
build_time_client(&info),
build_volume_client(&info),
build_product_client(&info),
build_radio_client(&info),
queue,
);
if renderer.has_any_openhome_service() {
Ok(renderer)
} else {
Err(ControlPointError::OpenHomeNotAValidDevice(format!(
"{:?}",
info.id()
)))
}
}
fn to_backend(self) -> MusicRendererBackend {
MusicRendererBackend::OpenHome(self)
}
}
impl RendererBackend for OpenHomeRenderer {
fn queue(&self) -> &Arc<Mutex<MusicQueue>> {
&self.queue
}
}
impl TransportControl for OpenHomeRenderer {
fn play_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError> {
let playlist = self.playlist_client_for("play_uri")?;
if let Err(err) = playlist.delete_all() {
debug!(
// renderer = self.info.id.0.as_str(),
error = %err,
"Failed to clear OpenHome playlist before insert"
);
}
// Reuse the same insertion logic as the queue path so that we honor
// renderer expectations (IdArray sequencing, etc.).
self.add_track_openhome(uri, meta, None, true)?;
// Start playback (like UPnP renderer does with avt.play())
playlist.play()?;
Ok(())
}
fn play(&self) -> Result<(), ControlPointError> {
let playlist = self.playlist_client_for("play")?;
playlist.play()
}
fn pause(&self) -> Result<(), ControlPointError> {
let playlist = self.playlist_client_for("pause")?;
playlist.pause()
}
fn stop(&self) -> Result<(), ControlPointError> {
let playlist = self.playlist_client_for("stop")?;
playlist.stop()
}
fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> {
let seconds = parse_time_flexible(hhmmss)?;
let playlist = self.playlist_client_for("seek_rel_time")?;
playlist.seek_second_absolute(seconds)
}
}
impl VolumeControl for OpenHomeRenderer {
fn volume(&self) -> Result<u16, ControlPointError> {
let client = self.volume_client_for("volume")?;
client.volume()
}
fn set_volume(&self, v: u16) -> Result<(), ControlPointError> {
let client = self.volume_client_for("set_volume")?;
client.set_volume(v)
}
fn mute(&self) -> Result<bool, ControlPointError> {
let client = self.volume_client_for("mute")?;
client.mute()
}
fn set_mute(&self, m: bool) -> Result<(), ControlPointError> {
let client = self.volume_client_for("set_mute")?;
client.set_mute(m)
}
}
impl PlaybackStatus for OpenHomeRenderer {
fn playback_state(&self) -> Result<PlaybackState, ControlPointError> {
let client = self.playlist_client_for("playback_state")?;
let raw = client.transport_state().map_err(|err| {
tracing::warn!(
error = %err,
"OpenHome transport_state() failed — state change detection disabled"
);
err
})?;
let mapped = if raw.is_empty() {
tracing::trace!("OpenHome TransportState: empty (device initializing)");
PlaybackState::Transitioning
} else {
let mapped = map_openhome_state(&raw);
tracing::trace!(
raw_state = raw.as_str(),
mapped_state = ?mapped,
"OpenHome TransportState"
);
mapped
};
Ok(mapped)
}
}
impl PlaybackPosition for OpenHomeRenderer {
fn playback_position(&self) -> Result<PlaybackPositionInfo, ControlPointError> {
let now = SystemTime::now();
// Check cache first
{
let mut cache = self.position_cache.lock().unwrap();
// Track calls for warning detection
cache.calls_in_last_second.push(now);
// Keep only calls from last second
cache.calls_in_last_second.retain(|t| {
now.duration_since(*t)
.map(|d| d.as_millis() < 1000)
.unwrap_or(false)
});
// Warn if called more than 3 times in last second
if cache.calls_in_last_second.len() > 3 {
tracing::warn!(
"OpenHome playback_position() called {} times in last second - possible inefficiency",
cache.calls_in_last_second.len()
);
}
// Return cached value if it's less than 900ms old (OpenHome has second precision)
if let (Some(last_pos), Some(last_update)) = (&cache.last_position, cache.last_update) {
if let Ok(elapsed) = now.duration_since(last_update) {
if elapsed.as_millis() < 900 {
tracing::trace!(
"OpenHome playback_position: returning cached value (age={}ms)",
elapsed.as_millis()
);
return Ok(last_pos.clone());
}
}
}
}
// Cache miss or stale - fetch from backend
let time_info = self.time_client_for("playback_position")?.position()?;
let mut track_id = None;
let mut track_uri = None;
let mut track_metadata_xml = None;
// Get track ID from queue (uses cached data)
let queue_guard_for_id = self.queue.lock().unwrap();
if let Some(oh_queue) = queue_guard_for_id.as_openhome() {
match oh_queue.current_track() {
Ok(id_opt) => track_id = id_opt,
Err(err) => debug!(
error = %err,
"Failed to read OpenHome track id"
),
}
}
drop(queue_guard_for_id);
// Use queue API to get current item with cached metadata
let mut queue_guard = self.queue.lock().unwrap();
if let Ok(Some((current_item, _))) = queue_guard.peek_current() {
// Use metadata from queue cache (updated via OpenHome events)
track_uri = Some(current_item.uri.clone());
// Build DIDL metadata XML from cached TrackMetadata
if let Some(ref metadata) = current_item.metadata {
track_metadata_xml = Some(
crate::music_renderer::musicrenderer::build_didl_lite_metadata(
metadata,
&current_item.uri,
&current_item.protocol_info,
),
);
}
// Check if the URI has changed to detect track changes
let mut cached_uri = self.current_track_uri.lock().unwrap();
let uri_changed = cached_uri.as_ref() != Some(&current_item.uri);
if uri_changed {
tracing::debug!(
"OpenHome track URI changed: {:?} -> {:?}",
cached_uri,
current_item.uri
);
// Détecte si la nouvelle URL est un flux continu
let is_stream = crate::music_renderer::is_continuous_stream_url(&current_item.uri);
*self.continuous_stream.lock().unwrap() = is_stream;
tracing::debug!("OpenHome URI changed, continuous_stream={}", is_stream);
*cached_uri = Some(current_item.uri.clone());
}
}
drop(queue_guard);
// Get duration from Time service - duration_secs=0 means stream (no duration)
let track_duration = if time_info.duration_secs == 0 {
None
} else {
Some(format_hhmmss_u32(time_info.duration_secs))
};
let rel_time = format_hhmmss_u32(time_info.elapsed_secs);
tracing::trace!(
"OpenHome playback_position: duration_secs={}, track_duration={:?}, elapsed_secs={}, rel_time={}",
time_info.duration_secs,
track_duration,
time_info.elapsed_secs,
rel_time
);
let position_info = PlaybackPositionInfo {
track: track_id,
rel_time: Some(rel_time),
abs_time: None,
track_duration,
track_metadata: track_metadata_xml,
track_uri,
};
// Update cache with fresh data
{
let mut cache = self.position_cache.lock().unwrap();
cache.last_position = Some(position_info.clone());
cache.last_update = Some(now);
}
Ok(position_info)
}
}
/// Parse duration from DIDL-Lite metadata XML (OpenHome version)
#[allow(dead_code)]
fn parse_didl_duration_openhome(didl: &str) -> Option<String> {
// Search for duration attribute in <res> element
let res_start = didl.find("<res ")?;
let after_res = &didl[res_start..];
let tag_close = after_res.find('>')?;
let tag_attrs = &after_res[..tag_close];
if let Some(duration_start) = tag_attrs.find("duration=\"") {
let duration_offset = duration_start + "duration=\"".len();
if let Some(duration_end) = tag_attrs[duration_offset..].find('"') {
let duration = &tag_attrs[duration_offset..duration_offset + duration_end];
return Some(duration.to_string());
}
}
tracing::debug!("OpenHome: No duration found in DIDL metadata");
None
}
pub(crate) fn map_openhome_state(raw: &str) -> PlaybackState {
match raw.trim().to_ascii_uppercase().as_str() {
"PLAYING" => PlaybackState::Playing,
"PAUSED" | "PAUSED_PLAYBACK" => PlaybackState::Paused,
"STOPPED" => PlaybackState::Stopped,
"BUFFERING" | "TRANSITIONING" => PlaybackState::Transitioning,
other => PlaybackState::Unknown(other.to_string()),
}
}
impl QueueTransportControl for OpenHomeRenderer {
fn play_from_queue(&self) -> Result<(), ControlPointError> {
{
let queue = self
.queue
.lock()
.map_err(|_| ControlPointError::QueueError("Queue mutex poisoned".into()))?;
if queue.current_index()?.is_none() {
if queue.len()? > 0 {
drop(queue);
let mut queue = self.queue.lock().map_err(|_| {
ControlPointError::QueueError("Queue mutex poisoned".into())
})?;
queue.set_index(Some(0))?;
} else {
return Err(ControlPointError::QueueError("Queue is empty".into()));
}
}
}
let playlist = self.playlist_client_for("play_from_queue")?;
playlist.play()
}
fn play_next(&self) -> Result<(), ControlPointError> {
{
let mut queue = self
.queue
.lock()
.map_err(|_| ControlPointError::QueueError("Queue mutex poisoned".into()))?;
let len = queue.len().unwrap_or(0);
let current = queue.current_index().ok().flatten();
let current_track_id = queue.current_track().ok().flatten();
let all_ids = queue.track_ids().ok().unwrap_or_default();
tracing::trace!(
queue_len = len,
current_index = ?current,
current_track_id = ?current_track_id,
all_track_ids = ?all_ids,
"OpenHome play_next: advancing queue"
);
if !queue.advance()? {
tracing::trace!(
queue_len = len,
current_index = ?current,
"OpenHome play_next: advance() returned false — no next track"
);
return Err(ControlPointError::QueueError("No next track".into()));
}
}
self.play_from_queue()
}
fn play_previous(&self) -> Result<(), ControlPointError> {
{
let mut queue = self
.queue
.lock()
.map_err(|_| ControlPointError::QueueError("Queue mutex poisoned".into()))?;
if !queue.rewind()? {
return Err(ControlPointError::QueueError("No previous track".into()));
}
}
self.play_from_queue()
}
fn play_from_index(&self, index: usize) -> Result<(), ControlPointError> {
// For OpenHome, we need to convert index to track_id
let track_id = {
let queue = self
.queue
.lock()
.map_err(|_| ControlPointError::QueueError("Queue mutex poisoned".into()))?;
queue.position_to_id(index)?
};
// Seek to the track by ID
let playlist = self.playlist_client_for("play_from_index")?;
playlist.seek_id(track_id)?;
// Update local queue index
{
let mut queue = self
.queue
.lock()
.map_err(|_| ControlPointError::QueueError("Queue mutex poisoned".into()))?;
queue.set_index(Some(index))?;
}
// Start playback
playlist.play()?;
Ok(())
}
}
impl QueueBackend for OpenHomeRenderer {
fn len(&self) -> Result<usize, ControlPointError> {
self.queue
.lock()
.map_err(|_| ControlPointError::QueueError("Queue mutex poisoned".into()))?
.len()
}
fn track_ids(&self) -> Result<Vec<u32>, ControlPointError> {
self.queue
.lock()
.map_err(|_| ControlPointError::QueueError("Queue mutex poisoned".into()))?
.track_ids()
}
fn id_to_position(&self, id: u32) -> Result<usize, ControlPointError> {
self.queue
.lock()
.map_err(|_| ControlPointError::QueueError("Queue mutex poisoned".into()))?
.id_to_position(id)
}
fn position_to_id(&self, id: usize) -> Result<u32, ControlPointError> {
self.queue
.lock()
.map_err(|_| ControlPointError::QueueError("Queue mutex poisoned".into()))?
.position_to_id(id)
}
fn current_track(&self) -> Result<Option<u32>, ControlPointError> {
self.queue
.lock()
.map_err(|_| ControlPointError::QueueError("Queue mutex poisoned".into()))?
.current_track()
}
fn current_index(&self) -> Result<Option<usize>, ControlPointError> {
self.queue
.lock()
.map_err(|_| ControlPointError::QueueError("Queue mutex poisoned".into()))?
.current_index()
}
fn queue_snapshot(&self) -> Result<QueueSnapshot, ControlPointError> {
self.queue
.lock()
.map_err(|_| ControlPointError::QueueError("Queue mutex poisoned".into()))?
.queue_snapshot()
}
fn set_index(&mut self, index: Option<usize>) -> Result<(), ControlPointError> {
self.queue
.lock()
.map_err(|_| ControlPointError::QueueError("Queue mutex poisoned".into()))?
.set_index(index)
}
fn replace_queue(
&mut self,
items: Vec<PlaybackItem>,
current_index: Option<usize>,
) -> Result<(), ControlPointError> {
// ✅ CORRECTION BUG PRODUCTION: On ne charge PAS toutes les métadonnées
// dans le thread principal. OpenHome sur 1000 titres inondait la base SQLite
// et bloquait TOUS les autres threads (mutex >500ms).
//
// On fait juste l'insertion minimaliste maintenant. Le préchargement
// des métadonnées est délégué à un thread background.
self.queue
.lock()
.map_err(|_| ControlPointError::QueueError("Mutex poisoned".into()))?
.replace_queue(items, current_index)?;
// Background worker: charge les métadonnées petit à petit sans bloquer personne
let queue = self.queue.clone();
std::thread::spawn(move || {
debug!("🔄 OpenHome: préchargement métadonnées queue en background");
if let Ok(mut queue) = queue.lock() {
// On ne fait que les 10 prochains titres maintenant, le reste on s'en fout
if let Ok(Some(idx)) = queue.current_index() {
let end = std::cmp::min(idx + 10, queue.len().unwrap_or(0));
for i in idx..end {
let _ = queue.get_item(i);
// Petit délai pour ne pas noyer la base de données
std::thread::sleep(std::time::Duration::from_millis(5));
}
}
}
debug!("✅ OpenHome: préchargement métadonnées terminé");
});
Ok(())
}
fn sync_queue(
&mut self,
items: Vec<PlaybackItem>,
cancel_token: &Arc<AtomicBool>,
on_ready: Option<Box<dyn FnOnce() + Send>>,
) -> Result<(), ControlPointError> {
self.queue
.lock()
.map_err(|_| ControlPointError::QueueError("Queue mutex poisoned".into()))?
.sync_queue(items, cancel_token, on_ready)
}
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError> {
self.queue
.lock()
.map_err(|_| ControlPointError::QueueError("Queue mutex poisoned".into()))?
.get_item(index)
}
fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<(), ControlPointError> {
self.queue
.lock()
.map_err(|_| ControlPointError::QueueError("Queue mutex poisoned".into()))?
.replace_item(index, item)
}
fn enqueue_items(
&mut self,
items: Vec<PlaybackItem>,
mode: EnqueueMode,
) -> Result<(), ControlPointError> {
self.queue
.lock()
.map_err(|_| ControlPointError::QueueError("Queue mutex poisoned".into()))?
.enqueue_items(items, mode)
}
}