webui control point step 2
This commit is contained in:
@@ -25,6 +25,7 @@ axum = { version = "0.8.4", optional = true }
|
||||
serde = { version = "1.0", features = ["derive"], optional = true }
|
||||
serde_json = { version = "1.0", optional = true }
|
||||
tokio = { version = "1", features = ["sync", "rt"], optional = true }
|
||||
tokio-util = { version = "0.7", optional = true }
|
||||
async-trait = { version = "0.1", optional = true }
|
||||
tokio-stream = { version = "0.1", features = ["sync"], optional = true }
|
||||
async-stream = { version = "0.3", optional = true }
|
||||
@@ -38,4 +39,4 @@ percent-encoding = "2.3"
|
||||
[features]
|
||||
default = []
|
||||
# Active l'API REST pmoserver
|
||||
pmoserver = ["dep:pmoserver", "dep:utoipa", "dep:axum", "dep:serde", "dep:serde_json", "dep:tokio", "dep:async-trait", "dep:tokio-stream", "dep:async-stream", "dep:chrono"]
|
||||
pmoserver = ["dep:pmoserver", "dep:utoipa", "dep:axum", "dep:serde", "dep:serde_json", "dep:tokio", "dep:tokio-util", "dep:async-trait", "dep:tokio-stream", "dep:async-stream", "dep:chrono"]
|
||||
|
||||
@@ -35,18 +35,18 @@ use crate::upnp_renderer::UpnpRenderer;
|
||||
/// 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)]
|
||||
struct PlaylistBinding {
|
||||
pub struct PlaylistBinding {
|
||||
/// MediaServer that owns the playlist container.
|
||||
server_id: ServerId,
|
||||
pub server_id: ServerId,
|
||||
/// DIDL-Lite object id of the playlist container.
|
||||
container_id: String,
|
||||
pub container_id: String,
|
||||
/// True once at least one ContainerUpdateIDs notification has been seen.
|
||||
has_seen_update: bool,
|
||||
pub(crate) has_seen_update: bool,
|
||||
/// Flag used internally to signal that the queue should be refreshed
|
||||
/// from the server container.
|
||||
pending_refresh: bool,
|
||||
pub(crate) pending_refresh: bool,
|
||||
/// Whether the next refresh should auto-start playback if the renderer is idle.
|
||||
auto_play_on_refresh: bool,
|
||||
pub(crate) auto_play_on_refresh: bool,
|
||||
}
|
||||
|
||||
/// Control point minimal :
|
||||
@@ -177,18 +177,33 @@ impl ControlPoint {
|
||||
}
|
||||
|
||||
// Extract and emit metadata changes
|
||||
if let Some(metadata) = extract_track_metadata(&position) {
|
||||
let metadata_changed = match prev_snapshot.last_metadata.as_ref() {
|
||||
Some(prev) => prev != &metadata,
|
||||
None => true,
|
||||
};
|
||||
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 {
|
||||
runtime_cp.emit_renderer_event(RendererEvent::MetadataChanged {
|
||||
id: renderer_id.clone(),
|
||||
metadata: metadata.clone(),
|
||||
});
|
||||
new_snapshot.last_metadata = Some(metadata);
|
||||
if metadata_changed {
|
||||
debug!(
|
||||
renderer = renderer_id.0.as_str(),
|
||||
title = metadata.title.as_deref(),
|
||||
artist = metadata.artist.as_deref(),
|
||||
"Emitting metadata changed event"
|
||||
);
|
||||
runtime_cp.emit_renderer_event(RendererEvent::MetadataChanged {
|
||||
id: renderer_id.clone(),
|
||||
metadata: metadata.clone(),
|
||||
});
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -596,12 +611,13 @@ impl ControlPoint {
|
||||
renderer_id: &RendererId,
|
||||
) -> anyhow::Result<(Vec<PlaybackItem>, Option<usize>)> {
|
||||
if !self.runtime.has_entry(renderer_id) {
|
||||
let err = Self::runtime_entry_missing(renderer_id);
|
||||
warn!(
|
||||
// Renderer not yet initialized in runtime (just discovered via SSDP)
|
||||
// This is normal and will be fixed on first polling cycle
|
||||
debug!(
|
||||
renderer = renderer_id.0.as_str(),
|
||||
"Cannot snapshot full queue: renderer not registered in runtime"
|
||||
"Renderer not yet initialized in runtime, returning empty queue"
|
||||
);
|
||||
return Err(err);
|
||||
return Err(Self::runtime_entry_missing(renderer_id));
|
||||
}
|
||||
|
||||
self.runtime
|
||||
@@ -609,6 +625,84 @@ impl ControlPoint {
|
||||
.ok_or_else(|| Self::runtime_entry_missing(renderer_id))
|
||||
}
|
||||
|
||||
/// Play the current item from the queue without advancing the index.
|
||||
///
|
||||
/// This is useful after a Stop operation to resume playback from the current
|
||||
/// position rather than skipping to the next track.
|
||||
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);
|
||||
}
|
||||
|
||||
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)
|
||||
})?;
|
||||
|
||||
if matches!(renderer.info().protocol, RendererProtocol::OpenHomeOnly) {
|
||||
self.runtime
|
||||
.set_playback_source(renderer_id, PlaybackSource::None);
|
||||
return Err(op_not_supported(
|
||||
"play_current_from_queue",
|
||||
"OpenHomeOnly renderer",
|
||||
));
|
||||
}
|
||||
|
||||
let playback = (|| -> anyhow::Result<()> {
|
||||
let didl_metadata = item.to_didl_metadata();
|
||||
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)"
|
||||
);
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -659,7 +753,8 @@ impl ControlPoint {
|
||||
}
|
||||
|
||||
let playback = (|| -> anyhow::Result<()> {
|
||||
renderer.play_uri(&item.uri, "")?;
|
||||
let didl_metadata = item.to_didl_metadata();
|
||||
renderer.play_uri(&item.uri, &didl_metadata)?;
|
||||
Ok(())
|
||||
})();
|
||||
|
||||
@@ -697,7 +792,8 @@ impl ControlPoint {
|
||||
if let Some(upnp) = renderer.as_upnp() {
|
||||
let known_supported = upnp.supports_set_next();
|
||||
if known_supported || upnp.has_avtransport() {
|
||||
match upnp.set_next_uri(&next_item.uri, "") {
|
||||
let next_didl_metadata = next_item.to_didl_metadata();
|
||||
match upnp.set_next_uri(&next_item.uri, &next_didl_metadata) {
|
||||
Ok(_) => debug!(
|
||||
renderer = renderer_id.0.as_str(),
|
||||
"Prefetched next track via SetNextAVTransportURI"
|
||||
@@ -745,6 +841,32 @@ impl ControlPoint {
|
||||
self.play_next_from_queue(renderer_id)
|
||||
}
|
||||
|
||||
/// 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)
|
||||
})?;
|
||||
|
||||
debug!(
|
||||
renderer = renderer_id.0.as_str(),
|
||||
"User-requested stop"
|
||||
);
|
||||
|
||||
renderer.stop()
|
||||
}
|
||||
|
||||
/// Subscribe to renderer events emitted by the control point runtime.
|
||||
///
|
||||
/// Each subscriber receives all future events independently.
|
||||
@@ -768,48 +890,83 @@ impl ControlPoint {
|
||||
/// container whenever the server notifies us of changes via ContentDirectory
|
||||
/// events. The binding is broken if the user explicitly mutates the queue
|
||||
/// through methods like `clear_queue` or `enqueue_items`.
|
||||
/// Attach a renderer's queue to a playlist container.
|
||||
///
|
||||
/// The queue will be automatically refreshed when the playlist changes on the server.
|
||||
pub fn attach_queue_to_playlist(
|
||||
&self,
|
||||
renderer_id: &RendererId,
|
||||
server_id: ServerId,
|
||||
container_id: String,
|
||||
) {
|
||||
self.attach_queue_to_playlist_internal(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(
|
||||
&self,
|
||||
renderer_id: &RendererId,
|
||||
server_id: ServerId,
|
||||
container_id: String,
|
||||
) {
|
||||
self.attach_queue_to_playlist_internal(renderer_id, server_id, container_id, true);
|
||||
}
|
||||
|
||||
/// Internal implementation with optional skip of initial refresh
|
||||
fn attach_queue_to_playlist_internal(
|
||||
&self,
|
||||
renderer_id: &RendererId,
|
||||
server_id: ServerId,
|
||||
container_id: String,
|
||||
skip_initial_refresh: bool,
|
||||
) {
|
||||
let binding = PlaylistBinding {
|
||||
server_id: server_id.clone(),
|
||||
container_id: container_id.clone(),
|
||||
has_seen_update: false,
|
||||
pending_refresh: !skip_initial_refresh,
|
||||
auto_play_on_refresh: !skip_initial_refresh,
|
||||
};
|
||||
|
||||
{
|
||||
let mut bindings = self.playlist_bindings.lock().unwrap();
|
||||
bindings.insert(
|
||||
renderer_id.clone(),
|
||||
PlaylistBinding {
|
||||
server_id: server_id.clone(),
|
||||
container_id: container_id.clone(),
|
||||
has_seen_update: false,
|
||||
pending_refresh: true,
|
||||
auto_play_on_refresh: true,
|
||||
},
|
||||
);
|
||||
bindings.insert(renderer_id.clone(), binding.clone());
|
||||
info!(
|
||||
renderer = renderer_id.0.as_str(),
|
||||
server = server_id.0.as_str(),
|
||||
container = container_id.as_str(),
|
||||
skip_refresh = skip_initial_refresh,
|
||||
"Queue attached to playlist container"
|
||||
);
|
||||
} // Drop bindings lock here before calling refresh_attached_queue_for
|
||||
|
||||
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"
|
||||
);
|
||||
// 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -818,14 +975,23 @@ 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 mut bindings = self.playlist_bindings.lock().unwrap();
|
||||
if let Some(binding) = bindings.remove(renderer_id) {
|
||||
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(),
|
||||
@@ -878,7 +1044,14 @@ impl ControlPoint {
|
||||
if let RendererEvent::StateChanged { id, state } = event {
|
||||
match state {
|
||||
PlaybackState::Stopped => {
|
||||
if self.runtime.is_playing_from_queue(id) {
|
||||
// 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) {
|
||||
debug!(
|
||||
renderer = id.0.as_str(),
|
||||
"Renderer stopped after queue-driven playback; advancing"
|
||||
@@ -933,6 +1106,7 @@ struct RendererRuntimeEntry {
|
||||
snapshot: RendererRuntimeSnapshot,
|
||||
queue: PlaybackQueue,
|
||||
playback_source: PlaybackSource,
|
||||
user_stop_requested: bool,
|
||||
}
|
||||
|
||||
struct RuntimeState {
|
||||
@@ -991,6 +1165,14 @@ impl RuntimeState {
|
||||
Some((item, remaining))
|
||||
}
|
||||
|
||||
fn peek_current(&self, id: &RendererId) -> Option<(PlaybackItem, usize)> {
|
||||
let entries = self.entries.lock().unwrap();
|
||||
let entry = entries.get(id)?;
|
||||
let item = entry.queue.peek()?.clone();
|
||||
let remaining = entry.queue.upcoming_len();
|
||||
Some((item, remaining))
|
||||
}
|
||||
|
||||
fn set_playback_source(&self, id: &RendererId, source: PlaybackSource) {
|
||||
let mut entries = self.entries.lock().unwrap();
|
||||
if let Some(entry) = entries.get_mut(id) {
|
||||
@@ -1029,6 +1211,24 @@ impl RuntimeState {
|
||||
.or_insert_with(RendererRuntimeEntry::default);
|
||||
f(entry)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal helper to refresh a renderer's playback queue from its bound
|
||||
@@ -1402,7 +1602,13 @@ fn playback_position_equal(a: &PlaybackPositionInfo, b: &PlaybackPositionInfo) -
|
||||
|
||||
/// Extract TrackMetadata from DIDL-Lite XML in PlaybackPositionInfo.
|
||||
fn extract_track_metadata(position: &PlaybackPositionInfo) -> Option<TrackMetadata> {
|
||||
let didl_xml = position.track_metadata.as_ref()?;
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
// Parse DIDL-Lite XML
|
||||
let didl = match pmodidl::parse_metadata::<pmodidl::DIDLLite>(didl_xml) {
|
||||
@@ -1414,7 +1620,13 @@ fn extract_track_metadata(position: &PlaybackPositionInfo) -> Option<TrackMetada
|
||||
};
|
||||
|
||||
// Extract first item metadata
|
||||
let item = didl.items.first()?;
|
||||
let item = match didl.items.first() {
|
||||
Some(item) => item,
|
||||
None => {
|
||||
debug!("DIDL-Lite has no items");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
Some(TrackMetadata {
|
||||
title: Some(item.title.clone()),
|
||||
|
||||
@@ -36,7 +36,7 @@ pub use capabilities::{
|
||||
VolumeControl,
|
||||
};
|
||||
pub use connection_manager_client::{ConnectionInfo, ConnectionManagerClient, ProtocolInfo};
|
||||
pub use control_point::ControlPoint;
|
||||
pub use control_point::{ControlPoint, PlaylistBinding};
|
||||
pub use linkplay::LinkPlayRenderer;
|
||||
pub use media_server::{
|
||||
MediaBrowser, MediaEntry, MediaResource, MediaServerInfo, MusicServer, ServerId,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::capabilities::{PlaybackPositionInfo, PlaybackState};
|
||||
use crate::control_point::PlaylistBinding;
|
||||
use crate::media_server::ServerId;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
@@ -99,6 +100,10 @@ pub enum RendererEvent {
|
||||
id: RendererId,
|
||||
queue_length: usize,
|
||||
},
|
||||
BindingChanged {
|
||||
id: RendererId,
|
||||
binding: Option<PlaylistBinding>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
|
||||
@@ -172,6 +172,16 @@ pub struct AttachPlaylistRequest {
|
||||
pub container_id: String,
|
||||
}
|
||||
|
||||
/// Requête pour lire ou ajouter du contenu à la queue
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[derive(Debug, Clone, Deserialize, ToSchema)]
|
||||
pub struct PlayContentRequest {
|
||||
/// ID du serveur de médias
|
||||
pub server_id: String,
|
||||
/// ID de l'objet (container ou item)
|
||||
pub object_id: String,
|
||||
}
|
||||
|
||||
/// Réponse générique de succès
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
@@ -280,6 +290,8 @@ GET /control/servers/{server_id}/containers/{container_id}
|
||||
crate::pmoserver_ext::toggle_mute_renderer,
|
||||
crate::pmoserver_ext::attach_playlist_binding,
|
||||
crate::pmoserver_ext::detach_playlist_binding,
|
||||
crate::pmoserver_ext::play_content,
|
||||
crate::pmoserver_ext::add_to_queue,
|
||||
crate::pmoserver_ext::list_servers,
|
||||
crate::pmoserver_ext::browse_container,
|
||||
crate::sse::all_events_sse,
|
||||
@@ -297,6 +309,7 @@ GET /control/servers/{server_id}/containers/{container_id}
|
||||
BrowseResponse,
|
||||
VolumeSetRequest,
|
||||
AttachPlaylistRequest,
|
||||
PlayContentRequest,
|
||||
SuccessResponse,
|
||||
ErrorResponse,
|
||||
)),
|
||||
|
||||
@@ -31,6 +31,67 @@ impl PlaybackItem {
|
||||
creator: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert PlaybackItem to DIDL-Lite XML metadata for SetAVTransportURI
|
||||
pub fn to_didl_metadata(&self) -> String {
|
||||
use quick_xml::escape::escape;
|
||||
|
||||
let title = self.title.as_deref().unwrap_or("Unknown");
|
||||
let escaped_uri = escape(&self.uri);
|
||||
let escaped_title = escape(title);
|
||||
|
||||
let mut didl = String::from(
|
||||
r#"<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/">"#,
|
||||
);
|
||||
didl.push_str(r#"<item id="0" parentID="-1" restricted="1">"#);
|
||||
didl.push_str(&format!("<dc:title>{}</dc:title>", escaped_title));
|
||||
|
||||
if let Some(artist) = &self.artist {
|
||||
let escaped_artist = escape(artist);
|
||||
didl.push_str(&format!("<upnp:artist>{}</upnp:artist>", escaped_artist));
|
||||
didl.push_str(&format!("<dc:creator>{}</dc:creator>", escaped_artist));
|
||||
}
|
||||
|
||||
if let Some(album) = &self.album {
|
||||
let escaped_album = escape(album);
|
||||
didl.push_str(&format!("<upnp:album>{}</upnp:album>", escaped_album));
|
||||
}
|
||||
|
||||
if let Some(genre) = &self.genre {
|
||||
let escaped_genre = escape(genre);
|
||||
didl.push_str(&format!("<upnp:genre>{}</upnp:genre>", escaped_genre));
|
||||
}
|
||||
|
||||
if let Some(album_art) = &self.album_art_uri {
|
||||
let escaped_art = escape(album_art);
|
||||
didl.push_str(&format!("<upnp:albumArtURI>{}</upnp:albumArtURI>", escaped_art));
|
||||
}
|
||||
|
||||
if let Some(date) = &self.date {
|
||||
let escaped_date = escape(date);
|
||||
didl.push_str(&format!("<dc:date>{}</dc:date>", escaped_date));
|
||||
}
|
||||
|
||||
if let Some(track_num) = &self.track_number {
|
||||
let escaped_track = escape(track_num);
|
||||
didl.push_str(&format!(
|
||||
"<upnp:originalTrackNumber>{}</upnp:originalTrackNumber>",
|
||||
escaped_track
|
||||
));
|
||||
}
|
||||
|
||||
// Add resource with URI
|
||||
didl.push_str(&format!(
|
||||
r#"<res protocolInfo="http-get:*:audio/*:*">{}</res>"#,
|
||||
escaped_uri
|
||||
));
|
||||
|
||||
didl.push_str(r#"<upnp:class>object.item.audioItem.musicTrack</upnp:class>"#);
|
||||
didl.push_str("</item>");
|
||||
didl.push_str("</DIDL-Lite>");
|
||||
|
||||
didl
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
|
||||
@@ -6,14 +6,16 @@
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use crate::control_point::ControlPoint;
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use crate::media_server::{MediaBrowser, MusicServer, ServerId};
|
||||
use crate::media_server::{MediaBrowser, MediaEntry, MediaResource, MusicServer, ServerId};
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use crate::model::{RendererId, RendererProtocol};
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use crate::playback_queue::PlaybackItem;
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use crate::openapi::{
|
||||
AttachPlaylistRequest, AttachedPlaylistInfo, BrowseResponse, ContainerEntry, ErrorResponse,
|
||||
MediaServerSummary, QueueItem, QueueSnapshot, RendererState, RendererSummary, SuccessResponse,
|
||||
VolumeSetRequest,
|
||||
MediaServerSummary, PlayContentRequest, QueueItem, QueueSnapshot, RendererState,
|
||||
RendererSummary, SuccessResponse, VolumeSetRequest,
|
||||
};
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use crate::{PlaybackPosition, PlaybackStatus, TransportControl, VolumeControl};
|
||||
@@ -260,18 +262,31 @@ async fn get_renderer_queue(
|
||||
) -> Result<Json<QueueSnapshot>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let rid = RendererId(renderer_id.clone());
|
||||
|
||||
let (items, current_index) =
|
||||
state
|
||||
.control_point
|
||||
.get_full_queue_snapshot(&rid)
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(ErrorResponse {
|
||||
error: format!("Failed to get queue: {}", e),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
// Verify renderer exists in registry
|
||||
let _renderer = state
|
||||
.control_point
|
||||
.music_renderer_by_id(&rid)
|
||||
.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(ErrorResponse {
|
||||
error: format!("Renderer {} not found", renderer_id),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
// 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()
|
||||
@@ -507,7 +522,8 @@ async fn stop_renderer(
|
||||
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let rid = RendererId(renderer_id.clone());
|
||||
|
||||
let renderer = state
|
||||
// Verify renderer exists
|
||||
let _renderer = state
|
||||
.control_point
|
||||
.music_renderer_by_id(&rid)
|
||||
.ok_or_else(|| {
|
||||
@@ -519,8 +535,9 @@ async fn stop_renderer(
|
||||
)
|
||||
})?;
|
||||
|
||||
let renderer_clone = renderer.clone();
|
||||
let stop_task = tokio::task::spawn_blocking(move || renderer_clone.stop());
|
||||
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));
|
||||
|
||||
time::timeout(TRANSPORT_COMMAND_TIMEOUT, stop_task)
|
||||
.await
|
||||
@@ -563,6 +580,77 @@ async fn stop_renderer(
|
||||
}))
|
||||
}
|
||||
|
||||
/// POST /control/renderers/{renderer_id}/resume - Reprend la lecture depuis le morceau actuel
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/renderers/{renderer_id}/resume",
|
||||
params(
|
||||
("renderer_id" = String, Path, description = "ID unique du renderer")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Lecture reprise", body = SuccessResponse),
|
||||
(status = 404, description = "Renderer non trouvé", body = ErrorResponse),
|
||||
(status = 500, description = "Erreur lors de l'exécution", body = ErrorResponse)
|
||||
),
|
||||
tag = "control"
|
||||
)]
|
||||
async fn resume_renderer(
|
||||
State(state): State<ControlPointState>,
|
||||
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();
|
||||
|
||||
let resume_task = tokio::task::spawn_blocking(move || {
|
||||
control_point.play_current_from_queue(&rid_clone)
|
||||
});
|
||||
|
||||
time::timeout(QUEUE_COMMAND_TIMEOUT, resume_task)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
warn!(
|
||||
"Resume command for renderer {} exceeded {:?}",
|
||||
renderer_id, QUEUE_COMMAND_TIMEOUT
|
||||
);
|
||||
(
|
||||
StatusCode::GATEWAY_TIMEOUT,
|
||||
Json(ErrorResponse {
|
||||
error: format!(
|
||||
"Resume command timed out after {}s",
|
||||
QUEUE_COMMAND_TIMEOUT.as_secs()
|
||||
),
|
||||
}),
|
||||
)
|
||||
})?
|
||||
.map_err(|e| {
|
||||
warn!("Task join error during resume: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: format!("Internal task error: {}", e),
|
||||
}),
|
||||
)
|
||||
})?
|
||||
.map_err(|e| {
|
||||
warn!(
|
||||
"Failed to resume renderer {}: {}",
|
||||
renderer_id, e
|
||||
);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: format!("Failed to resume: {}", e),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Json(SuccessResponse {
|
||||
message: "Playback resumed from current track".to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// POST /control/renderers/{renderer_id}/next - Passe au morceau suivant de la queue
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[utoipa::path(
|
||||
@@ -1077,6 +1165,238 @@ async fn detach_playlist_binding(
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HANDLERS - QUEUE CONTENT
|
||||
// ============================================================================
|
||||
|
||||
/// POST /control/renderers/{renderer_id}/queue/play - Lire du contenu immédiatement
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/renderers/{renderer_id}/queue/play",
|
||||
params(
|
||||
("renderer_id" = String, Path, description = "ID unique du renderer")
|
||||
),
|
||||
request_body = PlayContentRequest,
|
||||
responses(
|
||||
(status = 200, description = "Contenu en cours de lecture", body = SuccessResponse),
|
||||
(status = 404, description = "Renderer ou serveur non trouvé", body = ErrorResponse),
|
||||
(status = 500, description = "Erreur lors de l'exécution", body = ErrorResponse)
|
||||
),
|
||||
tag = "control"
|
||||
)]
|
||||
async fn play_content(
|
||||
State(state): State<ControlPointState>,
|
||||
Path(renderer_id): Path<String>,
|
||||
Json(req): Json<PlayContentRequest>,
|
||||
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let rid = RendererId(renderer_id.clone());
|
||||
let sid = ServerId(req.server_id.clone());
|
||||
let object_id = req.object_id.clone();
|
||||
let object_id_for_log = object_id.clone();
|
||||
|
||||
// Get renderer to verify it exists
|
||||
let renderer = 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);
|
||||
|
||||
// Spawn blocking task for content loading
|
||||
let play_task = tokio::task::spawn_blocking(move || {
|
||||
// Fetch playback items from server
|
||||
let items = fetch_playback_items(&control_point, &sid, &object_id)?;
|
||||
|
||||
if items.is_empty() {
|
||||
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
|
||||
renderer.play()?;
|
||||
|
||||
// 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)"
|
||||
);
|
||||
control_point.attach_queue_to_playlist_without_refresh(&rid, sid.clone(), object_id.clone());
|
||||
}
|
||||
|
||||
Ok::<(), anyhow::Error>(())
|
||||
});
|
||||
|
||||
time::timeout(QUEUE_COMMAND_TIMEOUT, play_task)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
warn!(
|
||||
"Play content command for renderer {} exceeded {:?}",
|
||||
renderer_id, QUEUE_COMMAND_TIMEOUT
|
||||
);
|
||||
(
|
||||
StatusCode::GATEWAY_TIMEOUT,
|
||||
Json(ErrorResponse {
|
||||
error: format!(
|
||||
"Play content timed out after {}s",
|
||||
QUEUE_COMMAND_TIMEOUT.as_secs()
|
||||
),
|
||||
}),
|
||||
)
|
||||
})?
|
||||
.map_err(|e| {
|
||||
warn!("Task join error during play content: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: format!("Internal task error: {}", e),
|
||||
}),
|
||||
)
|
||||
})?
|
||||
.map_err(|e| {
|
||||
warn!("Failed to play content on renderer {}: {}", renderer_id, e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: format!("Failed to play content: {}", e),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
debug!(
|
||||
renderer = renderer_id.as_str(),
|
||||
server = req.server_id.as_str(),
|
||||
object = object_id_for_log.as_str(),
|
||||
"Content playing via HTTP API"
|
||||
);
|
||||
|
||||
Ok(Json(SuccessResponse {
|
||||
message: "Content playing".to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// POST /control/renderers/{renderer_id}/queue/add - Ajouter du contenu à la queue
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/renderers/{renderer_id}/queue/add",
|
||||
params(
|
||||
("renderer_id" = String, Path, description = "ID unique du renderer")
|
||||
),
|
||||
request_body = PlayContentRequest,
|
||||
responses(
|
||||
(status = 200, description = "Contenu ajouté à la queue", body = SuccessResponse),
|
||||
(status = 404, description = "Renderer ou serveur non trouvé", body = ErrorResponse),
|
||||
(status = 500, description = "Erreur lors de l'exécution", body = ErrorResponse)
|
||||
),
|
||||
tag = "control"
|
||||
)]
|
||||
async fn add_to_queue(
|
||||
State(state): State<ControlPointState>,
|
||||
Path(renderer_id): Path<String>,
|
||||
Json(req): Json<PlayContentRequest>,
|
||||
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let rid = RendererId(renderer_id.clone());
|
||||
let sid = ServerId(req.server_id.clone());
|
||||
let object_id = req.object_id.clone();
|
||||
let object_id_for_log = object_id.clone();
|
||||
|
||||
// Verify renderer exists
|
||||
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);
|
||||
|
||||
// Spawn blocking task for content loading
|
||||
let add_task = tokio::task::spawn_blocking(move || {
|
||||
// Fetch playback items from server
|
||||
let items = fetch_playback_items(&control_point, &sid, &object_id)?;
|
||||
|
||||
if items.is_empty() {
|
||||
return Err(anyhow::anyhow!("No playable content found"));
|
||||
}
|
||||
|
||||
// Enqueue items
|
||||
control_point.enqueue_items(&rid, items)?;
|
||||
|
||||
Ok::<(), anyhow::Error>(())
|
||||
});
|
||||
|
||||
time::timeout(QUEUE_COMMAND_TIMEOUT, add_task)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
warn!(
|
||||
"Add to queue command for renderer {} exceeded {:?}",
|
||||
renderer_id, QUEUE_COMMAND_TIMEOUT
|
||||
);
|
||||
(
|
||||
StatusCode::GATEWAY_TIMEOUT,
|
||||
Json(ErrorResponse {
|
||||
error: format!(
|
||||
"Add to queue timed out after {}s",
|
||||
QUEUE_COMMAND_TIMEOUT.as_secs()
|
||||
),
|
||||
}),
|
||||
)
|
||||
})?
|
||||
.map_err(|e| {
|
||||
warn!("Task join error during add to queue: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: format!("Internal task error: {}", e),
|
||||
}),
|
||||
)
|
||||
})?
|
||||
.map_err(|e| {
|
||||
warn!("Failed to add content to queue for renderer {}: {}", renderer_id, e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: format!("Failed to add to queue: {}", e),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
debug!(
|
||||
renderer = renderer_id.as_str(),
|
||||
server = req.server_id.as_str(),
|
||||
object = object_id_for_log.as_str(),
|
||||
"Content added to queue via HTTP API"
|
||||
);
|
||||
|
||||
Ok(Json(SuccessResponse {
|
||||
message: "Content added to queue".to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HANDLERS - MEDIA SERVERS
|
||||
// ============================================================================
|
||||
@@ -1236,6 +1556,93 @@ async fn browse_container(
|
||||
// HELPERS
|
||||
// ============================================================================
|
||||
|
||||
/// Helper to fetch playback items from a media server object (container or item).
|
||||
///
|
||||
/// This function browses the server to get the entries and converts them to PlaybackItem.
|
||||
/// For containers, it browses children. For items, it browses metadata.
|
||||
#[cfg(feature = "pmoserver")]
|
||||
fn fetch_playback_items(
|
||||
control_point: &ControlPoint,
|
||||
server_id: &ServerId,
|
||||
object_id: &str,
|
||||
) -> anyhow::Result<Vec<PlaybackItem>> {
|
||||
// Get server info from registry
|
||||
let server_info = control_point
|
||||
.media_server(server_id)
|
||||
.ok_or_else(|| anyhow::anyhow!("Server {} not found", server_id.0))?;
|
||||
|
||||
if !server_info.online {
|
||||
return Err(anyhow::anyhow!("Server {} is offline", server_id.0));
|
||||
}
|
||||
|
||||
if !server_info.has_content_directory {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Server {} does not support ContentDirectory",
|
||||
server_id.0
|
||||
));
|
||||
}
|
||||
|
||||
// Create MusicServer
|
||||
let music_server = MusicServer::from_info(&server_info, MEDIA_SERVER_SOAP_TIMEOUT)?;
|
||||
|
||||
// Browse the object to get entries
|
||||
let entries = music_server.browse_children(object_id, 0, BROWSE_PAGE_SIZE)?;
|
||||
|
||||
// Convert to PlaybackItem
|
||||
let items: Vec<PlaybackItem> = entries
|
||||
.iter()
|
||||
.filter_map(|entry| playback_item_from_entry(&music_server, entry))
|
||||
.collect();
|
||||
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
/// Helper to convert a MediaEntry to a PlaybackItem.
|
||||
#[cfg(feature = "pmoserver")]
|
||||
fn playback_item_from_entry(server: &MusicServer, entry: &MediaEntry) -> Option<PlaybackItem> {
|
||||
// Ignore containers
|
||||
if entry.is_container {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Skip "live stream" entries
|
||||
if entry.title.to_ascii_lowercase().contains("live stream") {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Find an audio resource
|
||||
let resource = entry.resources.iter().find(|res| is_audio_resource(res))?;
|
||||
|
||||
let mut item = PlaybackItem::new(resource.uri.clone());
|
||||
item.title = Some(entry.title.clone());
|
||||
item.server_id = Some(server.id().clone());
|
||||
item.object_id = Some(entry.id.clone());
|
||||
item.artist = entry.artist.clone();
|
||||
item.album = entry.album.clone();
|
||||
item.genre = entry.genre.clone();
|
||||
item.album_art_uri = entry.album_art_uri.clone();
|
||||
item.date = entry.date.clone();
|
||||
item.track_number = entry.track_number.clone();
|
||||
item.creator = entry.creator.clone();
|
||||
|
||||
Some(item)
|
||||
}
|
||||
|
||||
/// Helper to detect if a MediaResource is audio content.
|
||||
#[cfg(feature = "pmoserver")]
|
||||
fn is_audio_resource(res: &MediaResource) -> bool {
|
||||
let lower = res.protocol_info.to_ascii_lowercase();
|
||||
if lower.contains("audio/") {
|
||||
return true;
|
||||
}
|
||||
// Check MIME type in protocolInfo (format: protocol:network:contentFormat:additionalInfo)
|
||||
lower
|
||||
.split(':')
|
||||
.nth(2)
|
||||
.map(|mime| mime.starts_with("audio/"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(feature = "pmoserver")]
|
||||
fn protocol_to_string(protocol: &RendererProtocol) -> String {
|
||||
match protocol {
|
||||
@@ -1293,6 +1700,7 @@ pub fn create_api_router(state: ControlPointState, control_point: Arc<ControlPoi
|
||||
.route("/renderers/{renderer_id}/play", post(play_renderer))
|
||||
.route("/renderers/{renderer_id}/pause", post(pause_renderer))
|
||||
.route("/renderers/{renderer_id}/stop", post(stop_renderer))
|
||||
.route("/renderers/{renderer_id}/resume", post(resume_renderer))
|
||||
.route("/renderers/{renderer_id}/next", post(next_renderer))
|
||||
// Volume control
|
||||
.route(
|
||||
@@ -1320,6 +1728,15 @@ pub fn create_api_router(state: ControlPointState, control_point: Arc<ControlPoi
|
||||
"/renderers/{renderer_id}/binding/detach",
|
||||
post(detach_playlist_binding),
|
||||
)
|
||||
// Queue content
|
||||
.route(
|
||||
"/renderers/{renderer_id}/queue/play",
|
||||
post(play_content),
|
||||
)
|
||||
.route(
|
||||
"/renderers/{renderer_id}/queue/add",
|
||||
post(add_to_queue),
|
||||
)
|
||||
// Servers
|
||||
.route("/servers", get(list_servers))
|
||||
.route(
|
||||
|
||||
@@ -74,6 +74,12 @@ pub enum RendererEventPayload {
|
||||
queue_length: usize,
|
||||
timestamp: chrono::DateTime<chrono::Utc>,
|
||||
},
|
||||
BindingChanged {
|
||||
renderer_id: String,
|
||||
server_id: Option<String>,
|
||||
container_id: Option<String>,
|
||||
timestamp: chrono::DateTime<chrono::Utc>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Payload SSE pour un événement serveur de médias
|
||||
@@ -189,6 +195,14 @@ pub async fn renderer_events_sse(
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
RendererEvent::BindingChanged { id, binding } => {
|
||||
RendererEventPayload::BindingChanged {
|
||||
renderer_id: id.0,
|
||||
server_id: binding.as_ref().map(|b| b.server_id.0.clone()),
|
||||
container_id: binding.as_ref().map(|b| b.container_id.clone()),
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if let Ok(json) = serde_json::to_string(&payload) {
|
||||
@@ -355,6 +369,14 @@ pub async fn all_events_sse(State(control_point): State<Arc<ControlPoint>>) -> i
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
RendererEvent::BindingChanged { id, binding } => {
|
||||
RendererEventPayload::BindingChanged {
|
||||
renderer_id: id.0,
|
||||
server_id: binding.as_ref().map(|b| b.server_id.0.clone()),
|
||||
container_id: binding.as_ref().map(|b| b.container_id.clone()),
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let payload = UnifiedEventPayload::Renderer(renderer_payload);
|
||||
|
||||
Reference in New Issue
Block a user