debugagen openhome

This commit is contained in:
2025-12-05 21:59:43 +01:00
parent 2ffd3b5fb5
commit 27aa70d81f
17 changed files with 919 additions and 496 deletions

View File

@@ -19,11 +19,11 @@ use std::thread;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use pmocontrol::model::TrackMetadata;
use pmocontrol::openhome_renderer::{format_seconds, map_openhome_state};
use pmocontrol::{
ControlPoint, DeviceRegistryRead, PlaybackPositionInfo, PlaybackState, RendererEvent,
RendererId, RendererInfo,
};
use pmocontrol::openhome_renderer::{format_seconds, map_openhome_state};
fn main() -> io::Result<()> {
// Logging simple (tracing_subscriber est déjà utilisé dans les autres exemples)
@@ -200,10 +200,7 @@ fn print_openhome_summary(prefix: &str, info: &RendererInfo, cp: &ControlPoint)
match client.transport_state() {
Ok(state) => {
let logical = map_openhome_state(&state);
println!(
"{prefix} Transport state : {} ({:?})",
state, logical
);
println!("{prefix} Transport state : {} ({:?})", state, logical);
}
Err(err) => println!("{prefix} Transport state : <error {err}>"),
}

View File

@@ -8,28 +8,28 @@ use std::fs::{File, OpenOptions};
use std::io::{self, Stdout, Write};
use std::process;
use std::sync::mpsc::TryRecvError;
use std::sync::{mpsc, Arc, Mutex};
use std::sync::{Arc, Mutex, mpsc};
use std::thread;
use std::time::{Duration, Instant};
use anyhow::{anyhow, bail, Context, Result};
use anyhow::{Context, Result, anyhow, bail};
use crossterm::event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent};
use crossterm::execute;
use crossterm::terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Gauge, List, ListItem, ListState, Paragraph};
use ratatui::Terminal;
use serde::de::{DeserializeOwned, Deserializer};
use serde::{Deserialize, Serialize};
use tracing::info;
use tracing_subscriber::fmt::writer::BoxMakeWriter;
use tracing_subscriber::EnvFilter;
use tracing_subscriber::fmt::writer::BoxMakeWriter;
use ureq::http;
use ureq::{Agent, Body};

View File

