Implémentation de l'API REST De PMOControl.

This commit is contained in:
2025-12-03 18:59:41 +01:00
parent 825254a2bb
commit 72bf73ef10
21 changed files with 3383 additions and 728 deletions

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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,

View File

@@ -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);

View File

@@ -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(&registry);
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(&registry);
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)) = (&current.object_id, &new_item.object_id)
{
if let (Some(current_obj), Some(new_obj)) = (&current.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(())

View File

@@ -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};

View File

@@ -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)
};

View File

@@ -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
View 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;

View 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
View 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,
}
}