debugagen openhome
This commit is contained in:
@@ -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}>"),
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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}")
|
||||
}
|
||||
|
||||
@@ -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, ¬ify.body);
|
||||
let properties =
|
||||
parse_openhome_propertyset(&entry.renderer.id, &entry.service, ¬ify.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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
29
pmocontrol/src/openhome_playlist.rs
Normal file
29
pmocontrol/src/openhome_playlist.rs
Normal 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>,
|
||||
}
|
||||
@@ -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(),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user