@@ -14,12 +14,12 @@
use anyhow::Result;
use pmocontrol::PlaybackPosition;
use pmocontrol::model::RendererInfo;
use pmocontrol::openhome_client::{OhInfoClient, OhPlaylistClient, OhTimeClient};
use pmocontrol::{
ControlPoint, MusicRenderer, PlaybackState, PlaybackStatus, RendererCapabilities,
RendererProtocol, TransportControl, VolumeControl,
};
use pmocontrol::model::RendererInfo;
use pmocontrol::openhome_renderer::{format_seconds, map_openhome_state};
use std::env;
use std::thread;
use std::time::Duration;
@@ -85,7 +85,7 @@ fn main() -> Result<()> {
);
print_backend(" ", r);
print_capabilities(" ", &info.capabilities, &info.protocol);
print_openhome_details(" ", info, &cp);
print_openhome_details(" ", info);
}
// 5. Select renderer
@@ -101,7 +101,7 @@ fn main() -> Result<()> {
println!(" Protocol : {:?}", info.protocol);
print_backend(" ", renderer);
print_capabilities(" ", &info.capabilities, &info.protocol);
print_openhome_details(" ", info, &cp);
print_openhome_details(" ", info);
if let Some(upnp) = renderer.as_upnp() {
println!(
@@ -228,7 +228,7 @@ fn print_capabilities(prefix: &str, caps: &RendererCapabilities, proto: &Rendere
println!("{prefix} OH Radio : {}", caps.has_oh_radio);
}
fn print_openhome_details(prefix: &str, info: &RendererInfo, cp: &ControlPoint) {
fn print_openhome_details(prefix: &str, info: &RendererInfo) {
if !info.capabilities.has_oh_playlist
&& !info.capabilities.has_oh_info
&& !info.capabilities.has_oh_time
@@ -236,12 +236,9 @@ fn print_openhome_details(prefix: &str, info: &RendererInfo, cp: &ControlPoint)
return;
}
let registry = cp.registry();
let reg = registry.read().unwrap();
let playlist_client = reg.oh_playlist_client_for_renderer(&info.id);
let info_client = reg.oh_info_client_for_renderer(&info.id);
let time_client = reg.oh_time_client_for_renderer(&info.id);
drop(reg);
let playlist_client = build_playlist_client(info);
let info_client = build_info_client(info);
let time_client = build_time_client(info);
if playlist_client.is_none() && info_client.is_none() && time_client.is_none() {
return;
@@ -260,10 +257,7 @@ fn print_openhome_details(prefix: &str, info: &RendererInfo, cp: &ControlPoint)
match client.transport_state() {
Ok(state) => {
let logical = map_openhome_state(&state);
println!(
"{prefix} Transport state : {} ({:?})",
state, logical
);
println!("{prefix} Transport state : {} ({:?})", state, logical);
}
Err(err) => println!("{prefix} Transport state : <error {err}>"),
}
@@ -290,6 +284,7 @@ fn print_backend(prefix: &str, renderer: &MusicRenderer) {
MusicRenderer::HybridUpnpArylic { .. } => {
"Hybrid UpnpArylicRenderer (UPnP AV / DLNA + ARylic TCP Protocol)"
}
MusicRenderer::OpenHome(_) => "OpenHomeRenderer (native OpenHome stack)",
};
println!("{prefix}Backend : {backend}");
}
@@ -407,3 +402,41 @@ fn volume_demo(renderer: &MusicRenderer) -> Result<()> {
Ok(())
}
fn build_playlist_client(info: &RendererInfo) -> Option<OhPlaylistClient> {
let control_url = info.oh_playlist_control_url.as_ref()?;
let service_type = info.oh_playlist_service_type.as_ref()?;
Some(OhPlaylistClient::new(
control_url.clone(),
service_type.clone(),
))
}
fn build_info_client(info: &RendererInfo) -> Option<OhInfoClient> {
let control_url = info.oh_info_control_url.as_ref()?;
let service_type = info.oh_info_service_type.as_ref()?;
Some(OhInfoClient::new(control_url.clone(), service_type.clone()))
}
fn build_time_client(info: &RendererInfo) -> Option<OhTimeClient> {
let control_url = info.oh_time_control_url.as_ref()?;
let service_type = info.oh_time_service_type.as_ref()?;
Some(OhTimeClient::new(control_url.clone(), service_type.clone()))
}
fn map_openhome_state(raw: &str) -> PlaybackState {
match raw.trim().to_ascii_uppercase().as_str() {
"PLAYING" => PlaybackState::Playing,
"PAUSED" | "PAUSED_PLAYBACK" => PlaybackState::Paused,
"STOPPED" => PlaybackState::Stopped,
"BUFFERING" | "TRANSITIONING" => PlaybackState::Transitioning,
other => PlaybackState::Unknown(other.to_string()),
}
}
fn format_seconds(seconds: u32) -> String {
let hours = seconds / 3600;
let minutes = (seconds % 3600) / 60;
let secs = seconds % 60;
format!("{hours:02}:{minutes:02}:{secs:02}")
}

View File

@@ -6,9 +6,10 @@ use std::sync::{Arc, Mutex, RwLock};
use std::thread;
use std::time::{Duration, Instant};
use anyhow::{anyhow, Context};
use anyhow::{Context, anyhow};
use crossbeam_channel::{Receiver, Sender, unbounded};
use pmoupnp::ssdp::SsdpClient;
use thiserror::Error;
use tracing::{debug, error, info, warn};
use ureq::{Agent, http};
use xmltree::{Element, XMLNode};
@@ -26,7 +27,8 @@ use crate::media_server::{
use crate::media_server_events::spawn_media_server_event_runtime;
use crate::model::TrackMetadata;
use crate::model::{MediaServerEvent, RendererEvent, RendererId, RendererInfo};
use crate::openhome_client::{OhPlaylistClient, OhTrackEntry, parse_track_metadata_from_didl};
use crate::openhome_client::parse_track_metadata_from_didl;
use crate::openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack};
use crate::openhome_renderer::{format_seconds, map_openhome_state};
use crate::playback_queue::{PlaybackItem, PlaybackQueue};
use crate::provider::HttpXmlDescriptionProvider;
@@ -54,6 +56,14 @@ pub struct PlaylistBinding {
pub(crate) auto_play_on_refresh: bool,
}
#[derive(Debug, Error)]
pub enum OpenHomeAccessError {
#[error("Renderer {0} not found")]
RendererNotFound(String),
#[error("Renderer {0} has no OpenHome playlist service")]
PlaylistNotSupported(String),
}
/// Control point minimal :
/// - lance un SsdpClient dans un thread,
/// - passe les SsdpEvent au DiscoveryManager,
@@ -164,9 +174,7 @@ impl ControlPoint {
};
let previous_backend = runtime_cp.runtime.playlist_backend(&info.id);
if previous_backend != backend {
runtime_cp
.runtime
.set_playlist_backend(&info.id, backend);
runtime_cp.runtime.set_playlist_backend(&info.id, backend);
if matches!(backend, PlaylistBackend::OpenHome) {
if let Err(err) = sync_openhome_playlist(
&runtime_cp.registry,
@@ -217,10 +225,12 @@ impl ControlPoint {
artist = metadata.artist.as_deref(),
"Emitting metadata changed event"
);
runtime_cp.emit_renderer_event(RendererEvent::MetadataChanged {
id: renderer_id.clone(),
metadata: metadata.clone(),
});
runtime_cp.emit_renderer_event(
RendererEvent::MetadataChanged {
id: renderer_id.clone(),
metadata: metadata.clone(),
},
);
new_snapshot.last_metadata = Some(metadata);
}
}
@@ -568,8 +578,8 @@ impl ControlPoint {
self.detach_binding_on_user_mutation(renderer_id, "clear_queue");
if self.runtime.uses_openhome_playlist(renderer_id) {
let client = self.openhome_playlist_client(renderer_id)?;
client.delete_all()?;
let renderer = self.openhome_renderer(renderer_id)?;
renderer.openhome_playlist_clear()?;
self.sync_openhome_playlist_for(renderer_id)?;
debug!(
renderer = renderer_id.0.as_str(),
@@ -695,6 +705,45 @@ impl ControlPoint {
self.sync_openhome_playlist_for(renderer_id)
}
pub fn get_openhome_playlist_snapshot(
&self,
renderer_id: &RendererId,
) -> anyhow::Result<OpenHomePlaylistSnapshot> {
let renderer = self.openhome_renderer(renderer_id)?;
renderer.openhome_playlist_snapshot()
}
pub fn clear_openhome_playlist(&self, renderer_id: &RendererId) -> anyhow::Result<()> {
let renderer = self.openhome_renderer(renderer_id)?;
renderer.openhome_playlist_clear()?;
self.sync_openhome_playlist_for(renderer_id)
}
pub fn add_openhome_track(
&self,
renderer_id: &RendererId,
uri: &str,
metadata: &str,
after_id: Option<u32>,
play: bool,
) -> anyhow::Result<()> {
let renderer = self.openhome_renderer(renderer_id)?;
renderer.openhome_playlist_add_track(uri, metadata, after_id, play)?;
self.sync_openhome_playlist_for(renderer_id)
}
pub fn play_openhome_track_id(
&self,
renderer_id: &RendererId,
track_id: u32,
) -> anyhow::Result<()> {
let renderer = self.openhome_renderer(renderer_id)?;
renderer.openhome_playlist_play_id(track_id)?;
self.runtime
.set_playback_source(renderer_id, PlaybackSource::FromQueue);
self.sync_openhome_playlist_for(renderer_id)
}
/// Play the current item from the queue without advancing the index.
///
/// This is useful after a Stop operation to resume playback from the current
@@ -917,10 +966,7 @@ impl ControlPoint {
anyhow!("Renderer {} not found", renderer_id.0)
})?;
debug!(
renderer = renderer_id.0.as_str(),
"User-requested stop"
);
debug!(renderer = renderer_id.0.as_str(), "User-requested stop");
renderer.stop()
}
@@ -1141,17 +1187,18 @@ impl ControlPoint {
)
}
fn sync_openhome_playlist_for(&self, renderer_id: &RendererId) -> anyhow::Result<()> {
sync_openhome_playlist(&self.registry, &self.runtime, &self.event_bus, renderer_id)
fn openhome_renderer(&self, renderer_id: &RendererId) -> anyhow::Result<MusicRenderer> {
let renderer = self
.music_renderer_by_id(renderer_id)
.ok_or_else(|| OpenHomeAccessError::RendererNotFound(renderer_id.0.clone()))?;
if !renderer.info().capabilities.has_oh_playlist {
return Err(OpenHomeAccessError::PlaylistNotSupported(renderer_id.0.clone()).into());
}
Ok(renderer)
}
fn openhome_playlist_client(
&self,
renderer_id: &RendererId,
) -> anyhow::Result<OhPlaylistClient> {
let reg = self.registry.read().unwrap();
reg.oh_playlist_client_for_renderer(renderer_id)
.ok_or_else(|| anyhow!("Renderer {} has no OpenHome playlist", renderer_id.0))
fn sync_openhome_playlist_for(&self, renderer_id: &RendererId) -> anyhow::Result<()> {
sync_openhome_playlist(&self.registry, &self.runtime, &self.event_bus, renderer_id)
}
fn enqueue_items_openhome(
@@ -1163,16 +1210,16 @@ impl ControlPoint {
return Ok(());
}
let client = self.openhome_playlist_client(renderer_id)?;
let renderer = self.openhome_renderer(renderer_id)?;
let mut after_id = self
.runtime
.queue_full_snapshot(renderer_id)
.and_then(|(queue, _)| queue.last().and_then(openhome_track_id_from_item))
.unwrap_or(0);
.and_then(|(queue, _)| queue.last().and_then(openhome_track_id_from_item));
for item in items.iter() {
let metadata = item.to_didl_metadata();
after_id = client.insert(after_id, &item.uri, &metadata)?;
after_id =
Some(renderer.openhome_playlist_add_track(&item.uri, &metadata, after_id, false)?);
}
self.sync_openhome_playlist_for(renderer_id)?;
@@ -1192,28 +1239,49 @@ impl ControlPoint {
let track_id = openhome_track_id_from_item(&item)
.ok_or_else(|| anyhow!("Current OpenHome item has no track id"))?;
let client = self.openhome_playlist_client(renderer_id)?;
client.play_id(track_id)?;
let renderer = self.openhome_renderer(renderer_id)?;
renderer.openhome_playlist_play_id(track_id)?;
self.runtime
.set_playback_source(renderer_id, PlaybackSource::FromQueue);
self.sync_openhome_playlist_for(renderer_id)?;
info!(
renderer = renderer_id.0.as_str(),
track_id,
"Started OpenHome playlist playback (current item)"
track_id, "Started OpenHome playlist playback (current item)"
);
Ok(())
}
fn play_next_openhome(&self, renderer_id: &RendererId) -> anyhow::Result<()> {
let client = self.openhome_playlist_client(renderer_id)?;
client.next()?;
let renderer = self.openhome_renderer(renderer_id)?;
let (queue, current_index) = self
.runtime
.queue_full_snapshot(renderer_id)
.ok_or_else(|| Self::runtime_entry_missing(renderer_id))?;
let next_item = match current_index {
Some(idx) => queue.get(idx + 1),
None => queue.first(),
};
let Some(item) = next_item else {
debug!(
renderer = renderer_id.0.as_str(),
"No OpenHome track available to advance to"
);
self.runtime
.set_playback_source(renderer_id, PlaybackSource::None);
return Ok(());
};
let track_id = openhome_track_id_from_item(item)
.ok_or_else(|| anyhow!("Next OpenHome item has no track id"))?;
renderer.openhome_playlist_play_id(track_id)?;
self.runtime
.set_playback_source(renderer_id, PlaybackSource::FromQueue);
self.sync_openhome_playlist_for(renderer_id)?;
info!(
renderer = renderer_id.0.as_str(),
"Advanced OpenHome playlist to next track"
track_id, "Advanced OpenHome playlist to next track"
);
Ok(())
}
@@ -1389,10 +1457,7 @@ impl RuntimeState {
}
fn uses_openhome_playlist(&self, id: &RendererId) -> bool {
matches!(
self.playlist_backend(id),
PlaylistBackend::OpenHome
)
matches!(self.playlist_backend(id), PlaylistBackend::OpenHome)
}
fn mark_user_stop_requested(&self, id: &RendererId) {
@@ -1688,21 +1753,13 @@ fn playback_item_from_entry(server: &MusicServer, entry: &MediaEntry) -> Option<
const OPENHOME_TRACK_PREFIX: &str = "openhome:";
fn playback_item_from_openhome_entry(entry: &OhTrackEntry) -> PlaybackItem {
let mut item = PlaybackItem::new(entry.uri.clone());
item.object_id = Some(format!("{}{}", OPENHOME_TRACK_PREFIX, entry.id));
if let Some(metadata) = parse_track_metadata_from_didl(&entry.metadata_xml) {
item.title = metadata.title;
item.artist = metadata.artist;
item.album = metadata.album;
item.genre = metadata.genre;
item.album_art_uri = metadata.album_art_uri;
item.date = metadata.date;
item.track_number = metadata.track_number;
item.creator = metadata.creator;
}
fn playback_item_from_openhome_track(track: &OpenHomePlaylistTrack) -> PlaybackItem {
let mut item = PlaybackItem::new(track.uri.clone());
item.object_id = Some(format!("{}{}", OPENHOME_TRACK_PREFIX, track.id));
item.title = track.title.clone();
item.artist = track.artist.clone();
item.album = track.album.clone();
item.album_art_uri = track.album_art_uri.clone();
item
}
@@ -1718,23 +1775,28 @@ fn sync_openhome_playlist(
event_bus: &RendererEventBus,
renderer_id: &RendererId,
) -> anyhow::Result<()> {
let (playlist_client, info_client) = {
let reg = registry.read().unwrap();
let playlist = reg
.oh_playlist_client_for_renderer(renderer_id)
.ok_or_else(|| anyhow!("Renderer has no OpenHome playlist service"))?;
let info = reg.oh_info_client_for_renderer(renderer_id);
(playlist, info)
let renderer = {
let info = {
let reg = registry.read().unwrap();
reg.get_renderer(renderer_id)
.ok_or_else(|| OpenHomeAccessError::RendererNotFound(renderer_id.0.clone()))?
};
MusicRenderer::from_registry_info(info, registry)
.and_then(|r| match r {
MusicRenderer::OpenHome(_) => Some(r),
_ => None,
})
.ok_or_else(|| OpenHomeAccessError::PlaylistNotSupported(renderer_id.0.clone()))?
};
let track_entries = playlist_client.read_all_tracks()?;
let playback_items: Vec<PlaybackItem> = track_entries
let snapshot = renderer.openhome_playlist_snapshot()?;
let playback_items: Vec<PlaybackItem> = snapshot
.tracks
.iter()
.map(playback_item_from_openhome_entry)
.map(playback_item_from_openhome_track)
.collect();
let current_id = info_client
.and_then(|client| client.id().ok());
let current_id = snapshot.current_id;
let current_index = current_id.and_then(|id| {
playback_items
@@ -1904,19 +1966,16 @@ impl OpenHomeEventRuntime {
info: RendererInfo,
event_url: String,
) {
let entry = self
.subscriptions
.entry(key.clone())
.or_insert_with(|| OpenHomeSubscriptionState::new(info.clone(), key.service, event_url.clone()));
let entry = self.subscriptions.entry(key.clone()).or_insert_with(|| {
OpenHomeSubscriptionState::new(info.clone(), key.service, event_url.clone())
});
entry.update(info, event_url);
self.path_index
.insert(entry.callback_path.clone(), key.clone());
if entry.sid.is_none() && entry.should_retry() {
if let Err(err) =
Self::subscribe_entry(self.listener_port, self.http_timeout, entry)
{
if let Err(err) = Self::subscribe_entry(self.listener_port, self.http_timeout, entry) {
warn!(
renderer = entry.renderer.friendly_name.as_str(),
service = entry.service.as_str(),
@@ -1977,18 +2036,19 @@ impl OpenHomeEventRuntime {
}
}
let properties = parse_openhome_propertyset(&entry.renderer.id, &entry.service, &notify.body);
let properties =
parse_openhome_propertyset(&entry.renderer.id, &entry.service, &notify.body);
if properties.is_empty() {
return;
}
match entry.service {
OpenHomeServiceKind::Playlist => {
if properties.iter().any(|(name, _)| is_id_array_property(name)) {
if self
.runtime
.uses_openhome_playlist(&entry.renderer.id)
{
if properties
.iter()
.any(|(name, _)| is_id_array_property(name))
{
if self.runtime.uses_openhome_playlist(&entry.renderer.id) {
if let Err(err) = sync_openhome_playlist(
&self.registry,
&self.runtime,
@@ -2013,11 +2073,7 @@ impl OpenHomeEventRuntime {
}
}
fn handle_info_properties(
&self,
renderer_id: &RendererId,
properties: Vec<(String, String)>,
) {
fn handle_info_properties(&self, renderer_id: &RendererId, properties: Vec<(String, String)>) {
let mut metadata_xml: Option<String> = None;
let mut transport_state: Option<String> = None;
let mut track_id: Option<u32> = None;
@@ -2090,11 +2146,7 @@ impl OpenHomeEventRuntime {
}
}
fn handle_time_properties(
&self,
renderer_id: &RendererId,
properties: Vec<(String, String)>,
) {
fn handle_time_properties(&self, renderer_id: &RendererId, properties: Vec<(String, String)>) {
let mut duration: Option<u32> = None;
let mut seconds: Option<u32> = None;
@@ -2123,10 +2175,9 @@ impl OpenHomeEventRuntime {
new_position.rel_time = Some(format_seconds(s));
}
self.runtime
.update_snapshot_with(renderer_id, |snapshot| {
snapshot.position = Some(new_position.clone());
});
self.runtime.update_snapshot_with(renderer_id, |snapshot| {
snapshot.position = Some(new_position.clone());
});
let _ = self.event_tx.send(RendererEvent::PositionChanged {
id: renderer_id.clone(),
@@ -2175,10 +2226,7 @@ impl OpenHomeEventRuntime {
let response = build_agent(http_timeout).run(request)?;
if !response.status().is_success() {
anyhow::bail!(
"SUBSCRIBE returned HTTP {}",
response.status()
);
anyhow::bail!("SUBSCRIBE returned HTTP {}", response.status());
}
let sid = response
@@ -2226,10 +2274,7 @@ impl OpenHomeEventRuntime {
.map_err(anyhow::Error::new)?;
let response = build_agent(http_timeout).run(request)?;
if !response.status().is_success() {
anyhow::bail!(
"SUBSCRIBE renewal failed with {}",
response.status()
);
anyhow::bail!("SUBSCRIBE renewal failed with {}", response.status());
}
let timeout = parse_timeout(
response
@@ -2332,9 +2377,7 @@ impl OpenHomeSubscriptionState {
}
fn update(&mut self, renderer: RendererInfo, event_url: String) {
if self.renderer.location != renderer.location
|| self.event_sub_url != event_url
{
if self.renderer.location != renderer.location || self.event_sub_url != event_url {
self.event_sub_url = event_url;
self.sid = None;
self.expires_at = None;
@@ -2364,15 +2407,15 @@ struct OpenHomeIncomingNotify {
body: Vec<u8>,
}
fn run_openhome_http_listener(
listener: TcpListener,
notify_tx: Sender<OpenHomeIncomingNotify>,
) {
fn run_openhome_http_listener(listener: TcpListener, notify_tx: Sender<OpenHomeIncomingNotify>) {
for stream in listener.incoming() {
match stream {
Ok(mut stream) => {
if let Err(err) = stream.set_read_timeout(Some(Duration::from_secs(5))) {
warn!("Failed to set read timeout on OpenHome notify connection: {}", err);
warn!(
"Failed to set read timeout on OpenHome notify connection: {}",
err
);
}
match read_openhome_http_request(&mut stream) {
@@ -2495,11 +2538,7 @@ fn build_openhome_callback_path(id: &RendererId, service: OpenHomeServiceKind) -
id.hash(&mut hasher);
service.hash(&mut hasher);
let suffix = hasher.finish();
format!(
"/openhome-events/{}/{:x}",
service.as_str(),
suffix
)
format!("/openhome-events/{}/{:x}", service.as_str(), suffix)
}
fn parse_openhome_propertyset(
@@ -2536,9 +2575,7 @@ fn parse_openhome_propertyset(
}
fn is_id_array_property(name: &str) -> bool {
name.trim()
.to_ascii_lowercase()
.ends_with("idarray")
name.trim().to_ascii_lowercase().ends_with("idarray")
}
fn empty_playback_position() -> PlaybackPositionInfo {

View File

@@ -12,6 +12,7 @@ pub mod media_server;
pub mod model;
pub mod music_renderer;
pub mod openhome_client;
pub mod openhome_playlist;
pub mod openhome_renderer;
pub mod playback_queue;
pub mod provider;
@@ -45,6 +46,7 @@ pub use media_server::{
UpnpMediaServer,
};
pub use music_renderer::MusicRenderer;
pub use openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack};
pub use openhome_renderer::OpenHomeRenderer;
pub use playback_queue::{PlaybackItem, PlaybackQueue};
pub use rendering_control_client::RenderingControlClient;

View File

@@ -10,6 +10,7 @@ use std::sync::{Arc, RwLock};
use crate::capabilities::{PlaybackPositionInfo, PlaybackStatus};
use crate::model::{RendererId, RendererInfo, RendererProtocol};
use crate::openhome_playlist::OpenHomePlaylistSnapshot;
use crate::{
ArylicTcpRenderer, DeviceRegistry, LinkPlayRenderer, OpenHomeRenderer, PlaybackPosition,
PlaybackState, TransportControl, UpnpRenderer, VolumeControl,
@@ -101,10 +102,12 @@ impl MusicRenderer {
info: RendererInfo,
registry: &Arc<RwLock<DeviceRegistry>>,
) -> Option<Self> {
if matches!(info.protocol, RendererProtocol::OpenHomeOnly | RendererProtocol::Hybrid) {
if matches!(
info.protocol,
RendererProtocol::OpenHomeOnly | RendererProtocol::Hybrid
) {
if let Some(renderer) = {
let reg = registry.read().unwrap();
let renderer = OpenHomeRenderer::new(info.clone(), &*reg);
let renderer = OpenHomeRenderer::new(info.clone());
renderer.has_any_openhome_service().then_some(renderer)
} {
return Some(MusicRenderer::OpenHome(renderer));
@@ -157,6 +160,64 @@ impl MusicRenderer {
RendererProtocol::OpenHomeOnly => None,
}
}
pub fn openhome_playlist_snapshot(&self) -> Result<OpenHomePlaylistSnapshot> {
match self {
MusicRenderer::OpenHome(renderer) => renderer.snapshot_openhome_playlist(),
_ => Err(op_not_supported(
"openhome_playlist_snapshot",
self.unsupported_backend_name(),
)),
}
}
pub fn openhome_playlist_clear(&self) -> Result<()> {
match self {
MusicRenderer::OpenHome(renderer) => renderer.clear_openhome_playlist(),
_ => Err(op_not_supported(
"openhome_playlist_clear",
self.unsupported_backend_name(),
)),
}
}
pub fn openhome_playlist_add_track(
&self,
uri: &str,
metadata: &str,
after_id: Option<u32>,
play: bool,
) -> Result<u32> {
match self {
MusicRenderer::OpenHome(renderer) => {
renderer.add_track_openhome(uri, metadata, after_id, play)
}
_ => Err(op_not_supported(
"openhome_playlist_add_track",
self.unsupported_backend_name(),
)),
}
}
pub fn openhome_playlist_play_id(&self, id: u32) -> Result<()> {
match self {
MusicRenderer::OpenHome(renderer) => renderer.play_openhome_track_id(id),
_ => Err(op_not_supported(
"openhome_playlist_play_id",
self.unsupported_backend_name(),
)),
}
}
fn unsupported_backend_name(&self) -> &'static str {
match self {
MusicRenderer::Upnp(_) => "UPnP",
MusicRenderer::OpenHome(_) => "OpenHome",
MusicRenderer::LinkPlay(_) => "LinkPlay",
MusicRenderer::ArylicTcp(_) => "ArylicTcp",
MusicRenderer::HybridUpnpArylic { .. } => "HybridUpnpArylic",
}
}
}
/// Transport control façade that dispatches to whichever backend can fulfill

View File

@@ -133,35 +133,8 @@ pub struct QueueSnapshot {
// OPENHOME PLAYLIST
// ============================================================================
/// Snapshot de la playlist native OpenHome
#[cfg(feature = "pmoserver")]
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct OpenHomePlaylistSnapshot {
/// ID du renderer concerné
pub renderer_id: String,
/// ID courant dans la playlist (si connu)
pub current_id: Option<u32>,
/// Tracks présents dans la playlist native
pub tracks: Vec<OpenHomePlaylistTrack>,
}
/// Track issue de la playlist native OpenHome
#[cfg(feature = "pmoserver")]
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct OpenHomePlaylistTrack {
/// ID interne OpenHome
pub id: u32,
/// URI du flux
pub uri: String,
/// Titre
pub title: Option<String>,
/// Artiste
pub artist: Option<String>,
/// Album
pub album: Option<String>,
/// Pochette (si disponible)
pub album_art_uri: Option<String>,
}
pub use crate::openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack};
/// Requête pour ajouter un track à la playlist OpenHome
#[cfg(feature = "pmoserver")]

View File

@@ -1,6 +1,6 @@
use anyhow::{Result, anyhow};
use crate::model::TrackMetadata;
use crate::soap_client::{SoapCallResult, invoke_upnp_action};
use anyhow::{Result, anyhow};
use pmoupnp::soap::SoapEnvelope;
use xmltree::{Element, XMLNode};
@@ -64,12 +64,8 @@ impl OhPlaylistClient {
.join(",");
let args = [("aIdList", id_list_csv.as_str())];
let call_result = invoke_upnp_action(
&self.control_url,
&self.service_type,
"ReadList",
&args,
)?;
let call_result =
invoke_upnp_action(&self.control_url, &self.service_type, "ReadList", &args)?;
let envelope = ensure_success("ReadList", &call_result)?;
let response = find_child_with_suffix(&envelope.body.content, "ReadListResponse")
@@ -87,12 +83,8 @@ impl OhPlaylistClient {
("aMetadata", metadata),
];
let call_result = invoke_upnp_action(
&self.control_url,
&self.service_type,
"Insert",
&args,
)?;
let call_result =
invoke_upnp_action(&self.control_url, &self.service_type, "Insert", &args)?;
let envelope = ensure_success("Insert", &call_result)?;
let response = find_child_with_suffix(&envelope.body.content, "InsertResponse")
@@ -109,37 +101,29 @@ impl OhPlaylistClient {
let id_str = id.to_string();
let args = [("aId", id_str.as_str())];
let call_result = invoke_upnp_action(
&self.control_url,
&self.service_type,
"PlayId",
&args,
)?;
let call_result =
invoke_upnp_action(&self.control_url, &self.service_type, "PlayId", &args)?;
handle_action_response("PlayId", &call_result)
}
pub fn play(&self) -> Result<()> {
let call_result =
invoke_upnp_action(&self.control_url, &self.service_type, "Play", &[])?;
let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Play", &[])?;
handle_action_response("Play", &call_result)
}
pub fn pause(&self) -> Result<()> {
let call_result =
invoke_upnp_action(&self.control_url, &self.service_type, "Pause", &[])?;
let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Pause", &[])?;
handle_action_response("Pause", &call_result)
}
pub fn stop(&self) -> Result<()> {
let call_result =
invoke_upnp_action(&self.control_url, &self.service_type, "Stop", &[])?;
let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Stop", &[])?;
handle_action_response("Stop", &call_result)
}
pub fn next(&self) -> Result<()> {
let call_result =
invoke_upnp_action(&self.control_url, &self.service_type, "Next", &[])?;
let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Next", &[])?;
handle_action_response("Next", &call_result)
}
@@ -166,12 +150,8 @@ impl OhPlaylistClient {
let id_str = id.to_string();
let args = [("aId", id_str.as_str())];
let call_result = invoke_upnp_action(
&self.control_url,
&self.service_type,
"DeleteId",
&args,
)?;
let call_result =
invoke_upnp_action(&self.control_url, &self.service_type, "DeleteId", &args)?;
handle_action_response("DeleteId", &call_result)
}
@@ -183,12 +163,8 @@ impl OhPlaylistClient {
}
pub fn tracks_max(&self) -> Result<u32> {
let call_result = invoke_upnp_action(
&self.control_url,
&self.service_type,
"TracksMax",
&[],
)?;
let call_result =
invoke_upnp_action(&self.control_url, &self.service_type, "TracksMax", &[])?;
let envelope = ensure_success("TracksMax", &call_result)?;
let response = find_child_with_suffix(&envelope.body.content, "TracksMaxResponse")
@@ -202,12 +178,8 @@ impl OhPlaylistClient {
}
pub fn id_array(&self) -> Result<Vec<u32>> {
let call_result = invoke_upnp_action(
&self.control_url,
&self.service_type,
"IdArray",
&[],
)?;
let call_result =
invoke_upnp_action(&self.control_url, &self.service_type, "IdArray", &[])?;
let envelope = ensure_success("IdArray", &call_result)?;
let response = find_child_with_suffix(&envelope.body.content, "IdArrayResponse")
.ok_or_else(|| anyhow!("Missing IdArrayResponse element in SOAP body"))?;
@@ -259,8 +231,7 @@ impl OhInfoClient {
}
pub fn track(&self) -> Result<OhInfoTrack> {
let call_result =
invoke_upnp_action(&self.control_url, &self.service_type, "Track", &[])?;
let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Track", &[])?;
let envelope = ensure_success("Track", &call_result)?;
let response = find_child_with_suffix(&envelope.body.content, "TrackResponse")
@@ -275,8 +246,7 @@ impl OhInfoClient {
}
pub fn next(&self) -> Result<OhInfoTrack> {
let call_result =
invoke_upnp_action(&self.control_url, &self.service_type, "Next", &[])?;
let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Next", &[])?;
let envelope = ensure_success("Next", &call_result)?;
let response = find_child_with_suffix(&envelope.body.content, "NextResponse")
@@ -291,8 +261,7 @@ impl OhInfoClient {
}
pub fn id(&self) -> Result<u32> {
let call_result =
invoke_upnp_action(&self.control_url, &self.service_type, "Id", &[])?;
let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Id", &[])?;
let envelope = ensure_success("Id", &call_result)?;
let response = find_child_with_suffix(&envelope.body.content, "IdResponse")
@@ -305,17 +274,12 @@ impl OhInfoClient {
}
pub fn transport_state(&self) -> Result<String> {
let call_result = invoke_upnp_action(
&self.control_url,
&self.service_type,
"TransportState",
&[],
)?;
let call_result =
invoke_upnp_action(&self.control_url, &self.service_type, "TransportState", &[])?;
let envelope = ensure_success("TransportState", &call_result)?;
let response =
find_child_with_suffix(&envelope.body.content, "TransportStateResponse")
.ok_or_else(|| anyhow!("Missing TransportStateResponse element in SOAP body"))?;
let response = find_child_with_suffix(&envelope.body.content, "TransportStateResponse")
.ok_or_else(|| anyhow!("Missing TransportStateResponse element in SOAP body"))?;
let state = extract_child_text(response, "aState")?;
Ok(state)
}
@@ -341,8 +305,7 @@ impl OhTimeClient {
}
pub fn position(&self) -> Result<OhTimePosition> {
let call_result =
invoke_upnp_action(&self.control_url, &self.service_type, "Time", &[])?;
let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Time", &[])?;
let envelope = ensure_success("Time", &call_result)?;
let response = find_child_with_suffix(&envelope.body.content, "TimeResponse")
@@ -381,8 +344,7 @@ impl OhVolumeClient {
}
pub fn volume(&self) -> Result<u16> {
let call_result =
invoke_upnp_action(&self.control_url, &self.service_type, "Volume", &[])?;
let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Volume", &[])?;
let envelope = ensure_success("Volume", &call_result)?;
let response = find_child_with_suffix(&envelope.body.content, "VolumeResponse")
.ok_or_else(|| anyhow!("Missing VolumeResponse element in SOAP body"))?;
@@ -396,18 +358,13 @@ impl OhVolumeClient {
pub fn set_volume(&self, vol: u16) -> Result<()> {
let vol_str = vol.to_string();
let args = [("aVolume", vol_str.as_str())];
let call_result = invoke_upnp_action(
&self.control_url,
&self.service_type,
"SetVolume",
&args,
)?;
let call_result =
invoke_upnp_action(&self.control_url, &self.service_type, "SetVolume", &args)?;
handle_action_response("SetVolume", &call_result)
}
pub fn mute(&self) -> Result<bool> {
let call_result =
invoke_upnp_action(&self.control_url, &self.service_type, "Mute", &[])?;
let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Mute", &[])?;
let envelope = ensure_success("Mute", &call_result)?;
let response = find_child_with_suffix(&envelope.body.content, "MuteResponse")
.ok_or_else(|| anyhow!("Missing MuteResponse element in SOAP body"))?;
@@ -418,12 +375,8 @@ impl OhVolumeClient {
pub fn set_mute(&self, mute: bool) -> Result<()> {
let mute_str = if mute { "1" } else { "0" };
let args = [("aMute", mute_str)];
let call_result = invoke_upnp_action(
&self.control_url,
&self.service_type,
"SetMute",
&args,
)?;
let call_result =
invoke_upnp_action(&self.control_url, &self.service_type, "SetMute", &args)?;
handle_action_response("SetMute", &call_result)
}
}
@@ -445,12 +398,8 @@ impl OhRadioClient {
pub fn play_channel(&self, id: u32) -> Result<()> {
let id_str = id.to_string();
let args = [("aId", id_str.as_str())];
let call_result = invoke_upnp_action(
&self.control_url,
&self.service_type,
"PlayChannel",
&args,
)?;
let call_result =
invoke_upnp_action(&self.control_url, &self.service_type, "PlayChannel", &args)?;
handle_action_response("PlayChannel", &call_result)
}
@@ -529,10 +478,7 @@ fn parse_track_entry(elem: &Element) -> Result<OhTrackEntry> {
})
}
fn ensure_success<'a>(
action: &str,
call_result: &'a SoapCallResult,
) -> Result<&'a SoapEnvelope> {
fn ensure_success<'a>(action: &str, call_result: &'a SoapCallResult) -> Result<&'a SoapEnvelope> {
if !call_result.status.is_success() {
if let Some(env) = &call_result.envelope {
if let Some(err) = parse_upnp_error(env) {
@@ -631,7 +577,10 @@ fn extract_child_text(parent: &Element, suffix: &str) -> Result<String> {
fn extract_child_text_optional(parent: &Element, suffix: &str) -> Result<Option<String>> {
if let Some(child) = find_child_with_suffix(parent, suffix) {
let text = child.get_text().map(|t| t.trim().to_string()).unwrap_or_default();
let text = child
.get_text()
.map(|t| t.trim().to_string())
.unwrap_or_default();
Ok(Some(text))
} else {
Ok(None)
@@ -644,7 +593,10 @@ fn extract_child_text_any(parent: &Element, suffixes: &[&str]) -> Result<String>
return Ok(text);
}
}
Err(anyhow!("Missing {} element in response", suffixes.join(" or ")))
Err(anyhow!(
"Missing {} element in response",
suffixes.join(" or ")
))
}
fn parse_bool(value: &str) -> Result<bool> {
@@ -678,8 +630,8 @@ pub(crate) fn decode_base64(input: &str) -> Result<Vec<u8>> {
if byte == b'\r' || byte == b'\n' || byte == b' ' || byte == b'\t' {
continue;
}
let val = value(byte)
.ok_or_else(|| anyhow!("Invalid base64 character '{}'", byte as char))?;
let val =
value(byte).ok_or_else(|| anyhow!("Invalid base64 character '{}'", byte as char))?;
buffer = (buffer << 6) | (val as u32);
bits_collected += 6;
if bits_collected >= 8 {

View File

@@ -0,0 +1,29 @@
/// Snapshot de la playlist native OpenHome pour un renderer donné.
#[cfg_attr(feature = "pmoserver", derive(serde::Serialize, utoipa::ToSchema))]
#[derive(Debug, Clone)]
pub struct OpenHomePlaylistSnapshot {
/// ID du renderer concerné.
pub renderer_id: String,
/// ID courant dans la playlist (si connu).
pub current_id: Option<u32>,
/// Tracks présents dans la playlist native.
pub tracks: Vec<OpenHomePlaylistTrack>,
}
/// Représentation d'un track OpenHome tel qu'exposé par la playlist native.
#[cfg_attr(feature = "pmoserver", derive(serde::Serialize, utoipa::ToSchema))]
#[derive(Debug, Clone)]
pub struct OpenHomePlaylistTrack {
/// ID interne OpenHome du track.
pub id: u32,
/// URI de lecture.
pub uri: String,
/// Titre (optionnel si non fourni par le renderer).
pub title: Option<String>,
/// Artiste (optionnel).
pub artist: Option<String>,
/// Album (optionnel).
pub album: Option<String>,
/// URI de pochette (optionnelle).
pub album_art_uri: Option<String>,
}

View File

@@ -1,4 +1,3 @@
use anyhow::{Result, anyhow};
use crate::capabilities::{
PlaybackPosition, PlaybackPositionInfo, PlaybackState, PlaybackStatus, TransportControl,
VolumeControl,
@@ -6,9 +5,11 @@ use crate::capabilities::{
use crate::model::{RendererId, RendererInfo, RendererProtocol};
use crate::music_renderer::op_not_supported;
use crate::openhome_client::{
OhInfoClient, OhPlaylistClient, OhRadioClient, OhTimeClient, OhVolumeClient,
OhInfoClient, OhPlaylistClient, OhRadioClient, OhTimeClient, OhTrackEntry, OhVolumeClient,
parse_track_metadata_from_didl,
};
use crate::registry::DeviceRegistry;
use crate::openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack};
use anyhow::{Result, anyhow};
use tracing::debug;
#[derive(Clone, Debug)]
@@ -23,14 +24,13 @@ pub struct OpenHomeRenderer {
}
impl OpenHomeRenderer {
pub fn new(info: RendererInfo, registry: &DeviceRegistry) -> Self {
let id = info.id.clone();
pub fn new(info: RendererInfo) -> Self {
Self {
playlist: registry.oh_playlist_client_for_renderer(&id),
info_client: registry.oh_info_client_for_renderer(&id),
time_client: registry.oh_time_client_for_renderer(&id),
volume_client: registry.oh_volume_client_for_renderer(&id),
radio_client: registry.oh_radio_client_for_renderer(&id),
playlist: build_playlist_client(&info),
info_client: build_info_client(&info),
time_client: build_time_client(&info),
volume_client: build_volume_client(&info),
radio_client: build_radio_client(&info),
info,
}
}
@@ -90,6 +90,53 @@ impl OpenHomeRenderer {
.as_ref()
.ok_or_else(|| op_not_supported(op, "OpenHome Volume"))
}
pub(crate) fn snapshot_openhome_playlist(&self) -> Result<OpenHomePlaylistSnapshot> {
let playlist = self.playlist_client_for("snapshot_openhome_playlist")?;
let entries = playlist.read_all_tracks()?;
let current_id = self
.info_client
.as_ref()
.and_then(|client| client.id().ok());
let tracks = entries.iter().map(convert_oh_track_entry).collect();
Ok(OpenHomePlaylistSnapshot {
renderer_id: self.info.id.0.clone(),
current_id,
tracks,
})
}
pub(crate) fn clear_openhome_playlist(&self) -> Result<()> {
let playlist = self.playlist_client_for("clear_openhome_playlist")?;
playlist.delete_all()
}
pub(crate) fn add_track_openhome(
&self,
uri: &str,
metadata: &str,
after_id: Option<u32>,
play: bool,
) -> Result<u32> {
let playlist = self.playlist_client_for("add_track_openhome")?;
let insert_after = match after_id {
Some(id) => id,
None => playlist.id_array()?.last().copied().unwrap_or(0),
};
let new_id = playlist.insert(insert_after, uri, metadata)?;
if play {
playlist.play_id(new_id)?;
}
Ok(new_id)
}
pub(crate) fn play_openhome_track_id(&self, id: u32) -> Result<()> {
let playlist = self.playlist_client_for("play_openhome_track_id")?;
playlist.play_id(id)
}
}
impl TransportControl for OpenHomeRenderer {
@@ -237,3 +284,54 @@ pub(crate) fn format_seconds(seconds: u32) -> String {
let secs = seconds % 60;
format!("{hours:02}:{minutes:02}:{secs:02}")
}
fn convert_oh_track_entry(entry: &OhTrackEntry) -> OpenHomePlaylistTrack {
let metadata = parse_track_metadata_from_didl(&entry.metadata_xml);
OpenHomePlaylistTrack {
id: entry.id,
uri: entry.uri.clone(),
title: metadata.as_ref().and_then(|m| m.title.clone()),
artist: metadata.as_ref().and_then(|m| m.artist.clone()),
album: metadata.as_ref().and_then(|m| m.album.clone()),
album_art_uri: metadata.and_then(|m| m.album_art_uri),
}
}
fn build_playlist_client(info: &RendererInfo) -> Option<OhPlaylistClient> {
let control_url = info.oh_playlist_control_url.as_ref()?;
let service_type = info.oh_playlist_service_type.as_ref()?;
Some(OhPlaylistClient::new(
control_url.clone(),
service_type.clone(),
))
}
fn build_info_client(info: &RendererInfo) -> Option<OhInfoClient> {
let control_url = info.oh_info_control_url.as_ref()?;
let service_type = info.oh_info_service_type.as_ref()?;
Some(OhInfoClient::new(control_url.clone(), service_type.clone()))
}
fn build_time_client(info: &RendererInfo) -> Option<OhTimeClient> {
let control_url = info.oh_time_control_url.as_ref()?;
let service_type = info.oh_time_service_type.as_ref()?;
Some(OhTimeClient::new(control_url.clone(), service_type.clone()))
}
fn build_volume_client(info: &RendererInfo) -> Option<OhVolumeClient> {
let control_url = info.oh_volume_control_url.as_ref()?;
let service_type = info.oh_volume_service_type.as_ref()?;
Some(OhVolumeClient::new(
control_url.clone(),
service_type.clone(),
))
}
fn build_radio_client(info: &RendererInfo) -> Option<OhRadioClient> {
let control_url = info.oh_radio_control_url.as_ref()?;
let service_type = info.oh_radio_service_type.as_ref()?;
Some(OhRadioClient::new(
control_url.clone(),
service_type.clone(),
))
}

View File

@@ -64,7 +64,10 @@ impl PlaybackItem {
if let Some(album_art) = &self.album_art_uri {
let escaped_art = escape(album_art);
didl.push_str(&format!("<upnp:albumArtURI>{}</upnp:albumArtURI>", escaped_art));
didl.push_str(&format!(
"<upnp:albumArtURI>{}</upnp:albumArtURI>",
escaped_art
));
}
if let Some(date) = &self.date {

View File

@@ -4,25 +4,20 @@
//! et naviguer dans les serveurs de médias.
#[cfg(feature = "pmoserver")]
use crate::control_point::ControlPoint;
use crate::control_point::{ControlPoint, OpenHomeAccessError};
#[cfg(feature = "pmoserver")]
use crate::media_server::{MediaBrowser, MediaEntry, MediaResource, MusicServer, ServerId};
#[cfg(feature = "pmoserver")]
use crate::model::{RendererCapabilities, RendererId, RendererProtocol};
#[cfg(feature = "pmoserver")]
use crate::playback_queue::PlaybackItem;
#[cfg(feature = "pmoserver")]
use crate::openapi::{
AttachPlaylistRequest, AttachedPlaylistInfo, BrowseResponse, ContainerEntry, ErrorResponse,
MediaServerSummary, OpenHomePlaylistAddRequest, OpenHomePlaylistSnapshot,
OpenHomePlaylistTrack, PlayContentRequest, QueueItem, QueueSnapshot,
RendererCapabilitiesSummary, RendererProtocolSummary, RendererState, RendererSummary,
SuccessResponse, VolumeSetRequest,
MediaServerSummary, OpenHomePlaylistAddRequest, OpenHomePlaylistSnapshot, PlayContentRequest,
QueueItem, QueueSnapshot, RendererCapabilitiesSummary, RendererProtocolSummary, RendererState,
RendererSummary, SuccessResponse, VolumeSetRequest,
};
#[cfg(feature = "pmoserver")]
use crate::openhome_client::{
OhInfoClient, OhPlaylistClient, OhTrackEntry, parse_track_metadata_from_didl,
};
use crate::playback_queue::PlaybackItem;
#[cfg(feature = "pmoserver")]
use crate::{PlaybackPosition, PlaybackStatus, TransportControl, VolumeControl};
@@ -178,7 +173,10 @@ async fn get_renderer_state(
.unwrap_or((None, None));
// Volume et mute
let volume = renderer_clone.volume().ok().and_then(|v| u8::try_from(v).ok());
let volume = renderer_clone
.volume()
.ok()
.and_then(|v| u8::try_from(v).ok());
let mute = renderer_clone.mute().ok();
(transport_state, position_ms, duration_ms, volume, mute)
@@ -610,9 +608,8 @@ async fn resume_renderer(
let control_point = state.control_point.clone();
let rid_clone = rid.clone();
let resume_task = tokio::task::spawn_blocking(move || {
control_point.play_current_from_queue(&rid_clone)
});
let resume_task =
tokio::task::spawn_blocking(move || control_point.play_current_from_queue(&rid_clone));
time::timeout(QUEUE_COMMAND_TIMEOUT, resume_task)
.await
@@ -641,10 +638,7 @@ async fn resume_renderer(
)
})?
.map_err(|e| {
warn!(
"Failed to resume renderer {}: {}",
renderer_id, e
);
warn!("Failed to resume renderer {}: {}", renderer_id, e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
@@ -681,9 +675,8 @@ async fn next_renderer(
let control_point = state.control_point.clone();
let rid_clone = rid.clone();
let next_task = tokio::task::spawn_blocking(move || {
control_point.play_next_from_queue(&rid_clone)
});
let next_task =
tokio::task::spawn_blocking(move || control_point.play_next_from_queue(&rid_clone));
time::timeout(QUEUE_COMMAND_TIMEOUT, next_task)
.await
@@ -771,9 +764,7 @@ async fn set_renderer_volume(
let renderer_clone = renderer.clone();
let volume = req.volume;
let volume_task = tokio::task::spawn_blocking(move || {
renderer_clone.set_volume(volume as u16)
});
let volume_task = tokio::task::spawn_blocking(move || renderer_clone.set_volume(volume as u16));
time::timeout(VOLUME_COMMAND_TIMEOUT, volume_task)
.await
@@ -1195,20 +1186,11 @@ async fn get_openhome_playlist(
Path(renderer_id): Path<String>,
) -> Result<Json<OpenHomePlaylistSnapshot>, (StatusCode, Json<ErrorResponse>)> {
let rid = RendererId(renderer_id.clone());
let (playlist_client, info_client) =
openhome_clients_for_renderer(&state.control_point, &rid)?;
let control_point = Arc::clone(&state.control_point);
let rid_for_task = rid.clone();
let fetch_task = tokio::task::spawn_blocking(move || -> anyhow::Result<OpenHomePlaylistSnapshot> {
let entries = playlist_client.read_all_tracks()?;
let current_id = info_client
.and_then(|client| client.id().ok());
let tracks: Vec<OpenHomePlaylistTrack> =
entries.iter().map(convert_openhome_track).collect();
Ok(OpenHomePlaylistSnapshot {
renderer_id: rid.0,
current_id,
tracks,
})
let fetch_task = tokio::task::spawn_blocking(move || {
control_point.get_openhome_playlist_snapshot(&rid_for_task)
});
let snapshot = fetch_task
@@ -1232,12 +1214,7 @@ async fn get_openhome_playlist(
error = %e,
"Failed to read OpenHome playlist"
);
(
StatusCode::BAD_GATEWAY,
Json(ErrorResponse {
error: format!("Failed to read OpenHome playlist: {}", e),
}),
)
map_openhome_error(&rid, e, "read OpenHome playlist")
})?;
Ok(Json(snapshot))
@@ -1262,11 +1239,11 @@ async fn clear_openhome_playlist(
Path(renderer_id): Path<String>,
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
let rid = RendererId(renderer_id.clone());
let (playlist_client, _) =
openhome_clients_for_renderer(&state.control_point, &rid)?;
let control_point = Arc::clone(&state.control_point);
let rid_for_task = rid.clone();
let clear_task =
tokio::task::spawn_blocking(move || playlist_client.delete_all());
tokio::task::spawn_blocking(move || control_point.clear_openhome_playlist(&rid_for_task));
time::timeout(QUEUE_COMMAND_TIMEOUT, clear_task)
.await
@@ -1305,22 +1282,9 @@ async fn clear_openhome_playlist(
error = %e,
"Failed to clear OpenHome playlist"
);
(
StatusCode::BAD_GATEWAY,
Json(ErrorResponse {
error: format!("Failed to clear OpenHome playlist: {}", e),
}),
)
map_openhome_error(&rid, e, "clear OpenHome playlist")
})?;
if let Err(err) = state.control_point.refresh_openhome_playlist(&rid) {
warn!(
renderer = renderer_id.as_str(),
error = %err,
"Failed to refresh queue after OpenHome clear"
);
}
Ok(Json(SuccessResponse {
message: "OpenHome playlist cleared".to_string(),
}))
@@ -1347,26 +1311,17 @@ async fn add_openhome_playlist_item(
Json(req): Json<OpenHomePlaylistAddRequest>,
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
let rid = RendererId(renderer_id.clone());
let (playlist_client, _) =
openhome_clients_for_renderer(&state.control_point, &rid)?;
let control_point = Arc::clone(&state.control_point);
let rid_for_task = rid.clone();
let add_task = tokio::task::spawn_blocking(move || -> anyhow::Result<()> {
let mut after_id = if let Some(id) = req.after_id {
id
} else {
playlist_client
.id_array()?
.last()
.copied()
.unwrap_or(0)
};
let new_id =
playlist_client.insert(after_id, &req.uri, &req.metadata)?;
after_id = new_id;
if req.play {
playlist_client.play_id(after_id)?;
}
Ok(())
let add_task = tokio::task::spawn_blocking(move || {
control_point.add_openhome_track(
&rid_for_task,
&req.uri,
&req.metadata,
req.after_id,
req.play,
)
});
time::timeout(QUEUE_COMMAND_TIMEOUT, add_task)
@@ -1406,22 +1361,9 @@ async fn add_openhome_playlist_item(
error = %e,
"Failed to add OpenHome track"
);
(
StatusCode::BAD_GATEWAY,
Json(ErrorResponse {
error: format!("Failed to add OpenHome track: {}", e),
}),
)
map_openhome_error(&rid, e, "add OpenHome track")
})?;
if let Err(err) = state.control_point.refresh_openhome_playlist(&rid) {
warn!(
renderer = renderer_id.as_str(),
error = %err,
"Failed to refresh queue after OpenHome add"
);
}
Ok(Json(SuccessResponse {
message: "Track added to OpenHome playlist".to_string(),
}))
@@ -1447,9 +1389,6 @@ async fn play_openhome_track(
Path((renderer_id, track_id)): Path<(String, String)>,
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
let rid = RendererId(renderer_id.clone());
let (playlist_client, _) =
openhome_clients_for_renderer(&state.control_point, &rid)?;
let parsed_id = track_id.parse::<u32>().map_err(|e| {
(
StatusCode::BAD_REQUEST,
@@ -1459,8 +1398,12 @@ async fn play_openhome_track(
)
})?;
let play_task =
tokio::task::spawn_blocking(move || playlist_client.play_id(parsed_id));
let control_point = Arc::clone(&state.control_point);
let rid_for_task = rid.clone();
let play_task = tokio::task::spawn_blocking(move || {
control_point.play_openhome_track_id(&rid_for_task, parsed_id)
});
time::timeout(QUEUE_COMMAND_TIMEOUT, play_task)
.await
@@ -1500,12 +1443,7 @@ async fn play_openhome_track(
track_id = parsed_id,
"Failed to start OpenHome track"
);
(
StatusCode::BAD_GATEWAY,
Json(ErrorResponse {
error: format!("Failed to play OpenHome track: {}", e),
}),
)
map_openhome_error(&rid, e, "play OpenHome track")
})?;
Ok(Json(SuccessResponse {
@@ -1587,7 +1525,11 @@ async fn play_content(
item_count = items.len(),
"Auto-binding playlist to renderer queue (without initial refresh)"
);
control_point.attach_queue_to_playlist_without_refresh(&rid, sid.clone(), object_id.clone());
control_point.attach_queue_to_playlist_without_refresh(
&rid,
sid.clone(),
object_id.clone(),
);
}
Ok::<(), anyhow::Error>(())
@@ -1724,7 +1666,10 @@ async fn add_to_queue(
)
})?
.map_err(|e| {
warn!("Failed to add content to queue for renderer {}: {}", renderer_id, e);
warn!(
"Failed to add content to queue for renderer {}: {}",
renderer_id, e
);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
@@ -1904,6 +1849,32 @@ async fn browse_container(
// HELPERS
// ============================================================================
#[cfg(feature = "pmoserver")]
fn map_openhome_error(
renderer_id: &RendererId,
err: anyhow::Error,
context: &str,
) -> (StatusCode, Json<ErrorResponse>) {
if err.downcast_ref::<OpenHomeAccessError>().is_some() {
(
StatusCode::NOT_FOUND,
Json(ErrorResponse {
error: err.to_string(),
}),
)
} else {
(
StatusCode::BAD_GATEWAY,
Json(ErrorResponse {
error: format!(
"Failed to {context} for renderer {}: {}",
renderer_id.0, err
),
}),
)
}
}
/// Helper to fetch playback items from a media server object (container or item).
///
/// This function browses the server to get the entries and converts them to PlaybackItem.
@@ -2017,42 +1988,6 @@ fn capability_summary(caps: &RendererCapabilities) -> RendererCapabilitiesSummar
}
}
#[cfg(feature = "pmoserver")]
fn openhome_clients_for_renderer(
control_point: &ControlPoint,
renderer_id: &RendererId,
) -> Result<(OhPlaylistClient, Option<OhInfoClient>), (StatusCode, Json<ErrorResponse>)> {
let registry = control_point.registry();
let reg = registry.read().unwrap();
let Some(client) = reg.oh_playlist_client_for_renderer(renderer_id) else {
return Err((
StatusCode::NOT_FOUND,
Json(ErrorResponse {
error: format!(
"Renderer {} has no OpenHome playlist service",
renderer_id.0
),
}),
));
};
let info_client = reg.oh_info_client_for_renderer(renderer_id);
Ok((client, info_client))
}
#[cfg(feature = "pmoserver")]
fn convert_openhome_track(entry: &OhTrackEntry) -> OpenHomePlaylistTrack {
let metadata = parse_track_metadata_from_didl(&entry.metadata_xml);
OpenHomePlaylistTrack {
id: entry.id,
uri: entry.uri.clone(),
title: metadata.as_ref().and_then(|m| m.title.clone()),
artist: metadata.as_ref().and_then(|m| m.artist.clone()),
album: metadata.as_ref().and_then(|m| m.album.clone()),
album_art_uri: metadata
.and_then(|m| m.album_art_uri),
}
}
#[cfg(feature = "pmoserver")]
fn state_to_string(state: crate::PlaybackState) -> String {
use crate::PlaybackState;
@@ -2147,14 +2082,8 @@ pub fn create_api_router(state: ControlPointState, control_point: Arc<ControlPoi
post(play_openhome_track),
)
// Queue content
.route(
"/renderers/{renderer_id}/queue/play",
post(play_content),
)
.route(
"/renderers/{renderer_id}/queue/add",
post(add_to_queue),
)
.route("/renderers/{renderer_id}/queue/play", post(play_content))
.route("/renderers/{renderer_id}/queue/add", post(add_to_queue))
// Servers
.route("/servers", get(list_servers))
.route(

View File

@@ -5,9 +5,6 @@ use crate::avtransport_client::AvTransportClient;
use crate::connection_manager_client::ConnectionManagerClient;
use crate::media_server::{MediaServerInfo, ServerId};
use crate::model::{RendererId, RendererInfo};
use crate::openhome_client::{
OhInfoClient, OhPlaylistClient, OhRadioClient, OhTimeClient, OhVolumeClient,
};
use crate::rendering_control_client::RenderingControlClient;
use tracing::debug;
@@ -206,54 +203,4 @@ impl DeviceRegistry {
}
}
}
pub fn oh_playlist_client_for_renderer(&self, id: &RendererId) -> Option<OhPlaylistClient> {
let info = self.renderers.get(id)?;
let service_type = info.oh_playlist_service_type.as_ref()?;
let control_url = info.oh_playlist_control_url.as_ref()?;
Some(OhPlaylistClient::new(
control_url.clone(),
service_type.clone(),
))
}
pub fn oh_info_client_for_renderer(&self, id: &RendererId) -> Option<OhInfoClient> {
let info = self.renderers.get(id)?;
let service_type = info.oh_info_service_type.as_ref()?;
let control_url = info.oh_info_control_url.as_ref()?;
Some(OhInfoClient::new(
control_url.clone(),
service_type.clone(),
))
}
pub fn oh_time_client_for_renderer(&self, id: &RendererId) -> Option<OhTimeClient> {
let info = self.renderers.get(id)?;
let service_type = info.oh_time_service_type.as_ref()?;
let control_url = info.oh_time_control_url.as_ref()?;
Some(OhTimeClient::new(
control_url.clone(),
service_type.clone(),
))
}
pub fn oh_volume_client_for_renderer(&self, id: &RendererId) -> Option<OhVolumeClient> {
let info = self.renderers.get(id)?;
let service_type = info.oh_volume_service_type.as_ref()?;
let control_url = info.oh_volume_control_url.as_ref()?;
Some(OhVolumeClient::new(
control_url.clone(),
service_type.clone(),
))
}
pub fn oh_radio_client_for_renderer(&self, id: &RendererId) -> Option<OhRadioClient> {
let info = self.renderers.get(id)?;
let service_type = info.oh_radio_service_type.as_ref()?;
let control_url = info.oh_radio_control_url.as_ref()?;
Some(OhRadioClient::new(
control_url.clone(),
service_type.clone(),
))
}
}