Implémentation de l'API REST De PMOControl.
This commit is contained in:
@@ -14,3 +14,22 @@ tracing-subscriber = "0.3"
|
||||
anyhow = "1.0"
|
||||
xmltree = "0.11.0"
|
||||
crossbeam-channel = "0.5"
|
||||
ratatui = { version = "0.26", default-features = false, features = ["crossterm"] }
|
||||
crossterm = "0.27"
|
||||
|
||||
# pmoserver extension support (optional)
|
||||
pmoserver = { path = "../pmoserver", optional = true }
|
||||
utoipa = { version = "5.4.0", optional = true }
|
||||
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 }
|
||||
async-trait = { version = "0.1", optional = true }
|
||||
tokio-stream = { version = "0.1", features = ["sync"], optional = true }
|
||||
async-stream = { version = "0.3", optional = true }
|
||||
chrono = { version = "0.4", features = ["serde"], optional = true }
|
||||
|
||||
[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"]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
//! Live PMOMusic demo: binds a renderer queue to a dynamic "Live Playlist" container
|
||||
//! and monitors ContentDirectory updates over an extended period (~30 minutes).
|
||||
|
||||
use std::collections::{VecDeque, HashSet};
|
||||
use std::collections::{HashSet, VecDeque};
|
||||
use std::env;
|
||||
use std::process;
|
||||
use std::thread;
|
||||
@@ -77,9 +77,7 @@ fn main() -> Result<()> {
|
||||
.unwrap_or(false);
|
||||
println!(
|
||||
"Renderer \"{}\": AVTransport present = {}, SetNextAVTransportURI supported = {}",
|
||||
renderer.friendly_name,
|
||||
renderer.capabilities.has_avtransport,
|
||||
supports_set_next
|
||||
renderer.friendly_name, renderer.capabilities.has_avtransport, supports_set_next
|
||||
);
|
||||
|
||||
let timeout = Duration::from_secs(config.timeout_secs);
|
||||
@@ -93,7 +91,10 @@ fn main() -> Result<()> {
|
||||
let live_playlist_container = match live_playlist_container {
|
||||
Some(container) => container,
|
||||
None => {
|
||||
println!("No Live Playlist container found on server \"{}\". Exiting.", server_info.friendly_name);
|
||||
println!(
|
||||
"No Live Playlist container found on server \"{}\". Exiting.",
|
||||
server_info.friendly_name
|
||||
);
|
||||
process::exit(1);
|
||||
}
|
||||
};
|
||||
@@ -112,7 +113,10 @@ fn main() -> Result<()> {
|
||||
.context("Failed to collect playable items from Live Playlist container")?;
|
||||
|
||||
if playback_items.is_empty() {
|
||||
println!("Live Playlist container '{}' contains no playable tracks.", live_playlist_container.title);
|
||||
println!(
|
||||
"Live Playlist container '{}' contains no playable tracks.",
|
||||
live_playlist_container.title
|
||||
);
|
||||
process::exit(1);
|
||||
}
|
||||
|
||||
@@ -204,13 +208,16 @@ fn main() -> Result<()> {
|
||||
bound_container
|
||||
);
|
||||
// Take a fresh snapshot to observe changes
|
||||
if let Ok(fresh_snapshot) = control_point.get_queue_snapshot(&renderer_id) {
|
||||
if let Ok(fresh_snapshot) =
|
||||
control_point.get_queue_snapshot(&renderer_id)
|
||||
{
|
||||
println!(
|
||||
" → Queue length after refresh: {} items",
|
||||
fresh_snapshot.len()
|
||||
);
|
||||
if !fresh_snapshot.is_empty() {
|
||||
println!(" → First item: {}",
|
||||
println!(
|
||||
" → First item: {}",
|
||||
fresh_snapshot[0].title.as_deref().unwrap_or("<no title>")
|
||||
);
|
||||
}
|
||||
@@ -257,7 +264,10 @@ fn main() -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
println!("Monitoring finished ({}s elapsed), exiting.", MONITOR_DURATION_SECS);
|
||||
println!(
|
||||
"Monitoring finished ({}s elapsed), exiting.",
|
||||
MONITOR_DURATION_SECS
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -392,7 +402,10 @@ fn find_live_playlist_container(server: &MusicServer) -> Result<Option<MediaEntr
|
||||
.browse_root()
|
||||
.context("Failed to browse ContentDirectory root")?;
|
||||
|
||||
println!("Root returned {} entries, starting BFS search for Live Playlist...", root_entries.len());
|
||||
println!(
|
||||
"Root returned {} entries, starting BFS search for Live Playlist...",
|
||||
root_entries.len()
|
||||
);
|
||||
|
||||
// BFS queue: (entry, depth)
|
||||
let mut queue: VecDeque<(MediaEntry, usize)> = VecDeque::new();
|
||||
@@ -412,7 +425,10 @@ fn find_live_playlist_container(server: &MusicServer) -> Result<Option<MediaEntr
|
||||
continue;
|
||||
}
|
||||
if containers_explored >= MAX_CONTAINERS_TO_EXPLORE {
|
||||
println!("Reached max containers to explore ({}), stopping search.", MAX_CONTAINERS_TO_EXPLORE);
|
||||
println!(
|
||||
"Reached max containers to explore ({}), stopping search.",
|
||||
MAX_CONTAINERS_TO_EXPLORE
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -453,7 +469,10 @@ fn find_live_playlist_container(server: &MusicServer) -> Result<Option<MediaEntr
|
||||
}
|
||||
}
|
||||
|
||||
println!("No Live Playlist container found after exploring {} containers.", containers_explored);
|
||||
println!(
|
||||
"No Live Playlist container found after exploring {} containers.",
|
||||
containers_explored
|
||||
);
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
@@ -482,14 +501,19 @@ fn collect_playable_items_from_container(
|
||||
// Fallback: try with minimal number of items
|
||||
server
|
||||
.browse_children(container_id, 0, FALLBACK_MIN_TRACKS as u32)
|
||||
.context("Failed to browse Live Playlist container even with minimal item count")?
|
||||
.context(
|
||||
"Failed to browse Live Playlist container even with minimal item count",
|
||||
)?
|
||||
} else {
|
||||
return Err(err).context("Failed to browse Live Playlist container children");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
println!("Browse returned {} entries from Live Playlist", children.len());
|
||||
println!(
|
||||
"Browse returned {} entries from Live Playlist",
|
||||
children.len()
|
||||
);
|
||||
|
||||
let mut items = Vec::new();
|
||||
for entry in &children {
|
||||
@@ -503,7 +527,10 @@ fn collect_playable_items_from_container(
|
||||
}
|
||||
}
|
||||
|
||||
println!("Extracted {} playable items from Live Playlist", items.len());
|
||||
println!(
|
||||
"Extracted {} playable items from Live Playlist",
|
||||
items.len()
|
||||
);
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
|
||||
@@ -62,7 +62,11 @@ fn main() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_event(cp: &ControlPoint, cache: &mut HashMap<ServerId, MediaServerInfo>, event: &MediaServerEvent) {
|
||||
fn print_event(
|
||||
cp: &ControlPoint,
|
||||
cache: &mut HashMap<ServerId, MediaServerInfo>,
|
||||
event: &MediaServerEvent,
|
||||
) {
|
||||
match event {
|
||||
MediaServerEvent::GlobalUpdated {
|
||||
server_id,
|
||||
|
||||
@@ -80,9 +80,7 @@ fn main() -> Result<()> {
|
||||
.unwrap_or(false);
|
||||
println!(
|
||||
"Renderer \"{}\": AVTransport present = {}, SetNextAVTransportURI supported = {}",
|
||||
renderer.friendly_name,
|
||||
renderer.capabilities.has_avtransport,
|
||||
supports_set_next
|
||||
renderer.friendly_name, renderer.capabilities.has_avtransport, supports_set_next
|
||||
);
|
||||
|
||||
let timeout = Duration::from_secs(config.timeout_secs);
|
||||
|
||||
@@ -14,13 +14,13 @@ use crate::capabilities::{
|
||||
PlaybackPosition, PlaybackPositionInfo, PlaybackState, PlaybackStatus, TransportControl,
|
||||
VolumeControl,
|
||||
};
|
||||
use crate::model::TrackMetadata;
|
||||
use crate::discovery::DiscoveryManager;
|
||||
use crate::events::{MediaServerEventBus, RendererEventBus};
|
||||
use crate::media_server::{
|
||||
MediaBrowser, MediaEntry, MediaResource, MediaServerInfo, MusicServer, ServerId,
|
||||
};
|
||||
use crate::media_server_events::spawn_media_server_event_runtime;
|
||||
use crate::model::TrackMetadata;
|
||||
use crate::model::{MediaServerEvent, RendererEvent, RendererId, RendererProtocol};
|
||||
use crate::music_renderer::op_not_supported;
|
||||
use crate::playback_queue::{PlaybackItem, PlaybackQueue};
|
||||
@@ -90,11 +90,11 @@ impl ControlPoint {
|
||||
// ACTIVE DISCOVERY : envoyer quelques M-SEARCH au démarrage
|
||||
// pour forcer les devices à répondre rapidement.
|
||||
let search_targets = [
|
||||
"ssdp:all",
|
||||
"urn:schemas-upnp-org:device:MediaRenderer:1",
|
||||
"urn:av-openhome-org:device:MediaRenderer:1",
|
||||
"urn:schemas-upnp-org:device:MediaServer:1",
|
||||
"urn:schemas-wiimu-com:service:PlayQueue:1", // <-- AJOUTER
|
||||
"ssdp:all",
|
||||
"urn:schemas-upnp-org:device:MediaRenderer:1",
|
||||
"urn:av-openhome-org:device:MediaRenderer:1",
|
||||
"urn:schemas-upnp-org:device:MediaServer:1",
|
||||
"urn:schemas-wiimu-com:service:PlayQueue:1", // <-- AJOUTER
|
||||
];
|
||||
|
||||
for st in &search_targets {
|
||||
@@ -138,7 +138,9 @@ impl ControlPoint {
|
||||
};
|
||||
let renderers = infos
|
||||
.into_iter()
|
||||
.filter_map(|info| MusicRenderer::from_registry_info(info, &runtime_cp.registry))
|
||||
.filter_map(|info| {
|
||||
MusicRenderer::from_registry_info(info, &runtime_cp.registry)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
for renderer in renderers {
|
||||
@@ -264,6 +266,7 @@ impl ControlPoint {
|
||||
let registry_for_media_worker = Arc::clone(®istry);
|
||||
let runtime_for_media_worker = Arc::clone(&runtime);
|
||||
let bindings_for_media_worker = Arc::clone(&playlist_bindings);
|
||||
let event_bus_for_media_worker = event_bus.clone();
|
||||
let media_rx = media_event_bus.subscribe();
|
||||
|
||||
thread::Builder::new()
|
||||
@@ -322,6 +325,7 @@ impl ControlPoint {
|
||||
&runtime_for_media_worker,
|
||||
&bindings_for_media_worker,
|
||||
&renderer_id,
|
||||
&event_bus_for_media_worker,
|
||||
) {
|
||||
warn!(
|
||||
renderer = renderer_id.0.as_str(),
|
||||
@@ -341,6 +345,7 @@ impl ControlPoint {
|
||||
let registry_for_periodic = Arc::clone(®istry);
|
||||
let runtime_for_periodic = Arc::clone(&runtime);
|
||||
let bindings_for_periodic = Arc::clone(&playlist_bindings);
|
||||
let event_bus_for_periodic = event_bus.clone();
|
||||
|
||||
thread::Builder::new()
|
||||
.name("cp-playlist-periodic-refresh".into())
|
||||
@@ -374,6 +379,7 @@ impl ControlPoint {
|
||||
&runtime_for_periodic,
|
||||
&bindings_for_periodic,
|
||||
&renderer_id,
|
||||
&event_bus_for_periodic,
|
||||
) {
|
||||
warn!(
|
||||
renderer = renderer_id.0.as_str(),
|
||||
@@ -511,6 +517,13 @@ impl ControlPoint {
|
||||
queue_len = 0,
|
||||
"Cleared playback queue"
|
||||
);
|
||||
|
||||
// Emit QueueUpdated event
|
||||
self.emit_renderer_event(RendererEvent::QueueUpdated {
|
||||
id: renderer_id.clone(),
|
||||
queue_length: 0,
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -546,6 +559,13 @@ impl ControlPoint {
|
||||
queue_len = new_len,
|
||||
"Enqueued playback items"
|
||||
);
|
||||
|
||||
// Emit QueueUpdated event
|
||||
self.emit_renderer_event(RendererEvent::QueueUpdated {
|
||||
id: renderer_id.clone(),
|
||||
queue_length: new_len,
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -671,6 +691,12 @@ impl ControlPoint {
|
||||
}
|
||||
}
|
||||
|
||||
// Emit QueueUpdated event
|
||||
self.emit_renderer_event(RendererEvent::QueueUpdated {
|
||||
id: renderer_id.clone(),
|
||||
queue_length: remaining_after,
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -945,6 +971,7 @@ fn refresh_attached_queue_for(
|
||||
runtime: &Arc<RuntimeState>,
|
||||
bindings: &Arc<Mutex<HashMap<RendererId, PlaylistBinding>>>,
|
||||
renderer_id: &RendererId,
|
||||
event_bus: &RendererEventBus,
|
||||
) -> anyhow::Result<()> {
|
||||
// Step 1: Check binding and mark refresh as in-progress
|
||||
let (server_id, container_id) = {
|
||||
@@ -1040,17 +1067,25 @@ fn refresh_attached_queue_for(
|
||||
"Refreshed playlist is empty, clearing queue"
|
||||
);
|
||||
runtime.with_queue_mut(renderer_id, |queue| queue.clear());
|
||||
|
||||
// Emit QueueUpdated event
|
||||
event_bus.broadcast(RendererEvent::QueueUpdated {
|
||||
id: renderer_id.clone(),
|
||||
queue_length: 0,
|
||||
});
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Step 5: Intelligent refresh: try to keep current item if it's still in the new list
|
||||
let current_item = runtime.queue_snapshot(renderer_id).and_then(|q| q.first().cloned());
|
||||
let current_item = runtime
|
||||
.queue_snapshot(renderer_id)
|
||||
.and_then(|q| q.first().cloned());
|
||||
|
||||
let item_found_at = current_item.as_ref().and_then(|current| {
|
||||
new_items.iter().position(|new_item| {
|
||||
// Match by object_id if both have it
|
||||
if let (Some(current_obj), Some(new_obj)) = (¤t.object_id, &new_item.object_id)
|
||||
{
|
||||
if let (Some(current_obj), Some(new_obj)) = (¤t.object_id, &new_item.object_id) {
|
||||
return current_obj == new_obj;
|
||||
}
|
||||
// Fallback: match by URI
|
||||
@@ -1058,7 +1093,7 @@ fn refresh_attached_queue_for(
|
||||
})
|
||||
});
|
||||
|
||||
runtime.with_queue_mut(renderer_id, |queue| {
|
||||
let final_queue_len = runtime.with_queue_mut(renderer_id, |queue| {
|
||||
queue.clear();
|
||||
|
||||
if let Some(idx) = item_found_at {
|
||||
@@ -1074,6 +1109,7 @@ fn refresh_attached_queue_for(
|
||||
current_preserved = true,
|
||||
"Refreshed queue from playlist container"
|
||||
);
|
||||
new_items.len() - idx
|
||||
} else {
|
||||
// Current item not found: replace with full new list
|
||||
for item in new_items.iter() {
|
||||
@@ -1087,7 +1123,14 @@ fn refresh_attached_queue_for(
|
||||
current_preserved = false,
|
||||
"Refreshed queue from playlist container (current item not found)"
|
||||
);
|
||||
new_items.len()
|
||||
}
|
||||
}).unwrap_or(0);
|
||||
|
||||
// Emit QueueUpdated event
|
||||
event_bus.broadcast(RendererEvent::QueueUpdated {
|
||||
id: renderer_id.clone(),
|
||||
queue_length: final_queue_len,
|
||||
});
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -18,6 +18,14 @@ pub mod rendering_control_client;
|
||||
pub mod soap_client;
|
||||
pub mod upnp_renderer;
|
||||
|
||||
// pmoserver extension (optional)
|
||||
#[cfg(feature = "pmoserver")]
|
||||
pub mod openapi;
|
||||
#[cfg(feature = "pmoserver")]
|
||||
pub mod pmoserver_ext;
|
||||
#[cfg(feature = "pmoserver")]
|
||||
pub mod sse;
|
||||
|
||||
pub use arylic_tcp::ArylicTcpRenderer;
|
||||
pub use avtransport_client::{AvTransportClient, PositionInfo, TransportInfo};
|
||||
pub use capabilities::{
|
||||
@@ -38,7 +46,8 @@ pub use upnp_renderer::UpnpRenderer;
|
||||
|
||||
pub use discovery::{DeviceDescriptionProvider, DiscoveredEndpoint, DiscoveryManager};
|
||||
pub use model::{
|
||||
MediaServerEvent, RendererCapabilities, RendererEvent, RendererId, RendererInfo, RendererProtocol,
|
||||
MediaServerEvent, RendererCapabilities, RendererEvent, RendererId, RendererInfo,
|
||||
RendererProtocol,
|
||||
};
|
||||
pub use provider::HttpXmlDescriptionProvider;
|
||||
pub use registry::{DeviceRegistry, DeviceRegistryRead, DeviceUpdate};
|
||||
|
||||
@@ -34,10 +34,7 @@ pub(crate) fn spawn_media_server_event_runtime(
|
||||
.context("Failed to read listener address")
|
||||
.map_err(io_from_anyhow)?;
|
||||
|
||||
info!(
|
||||
"MediaServer event listener bound on {}",
|
||||
listener_addr
|
||||
);
|
||||
info!("MediaServer event listener bound on {}", listener_addr);
|
||||
|
||||
let (notify_tx, notify_rx) = unbounded::<IncomingNotify>();
|
||||
thread::Builder::new()
|
||||
@@ -85,10 +82,7 @@ fn run_http_listener(listener: TcpListener, notify_tx: Sender<IncomingNotify>) {
|
||||
|
||||
let notify = IncomingNotify {
|
||||
path: request.path,
|
||||
sid: request
|
||||
.headers
|
||||
.get("sid")
|
||||
.cloned(),
|
||||
sid: request.headers.get("sid").cloned(),
|
||||
body: request.body,
|
||||
};
|
||||
|
||||
@@ -151,10 +145,7 @@ fn read_http_request(stream: &mut TcpStream) -> io::Result<HttpRequest> {
|
||||
break;
|
||||
}
|
||||
if let Some((name, value)) = trimmed.split_once(':') {
|
||||
headers.insert(
|
||||
name.trim().to_ascii_lowercase(),
|
||||
value.trim().to_string(),
|
||||
);
|
||||
headers.insert(name.trim().to_ascii_lowercase(), value.trim().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -352,8 +343,12 @@ impl MediaServerEventWorker {
|
||||
let local_ip = determine_local_ip(&remote_host, remote_port)
|
||||
.context("Cannot determine local IP for callback")?;
|
||||
|
||||
let callback_url =
|
||||
format!("http://{}:{}{}", format_ip(&local_ip), listener_port, entry.callback_path);
|
||||
let callback_url = format!(
|
||||
"http://{}:{}{}",
|
||||
format_ip(&local_ip),
|
||||
listener_port,
|
||||
entry.callback_path
|
||||
);
|
||||
|
||||
debug!(
|
||||
server = entry.info.friendly_name.as_str(),
|
||||
@@ -392,7 +387,7 @@ impl MediaServerEventWorker {
|
||||
.get("TIMEOUT")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
)
|
||||
.unwrap_or(Duration::from_secs(SUBSCRIPTION_TIMEOUT_SECS));
|
||||
.unwrap_or(Duration::from_secs(SUBSCRIPTION_TIMEOUT_SECS));
|
||||
|
||||
entry.sid = Some(sid);
|
||||
entry.expires_at = Some(Instant::now() + timeout);
|
||||
@@ -441,7 +436,7 @@ impl MediaServerEventWorker {
|
||||
.get("TIMEOUT")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
)
|
||||
.unwrap_or(Duration::from_secs(SUBSCRIPTION_TIMEOUT_SECS));
|
||||
.unwrap_or(Duration::from_secs(SUBSCRIPTION_TIMEOUT_SECS));
|
||||
entry.expires_at = Some(Instant::now() + timeout);
|
||||
debug!(
|
||||
server = entry.info.friendly_name.as_str(),
|
||||
@@ -629,22 +624,14 @@ fn parse_notify_payload(server_id: &ServerId, body: &[u8]) -> Vec<MediaServerEve
|
||||
let mut system_update_id: Option<u32> = None;
|
||||
let mut container_ids: Vec<String> = Vec::new();
|
||||
|
||||
for property in root
|
||||
.children
|
||||
.iter()
|
||||
.filter_map(|node| match node {
|
||||
for property in root.children.iter().filter_map(|node| match node {
|
||||
XMLNode::Element(elem) => Some(elem),
|
||||
_ => None,
|
||||
}) {
|
||||
for child in property.children.iter().filter_map(|node| match node {
|
||||
XMLNode::Element(elem) => Some(elem),
|
||||
_ => None,
|
||||
})
|
||||
{
|
||||
for child in property
|
||||
.children
|
||||
.iter()
|
||||
.filter_map(|node| match node {
|
||||
XMLNode::Element(elem) => Some(elem),
|
||||
_ => None,
|
||||
})
|
||||
{
|
||||
}) {
|
||||
if child.name == "SystemUpdateID" {
|
||||
if let Some(text) = child.get_text() {
|
||||
let trimmed = text.trim();
|
||||
@@ -694,9 +681,14 @@ fn parse_container_update_ids(raw: &str) -> Vec<String> {
|
||||
.collect()
|
||||
} else {
|
||||
let mut ids = Vec::new();
|
||||
let mut tokens = trimmed.split(',').map(|t| t.trim()).filter(|t| !t.is_empty());
|
||||
let mut tokens = trimmed
|
||||
.split(',')
|
||||
.map(|t| t.trim())
|
||||
.filter(|t| !t.is_empty());
|
||||
loop {
|
||||
let Some(id) = tokens.next() else { break; };
|
||||
let Some(id) = tokens.next() else {
|
||||
break;
|
||||
};
|
||||
ids.push(id.to_string());
|
||||
tokens.next(); // Skip the accompanying UpdateID
|
||||
}
|
||||
@@ -801,7 +793,11 @@ fn parse_host_port(url: &str) -> Option<(String, u16)> {
|
||||
fn determine_local_ip(remote_host: &str, remote_port: u16) -> io::Result<IpAddr> {
|
||||
let is_ipv6 = remote_host.contains(':') && !remote_host.contains('.');
|
||||
let target = if is_ipv6 {
|
||||
format!("[{}]:{}", remote_host.trim_matches(|c| c == '[' || c == ']'), remote_port)
|
||||
format!(
|
||||
"[{}]:{}",
|
||||
remote_host.trim_matches(|c| c == '[' || c == ']'),
|
||||
remote_port
|
||||
)
|
||||
} else {
|
||||
format!("{}:{}", remote_host, remote_port)
|
||||
};
|
||||
|
||||
@@ -95,6 +95,10 @@ pub enum RendererEvent {
|
||||
id: RendererId,
|
||||
metadata: TrackMetadata,
|
||||
},
|
||||
QueueUpdated {
|
||||
id: RendererId,
|
||||
queue_length: usize,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
|
||||
305
pmocontrol/src/openapi.rs
Normal file
305
pmocontrol/src/openapi.rs
Normal file
@@ -0,0 +1,305 @@
|
||||
//! Documentation OpenAPI et DTOs pour l'API ControlPoint
|
||||
//!
|
||||
//! Ce module fournit les types de réponse / payloads pour l'API REST du ControlPoint,
|
||||
//! ainsi que la documentation OpenAPI via `utoipa`.
|
||||
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use serde::{Deserialize, Serialize};
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use utoipa::{OpenApi, ToSchema};
|
||||
|
||||
// ============================================================================
|
||||
// RENDERERS
|
||||
// ============================================================================
|
||||
|
||||
/// Résumé d'un renderer découvert
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct RendererSummary {
|
||||
/// ID unique du renderer
|
||||
pub id: String,
|
||||
/// Nom convivial
|
||||
pub friendly_name: String,
|
||||
/// Modèle du renderer
|
||||
pub model_name: String,
|
||||
/// Protocole (Upnp, Hybrid, etc.)
|
||||
pub protocol: String,
|
||||
/// Renderer en ligne
|
||||
pub online: bool,
|
||||
}
|
||||
|
||||
/// État détaillé d'un renderer
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct RendererState {
|
||||
/// ID unique du renderer
|
||||
pub id: String,
|
||||
/// Nom convivial
|
||||
pub friendly_name: String,
|
||||
/// État de transport ("PLAYING", "PAUSED", "STOPPED", etc.)
|
||||
pub transport_state: String,
|
||||
/// Position courante en millisecondes
|
||||
pub position_ms: Option<u64>,
|
||||
/// Durée totale en millisecondes
|
||||
pub duration_ms: Option<u64>,
|
||||
/// Volume (0-100)
|
||||
pub volume: Option<u8>,
|
||||
/// Mute actif
|
||||
pub mute: Option<bool>,
|
||||
/// Nombre d'items dans la queue
|
||||
pub queue_len: usize,
|
||||
/// Playlist attachée (si applicable)
|
||||
pub attached_playlist: Option<AttachedPlaylistInfo>,
|
||||
}
|
||||
|
||||
/// Information sur la playlist attachée
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct AttachedPlaylistInfo {
|
||||
/// ID du serveur de médias
|
||||
pub server_id: String,
|
||||
/// ID du container playlist
|
||||
pub container_id: String,
|
||||
/// True si au moins une mise à jour a été vue
|
||||
pub has_seen_update: bool,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// QUEUE
|
||||
// ============================================================================
|
||||
|
||||
/// Item de la queue de lecture
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct QueueItem {
|
||||
/// Index dans la queue (0-based)
|
||||
pub index: usize,
|
||||
/// URI de la ressource
|
||||
pub uri: String,
|
||||
/// Titre du morceau
|
||||
pub title: Option<String>,
|
||||
/// Artiste
|
||||
pub artist: Option<String>,
|
||||
/// Album
|
||||
pub album: Option<String>,
|
||||
/// ID du serveur source
|
||||
pub server_id: Option<String>,
|
||||
/// ID de l'objet DIDL-Lite
|
||||
pub object_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Snapshot de la queue d'un renderer
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct QueueSnapshot {
|
||||
/// ID du renderer
|
||||
pub renderer_id: String,
|
||||
/// Items de la queue
|
||||
pub items: Vec<QueueItem>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MEDIA SERVERS
|
||||
// ============================================================================
|
||||
|
||||
/// Résumé d'un serveur de médias découvert
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct MediaServerSummary {
|
||||
/// ID unique du serveur
|
||||
pub id: String,
|
||||
/// Nom convivial
|
||||
pub friendly_name: String,
|
||||
/// Modèle du serveur
|
||||
pub model_name: String,
|
||||
/// Serveur en ligne
|
||||
pub online: bool,
|
||||
}
|
||||
|
||||
/// Entrée de navigation (container ou item)
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct ContainerEntry {
|
||||
/// ID de l'objet
|
||||
pub id: String,
|
||||
/// Titre
|
||||
pub title: String,
|
||||
/// Classe UPnP (object.container.*, object.item.*, etc.)
|
||||
pub class: String,
|
||||
/// True si c'est un container (navigable)
|
||||
pub is_container: bool,
|
||||
/// Nombre d'enfants (si container)
|
||||
pub child_count: Option<u32>,
|
||||
/// Artiste (si item audio)
|
||||
pub artist: Option<String>,
|
||||
/// Album (si item audio)
|
||||
pub album: Option<String>,
|
||||
/// URI de la pochette d'album
|
||||
pub album_art_uri: Option<String>,
|
||||
}
|
||||
|
||||
/// Résultat de navigation dans un container
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct BrowseResponse {
|
||||
/// ID du container browsé
|
||||
pub container_id: String,
|
||||
/// Entrées du container
|
||||
pub entries: Vec<ContainerEntry>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PAYLOADS DE COMMANDES
|
||||
// ============================================================================
|
||||
|
||||
/// Requête pour définir le volume
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[derive(Debug, Clone, Deserialize, ToSchema)]
|
||||
pub struct VolumeSetRequest {
|
||||
/// Nouveau volume (0-100)
|
||||
pub volume: u8,
|
||||
}
|
||||
|
||||
/// Requête pour attacher une playlist
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[derive(Debug, Clone, Deserialize, ToSchema)]
|
||||
pub struct AttachPlaylistRequest {
|
||||
/// ID du serveur de médias
|
||||
pub server_id: String,
|
||||
/// ID du container playlist
|
||||
pub container_id: String,
|
||||
}
|
||||
|
||||
/// Réponse générique de succès
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct SuccessResponse {
|
||||
/// Message de succès
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// Réponse d'erreur
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct ErrorResponse {
|
||||
/// Message d'erreur
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DOCUMENTATION OPENAPI
|
||||
// ============================================================================
|
||||
|
||||
/// Documentation OpenAPI pour l'API ControlPoint
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
info(
|
||||
title = "PMOMusic Control Point API",
|
||||
version = "1.0.0",
|
||||
description = r#"
|
||||
# API REST pour le Control Point PMOMusic
|
||||
|
||||
Cette API permet de contrôler les renderers UPnP et de naviguer dans les serveurs de médias.
|
||||
|
||||
## Fonctionnalités
|
||||
|
||||
### Renderers
|
||||
- **Découverte** : Liste des renderers disponibles
|
||||
- **État** : Récupération de l'état détaillé d'un renderer
|
||||
- **Contrôle transport** : Play, pause, stop, next
|
||||
- **Contrôle volume** : Lecture et modification du volume / mute
|
||||
- **Queue** : Gestion de la queue de lecture
|
||||
|
||||
### Playlists
|
||||
- **Binding** : Attachement de la queue à un container playlist d'un serveur
|
||||
- **Synchronisation automatique** : Mise à jour de la queue lors des changements côté serveur
|
||||
|
||||
### Serveurs de médias
|
||||
- **Découverte** : Liste des serveurs disponibles
|
||||
- **Navigation** : Exploration de la hiérarchie des containers
|
||||
|
||||
## Architecture
|
||||
|
||||
Le Control Point PMOMusic est un point de contrôle UPnP qui :
|
||||
1. Découvre automatiquement les renderers et serveurs via SSDP
|
||||
2. Maintient un registre des devices actifs
|
||||
3. Permet le contrôle unifié des renderers (UPnP AV, LinkPlay, Arylic TCP)
|
||||
4. Gère une queue de lecture locale avec synchronisation optionnelle
|
||||
|
||||
## Exemples d'utilisation
|
||||
|
||||
### Lister les renderers
|
||||
```
|
||||
GET /control/renderers
|
||||
```
|
||||
|
||||
### Contrôler un renderer
|
||||
```
|
||||
POST /control/renderers/{renderer_id}/play
|
||||
POST /control/renderers/{renderer_id}/pause
|
||||
POST /control/renderers/{renderer_id}/volume/set
|
||||
Body: {"volume": 50}
|
||||
```
|
||||
|
||||
### Attacher une playlist
|
||||
```
|
||||
POST /control/renderers/{renderer_id}/binding/attach
|
||||
Body: {
|
||||
"server_id": "uuid:...",
|
||||
"container_id": "0$/Music/MyPlaylist"
|
||||
}
|
||||
```
|
||||
|
||||
### Naviguer dans un serveur
|
||||
```
|
||||
GET /control/servers/{server_id}/containers/{container_id}
|
||||
```
|
||||
"#,
|
||||
contact(
|
||||
name = "PMOMusic",
|
||||
),
|
||||
license(
|
||||
name = "MIT",
|
||||
),
|
||||
),
|
||||
paths(
|
||||
crate::pmoserver_ext::list_renderers,
|
||||
crate::pmoserver_ext::get_renderer_state,
|
||||
crate::pmoserver_ext::get_renderer_queue,
|
||||
crate::pmoserver_ext::get_renderer_binding,
|
||||
crate::pmoserver_ext::play_renderer,
|
||||
crate::pmoserver_ext::pause_renderer,
|
||||
crate::pmoserver_ext::stop_renderer,
|
||||
crate::pmoserver_ext::next_renderer,
|
||||
crate::pmoserver_ext::set_renderer_volume,
|
||||
crate::pmoserver_ext::volume_up_renderer,
|
||||
crate::pmoserver_ext::volume_down_renderer,
|
||||
crate::pmoserver_ext::toggle_mute_renderer,
|
||||
crate::pmoserver_ext::attach_playlist_binding,
|
||||
crate::pmoserver_ext::detach_playlist_binding,
|
||||
crate::pmoserver_ext::list_servers,
|
||||
crate::pmoserver_ext::browse_container,
|
||||
crate::sse::all_events_sse,
|
||||
crate::sse::renderer_events_sse,
|
||||
crate::sse::media_server_events_sse,
|
||||
),
|
||||
components(schemas(
|
||||
RendererSummary,
|
||||
RendererState,
|
||||
AttachedPlaylistInfo,
|
||||
QueueItem,
|
||||
QueueSnapshot,
|
||||
MediaServerSummary,
|
||||
ContainerEntry,
|
||||
BrowseResponse,
|
||||
VolumeSetRequest,
|
||||
AttachPlaylistRequest,
|
||||
SuccessResponse,
|
||||
ErrorResponse,
|
||||
)),
|
||||
tags(
|
||||
(name = "control", description = "Contrôle des renderers et navigation des serveurs")
|
||||
)
|
||||
)]
|
||||
pub struct ApiDoc;
|
||||
998
pmocontrol/src/pmoserver_ext.rs
Normal file
998
pmocontrol/src/pmoserver_ext.rs
Normal file
@@ -0,0 +1,998 @@
|
||||
//! Extension pmoserver pour le Control Point
|
||||
//!
|
||||
//! Ce module fournit une API REST pour contrôler les renderers UPnP
|
||||
//! et naviguer dans les serveurs de médias.
|
||||
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use crate::control_point::ControlPoint;
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use crate::media_server::{MediaBrowser, MusicServer, ServerId};
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use crate::model::{RendererId, RendererProtocol};
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use crate::openapi::{
|
||||
AttachedPlaylistInfo, AttachPlaylistRequest, BrowseResponse, ContainerEntry, ErrorResponse,
|
||||
MediaServerSummary, QueueItem, QueueSnapshot, RendererState, RendererSummary, SuccessResponse,
|
||||
VolumeSetRequest,
|
||||
};
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use crate::{PlaybackPosition, PlaybackStatus, TransportControl, VolumeControl};
|
||||
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use async_trait::async_trait;
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use std::sync::Arc;
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use std::time::Duration;
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use tracing::{debug, warn};
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use utoipa::OpenApi;
|
||||
|
||||
/// État partagé pour l'API ControlPoint
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[derive(Clone)]
|
||||
pub struct ControlPointState {
|
||||
control_point: Arc<ControlPoint>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "pmoserver")]
|
||||
impl ControlPointState {
|
||||
pub fn new(control_point: Arc<ControlPoint>) -> Self {
|
||||
Self { control_point }
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HANDLERS - RENDERERS
|
||||
// ============================================================================
|
||||
|
||||
/// GET /control/renderers - Liste tous les renderers
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/renderers",
|
||||
responses(
|
||||
(status = 200, description = "Liste des renderers", body = Vec<RendererSummary>)
|
||||
),
|
||||
tag = "control"
|
||||
)]
|
||||
async fn list_renderers(
|
||||
State(state): State<ControlPointState>,
|
||||
) -> Json<Vec<RendererSummary>> {
|
||||
let renderers = state.control_point.list_music_renderers();
|
||||
|
||||
let summaries: Vec<RendererSummary> = renderers
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
let info = r.info();
|
||||
RendererSummary {
|
||||
id: info.id.0.clone(),
|
||||
friendly_name: info.friendly_name.clone(),
|
||||
model_name: info.model_name.clone(),
|
||||
protocol: protocol_to_string(&info.protocol),
|
||||
online: info.online,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Json(summaries)
|
||||
}
|
||||
|
||||
/// GET /control/renderers/{renderer_id} - Récupère l'état d'un renderer
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/renderers/{renderer_id}",
|
||||
params(
|
||||
("renderer_id" = String, Path, description = "ID unique du renderer")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "État du renderer", body = RendererState),
|
||||
(status = 404, description = "Renderer non trouvé", body = ErrorResponse)
|
||||
),
|
||||
tag = "control"
|
||||
)]
|
||||
async fn get_renderer_state(
|
||||
State(state): State<ControlPointState>,
|
||||
Path(renderer_id): Path<String>,
|
||||
) -> Result<Json<RendererState>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let rid = RendererId(renderer_id.clone());
|
||||
|
||||
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 info = renderer.info();
|
||||
|
||||
// État de transport
|
||||
let transport_state = renderer
|
||||
.playback_state()
|
||||
.ok()
|
||||
.map(state_to_string)
|
||||
.unwrap_or_else(|| "UNKNOWN".to_string());
|
||||
|
||||
// Position et durée
|
||||
let (position_ms, duration_ms) = renderer
|
||||
.playback_position()
|
||||
.ok()
|
||||
.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));
|
||||
|
||||
// Volume et mute
|
||||
let volume = renderer.volume().ok().and_then(|v| u8::try_from(v).ok());
|
||||
let mute = renderer.mute().ok();
|
||||
|
||||
// Queue
|
||||
let queue_len = state
|
||||
.control_point
|
||||
.get_queue_snapshot(&rid)
|
||||
.ok()
|
||||
.map(|q| q.len())
|
||||
.unwrap_or(0);
|
||||
|
||||
// Playlist binding
|
||||
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,
|
||||
});
|
||||
|
||||
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,
|
||||
}))
|
||||
}
|
||||
|
||||
/// GET /control/renderers/{renderer_id}/queue - Récupère la queue d'un renderer
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/renderers/{renderer_id}/queue",
|
||||
params(
|
||||
("renderer_id" = String, Path, description = "ID unique du renderer")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Queue du renderer", body = QueueSnapshot),
|
||||
(status = 404, description = "Renderer non trouvé", body = ErrorResponse)
|
||||
),
|
||||
tag = "control"
|
||||
)]
|
||||
async fn get_renderer_queue(
|
||||
State(state): State<ControlPointState>,
|
||||
Path(renderer_id): Path<String>,
|
||||
) -> Result<Json<QueueSnapshot>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let rid = RendererId(renderer_id.clone());
|
||||
|
||||
let items = state
|
||||
.control_point
|
||||
.get_queue_snapshot(&rid)
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(ErrorResponse {
|
||||
error: format!("Failed to get queue: {}", e),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
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,
|
||||
server_id: item.server_id.map(|s| s.0),
|
||||
object_id: item.object_id,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(QueueSnapshot {
|
||||
renderer_id,
|
||||
items: queue_items,
|
||||
}))
|
||||
}
|
||||
|
||||
/// GET /control/renderers/{renderer_id}/binding - Récupère le binding playlist
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/renderers/{renderer_id}/binding",
|
||||
params(
|
||||
("renderer_id" = String, Path, description = "ID unique du renderer")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Binding playlist", body = Option<AttachedPlaylistInfo>),
|
||||
(status = 404, description = "Renderer non trouvé", body = ErrorResponse)
|
||||
),
|
||||
tag = "control"
|
||||
)]
|
||||
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
|
||||
.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,
|
||||
});
|
||||
|
||||
Ok(Json(binding))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HANDLERS - CONTRÔLE TRANSPORT
|
||||
// ============================================================================
|
||||
|
||||
/// POST /control/renderers/{renderer_id}/play - Démarre la lecture
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/renderers/{renderer_id}/play",
|
||||
params(
|
||||
("renderer_id" = String, Path, description = "ID unique du renderer")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Lecture démarrée", 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 play_renderer(
|
||||
State(state): State<ControlPointState>,
|
||||
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)
|
||||
.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(ErrorResponse {
|
||||
error: format!("Renderer {} not found", renderer_id),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
renderer.play().map_err(|e| {
|
||||
warn!("Failed to play renderer {}: {}", renderer_id, e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: format!("Failed to play: {}", e),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Json(SuccessResponse {
|
||||
message: "Playback started".to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// POST /control/renderers/{renderer_id}/pause - Met en pause
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/renderers/{renderer_id}/pause",
|
||||
params(
|
||||
("renderer_id" = String, Path, description = "ID unique du renderer")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Lecture en pause", 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 pause_renderer(
|
||||
State(state): State<ControlPointState>,
|
||||
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)
|
||||
.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(ErrorResponse {
|
||||
error: format!("Renderer {} not found", renderer_id),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
renderer.pause().map_err(|e| {
|
||||
warn!("Failed to pause renderer {}: {}", renderer_id, e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: format!("Failed to pause: {}", e),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Json(SuccessResponse {
|
||||
message: "Playback paused".to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// POST /control/renderers/{renderer_id}/stop - Arrête la lecture
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/renderers/{renderer_id}/stop",
|
||||
params(
|
||||
("renderer_id" = String, Path, description = "ID unique du renderer")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Lecture arrêtée", 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 stop_renderer(
|
||||
State(state): State<ControlPointState>,
|
||||
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)
|
||||
.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(ErrorResponse {
|
||||
error: format!("Renderer {} not found", renderer_id),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
renderer.stop().map_err(|e| {
|
||||
warn!("Failed to stop renderer {}: {}", renderer_id, e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: format!("Failed to stop: {}", e),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Json(SuccessResponse {
|
||||
message: "Playback stopped".to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// POST /control/renderers/{renderer_id}/next - Passe au morceau suivant de la queue
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/renderers/{renderer_id}/next",
|
||||
params(
|
||||
("renderer_id" = String, Path, description = "ID unique du renderer")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Passage au suivant", 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 next_renderer(
|
||||
State(state): State<ControlPointState>,
|
||||
Path(renderer_id): Path<String>,
|
||||
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let rid = RendererId(renderer_id.clone());
|
||||
|
||||
state
|
||||
.control_point
|
||||
.play_next_from_queue(&rid)
|
||||
.map_err(|e| {
|
||||
warn!("Failed to advance queue for renderer {}: {}", renderer_id, e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: format!("Failed to advance queue: {}", e),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Json(SuccessResponse {
|
||||
message: "Advanced to next track".to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HANDLERS - VOLUME
|
||||
// ============================================================================
|
||||
|
||||
/// POST /control/renderers/{renderer_id}/volume/set - Définit le volume
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/renderers/{renderer_id}/volume/set",
|
||||
params(
|
||||
("renderer_id" = String, Path, description = "ID unique du renderer")
|
||||
),
|
||||
request_body = VolumeSetRequest,
|
||||
responses(
|
||||
(status = 200, description = "Volume défini", body = SuccessResponse),
|
||||
(status = 400, description = "Requête invalide", body = ErrorResponse),
|
||||
(status = 404, description = "Renderer non trouvé", body = ErrorResponse),
|
||||
(status = 500, description = "Erreur lors de l'exécution", body = ErrorResponse)
|
||||
),
|
||||
tag = "control"
|
||||
)]
|
||||
async fn set_renderer_volume(
|
||||
State(state): State<ControlPointState>,
|
||||
Path(renderer_id): Path<String>,
|
||||
Json(req): Json<VolumeSetRequest>,
|
||||
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let rid = RendererId(renderer_id.clone());
|
||||
|
||||
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),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
renderer.set_volume(req.volume as u16).map_err(|e| {
|
||||
warn!("Failed to set volume for renderer {}: {}", renderer_id, e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: format!("Failed to set volume: {}", e),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Json(SuccessResponse {
|
||||
message: format!("Volume set to {}", req.volume),
|
||||
}))
|
||||
}
|
||||
|
||||
/// POST /control/renderers/{renderer_id}/volume/up - Augmente le volume
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/renderers/{renderer_id}/volume/up",
|
||||
params(
|
||||
("renderer_id" = String, Path, description = "ID unique du renderer")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Volume augmenté", 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 volume_up_renderer(
|
||||
State(state): State<ControlPointState>,
|
||||
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)
|
||||
.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(ErrorResponse {
|
||||
error: format!("Renderer {} not found", renderer_id),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
let current = renderer.volume().map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: format!("Failed to get current volume: {}", e),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
let new_volume = (current + 5).min(100);
|
||||
renderer.set_volume(new_volume).map_err(|e| {
|
||||
warn!("Failed to increase volume for renderer {}: {}", renderer_id, e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: format!("Failed to increase volume: {}", e),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Json(SuccessResponse {
|
||||
message: format!("Volume increased to {}", new_volume),
|
||||
}))
|
||||
}
|
||||
|
||||
/// POST /control/renderers/{renderer_id}/volume/down - Diminue le volume
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/renderers/{renderer_id}/volume/down",
|
||||
params(
|
||||
("renderer_id" = String, Path, description = "ID unique du renderer")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Volume diminué", 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 volume_down_renderer(
|
||||
State(state): State<ControlPointState>,
|
||||
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)
|
||||
.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(ErrorResponse {
|
||||
error: format!("Renderer {} not found", renderer_id),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
let current = renderer.volume().map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: format!("Failed to get current volume: {}", e),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
let new_volume = current.saturating_sub(5);
|
||||
renderer.set_volume(new_volume).map_err(|e| {
|
||||
warn!("Failed to decrease volume for renderer {}: {}", renderer_id, e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: format!("Failed to decrease volume: {}", e),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Json(SuccessResponse {
|
||||
message: format!("Volume decreased to {}", new_volume),
|
||||
}))
|
||||
}
|
||||
|
||||
/// POST /control/renderers/{renderer_id}/mute/toggle - Bascule le mute
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/renderers/{renderer_id}/mute/toggle",
|
||||
params(
|
||||
("renderer_id" = String, Path, description = "ID unique du renderer")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Mute basculé", 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 toggle_mute_renderer(
|
||||
State(state): State<ControlPointState>,
|
||||
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)
|
||||
.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(ErrorResponse {
|
||||
error: format!("Renderer {} not found", renderer_id),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
let current_mute = renderer.mute().map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: format!("Failed to get current mute state: {}", e),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
let new_mute = !current_mute;
|
||||
renderer.set_mute(new_mute).map_err(|e| {
|
||||
warn!("Failed to toggle mute for renderer {}: {}", renderer_id, e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: format!("Failed to toggle mute: {}", e),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Json(SuccessResponse {
|
||||
message: format!("Mute {}", if new_mute { "enabled" } else { "disabled" }),
|
||||
}))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HANDLERS - BINDING PLAYLIST
|
||||
// ============================================================================
|
||||
|
||||
/// POST /control/renderers/{renderer_id}/binding/attach - Attache une playlist
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/renderers/{renderer_id}/binding/attach",
|
||||
params(
|
||||
("renderer_id" = String, Path, description = "ID unique du renderer")
|
||||
),
|
||||
request_body = AttachPlaylistRequest,
|
||||
responses(
|
||||
(status = 200, description = "Playlist attachée", body = SuccessResponse),
|
||||
(status = 404, description = "Renderer non trouvé", body = ErrorResponse)
|
||||
),
|
||||
tag = "control"
|
||||
)]
|
||||
async fn attach_playlist_binding(
|
||||
State(state): State<ControlPointState>,
|
||||
Path(renderer_id): Path<String>,
|
||||
Json(req): Json<AttachPlaylistRequest>,
|
||||
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let rid = RendererId(renderer_id.clone());
|
||||
let sid = ServerId(req.server_id.clone());
|
||||
|
||||
state
|
||||
.control_point
|
||||
.attach_queue_to_playlist(&rid, sid, req.container_id.clone());
|
||||
|
||||
debug!(
|
||||
renderer = renderer_id.as_str(),
|
||||
server = req.server_id.as_str(),
|
||||
container = req.container_id.as_str(),
|
||||
"Playlist attached via HTTP API"
|
||||
);
|
||||
|
||||
Ok(Json(SuccessResponse {
|
||||
message: format!(
|
||||
"Playlist {} attached to renderer",
|
||||
req.container_id
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
/// POST /control/renderers/{renderer_id}/binding/detach - Détache la playlist
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/renderers/{renderer_id}/binding/detach",
|
||||
params(
|
||||
("renderer_id" = String, Path, description = "ID unique du renderer")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Playlist détachée", body = SuccessResponse)
|
||||
),
|
||||
tag = "control"
|
||||
)]
|
||||
async fn detach_playlist_binding(
|
||||
State(state): State<ControlPointState>,
|
||||
Path(renderer_id): Path<String>,
|
||||
) -> Json<SuccessResponse> {
|
||||
let rid = RendererId(renderer_id.clone());
|
||||
|
||||
state.control_point.detach_queue_playlist(&rid);
|
||||
|
||||
debug!(
|
||||
renderer = renderer_id.as_str(),
|
||||
"Playlist detached via HTTP API"
|
||||
);
|
||||
|
||||
Json(SuccessResponse {
|
||||
message: "Playlist detached".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HANDLERS - MEDIA SERVERS
|
||||
// ============================================================================
|
||||
|
||||
/// GET /control/servers - Liste tous les serveurs de médias
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/servers",
|
||||
responses(
|
||||
(status = 200, description = "Liste des serveurs de médias", body = Vec<MediaServerSummary>)
|
||||
),
|
||||
tag = "control"
|
||||
)]
|
||||
async fn list_servers(
|
||||
State(state): State<ControlPointState>,
|
||||
) -> Json<Vec<MediaServerSummary>> {
|
||||
let servers = state.control_point.list_media_servers();
|
||||
|
||||
let summaries: Vec<MediaServerSummary> = servers
|
||||
.into_iter()
|
||||
.map(|s| MediaServerSummary {
|
||||
id: s.id.0,
|
||||
friendly_name: s.friendly_name,
|
||||
model_name: s.model_name,
|
||||
online: s.online,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Json(summaries)
|
||||
}
|
||||
|
||||
/// GET /control/servers/{server_id}/containers/{container_id} - Browse un container
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/servers/{server_id}/containers/{container_id}",
|
||||
params(
|
||||
("server_id" = String, Path, description = "ID unique du serveur"),
|
||||
("container_id" = String, Path, description = "ID du container (use '0' for root)")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Contenu du container", body = BrowseResponse),
|
||||
(status = 404, description = "Serveur non trouvé", body = ErrorResponse),
|
||||
(status = 500, description = "Erreur lors du browse", body = ErrorResponse)
|
||||
),
|
||||
tag = "control"
|
||||
)]
|
||||
async fn browse_container(
|
||||
State(state): State<ControlPointState>,
|
||||
Path((server_id, container_id)): Path<(String, String)>,
|
||||
) -> Result<Json<BrowseResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let sid = ServerId(server_id.clone());
|
||||
|
||||
let server_info = state
|
||||
.control_point
|
||||
.media_server(&sid)
|
||||
.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(ErrorResponse {
|
||||
error: format!("Server {} not found", server_id),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
if !server_info.online {
|
||||
return Err((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(ErrorResponse {
|
||||
error: format!("Server {} is offline", server_id),
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
if !server_info.has_content_directory {
|
||||
return Err((
|
||||
StatusCode::NOT_IMPLEMENTED,
|
||||
Json(ErrorResponse {
|
||||
error: format!("Server {} does not support ContentDirectory", server_id),
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
let music_server = MusicServer::from_info(&server_info, Duration::from_secs(10)).map_err(
|
||||
|e| {
|
||||
warn!("Failed to create MusicServer for {}: {}", server_id, e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: format!("Failed to initialize server: {}", e),
|
||||
}),
|
||||
)
|
||||
},
|
||||
)?;
|
||||
|
||||
let entries = music_server
|
||||
.browse_children(&container_id, 0, 100)
|
||||
.map_err(|e| {
|
||||
warn!(
|
||||
"Failed to browse container {} on server {}: {}",
|
||||
container_id, server_id, e
|
||||
);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: format!("Failed to browse container: {}", e),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
let container_entries: Vec<ContainerEntry> = entries
|
||||
.into_iter()
|
||||
.map(|e| ContainerEntry {
|
||||
id: e.id,
|
||||
title: e.title,
|
||||
class: e.class,
|
||||
is_container: e.is_container,
|
||||
child_count: None, // Could be extracted from DIDL-Lite if needed
|
||||
artist: e.artist,
|
||||
album: e.album,
|
||||
album_art_uri: e.album_art_uri,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(BrowseResponse {
|
||||
container_id,
|
||||
entries: container_entries,
|
||||
}))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HELPERS
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(feature = "pmoserver")]
|
||||
fn protocol_to_string(protocol: &RendererProtocol) -> String {
|
||||
match protocol {
|
||||
RendererProtocol::UpnpAvOnly => "UpnpAvOnly".to_string(),
|
||||
RendererProtocol::OpenHomeOnly => "OpenHomeOnly".to_string(),
|
||||
RendererProtocol::Hybrid => "Hybrid".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "pmoserver")]
|
||||
fn state_to_string(state: crate::PlaybackState) -> String {
|
||||
use crate::PlaybackState;
|
||||
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(s) => s,
|
||||
}
|
||||
}
|
||||
|
||||
#[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
|
||||
// ============================================================================
|
||||
|
||||
/// Crée le router pour l'API Control Point
|
||||
#[cfg(feature = "pmoserver")]
|
||||
pub fn create_api_router(state: ControlPointState, control_point: Arc<ControlPoint>) -> Router {
|
||||
Router::new()
|
||||
// Renderers
|
||||
.route("/renderers", get(list_renderers))
|
||||
.route("/renderers/{renderer_id}", get(get_renderer_state))
|
||||
.route("/renderers/{renderer_id}/queue", get(get_renderer_queue))
|
||||
.route("/renderers/{renderer_id}/binding", get(get_renderer_binding))
|
||||
// Transport control
|
||||
.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}/next", post(next_renderer))
|
||||
// Volume control
|
||||
.route("/renderers/{renderer_id}/volume/set", post(set_renderer_volume))
|
||||
.route("/renderers/{renderer_id}/volume/up", post(volume_up_renderer))
|
||||
.route("/renderers/{renderer_id}/volume/down", post(volume_down_renderer))
|
||||
.route("/renderers/{renderer_id}/mute/toggle", post(toggle_mute_renderer))
|
||||
// Playlist binding
|
||||
.route("/renderers/{renderer_id}/binding/attach", post(attach_playlist_binding))
|
||||
.route("/renderers/{renderer_id}/binding/detach", post(detach_playlist_binding))
|
||||
// Servers
|
||||
.route("/servers", get(list_servers))
|
||||
.route("/servers/{server_id}/containers/{container_id}", get(browse_container))
|
||||
.with_state(state)
|
||||
// SSE events - merge the SSE router
|
||||
.merge(crate::sse::create_sse_router(control_point))
|
||||
}
|
||||
|
||||
/// Trait d'extension pour pmoserver::Server
|
||||
///
|
||||
/// Permet d'initialiser le ControlPoint avec routes HTTP complètes
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[async_trait]
|
||||
pub trait ControlPointExt {
|
||||
/// Initialise l'API Control Point
|
||||
///
|
||||
/// # Routes créées
|
||||
///
|
||||
/// - API REST: `/api/control/*`
|
||||
/// - `/renderers` - Liste et état des renderers
|
||||
/// - `/servers` - Liste et navigation des serveurs de médias
|
||||
/// - Contrôles de transport, volume, queue, binding
|
||||
/// - SSE Events: `/api/control/events/*`
|
||||
/// - `/events` - Tous les événements (renderers + serveurs)
|
||||
/// - `/events/renderers` - Événements renderers uniquement
|
||||
/// - `/events/servers` - Événements serveurs uniquement
|
||||
/// - Swagger: `/swagger-ui/control`
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `control_point` - Instance du ControlPoint
|
||||
async fn init_control_point(&mut self, control_point: Arc<ControlPoint>);
|
||||
}
|
||||
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[async_trait]
|
||||
impl ControlPointExt for pmoserver::Server {
|
||||
async fn init_control_point(&mut self, control_point: Arc<ControlPoint>) {
|
||||
let state = ControlPointState::new(control_point.clone());
|
||||
|
||||
// Créer le router API (inclut REST et SSE)
|
||||
let api_router = create_api_router(state, control_point);
|
||||
|
||||
// L'enregistrer avec OpenAPI
|
||||
self.add_openapi(api_router, crate::openapi::ApiDoc::openapi(), "control")
|
||||
.await;
|
||||
}
|
||||
}
|
||||
432
pmocontrol/src/sse.rs
Normal file
432
pmocontrol/src/sse.rs
Normal file
@@ -0,0 +1,432 @@
|
||||
//! SSE endpoints pour les événements du Control Point
|
||||
//!
|
||||
//! Ce module fournit des endpoints Server-Sent Events pour permettre aux clients
|
||||
//! web de recevoir en temps réel :
|
||||
//! - Les événements des renderers (state, volume, position, queue, etc.)
|
||||
//! - Les événements des serveurs de médias (global updates, container updates)
|
||||
//!
|
||||
//! Routes:
|
||||
//! - 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)
|
||||
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use crate::control_point::ControlPoint;
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use crate::model::{MediaServerEvent, RendererEvent};
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use crate::PlaybackState;
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use async_stream::stream;
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use axum::{
|
||||
extract::State,
|
||||
response::sse::{Event, KeepAlive, Sse},
|
||||
response::IntoResponse,
|
||||
Router,
|
||||
};
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use serde::Serialize;
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use std::sync::Arc;
|
||||
|
||||
// ============================================================================
|
||||
// PAYLOADS SSE
|
||||
// ============================================================================
|
||||
|
||||
/// Payload SSE pour un événement renderer
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum RendererEventPayload {
|
||||
StateChanged {
|
||||
renderer_id: String,
|
||||
state: String,
|
||||
timestamp: chrono::DateTime<chrono::Utc>,
|
||||
},
|
||||
PositionChanged {
|
||||
renderer_id: String,
|
||||
track: Option<u32>,
|
||||
rel_time: Option<String>,
|
||||
track_duration: Option<String>,
|
||||
timestamp: chrono::DateTime<chrono::Utc>,
|
||||
},
|
||||
VolumeChanged {
|
||||
renderer_id: String,
|
||||
volume: u16,
|
||||
timestamp: chrono::DateTime<chrono::Utc>,
|
||||
},
|
||||
MuteChanged {
|
||||
renderer_id: String,
|
||||
mute: bool,
|
||||
timestamp: chrono::DateTime<chrono::Utc>,
|
||||
},
|
||||
MetadataChanged {
|
||||
renderer_id: String,
|
||||
title: Option<String>,
|
||||
artist: Option<String>,
|
||||
album: Option<String>,
|
||||
album_art_uri: Option<String>,
|
||||
timestamp: chrono::DateTime<chrono::Utc>,
|
||||
},
|
||||
QueueUpdated {
|
||||
renderer_id: String,
|
||||
queue_length: usize,
|
||||
timestamp: chrono::DateTime<chrono::Utc>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Payload SSE pour un événement serveur de médias
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum MediaServerEventPayload {
|
||||
GlobalUpdated {
|
||||
server_id: String,
|
||||
system_update_id: Option<u32>,
|
||||
timestamp: chrono::DateTime<chrono::Utc>,
|
||||
},
|
||||
ContainersUpdated {
|
||||
server_id: String,
|
||||
container_ids: Vec<String>,
|
||||
timestamp: chrono::DateTime<chrono::Utc>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Payload SSE unifié pour tous les événements
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(tag = "category", rename_all = "snake_case")]
|
||||
pub enum UnifiedEventPayload {
|
||||
Renderer(RendererEventPayload),
|
||||
MediaServer(MediaServerEventPayload),
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HANDLERS SSE
|
||||
// ============================================================================
|
||||
|
||||
/// Handler SSE pour les événements renderers
|
||||
///
|
||||
/// Route: GET /api/control/events/renderers
|
||||
///
|
||||
/// Diffuse tous les événements liés aux renderers (state, volume, position, queue, etc.)
|
||||
/// en temps réel via Server-Sent Events.
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/events/renderers",
|
||||
responses(
|
||||
(status = 200, description = "Flux SSE des événements renderers", content_type = "text/event-stream")
|
||||
),
|
||||
tag = "control"
|
||||
)]
|
||||
pub async fn renderer_events_sse(
|
||||
State(control_point): State<Arc<ControlPoint>>,
|
||||
) -> impl IntoResponse {
|
||||
// Convert crossbeam channel to tokio channel for async compatibility
|
||||
let (tx, mut rx_tokio) = tokio::sync::mpsc::unbounded_channel();
|
||||
let rx = control_point.subscribe_events();
|
||||
|
||||
// Spawn blocking task to bridge crossbeam -> tokio
|
||||
tokio::task::spawn_blocking(move || {
|
||||
while let Ok(event) = rx.recv() {
|
||||
if tx.send(event).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let stream = stream! {
|
||||
while let Some(event) = rx_tokio.recv().await {
|
||||
let timestamp = chrono::Utc::now();
|
||||
|
||||
let payload = match event {
|
||||
RendererEvent::StateChanged { id, state } => {
|
||||
RendererEventPayload::StateChanged {
|
||||
renderer_id: id.0,
|
||||
state: state_to_string(state),
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
RendererEvent::PositionChanged { id, position } => {
|
||||
RendererEventPayload::PositionChanged {
|
||||
renderer_id: id.0,
|
||||
track: position.track,
|
||||
rel_time: position.rel_time,
|
||||
track_duration: position.track_duration,
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
RendererEvent::VolumeChanged { id, volume } => {
|
||||
RendererEventPayload::VolumeChanged {
|
||||
renderer_id: id.0,
|
||||
volume,
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
RendererEvent::MuteChanged { id, mute } => {
|
||||
RendererEventPayload::MuteChanged {
|
||||
renderer_id: id.0,
|
||||
mute,
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
RendererEvent::MetadataChanged { id, metadata } => {
|
||||
RendererEventPayload::MetadataChanged {
|
||||
renderer_id: id.0,
|
||||
title: metadata.title,
|
||||
artist: metadata.artist,
|
||||
album: metadata.album,
|
||||
album_art_uri: metadata.album_art_uri,
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
RendererEvent::QueueUpdated { id, queue_length } => {
|
||||
RendererEventPayload::QueueUpdated {
|
||||
renderer_id: id.0,
|
||||
queue_length,
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if let Ok(json) = serde_json::to_string(&payload) {
|
||||
yield Ok::<_, axum::Error>(Event::default().event("renderer").data(json));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Sse::new(stream).keep_alive(KeepAlive::default())
|
||||
}
|
||||
|
||||
/// Handler SSE pour les événements serveurs de médias
|
||||
///
|
||||
/// Route: GET /api/control/events/servers
|
||||
///
|
||||
/// Diffuse tous les événements liés aux serveurs de médias (global updates, container updates)
|
||||
/// en temps réel via Server-Sent Events.
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/events/servers",
|
||||
responses(
|
||||
(status = 200, description = "Flux SSE des événements serveurs de médias", content_type = "text/event-stream")
|
||||
),
|
||||
tag = "control"
|
||||
)]
|
||||
pub async fn media_server_events_sse(
|
||||
State(control_point): State<Arc<ControlPoint>>,
|
||||
) -> impl IntoResponse {
|
||||
// Convert crossbeam channel to tokio channel for async compatibility
|
||||
let (tx, mut rx_tokio) = tokio::sync::mpsc::unbounded_channel();
|
||||
let rx = control_point.subscribe_media_server_events();
|
||||
|
||||
// Spawn blocking task to bridge crossbeam -> tokio
|
||||
tokio::task::spawn_blocking(move || {
|
||||
while let Ok(event) = rx.recv() {
|
||||
if tx.send(event).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let stream = stream! {
|
||||
while let Some(event) = rx_tokio.recv().await {
|
||||
let timestamp = chrono::Utc::now();
|
||||
|
||||
let payload = match event {
|
||||
MediaServerEvent::GlobalUpdated { server_id, system_update_id } => {
|
||||
MediaServerEventPayload::GlobalUpdated {
|
||||
server_id: server_id.0,
|
||||
system_update_id,
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
MediaServerEvent::ContainersUpdated { server_id, container_ids } => {
|
||||
MediaServerEventPayload::ContainersUpdated {
|
||||
server_id: server_id.0,
|
||||
container_ids,
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if let Ok(json) = serde_json::to_string(&payload) {
|
||||
yield Ok::<_, axum::Error>(Event::default().event("media_server").data(json));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Sse::new(stream).keep_alive(KeepAlive::default())
|
||||
}
|
||||
|
||||
/// Handler SSE pour tous les événements (renderers + serveurs)
|
||||
///
|
||||
/// Route: GET /api/control/events
|
||||
///
|
||||
/// Diffuse tous les événements du control point (renderers et serveurs) en temps réel.
|
||||
/// Chaque événement est catégorisé et inclut un timestamp.
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/events",
|
||||
responses(
|
||||
(status = 200, description = "Flux SSE de tous les événements du control point", content_type = "text/event-stream")
|
||||
),
|
||||
tag = "control"
|
||||
)]
|
||||
pub async fn all_events_sse(
|
||||
State(control_point): State<Arc<ControlPoint>>,
|
||||
) -> impl IntoResponse {
|
||||
// Convert crossbeam channels to tokio channels for async compatibility
|
||||
let (renderer_tx, mut renderer_rx_tokio) = tokio::sync::mpsc::unbounded_channel();
|
||||
let (server_tx, mut server_rx_tokio) = tokio::sync::mpsc::unbounded_channel();
|
||||
|
||||
let renderer_rx = control_point.subscribe_events();
|
||||
let server_rx = control_point.subscribe_media_server_events();
|
||||
|
||||
// Spawn blocking tasks to bridge crossbeam -> tokio
|
||||
tokio::task::spawn_blocking(move || {
|
||||
while let Ok(event) = renderer_rx.recv() {
|
||||
if renderer_tx.send(event).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
while let Ok(event) = server_rx.recv() {
|
||||
if server_tx.send(event).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let stream = stream! {
|
||||
loop {
|
||||
tokio::select! {
|
||||
Some(event) = renderer_rx_tokio.recv() => {
|
||||
let timestamp = chrono::Utc::now();
|
||||
|
||||
let renderer_payload = match event {
|
||||
RendererEvent::StateChanged { id, state } => {
|
||||
RendererEventPayload::StateChanged {
|
||||
renderer_id: id.0,
|
||||
state: state_to_string(state),
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
RendererEvent::PositionChanged { id, position } => {
|
||||
RendererEventPayload::PositionChanged {
|
||||
renderer_id: id.0,
|
||||
track: position.track,
|
||||
rel_time: position.rel_time,
|
||||
track_duration: position.track_duration,
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
RendererEvent::VolumeChanged { id, volume } => {
|
||||
RendererEventPayload::VolumeChanged {
|
||||
renderer_id: id.0,
|
||||
volume,
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
RendererEvent::MuteChanged { id, mute } => {
|
||||
RendererEventPayload::MuteChanged {
|
||||
renderer_id: id.0,
|
||||
mute,
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
RendererEvent::MetadataChanged { id, metadata } => {
|
||||
RendererEventPayload::MetadataChanged {
|
||||
renderer_id: id.0,
|
||||
title: metadata.title,
|
||||
artist: metadata.artist,
|
||||
album: metadata.album,
|
||||
album_art_uri: metadata.album_art_uri,
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
RendererEvent::QueueUpdated { id, queue_length } => {
|
||||
RendererEventPayload::QueueUpdated {
|
||||
renderer_id: id.0,
|
||||
queue_length,
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let payload = UnifiedEventPayload::Renderer(renderer_payload);
|
||||
|
||||
if let Ok(json) = serde_json::to_string(&payload) {
|
||||
yield Ok::<_, axum::Error>(Event::default().event("control").data(json));
|
||||
}
|
||||
}
|
||||
Some(event) = server_rx_tokio.recv() => {
|
||||
let timestamp = chrono::Utc::now();
|
||||
|
||||
let server_payload = match event {
|
||||
MediaServerEvent::GlobalUpdated { server_id, system_update_id } => {
|
||||
MediaServerEventPayload::GlobalUpdated {
|
||||
server_id: server_id.0,
|
||||
system_update_id,
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
MediaServerEvent::ContainersUpdated { server_id, container_ids } => {
|
||||
MediaServerEventPayload::ContainersUpdated {
|
||||
server_id: server_id.0,
|
||||
container_ids,
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let payload = UnifiedEventPayload::MediaServer(server_payload);
|
||||
|
||||
if let Ok(json) = serde_json::to_string(&payload) {
|
||||
yield Ok::<_, axum::Error>(Event::default().event("control").data(json));
|
||||
}
|
||||
}
|
||||
else => break
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Sse::new(stream).keep_alive(KeepAlive::default())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ROUTER
|
||||
// ============================================================================
|
||||
|
||||
/// Crée le router SSE pour les événements du Control Point
|
||||
#[cfg(feature = "pmoserver")]
|
||||
pub fn create_sse_router(control_point: Arc<ControlPoint>) -> Router {
|
||||
use axum::routing::get;
|
||||
|
||||
Router::new()
|
||||
.route("/events", get(all_events_sse))
|
||||
.route("/events/renderers", get(renderer_events_sse))
|
||||
.route("/events/servers", get(media_server_events_sse))
|
||||
.with_state(control_point)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HELPERS
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(feature = "pmoserver")]
|
||||
fn state_to_string(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(s) => s,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user