Des debug après la simplification

This commit is contained in:
2025-12-06 00:38:06 +01:00
parent daef8d6150
commit 380e105873
16 changed files with 800 additions and 668 deletions

View File

@@ -746,11 +746,9 @@ impl App {
self.pending_binding_container.clone(),
self.renderer_id(),
) {
self.control_point.attach_queue_to_playlist(
&renderer_id,
server.id.clone(),
container.clone(),
);
self.control_point
.attach_queue_to_playlist(&renderer_id, server.id.clone(), container.clone())
.context("Failed to attach queue to playlist")?;
self.ui_state
.set_status(format!("File liée à '{}'", container));
}

View File

@@ -134,11 +134,13 @@ fn main() -> Result<()> {
.context("Failed to enqueue playback items")?;
// Attach queue to live playlist container
control_point.attach_queue_to_playlist(
&renderer_id,
server_info.id.clone(),
live_playlist_container.id.clone(),
);
control_point
.attach_queue_to_playlist(
&renderer_id,
server_info.id.clone(),
live_playlist_container.id.clone(),
)
.context("Failed to attach queue to live playlist container")?;
println!(
"✓ Queue attached to Live Playlist container '{}' (id: {}) on server '{}'",
live_playlist_container.title, live_playlist_container.id, server_info.friendly_name

View File

@@ -127,11 +127,9 @@ fn main() -> Result<()> {
// Attach queue to playlist container if we found one
if let Some(container_id) = bound_container_id {
control_point.attach_queue_to_playlist(
&renderer_id,
server_info.id.clone(),
container_id.clone(),
);
control_point
.attach_queue_to_playlist(&renderer_id, server_info.id.clone(), container_id.clone())
.context("Failed to attach queue to playlist container")?;
println!(
"✓ Queue attached to playlist container {} on server {}",
container_id, server_info.friendly_name

View File

@@ -27,6 +27,11 @@ use crate::media_server::{
use crate::media_server_events::spawn_media_server_event_runtime;
use crate::model::TrackMetadata;
use crate::model::{MediaServerEvent, RendererEvent, RendererId, RendererInfo};
#[cfg(feature = "pmoserver")]
use crate::openapi::{
CurrentTrackMetadata, FullRendererSnapshot, QueueItem, QueueSnapshotView, RendererBindingView,
RendererStateView,
};
use crate::openhome_client::parse_track_metadata_from_didl;
use crate::openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack};
use crate::openhome_renderer::{format_seconds, map_openhome_state};
@@ -68,6 +73,10 @@ pub enum OpenHomeAccessError {
/// - lance un SsdpClient dans un thread,
/// - passe les SsdpEvent au DiscoveryManager,
/// - applique les DeviceUpdate dans le DeviceRegistry.
///
/// Le runtime est **l'unique source de vérité** pour l'état des renderers :
/// les clients doivent toujours consommer des snapshots consolidés côté serveur
/// et n'utiliser les événements SSE que comme signaux de rafraîchissement.
pub struct ControlPoint {
registry: Arc<RwLock<DeviceRegistry>>,
event_bus: RendererEventBus,
@@ -575,7 +584,7 @@ impl ControlPoint {
}
// User-driven mutation: detach any playlist binding
self.detach_binding_on_user_mutation(renderer_id, "clear_queue");
self.detach_playlist_binding(renderer_id, "clear_queue");
if self.runtime.uses_openhome_playlist(renderer_id) {
let renderer = self.openhome_renderer(renderer_id)?;
@@ -628,7 +637,7 @@ impl ControlPoint {
}
// User-driven mutation: detach any playlist binding
self.detach_binding_on_user_mutation(renderer_id, "enqueue_items");
self.detach_playlist_binding(renderer_id, "enqueue_items");
if self.runtime.uses_openhome_playlist(renderer_id) {
self.enqueue_items_openhome(renderer_id, items)?;
@@ -698,10 +707,7 @@ impl ControlPoint {
}
/// Retourne les métadonnées courantes depuis le snapshot en mémoire
pub fn get_current_track_metadata(
&self,
renderer_id: &RendererId,
) -> Option<TrackMetadata> {
pub fn get_current_track_metadata(&self, renderer_id: &RendererId) -> Option<TrackMetadata> {
self.runtime.current_track_metadata(renderer_id)
}
@@ -726,6 +732,122 @@ impl ControlPoint {
renderer.openhome_playlist_len()
}
/// 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();
let (runtime_snapshot, queue_items, current_index) =
self.runtime.renderer_snapshot_bundle(renderer_id);
let playback_source = self.runtime.playback_source(renderer_id);
let queue_len = queue_items.len();
let mut queue_current_index = current_index;
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(),
title: item.title.clone(),
artist: item.artist.clone(),
album: item.album.clone(),
album_art_uri: item.album_art_uri.clone(),
server_id: item.server_id.as_ref().map(|s| s.0.clone()),
object_id: item.object_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);
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()
.map(playback_state_label)
.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,
})
}
pub fn clear_openhome_playlist(&self, renderer_id: &RendererId) -> anyhow::Result<()> {
let renderer = self.openhome_renderer(renderer_id)?;
renderer.openhome_playlist_clear()?;
@@ -1048,37 +1170,35 @@ impl ControlPoint {
renderer_id: &RendererId,
server_id: ServerId,
container_id: String,
) {
self.attach_queue_to_playlist_internal(renderer_id, server_id, container_id, false);
) -> anyhow::Result<()> {
self.attach_queue_to_playlist_with_options(renderer_id, server_id, container_id, false)
}
/// Attach a renderer's queue to a playlist container without doing the initial refresh.
///
/// This is useful when the queue has already been manually populated and we just want
/// to track future changes to the playlist.
pub fn attach_queue_to_playlist_without_refresh(
/// Attach a renderer queue to a playlist with explicit `auto_play` behaviour.
pub fn attach_queue_to_playlist_with_options(
&self,
renderer_id: &RendererId,
server_id: ServerId,
container_id: String,
) {
self.attach_queue_to_playlist_internal(renderer_id, server_id, container_id, true);
auto_play: bool,
) -> anyhow::Result<()> {
self.attach_queue_to_playlist_internal(renderer_id, &server_id, &container_id, auto_play)
}
/// Internal implementation with optional skip of initial refresh
/// Internal implementation shared by every attach wrapper.
fn attach_queue_to_playlist_internal(
&self,
renderer_id: &RendererId,
server_id: ServerId,
container_id: String,
skip_initial_refresh: bool,
) {
server_id: &ServerId,
container_id: &str,
auto_play: bool,
) -> anyhow::Result<()> {
let binding = PlaylistBinding {
server_id: server_id.clone(),
container_id: container_id.clone(),
container_id: container_id.to_string(),
has_seen_update: false,
pending_refresh: !skip_initial_refresh,
auto_play_on_refresh: !skip_initial_refresh,
pending_refresh: true,
auto_play_on_refresh: auto_play,
};
{
@@ -1087,37 +1207,32 @@ impl ControlPoint {
info!(
renderer = renderer_id.0.as_str(),
server = server_id.0.as_str(),
container = container_id.as_str(),
skip_refresh = skip_initial_refresh,
container = container_id,
auto_play,
"Queue attached to playlist container"
);
} // Drop bindings lock here before calling refresh_attached_queue_for
}
// Emit binding changed event to notify frontend
self.emit_renderer_event(RendererEvent::BindingChanged {
id: renderer_id.clone(),
binding: Some(binding),
});
if !skip_initial_refresh {
let mut auto_start_cb = |rid: &RendererId| self.start_queue_playback_if_idle(rid);
if let Err(err) = refresh_attached_queue_for(
&self.registry,
&self.runtime,
&self.playlist_bindings,
renderer_id,
&self.event_bus,
Some(&mut auto_start_cb),
) {
warn!(
renderer = renderer_id.0.as_str(),
server = server_id.0.as_str(),
container = container_id.as_str(),
error = %err,
"Initial playlist refresh after attachment failed"
);
}
}
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,
)
}
/// Detach a renderer's queue from its associated playlist container.
@@ -1125,29 +1240,7 @@ impl ControlPoint {
/// After calling this, the queue will no longer be automatically refreshed
/// from the server. If no binding existed, this is a no-op.
pub fn detach_queue_playlist(&self, renderer_id: &RendererId) {
let removed = {
let mut bindings = self.playlist_bindings.lock().unwrap();
bindings.remove(renderer_id)
};
if let Some(binding) = removed {
info!(
renderer = renderer_id.0.as_str(),
server = binding.server_id.0.as_str(),
container = binding.container_id.as_str(),
"Queue detached from playlist container"
);
// Emit binding changed event to notify frontend
self.emit_renderer_event(RendererEvent::BindingChanged {
id: renderer_id.clone(),
binding: None,
});
} else {
debug!(
renderer = renderer_id.0.as_str(),
"detach_queue_playlist: no binding to remove"
);
}
self.detach_playlist_binding(renderer_id, "api_detach");
}
/// Query the current playlist binding for a renderer's queue, if any.
@@ -1168,19 +1261,33 @@ impl ControlPoint {
})
}
/// Internal helper to detach the playlist binding on user-driven mutations.
/// Internal helper to detach any playlist binding and notify observers.
///
/// This is called by public queue mutation methods (clear, enqueue, etc.)
/// to ensure that explicit user actions break the automatic refresh binding.
fn detach_binding_on_user_mutation(&self, renderer_id: &RendererId, reason: &str) {
let mut bindings = self.playlist_bindings.lock().unwrap();
if let Some(binding) = bindings.remove(renderer_id) {
/// 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 {
info!(
renderer = renderer_id.0.as_str(),
server = binding.server_id.0.as_str(),
container = binding.container_id.as_str(),
reason = reason,
"Playlist binding auto-detached due to user mutation"
"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"
);
}
}
@@ -1358,6 +1465,44 @@ impl ControlPoint {
}
}
#[cfg(feature = "pmoserver")]
fn convert_runtime_position(position: Option<&PlaybackPositionInfo>) -> (Option<u64>, Option<u64>) {
match position {
Some(info) => (
parse_hms_to_ms(info.rel_time.as_deref()),
parse_hms_to_ms(info.track_duration.as_deref()),
),
None => (None, None),
}
}
#[cfg(feature = "pmoserver")]
fn playback_state_label(state: &PlaybackState) -> String {
match state {
PlaybackState::Stopped => "STOPPED".to_string(),
PlaybackState::Playing => "PLAYING".to_string(),
PlaybackState::Paused => "PAUSED".to_string(),
PlaybackState::Transitioning => "TRANSITIONING".to_string(),
PlaybackState::NoMedia => "NO_MEDIA".to_string(),
PlaybackState::Unknown(custom) => custom.clone(),
}
}
#[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)
}
#[derive(Clone, Default)]
struct RendererRuntimeSnapshot {
state: Option<PlaybackState>,
@@ -1465,6 +1610,20 @@ impl RuntimeState {
.and_then(|entry| entry.snapshot.last_metadata.clone())
}
#[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) {
let (items, current_index) = entry.queue.full_snapshot();
(entry.snapshot.clone(), items, current_index)
} else {
(RendererRuntimeSnapshot::default(), Vec::new(), None)
}
}
fn dequeue_next(&self, id: &RendererId) -> Option<(PlaybackItem, usize)> {
let mut entries = self.entries.lock().unwrap();
let entry = entries.get_mut(id)?;
@@ -2839,6 +2998,16 @@ fn playback_position_equal(a: &PlaybackPositionInfo, b: &PlaybackPositionInfo) -
&& a.track_uri == b.track_uri
}
#[cfg(feature = "pmoserver")]
fn current_track_from_playback_item(item: &PlaybackItem) -> CurrentTrackMetadata {
CurrentTrackMetadata {
title: item.title.clone(),
artist: item.artist.clone(),
album: item.album.clone(),
album_art_uri: item.album_art_uri.clone(),
}
}
/// Extract TrackMetadata from DIDL-Lite XML in PlaybackPositionInfo.
fn extract_track_metadata(position: &PlaybackPositionInfo) -> Option<TrackMetadata> {
let didl_xml = match position.track_metadata.as_ref() {

View File

@@ -219,6 +219,30 @@ pub struct BrowseResponse {
pub entries: Vec<ContainerEntry>,
}
// ============================================================================
// SNAPSHOT AGGRÉGÉ
// ============================================================================
/// Alias de lisibilité pour les view-models déjà existants.
#[cfg(feature = "pmoserver")]
pub type RendererStateView = RendererState;
#[cfg(feature = "pmoserver")]
pub type QueueSnapshotView = QueueSnapshot;
#[cfg(feature = "pmoserver")]
pub type RendererBindingView = AttachedPlaylistInfo;
/// Instantané complet et cohérent d'un renderer.
///
/// Le ControlPoint est la source de vérité : ce snapshot agrège l'état,
/// la queue et le binding observés atomiquement côté serveur.
#[cfg(feature = "pmoserver")]
#[derive(Clone, Debug, Serialize, ToSchema)]
pub struct FullRendererSnapshot {
pub state: RendererStateView,
pub queue: QueueSnapshotView,
pub binding: Option<RendererBindingView>,
}
// ============================================================================
// PAYLOADS DE COMMANDES
// ============================================================================
@@ -239,6 +263,9 @@ pub struct AttachPlaylistRequest {
pub server_id: String,
/// ID du container playlist
pub container_id: String,
/// Si true, démarre la lecture automatiquement après le refresh
#[serde(default)]
pub auto_play: bool,
}
/// Requête pour lire ou ajouter du contenu à la queue

View File

@@ -265,10 +265,7 @@ impl OhInfoClient {
);
if let Some(ref xml) = metadata_xml {
debug!(
metadata_xml = xml.as_str(),
"OpenHome metadata XML content"
);
debug!(metadata_xml = xml.as_str(), "OpenHome metadata XML content");
}
Ok(OhInfoTrack { uri, metadata_xml })

View File

@@ -11,11 +11,10 @@ use crate::media_server::{MediaBrowser, MediaEntry, MediaResource, MusicServer,
use crate::model::{RendererCapabilities, RendererId, RendererProtocol};
#[cfg(feature = "pmoserver")]
use crate::openapi::{
AttachPlaylistRequest, AttachedPlaylistInfo, BrowseResponse, ContainerEntry,
CurrentTrackMetadata, ErrorResponse, MediaServerSummary, OpenHomePlaylistAddRequest,
OpenHomePlaylistSnapshot, PlayContentRequest, QueueItem, QueueSnapshot,
RendererCapabilitiesSummary, RendererProtocolSummary, RendererState, RendererSummary,
SuccessResponse, VolumeSetRequest,
AttachPlaylistRequest, AttachedPlaylistInfo, BrowseResponse, ContainerEntry, ErrorResponse,
FullRendererSnapshot, MediaServerSummary, OpenHomePlaylistAddRequest, OpenHomePlaylistSnapshot,
PlayContentRequest, QueueItem, QueueSnapshot, RendererCapabilitiesSummary,
RendererProtocolSummary, RendererState, RendererSummary, SuccessResponse, VolumeSetRequest,
};
#[cfg(feature = "pmoserver")]
use crate::playback_queue::PlaybackItem;
@@ -65,10 +64,6 @@ const QUEUE_COMMAND_TIMEOUT: Duration = Duration::from_secs(10);
#[cfg(feature = "pmoserver")]
const ATTACH_PLAYLIST_TIMEOUT: Duration = Duration::from_secs(60);
// Timeout for renderer state queries (multiple SOAP calls)
#[cfg(feature = "pmoserver")]
const STATE_QUERY_TIMEOUT: Duration = Duration::from_secs(8);
/// État partagé pour l'API ControlPoint
#[cfg(feature = "pmoserver")]
#[derive(Clone)]
@@ -137,168 +132,38 @@ async fn get_renderer_state(
Path(renderer_id): Path<String>,
) -> Result<Json<RendererState>, (StatusCode, Json<ErrorResponse>)> {
let rid = RendererId(renderer_id.clone());
let renderer = state
let snapshot = state
.control_point
.music_renderer_by_id(&rid)
.ok_or_else(|| {
(
StatusCode::NOT_FOUND,
Json(ErrorResponse {
error: format!("Renderer {} not found", renderer_id),
}),
)
})?;
.renderer_full_snapshot(&rid)
.map_err(|err| map_snapshot_error(renderer_id, err))?;
let info = renderer.info();
let renderer = renderer.clone();
Ok(Json(snapshot.state))
}
// Spawn blocking task for all SOAP calls to avoid blocking Tokio runtime
let state_task = tokio::task::spawn_blocking(move || {
// État de transport
let transport_state = renderer
.playback_state()
.ok()
.map(state_to_string)
.unwrap_or_else(|| "UNKNOWN".to_string());
// Position, durée et métadonnées depuis GetPositionInfo
let position_info = renderer.playback_position().ok();
let (position_ms, duration_ms) = position_info
.as_ref()
.and_then(|pos| {
let position = parse_hms_to_ms(pos.rel_time.as_deref());
let duration = parse_hms_to_ms(pos.track_duration.as_deref());
Some((position, duration))
})
.unwrap_or((None, None));
// Extraire les métadonnées depuis le XML track_metadata si disponible
let soap_metadata = position_info.and_then(|pos| {
match &pos.track_metadata {
Some(xml) => {
debug!("GetPositionInfo returned metadata XML: {}", xml);
use crate::openhome_client::parse_track_metadata_from_didl;
match parse_track_metadata_from_didl(xml) {
Some(metadata) => {
debug!("Successfully parsed metadata: title={:?}, artist={:?}",
metadata.title, metadata.artist);
Some(metadata)
}
None => {
debug!("Failed to parse metadata XML");
None
}
}
}
None => {
debug!("GetPositionInfo returned no metadata XML");
None
}
}
});
// Volume et mute
let volume = renderer
.volume()
.ok()
.and_then(|v| u8::try_from(v).ok());
let mute = renderer.mute().ok();
(transport_state, position_ms, duration_ms, volume, mute, soap_metadata)
});
let (transport_state, position_ms, duration_ms, volume, mute, soap_metadata) =
time::timeout(STATE_QUERY_TIMEOUT, state_task)
.await
.map_err(|_| {
warn!(
"State query for renderer {} exceeded {:?}",
renderer_id, STATE_QUERY_TIMEOUT
);
(
StatusCode::GATEWAY_TIMEOUT,
Json(ErrorResponse {
error: format!(
"State query timed out after {}s",
STATE_QUERY_TIMEOUT.as_secs()
),
}),
)
})?
.map_err(|e| {
warn!("Task join error during state query: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: format!("Internal task error: {}", e),
}),
)
})?;
// Queue (non-blocking, local data)
// Pour les renderers OpenHome, on utilise la playlist native au lieu de la PlaybackQueue
let queue_len = state
#[cfg(feature = "pmoserver")]
#[utoipa::path(
get,
path = "/renderers/{renderer_id}/full",
params(
("renderer_id" = String, Path, description = "ID unique du renderer")
),
responses(
(status = 200, description = "Snapshot complet du renderer", body = FullRendererSnapshot),
(status = 404, description = "Renderer non trouvé", body = ErrorResponse)
),
tag = "control"
)]
async fn get_renderer_full_snapshot(
State(state): State<ControlPointState>,
Path(renderer_id): Path<String>,
) -> Result<Json<FullRendererSnapshot>, (StatusCode, Json<ErrorResponse>)> {
let rid = RendererId(renderer_id.clone());
let snapshot = state
.control_point
.get_openhome_playlist_len(&rid)
.ok()
.or_else(|| {
state
.control_point
.get_queue_snapshot(&rid)
.ok()
.map(|q| q.len())
})
.unwrap_or(0);
.renderer_full_snapshot(&rid)
.map_err(|err| map_snapshot_error(renderer_id, err))?;
// Playlist binding (non-blocking, local data)
let attached_playlist = state
.control_point
.current_queue_playlist_binding(&rid)
.map(
|(server_id, container_id, has_seen_update)| AttachedPlaylistInfo {
server_id: server_id.0,
container_id,
has_seen_update,
},
);
// Current track metadata:
// 1. Essayer d'abord le snapshot en mémoire (rapide, mis à jour par SSE)
// 2. Si None et qu'on a des métadonnées SOAP fraîches, les utiliser
let snapshot_metadata = state
.control_point
.get_current_track_metadata(&rid);
debug!(
"Renderer {} - snapshot_metadata: {:?}, soap_metadata: {:?}",
renderer_id,
snapshot_metadata.as_ref().map(|m| (&m.title, &m.artist)),
soap_metadata.as_ref().map(|m| (&m.title, &m.artist))
);
let current_track = snapshot_metadata
.or(soap_metadata)
.map(|metadata| CurrentTrackMetadata {
title: metadata.title,
artist: metadata.artist,
album: metadata.album,
album_art_uri: metadata.album_art_uri,
});
Ok(Json(RendererState {
id: info.id.0.clone(),
friendly_name: info.friendly_name.clone(),
transport_state,
position_ms,
duration_ms,
volume,
mute,
queue_len,
attached_playlist,
current_track,
}))
Ok(Json(snapshot))
}
/// GET /control/renderers/{renderer_id}/queue - Récupère la queue d'un renderer
@@ -324,53 +189,12 @@ async fn get_renderer_queue(
Path(renderer_id): Path<String>,
) -> Result<Json<QueueSnapshot>, (StatusCode, Json<ErrorResponse>)> {
let rid = RendererId(renderer_id.clone());
// Verify renderer exists in registry
let _renderer = state
let snapshot = state
.control_point
.music_renderer_by_id(&rid)
.ok_or_else(|| {
(
StatusCode::NOT_FOUND,
Json(ErrorResponse {
error: format!("Renderer {} not found", renderer_id),
}),
)
})?;
.renderer_full_snapshot(&rid)
.map_err(|err| map_snapshot_error(renderer_id, err))?;
// Get queue snapshot - if renderer not yet in runtime (just discovered),
// return empty queue instead of error
let (items, current_index) = state
.control_point
.get_full_queue_snapshot(&rid)
.unwrap_or_else(|_| {
debug!(
renderer = renderer_id.as_str(),
"Renderer not yet initialized in runtime, returning empty queue"
);
(vec![], None)
});
let queue_items: Vec<QueueItem> = items
.into_iter()
.enumerate()
.map(|(index, item)| QueueItem {
index,
uri: item.uri,
title: item.title,
artist: item.artist,
album: item.album,
album_art_uri: item.album_art_uri,
server_id: item.server_id.map(|s| s.0),
object_id: item.object_id,
})
.collect();
Ok(Json(QueueSnapshot {
renderer_id,
items: queue_items,
current_index,
}))
Ok(Json(snapshot.queue))
}
/// GET /control/renderers/{renderer_id}/binding - Récupère le binding playlist
@@ -391,24 +215,17 @@ async fn get_renderer_binding(
State(state): State<ControlPointState>,
Path(renderer_id): Path<String>,
) -> Result<Json<Option<AttachedPlaylistInfo>>, (StatusCode, Json<ErrorResponse>)> {
let rid = RendererId(renderer_id);
let binding = state
let rid = RendererId(renderer_id.clone());
let snapshot = state
.control_point
.current_queue_playlist_binding(&rid)
.map(
|(server_id, container_id, has_seen_update)| AttachedPlaylistInfo {
server_id: server_id.0,
container_id,
has_seen_update,
},
);
.renderer_full_snapshot(&rid)
.map_err(|err| map_snapshot_error(renderer_id, err))?;
Ok(Json(binding))
Ok(Json(snapshot.binding))
}
// ============================================================================
// HANDLERS - CONTRÔLE TRANSPORT
// HANDLERS - TRANSPORT CONTROLS
// ============================================================================
/// POST /control/renderers/{renderer_id}/play - Démarre la lecture
@@ -431,7 +248,6 @@ async fn play_renderer(
Path(renderer_id): Path<String>,
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
let rid = RendererId(renderer_id.clone());
let renderer = state
.control_point
.music_renderer_by_id(&rid)
@@ -508,7 +324,6 @@ async fn pause_renderer(
Path(renderer_id): Path<String>,
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
let rid = RendererId(renderer_id.clone());
let renderer = state
.control_point
.music_renderer_by_id(&rid)
@@ -585,9 +400,7 @@ async fn stop_renderer(
Path(renderer_id): Path<String>,
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
let rid = RendererId(renderer_id.clone());
// Verify renderer exists
let _renderer = state
state
.control_point
.music_renderer_by_id(&rid)
.ok_or_else(|| {
@@ -599,9 +412,9 @@ async fn stop_renderer(
)
})?;
let control_point = state.control_point.clone();
let rid_clone = rid.clone();
let stop_task = tokio::task::spawn_blocking(move || control_point.user_stop(&rid_clone));
let control_point = Arc::clone(&state.control_point);
let rid_for_task = rid.clone();
let stop_task = tokio::task::spawn_blocking(move || control_point.user_stop(&rid_for_task));
time::timeout(TRANSPORT_COMMAND_TIMEOUT, stop_task)
.await
@@ -644,7 +457,7 @@ async fn stop_renderer(
}))
}
/// POST /control/renderers/{renderer_id}/resume - Reprend la lecture depuis le morceau actuel
/// POST /control/renderers/{renderer_id}/resume - Reprend la lecture depuis la queue
#[cfg(feature = "pmoserver")]
#[utoipa::path(
post,
@@ -664,25 +477,36 @@ async fn resume_renderer(
Path(renderer_id): Path<String>,
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
let rid = RendererId(renderer_id.clone());
let control_point = state.control_point.clone();
let rid_clone = rid.clone();
state
.control_point
.music_renderer_by_id(&rid)
.ok_or_else(|| {
(
StatusCode::NOT_FOUND,
Json(ErrorResponse {
error: format!("Renderer {} not found", renderer_id),
}),
)
})?;
let control_point = Arc::clone(&state.control_point);
let rid_for_task = rid.clone();
let resume_task =
tokio::task::spawn_blocking(move || control_point.play_current_from_queue(&rid_clone));
tokio::task::spawn_blocking(move || control_point.play_current_from_queue(&rid_for_task));
time::timeout(QUEUE_COMMAND_TIMEOUT, resume_task)
time::timeout(TRANSPORT_COMMAND_TIMEOUT, resume_task)
.await
.map_err(|_| {
warn!(
"Resume command for renderer {} exceeded {:?}",
renderer_id, QUEUE_COMMAND_TIMEOUT
renderer_id, TRANSPORT_COMMAND_TIMEOUT
);
(
StatusCode::GATEWAY_TIMEOUT,
Json(ErrorResponse {
error: format!(
"Resume command timed out after {}s",
QUEUE_COMMAND_TIMEOUT.as_secs()
TRANSPORT_COMMAND_TIMEOUT.as_secs()
),
}),
)
@@ -697,21 +521,24 @@ async fn resume_renderer(
)
})?
.map_err(|e| {
warn!("Failed to resume renderer {}: {}", renderer_id, e);
warn!(
"Failed to resume playback for renderer {}: {}",
renderer_id, e
);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: format!("Failed to resume: {}", e),
error: format!("Failed to resume playback: {}", e),
}),
)
})?;
Ok(Json(SuccessResponse {
message: "Playback resumed from current track".to_string(),
message: "Playback resumed".to_string(),
}))
}
/// POST /control/renderers/{renderer_id}/next - Passe au morceau suivant de la queue
/// POST /control/renderers/{renderer_id}/next - Passe au morceau suivant
#[cfg(feature = "pmoserver")]
#[utoipa::path(
post,
@@ -720,7 +547,7 @@ async fn resume_renderer(
("renderer_id" = String, Path, description = "ID unique du renderer")
),
responses(
(status = 200, description = "Passage au suivant", body = SuccessResponse),
(status = 200, description = "Piste suivante lancée", body = SuccessResponse),
(status = 404, description = "Renderer non trouvé", body = ErrorResponse),
(status = 500, description = "Erreur lors de l'exécution", body = ErrorResponse)
),
@@ -731,25 +558,36 @@ async fn next_renderer(
Path(renderer_id): Path<String>,
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
let rid = RendererId(renderer_id.clone());
let control_point = state.control_point.clone();
let rid_clone = rid.clone();
state
.control_point
.music_renderer_by_id(&rid)
.ok_or_else(|| {
(
StatusCode::NOT_FOUND,
Json(ErrorResponse {
error: format!("Renderer {} not found", renderer_id),
}),
)
})?;
let control_point = Arc::clone(&state.control_point);
let rid_for_task = rid.clone();
let next_task =
tokio::task::spawn_blocking(move || control_point.play_next_from_queue(&rid_clone));
tokio::task::spawn_blocking(move || control_point.play_next_from_queue(&rid_for_task));
time::timeout(QUEUE_COMMAND_TIMEOUT, next_task)
time::timeout(TRANSPORT_COMMAND_TIMEOUT, next_task)
.await
.map_err(|_| {
warn!(
"Next command for renderer {} exceeded {:?}",
renderer_id, QUEUE_COMMAND_TIMEOUT
renderer_id, TRANSPORT_COMMAND_TIMEOUT
);
(
StatusCode::GATEWAY_TIMEOUT,
Json(ErrorResponse {
error: format!(
"Next command timed out after {}s",
QUEUE_COMMAND_TIMEOUT.as_secs()
TRANSPORT_COMMAND_TIMEOUT.as_secs()
),
}),
)
@@ -765,26 +603,22 @@ async fn next_renderer(
})?
.map_err(|e| {
warn!(
"Failed to advance queue for renderer {}: {}",
"Failed to skip to next track for renderer {}: {}",
renderer_id, e
);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: format!("Failed to advance queue: {}", e),
error: format!("Failed to skip to next track: {}", e),
}),
)
})?;
Ok(Json(SuccessResponse {
message: "Advanced to next track".to_string(),
message: "Skipped to next track".to_string(),
}))
}
// ============================================================================
// HANDLERS - VOLUME
// ============================================================================
/// POST /control/renderers/{renderer_id}/volume/set - Définit le volume
#[cfg(feature = "pmoserver")]
#[utoipa::path(
@@ -1149,7 +983,7 @@ async fn attach_playlist_binding(
// Spawn blocking task and wait for completion with timeout
let attach_task = tokio::task::spawn_blocking(move || {
control_point.attach_queue_to_playlist(&rid, sid, container_id);
control_point.attach_queue_to_playlist_with_options(&rid, sid, container_id, req.auto_play)
});
time::timeout(ATTACH_PLAYLIST_TIMEOUT, attach_task)
@@ -1177,12 +1011,28 @@ async fn attach_playlist_binding(
error: format!("Internal task error: {}", e),
}),
)
})?
.map_err(|e| {
warn!(
renderer = renderer_id.as_str(),
server = req.server_id.as_str(),
container = req.container_id.as_str(),
error = %e,
"Failed to attach playlist"
);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: format!("Failed to attach playlist: {}", e),
}),
)
})?;
debug!(
renderer = renderer_id.as_str(),
server = req.server_id.as_str(),
container = req.container_id.as_str(),
auto_play = req.auto_play,
"Playlist attached via HTTP API"
);
@@ -1541,7 +1391,7 @@ async fn play_content(
let object_id_for_log = object_id.clone();
// Get renderer to verify it exists
let renderer = state
state
.control_point
.music_renderer_by_id(&rid)
.ok_or_else(|| {
@@ -1564,35 +1414,34 @@ async fn play_content(
return Err(anyhow::anyhow!("No playable content found"));
}
// Clear queue
control_point.clear_queue(&rid)?;
// Enqueue items
control_point.enqueue_items(&rid, items.clone())?;
// Start playback
// Pour les renderers OpenHome, play_current_from_queue() va gérer automatiquement
// la lecture depuis la playlist native si elle existe
control_point.play_current_from_queue(&rid)?;
// Auto-bind if playing a container (playlist/album)
// Rule: if multiple items, it's a container that should be bound
// We use _without_refresh because the queue was already populated by enqueue_items above
if items.len() > 1 {
debug!(
renderer = rid.0.as_str(),
server = sid.0.as_str(),
object = object_id.as_str(),
item_count = items.len(),
"Auto-binding playlist to renderer queue (without initial refresh)"
"Auto-binding playlist to renderer queue (auto_play = true)"
);
control_point.attach_queue_to_playlist_without_refresh(
control_point.attach_queue_to_playlist_with_options(
&rid,
sid.clone(),
object_id.clone(),
);
true,
)?;
return Ok(());
}
// Clear queue
control_point.clear_queue(&rid)?;
// Enqueue items
control_point.enqueue_items(&rid, items)?;
// Start playback
// Pour les renderers OpenHome, play_current_from_queue() va gérer automatiquement
// la lecture depuis la playlist native si elle existe
control_point.play_current_from_queue(&rid)?;
Ok::<(), anyhow::Error>(())
});
@@ -1910,6 +1759,24 @@ async fn browse_container(
// HELPERS
// ============================================================================
#[cfg(feature = "pmoserver")]
fn map_snapshot_error(
renderer_id: String,
err: anyhow::Error,
) -> (StatusCode, Json<ErrorResponse>) {
warn!(
renderer = renderer_id.as_str(),
error = %err,
"Failed to build renderer snapshot"
);
(
StatusCode::NOT_FOUND,
Json(ErrorResponse {
error: format!("Renderer {} not found", renderer_id),
}),
)
}
#[cfg(feature = "pmoserver")]
fn map_openhome_error(
renderer_id: &RendererId,
@@ -2063,21 +1930,6 @@ fn state_to_string(state: crate::PlaybackState) -> String {
}
}
#[cfg(feature = "pmoserver")]
fn parse_hms_to_ms(hms: Option<&str>) -> Option<u64> {
let hms = hms?;
let parts: Vec<&str> = hms.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)
}
// ============================================================================
// ROUTER & TRAIT
// ============================================================================
@@ -2089,6 +1941,10 @@ pub fn create_api_router(state: ControlPointState, control_point: Arc<ControlPoi
// Renderers
.route("/renderers", get(list_renderers))
.route("/renderers/{renderer_id}", get(get_renderer_state))
.route(
"/renderers/{renderer_id}/full",
get(get_renderer_full_snapshot),
)
.route("/renderers/{renderer_id}/queue", get(get_renderer_queue))
.route(
"/renderers/{renderer_id}/binding",

View File

@@ -9,6 +9,10 @@
//! - GET /api/control/events/renderers - Événements renderers uniquement
//! - GET /api/control/events/servers - Événements serveurs uniquement
//! - GET /api/control/events - Tous les événements (agrégés)
//!
//! ⚠️ Les payloads SSE servent uniquement de signaux de rafraîchissement :
//! l'UI doit toujours refetch l'instantané complet auprès du ControlPoint,
//! seule source de vérité de l'état renderer.
#[cfg(feature = "pmoserver")]
use crate::PlaybackState;